fix(data): enforce completed portfolio sessions

This commit is contained in:
2026-06-01 15:04:20 +03:00
parent f493836b77
commit da0a306340
7 changed files with 336 additions and 75 deletions
@@ -45,6 +45,7 @@
- `src/GmRelay.Shared/Features/Sessions/ListSessions/DeleteSessionHandler.cs` - `src/GmRelay.Shared/Features/Sessions/ListSessions/DeleteSessionHandler.cs`
- `src/GmRelay.DiscordBot/Features/Sessions/DiscordDeleteSessionHandler.cs` - `src/GmRelay.DiscordBot/Features/Sessions/DiscordDeleteSessionHandler.cs`
- `src/GmRelay.AppHost/Program.cs`
- `tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj` - `tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj`
- `tests/GmRelay.Bot.Tests/packages.lock.json` - `tests/GmRelay.Bot.Tests/packages.lock.json`
- `src/GmRelay.Web/Program.cs` - `src/GmRelay.Web/Program.cs`
@@ -76,6 +77,7 @@
- Create: `src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql` - Create: `src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql`
- Modify: `src/GmRelay.Shared/Features/Sessions/ListSessions/DeleteSessionHandler.cs` - Modify: `src/GmRelay.Shared/Features/Sessions/ListSessions/DeleteSessionHandler.cs`
- Modify: `src/GmRelay.DiscordBot/Features/Sessions/DiscordDeleteSessionHandler.cs` - Modify: `src/GmRelay.DiscordBot/Features/Sessions/DiscordDeleteSessionHandler.cs`
- Modify: `src/GmRelay.AppHost/Program.cs`
- Modify: `tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj` - Modify: `tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj`
- Modify: `tests/GmRelay.Bot.Tests/packages.lock.json` - Modify: `tests/GmRelay.Bot.Tests/packages.lock.json`
- Modify: `compose.yaml` - Modify: `compose.yaml`
@@ -112,10 +114,13 @@ public async Task MigrationV029_ShouldCreatePortfolioTablesAndPublicationGuards(
Assert.Contains("PERFORM pg_advisory_xact_lock(20260530, 108);", normalizedMigration, StringComparison.Ordinal); Assert.Contains("PERFORM pg_advisory_xact_lock(20260530, 108);", normalizedMigration, StringComparison.Ordinal);
Assert.Contains("current_setting('transaction_isolation') <> 'read committed'", normalizedMigration, StringComparison.Ordinal); Assert.Contains("current_setting('transaction_isolation') <> 'read committed'", normalizedMigration, StringComparison.Ordinal);
Assert.Contains("USING ERRCODE = '0A000';", normalizedMigration, StringComparison.Ordinal); Assert.Contains("USING ERRCODE = '0A000';", normalizedMigration, StringComparison.Ordinal);
Assert.Contains("s.scheduled_at >= now()", normalizedMigration, StringComparison.Ordinal);
Assert.Contains("USING ERRCODE = '23514';", normalizedMigration, StringComparison.Ordinal); Assert.Contains("USING ERRCODE = '23514';", normalizedMigration, StringComparison.Ordinal);
Assert.Contains("CREATE CONSTRAINT TRIGGER trg_portfolio_games_validate_required_links AFTER INSERT OR UPDATE OF is_public ON portfolio_games DEFERRABLE INITIALLY DEFERRED", normalizedMigration, StringComparison.Ordinal); Assert.Contains("CREATE CONSTRAINT TRIGGER trg_portfolio_games_validate_required_links AFTER INSERT OR UPDATE OF is_public ON portfolio_games DEFERRABLE INITIALLY DEFERRED", normalizedMigration, StringComparison.Ordinal);
Assert.Contains("CREATE CONSTRAINT TRIGGER trg_portfolio_game_sessions_validate_required_links AFTER DELETE OR UPDATE OF portfolio_game_id ON portfolio_game_sessions DEFERRABLE INITIALLY DEFERRED", normalizedMigration, StringComparison.Ordinal); Assert.Contains("CREATE CONSTRAINT TRIGGER trg_portfolio_game_sessions_validate_required_links AFTER INSERT OR DELETE OR UPDATE OF portfolio_game_id, session_id ON portfolio_game_sessions DEFERRABLE INITIALLY DEFERRED", normalizedMigration, StringComparison.Ordinal);
Assert.Contains("CREATE CONSTRAINT TRIGGER trg_portfolio_game_masters_validate_required_links AFTER DELETE OR UPDATE OF portfolio_game_id ON portfolio_game_masters DEFERRABLE INITIALLY DEFERRED", normalizedMigration, StringComparison.Ordinal); Assert.Contains("CREATE CONSTRAINT TRIGGER trg_portfolio_game_masters_validate_required_links AFTER DELETE OR UPDATE OF portfolio_game_id ON portfolio_game_masters DEFERRABLE INITIALLY DEFERRED", normalizedMigration, StringComparison.Ordinal);
Assert.Contains("CREATE FUNCTION unpublish_public_portfolio_games_for_future_session() RETURNS TRIGGER LANGUAGE plpgsql", normalizedMigration, StringComparison.Ordinal);
Assert.Contains("CREATE CONSTRAINT TRIGGER trg_sessions_unpublish_public_portfolio_games_for_future_reschedule AFTER UPDATE OF scheduled_at ON sessions DEFERRABLE INITIALLY DEFERRED", normalizedMigration, StringComparison.Ordinal);
Assert.DoesNotContain("FOR UPDATE", normalizedMigration, StringComparison.Ordinal); Assert.DoesNotContain("FOR UPDATE", normalizedMigration, StringComparison.Ordinal);
} }
``` ```
@@ -170,7 +175,7 @@ public async Task DiscordDeleteSessionHandler_ShouldUnpublishOnlyCardsFromTheInt
} }
``` ```
Add `PortfolioSchemaGateSourceTests.cs` and assert that both the `discord` and `web` Compose services depend on a healthy `bot`. The Telegram bot runs `DbMigrator` synchronously before exposing a healthy endpoint, so this dependency is the migration-first schema gate. Add `PortfolioSchemaGateSourceTests.cs` and assert that both the `discord` and `web` Compose services depend on a healthy `bot`. Assert the same schema gate in Aspire: save the `bot` project resource to a variable and make the `discord` and `web` project resources call `.WaitFor(bot)` in addition to `.WaitFor(postgres)`. The Telegram bot runs `DbMigrator` synchronously before exposing a healthy endpoint, so this dependency is the migration-first schema gate.
- [ ] **Step 2: Add the failing PostgreSQL Testcontainers integration fixture and tests** - [ ] **Step 2: Add the failing PostgreSQL Testcontainers integration fixture and tests**
@@ -202,8 +207,10 @@ public async Task DirectRequiredLinkDeletion_ShouldFailCommitForPublishedCard(st
[Fact] [Fact]
public async Task ExplicitUnpublishThenSessionDelete_ShouldCommitAndPreserveFirstPublishedAt() public async Task ExplicitUnpublishThenSessionDelete_ShouldCommitAndPreserveFirstPublishedAt()
[Fact] [Theory]
public async Task ConcurrentPublishAndLinkDelete_ShouldNotDeadlockOrCommitInvalidPublicCard() [InlineData(true)]
[InlineData(false)]
public async Task ConcurrentPublishAndLinkDelete_ShouldNotDeadlockOrCommitInvalidPublicCard(bool publishCommitsFirst)
[Theory] [Theory]
[InlineData("portfolio_game_sessions", "session_id")] [InlineData("portfolio_game_sessions", "session_id")]
@@ -215,8 +222,19 @@ public async Task ConcurrentRequiredLinkDeletes_ShouldSerializeAndRejectInvalidP
[InlineData("portfolio_game_masters", "player_id")] [InlineData("portfolio_game_masters", "player_id")]
public async Task RepeatableReadConcurrentRequiredLinkDeletes_ShouldBeRejectedWithoutInvalidPublicCard(string linkTable, string linkColumn) public async Task RepeatableReadConcurrentRequiredLinkDeletes_ShouldBeRejectedWithoutInvalidPublicCard(string linkTable, string linkColumn)
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task RepeatableReadDraftLinkDeleteRacingPublish_ShouldBeRejectedWithoutInvalidPublicCard(bool publishCommitsFirst)
[Fact] [Fact]
public async Task RepeatableReadDraftLinkDeleteRacingPublish_ShouldBeRejectedWithoutInvalidPublicCard() public async Task PublishedCardFutureReschedule_ShouldAutomaticallyUnpublishAndPreserveFirstPublishedAt()
[Fact]
public async Task PublishingDraftCardWithAnyFutureLinkedSession_ShouldFailCommit()
[Fact]
public async Task ConcurrentPublishAndFutureReschedule_ShouldNotDeadlockOrCommitInvalidPublicCard()
[Theory] [Theory]
[InlineData("portfolio_game_sessions")] [InlineData("portfolio_game_sessions")]
@@ -232,7 +250,7 @@ public async Task RequiredParentCascadeDelete_ShouldFailCommitForPublishedCard(s
public async Task ParentCardAndGroupCascadeDeletes_ShouldCommit() public async Task ParentCardAndGroupCascadeDeletes_ShouldCommit()
``` ```
The direct-delete, moved-link, and direct parent-cascade theories must expect PostgreSQL `23514` at commit. The explicit-unpublish scenario must delete the session successfully while preserving the first `published_at`. The `READ COMMITTED` concurrency scenarios must bound commits with timeouts, prove there is no deadlock or write-skew, and prove that an invalid public card cannot commit. The `REPEATABLE READ` scenarios must reject triggered portfolio writes with `0A000`, including draft-link deletion racing with publication, because a stale snapshot after lock acquisition cannot safely validate the invariant. The parent-card and owning-group cascade scenarios must commit successfully. The direct-delete, moved-link, invalid publication, and direct parent-cascade scenarios must expect PostgreSQL `23514` at commit. Every selected linked session must be completed with `scheduled_at < now()`: one future link among multiple selected sessions rejects publication. A future reschedule must atomically unpublish a linked public card while preserving its first `published_at`. The `READ COMMITTED` concurrency scenarios must launch bounded commit tasks together, cover both publish/delete lock orders, and prove there is no deadlock, write-skew, or invalid public commit. The publish/reschedule race must finish with the future session committed and the card private. The `REPEATABLE READ` scenarios must reject triggered portfolio writes with `0A000`, including both draft-link deletion versus publication commit orders, because a stale snapshot after lock acquisition cannot safely validate the invariant. The parent-card and owning-group cascade scenarios must commit successfully.
- [ ] **Step 3: Run the Task 1 tests to verify RED** - [ ] **Step 3: Run the Task 1 tests to verify RED**
@@ -242,7 +260,7 @@ Run:
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~PortfolioMigrationTests|FullyQualifiedName~PortfolioMigrationPostgresTests|FullyQualifiedName~PortfolioSessionDeletionSourceTests|FullyQualifiedName~PortfolioSchemaGateSourceTests" dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~PortfolioMigrationTests|FullyQualifiedName~PortfolioMigrationPostgresTests|FullyQualifiedName~PortfolioSessionDeletionSourceTests|FullyQualifiedName~PortfolioSchemaGateSourceTests"
``` ```
Expected: FAIL because `V029__add_completed_game_portfolios_and_reviews.sql` does not exist and the session-deletion handlers do not explicitly unpublish linked portfolio cards before deleting sessions. Expected during the Task 1 quality-review fix: FAIL because V029 does not yet validate completed linked sessions or automatically unpublish on future reschedule, and the Aspire AppHost does not yet gate `discord` and `web` on `bot`.
- [ ] **Step 4: Add migration V029** - [ ] **Step 4: Add migration V029**
@@ -304,13 +322,18 @@ LANGUAGE plpgsql
AS $$ AS $$
DECLARE DECLARE
target_portfolio_game_id UUID; target_portfolio_game_id UUID;
target_portfolio_game_ids UUID[];
BEGIN BEGIN
PERFORM pg_advisory_xact_lock(20260530, 108); PERFORM pg_advisory_xact_lock(20260530, 108);
IF TG_TABLE_NAME = 'portfolio_games' THEN IF TG_TABLE_NAME = 'portfolio_games' THEN
target_portfolio_game_id := NEW.id; target_portfolio_game_ids := ARRAY[NEW.id];
ELSIF TG_OP = 'DELETE' THEN
target_portfolio_game_ids := ARRAY[OLD.portfolio_game_id];
ELSIF TG_OP = 'INSERT' THEN
target_portfolio_game_ids := ARRAY[NEW.portfolio_game_id];
ELSE ELSE
target_portfolio_game_id := OLD.portfolio_game_id; target_portfolio_game_ids := ARRAY[OLD.portfolio_game_id, NEW.portfolio_game_id];
END IF; END IF;
IF current_setting('transaction_isolation') <> 'read committed' THEN IF current_setting('transaction_isolation') <> 'read committed' THEN
@@ -319,24 +342,33 @@ BEGIN
USING ERRCODE = '0A000'; USING ERRCODE = '0A000';
END IF; END IF;
IF EXISTS ( SELECT pg.id
SELECT 1 INTO target_portfolio_game_id
FROM portfolio_games pg FROM portfolio_games pg
WHERE pg.id = target_portfolio_game_id WHERE pg.id = ANY(target_portfolio_game_ids)
AND pg.is_public = true AND pg.is_public = true
AND ( AND (
NOT EXISTS ( NOT EXISTS (
SELECT 1 SELECT 1
FROM portfolio_game_sessions pgs FROM portfolio_game_sessions pgs
WHERE pgs.portfolio_game_id = target_portfolio_game_id WHERE pgs.portfolio_game_id = pg.id
)
OR NOT EXISTS (
SELECT 1
FROM portfolio_game_masters pgm
WHERE pgm.portfolio_game_id = target_portfolio_game_id
)
) )
) THEN OR EXISTS (
SELECT 1
FROM portfolio_game_sessions pgs
JOIN sessions s ON s.id = pgs.session_id
WHERE pgs.portfolio_game_id = pg.id
AND s.scheduled_at >= now()
)
OR NOT EXISTS (
SELECT 1
FROM portfolio_game_masters pgm
WHERE pgm.portfolio_game_id = pg.id
)
)
LIMIT 1;
IF target_portfolio_game_id IS NOT NULL THEN
RAISE EXCEPTION RAISE EXCEPTION
'published portfolio game % must have at least one linked session and at least one linked master', 'published portfolio game % must have at least one linked session and at least one linked master',
target_portfolio_game_id target_portfolio_game_id
@@ -347,6 +379,32 @@ BEGIN
END; END;
$$; $$;
CREATE FUNCTION unpublish_public_portfolio_games_for_future_session()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
IF OLD.scheduled_at IS DISTINCT FROM NEW.scheduled_at
AND NEW.scheduled_at >= now() THEN
UPDATE portfolio_games pg
SET is_public = false,
updated_at = now()
FROM portfolio_game_sessions pgs
WHERE pgs.portfolio_game_id = pg.id
AND pgs.session_id = NEW.id
AND pg.is_public = true;
END IF;
RETURN NULL;
END;
$$;
CREATE CONSTRAINT TRIGGER trg_sessions_unpublish_public_portfolio_games_for_future_reschedule
AFTER UPDATE OF scheduled_at ON sessions
DEFERRABLE INITIALLY DEFERRED
FOR EACH ROW
EXECUTE FUNCTION unpublish_public_portfolio_games_for_future_session();
CREATE CONSTRAINT TRIGGER trg_portfolio_games_validate_required_links CREATE CONSTRAINT TRIGGER trg_portfolio_games_validate_required_links
AFTER INSERT OR UPDATE OF is_public ON portfolio_games AFTER INSERT OR UPDATE OF is_public ON portfolio_games
DEFERRABLE INITIALLY DEFERRED DEFERRABLE INITIALLY DEFERRED
@@ -354,7 +412,7 @@ FOR EACH ROW
EXECUTE FUNCTION validate_public_portfolio_game_required_links(); EXECUTE FUNCTION validate_public_portfolio_game_required_links();
CREATE CONSTRAINT TRIGGER trg_portfolio_game_sessions_validate_required_links CREATE CONSTRAINT TRIGGER trg_portfolio_game_sessions_validate_required_links
AFTER DELETE OR UPDATE OF portfolio_game_id ON portfolio_game_sessions AFTER INSERT OR DELETE OR UPDATE OF portfolio_game_id, session_id ON portfolio_game_sessions
DEFERRABLE INITIALLY DEFERRED DEFERRABLE INITIALLY DEFERRED
FOR EACH ROW FOR EACH ROW
EXECUTE FUNCTION validate_public_portfolio_game_required_links(); EXECUTE FUNCTION validate_public_portfolio_game_required_links();
@@ -397,7 +455,7 @@ CREATE INDEX ix_portfolio_game_reviews_pending
WHERE moderation_status = 'Pending'; WHERE moderation_status = 'Pending';
``` ```
The deferred constraint triggers retain the link-table `ON DELETE CASCADE` behavior. At transaction commit they acquire the same transaction-level advisory lock and reject a surviving published card when either required link set is empty. The intentionally global lock is appropriate for low-volume portfolio publication writes: under the application default `READ COMMITTED` isolation level it serializes validation, prevents write-skew across distinct child links, and gives multi-card transactions one lock order. PostgreSQL retains stale snapshots under `REPEATABLE READ` and `SERIALIZABLE`, so the guard rejects every triggered portfolio write at those isolation levels with `0A000`. Child delete triggers do not lock or update the parent card. At `READ COMMITTED`, draft edits, explicit unpublishing, and card or club cascade deletion remain valid. Normal session-deletion handlers explicitly unpublish linked cards before deleting sessions. The deferred constraint triggers retain the link-table `ON DELETE CASCADE` behavior. At transaction commit validators acquire the same transaction-level advisory lock and reject a surviving published card when either required link set is empty or any linked session has `scheduled_at >= now()`. The intentionally global lock is appropriate for low-volume portfolio publication writes: under the application default `READ COMMITTED` isolation level it serializes validation, prevents write-skew across distinct child links, and gives multi-card transactions one lock order. PostgreSQL retains stale snapshots under `REPEATABLE READ` and `SERIALIZABLE`, so the guard rejects every triggered portfolio write at those isolation levels with `0A000`. The deferred future-reschedule trigger atomically unpublishes linked public cards while preserving `published_at`; it updates the card before validator lock acquisition so a racing publication cannot create an inverted lock order. At `READ COMMITTED`, draft edits, explicit unpublishing, future reschedules, and card or club cascade deletion remain valid. Normal session-deletion handlers explicitly unpublish linked cards before deleting sessions.
- [ ] **Step 5: Explicitly unpublish linked cards in both session-deletion handlers** - [ ] **Step 5: Explicitly unpublish linked cards in both session-deletion handlers**
@@ -433,7 +491,7 @@ Both handlers deliberately unpublish before session deletion. This keeps normal
Also add `AND p.platform = 'Discord'` to the Discord manager lookup before casting manager IDs, so cross-platform identities cannot affect authorization. Also add `AND p.platform = 'Discord'` to the Discord manager lookup before casting manager IDs, so cross-platform identities cannot affect authorization.
In `compose.yaml`, make both `discord` and `web` depend on a healthy `bot` in addition to the healthy database. `DbMigrator` runs synchronously before the bot health endpoint starts, so this gates consumers on V029 without duplicating the migrator. In `compose.yaml`, make both `discord` and `web` depend on a healthy `bot` in addition to the healthy database. Mirror the same schema gate in `src/GmRelay.AppHost/Program.cs`: save the `bot` project resource and add `.WaitFor(bot)` to both `discord` and `web` after `.WaitFor(postgres)`. `DbMigrator` runs synchronously before the bot health endpoint starts, so this gates consumers on V029 without duplicating the migrator.
- [ ] **Step 6: Run the Task 1 tests to verify GREEN** - [ ] **Step 6: Run the Task 1 tests to verify GREEN**
@@ -443,12 +501,12 @@ Run:
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~PortfolioMigrationTests|FullyQualifiedName~PortfolioMigrationPostgresTests|FullyQualifiedName~PortfolioSessionDeletionSourceTests|FullyQualifiedName~PortfolioSchemaGateSourceTests" dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~PortfolioMigrationTests|FullyQualifiedName~PortfolioMigrationPostgresTests|FullyQualifiedName~PortfolioSessionDeletionSourceTests|FullyQualifiedName~PortfolioSchemaGateSourceTests"
``` ```
Expected: PASS, including PostgreSQL 17 migration application, rejected direct required-link deletes, rejected moved links and session/player cascades, successful explicit unpublish plus session delete with preserved `published_at`, bounded `READ COMMITTED` concurrent publish/delete and distinct-link deletion without deadlock, write-skew, or invalid public commit, rejected `REPEATABLE READ` triggered writes including draft-delete versus publish races, successful parent-card and owning-group cascades, Discord identity scoping, and Compose schema gating. Expected: PASS, including PostgreSQL 17 migration application, rejected direct required-link deletes, rejected moved links and session/player cascades, rejected publication with any future linked session, automatic unpublish with preserved `published_at` after future reschedule, bounded `READ COMMITTED` publish/delete in both commit orders, publish/reschedule races, and distinct-link deletion without deadlock, write-skew, or invalid public commit, rejected `REPEATABLE READ` triggered writes including both draft-delete versus publish commit orders, successful parent-card and owning-group cascades, Discord identity scoping, and Compose/Aspire schema gating.
- [ ] **Step 7: Commit** - [ ] **Step 7: Commit**
```powershell ```powershell
git add src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql src/GmRelay.Shared/Features/Sessions/ListSessions/DeleteSessionHandler.cs src/GmRelay.DiscordBot/Features/Sessions/DiscordDeleteSessionHandler.cs compose.yaml tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj tests/GmRelay.Bot.Tests/packages.lock.json tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs tests/GmRelay.Bot.Tests/Web/PortfolioSessionDeletionSourceTests.cs tests/GmRelay.Bot.Tests/Web/PortfolioSchemaGateSourceTests.cs tests/GmRelay.Bot.Tests/Web/PortfolioMigrationPostgresFixture.cs tests/GmRelay.Bot.Tests/Web/PortfolioMigrationPostgresTests.cs git add src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql src/GmRelay.Shared/Features/Sessions/ListSessions/DeleteSessionHandler.cs src/GmRelay.DiscordBot/Features/Sessions/DiscordDeleteSessionHandler.cs src/GmRelay.AppHost/Program.cs compose.yaml tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj tests/GmRelay.Bot.Tests/packages.lock.json tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs tests/GmRelay.Bot.Tests/Web/PortfolioSessionDeletionSourceTests.cs tests/GmRelay.Bot.Tests/Web/PortfolioSchemaGateSourceTests.cs tests/GmRelay.Bot.Tests/Web/PortfolioMigrationPostgresFixture.cs tests/GmRelay.Bot.Tests/Web/PortfolioMigrationPostgresTests.cs
git commit -m "fix(data): harden portfolio publication concurrency" git commit -m "fix(data): harden portfolio publication concurrency"
``` ```
@@ -938,7 +996,7 @@ Rules:
- Update runs in one transaction, locks the portfolio row, updates scalar fields, unpublishes the card before replacing required child links, replaces child links, rejects cross-club or future sessions, and accepts only managers from the same club. - Update runs in one transaction, locks the portfolio row, updates scalar fields, unpublishes the card before replacing required child links, replaces child links, rejects cross-club or future sessions, and accepts only managers from the same club.
- Cover replacement returns the prior storage key after the database update. - Cover replacement returns the prior storage key after the database update.
- Delete returns the cover key after deleting the row. - Delete returns the cover key after deleting the row.
- Publishing locks the row and verifies slug, description, cover key, one or more linked past sessions, and one or more masters before setting `is_public = true` and `published_at = COALESCE(published_at, now())`. The deferred database guard is a backstop for direct SQL and concurrent changes. - Publishing locks the row and verifies slug, description, cover key, one or more linked sessions, every linked session has `scheduled_at < now()`, and one or more masters before setting `is_public = true` and `published_at = COALESCE(published_at, now())`. The deferred database guard is a backstop for direct SQL and concurrent changes.
- Unpublishing only sets `is_public = false`. - Unpublishing only sets `is_public = false`.
- Moderation accepts `Approved`, `Rejected`, or `Hidden`, stores moderator ID and timestamp, and scopes the review to the managed adventure. - Moderation accepts `Approved`, `Rejected`, or `Hidden`, stores moderator ID and timestamp, and scopes the review to the managed adventure.
@@ -77,9 +77,11 @@ CHECK (NOT is_public OR (
- Index on `(group_id, completed_at DESC)`. - Index on `(group_id, completed_at DESC)`.
- Partial public index on `(completed_at DESC)` where `is_public = true`. - Partial public index on `(completed_at DESC)` where `is_public = true`.
Application validation additionally requires at least one linked completed session and at least one linked GM before publishing because those requirements span child tables. Publishing locks the parent card, validates both required link sets, then sets `is_public = true` and `published_at = COALESCE(published_at, now())` so `published_at` remains the first-publication timestamp. Link replacement locks the parent card and unpublishes it before replacing required links. Application validation additionally requires at least one linked session, every linked session to be completed with `scheduled_at < now()`, and at least one linked GM before publishing because those requirements span child tables. Publishing locks the parent card, validates both required link sets, then sets `is_public = true` and `published_at = COALESCE(published_at, now())` so `published_at` remains the first-publication timestamp. Link replacement locks the parent card and unpublishes it before replacing required links.
Deferred database constraint triggers validate the same invariant at transaction commit after a card transitions to public or a required session/master link is deleted or moved. They raise a check-violation error if a published card would commit without both required link sets. Before checking state, each trigger acquires the same transaction-level PostgreSQL advisory lock, `pg_advisory_xact_lock(20260530, 108)`. Portfolio publication writes are low volume, so this intentionally global lock serializes invariant validation with one lock order, prevents write-skew under the application default `READ COMMITTED` isolation level, and avoids multi-card deadlocks. PostgreSQL keeps a stale snapshot after waiting under `REPEATABLE READ` or `SERIALIZABLE`, so the guard rejects every triggered portfolio write at those levels; callers must use `READ COMMITTED` for portfolio mutations. The deferred guard is a database backstop and deliberately does not lock or update a parent row from a child delete trigger. Normal session-deletion handlers explicitly unpublish linked cards in the same transaction before deleting the session. The link foreign keys retain `ON DELETE CASCADE`; when the card itself or its owning club is deleted at `READ COMMITTED`, deferred validation sees no surviving published card and remains harmless. Deferred database constraint triggers validate the same invariant at transaction commit after a card transitions to public, a session link is inserted, deleted, moved, or repointed, or a required master link is deleted or moved. They raise a check-violation error if a published card would commit without both required link sets or with any linked session where `scheduled_at >= now()`. Before checking state, each validator acquires the same transaction-level PostgreSQL advisory lock, `pg_advisory_xact_lock(20260530, 108)`. Portfolio publication writes are low volume, so this intentionally global lock serializes invariant validation with one lock order, prevents write-skew under the application default `READ COMMITTED` isolation level, and avoids multi-card deadlocks. PostgreSQL keeps a stale snapshot after waiting under `REPEATABLE READ` or `SERIALIZABLE`, so the guard rejects every triggered portfolio write at those levels; callers must use `READ COMMITTED` for portfolio mutations.
A deferred `sessions.scheduled_at` trigger atomically unpublishes linked public cards when a completed session is rescheduled into the future, preserving the first `published_at`. Running that update at commit lets it re-check the current card row after a racing publication without taking the advisory lock before the row update, avoiding an inverted lock order. Normal session-deletion handlers still explicitly unpublish linked cards in the same transaction before deleting the session. The link foreign keys retain `ON DELETE CASCADE`; when the card itself or its owning club is deleted at `READ COMMITTED`, deferred validation sees no surviving published card and remains harmless.
### `portfolio_game_sessions` ### `portfolio_game_sessions`
@@ -90,7 +92,7 @@ Deferred database constraint triggers validate the same invariant at transaction
Primary key: `(portfolio_game_id, session_id)`. Primary key: `(portfolio_game_id, session_id)`.
The application accepts only sessions from the adventure's club with `scheduled_at < now()` and rejects cross-club links. A session belongs to at most one portfolio adventure. The application accepts only sessions from the adventure's club with `scheduled_at < now()` and rejects cross-club links. The deferred database guard enforces the completed-session condition for every linked session before a public card can commit. A session belongs to at most one portfolio adventure.
### `portfolio_game_masters` ### `portfolio_game_masters`
@@ -334,7 +336,7 @@ Development configuration uses a local directory under the application content r
The Web Docker image creates `/app/portfolio-covers` and assigns it to `$APP_UID` before switching to the non-root runtime user. The Web Docker image creates `/app/portfolio-covers` and assigns it to `$APP_UID` before switching to the non-root runtime user.
The Telegram bot runs `DbMigrator` synchronously before its health endpoint becomes healthy. Docker Compose therefore starts Discord and Web only after the bot is healthy, using it as the schema-migration gate without duplicating migration ownership. The Telegram bot runs `DbMigrator` synchronously before its health endpoint becomes healthy. Docker Compose therefore starts Discord and Web only after the bot is healthy, using it as the schema-migration gate without duplicating migration ownership. The Aspire AppHost mirrors this ordering: its `discord` and `web` project resources wait for both PostgreSQL and the `bot` resource.
--- ---
@@ -353,8 +355,8 @@ Follow TDD for production changes.
### Schema And Contracts ### Schema And Contracts
- Migration source-contract tests assert the four new tables, format constraint, publication guard, case-insensitive slug uniqueness, group and GM-profile indexes, card-oriented pending-review index, and deferred constraint-trigger backstop. - Migration source-contract tests assert the four new tables, format constraint, publication guard, case-insensitive slug uniqueness, group and GM-profile indexes, card-oriented pending-review index, completed-session validator, deferred future-reschedule unpublish trigger, and AppHost schema gate.
- PostgreSQL integration tests apply migrations V001 through V029 to `postgres:17-alpine` and cover direct invalid link removal, moved links, direct session/player cascades, explicit unpublish before session deletion, concurrent publish/delete ordering, concurrent removal of distinct required links without write-skew or deadlock under `READ COMMITTED`, rejection of equivalent `REPEATABLE READ` writes including draft-delete versus publish races, and parent/card cascade deletion. - PostgreSQL integration tests apply migrations V001 through V029 to `postgres:17-alpine` and cover direct invalid link removal, moved links, direct session/player cascades, explicit unpublish before session deletion, rejection of publication when any linked session is future, automatic unpublish with preserved `published_at` after future reschedule, publish/reschedule races, both bounded publish/delete commit orders, concurrent removal of distinct required links without write-skew or deadlock under `READ COMMITTED`, rejection of equivalent `REPEATABLE READ` writes including both draft-delete versus publish commit orders, and parent/card cascade deletion.
- Public DTO reflection/source tests assert that private identifiers and physical storage paths are absent. - Public DTO reflection/source tests assert that private identifiers and physical storage paths are absent.
- Existing showcase tests continue to assert the future-session catalog boundary. - Existing showcase tests continue to assert the future-session catalog boundary.
@@ -410,6 +412,7 @@ Synchronize:
- [ ] A club owner or co-GM can publish a completed adventure with uploaded cover and description. - [ ] A club owner or co-GM can publish a completed adventure with uploaded cover and description.
- [ ] A portfolio adventure can group one or more completed sessions from the same club. - [ ] A portfolio adventure can group one or more completed sessions from the same club.
- [ ] A public portfolio adventure automatically becomes private if any linked completed session is rescheduled into the future, preserving its first-publication timestamp.
- [ ] Selected public GM profiles show portfolio cards independently of club-page visibility. - [ ] Selected public GM profiles show portfolio cards independently of club-page visibility.
- [ ] A public club page shows portfolio cards when enabled. - [ ] A public club page shows portfolio cards when enabled.
- [ ] `/portfolio/{slug}` shows cover, description, metadata, selected GMs, and approved player reviews. - [ ] `/portfolio/{slug}` shows cover, description, metadata, selected GMs, and approved player reviews.
+5 -3
View File
@@ -4,16 +4,18 @@ var postgres = builder.AddPostgres("postgres")
.WithPgAdmin() .WithPgAdmin()
.AddDatabase("gmrelay-db"); .AddDatabase("gmrelay-db");
builder.AddProject<Projects.GmRelay_Bot>("bot") var bot = builder.AddProject<Projects.GmRelay_Bot>("bot")
.WithReference(postgres) .WithReference(postgres)
.WaitFor(postgres); .WaitFor(postgres);
builder.AddProject<Projects.GmRelay_DiscordBot>("discord") builder.AddProject<Projects.GmRelay_DiscordBot>("discord")
.WithReference(postgres) .WithReference(postgres)
.WaitFor(postgres); .WaitFor(postgres)
.WaitFor(bot);
builder.AddProject<Projects.GmRelay_Web>("web") builder.AddProject<Projects.GmRelay_Web>("web")
.WithReference(postgres) .WithReference(postgres)
.WaitFor(postgres); .WaitFor(postgres)
.WaitFor(bot);
builder.Build().Run(); builder.Build().Run();
@@ -58,13 +58,18 @@ LANGUAGE plpgsql
AS $$ AS $$
DECLARE DECLARE
target_portfolio_game_id UUID; target_portfolio_game_id UUID;
target_portfolio_game_ids UUID[];
BEGIN BEGIN
PERFORM pg_advisory_xact_lock(20260530, 108); PERFORM pg_advisory_xact_lock(20260530, 108);
IF TG_TABLE_NAME = 'portfolio_games' THEN IF TG_TABLE_NAME = 'portfolio_games' THEN
target_portfolio_game_id := NEW.id; target_portfolio_game_ids := ARRAY[NEW.id];
ELSIF TG_OP = 'DELETE' THEN
target_portfolio_game_ids := ARRAY[OLD.portfolio_game_id];
ELSIF TG_OP = 'INSERT' THEN
target_portfolio_game_ids := ARRAY[NEW.portfolio_game_id];
ELSE ELSE
target_portfolio_game_id := OLD.portfolio_game_id; target_portfolio_game_ids := ARRAY[OLD.portfolio_game_id, NEW.portfolio_game_id];
END IF; END IF;
IF current_setting('transaction_isolation') <> 'read committed' THEN IF current_setting('transaction_isolation') <> 'read committed' THEN
@@ -73,24 +78,33 @@ BEGIN
USING ERRCODE = '0A000'; USING ERRCODE = '0A000';
END IF; END IF;
IF EXISTS ( SELECT pg.id
SELECT 1 INTO target_portfolio_game_id
FROM portfolio_games pg FROM portfolio_games pg
WHERE pg.id = target_portfolio_game_id WHERE pg.id = ANY(target_portfolio_game_ids)
AND pg.is_public = true AND pg.is_public = true
AND ( AND (
NOT EXISTS ( NOT EXISTS (
SELECT 1 SELECT 1
FROM portfolio_game_sessions pgs FROM portfolio_game_sessions pgs
WHERE pgs.portfolio_game_id = target_portfolio_game_id WHERE pgs.portfolio_game_id = pg.id
)
OR NOT EXISTS (
SELECT 1
FROM portfolio_game_masters pgm
WHERE pgm.portfolio_game_id = target_portfolio_game_id
)
) )
) THEN OR EXISTS (
SELECT 1
FROM portfolio_game_sessions pgs
JOIN sessions s ON s.id = pgs.session_id
WHERE pgs.portfolio_game_id = pg.id
AND s.scheduled_at >= now()
)
OR NOT EXISTS (
SELECT 1
FROM portfolio_game_masters pgm
WHERE pgm.portfolio_game_id = pg.id
)
)
LIMIT 1;
IF target_portfolio_game_id IS NOT NULL THEN
RAISE EXCEPTION RAISE EXCEPTION
'published portfolio game % must have at least one linked session and at least one linked master', 'published portfolio game % must have at least one linked session and at least one linked master',
target_portfolio_game_id target_portfolio_game_id
@@ -101,6 +115,32 @@ BEGIN
END; END;
$$; $$;
CREATE FUNCTION unpublish_public_portfolio_games_for_future_session()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
IF OLD.scheduled_at IS DISTINCT FROM NEW.scheduled_at
AND NEW.scheduled_at >= now() THEN
UPDATE portfolio_games pg
SET is_public = false,
updated_at = now()
FROM portfolio_game_sessions pgs
WHERE pgs.portfolio_game_id = pg.id
AND pgs.session_id = NEW.id
AND pg.is_public = true;
END IF;
RETURN NULL;
END;
$$;
CREATE CONSTRAINT TRIGGER trg_sessions_unpublish_public_portfolio_games_for_future_reschedule
AFTER UPDATE OF scheduled_at ON sessions
DEFERRABLE INITIALLY DEFERRED
FOR EACH ROW
EXECUTE FUNCTION unpublish_public_portfolio_games_for_future_session();
CREATE CONSTRAINT TRIGGER trg_portfolio_games_validate_required_links CREATE CONSTRAINT TRIGGER trg_portfolio_games_validate_required_links
AFTER INSERT OR UPDATE OF is_public ON portfolio_games AFTER INSERT OR UPDATE OF is_public ON portfolio_games
DEFERRABLE INITIALLY DEFERRED DEFERRABLE INITIALLY DEFERRED
@@ -108,7 +148,7 @@ FOR EACH ROW
EXECUTE FUNCTION validate_public_portfolio_game_required_links(); EXECUTE FUNCTION validate_public_portfolio_game_required_links();
CREATE CONSTRAINT TRIGGER trg_portfolio_game_sessions_validate_required_links CREATE CONSTRAINT TRIGGER trg_portfolio_game_sessions_validate_required_links
AFTER DELETE OR UPDATE OF portfolio_game_id ON portfolio_game_sessions AFTER INSERT OR DELETE OR UPDATE OF portfolio_game_id, session_id ON portfolio_game_sessions
DEFERRABLE INITIALLY DEFERRED DEFERRABLE INITIALLY DEFERRED
FOR EACH ROW FOR EACH ROW
EXECUTE FUNCTION validate_public_portfolio_game_required_links(); EXECUTE FUNCTION validate_public_portfolio_game_required_links();
@@ -93,8 +93,10 @@ public sealed class PortfolioMigrationPostgresTests(PortfolioMigrationPostgresFi
parameters: new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId))); parameters: new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId)));
} }
[Fact] [Theory]
public async Task ConcurrentPublishAndLinkDelete_ShouldNotDeadlockOrCommitInvalidPublicCard() [InlineData(true)]
[InlineData(false)]
public async Task ConcurrentPublishAndLinkDelete_ShouldNotDeadlockOrCommitInvalidPublicCard(bool publishCommitsFirst)
{ {
var database = await fixture.CreateMigratedDatabaseAsync(); var database = await fixture.CreateMigratedDatabaseAsync();
await using var publishConnection = await database.OpenConnectionAsync(); await using var publishConnection = await database.OpenConnectionAsync();
@@ -121,17 +123,27 @@ public sealed class PortfolioMigrationPostgresTests(PortfolioMigrationPostgresFi
"DELETE FROM portfolio_game_sessions WHERE portfolio_game_id = @portfolioGameId", "DELETE FROM portfolio_game_sessions WHERE portfolio_game_id = @portfolioGameId",
deleteTransaction, deleteTransaction,
new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId))); new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId)));
await deleteTransaction.CommitAsync().WaitAsync(CommandTimeout);
var exception = await Assert.ThrowsAsync<PostgresException>( await AcquirePortfolioValidationLockAsync(
() => publishTransaction.CommitAsync().WaitAsync(CommandTimeout)); publishCommitsFirst ? publishConnection : deleteConnection,
Assert.Equal(PostgresErrorCodes.CheckViolation, exception.SqlState); publishCommitsFirst ? publishTransaction : deleteTransaction);
var commitStates = await Task.WhenAll(
CommitAndCaptureSqlStateAsync(publishTransaction),
CommitAndCaptureSqlStateAsync(deleteTransaction)).WaitAsync(CommandTimeout);
Assert.Equal(publishCommitsFirst ? null : PostgresErrorCodes.CheckViolation, commitStates[0]);
Assert.Equal(publishCommitsFirst ? PostgresErrorCodes.CheckViolation : null, commitStates[1]);
await using var verificationConnection = await database.OpenConnectionAsync(); await using var verificationConnection = await database.OpenConnectionAsync();
Assert.False(await ExecuteScalarAsync<bool>( Assert.Equal(publishCommitsFirst, await ExecuteScalarAsync<bool>(
verificationConnection, verificationConnection,
"SELECT is_public FROM portfolio_games WHERE id = @portfolioGameId", "SELECT is_public FROM portfolio_games WHERE id = @portfolioGameId",
parameters: new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId))); parameters: new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId)));
Assert.Equal(publishCommitsFirst ? 1L : 0L, await ExecuteScalarAsync<long>(
verificationConnection,
"SELECT COUNT(*) FROM portfolio_game_sessions WHERE portfolio_game_id = @portfolioGameId",
parameters: new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId)));
} }
[Theory] [Theory]
@@ -257,8 +269,11 @@ public sealed class PortfolioMigrationPostgresTests(PortfolioMigrationPostgresFi
Assert.Equal(PostgresErrorCodes.FeatureNotSupported, exception.SqlState); Assert.Equal(PostgresErrorCodes.FeatureNotSupported, exception.SqlState);
} }
[Fact] [Theory]
public async Task RepeatableReadDraftLinkDeleteRacingPublish_ShouldBeRejectedWithoutInvalidPublicCard() [InlineData(true)]
[InlineData(false)]
public async Task RepeatableReadDraftLinkDeleteRacingPublish_ShouldBeRejectedWithoutInvalidPublicCard(
bool publishCommitsFirst)
{ {
var database = await fixture.CreateMigratedDatabaseAsync(); var database = await fixture.CreateMigratedDatabaseAsync();
await using var seedConnection = await database.OpenConnectionAsync(); await using var seedConnection = await database.OpenConnectionAsync();
@@ -285,11 +300,16 @@ public sealed class PortfolioMigrationPostgresTests(PortfolioMigrationPostgresFi
publishTransaction, publishTransaction,
new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId)); new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId));
var deleteSqlState = await CommitAndCaptureSqlStateAsync(deleteTransaction); await AcquirePortfolioValidationLockAsync(
var publishSqlState = await CommitAndCaptureSqlStateAsync(publishTransaction); publishCommitsFirst ? publishConnection : deleteConnection,
publishCommitsFirst ? publishTransaction : deleteTransaction);
Assert.Equal(PostgresErrorCodes.FeatureNotSupported, deleteSqlState); var commitStates = await Task.WhenAll(
Assert.Null(publishSqlState); CommitAndCaptureSqlStateAsync(deleteTransaction),
CommitAndCaptureSqlStateAsync(publishTransaction)).WaitAsync(CommandTimeout);
Assert.Equal(PostgresErrorCodes.FeatureNotSupported, commitStates[0]);
Assert.Null(commitStates[1]);
await using var verificationConnection = await database.OpenConnectionAsync(); await using var verificationConnection = await database.OpenConnectionAsync();
Assert.True(await ExecuteScalarAsync<bool>( Assert.True(await ExecuteScalarAsync<bool>(
@@ -302,6 +322,105 @@ public sealed class PortfolioMigrationPostgresTests(PortfolioMigrationPostgresFi
parameters: new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId))); parameters: new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId)));
} }
[Fact]
public async Task PublishedCardFutureReschedule_ShouldAutomaticallyUnpublishAndPreserveFirstPublishedAt()
{
var database = await fixture.CreateMigratedDatabaseAsync();
await using var connection = await database.OpenConnectionAsync();
var seed = await SeedCardAsync(connection, isPublic: true);
await ExecuteNonQueryAsync(
connection,
"UPDATE sessions SET scheduled_at = now() + interval '1 day' WHERE id = @sessionId",
parameters: new NpgsqlParameter("sessionId", seed.SessionIds[0]));
Assert.False(await ExecuteScalarAsync<bool>(
connection,
"SELECT is_public FROM portfolio_games WHERE id = @portfolioGameId",
parameters: new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId)));
Assert.Equal(seed.PublishedAt, await ExecuteScalarAsync<DateTime>(
connection,
"SELECT published_at FROM portfolio_games WHERE id = @portfolioGameId",
parameters: new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId)));
}
[Fact]
public async Task PublishingDraftCardWithAnyFutureLinkedSession_ShouldFailCommit()
{
var database = await fixture.CreateMigratedDatabaseAsync();
await using var connection = await database.OpenConnectionAsync();
var seed = await SeedCardAsync(connection, isPublic: false, sessionCount: 2);
await ExecuteNonQueryAsync(
connection,
"UPDATE sessions SET scheduled_at = now() + interval '1 day' WHERE id = @sessionId",
parameters: new NpgsqlParameter("sessionId", seed.SessionIds[1]));
await using var transaction = await connection.BeginTransactionAsync();
await ExecuteNonQueryAsync(
connection,
"""
UPDATE portfolio_games
SET is_public = true,
published_at = COALESCE(published_at, now()),
updated_at = now()
WHERE id = @portfolioGameId
""",
transaction,
new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId));
var exception = await Assert.ThrowsAsync<PostgresException>(
() => transaction.CommitAsync().WaitAsync(CommandTimeout));
Assert.Equal(PostgresErrorCodes.CheckViolation, exception.SqlState);
}
[Fact]
public async Task ConcurrentPublishAndFutureReschedule_ShouldNotDeadlockOrCommitInvalidPublicCard()
{
var database = await fixture.CreateMigratedDatabaseAsync();
await using var publishConnection = await database.OpenConnectionAsync();
await using var rescheduleConnection = await database.OpenConnectionAsync();
var seed = await SeedCardAsync(publishConnection, isPublic: false);
await using var publishTransaction = await publishConnection.BeginTransactionAsync();
await using var rescheduleTransaction = await rescheduleConnection.BeginTransactionAsync();
await ExecuteNonQueryAsync(
publishConnection,
"""
UPDATE portfolio_games
SET is_public = true,
published_at = COALESCE(published_at, now()),
updated_at = now()
WHERE id = @portfolioGameId
""",
publishTransaction,
new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId));
await ExecuteNonQueryAsync(
rescheduleConnection,
"UPDATE sessions SET scheduled_at = now() + interval '1 day' WHERE id = @sessionId",
rescheduleTransaction,
new NpgsqlParameter("sessionId", seed.SessionIds[0]));
var commitStates = await Task.WhenAll(
CommitAndCaptureSqlStateAsync(publishTransaction),
CommitAndCaptureSqlStateAsync(rescheduleTransaction)).WaitAsync(CommandTimeout);
Assert.True(
commitStates[0] is null or PostgresErrorCodes.CheckViolation,
$"Unexpected publish SQLSTATE: {commitStates[0] ?? "<none>"}.");
Assert.Null(commitStates[1]);
await using var verificationConnection = await database.OpenConnectionAsync();
Assert.False(await ExecuteScalarAsync<bool>(
verificationConnection,
"SELECT is_public FROM portfolio_games WHERE id = @portfolioGameId",
parameters: new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId)));
Assert.True(await ExecuteScalarAsync<bool>(
verificationConnection,
"SELECT scheduled_at >= now() FROM sessions WHERE id = @sessionId",
parameters: new NpgsqlParameter("sessionId", seed.SessionIds[0])));
}
[Theory] [Theory]
[InlineData("portfolio_game_sessions")] [InlineData("portfolio_game_sessions")]
[InlineData("portfolio_game_masters")] [InlineData("portfolio_game_masters")]
@@ -489,6 +608,16 @@ public sealed class PortfolioMigrationPostgresTests(PortfolioMigrationPostgresFi
} }
} }
private static Task<int> AcquirePortfolioValidationLockAsync(
NpgsqlConnection connection,
NpgsqlTransaction transaction)
{
return ExecuteNonQueryAsync(
connection,
"SELECT pg_advisory_xact_lock(20260530, 108)",
transaction);
}
private static async Task<int> ExecuteNonQueryAsync( private static async Task<int> ExecuteNonQueryAsync(
NpgsqlConnection connection, NpgsqlConnection connection,
string sql, string sql,
@@ -34,10 +34,15 @@ public sealed class PortfolioMigrationTests
Assert.Contains("PERFORM pg_advisory_xact_lock(20260530, 108);", normalizedMigration, StringComparison.Ordinal); Assert.Contains("PERFORM pg_advisory_xact_lock(20260530, 108);", normalizedMigration, StringComparison.Ordinal);
Assert.Contains("current_setting('transaction_isolation') <> 'read committed'", normalizedMigration, StringComparison.Ordinal); Assert.Contains("current_setting('transaction_isolation') <> 'read committed'", normalizedMigration, StringComparison.Ordinal);
Assert.Contains("USING ERRCODE = '0A000';", normalizedMigration, StringComparison.Ordinal); Assert.Contains("USING ERRCODE = '0A000';", normalizedMigration, StringComparison.Ordinal);
Assert.Contains("s.scheduled_at >= now()", normalizedMigration, StringComparison.Ordinal);
Assert.Contains("RAISE EXCEPTION 'published portfolio game % must have at least one linked session and at least one linked master', target_portfolio_game_id USING ERRCODE = '23514';", normalizedMigration, StringComparison.Ordinal); Assert.Contains("RAISE EXCEPTION 'published portfolio game % must have at least one linked session and at least one linked master', target_portfolio_game_id USING ERRCODE = '23514';", normalizedMigration, StringComparison.Ordinal);
Assert.Contains("CREATE CONSTRAINT TRIGGER trg_portfolio_games_validate_required_links AFTER INSERT OR UPDATE OF is_public ON portfolio_games DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION validate_public_portfolio_game_required_links();", normalizedMigration, StringComparison.Ordinal); Assert.Contains("CREATE CONSTRAINT TRIGGER trg_portfolio_games_validate_required_links AFTER INSERT OR UPDATE OF is_public ON portfolio_games DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION validate_public_portfolio_game_required_links();", normalizedMigration, StringComparison.Ordinal);
Assert.Contains("CREATE CONSTRAINT TRIGGER trg_portfolio_game_sessions_validate_required_links AFTER DELETE OR UPDATE OF portfolio_game_id ON portfolio_game_sessions DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION validate_public_portfolio_game_required_links();", normalizedMigration, StringComparison.Ordinal); Assert.Contains("CREATE CONSTRAINT TRIGGER trg_portfolio_game_sessions_validate_required_links AFTER INSERT OR DELETE OR UPDATE OF portfolio_game_id, session_id ON portfolio_game_sessions DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION validate_public_portfolio_game_required_links();", normalizedMigration, StringComparison.Ordinal);
Assert.Contains("CREATE CONSTRAINT TRIGGER trg_portfolio_game_masters_validate_required_links AFTER DELETE OR UPDATE OF portfolio_game_id ON portfolio_game_masters DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION validate_public_portfolio_game_required_links();", normalizedMigration, StringComparison.Ordinal); Assert.Contains("CREATE CONSTRAINT TRIGGER trg_portfolio_game_masters_validate_required_links AFTER DELETE OR UPDATE OF portfolio_game_id ON portfolio_game_masters DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION validate_public_portfolio_game_required_links();", normalizedMigration, StringComparison.Ordinal);
Assert.Contains("CREATE FUNCTION unpublish_public_portfolio_games_for_future_session() RETURNS TRIGGER LANGUAGE plpgsql", normalizedMigration, StringComparison.Ordinal);
Assert.Contains("OLD.scheduled_at IS DISTINCT FROM NEW.scheduled_at AND NEW.scheduled_at >= now()", normalizedMigration, StringComparison.Ordinal);
Assert.Contains("UPDATE portfolio_games pg SET is_public = false, updated_at = now() FROM portfolio_game_sessions pgs WHERE pgs.portfolio_game_id = pg.id AND pgs.session_id = NEW.id AND pg.is_public = true;", normalizedMigration, StringComparison.Ordinal);
Assert.Contains("CREATE CONSTRAINT TRIGGER trg_sessions_unpublish_public_portfolio_games_for_future_reschedule AFTER UPDATE OF scheduled_at ON sessions DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION unpublish_public_portfolio_games_for_future_session();", normalizedMigration, StringComparison.Ordinal);
Assert.DoesNotContain("unpublish_portfolio_game_without_required_links", normalizedMigration, StringComparison.Ordinal); Assert.DoesNotContain("unpublish_portfolio_game_without_required_links", normalizedMigration, StringComparison.Ordinal);
Assert.DoesNotContain("FOR UPDATE", normalizedMigration, StringComparison.Ordinal); Assert.DoesNotContain("FOR UPDATE", normalizedMigration, StringComparison.Ordinal);
Assert.DoesNotContain("published_at = NULL", normalizedMigration, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain("published_at = NULL", normalizedMigration, StringComparison.OrdinalIgnoreCase);
@@ -11,6 +11,25 @@ public sealed class PortfolioSchemaGateSourceTests
AssertServiceDependsOnHealthyBot(compose, "web"); AssertServiceDependsOnHealthyBot(compose, "web");
} }
[Fact]
public async Task Aspire_ShouldStartDiscordAndWebOnlyAfterBotMigrationsAreHealthy()
{
var appHost = NormalizeSource(await ReadRepositoryFileAsync("src/GmRelay.AppHost/Program.cs"));
Assert.Contains(
"var bot = builder.AddProject<Projects.GmRelay_Bot>(\"bot\") .WithReference(postgres) .WaitFor(postgres);",
appHost,
StringComparison.Ordinal);
Assert.Contains(
"builder.AddProject<Projects.GmRelay_DiscordBot>(\"discord\") .WithReference(postgres) .WaitFor(postgres) .WaitFor(bot);",
appHost,
StringComparison.Ordinal);
Assert.Contains(
"builder.AddProject<Projects.GmRelay_Web>(\"web\") .WithReference(postgres) .WaitFor(postgres) .WaitFor(bot);",
appHost,
StringComparison.Ordinal);
}
private static void AssertServiceDependsOnHealthyBot(string compose, string serviceName) private static void AssertServiceDependsOnHealthyBot(string compose, string serviceName)
{ {
var serviceBlock = GetServiceBlock(compose, serviceName); var serviceBlock = GetServiceBlock(compose, serviceName);
@@ -40,6 +59,11 @@ public sealed class PortfolioSchemaGateSourceTests
return string.Join('\n', lines[start..(end < 0 ? lines.Length : end)]); return string.Join('\n', lines[start..(end < 0 ? lines.Length : end)]);
} }
private static string NormalizeSource(string source)
{
return string.Join(' ', source.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries));
}
private static async Task<string> ReadRepositoryFileAsync(string relativePath) private static async Task<string> ReadRepositoryFileAsync(string relativePath)
{ {
var directory = new DirectoryInfo(AppContext.BaseDirectory); var directory = new DirectoryInfo(AppContext.BaseDirectory);