fix: 투표 UTC wall과 migration 시간대 경계를 고정한다
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 } });
|
||||
|
||||
|
||||
Reference in New Issue
Block a user