feat: synchronize account icons across game profiles

This commit is contained in:
2026-07-31 11:21:08 +00:00
parent c8adeeb47b
commit 5f20413552
87 changed files with 5755 additions and 280 deletions
@@ -0,0 +1,52 @@
import { describe, expect, it, vi } from 'vitest';
import { InMemoryTurnDaemonTransport } from '../src/daemon/inMemoryTransport.js';
import { createAdminProfileIconResetFlushHandler } from '../src/services/accountIconSync.js';
describe('administrator profile icon reset flush', () => {
it('enqueues only the administrator reset projection', async () => {
const transport = new InMemoryTurnDaemonTransport();
const get = vi.fn(async () => ({
revision: '2026-07-31T09:00:00.001Z',
picture: 'default.jpg',
imageServer: 0,
}));
const handler = createAdminProfileIconResetFlushHandler({ get }, transport);
await handler({
userId: 'user-1',
flushedAt: '2026-07-31T09:00:00.000Z',
reason: 'account-icon-changed',
});
expect(get).not.toHaveBeenCalled();
expect(transport.commands).toHaveLength(0);
await handler({
userId: 'user-1',
flushedAt: '2026-07-31T09:00:00.001Z',
reason: 'admin-profile-icon-reset',
iconRevision: '2026-07-31T09:00:00.001Z',
});
expect(transport.commands.at(-1)?.command).toEqual({
type: 'adjustGeneralIcon',
requestId: 'general:adjustIcon:user-1:2026-07-31T09:00:00.001Z',
userId: 'user-1',
picture: 'default.jpg',
imageServer: 0,
iconRevision: '2026-07-31T09:00:00.001Z',
});
get.mockResolvedValueOnce({
revision: '2026-07-31T09:00:00.002Z',
picture: 'default.jpg',
imageServer: 0,
});
await handler({
userId: 'user-1',
flushedAt: '2026-07-31T09:00:00.003Z',
reason: 'admin-profile-icon-reset',
iconRevision: '2026-07-31T09:00:00.001Z',
});
expect(transport.commands).toHaveLength(1);
});
});
@@ -0,0 +1,358 @@
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { SystemClock } from '@sammo-ts/common';
import {
createDatabaseTurnHooks,
DatabaseTurnDaemonCommandQueue,
EngineStateManager,
InMemoryTurnStateStore,
InMemoryTurnWorld,
loadTurnWorldFromDatabase,
TurnDaemonLifecycle,
type TurnGeneral,
type TurnWorldSnapshot,
type TurnWorldState,
} from '@sammo-ts/game-engine';
import { createTurnDaemonCommandHandler } from '@sammo-ts/game-engine/turn/worldCommandHandler.js';
import { createGamePostgresConnector, type GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
import type { MapDefinition, ScenarioConfig, ScenarioMeta, TurnSchedule } from '@sammo-ts/logic';
import { DatabaseTurnDaemonTransport } from '../src/daemon/databaseTransport.js';
import { AccountIconResetReconciler } from '../src/services/accountIconResetReconciler.js';
const databaseUrl = process.env.IMMEDIATE_ACTION_DATABASE_URL;
const integration = describe.skipIf(!databaseUrl);
const generalId = 991_744;
const userId = 'account-icon-reset-reconcile-user';
const revision = '2026-07-31T10:00:00.001Z';
const requestId = `general:adjustIcon:${userId}:${revision}`;
const retryRequestId = `${requestId}:retry:1`;
const lifecycleGeneralId = 991_745;
const lifecycleWorldId = 991_745;
const lifecycleUserId = 'account-icon-reset-lifecycle-user';
const lifecycleRevision = '2026-07-31T10:20:00.001Z';
const lifecycleRequestId = `general:adjustIcon:${lifecycleUserId}:${lifecycleRevision}`;
const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] };
const scenarioConfig: ScenarioConfig = {
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 65 },
iconPath: '',
map: {},
const: {},
environment: { mapName: 'che', unitSet: 'che' },
};
const scenarioMeta: ScenarioMeta = {
title: '계정 아이콘 reset lifecycle 통합',
startYear: 190,
life: null,
fiction: null,
history: [],
ignoreDefaultEvents: false,
};
const map: MapDefinition = { id: 'account-icon-reset-lifecycle', name: scenarioMeta.title, cities: [] };
const lifecycleState: TurnWorldState = {
id: lifecycleWorldId,
currentYear: 190,
currentMonth: 1,
tickSeconds: 600,
lastTurnTime: new Date('2026-07-31T10:00:00.000Z'),
meta: { killturn: 24, scenarioMeta },
};
const lifecycleGeneral: TurnGeneral = {
id: lifecycleGeneralId,
userId: lifecycleUserId,
name: '초기화lifecycle장수',
nationId: 0,
cityId: 0,
troopId: 0,
stats: { leadership: 50, strength: 50, intelligence: 50 },
turnTime: new Date('2026-07-31T10:30:00.000Z'),
recentWarTime: null,
role: {
items: { horse: null, weapon: null, book: null, item: null },
personality: null,
specialDomestic: null,
specialWar: null,
},
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
meta: { killturn: 24, accountIconUpdatedAt: '2026-07-31T10:10:00.000Z', preserved: 'yes' },
penalty: {},
officerLevel: 0,
experience: 0,
dedication: 0,
injury: 0,
gold: 1_000,
rice: 1_000,
crew: 0,
crewTypeId: 0,
train: 0,
atmos: 0,
age: 20,
npcState: 0,
picture: 'before-lifecycle-reset.png',
imageServer: 1,
};
const assertDedicatedDatabase = (rawUrl: string): void => {
const schema = new URL(rawUrl).searchParams.get('schema');
if (!schema?.endsWith('immediate_action_integration')) {
throw new Error(`Refusing to mutate non-dedicated schema: ${schema ?? '(missing)'}`);
}
};
integration('account icon reset reconciliation PostgreSQL queue', () => {
let db: GamePrismaClient;
let disconnect: (() => Promise<void>) | undefined;
beforeAll(async () => {
assertDedicatedDatabase(databaseUrl!);
const connector = createGamePostgresConnector({ url: databaseUrl! });
await connector.connect();
db = connector.prisma;
disconnect = () => connector.disconnect();
await db.inputEvent.deleteMany({ where: { requestId: { startsWith: requestId } } });
await db.inputEvent.deleteMany({ where: { requestId: { startsWith: lifecycleRequestId } } });
await db.general.deleteMany({ where: { id: { in: [generalId, lifecycleGeneralId] } } });
await db.worldState.deleteMany({ where: { id: lifecycleWorldId } });
await db.worldState.create({
data: {
id: lifecycleWorldId,
scenarioCode: 'account-icon-reset-lifecycle',
currentYear: lifecycleState.currentYear,
currentMonth: lifecycleState.currentMonth,
tickSeconds: lifecycleState.tickSeconds,
config: JSON.parse(JSON.stringify(scenarioConfig)) as GamePrisma.InputJsonValue,
meta: lifecycleState.meta as GamePrisma.InputJsonValue,
},
});
await db.general.create({
data: {
id: generalId,
userId,
name: '초기화복구장수',
turnTime: new Date('2026-07-31T10:10:00.000Z'),
picture: 'before-reset.png',
imageServer: 1,
meta: { killturn: 24, accountIconUpdatedAt: '2026-07-31T09:00:00.000Z' },
},
});
await db.general.create({
data: {
id: lifecycleGeneral.id,
userId: lifecycleGeneral.userId,
name: lifecycleGeneral.name,
turnTime: lifecycleGeneral.turnTime,
picture: lifecycleGeneral.picture,
imageServer: lifecycleGeneral.imageServer,
meta: lifecycleGeneral.meta,
},
});
});
afterAll(async () => {
if (db) {
await db.inputEvent.deleteMany({ where: { requestId: { startsWith: requestId } } });
await db.inputEvent.deleteMany({ where: { requestId: { startsWith: lifecycleRequestId } } });
await db.general.deleteMany({ where: { id: { in: [generalId, lifecycleGeneralId] } } });
await db.worldState.deleteMany({ where: { id: lifecycleWorldId } });
}
await disconnect?.();
});
it('deduplicates an active durable enqueue and creates one bounded retry after terminal failure', async () => {
const source = {
listResets: vi.fn(async () => [
{
userId,
resetRevision: revision,
current: {
revision,
picture: 'default.jpg',
imageServer: 0,
},
},
]),
};
const reconciler = new AccountIconResetReconciler(
db,
source,
new DatabaseTurnDaemonTransport(db, 1_000),
30_000
);
await reconciler.reconcileOnce();
await reconciler.reconcileOnce();
await expect(db.inputEvent.findMany({ where: { requestId } })).resolves.toEqual([
expect.objectContaining({
requestId,
target: 'ENGINE',
eventType: 'adjustGeneralIcon',
status: 'PENDING',
actorUserId: userId,
payload: {
type: 'adjustGeneralIcon',
requestId,
userId,
picture: 'default.jpg',
imageServer: 0,
iconRevision: revision,
},
}),
]);
await db.inputEvent.update({
where: { requestId },
data: {
status: 'FAILED',
attempts: 3,
error: 'simulated terminal failure',
completedAt: new Date(),
},
});
await reconciler.reconcileOnce();
await reconciler.reconcileOnce();
await expect(
db.inputEvent.findMany({
where: { requestId: { startsWith: requestId } },
orderBy: { sequence: 'asc' },
})
).resolves.toEqual([
expect.objectContaining({ requestId, status: 'FAILED' }),
expect.objectContaining({
requestId: retryRequestId,
status: 'PENDING',
actorUserId: userId,
payload: {
type: 'adjustGeneralIcon',
requestId: retryRequestId,
userId,
picture: 'default.jpg',
imageServer: 0,
iconRevision: revision,
},
}),
]);
});
it('flows from reconciliation through the durable daemon lifecycle and persists one reset', async () => {
const source = {
listResets: vi.fn(async () => [
{
userId: lifecycleUserId,
resetRevision: lifecycleRevision,
current: {
revision: lifecycleRevision,
picture: 'default.jpg',
imageServer: 0,
},
},
]),
};
const reconciler = new AccountIconResetReconciler(
db,
source,
new DatabaseTurnDaemonTransport(db, 1_000),
30_000
);
await reconciler.reconcileOnce();
const snapshot: TurnWorldSnapshot = {
generals: [lifecycleGeneral],
cities: [],
nations: [],
troops: [],
diplomacy: [],
events: [],
initialEvents: [],
scenarioConfig,
scenarioMeta,
map,
};
const world = new InMemoryTurnWorld(lifecycleState, snapshot, { schedule });
const queue = new DatabaseTurnDaemonCommandQueue(db);
await queue.initialize();
const hooks = await createDatabaseTurnHooks(databaseUrl!, world);
const stateManager = new EngineStateManager();
stateManager.register('world', {
capture: () => world.captureState(),
restore: (captured) => world.restoreState(captured),
});
const lifecycle = new TurnDaemonLifecycle(
{
clock: new SystemClock(),
controlQueue: queue,
commandResponder: queue,
getNextTickTime: () => new Date(Date.now() + 3_600_000),
stateStore: new InMemoryTurnStateStore(world),
processor: {
run: async () => {
throw new Error('scheduled turn must not run in account icon lifecycle integration');
},
},
commandHandler: createTurnDaemonCommandHandler({ world }),
hooks: hooks.hooks,
stateManager,
},
{
profile: 'account-icon-reset-lifecycle',
defaultBudget: { budgetMs: 100, maxGenerals: 1, catchUpCap: 1 },
}
);
const waitForSuccess = async () => {
for (let attempt = 0; attempt < 200; attempt += 1) {
const event = await db.inputEvent.findUnique({ where: { requestId: lifecycleRequestId } });
if (event?.status === 'SUCCEEDED' && event.lockedBy === null) return event;
await new Promise((resolve) => setTimeout(resolve, 25));
}
const observed = await db.inputEvent.findUnique({ where: { requestId: lifecycleRequestId } });
throw new Error(
`Timed out waiting for ${lifecycleRequestId} to succeed: ${JSON.stringify(
observed && {
status: observed.status,
attempts: observed.attempts,
error: observed.error,
result: observed.result,
lockedBy: observed.lockedBy,
}
)}`
);
};
let loop: Promise<void> | undefined;
try {
loop = lifecycle.start();
await expect(waitForSuccess()).resolves.toMatchObject({
attempts: 1,
result: {
type: 'adjustGeneralIcon',
ok: true,
generalId: lifecycleGeneralId,
updated: true,
},
});
} finally {
await lifecycle.stop('account icon reset integration finished');
await loop;
await hooks.close();
}
await expect(db.general.findUniqueOrThrow({ where: { id: lifecycleGeneralId } })).resolves.toMatchObject({
picture: 'default.jpg',
imageServer: 0,
meta: { accountIconUpdatedAt: lifecycleRevision, preserved: 'yes' },
});
const reloaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! });
expect(reloaded.snapshot.generals.find((entry) => entry.id === lifecycleGeneralId)).toMatchObject({
picture: 'default.jpg',
imageServer: 0,
meta: { accountIconUpdatedAt: lifecycleRevision, preserved: 'yes' },
});
await reconciler.reconcileOnce();
await expect(db.inputEvent.count({ where: { requestId: { startsWith: lifecycleRequestId } } })).resolves.toBe(
1
);
}, 15_000);
});
@@ -0,0 +1,130 @@
import { describe, expect, it, vi } from 'vitest';
import type { DatabaseClient } from '../src/context.js';
import { InMemoryTurnDaemonTransport } from '../src/daemon/inMemoryTransport.js';
import { AccountIconResetReconciler } from '../src/services/accountIconResetReconciler.js';
const reset = (userId: string) => ({
userId,
resetRevision: '2026-07-31T09:00:00.001Z',
current: {
revision: '2026-07-31T09:00:00.002Z',
picture: 'newer.png',
imageServer: 1,
},
});
describe('AccountIconResetReconciler', () => {
it('replays stale resets, skips newer watermarks, and bootstraps matching post-reset tuples', async () => {
const db = {
general: {
findMany: vi.fn(async () => [
{
id: 1,
userId: 'needs-reset',
picture: 'old.png',
imageServer: 1,
meta: { accountIconUpdatedAt: '2026-07-31T08:59:59.999Z' },
},
{
id: 2,
userId: 'already-newer',
picture: 'newer.png',
imageServer: 1,
meta: { accountIconUpdatedAt: '2026-07-31T09:00:00.002Z' },
},
{
id: 3,
userId: 'legacy-watermark',
picture: 'newer.png',
imageServer: 1,
meta: {},
},
]),
},
inputEvent: {
findMany: vi.fn(async () => []),
},
} as unknown as DatabaseClient;
const source = {
listResets: vi.fn(async () => [reset('needs-reset'), reset('already-newer'), reset('legacy-watermark')]),
};
const transport = new InMemoryTurnDaemonTransport();
const reconciler = new AccountIconResetReconciler(db, source, transport, 30_000);
await reconciler.reconcileOnce();
expect(source.listResets).toHaveBeenCalledWith(['needs-reset', 'already-newer', 'legacy-watermark']);
expect(transport.commands.map(({ command }) => command)).toEqual([
{
type: 'adjustGeneralIcon',
requestId: 'general:adjustIcon:needs-reset:2026-07-31T09:00:00.001Z',
userId: 'needs-reset',
picture: 'default.jpg',
imageServer: 0,
iconRevision: '2026-07-31T09:00:00.001Z',
},
{
type: 'adjustGeneralIcon',
requestId: 'general:adjustIcon:legacy-watermark:2026-07-31T09:00:00.002Z',
userId: 'legacy-watermark',
picture: 'newer.png',
imageServer: 1,
iconRevision: '2026-07-31T09:00:00.002Z',
},
]);
expect(reconciler.getHealth()).toMatchObject({ lastSuccessAt: expect.any(String), lastError: null });
});
it('requeues terminal events and isolates a persistent user failure from later users', async () => {
const db = {
general: {
findMany: vi.fn(async () =>
['terminal', 'broken', 'later'].map((userId, index) => ({
id: index + 1,
userId,
picture: 'old.png',
imageServer: 1,
meta: {},
}))
),
},
inputEvent: {
findMany: vi.fn(async ({ where }: { where: { OR: Array<{ requestId: unknown }> } }) => {
const first = where.OR[0]?.requestId;
if (first === 'general:adjustIcon:broken:2026-07-31T09:00:00.001Z') {
throw new Error('broken event lookup');
}
if (first === 'general:adjustIcon:terminal:2026-07-31T09:00:00.001Z') {
return [
{
requestId: first,
status: 'FAILED',
eventType: 'adjustGeneralIcon',
payload: {
type: 'adjustGeneralIcon',
requestId: first,
userId: 'terminal',
picture: 'default.jpg',
imageServer: 0,
iconRevision: '2026-07-31T09:00:00.001Z',
},
},
];
}
return [];
}),
},
} as unknown as DatabaseClient;
const source = { listResets: vi.fn(async () => ['terminal', 'broken', 'later'].map(reset)) };
const transport = new InMemoryTurnDaemonTransport();
const reconciler = new AccountIconResetReconciler(db, source, transport, 30_000);
await expect(reconciler.reconcileOnce()).rejects.toThrow('1 account icon reset reconciliation');
expect(transport.commands.map(({ command }) => command.requestId)).toEqual([
'general:adjustIcon:terminal:2026-07-31T09:00:00.001Z:retry:1',
'general:adjustIcon:later:2026-07-31T09:00:00.001Z',
]);
expect(reconciler.getHealth()).toMatchObject({ lastErrorAt: expect.any(String) });
});
});
+115
View File
@@ -0,0 +1,115 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { GatewayHttpAccountIconSource } from '../src/auth/accountIconSource.js';
const projection = {
revision: '2026-07-31T09:00:00.001Z',
picture: 'latest.png',
imageServer: 1,
};
afterEach(() => {
vi.unstubAllGlobals();
});
describe('GatewayHttpAccountIconSource', () => {
it('uses an encoded path and a purpose-derived credential', async () => {
const fetchMock = vi.fn(
async (_input: string | URL | Request, _init?: RequestInit) =>
new Response(JSON.stringify(projection), { status: 200 })
);
vi.stubGlobal('fetch', fetchMock);
const source = new GatewayHttpAccountIconSource('http://gateway.internal/', 'root-secret');
await expect(source.get('user/한글')).resolves.toEqual(projection);
const [url, init] = fetchMock.mock.calls[0]!;
expect(url).toBe('http://gateway.internal/internal/account-icons/user%2F%ED%95%9C%EA%B8%80');
expect(init?.headers).toMatchObject({
'x-sammo-internal-token': expect.not.stringContaining('root-secret'),
});
});
it('returns null only for a missing user', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response(null, { status: 404 }))
);
await expect(new GatewayHttpAccountIconSource('http://gateway', 'secret').get('missing')).resolves.toBeNull();
});
it.each([401, 500])('rejects HTTP %s', async (status) => {
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response(null, { status }))
);
await expect(new GatewayHttpAccountIconSource('http://gateway', 'secret').get('user')).rejects.toThrow(
`HTTP ${status}`
);
});
it('rejects malformed or over-broad responses', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response(JSON.stringify({ ...projection, email: 'must-not-leak@example.test' })))
);
await expect(new GatewayHttpAccountIconSource('http://gateway', 'secret').get('user')).rejects.toThrow(
'invalid'
);
});
it('loads only strict reset projections for the requested users', async () => {
const fetchMock = vi.fn(
async (_input: string | URL | Request, _init?: RequestInit) =>
new Response(
JSON.stringify({
resets: [
{
userId: 'user-1',
resetRevision: '2026-07-31T09:00:00.001Z',
current: projection,
},
],
})
)
);
vi.stubGlobal('fetch', fetchMock);
const source = new GatewayHttpAccountIconSource('http://gateway.internal/', 'root-secret');
await expect(source.listResets(['user-1'])).resolves.toEqual([
{
userId: 'user-1',
resetRevision: '2026-07-31T09:00:00.001Z',
current: projection,
},
]);
expect(fetchMock.mock.calls[0]?.[0]).toBe('http://gateway.internal/internal/account-icon-resets');
expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({
method: 'POST',
body: JSON.stringify({ userIds: ['user-1'] }),
});
});
it('rejects reset projections for users that were not requested', async () => {
vi.stubGlobal(
'fetch',
vi.fn(
async () =>
new Response(
JSON.stringify({
resets: [
{
userId: 'other-user',
resetRevision: '2026-07-31T09:00:00.001Z',
current: projection,
},
],
})
)
)
);
await expect(
new GatewayHttpAccountIconSource('http://gateway', 'secret').listResets(['user-1'])
).rejects.toThrow('invalid');
});
});
@@ -0,0 +1,55 @@
import { describe, expect, it, vi } from 'vitest';
import { createBestEffortResourceCloser } from '../src/services/bestEffortResourceCloser.js';
describe('createBestEffortResourceCloser', () => {
it('continues after a failed step and retries only the unfinished step', async () => {
const calls: string[] = [];
let rejectMiddle = true;
const close = createBestEffortResourceCloser([
{
name: 'first',
run: async () => {
calls.push('first');
},
},
{
name: 'middle',
run: async () => {
calls.push('middle');
if (rejectMiddle) throw new Error('temporary failure');
},
},
{
name: 'last',
run: async () => {
calls.push('last');
},
},
]);
await expect(close()).rejects.toThrow('One or more resources failed to close.');
expect(calls).toEqual(['first', 'middle', 'last']);
rejectMiddle = false;
await close();
await close();
expect(calls).toEqual(['first', 'middle', 'last', 'middle']);
});
it('shares one in-flight close across concurrent callers', async () => {
let release: (() => void) | undefined;
const pending = new Promise<void>((resolve) => {
release = resolve;
});
const run = vi.fn(async () => pending);
const close = createBestEffortResourceCloser([{ name: 'only', run }]);
const first = close();
const second = close();
expect(run).toHaveBeenCalledTimes(1);
release?.();
await Promise.all([first, second]);
expect(run).toHaveBeenCalledTimes(1);
});
});
+18
View File
@@ -25,4 +25,22 @@ describe('resolveGameApiConfigFromEnv', () => {
expect(config.profileName).toBe('hwe:2');
});
it.each(['0', '-1', '1.5', '999', 'Infinity'])('rejects unsafe account icon reconcile interval %s', (value) => {
expect(() =>
resolveGameApiConfigFromEnv({
GAME_TOKEN_SECRET: 'test-secret',
ACCOUNT_ICON_RESET_RECONCILE_INTERVAL_MS: value,
})
).toThrow('ACCOUNT_ICON_RESET_RECONCILE_INTERVAL_MS');
});
it('accepts a bounded integer account icon reconcile interval', () => {
expect(
resolveGameApiConfigFromEnv({
GAME_TOKEN_SECRET: 'test-secret',
ACCOUNT_ICON_RESET_RECONCILE_INTERVAL_MS: '1000',
}).accountIconResetReconcileIntervalMs
).toBe(1000);
});
});
@@ -50,6 +50,7 @@ const buildAuth = (id: string, displayName: string, legacyMemberNo: number): Gam
legacyMemberNo,
picture: 'custom-owner.webp',
imageServer: 2,
iconUpdatedAt: '2026-07-30T00:00:00.000Z',
canUseGeneralPicture: true,
},
sanctions: {
@@ -92,6 +93,16 @@ integration('generic general creation through the durable turn daemon', () => {
accessTokenStore: new RedisAccessTokenStore(redisClient, profile),
flushStore: new InMemoryFlushStore(),
gameTokenSecret: 'create-general-test-secret',
accountIconSource: {
get: async (accountId) =>
accountId === auth.user.id && auth.user.iconUpdatedAt
? {
revision: auth.user.iconUpdatedAt,
picture: auth.user.picture ?? 'default.jpg',
imageServer: auth.user.imageServer ?? 0,
}
: null,
},
};
};
@@ -273,6 +284,7 @@ integration('generic general creation through the durable turn daemon', () => {
ownerName: '생성사용자',
killturn: 6,
inherit_spent_dyn: 4500,
accountIconUpdatedAt: '2026-07-30T00:00:00.000Z',
});
const createdAccess = await db.generalAccessLog.findUniqueOrThrow({ where: { generalId: created.id } });
if (!createdAccess.lastRefresh) {
+68
View File
@@ -0,0 +1,68 @@
import { describe, expect, it, vi } from 'vitest';
import { InMemoryFlushStore, RedisGatewayFlushSubscriber } from '../src/auth/flushStore.js';
describe('RedisGatewayFlushSubscriber', () => {
it('reports asynchronous flush handler failures with event context', async () => {
let listener: ((message: string) => void) | undefined;
const client = {
subscribe: vi.fn(async (_channel: string, next: (message: string) => void) => {
listener = next;
}),
unsubscribe: vi.fn(async () => undefined),
};
const error = new Error('durable enqueue unavailable');
const onError = vi.fn();
const subscriber = new RedisGatewayFlushSubscriber(
client,
'flush',
new InMemoryFlushStore(),
async () => Promise.reject(error),
onError
);
await subscriber.start();
const event = {
userId: 'user-1',
flushedAt: '2026-07-31T09:00:00.001Z',
reason: 'admin-profile-icon-reset',
iconRevision: '2026-07-31T09:00:00.001Z',
};
listener?.(JSON.stringify(event));
await vi.waitFor(() => expect(onError).toHaveBeenCalledWith(error, event));
await subscriber.stop();
});
it('unsubscribes and drains an in-flight durable flush before stopping', async () => {
let listener: ((message: string) => void) | undefined;
let release: (() => void) | undefined;
const client = {
subscribe: vi.fn(async (_channel: string, next: (message: string) => void) => {
listener = next;
}),
unsubscribe: vi.fn(async () => undefined),
};
const onFlush = vi.fn(
async () =>
new Promise<void>((resolve) => {
release = resolve;
})
);
const subscriber = new RedisGatewayFlushSubscriber(client, 'flush', new InMemoryFlushStore(), onFlush);
await subscriber.start();
listener?.(JSON.stringify({ userId: 'user-1', flushedAt: '2026-07-31T09:00:00.001Z' }));
await vi.waitFor(() => expect(onFlush).toHaveBeenCalledOnce());
let stopped = false;
const stopping = subscriber.stop().then(() => {
stopped = true;
});
await vi.waitFor(() => expect(client.unsubscribe).toHaveBeenCalledOnce());
expect(stopped).toBe(false);
release?.();
await stopping;
expect(stopped).toBe(true);
});
});
+240 -4
View File
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import type {
DatabaseClient,
@@ -15,7 +15,7 @@ import { InMemoryTurnDaemonTransport } from '../src/daemon/inMemoryTransport.js'
import { InMemoryFlushStore } from '../src/auth/flushStore.js';
import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
import { appRouter } from '../src/router.js';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import { encryptGameSessionToken, type GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
const profile: GameProfile = {
id: 'che',
@@ -114,6 +114,9 @@ const buildContext = (options?: {
generalTurnWrites?: unknown[];
nationTurnWrites?: unknown[];
auth?: GameSessionTokenPayload | null;
currentAccountIcon?: unknown;
accountIconGet?: (userId: string) => Promise<unknown>;
accessTokenStore?: RedisAccessTokenStore;
worldStateReads?: { count: number };
}): GameApiContext => {
const transport = options?.transport ?? new InMemoryTurnDaemonTransport();
@@ -227,10 +230,30 @@ const buildContext = (options?: {
uploadDir: 'uploads',
uploadPath: '/uploads',
uploadPublicUrl: null,
redis: {} as unknown as RedisConnector['client'],
accessTokenStore,
redis: {
get: async () => null,
} as unknown as RedisConnector['client'],
accessTokenStore: options?.accessTokenStore ?? accessTokenStore,
flushStore: new InMemoryFlushStore(),
gameTokenSecret: 'test-secret',
accountIconSource: {
get: async (userId: string) => {
if (options?.accountIconGet) {
return (await options.accountIconGet(userId)) as {
revision: string;
picture: string;
imageServer: number;
} | null;
}
return options?.currentAccountIcon === undefined
? null
: (options.currentAccountIcon as {
revision: string;
picture: string;
imageServer: number;
});
},
},
};
};
@@ -280,6 +303,140 @@ describe('appRouter', () => {
await expect(caller.auth.status()).resolves.toEqual({ userId: 'user-1' });
});
it('keeps ordinary account icon changes on the explicitly selected servers', async () => {
const transport = new InMemoryTurnDaemonTransport();
const accountIconGet = vi.fn(async () => {
throw new Error('ordinary exchange must not read the icon source');
});
const accessTokenStore = {
markGatewayTokenUsed: vi.fn(async () => true),
create: vi.fn(async () => ({
accessToken: 'ga_access',
expiresAt: '2099-01-01T01:00:00.000Z',
})),
} as unknown as RedisAccessTokenStore;
const payload = buildAuth();
payload.issuedAt = '2099-01-01T00:00:00.000Z';
payload.expiresAt = '2099-01-01T01:00:00.000Z';
payload.user.iconUpdatedAt = '2099-01-01T00:00:00.000Z';
const caller = appRouter.createCaller(
buildContext({
auth: null,
transport,
accountIconGet,
accessTokenStore,
})
);
await expect(
caller.auth.exchangeGatewayToken({
gatewayToken: encryptGameSessionToken(payload, 'test-secret'),
})
).resolves.toMatchObject({ accessToken: 'ga_access' });
expect(accountIconGet).not.toHaveBeenCalled();
expect(transport.commands).toHaveLength(0);
});
it('durably re-applies an administrator reset before consuming the one-time token', async () => {
const transport = new InMemoryTurnDaemonTransport();
const calls: string[] = [];
const revision = '2099-01-01T00:00:00.001Z';
const accessTokenStore = {
markGatewayTokenUsed: vi.fn(async () => {
calls.push('mark-used');
return true;
}),
create: vi.fn(async () => ({
accessToken: 'ga_access',
expiresAt: '2099-01-01T01:00:00.000Z',
})),
} as unknown as RedisAccessTokenStore;
const payload = buildAuth();
payload.issuedAt = '2099-01-01T00:00:00.000Z';
payload.expiresAt = '2099-01-01T01:00:00.000Z';
payload.user.iconUpdatedAt = revision;
payload.user.profileIconResetAt = revision;
const caller = appRouter.createCaller(
buildContext({
auth: null,
transport,
accessTokenStore,
accountIconGet: async () => {
calls.push('projection');
return {
revision,
picture: 'default.jpg',
imageServer: 0,
};
},
})
);
await caller.auth.exchangeGatewayToken({
gatewayToken: encryptGameSessionToken(payload, 'test-secret'),
});
expect(calls).toEqual(['projection', 'mark-used']);
expect(transport.commands.at(-1)?.command).toEqual({
type: 'adjustGeneralIcon',
requestId: `general:adjustIcon:${payload.user.id}:${revision}`,
userId: payload.user.id,
picture: 'default.jpg',
imageServer: 0,
iconRevision: revision,
});
});
it('applies the current Gateway database icon instead of stale token claims', async () => {
const transport = new InMemoryTurnDaemonTransport();
const currentAccountIcon = {
revision: '2026-07-31T09:00:00.000Z',
picture: 'latest.png',
imageServer: 1,
};
const auth = buildAuth();
auth.user.picture = 'stale.png';
auth.user.imageServer = 0;
auth.user.iconUpdatedAt = '2026-07-30T09:00:00.000Z';
const requestId = `general:adjustIcon:${auth.user.id}:${currentAccountIcon.revision}`;
transport.setCommandResult(requestId, {
type: 'adjustGeneralIcon',
ok: true,
generalId: 1,
updated: true,
});
const caller = appRouter.createCaller(
buildContext({
auth,
transport,
currentAccountIcon,
})
);
await expect(caller.general.adjustIcon()).resolves.toEqual({
ok: true,
generalId: 1,
updated: true,
});
expect(transport.commands.at(-1)?.command).toEqual({
type: 'adjustGeneralIcon',
requestId,
userId: auth.user.id,
picture: 'latest.png',
imageServer: 1,
iconRevision: currentAccountIcon.revision,
});
});
it('rejects icon adjustment without auth or a current Gateway account', async () => {
await expect(appRouter.createCaller(buildContext({ auth: null })).general.adjustIcon()).rejects.toMatchObject({
code: 'UNAUTHORIZED',
});
await expect(
appRouter.createCaller(buildContext({ auth: buildAuth() })).general.adjustIcon()
).rejects.toMatchObject({ code: 'PRECONDITION_FAILED' });
});
it('rejects unauthenticated or game-blocked auth status checks', async () => {
await expect(appRouter.createCaller(buildContext({ auth: null })).auth.status()).rejects.toMatchObject({
code: 'UNAUTHORIZED',
@@ -356,6 +513,85 @@ describe('appRouter', () => {
expect(worldStateReads.count).toBe(0);
});
it('does not require the Gateway icon source when creating a default-picture general', async () => {
const transport = new InMemoryTurnDaemonTransport();
const clientRequestId = '1b9afacd-d29b-456d-8ef3-a6be4b497e6e';
const requestId = `join-create:user-1:${clientRequestId}`;
transport.setCommandResult(requestId, {
type: 'joinCreateGeneral',
ok: true,
generalId: 41,
});
const accountIconGet = vi.fn(async () => {
throw new Error('default-picture join must not read the icon source');
});
const caller = appRouter.createCaller(
buildContext({
state: buildWorldState(),
transport,
accountIconGet,
})
);
await expect(
caller.join.createGeneral({
name: '기본전콘',
leadership: 55,
strength: 55,
intel: 55,
pic: false,
character: 'Random',
clientRequestId,
})
).resolves.toEqual({ ok: true, generalId: 41 });
expect(accountIconGet).not.toHaveBeenCalled();
expect(transport.commands.at(-1)?.command).not.toHaveProperty('ownerIconRevision');
});
it('uses the authoritative projection instead of stale token claims for picture creation', async () => {
const transport = new InMemoryTurnDaemonTransport();
const clientRequestId = '824454da-d0ab-48d2-a7d5-e2e5aaf83ba4';
const requestId = `join-create:user-1:${clientRequestId}`;
const revision = '2026-07-31T09:00:00.001Z';
transport.setCommandResult(requestId, {
type: 'joinCreateGeneral',
ok: true,
generalId: 42,
});
const auth = buildAuth();
auth.user.picture = 'stale.png';
auth.user.imageServer = 0;
auth.user.iconUpdatedAt = '2026-07-30T09:00:00.000Z';
const caller = appRouter.createCaller(
buildContext({
state: buildWorldState(),
auth,
transport,
currentAccountIcon: {
revision,
picture: 'latest.png',
imageServer: 1,
},
})
);
await caller.join.createGeneral({
name: '최신전콘',
leadership: 55,
strength: 55,
intel: 55,
pic: true,
character: 'Random',
clientRequestId,
});
expect(transport.commands.at(-1)?.command).toMatchObject({
ownerPicture: 'latest.png',
ownerImageServer: 1,
ownerIconRevision: revision,
});
});
it('queues turn daemon run commands', async () => {
const transport = new InMemoryTurnDaemonTransport();
const caller = appRouter.createCaller(buildContext({ transport }));