fix: 투표 UTC wall과 migration 시간대 경계를 고정한다

This commit is contained in:
2026-08-24 09:18:42 +00:00
parent 821b8a0723
commit 7a1254cb3a
15 changed files with 667 additions and 25 deletions
+32 -7
View File
@@ -394,10 +394,27 @@ export const voteRouter = router({
? await ctx.db.nation.findFirst({ where: { id: general.nationId }, select: { name: true } })
: null;
const nationName = nation?.name ?? '재야';
const createdAt = new Date();
await ctx.db.$queryRaw(GamePrisma.sql`
INSERT INTO vote_comment (vote_id, general_id, nation_id, general_name, nation_name, text)
VALUES (${input.voteId}, ${general.id}, ${general.nationId}, ${general.name}, ${nationName}, ${input.text})
INSERT INTO vote_comment (
vote_id,
general_id,
nation_id,
general_name,
nation_name,
text,
created_at
)
VALUES (
${input.voteId},
${general.id},
${general.nationId},
${general.name},
${nationName},
${input.text},
${createdAt}
)
`);
return { ok: true };
@@ -430,6 +447,7 @@ export const voteRouter = router({
if (endAt && endAt < gameTime.now) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '종료일이 이미 지났습니다.' });
}
const operationalAt = new Date();
let multipleOptions = input.multipleOptions;
if (multipleOptions < 0) {
@@ -442,7 +460,7 @@ export const voteRouter = router({
if (input.closePrevious) {
await ctx.db.$queryRaw(GamePrisma.sql`
UPDATE vote_poll
SET closed_at = ${gameTime.now}, updated_at = NOW()
SET closed_at = ${gameTime.now}, updated_at = ${operationalAt}
WHERE closed_at IS NULL
`);
}
@@ -459,7 +477,9 @@ export const voteRouter = router({
start_at,
start_tick,
end_at,
end_tick
end_tick,
created_at,
updated_at
)
VALUES (
${input.title},
@@ -472,7 +492,9 @@ export const voteRouter = router({
${gameTime.now},
${gameTime.tick === null ? null : BigInt(gameTime.tick)},
${endAt},
${toGameTickOrNull(gameTime, endAt)}
${toGameTickOrNull(gameTime, endAt)},
${operationalAt},
${operationalAt}
)
`);
@@ -547,6 +569,7 @@ export const voteRouter = router({
if (endAt && endAt < gameTime.now) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '종료일이 이미 지났습니다.' });
}
const updatedAt = new Date();
if (
input.title === undefined &&
@@ -569,7 +592,7 @@ export const voteRouter = router({
reveal_mode = COALESCE(${input.revealMode}, reveal_mode),
end_at = ${endAt ?? poll.end_at},
end_tick = ${endAt ? toGameTickOrNull(gameTime, endAt) : poll.end_tick},
updated_at = NOW()
updated_at = ${updatedAt}
WHERE id = ${input.voteId}
`);
@@ -581,9 +604,11 @@ export const voteRouter = router({
closePoll: adminProcedure
.input(z.object({ voteId: z.number().int().positive() }))
.mutation(async ({ ctx, input }) => {
const gameTime = await loadCurrentGameTime(ctx.db);
const updatedAt = new Date();
const rows = await ctx.db.$queryRaw<Array<{ id: number }>>(GamePrisma.sql`
UPDATE vote_poll
SET closed_at = ${(await loadCurrentGameTime(ctx.db)).now}, updated_at = NOW()
SET closed_at = ${gameTime.now}, updated_at = ${updatedAt}
WHERE id = ${input.voteId}
RETURNING id
`);
@@ -99,6 +99,8 @@ integration('API input event boundary', () => {
actorUserId: 'user-7',
attempts: 1,
});
expect(event.processingAt).toBeInstanceOf(Date);
expect(Math.abs(event.createdAt.getTime() - (event.processingAt?.getTime() ?? 0))).toBeLessThan(1_000);
expect(marker.status).toBe('PENDING');
});
@@ -254,7 +256,12 @@ integration('API input event boundary', () => {
it('reuses the same engine child event but rejects a changed retry payload', async () => {
const transport = new DatabaseTurnDaemonTransport(db, 100);
const requestId = 'integration:api:engine-child';
const acceptedWindowStart = Date.now();
await transport.sendCommand({ type: 'vacation', requestId, generalId: 7 });
const acceptedWindowEnd = Date.now();
const event = await db.inputEvent.findUniqueOrThrow({ where: { requestId } });
expect(event.createdAt.getTime()).toBeGreaterThanOrEqual(acceptedWindowStart);
expect(event.createdAt.getTime()).toBeLessThanOrEqual(acceptedWindowEnd);
await expect(transport.sendCommand({ type: 'vacation', requestId, generalId: 7 })).resolves.toBe(requestId);
await expect(transport.sendCommand({ type: 'vacation', requestId, generalId: 8 })).rejects.toBeInstanceOf(
ConflictingTurnDaemonCommandError
@@ -0,0 +1,177 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra';
import type { GameApiContext } from '../src/context.js';
import { appRouter } from '../src/router.js';
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
const integration = describe.skipIf(!databaseUrl);
const fixtureId = 9_984_241;
const fallbackPollId = fixtureId + 1;
const fixtureUserId = 'vote-comment-timestamp-user';
const routeText = '설문 댓글 UTC writer 검증';
const fallbackText = '설문 댓글 UTC default 검증';
const auth: GameSessionTokenPayload = {
version: 1,
profile: 'che:vote-comment-timestamp',
issuedAt: '2026-08-24T00:00:00.000Z',
expiresAt: '2027-08-24T00:00:00.000Z',
sessionId: 'vote-comment-timestamp-session',
user: {
id: fixtureUserId,
username: fixtureUserId,
displayName: fixtureUserId,
roles: [],
},
sanctions: {},
};
integration('vote comment operational timestamp', () => {
let db: GamePrismaClient;
let closeDb: (() => Promise<void>) | undefined;
const cleanup = async (): Promise<void> => {
await db.inputEvent.deleteMany({
where: { requestId: { startsWith: 'integration:vote-comment-timestamp' } },
});
await db.voteComment.deleteMany({ where: { voteId: { in: [fixtureId, fallbackPollId] } } });
await db.vote.deleteMany({ where: { voteId: { in: [fixtureId, fallbackPollId] } } });
await db.votePoll.deleteMany({ where: { id: { in: [fixtureId, fallbackPollId] } } });
await db.general.deleteMany({ where: { id: fixtureId } });
await db.nation.deleteMany({ where: { id: fixtureId } });
};
beforeAll(async () => {
const connector = createGamePostgresConnector({ url: databaseUrl! });
await connector.connect();
db = connector.prisma;
closeDb = () => connector.disconnect();
await cleanup();
await db.nation.create({ data: { id: fixtureId, name: '시각국', color: '#123456' } });
await db.general.create({
data: {
id: fixtureId,
userId: fixtureUserId,
name: '시각장수',
nationId: fixtureId,
turnTime: new Date('0200-01-01T00:00:00.000Z'),
},
});
await db.votePoll.create({
data: {
id: fixtureId,
title: '시각 설문',
options: ['찬성', '반대'],
multipleOptions: 1,
revealMode: 'after_vote',
openerGeneralId: fixtureId,
openerName: '시각장수',
startAt: new Date('0200-01-01T00:00:00.000Z'),
},
});
});
afterAll(async () => {
await cleanup();
await closeDb?.();
});
it('stores current writers and rollback-compatible vote defaults as UTC wall time in KST', async () => {
const [session] = await db.$queryRaw<Array<{ timeZone: string }>>`
SELECT current_setting('TIMEZONE') AS "timeZone"
`;
expect(session?.timeZone).toBe('Asia/Seoul');
const routeWindowStart = Date.now();
const caller = appRouter.createCaller({
requestId: 'integration:vote-comment-timestamp',
db,
auth,
profile: { id: 'che', scenario: 'vote-comment-timestamp', name: 'che:vote-comment-timestamp' },
turnDaemon: {},
} as unknown as GameApiContext);
await expect(caller.vote.addComment({ voteId: fixtureId, text: routeText })).resolves.toEqual({ ok: true });
const routeWindowEnd = Date.now();
const fallbackWindowStart = Date.now();
await db.$executeRaw`
INSERT INTO "vote_comment" (
"vote_id",
"general_id",
"nation_id",
"general_name",
"nation_name",
"text"
)
VALUES (
${fixtureId},
${fixtureId},
${fixtureId},
'시각장수',
'시각국',
${fallbackText}
)
`;
const fallbackWindowEnd = Date.now();
const pollFallbackWindowStart = Date.now();
await db.$executeRaw`
INSERT INTO "vote_poll" (
"id",
"title",
"body",
"options",
"multiple_options",
"reveal_mode",
"opener_general_id",
"opener_name",
"start_at"
)
VALUES (
${fallbackPollId},
'이전 writer 기본값 설문',
'',
'["찬성", "반대"]'::jsonb,
1,
'after_vote',
${fixtureId},
'시각장수',
${new Date('0200-01-01T00:00:00.000Z')}
)
`;
const pollFallbackWindowEnd = Date.now();
const voteFallbackWindowStart = Date.now();
await db.$executeRaw`
INSERT INTO "vote" ("vote_id", "general_id", "nation_id", "selection")
VALUES (${fixtureId}, ${fixtureId}, ${fixtureId}, '[0]'::jsonb)
`;
const voteFallbackWindowEnd = Date.now();
const rows = await db.voteComment.findMany({
where: { voteId: fixtureId },
select: { text: true, createdAt: true },
});
const routeRow = rows.find((row) => row.text === routeText);
const fallbackRow = rows.find((row) => row.text === fallbackText);
expect(routeRow?.createdAt.getTime()).toBeGreaterThanOrEqual(routeWindowStart);
expect(routeRow?.createdAt.getTime()).toBeLessThanOrEqual(routeWindowEnd);
expect(fallbackRow?.createdAt.getTime()).toBeGreaterThanOrEqual(fallbackWindowStart);
expect(fallbackRow?.createdAt.getTime()).toBeLessThanOrEqual(fallbackWindowEnd);
const fallbackPoll = await db.votePoll.findUniqueOrThrow({ where: { id: fallbackPollId } });
expect(fallbackPoll.createdAt.getTime()).toBeGreaterThanOrEqual(pollFallbackWindowStart);
expect(fallbackPoll.createdAt.getTime()).toBeLessThanOrEqual(pollFallbackWindowEnd);
expect(fallbackPoll.updatedAt.getTime()).toBeGreaterThanOrEqual(pollFallbackWindowStart);
expect(fallbackPoll.updatedAt.getTime()).toBeLessThanOrEqual(pollFallbackWindowEnd);
const fallbackVote = await db.vote.findUniqueOrThrow({
where: { voteId_generalId: { voteId: fixtureId, generalId: fixtureId } },
});
expect(fallbackVote.createdAt.getTime()).toBeGreaterThanOrEqual(voteFallbackWindowStart);
expect(fallbackVote.createdAt.getTime()).toBeLessThanOrEqual(voteFallbackWindowEnd);
});
});
+57
View File
@@ -130,6 +130,9 @@ const buildContext = (options: {
if (text.includes('INSERT INTO vote_comment')) {
return [];
}
if (text.includes('UPDATE vote_poll')) {
return [{ id: 1 }];
}
return [];
});
const db = {
@@ -289,6 +292,60 @@ describe('vote router actor and permission boundaries', () => {
expect(insert?.values).not.toContain('관리자 장수');
});
it('binds current operational timestamps in every raw SQL vote writer', async () => {
const auth = buildAuth(['admin.survey.open']);
const fixture = buildContext({ auth });
const caller = appRouter.createCaller(fixture.context);
const windowStart = Date.now();
await expect(caller.vote.addComment({ voteId: 1, text: '시각 댓글' })).resolves.toEqual({ ok: true });
await expect(
caller.vote.createPoll({
title: '시각 설문',
options: ['찬성', '반대'],
revealMode: 'after_vote',
closePrevious: true,
})
).resolves.toEqual({ ok: true });
await expect(caller.vote.updatePoll({ voteId: 1, title: '시각 설문 수정' })).resolves.toEqual({ ok: true });
await expect(caller.vote.closePoll({ voteId: 1 })).resolves.toEqual({ ok: true });
const windowEnd = Date.now();
const mutationQueries = fixture.queryRaw.mock.calls
.map(([query]) => query)
.filter((query) => /INSERT INTO vote_comment|INSERT INTO vote_poll|UPDATE vote_poll/.test(sqlText(query)));
const commentInsert = mutationQueries.find((query) => sqlText(query).includes('INSERT INTO vote_comment'));
const pollInsert = mutationQueries.find((query) => sqlText(query).includes('INSERT INTO vote_poll'));
const pollUpdates = mutationQueries.filter((query) => sqlText(query).includes('UPDATE vote_poll'));
const closePreviousUpdate = pollUpdates.find((query) => sqlText(query).includes('WHERE closed_at IS NULL'));
const editPollUpdate = pollUpdates.find((query) => sqlText(query).includes('title = COALESCE'));
const closePollUpdate = pollUpdates.find((query) => sqlText(query).includes('RETURNING id'));
const expectCurrentDateAt = (query: GamePrisma.Sql | undefined, index: number): Date => {
expect(query).toBeDefined();
const value = query?.values.at(index);
expect(value).toBeInstanceOf(Date);
expect((value as Date).getTime()).toBeGreaterThanOrEqual(windowStart);
expect((value as Date).getTime()).toBeLessThanOrEqual(windowEnd);
return value as Date;
};
expect(sqlText(commentInsert!)).toContain('created_at');
expectCurrentDateAt(commentInsert, -1);
expect(sqlText(pollInsert!)).toContain('created_at');
expect(sqlText(pollInsert!)).toContain('updated_at');
const pollCreatedAt = expectCurrentDateAt(pollInsert, -2);
const pollUpdatedAt = expectCurrentDateAt(pollInsert, -1);
expect(pollUpdatedAt).toBe(pollCreatedAt);
expect(pollUpdates).toHaveLength(3);
expect(sqlText(closePreviousUpdate!)).toContain('updated_at');
expect(expectCurrentDateAt(closePreviousUpdate, -1)).toBe(pollCreatedAt);
expect(sqlText(editPollUpdate!)).toContain('updated_at');
expectCurrentDateAt(editPollUpdate, -2);
expect(sqlText(closePollUpdate!)).toContain('updated_at');
expectCurrentDateAt(closePollUpdate, -2);
});
it('reports the current world develcost as the legacy five-times survey reward', async () => {
const fixture = buildContext({ metaDevelCost: 30, configConst: { develCost: 0 } });