337 lines
13 KiB
TypeScript
337 lines
13 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
|
|
import type { TurnDaemonCommand, TurnDaemonCommandResult } from '@sammo-ts/common';
|
|
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
|
import type { RedisConnector } from '@sammo-ts/infra';
|
|
|
|
import { InMemoryFlushStore } from '../src/auth/flushStore.js';
|
|
import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
|
|
import type { DatabaseClient, GameApiContext, GeneralRow } from '../src/context.js';
|
|
import type { TurnDaemonTransport } from '../src/daemon/transport.js';
|
|
import { appRouter } from '../src/router.js';
|
|
|
|
class MemoryRedis {
|
|
private readonly values = new Map<string, string>();
|
|
|
|
async get(key: string): Promise<string | null> {
|
|
return this.values.get(key) ?? null;
|
|
}
|
|
|
|
async set(key: string, value: string, options?: { NX?: boolean }): Promise<string | null> {
|
|
if (options?.NX && this.values.has(key)) {
|
|
return null;
|
|
}
|
|
this.values.set(key, value);
|
|
return 'OK';
|
|
}
|
|
|
|
async del(key: string): Promise<number> {
|
|
return this.values.delete(key) ? 1 : 0;
|
|
}
|
|
}
|
|
|
|
class TournamentTransport implements TurnDaemonTransport {
|
|
readonly commands: TurnDaemonCommand[] = [];
|
|
readonly gold = new Map<number, number>();
|
|
failNextRankUpdate = false;
|
|
|
|
async sendCommand(command: TurnDaemonCommand): Promise<string> {
|
|
this.commands.push(command);
|
|
return `command-${this.commands.length}`;
|
|
}
|
|
|
|
async requestCommand(command: TurnDaemonCommand): Promise<TurnDaemonCommandResult | null> {
|
|
this.commands.push(command);
|
|
if (command.type === 'adjustGeneralResources') {
|
|
const adjustment = command.adjustments[0];
|
|
if (!adjustment) {
|
|
return { type: 'adjustGeneralResources', ok: false, reason: '조정 대상이 없습니다.' };
|
|
}
|
|
const nextGold = (this.gold.get(adjustment.generalId) ?? 0) + (adjustment.goldDelta ?? 0);
|
|
if (nextGold < (adjustment.minGoldAfter ?? 0)) {
|
|
return { type: 'adjustGeneralResources', ok: false, reason: '자원이 부족합니다.' };
|
|
}
|
|
this.gold.set(adjustment.generalId, nextGold);
|
|
return {
|
|
type: 'adjustGeneralResources',
|
|
ok: true,
|
|
processed: 1,
|
|
missing: 0,
|
|
totalGoldDelta: adjustment.goldDelta ?? 0,
|
|
totalRiceDelta: 0,
|
|
};
|
|
}
|
|
if (command.type === 'setMySetting') {
|
|
return { type: 'setMySetting', ok: true, generalId: command.generalId };
|
|
}
|
|
if (command.type === 'adjustGeneralMeta') {
|
|
if (this.failNextRankUpdate) {
|
|
this.failNextRankUpdate = false;
|
|
return { type: 'adjustGeneralMeta', ok: false, reason: 'rank update failed' };
|
|
}
|
|
return { type: 'adjustGeneralMeta', ok: true, processed: 1, missing: 0 };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async requestStatus() {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
const buildGeneral = (id: number, userId: string, gold = 2_000): GeneralRow =>
|
|
({
|
|
id,
|
|
userId,
|
|
name: `장수${id}`,
|
|
leadership: 70 + id,
|
|
strength: 60 + id,
|
|
intel: 50 + id,
|
|
gold,
|
|
meta: { explevel: 5 },
|
|
}) as unknown as GeneralRow;
|
|
|
|
const buildAuth = (userId: string, roles: string[] = []): GameSessionTokenPayload => ({
|
|
version: 1,
|
|
profile: 'che:default',
|
|
issuedAt: '2026-07-26T00:00:00.000Z',
|
|
expiresAt: '2026-07-27T00:00:00.000Z',
|
|
sessionId: `session-${userId}`,
|
|
user: {
|
|
id: userId,
|
|
username: userId,
|
|
displayName: userId,
|
|
roles,
|
|
},
|
|
sanctions: {},
|
|
});
|
|
|
|
const buildContext = (options: {
|
|
redis: MemoryRedis;
|
|
transport: TournamentTransport;
|
|
generals: GeneralRow[];
|
|
userId: string;
|
|
roles?: string[];
|
|
develCost?: number;
|
|
rankRows?: Array<{ generalId: number; type: string; value: number }>;
|
|
}): GameApiContext => {
|
|
const db = {
|
|
general: {
|
|
findFirst: async ({ where }: { where: { userId: string } }) =>
|
|
options.generals.find((general) => general.userId === where.userId) ?? null,
|
|
findMany: async ({ where }: { where: { id: { in: number[] } } }) =>
|
|
options.generals.filter((general) => where.id.in.includes(general.id)),
|
|
},
|
|
rankData: {
|
|
findMany: async () => options.rankRows ?? [],
|
|
},
|
|
worldState: {
|
|
findFirst: async () => ({ config: { const: { develCost: options.develCost ?? 200 } } }),
|
|
},
|
|
} as unknown as DatabaseClient;
|
|
return {
|
|
db,
|
|
redis: options.redis as unknown as RedisConnector['client'],
|
|
turnDaemon: options.transport,
|
|
battleSim: {} as GameApiContext['battleSim'],
|
|
profile: { id: 'che', scenario: 'default', name: 'che:default' },
|
|
uploadDir: 'uploads',
|
|
uploadPath: '/uploads',
|
|
uploadPublicUrl: null,
|
|
auth: buildAuth(options.userId, options.roles),
|
|
accessTokenStore: new RedisAccessTokenStore(options.redis, 'che:default'),
|
|
flushStore: new InMemoryFlushStore(),
|
|
gameTokenSecret: 'test-secret',
|
|
};
|
|
};
|
|
|
|
const setTournamentFixture = async (redis: MemoryRedis, state: Record<string, unknown>) => {
|
|
const prefix = 'sammo:che:default:tournament';
|
|
await redis.set(`${prefix}:state`, JSON.stringify(state));
|
|
await redis.set(
|
|
`${prefix}:participants`,
|
|
JSON.stringify([
|
|
{ id: 11, name: '후보11', leadership: 80, strength: 70, intel: 60, level: 5 },
|
|
{ id: 12, name: '후보12', leadership: 70, strength: 80, intel: 60, level: 5 },
|
|
])
|
|
);
|
|
await redis.set(
|
|
`${prefix}:matches`,
|
|
JSON.stringify([{ id: 1, stage: 7, roundIndex: 0, attackerId: 11, defenderId: 12 }])
|
|
);
|
|
await redis.set(`${prefix}:betting`, '[]');
|
|
};
|
|
|
|
describe('tournament router permissions and mutations', () => {
|
|
it('charges the authenticated general once when joining', async () => {
|
|
const redis = new MemoryRedis();
|
|
const transport = new TournamentTransport();
|
|
const general = buildGeneral(1, 'user-1');
|
|
transport.gold.set(general.id, general.gold);
|
|
await setTournamentFixture(redis, {
|
|
stage: 1,
|
|
phase: 0,
|
|
type: 0,
|
|
auto: true,
|
|
openYear: 193,
|
|
openMonth: 1,
|
|
termSeconds: 60,
|
|
nextAt: '2026-07-26T01:00:00.000Z',
|
|
});
|
|
await redis.set('sammo:che:default:tournament:participants', '[]');
|
|
const caller = appRouter.createCaller(
|
|
buildContext({ redis, transport, generals: [general], userId: 'user-1', develCost: 200 })
|
|
);
|
|
|
|
await expect(caller.tournament.join()).resolves.toEqual({ ok: true, count: 1 });
|
|
await expect(caller.tournament.join()).resolves.toEqual({ ok: true, count: 1 });
|
|
|
|
expect(transport.gold.get(general.id)).toBe(1_800);
|
|
expect(transport.commands.filter((command) => command.type === 'adjustGeneralResources')).toHaveLength(1);
|
|
});
|
|
|
|
it('serializes concurrent bets and enforces the legacy per-user 1000 limit', async () => {
|
|
const redis = new MemoryRedis();
|
|
const transport = new TournamentTransport();
|
|
const general = buildGeneral(1, 'user-1', 3_000);
|
|
transport.gold.set(general.id, general.gold);
|
|
await setTournamentFixture(redis, {
|
|
stage: 6,
|
|
phase: 0,
|
|
type: 0,
|
|
auto: true,
|
|
openYear: 193,
|
|
openMonth: 1,
|
|
termSeconds: 60,
|
|
nextAt: '2026-07-26T01:00:00.000Z',
|
|
bettingCloseAt: '2099-01-01T00:00:00.000Z',
|
|
});
|
|
const caller = appRouter.createCaller(
|
|
buildContext({ redis, transport, generals: [general], userId: 'user-1' })
|
|
);
|
|
|
|
const results = await Promise.allSettled([
|
|
caller.tournament.placeBet({ targetId: 11, amount: 600 }),
|
|
caller.tournament.placeBet({ targetId: 12, amount: 600 }),
|
|
]);
|
|
|
|
expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1);
|
|
expect(results.filter((result) => result.status === 'rejected')).toHaveLength(1);
|
|
const summary = await caller.tournament.getBettingSummary();
|
|
expect(summary.myAmount).toBe(600);
|
|
expect(summary.totalAmount).toBe(600);
|
|
expect(transport.gold.get(general.id)).toBe(2_400);
|
|
});
|
|
|
|
it('keeps another user from reading my bet identity and requires that user to own a general', async () => {
|
|
const redis = new MemoryRedis();
|
|
const transport = new TournamentTransport();
|
|
const owner = buildGeneral(1, 'user-1');
|
|
const other = buildGeneral(2, 'user-2');
|
|
await setTournamentFixture(redis, {
|
|
stage: 6,
|
|
phase: 0,
|
|
type: 0,
|
|
auto: true,
|
|
openYear: 193,
|
|
openMonth: 1,
|
|
termSeconds: 60,
|
|
nextAt: '2026-07-26T01:00:00.000Z',
|
|
});
|
|
await redis.set(
|
|
'sammo:che:default:tournament:betting',
|
|
JSON.stringify([{ generalId: owner.id, targetId: 11, amount: 300 }])
|
|
);
|
|
|
|
const otherCaller = appRouter.createCaller(
|
|
buildContext({ redis, transport, generals: [owner, other], userId: 'user-2' })
|
|
);
|
|
const snapshot = await otherCaller.tournament.getSnapshot();
|
|
expect(snapshot).toMatchObject({ betCount: 1 });
|
|
expect(snapshot).not.toHaveProperty('bets');
|
|
expect((await otherCaller.tournament.getBettingSummary()).myAmount).toBe(0);
|
|
|
|
const generalLessCaller = appRouter.createCaller(
|
|
buildContext({ redis, transport, generals: [owner], userId: 'user-3' })
|
|
);
|
|
await expect(generalLessCaller.tournament.getSnapshot()).rejects.toMatchObject({ code: 'NOT_FOUND' });
|
|
await expect(generalLessCaller.tournament.join()).rejects.toMatchObject({ code: 'NOT_FOUND' });
|
|
});
|
|
|
|
it('limits tournament administration to global or profile-scoped roles', async () => {
|
|
const redis = new MemoryRedis();
|
|
const transport = new TournamentTransport();
|
|
const general = buildGeneral(1, 'user-1');
|
|
const userCaller = appRouter.createCaller(
|
|
buildContext({ redis, transport, generals: [general], userId: 'user-1', roles: ['user'] })
|
|
);
|
|
await expect(userCaller.tournament.getAdminStatus()).rejects.toMatchObject({ code: 'FORBIDDEN' });
|
|
|
|
const adminCaller = appRouter.createCaller(
|
|
buildContext({
|
|
redis,
|
|
transport,
|
|
generals: [general],
|
|
userId: 'user-1',
|
|
roles: ['admin.tournament:che:default'],
|
|
})
|
|
);
|
|
await expect(adminCaller.tournament.getAdminStatus()).resolves.toEqual({ ok: true });
|
|
});
|
|
|
|
it('returns the legacy tournament rank ordering only to a user who owns a general', async () => {
|
|
const redis = new MemoryRedis();
|
|
const transport = new TournamentTransport();
|
|
const first = buildGeneral(1, 'user-1');
|
|
const second = buildGeneral(2, 'user-2');
|
|
const rankRows = [
|
|
{ generalId: first.id, type: 'ttg', value: 20 },
|
|
{ generalId: first.id, type: 'ttw', value: 3 },
|
|
{ generalId: second.id, type: 'ttg', value: 20 },
|
|
{ generalId: second.id, type: 'ttw', value: 4 },
|
|
];
|
|
const ownerCaller = appRouter.createCaller(
|
|
buildContext({ redis, transport, generals: [first, second], userId: 'user-1', rankRows })
|
|
);
|
|
const sections = await ownerCaller.tournament.getRankings();
|
|
expect(sections).toHaveLength(4);
|
|
expect(sections[0]?.entries.map((entry) => entry.generalId)).toEqual([second.id, first.id]);
|
|
|
|
const generalLessCaller = appRouter.createCaller(
|
|
buildContext({ redis, transport, generals: [first, second], userId: 'user-3', rankRows })
|
|
);
|
|
await expect(generalLessCaller.tournament.getRankings()).rejects.toMatchObject({ code: 'NOT_FOUND' });
|
|
});
|
|
|
|
it('refunds gold when the tournament bet rank update fails', async () => {
|
|
const redis = new MemoryRedis();
|
|
const transport = new TournamentTransport();
|
|
const general = buildGeneral(1, 'user-1', 2_000);
|
|
transport.gold.set(general.id, general.gold);
|
|
transport.failNextRankUpdate = true;
|
|
await setTournamentFixture(redis, {
|
|
stage: 6,
|
|
phase: 0,
|
|
type: 0,
|
|
auto: true,
|
|
openYear: 193,
|
|
openMonth: 1,
|
|
termSeconds: 60,
|
|
nextAt: '2026-07-26T01:00:00.000Z',
|
|
bettingCloseAt: '2099-01-01T00:00:00.000Z',
|
|
});
|
|
const caller = appRouter.createCaller(
|
|
buildContext({ redis, transport, generals: [general], userId: 'user-1' })
|
|
);
|
|
|
|
await expect(caller.tournament.placeBet({ targetId: 11, amount: 100 })).rejects.toMatchObject({
|
|
code: 'INTERNAL_SERVER_ERROR',
|
|
});
|
|
expect(transport.gold.get(general.id)).toBe(2_000);
|
|
await expect(caller.tournament.getBettingSummary()).resolves.toMatchObject({
|
|
totalAmount: 0,
|
|
myAmount: 0,
|
|
});
|
|
});
|
|
});
|