Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .env.ci
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug

# Database (PostgreSQL for CI)
# Database (the workflow overrides DB_CONNECTION/DB_USERNAME/DB_PORT per engine)
DB_CONNECTION=pgsql
DB_HOST=127.0.0.1
DB_PORT=5432
Expand Down
4 changes: 2 additions & 2 deletions .github/actions/setup-laravel/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ inputs:
description: 'Also set up Node.js and install npm dependencies.'
default: 'false'
db-port:
description: 'Host port mapped to the Postgres service.'
description: 'Host port mapped to the database service.'
required: true
redis-port:
description: 'Host port mapped to the Redis service.'
Expand All @@ -19,7 +19,7 @@ runs:
uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, gd, redis
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, pdo_mysql, bcmath, intl, gd, redis
coverage: none

- name: Setup Node.js
Expand Down
52 changes: 43 additions & 9 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,23 +17,47 @@ env:
SESSION_DRIVER: array

jobs:
backend:
tests:
name: Tests (${{ matrix.name }})
runs-on: ubuntu-latest

strategy:
fail-fast: false
matrix:
include:
- name: PostgreSQL
connection: pgsql
image: postgres:16
port: '5432'
username: postgres
health: '--health-cmd="pg_isready"'
- name: MySQL
connection: mysql
image: mysql:8.4
port: '3306'
username: root
health: '--health-cmd="mysqladmin ping -h 127.0.0.1 -u root -ppassword"'

env:
DB_CONNECTION: ${{ matrix.connection }}
DB_USERNAME: ${{ matrix.username }}

services:
postgres:
image: postgres:16
database:
image: ${{ matrix.image }}
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
POSTGRES_DB: trypost_test
MYSQL_ROOT_PASSWORD: password
MYSQL_DATABASE: trypost_test
ports:
- 5432/tcp
- ${{ matrix.port }}/tcp
options: >-
--health-cmd="pg_isready"
--health-interval=10s
${{ matrix.health }}
--health-interval=5s
--health-timeout=5s
--health-retries=3
--health-retries=20

redis:
image: redis:7
Expand All @@ -52,15 +76,25 @@ jobs:
- name: Setup test environment
uses: ./.github/actions/setup-laravel
with:
db-port: ${{ job.services.postgres.ports['5432'] }}
db-port: ${{ job.services.database.ports[matrix.port] }}
redis-port: ${{ job.services.redis.ports['6379'] }}

- name: Run backend tests
env:
DB_PORT: ${{ job.services.postgres.ports['5432'] }}
DB_PORT: ${{ job.services.database.ports[matrix.port] }}
REDIS_PORT: ${{ job.services.redis.ports['6379'] }}
run: php artisan test --compact --parallel

backend:
name: backend
runs-on: ubuntu-latest
needs: [tests]
if: always()

steps:
- name: Fail unless every engine passed
run: '[ "${{ needs.tests.result }}" = "success" ]'

e2e:
runs-on: ubuntu-latest

Expand Down
14 changes: 14 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,20 @@ One connected identity per social network per workspace is the Cloud default. Th

Self-hosted compose / `.env.example` set this `true`. When the env is unset, the config falls back to `SELF_HOSTED` so existing self-hosted installs keep multiple accounts. Do **not** use `selfHosted` for the occupancy check (observer, Telegram connect, `NetworkConnectGrid`).

## Database engines (PostgreSQL + MySQL)

TryPost runs on **both PostgreSQL and MySQL**. Cloud runs PostgreSQL; a self-hosted install may pick either. Every query, migration, and test must work on both — the suite is expected to be green on each.

- **What the app supports is the intersection of the two engines, never the superset of one.** When they differ, take the narrower behaviour — a feature that only holds on PostgreSQL is a feature TryPost does not have.
- Never use an engine-specific operator or function. Search uses `whereLike()` (Laravel handles the case-insensitive form per driver), never `ilike` or a raw `LOWER(...)` comparison.
- Traps that only surface on MySQL:
- **JSON object key order is not preserved.** MySQL reorders object keys on storage (by length, then lexicographically); PostgreSQL keeps insertion order. Assert JSON read back from the database with `toEqual` (recursive, order-independent), never `toBe`/`assertSame`. Array *element* order is preserved on both.
- **`$table->timestamp()` tops out at 2038-01-19.** PostgreSQL has no such limit, so 2038-01-19 is the app's ceiling: nothing written to a `timestamp()` column may go past it — scheduled posts, expiry sentinels and test fixtures alike. `2037-12-31` reads as "far future" and works on both. Do not widen a column to escape the limit without a deliberate decision; it changes what self-hosted MySQL installs can store.
- **Raw query-builder reads carry no Eloquent cast**, so the driver's native shape leaks through: `DB::table(...)->value('some_bool')` is `true` on PostgreSQL and `1` on MySQL. Read through the model, or use `assertDatabaseHas`.
- **Identifier quoting differs** — PostgreSQL emits `"post_platforms"`, MySQL emits backticks. Never match logged SQL (`DB::listen`) against a quoted identifier.
- **MySQL refuses to drop the only index backing a foreign key** (SQLSTATE `1553`). A migration `down()` that drops a unique whose leftmost prefix is an FK column must create a standalone index for that column first.
- **DDL implicitly commits**, which defeats `RefreshDatabase`'s rollback: schema changes made inside a test leak into the tests that follow. Keep them idempotent.

## Social Platform API Documentation (official sources)

**Always consult the official docs below before implementing or changing OAuth, publishing, deletion, rate-limit, or any other platform-specific behavior — never guess endpoints, scopes, rate limits, or capabilities from memory.** APIs shift over time; a behavior confirmed in a past session may no longer hold. One entry per social network we integrate with:
Expand Down
14 changes: 14 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,20 @@ Self-hosted compose / `.env.example` set this `true`. When the env is unset, the
- Validation rules always live in a dedicated `Illuminate\Foundation\Http\FormRequest` subclass under `app/Http/Requests/App/<Group>/`. Controller actions must type-hint the FormRequest as the parameter — NEVER call `$request->validate([...])` inline in the controller.
- Naming: `<Verb><Resource>Request.php` (e.g. `StorePostRequest`, `UpdatePostRequest`, `LinkPreviewRequest`).

## Database engines (PostgreSQL + MySQL)

TryPost runs on **both PostgreSQL and MySQL**. Cloud runs PostgreSQL; a self-hosted install may pick either. Every query, migration, and test must work on both — the suite is expected to be green on each.

- **What the app supports is the intersection of the two engines, never the superset of one.** When they differ, take the narrower behaviour — a feature that only holds on PostgreSQL is a feature TryPost does not have.
- Never use an engine-specific operator or function. Search uses `whereLike()` (Laravel handles the case-insensitive form per driver), never `ilike` or a raw `LOWER(...)` comparison.
- Traps that only surface on MySQL:
- **JSON object key order is not preserved.** MySQL reorders object keys on storage (by length, then lexicographically); PostgreSQL keeps insertion order. Assert JSON read back from the database with `toEqual` (recursive, order-independent), never `toBe`/`assertSame`. Array *element* order is preserved on both.
- **`$table->timestamp()` tops out at 2038-01-19.** PostgreSQL has no such limit, so 2038-01-19 is the app's ceiling: nothing written to a `timestamp()` column may go past it — scheduled posts, expiry sentinels and test fixtures alike. `2037-12-31` reads as "far future" and works on both. Do not widen a column to escape the limit without a deliberate decision; it changes what self-hosted MySQL installs can store.
- **Raw query-builder reads carry no Eloquent cast**, so the driver's native shape leaks through: `DB::table(...)->value('some_bool')` is `true` on PostgreSQL and `1` on MySQL. Read through the model, or use `assertDatabaseHas`.
- **Identifier quoting differs** — PostgreSQL emits `"post_platforms"`, MySQL emits backticks. Never match logged SQL (`DB::listen`) against a quoted identifier.
- **MySQL refuses to drop the only index backing a foreign key** (SQLSTATE `1553`). A migration `down()` that drops a unique whose leftmost prefix is an FK column must create a standalone index for that column first.
- **DDL implicitly commits**, which defeats `RefreshDatabase`'s rollback: schema changes made inside a test leak into the tests that follow. Keep them idempotent.

## Per-Platform Post Meta (`PostPlatform.meta`)

- All `platforms.*.meta` validation (the parent array rule AND every per-platform sub-key: `aspect_ratio`, TikTok `privacy_level`/flags, Pinterest `board_id`, Discord `channel_id`/`mentions`/`embeds`, etc.) lives in ONE place: `App\Support\PostPlatformMetaRules`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,21 @@ public function up(): void
* Drops the index only. The data merge in `up()` is one-way: the losing
* rows are gone, so rolling back leaves the collapsed identities collapsed.
* Every merge is logged at warning level so it can be reconstructed.
*
* `social_accounts.workspace_id` carries a foreign key, and the composite
* unique is the only index covering it - as its leftmost prefix. MySQL
* refuses to drop the sole index backing a foreign key, so the constraint
* needs another one to rest on first. PostgreSQL has no such requirement
* and simply carries the extra index.
*/
public function down(): void
{
if (! Schema::hasIndex('social_accounts', 'social_accounts_workspace_id_index')) {
Schema::table('social_accounts', function (Blueprint $table) {
$table->index('workspace_id');
});
}

Schema::table('social_accounts', function (Blueprint $table) {
$table->dropUnique('social_accounts_workspace_platform_identity_unique');
});
Expand Down
2 changes: 2 additions & 0 deletions docker/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ RUN apk add --no-cache \
tzdata \
postgresql-client \
postgresql-dev \
mysql-client \
libpng-dev \
libjpeg-turbo-dev \
freetype-dev \
Expand All @@ -40,6 +41,7 @@ RUN apk add --no-cache \
&& docker-php-ext-install -j"$(nproc)" \
pdo_pgsql \
pgsql \
pdo_mysql \
gd \
zip \
opcache \
Expand Down
4 changes: 2 additions & 2 deletions tests/Feature/Api/PostMediaApiTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -662,7 +662,7 @@
expect($this->post->fresh()->media)->toHaveCount(1)
->and(data_get($this->post->fresh()->media, '0.id'))->toBe($asset->id)
->and(data_get($this->post->fresh()->media, '0.size'))->toBe(12345)
->and(data_get($this->post->fresh()->media, '0.meta'))->toBe([
->and(data_get($this->post->fresh()->media, '0.meta'))->toEqual([
'width' => 1920,
'height' => 1080,
'duration' => 12.5,
Expand All @@ -688,7 +688,7 @@
])
->assertOk();

expect(data_get($this->post->fresh()->media, '0.meta'))->toBe([
expect(data_get($this->post->fresh()->media, '0.meta'))->toEqual([
'width' => 800,
'height' => 600,
'alt_text' => 'From library',
Expand Down
2 changes: 1 addition & 1 deletion tests/Feature/Automation/DetailTabsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@

$automation->refresh();
expect($automation->name)->toBe('Renamed flow');
expect($automation->nodes)->toBe($originalNodes);
expect($automation->nodes)->toEqual($originalNodes);
});

it('renders the invocations tab with a scroll-paginated list', function () {
Expand Down
21 changes: 15 additions & 6 deletions tests/Feature/Jobs/VerifyUpcomingPostConnectionsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,15 @@
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;

/**
* Whether a logged query is a plain select against $table, asking the connection
* how it quotes identifiers rather than assuming a driver.
*/
function verifyUpcomingSelectsFrom(string $sql, string $table): bool
{
return str_starts_with($sql, 'select * from '.DB::getQueryGrammar()->wrapTable($table));
}

test('marks the account expired and queues a notification when verify throws TokenExpiredException', function () {
Mail::fake();

Expand Down Expand Up @@ -943,10 +952,10 @@
// str_starts_with (not str_contains) deliberately excludes the
// recentlyWarnedAbout()/recentlyDisconnected() exists() subqueries —
// Laravel compiles ->exists() as "select exists(select * from
// \"post_platforms\" where ...)", which contains but doesn't start with
// this prefix, so those never trip the listener.
// post_platforms where ...)", which contains but doesn't start with this
// prefix, so those never trip the listener.
$listener = function ($query) use ($doomedPost) {
if (str_starts_with($query->sql, 'select * from "post_platforms"')) {
if (verifyUpcomingSelectsFrom($query->sql, 'post_platforms')) {
Post::where('id', $doomedPost->id)->delete();
}
};
Expand Down Expand Up @@ -1090,7 +1099,7 @@
// but before the per-account loop reaches it, reproducing the race the
// fresh() re-check at the top of each account's iteration exists to close.
$listener = function ($query) use ($account) {
if (str_starts_with($query->sql, 'select * from "post_platforms"')) {
if (verifyUpcomingSelectsFrom($query->sql, 'post_platforms')) {
$account->update(['is_active' => false]);
}
};
Expand Down Expand Up @@ -1132,7 +1141,7 @@
// reaching it shouldn't get warned about a connection its owner
// deliberately paused, even though it's already broken.
$listener = function ($query) use ($account) {
if (str_starts_with($query->sql, 'select * from "post_platforms"')) {
if (verifyUpcomingSelectsFrom($query->sql, 'post_platforms')) {
$account->update(['is_active' => false]);
}
};
Expand Down Expand Up @@ -1179,7 +1188,7 @@
// needs the account to still resolve as non-null going into the loop,
// then disappear before the guard's own re-fetch runs.
$listener = function ($query) use ($account) {
if (str_starts_with($query->sql, 'select * from "social_accounts"')) {
if (verifyUpcomingSelectsFrom($query->sql, 'social_accounts')) {
$account->delete();
}
};
Expand Down
4 changes: 2 additions & 2 deletions tests/Feature/Mcp/AssetToolTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@

expect($this->post->fresh()->media)->toHaveCount(1)
->and(data_get($this->post->fresh()->media, '0.size'))->toBe(12345)
->and(data_get($this->post->fresh()->media, '0.meta'))->toBe([
->and(data_get($this->post->fresh()->media, '0.meta'))->toEqual([
'width' => 1920,
'height' => 1080,
'duration' => 12.5,
Expand All @@ -225,7 +225,7 @@
])
->assertOk();

expect(data_get($this->post->fresh()->media, '0.meta'))->toBe([
expect(data_get($this->post->fresh()->media, '0.meta'))->toEqual([
'width' => 800,
'height' => 600,
'alt_text' => 'From library',
Expand Down
2 changes: 1 addition & 1 deletion tests/Feature/Mcp/PostPublishToolTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,7 @@
$response = TryPostServer::actingAs($this->user)
->tool(PublishPostTool::class, [
'post_id' => $post->id,
'scheduled_at' => '2099-12-31T15:30:00Z',
'scheduled_at' => '2037-12-31T15:30:00Z',
]);

$response->assertOk();
Expand Down
4 changes: 2 additions & 2 deletions tests/Feature/Mcp/PostToolTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -143,14 +143,14 @@
$response = TryPostServer::actingAs($this->user)
->tool(CreatePostTool::class, [
'content' => 'My new post',
'scheduled_at' => '2099-12-31T15:30:00Z',
'scheduled_at' => '2037-12-31T15:30:00Z',
]);

$response->assertOk()
->assertStructuredContent(function (AssertableJson $json) {
$json->where('content', 'My new post')
->where('status', 'draft')
->where('scheduled_at', '2099-12-31 15:30:00')
->where('scheduled_at', '2037-12-31 15:30:00')
->etc();
});

Expand Down
2 changes: 1 addition & 1 deletion tests/Feature/McpSettingsControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@
->assertSessionHas('flash.success');

expect($token->fresh()->revoked)->toBeTrue()
->and(DB::table('oauth_refresh_tokens')->where('id', $refreshTokenId)->value('revoked'))->toBeTrue();
->and((bool) DB::table('oauth_refresh_tokens')->where('id', $refreshTokenId)->value('revoked'))->toBeTrue();
});

it('lists a client when its access token expired but its refresh token is live', function (): void {
Expand Down
4 changes: 2 additions & 2 deletions tests/Feature/Services/Social/InstagramPublisherTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -922,7 +922,7 @@ function fakeJpegBytes(int $width = 1200, int $height = 800): string
'url' => null,
]);

expect($this->postPlatform->fresh()->error_context['instagram_workflow'] ?? null)->toBe([
expect($this->postPlatform->fresh()->error_context['instagram_workflow'] ?? null)->toEqual([
'stage' => 'final_container',
'container_id' => 'container-123',
'media_id' => 'media-123456789',
Expand Down Expand Up @@ -1272,7 +1272,7 @@ function fakeJpegBytes(int $width = 1200, int $height = 800): string
'url' => null,
]);

expect($this->postPlatform->fresh()->error_context['instagram_workflow'] ?? null)->toBe([
expect($this->postPlatform->fresh()->error_context['instagram_workflow'] ?? null)->toEqual([
'stage' => 'final_container',
'container_id' => 'container-123',
'media_id' => 'media-123456789',
Expand Down
Loading