fix: 공개 API와 토너먼트의 사용자·장수 권한 경계를 강화한다
This commit is contained in:
@@ -387,7 +387,7 @@ describe('general access tracking', () => {
|
||||
db,
|
||||
generalAccessTracking: true,
|
||||
requestId: 'access-boundary-test',
|
||||
profile: { id: 'che:default', name: 'che' },
|
||||
profile: { id: 'che', name: 'che:default', scenario: 'default' },
|
||||
profileStatusSource,
|
||||
} as unknown as GameApiContext;
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ const buildContext = (executeRaw = vi.fn(async (_query: unknown) => 1), token =
|
||||
auth: token,
|
||||
db: { $executeRaw: executeRaw },
|
||||
generalAccessTracking: true,
|
||||
profile: { id: 'che:default', name: 'che', scenario: 'default' },
|
||||
profile: { id: 'che', name: 'che:default', scenario: 'default' },
|
||||
}) as unknown as GameApiContext;
|
||||
|
||||
const activityRouter = router({
|
||||
|
||||
@@ -31,6 +31,7 @@ const buildContext = (
|
||||
) =>
|
||||
({
|
||||
auth: options.auth === undefined ? auth : options.auth,
|
||||
profile: { id: 'che', name: 'che:default', scenario: 'default' },
|
||||
db: {
|
||||
general: {
|
||||
findFirst: vi.fn(async () => ({
|
||||
|
||||
@@ -36,6 +36,7 @@ type LogQuery = {
|
||||
const buildContext = (findMany: (query: LogQuery) => Promise<Array<{ id: number; text: string }>>) =>
|
||||
({
|
||||
auth,
|
||||
profile: { id: 'che', name: 'che:default', scenario: 'default' },
|
||||
db: {
|
||||
general: {
|
||||
findFirst: vi.fn(async ({ where }: { where: { userId: string } }) => ({
|
||||
|
||||
@@ -20,7 +20,7 @@ const profile: GameProfile = {
|
||||
|
||||
const auth: GameSessionTokenPayload = {
|
||||
version: 1,
|
||||
profile: 'che',
|
||||
profile: 'che:default',
|
||||
issuedAt: '2026-07-26T00:00:00.000Z',
|
||||
expiresAt: '2026-07-27T00:00:00.000Z',
|
||||
sessionId: 'ranking-session',
|
||||
@@ -239,7 +239,7 @@ const buildContext = (options?: {
|
||||
turnDaemon: new InMemoryTurnDaemonTransport(),
|
||||
battleSim: new InMemoryBattleSimTransport(),
|
||||
profile: selectedProfile,
|
||||
auth: options?.authenticated === false ? null : auth,
|
||||
auth: options?.authenticated === false ? null : { ...auth, profile: selectedProfile.name },
|
||||
uploadDir: 'uploads',
|
||||
uploadPath: '/uploads',
|
||||
uploadPublicUrl: null,
|
||||
|
||||
@@ -70,6 +70,7 @@ const envKeys = [
|
||||
'GATEWAY_REDIS_PREFIX',
|
||||
'GATEWAY_INTERNAL_API_URL',
|
||||
'GAME_UPLOAD_DIR',
|
||||
'API_ALLOWED_ORIGINS',
|
||||
] as const;
|
||||
const originalEnv = new Map(envKeys.map((key) => [key, process.env[key]]));
|
||||
|
||||
@@ -642,6 +643,7 @@ integration('game API security over HTTP transport', () => {
|
||||
process.env.GATEWAY_REDIS_PREFIX = redisPrefix;
|
||||
process.env.GATEWAY_INTERNAL_API_URL = await listenGatewayStatusStub();
|
||||
process.env.GAME_UPLOAD_DIR = uploadDir;
|
||||
process.env.API_ALLOWED_ORIGINS = 'https://sam.hided.net';
|
||||
|
||||
const connector = createGamePostgresConnector({ url: dedicatedTarget.databaseUrl });
|
||||
await connector.connect();
|
||||
@@ -3774,6 +3776,58 @@ integration('game API security over HTTP transport', () => {
|
||||
}
|
||||
}, 15_000);
|
||||
|
||||
it('rejects an untrusted browser Origin before an authenticated logout mutation', async () => {
|
||||
const token = await createAccessToken('origin-mutation', {});
|
||||
const response = await fetch(`${baseUrl}/trpc/auth.logout`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
origin: 'https://untrusted.example',
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
expect(response.status).toBe(403);
|
||||
expect(response.headers.get('access-control-allow-origin')).toBeNull();
|
||||
expect(await accessTokenStore.get(token)).not.toBeNull();
|
||||
for (const origin of ['https://sam.hided.net', 'https://untrusted.example', 'null']) {
|
||||
const preflight = await fetch(`${baseUrl}/trpc/auth.logout`, {
|
||||
method: 'OPTIONS',
|
||||
headers: {
|
||||
origin,
|
||||
'access-control-request-method': 'POST',
|
||||
'access-control-request-headers': 'authorization,content-type',
|
||||
},
|
||||
});
|
||||
expect(preflight.status).toBe(origin === 'https://sam.hided.net' ? 204 : 403);
|
||||
expect(preflight.headers.get('access-control-allow-origin')).toBe(
|
||||
origin === 'https://sam.hided.net' ? origin : null
|
||||
);
|
||||
}
|
||||
await accessTokenStore.revoke(token);
|
||||
});
|
||||
|
||||
it('rejects a same-namespace foreign-profile token over tRPC before any DB mutation', async () => {
|
||||
const foreign = await accessTokenStore.create({
|
||||
...buildPayload('trpc-foreign', {}),
|
||||
profile: 'other:default',
|
||||
});
|
||||
if (!foreign) throw new Error('Foreign token fixture failed');
|
||||
const before = await readReservedMutationState();
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${baseUrl}/trpc/turns.reserved.getGeneral?input=${encodeURIComponent(JSON.stringify({ generalId }))}`,
|
||||
{
|
||||
headers: { authorization: `Bearer ${foreign.accessToken}` },
|
||||
}
|
||||
);
|
||||
expect(response.status).toBe(401);
|
||||
expect(await readReservedMutationState()).toEqual(before);
|
||||
} finally {
|
||||
await accessTokenStore.revoke(foreign.accessToken);
|
||||
}
|
||||
});
|
||||
|
||||
it('SSE accepts header auth and rejects query or cross-profile credentials', async () => {
|
||||
const token = await createAccessToken('sse-query', {});
|
||||
const queryOnly = await fetch(`${baseUrl}${server!.config.eventsPath}?token=${encodeURIComponent(token)}`);
|
||||
|
||||
@@ -76,35 +76,21 @@ class TournamentTransport implements TurnDaemonTransport {
|
||||
|
||||
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: '조정 대상이 없습니다.' };
|
||||
if (command.type === 'tournamentAdjustGeneral') {
|
||||
if (command.betGoldDelta !== undefined && this.failNextRankUpdate) {
|
||||
this.failNextRankUpdate = false;
|
||||
return { type: command.type, ok: false, code: 'BAD_REQUEST', reason: 'rank update failed' };
|
||||
}
|
||||
const nextGold = (this.gold.get(adjustment.generalId) ?? 0) + (adjustment.goldDelta ?? 0);
|
||||
if (nextGold < (adjustment.minGoldAfter ?? 0)) {
|
||||
return { type: 'adjustGeneralResources', ok: false, reason: '자원이 부족합니다.' };
|
||||
const nextGold = (this.gold.get(command.generalId) ?? 0) + command.goldDelta;
|
||||
if (nextGold < command.minGoldAfter) {
|
||||
return { type: command.type, ok: false, code: 'BAD_REQUEST', reason: '자원이 부족합니다.' };
|
||||
}
|
||||
this.gold.set(adjustment.generalId, nextGold);
|
||||
return {
|
||||
type: 'adjustGeneralResources',
|
||||
ok: true,
|
||||
processed: 1,
|
||||
missing: 0,
|
||||
totalGoldDelta: adjustment.goldDelta ?? 0,
|
||||
totalRiceDelta: 0,
|
||||
};
|
||||
this.gold.set(command.generalId, nextGold);
|
||||
return { type: command.type, ok: true, generalId: command.generalId };
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -404,12 +390,12 @@ describe('tournament router permissions and mutations', () => {
|
||||
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);
|
||||
expect(transport.commands.filter((command) => command.type === 'tournamentAdjustGeneral')).toHaveLength(1);
|
||||
expect(transport.commands).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: 'adjustGeneralResources',
|
||||
type: 'tournamentAdjustGeneral',
|
||||
requestId: 'http:tournament-join:tournamentJoin:resources',
|
||||
reason: 'tournamentJoin',
|
||||
userId: 'user-1',
|
||||
})
|
||||
);
|
||||
expect(outerApiTransaction).not.toHaveBeenCalled();
|
||||
@@ -459,9 +445,12 @@ describe('tournament router permissions and mutations', () => {
|
||||
await expect(caller.tournament.join()).resolves.toEqual({ ok: true, count: 1 });
|
||||
expect(transport.gold.get(general.id)).toBe(1_936);
|
||||
expect(transport.commands).toContainEqual({
|
||||
type: 'adjustGeneralResources',
|
||||
type: 'tournamentAdjustGeneral',
|
||||
userId: 'user-1',
|
||||
reason: 'tournamentJoin',
|
||||
adjustments: [{ generalId: general.id, goldDelta: -64, minGoldAfter: 0 }],
|
||||
generalId: general.id,
|
||||
goldDelta: -64,
|
||||
minGoldAfter: 0,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -534,21 +523,96 @@ describe('tournament router permissions and mutations', () => {
|
||||
expect((await caller.tournament.getBettingSummary()).myAmount).toBe(600);
|
||||
expect(transport.commands).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: 'adjustGeneralResources',
|
||||
type: 'tournamentAdjustGeneral',
|
||||
requestId: 'http:suspended-tournament-bet:tournamentBet:resources',
|
||||
reason: 'tournamentBet',
|
||||
userId: 'user-1',
|
||||
})
|
||||
);
|
||||
expect(outerApiTransaction).not.toHaveBeenCalled();
|
||||
expect(transport.commands).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: 'adjustGeneralMeta',
|
||||
requestId: 'http:suspended-tournament-bet:tournamentBet:rank',
|
||||
reason: 'tournamentBet',
|
||||
type: 'tournamentAdjustGeneral',
|
||||
userId: 'user-1',
|
||||
betGoldDelta: 600,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the per-user cap across possession and separates a previous owner's bets", async () => {
|
||||
const redis = new MemoryRedis();
|
||||
const transport = new TournamentTransport();
|
||||
const general = buildGeneral(1, 'user-2', 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',
|
||||
});
|
||||
await redis.set(
|
||||
'sammo:che:default:tournament:betting',
|
||||
JSON.stringify([
|
||||
{ generalId: 1, userId: 'user-1', targetId: 11, amount: 900 },
|
||||
{ generalId: 99, userId: 'user-2', targetId: 12, amount: 800 },
|
||||
{ generalId: 98, userId: null, targetId: 11, amount: 100 },
|
||||
])
|
||||
);
|
||||
const caller = appRouter.createCaller(
|
||||
buildContext({ redis, transport, generals: [general], userId: 'user-2' })
|
||||
);
|
||||
expect(await caller.tournament.getBettingSummary()).toMatchObject({
|
||||
myAmount: 800,
|
||||
myTotals: { 12: 800 },
|
||||
totalAmount: 1_800,
|
||||
});
|
||||
await expect(caller.tournament.placeBet({ targetId: 11, amount: 300 })).rejects.toMatchObject({
|
||||
code: 'BAD_REQUEST',
|
||||
});
|
||||
expect(transport.commands).toHaveLength(0);
|
||||
await expect(caller.tournament.placeBet({ targetId: 11, amount: 200 })).resolves.toEqual({ ok: true });
|
||||
expect(await caller.tournament.getBettingSummary()).toMatchObject({
|
||||
myAmount: 1_000,
|
||||
myTotals: { 11: 200, 12: 800 },
|
||||
});
|
||||
expect(transport.gold.get(1)).toBe(2_800);
|
||||
});
|
||||
|
||||
it('does not infer a legacy bet actor from the current general owner', async () => {
|
||||
const redis = new MemoryRedis();
|
||||
const transport = new TournamentTransport();
|
||||
const general = buildGeneral(1, 'user-2', 3_000);
|
||||
transport.gold.set(1, 3_000);
|
||||
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',
|
||||
});
|
||||
await redis.set(
|
||||
'sammo:che:default:tournament:betting',
|
||||
JSON.stringify([{ generalId: 1, targetId: 11, amount: 900 }])
|
||||
);
|
||||
const caller = appRouter.createCaller(
|
||||
buildContext({ redis, transport, generals: [general], userId: 'user-2' })
|
||||
);
|
||||
expect(await caller.tournament.getBettingSummary()).toMatchObject({ myAmount: 0, totalAmount: 900 });
|
||||
await expect(caller.tournament.placeBet({ targetId: 11, amount: 10 })).rejects.toMatchObject({
|
||||
code: 'BAD_REQUEST',
|
||||
});
|
||||
expect(transport.commands).toHaveLength(0);
|
||||
expect(transport.gold.get(1)).toBe(3_000);
|
||||
});
|
||||
|
||||
it('rejects a tournament bet during reconciliation without debiting gold', async () => {
|
||||
const redis = new MemoryRedis();
|
||||
const transport = new TournamentTransport();
|
||||
@@ -796,7 +860,7 @@ describe('tournament router permissions and mutations', () => {
|
||||
expect(snapshot.sourceRevision).toBe('41');
|
||||
});
|
||||
|
||||
it('refunds gold when the tournament bet rank update fails', async () => {
|
||||
it('does not debit gold when the atomic tournament adjustment is rejected', async () => {
|
||||
const redis = new MemoryRedis();
|
||||
const transport = new TournamentTransport();
|
||||
const general = buildGeneral(1, 'user-1', 2_000);
|
||||
@@ -818,7 +882,7 @@ describe('tournament router permissions and mutations', () => {
|
||||
);
|
||||
|
||||
await expect(caller.tournament.placeBet({ targetId: 11, amount: 100 })).rejects.toMatchObject({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
code: 'BAD_REQUEST',
|
||||
});
|
||||
expect(transport.gold.get(general.id)).toBe(2_000);
|
||||
await expect(caller.tournament.getBettingSummary()).resolves.toMatchObject({
|
||||
|
||||
@@ -53,7 +53,15 @@ const isRecord = (value: unknown): value is Record<string, unknown> => typeof va
|
||||
|
||||
const createNoopDaemonTransport = (): TurnDaemonTransport => ({
|
||||
sendCommand: async () => 'ok',
|
||||
requestCommand: async () => null,
|
||||
requestCommand: async (command) =>
|
||||
command.type === 'tournamentSeedNpcBets'
|
||||
? {
|
||||
type: command.type,
|
||||
ok: true,
|
||||
processedGeneralIds: command.bets.map((bet) => bet.generalId),
|
||||
skippedGeneralIds: [],
|
||||
}
|
||||
: null,
|
||||
requestStatus: async () => null,
|
||||
});
|
||||
|
||||
@@ -440,10 +448,10 @@ describe('tournament worker (in-memory)', () => {
|
||||
const commands: TurnDaemonCommand[] = [];
|
||||
const daemonTransport: TurnDaemonTransport = {
|
||||
...createNoopDaemonTransport(),
|
||||
sendCommand: async (command) => {
|
||||
requestCommand: async (command) => {
|
||||
expect((await store.getState())?.stage).toBe(5);
|
||||
commands.push(command);
|
||||
return 'ok';
|
||||
return createNoopDaemonTransport().requestCommand(command);
|
||||
},
|
||||
};
|
||||
const opened = await applyPreBattleStage(store, prisma, state, 'opening-seed', daemonTransport);
|
||||
@@ -455,21 +463,14 @@ describe('tournament worker (in-memory)', () => {
|
||||
expect(new Set(bets.slice(1).map((bet) => bet.targetId)).size).toBeGreaterThan(1);
|
||||
expect(commands).toEqual([
|
||||
{
|
||||
type: 'adjustGeneralResources',
|
||||
requestId: 'tournament:123:npc-bet:resources',
|
||||
reason: 'tournamentNpcBet',
|
||||
adjustments: [3, 4, 5, 6, 7].map((generalId) => ({ generalId, goldDelta: -amount })),
|
||||
},
|
||||
{
|
||||
type: 'adjustGeneralMeta',
|
||||
requestId: 'tournament:123:npc-bet:meta',
|
||||
reason: 'tournamentNpcBet',
|
||||
adjustments: [3, 4, 5, 6, 7].map((generalId) => ({ generalId, metaDelta: { betgold: amount } })),
|
||||
type: 'tournamentSeedNpcBets',
|
||||
requestId: 'tournament:123:npc-bet:atomic',
|
||||
bets: [3, 4, 5, 6, 7].map((generalId) => ({ generalId, amount })),
|
||||
},
|
||||
]);
|
||||
await seedNpcBets({ prisma, store, state: opened, baseSeed: 'opening-seed', daemonTransport });
|
||||
expect(await store.getBettingEntries()).toEqual(bets);
|
||||
expect(commands).toHaveLength(2);
|
||||
expect(commands).toHaveLength(1);
|
||||
// A fresh projection with the same seed and DB inputs has the same choices.
|
||||
await store.setBettingEntries([bets[0]!]);
|
||||
await seedNpcBets({
|
||||
@@ -482,6 +483,32 @@ describe('tournament worker (in-memory)', () => {
|
||||
expect(await store.getBettingEntries()).toEqual(bets);
|
||||
});
|
||||
|
||||
it('publishes only NPC bets acknowledged after the current-owner check', async () => {
|
||||
const store = new TournamentStore(new MemoryRedis(), buildTournamentKeys('npc-owned-after-plan'));
|
||||
const state = createTournamentState({
|
||||
stage: 5,
|
||||
bettingId: 789,
|
||||
npcBettingPlan: [
|
||||
{ generalId: 3, userId: null, targetId: 11, amount: 10 },
|
||||
{ generalId: 4, userId: null, targetId: 12, amount: 10 },
|
||||
],
|
||||
});
|
||||
await store.setState(state);
|
||||
await store.setMatches([{ id: 1, stage: 7, roundIndex: 0, attackerId: 11, defenderId: 12 }]);
|
||||
const transport: TurnDaemonTransport = {
|
||||
...createNoopDaemonTransport(),
|
||||
requestCommand: async (command) => {
|
||||
expect(command.type).toBe('tournamentSeedNpcBets');
|
||||
expect((await store.getState())?.stage).toBe(5);
|
||||
expect(await store.getBettingEntries()).toEqual([]);
|
||||
return { type: 'tournamentSeedNpcBets', ok: true, processedGeneralIds: [4], skippedGeneralIds: [3] };
|
||||
},
|
||||
};
|
||||
const opened = await applyPreBattleStage(store, createPrismaMock({}), state, 'seed', transport);
|
||||
expect(opened.stage).toBe(6);
|
||||
expect(await store.getBettingEntries()).toEqual([{ generalId: 4, userId: null, targetId: 12, amount: 10 }]);
|
||||
});
|
||||
|
||||
it('retries an opening query failure without advancing stage or changing the betting identity', async () => {
|
||||
const store = new TournamentStore(new MemoryRedis(), buildTournamentKeys('npc-opening-retry'));
|
||||
const state = createTournamentState({ stage: 5 });
|
||||
@@ -509,7 +536,7 @@ describe('tournament worker (in-memory)', () => {
|
||||
expect(await store.getBettingEntries()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('reuses durable command identities after a partial enqueue failure', async () => {
|
||||
it('reuses durable command identities after an uncertain atomic command response', async () => {
|
||||
const store = new TournamentStore(new MemoryRedis(), buildTournamentKeys('npc-enqueue-retry'));
|
||||
const state = createTournamentState({ stage: 5, bettingId: 456 });
|
||||
await store.setState(state);
|
||||
@@ -523,15 +550,15 @@ describe('tournament worker (in-memory)', () => {
|
||||
let fail = true;
|
||||
const transport: TurnDaemonTransport = {
|
||||
...createNoopDaemonTransport(),
|
||||
sendCommand: async (command) => {
|
||||
requestCommand: async (command) => {
|
||||
commands.push(command);
|
||||
if (command.type === 'adjustGeneralMeta' && fail) {
|
||||
if (command.type === 'tournamentSeedNpcBets' && fail) {
|
||||
fail = false;
|
||||
// The accepted debit may already have committed before retry.
|
||||
prisma.general.findMany = async () => [];
|
||||
throw new Error('enqueue unavailable');
|
||||
}
|
||||
return 'ok';
|
||||
return createNoopDaemonTransport().requestCommand(command);
|
||||
},
|
||||
};
|
||||
await expect(applyPreBattleStage(store, prisma, state, 'seed', transport)).rejects.toThrow(
|
||||
@@ -539,16 +566,14 @@ describe('tournament worker (in-memory)', () => {
|
||||
);
|
||||
expect(await store.getState()).toMatchObject({
|
||||
stage: 5,
|
||||
npcBettingPlan: [{ generalId: 3, targetId: expect.any(Number), amount: 10 }],
|
||||
npcBettingPlan: [{ generalId: 3, userId: null, targetId: expect.any(Number), amount: 10 }],
|
||||
});
|
||||
expect(await store.getBettingEntries()).toEqual([]);
|
||||
await applyPreBattleStage(store, prisma, (await store.getState())!, 'seed', transport);
|
||||
expect(commands.slice(2)).toEqual(commands.slice(0, 2));
|
||||
expect(commands.slice(1)).toEqual(commands.slice(0, 1));
|
||||
expect(commands.map((command) => command.requestId)).toEqual([
|
||||
'tournament:456:npc-bet:resources',
|
||||
'tournament:456:npc-bet:meta',
|
||||
'tournament:456:npc-bet:resources',
|
||||
'tournament:456:npc-bet:meta',
|
||||
'tournament:456:npc-bet:atomic',
|
||||
'tournament:456:npc-bet:atomic',
|
||||
]);
|
||||
expect(await store.getBettingEntries()).toHaveLength(1);
|
||||
});
|
||||
@@ -577,7 +602,10 @@ describe('tournament worker (in-memory)', () => {
|
||||
commands.push(command);
|
||||
return 'ok';
|
||||
},
|
||||
requestCommand: async () => null,
|
||||
requestCommand: async (command) => {
|
||||
commands.push(command);
|
||||
return createNoopDaemonTransport().requestCommand(command);
|
||||
},
|
||||
requestStatus: async () => null,
|
||||
};
|
||||
|
||||
@@ -598,11 +626,8 @@ describe('tournament worker (in-memory)', () => {
|
||||
expect(rankedGeneralIds.size).toBeGreaterThanOrEqual(10);
|
||||
expect(commands).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: 'adjustGeneralMeta',
|
||||
reason: 'tournamentNpcBet',
|
||||
adjustments: expect.arrayContaining([
|
||||
expect.objectContaining({ metaDelta: { betgold: expect.any(Number) } }),
|
||||
]),
|
||||
type: 'tournamentSeedNpcBets',
|
||||
bets: expect.arrayContaining([expect.objectContaining({ amount: expect.any(Number) })]),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user