From 69000d4cb968176d635f2256f413f0bb6a1451bb Mon Sep 17 00:00:00 2001 From: hided62 Date: Mon, 24 Aug 2026 09:18:52 +0000 Subject: [PATCH] =?UTF-8?q?test:=20=EC=A1=B0=EA=B1=B4=EB=B6=80=20=ED=86=B5?= =?UTF-8?q?=ED=95=A9=20fixture=EC=9D=98=20=EC=8B=9C=EA=B3=84=EC=99=80=20?= =?UTF-8?q?=EC=86=8C=EC=9C=A0=EA=B6=8C=EC=9D=84=20=EA=B2=A9=EB=A6=AC?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../test/auctionWorker.integration.test.ts | 5 ++++ .../test/selectPool.integration.test.ts | 9 +++++-- ...eralActionsPersistence.integration.test.ts | 17 +++++++----- ...thlyInvaderPersistence.integration.test.ts | 4 +-- ...PoolReleasePersistence.integration.test.ts | 26 +++++++++---------- ...ofileMigrationTimezone.integration.test.ts | 23 ++++++++++++++++ tools/conditional-integration-registry.tsv | 2 ++ ...NationBettingLifecycle.integration.test.ts | 2 +- tools/run-conditional-integration.sh | 16 +++++++++++- 9 files changed, 78 insertions(+), 26 deletions(-) create mode 100644 app/gateway-api/test/profileMigrationTimezone.integration.test.ts diff --git a/app/game-api/test/auctionWorker.integration.test.ts b/app/game-api/test/auctionWorker.integration.test.ts index 17e83935..a93c97a1 100644 --- a/app/game-api/test/auctionWorker.integration.test.ts +++ b/app/game-api/test/auctionWorker.integration.test.ts @@ -34,6 +34,7 @@ liveDescribe('auction worker durable recovery', () => { const createdRequestPrefixes: string[] = []; const createdWorldIds: number[] = []; const createdGeneralIds: number[] = []; + const lifecycleUserIds = ['auction-durable-host', 'auction-durable-bidder']; beforeAll(async () => { await connector.connect(); @@ -56,6 +57,8 @@ liveDescribe('auction worker durable recovery', () => { await connector.prisma.worldState.deleteMany({ where: { id: { in: createdWorldIds } } }); } if (createdGeneralIds.length > 0) { + await connector.prisma.message.deleteMany({ where: { mailbox: { in: createdGeneralIds } } }); + await connector.prisma.webPushOutbox.deleteMany({ where: { userIds: { hasSome: lifecycleUserIds } } }); await connector.prisma.logEntry.deleteMany({ where: { generalId: { in: createdGeneralIds } } }); await connector.prisma.general.deleteMany({ where: { id: { in: createdGeneralIds } } }); } @@ -366,6 +369,8 @@ liveDescribe('auction worker durable recovery', () => { }; const lifecycleGeneralIds = [8_032, 8_033]; + await connector.prisma.message.deleteMany({ where: { mailbox: { in: lifecycleGeneralIds } } }); + await connector.prisma.webPushOutbox.deleteMany({ where: { userIds: { hasSome: lifecycleUserIds } } }); await connector.prisma.logEntry.deleteMany({ where: { generalId: { in: lifecycleGeneralIds } } }); await connector.prisma.general.deleteMany({ where: { id: { in: lifecycleGeneralIds } } }); await connector.prisma.worldState.deleteMany({ where: { id: worldId } }); diff --git a/app/game-api/test/selectPool.integration.test.ts b/app/game-api/test/selectPool.integration.test.ts index 11df30cd..3ae96910 100644 --- a/app/game-api/test/selectPool.integration.test.ts +++ b/app/game-api/test/selectPool.integration.test.ts @@ -472,7 +472,9 @@ integration('scenario 903 select pool through the durable turn daemon', () => { }, 30_000); it('keeps a stable ENGINE event for retries and rejects reservation bypasses', async () => { - const logicalNowMs = runtime!.world.getGameNow(new Date()).getTime(); + const logicalNow = runtime!.world.getGameNow(new Date()); + const logicalNowMs = logicalNow.getTime(); + const logicalNowTick = runtime!.world.dateToGameTick(logicalNow); const reservation = await appRouter .createCaller(buildContext('select-pool-other-reserve', otherAuth)) .join.getSelectionPool(); @@ -488,7 +490,10 @@ integration('scenario 903 select pool through the durable turn daemon', () => { await db.selectPoolEntry.update({ where: { uniqueName: candidate.uniqueName }, - data: { reservedUntil: new Date(logicalNowMs - 60_000) }, + data: { + reservedUntil: new Date(logicalNowMs - 60_000), + reservedUntilTick: BigInt(logicalNowTick - 1), + }, }); await expect( appRouter.createCaller(buildContext('select-pool-expired-token', otherAuth)).join.selectPoolGeneral({ diff --git a/app/game-engine/test/immediateGeneralActionsPersistence.integration.test.ts b/app/game-engine/test/immediateGeneralActionsPersistence.integration.test.ts index 1894b9f4..11233213 100644 --- a/app/game-engine/test/immediateGeneralActionsPersistence.integration.test.ts +++ b/app/game-engine/test/immediateGeneralActionsPersistence.integration.test.ts @@ -560,12 +560,6 @@ integration('immediate general action persistence', () => { generalId, text: expect.stringContaining('낙양'), }), - expect.objectContaining({ - scope: 'NATION', - category: 'HISTORY', - nationId: existingNationId + 1, - text: expect.stringContaining('통합장수'), - }), expect.objectContaining({ scope: 'SYSTEM', category: 'SUMMARY', @@ -578,6 +572,17 @@ integration('immediate general action persistence', () => { }), ]) ); + // Ref creates this action logger while the actor is still unaffiliated, + // so its queued national history is discarded when nationID is 0. + await expect( + db.logEntry.count({ + where: { + scope: 'NATION', + category: 'HISTORY', + nationId: existingNationId + 1, + }, + }) + ).resolves.toBe(0); const reloaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! }); expect(reloaded.snapshot.generals.find((entry) => entry.id === generalId)).toMatchObject({ diff --git a/app/game-engine/test/monthlyInvaderPersistence.integration.test.ts b/app/game-engine/test/monthlyInvaderPersistence.integration.test.ts index e5125874..1afbed48 100644 --- a/app/game-engine/test/monthlyInvaderPersistence.integration.test.ts +++ b/app/game-engine/test/monthlyInvaderPersistence.integration.test.ts @@ -502,7 +502,6 @@ integration('RaiseInvader database persistence', () => { diplomacyCountsPerNation: [2], diplomacyStates: ['1:24'], isunited: 1, - isUnited: 1, blockChangeScout: false, }); } @@ -633,7 +632,6 @@ integration('RaiseInvader database persistence', () => { result: 'Deleted', endingEventPresent: false, isunited: 3, - isUnited: 3, refreshLimit: 300, logs: [ '●200년 4월:【이벤트】이민족을 모두 소탕했습니다!', @@ -644,5 +642,5 @@ integration('RaiseInvader database persistence', () => { } finally { await dbHooks.close(); } - }); + }, 30_000); }); diff --git a/app/game-engine/test/selectPoolReleasePersistence.integration.test.ts b/app/game-engine/test/selectPoolReleasePersistence.integration.test.ts index 019dc151..4e902924 100644 --- a/app/game-engine/test/selectPoolReleasePersistence.integration.test.ts +++ b/app/game-engine/test/selectPoolReleasePersistence.integration.test.ts @@ -43,8 +43,8 @@ integration('select pool release during general deletion', () => { 'release-candidate', 'claim-candidate', 'conflict-candidate', - 'early-protected-candidate', - 'later-free-candidate', + 'early-protected', + 'later-free', ], }, }, @@ -203,8 +203,8 @@ integration('select pool release during general deletion', () => { 'release-candidate', 'claim-candidate', 'conflict-candidate', - 'early-protected-candidate', - 'later-free-candidate', + 'early-protected', + 'later-free', ], }, }, @@ -366,12 +366,12 @@ integration('select pool release during general deletion', () => { await db.selectPoolEntry.createMany({ data: [ { - uniqueName: 'early-protected-candidate', + uniqueName: 'early-protected', ownerUserId: 'protected-user', generalId: null, reservedUntil, info: { - uniqueName: 'early-protected-candidate', + uniqueName: 'early-protected', generalName: '보호후보', leadership: 70, strength: 80, @@ -383,12 +383,12 @@ integration('select pool release during general deletion', () => { } as GamePrisma.InputJsonValue, }, { - uniqueName: 'later-free-candidate', + uniqueName: 'later-free', ownerUserId: null, generalId: null, reservedUntil: null, info: { - uniqueName: 'later-free-candidate', + uniqueName: 'later-free', generalName: '후행후보', leadership: 70, strength: 80, @@ -406,8 +406,8 @@ integration('select pool release during general deletion', () => { schedule: { entries: [{ startMinute: 0, tickMinutes: 5 }] }, }); const entries = world.listGeneralPoolEntries()!; - const protectedCandidate = entries.find((entry) => entry.uniqueName === 'early-protected-candidate')!.candidate; - const laterCandidate = entries.find((entry) => entry.uniqueName === 'later-free-candidate')!.candidate; + const protectedCandidate = entries.find((entry) => entry.uniqueName === 'early-protected')!.candidate; + const laterCandidate = entries.find((entry) => entry.uniqueName === 'later-free')!.candidate; const template = world.getGeneralById(generalId)!; expect( world.addGeneral({ @@ -446,7 +446,7 @@ integration('select pool release during general deletion', () => { durationMs: 0, partial: false, }) - ).rejects.toThrow('select_pool 후보를 점유하지 못했습니다: early-protected-candidate'); + ).rejects.toThrow('select_pool 후보를 점유하지 못했습니다: early-protected'); } finally { await hooks.close(); } @@ -454,14 +454,14 @@ integration('select pool release during general deletion', () => { await expect(db.general.findUnique({ where: { id: protectedGeneralId } })).resolves.toBeNull(); await expect(db.general.findUnique({ where: { id: laterGeneralId } })).resolves.toBeNull(); await expect( - db.selectPoolEntry.findUniqueOrThrow({ where: { uniqueName: 'early-protected-candidate' } }) + db.selectPoolEntry.findUniqueOrThrow({ where: { uniqueName: 'early-protected' } }) ).resolves.toMatchObject({ generalId: null, ownerUserId: 'protected-user', reservedUntil, }); await expect( - db.selectPoolEntry.findUniqueOrThrow({ where: { uniqueName: 'later-free-candidate' } }) + db.selectPoolEntry.findUniqueOrThrow({ where: { uniqueName: 'later-free' } }) ).resolves.toMatchObject({ generalId: null, ownerUserId: null, reservedUntil: null }); }); diff --git a/app/gateway-api/test/profileMigrationTimezone.integration.test.ts b/app/gateway-api/test/profileMigrationTimezone.integration.test.ts new file mode 100644 index 00000000..436ba7a4 --- /dev/null +++ b/app/gateway-api/test/profileMigrationTimezone.integration.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; + +import { PnpmBuildRunner } from '../src/orchestrator/buildRunner.js'; +import { buildProfileMigrationPreflightCommand } from '../src/orchestrator/gatewayOrchestrator.js'; + +const utcDatabaseUrl = process.env.PROFILE_MIGRATION_UTC_DATABASE_URL?.trim(); + +describe('profile migration timezone preflight', () => { + it.skipIf(!utcDatabaseUrl)('rejects an actual database role whose default session timezone is UTC', async () => { + if (!utcDatabaseUrl) throw new Error('PROFILE_MIGRATION_UTC_DATABASE_URL is required'); + const path = process.env.PATH; + if (!path) throw new Error('PATH is required'); + const command = buildProfileMigrationPreflightCommand(process.cwd(), utcDatabaseUrl, { PATH: path }); + + const result = await new PnpmBuildRunner().run([command]); + + expect(result.ok).toBe(false); + expect(result.output).toContain('database session TimeZone does not match the required migration contract'); + expect(result.output).not.toContain(utcDatabaseUrl); + const password = decodeURIComponent(new URL(utcDatabaseUrl).password); + if (password) expect(result.output).not.toContain(password); + }); +}); diff --git a/tools/conditional-integration-registry.tsv b/tools/conditional-integration-registry.tsv index 8c227581..41d1bc1f 100644 --- a/tools/conditional-integration-registry.tsv +++ b/tools/conditional-integration-registry.tsv @@ -13,10 +13,12 @@ NPC_POSSESSION_DIFFERENTIAL_DATABASE_URL reference_npc_possession PROFILE_SEED_CLI_DATABASE_URL core PROFILE_SEED_DATABASE_URL core PROFILE_LOCK_SECONDARY_DATABASE_URL core +PROFILE_MIGRATION_UTC_DATABASE_URL external_fixture READ_MODEL_JOURNAL_DATABASE_URL read_model_journal RESERVED_TURN_DATABASE_URL core SECURITY_TRANSPORT_DATABASE_URL security_transport SELECT_POOL_DATABASE_URL select_pool +SCENARIO_LIFECYCLE_DATABASE_URL scenario_lifecycle TURN_DAEMON_LEASE_DATABASE_URL core TURN_COMMAND_DURABLE_MATRIX_DATABASE_URL reference_command_durable_matrix TURN_DIFFERENTIAL_DATABASE_URL core diff --git a/tools/integration-tests/test/scenario29NationBettingLifecycle.integration.test.ts b/tools/integration-tests/test/scenario29NationBettingLifecycle.integration.test.ts index e6475e32..46b5d89d 100644 --- a/tools/integration-tests/test/scenario29NationBettingLifecycle.integration.test.ts +++ b/tools/integration-tests/test/scenario29NationBettingLifecycle.integration.test.ts @@ -14,7 +14,7 @@ import { type RedisConnector, } from '@sammo-ts/infra'; -const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL; +const databaseUrl = process.env.SCENARIO_LIFECYCLE_DATABASE_URL; const integration = describe.skipIf(!databaseUrl); const scenarioId = 2900; const userIds = ['scenario29-betting-winner', 'scenario29-betting-loser'] as const; diff --git a/tools/run-conditional-integration.sh b/tools/run-conditional-integration.sh index e3524f6f..7439da19 100755 --- a/tools/run-conditional-integration.sh +++ b/tools/run-conditional-integration.sh @@ -41,7 +41,7 @@ node_tag=$(printf '%s' "${CI_NODE_INDEX:-local}" | tr -cd 'a-zA-Z0-9_' | tr 'A-Z run_id=$(date -u +%m%d%H%M%S)_$$_${node_tag} export CONDITIONAL_INTEGRATION_RUN_ID=$run_id schema_ownership_token="sammo-conditional-integration:$run_id" -supported_registry_modes="core create_general external_fixture gateway_runtime immediate_action npc_possession read_model_journal reference_command_durable_matrix reference_full_lifecycle reference_live_sortie reference_npc_possession security_transport select_pool web_push_gateway" +supported_registry_modes="core create_general external_fixture gateway_runtime immediate_action npc_possession read_model_journal reference_command_durable_matrix reference_full_lifecycle reference_live_sortie reference_npc_possession scenario_lifecycle security_transport select_pool web_push_gateway" term_grace_seconds=${CONDITIONAL_INTEGRATION_TERM_GRACE_SECONDS:-10} case "$term_grace_seconds" in ''|*[!0-9]*) @@ -55,6 +55,7 @@ if [ "$term_grace_seconds" -lt 1 ] || [ "$term_grace_seconds" -gt 60 ]; then fi integration_schema=${CONDITIONAL_INTEGRATION_SCHEMA:-ci_${run_id}_integration} scenario_schema=${SCENARIO_SEED_INTEGRATION_SCHEMA:-ci_${run_id}_scenario_seed} +scenario_lifecycle_schema=${SCENARIO_LIFECYCLE_INTEGRATION_SCHEMA:-ci_${run_id}_scenario_lifecycle} npc_possession_schema=${NPC_POSSESSION_INTEGRATION_SCHEMA:-ci_${run_id}_npc_possession_integration} create_general_schema=${CREATE_GENERAL_INTEGRATION_SCHEMA:-ci_${run_id}_create_general_integration} select_pool_schema=${SELECT_POOL_INTEGRATION_SCHEMA:-ci_${run_id}_select_pool_integration} @@ -71,6 +72,7 @@ turn_full_lifecycle_schema=${TURN_FULL_LIFECYCLE_PERSISTENCE_SCHEMA:-ci_${run_id for schema in \ "$integration_schema" \ "$scenario_schema" \ + "$scenario_lifecycle_schema" \ "$npc_possession_schema" \ "$create_general_schema" \ "$select_pool_schema" \ @@ -485,6 +487,7 @@ cleanup_resources_started=1 create_owned_schema "$integration_schema" create_owned_schema "$npc_possession_schema" create_owned_schema "$scenario_schema" +create_owned_schema "$scenario_lifecycle_schema" database_url=$(build_database_url "$integration_schema") export POSTGRES_SCHEMA=$integration_schema @@ -510,6 +513,17 @@ run_marked_tests app/game-api "$core_database_markers" "game_api_postgresql" run_marked_tests app/game-engine "$core_database_markers" "game_engine_postgresql" run_marked_tests tools/integration-tests "$core_database_markers" "snapshot_postgresql" +scenario_lifecycle_database_url=$(build_database_url "$scenario_lifecycle_schema") +( + export POSTGRES_SCHEMA=$scenario_lifecycle_schema + export DATABASE_URL=$scenario_lifecycle_database_url + pnpm --filter @sammo-ts/infra prisma:migrate:deploy:game +) +export SCENARIO_LIFECYCLE_DATABASE_URL=$scenario_lifecycle_database_url +run_marked_tests tools/integration-tests \ + "$(markers_for_mode scenario_lifecycle)" \ + "scenario_lifecycle_postgresql" + create_owned_schema "$read_model_journal_schema" read_model_journal_database_url=$(build_database_url "$read_model_journal_schema") (