Merge branch 'main' into feature/general-access-tracking
This commit is contained in:
@@ -0,0 +1,308 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||
import type { GamePrisma, RedisConnector } from '@sammo-ts/infra';
|
||||
|
||||
import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
|
||||
import { InMemoryFlushStore } from '../src/auth/flushStore.js';
|
||||
import type { DatabaseClient, GameApiContext, GeneralRow } from '../src/context.js';
|
||||
import type { TurnDaemonTransport } from '../src/daemon/transport.js';
|
||||
import { appRouter } from '../src/router.js';
|
||||
|
||||
const buildGeneral = (overrides: Partial<GeneralRow> = {}): GeneralRow => ({
|
||||
id: 7,
|
||||
userId: 'user-1',
|
||||
name: '유비',
|
||||
nationId: 1,
|
||||
cityId: 1,
|
||||
troopId: 0,
|
||||
npcState: 0,
|
||||
affinity: null,
|
||||
bornYear: 180,
|
||||
deadYear: 300,
|
||||
picture: null,
|
||||
imageServer: 0,
|
||||
leadership: 50,
|
||||
strength: 50,
|
||||
intel: 50,
|
||||
injury: 0,
|
||||
experience: 0,
|
||||
dedication: 0,
|
||||
officerLevel: 1,
|
||||
gold: 10_000,
|
||||
rice: 10_000,
|
||||
crew: 0,
|
||||
crewTypeId: 0,
|
||||
train: 0,
|
||||
atmos: 0,
|
||||
weaponCode: 'None',
|
||||
bookCode: 'None',
|
||||
horseCode: 'None',
|
||||
itemCode: 'None',
|
||||
turnTime: new Date('2026-07-26T00:00:00Z'),
|
||||
recentWarTime: null,
|
||||
age: 20,
|
||||
startAge: 20,
|
||||
personalCode: 'None',
|
||||
specialCode: 'None',
|
||||
special2Code: 'None',
|
||||
lastTurn: {},
|
||||
meta: {},
|
||||
penalty: {},
|
||||
createdAt: new Date('2026-07-26T00:00:00Z'),
|
||||
updatedAt: new Date('2026-07-26T00:00:00Z'),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const buildAuth = (userId = 'user-1'): 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 sqlText = (query: GamePrisma.Sql): string => query.strings.join(' ');
|
||||
|
||||
const buildContext = (options: {
|
||||
auth?: GameSessionTokenPayload | null;
|
||||
general?: GeneralRow | null;
|
||||
auctions?: Array<Record<string, unknown>>;
|
||||
queryRaw?: (query: GamePrisma.Sql) => Promise<unknown>;
|
||||
}) => {
|
||||
const auth = options.auth === undefined ? buildAuth() : options.auth;
|
||||
const general = options.general === undefined ? buildGeneral() : options.general;
|
||||
const requestCommand = vi.fn(async (command: { type: string }) => {
|
||||
if (command.type === 'auctionOpen') {
|
||||
return {
|
||||
type: 'auctionOpen' as const,
|
||||
ok: true as const,
|
||||
auctionId: 91,
|
||||
closeAt: '2026-07-27T00:00:00.000Z',
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: 'auctionBid' as const,
|
||||
ok: true as const,
|
||||
auctionId: 91,
|
||||
closeAt: '2026-07-27T00:00:00.000Z',
|
||||
};
|
||||
});
|
||||
const queryRaw = vi.fn(options.queryRaw ?? (async () => []));
|
||||
const worldState = {
|
||||
id: 1,
|
||||
scenarioCode: 'default',
|
||||
currentYear: 200,
|
||||
currentMonth: 1,
|
||||
tickSeconds: 3600,
|
||||
config: {
|
||||
const: {
|
||||
auctionName: ['청룡', '백호', '주작', '현무'],
|
||||
allItems: { weapon: { che_무기_12_칠성검: 1 } },
|
||||
},
|
||||
},
|
||||
meta: { hiddenSeed: 'auction-hidden-seed' },
|
||||
updatedAt: new Date('2026-07-26T00:00:00Z'),
|
||||
};
|
||||
const db = {
|
||||
$queryRaw: queryRaw,
|
||||
general: {
|
||||
findFirst: vi.fn(async ({ where }: { where: { userId: string } }) =>
|
||||
general?.userId === where.userId ? general : null
|
||||
),
|
||||
findMany: vi.fn(async ({ where }: { where: { id: { in: number[] } } }) =>
|
||||
where.id.in.map((id) => ({ id, name: id === 88 ? '관우' : '조조' }))
|
||||
),
|
||||
},
|
||||
auction: {
|
||||
findMany: vi.fn(async () => options.auctions ?? []),
|
||||
findFirst: vi.fn(async () => null),
|
||||
},
|
||||
worldState: {
|
||||
findFirst: vi.fn(async () => worldState),
|
||||
},
|
||||
inheritancePoint: {
|
||||
findUnique: vi.fn(async () => ({ value: 10_000 })),
|
||||
},
|
||||
logEntry: {
|
||||
findMany: vi.fn(async () => []),
|
||||
},
|
||||
};
|
||||
const redis = {
|
||||
zAdd: vi.fn(async () => 1),
|
||||
};
|
||||
const accessTokenStore = new RedisAccessTokenStore(
|
||||
{
|
||||
get: async () => null,
|
||||
set: async () => null,
|
||||
},
|
||||
'che:default'
|
||||
);
|
||||
const context: GameApiContext = {
|
||||
db: db as unknown as DatabaseClient,
|
||||
redis: redis as unknown as RedisConnector['client'],
|
||||
turnDaemon: { requestCommand } as unknown as TurnDaemonTransport,
|
||||
battleSim: {} as GameApiContext['battleSim'],
|
||||
profile: { id: 'che', scenario: 'default', name: 'che:default' },
|
||||
auth,
|
||||
uploadDir: 'uploads',
|
||||
uploadPath: '/uploads',
|
||||
uploadPublicUrl: null,
|
||||
accessTokenStore,
|
||||
flushStore: new InMemoryFlushStore(),
|
||||
gameTokenSecret: 'test-secret',
|
||||
};
|
||||
return { context, db, queryRaw, redis, requestCommand };
|
||||
};
|
||||
|
||||
describe('auction router actor and permission boundaries', () => {
|
||||
it('rejects unauthenticated auction reads', async () => {
|
||||
const fixture = buildContext({ auth: null });
|
||||
|
||||
await expect(appRouter.createCaller(fixture.context).auction.getOverview()).rejects.toMatchObject({
|
||||
code: 'UNAUTHORIZED',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects reads and mutations when the authenticated user owns no general', async () => {
|
||||
const fixture = buildContext({
|
||||
auth: buildAuth('user-2'),
|
||||
general: buildGeneral({ userId: 'user-1' }),
|
||||
});
|
||||
const caller = appRouter.createCaller(fixture.context);
|
||||
|
||||
await expect(caller.auction.getOverview()).rejects.toMatchObject({
|
||||
code: 'UNAUTHORIZED',
|
||||
message: 'General not found.',
|
||||
});
|
||||
await expect(
|
||||
caller.auction.openBuyRice({
|
||||
amount: 1000,
|
||||
closeTurnCnt: 3,
|
||||
startBidAmount: 500,
|
||||
finishBidAmount: 2000,
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: 'UNAUTHORIZED',
|
||||
message: 'General not found.',
|
||||
});
|
||||
expect(fixture.requestCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('derives the daemon actor from the session-owned general and ignores a forged generalId field', async () => {
|
||||
const fixture = buildContext({ general: buildGeneral({ id: 7, userId: 'user-1' }) });
|
||||
const input = {
|
||||
amount: 1000,
|
||||
closeTurnCnt: 3,
|
||||
startBidAmount: 500,
|
||||
finishBidAmount: 2000,
|
||||
generalId: 999,
|
||||
};
|
||||
|
||||
await appRouter.createCaller(fixture.context).auction.openBuyRice(input);
|
||||
|
||||
expect(fixture.requestCommand).toHaveBeenCalledWith({
|
||||
type: 'auctionOpen',
|
||||
auctionType: 'BUY_RICE',
|
||||
generalId: 7,
|
||||
amount: 1000,
|
||||
closeTurnCnt: 3,
|
||||
startBidAmount: 500,
|
||||
finishBidAmount: 2000,
|
||||
});
|
||||
});
|
||||
|
||||
it('redacts real unique-auction identities while preserving caller markers', async () => {
|
||||
const openedAt = new Date('2026-07-26T01:00:00Z');
|
||||
const fixture = buildContext({
|
||||
auctions: [
|
||||
{
|
||||
id: 31,
|
||||
type: 'UNIQUE_ITEM',
|
||||
targetCode: 'che_무기_12_칠성검',
|
||||
hostGeneralId: 7,
|
||||
hostName: null,
|
||||
detail: { title: '칠성검 경매', startBidAmount: 5000 },
|
||||
status: 'OPEN',
|
||||
closeAt: new Date('2026-07-27T00:00:00Z'),
|
||||
bids: [
|
||||
{
|
||||
id: 41,
|
||||
generalId: 88,
|
||||
amount: 5500,
|
||||
eventAt: openedAt,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await appRouter.createCaller(fixture.context).auction.getOverview();
|
||||
const unique = result.uniqueAuctions[0];
|
||||
|
||||
expect(unique).toMatchObject({
|
||||
id: 31,
|
||||
hostGeneralId: null,
|
||||
isCallerHost: true,
|
||||
highestBid: { amount: 5500, isCaller: false },
|
||||
});
|
||||
expect(unique?.hostName).not.toBe('유비');
|
||||
expect(unique?.highestBid?.bidderName).not.toBe('관우');
|
||||
expect(JSON.stringify(unique)).not.toContain('"generalId"');
|
||||
expect(JSON.stringify(unique)).not.toContain('"hostGeneralId":7');
|
||||
});
|
||||
|
||||
it('keeps the legacy default of no requested close extension for a unique bid', async () => {
|
||||
const fixture = buildContext({
|
||||
queryRaw: async (query) => {
|
||||
const text = sqlText(query);
|
||||
if (text.includes('FROM auction') && text.includes('WHERE id =')) {
|
||||
return [
|
||||
{
|
||||
id: 31,
|
||||
type: 'UNIQUE_ITEM',
|
||||
targetCode: 'che_무기_12_칠성검',
|
||||
hostGeneralId: 88,
|
||||
detail: { startBidAmount: 100, isReverse: false },
|
||||
status: 'OPEN',
|
||||
closeAt: new Date('2026-07-27T00:00:00Z'),
|
||||
},
|
||||
];
|
||||
}
|
||||
if (text.includes('FROM auction_bid') && text.includes('general_id =')) {
|
||||
return [];
|
||||
}
|
||||
if (text.includes('SELECT bid.auction_id')) {
|
||||
return [{ auctionId: 31, generalId: 88, amount: 100 }];
|
||||
}
|
||||
if (text.includes('FROM auction_bid')) {
|
||||
return [{ id: 41, generalId: 88, amount: 100, meta: {} }];
|
||||
}
|
||||
if (text.includes('SELECT id, target_code')) {
|
||||
return [{ id: 31, targetCode: 'che_무기_12_칠성검' }];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
});
|
||||
|
||||
await appRouter.createCaller(fixture.context).auction.bidUnique({
|
||||
auctionId: 31,
|
||||
amount: 110,
|
||||
});
|
||||
|
||||
expect(fixture.requestCommand).toHaveBeenCalledWith({
|
||||
type: 'auctionBid',
|
||||
auctionId: 31,
|
||||
generalId: 7,
|
||||
amount: 110,
|
||||
tryExtendCloseDate: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -30,7 +30,7 @@ const auth: GameSessionTokenPayload = {
|
||||
sanctions: {},
|
||||
};
|
||||
|
||||
const buildContext = (overrides: Record<string, unknown> = {}) => {
|
||||
const buildContext = (overrides: Record<string, unknown> = {}, contextOverrides: Record<string, unknown> = {}) => {
|
||||
const executeRaw = vi.fn(async () => 1);
|
||||
const updateMany = vi.fn(async () => ({ count: 1 }));
|
||||
const db = {
|
||||
@@ -55,11 +55,15 @@ const buildContext = (overrides: Record<string, unknown> = {}) => {
|
||||
$executeRaw: executeRaw,
|
||||
...overrides,
|
||||
};
|
||||
const redis = {
|
||||
set: vi.fn(async () => 'OK'),
|
||||
publish: vi.fn(async () => 1),
|
||||
};
|
||||
const context = {
|
||||
db,
|
||||
auth,
|
||||
profile: { id: 'che', scenario: 'default', name: 'che:default' },
|
||||
redis: {},
|
||||
redis,
|
||||
turnDaemon: {},
|
||||
battleSim: {},
|
||||
uploadDir: 'uploads',
|
||||
@@ -68,8 +72,9 @@ const buildContext = (overrides: Record<string, unknown> = {}) => {
|
||||
accessTokenStore: {},
|
||||
flushStore: {},
|
||||
gameTokenSecret: 'test-secret',
|
||||
...contextOverrides,
|
||||
} as unknown as GameApiContext;
|
||||
return { caller: appRouter.createCaller(context), db, executeRaw, updateMany };
|
||||
return { caller: appRouter.createCaller(context), db, executeRaw, updateMany, redis };
|
||||
};
|
||||
|
||||
describe('messages router missing-flow compatibility', () => {
|
||||
@@ -99,6 +104,291 @@ describe('messages router missing-flow compatibility', () => {
|
||||
expect(result.canRespondDiplomacy).toBe(true);
|
||||
});
|
||||
|
||||
it('lists an appointed ambassador as permission 4 but keeps responses limited to officers', async () => {
|
||||
const ambassador = {
|
||||
...general,
|
||||
officerLevel: 1,
|
||||
meta: { permission: 'ambassador' },
|
||||
} as GeneralRow;
|
||||
const { caller } = buildContext({
|
||||
general: {
|
||||
findUnique: vi.fn(async () => ambassador),
|
||||
findMany: vi.fn(async () => []),
|
||||
},
|
||||
nation: {
|
||||
findMany: vi.fn(async () => []),
|
||||
findUnique: vi.fn(async () => ({ meta: {} })),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await caller.messages.getRecent({ generalId: ambassador.id });
|
||||
|
||||
expect(result.permission).toBe(4);
|
||||
expect(result.canRespondDiplomacy).toBe(false);
|
||||
});
|
||||
|
||||
it('redacts recent and old diplomacy content below secret permission 3', async () => {
|
||||
const diplomacyRow = {
|
||||
id: 19,
|
||||
mailbox: 9001,
|
||||
type: 'diplomacy',
|
||||
src: 9002,
|
||||
dest: 9001,
|
||||
time: new Date(),
|
||||
valid_until: new Date('9999-12-31T00:00:00Z'),
|
||||
message: {
|
||||
src: {
|
||||
generalId: 8,
|
||||
generalName: '외교관',
|
||||
nationId: 2,
|
||||
nationName: '촉',
|
||||
color: '#000000',
|
||||
icon: '',
|
||||
},
|
||||
dest: {
|
||||
generalId: 0,
|
||||
generalName: '',
|
||||
nationId: 1,
|
||||
nationName: '위',
|
||||
color: '#ffffff',
|
||||
icon: '',
|
||||
},
|
||||
text: '보이면 안 되는 외교 본문',
|
||||
option: { action: 'noAggression' },
|
||||
},
|
||||
};
|
||||
const queryRaw = vi.fn(async () => [diplomacyRow]);
|
||||
const { caller } = buildContext({
|
||||
$queryRaw: queryRaw,
|
||||
nation: {
|
||||
findMany: vi.fn(async () => []),
|
||||
findUnique: vi.fn(async () => ({ meta: {} })),
|
||||
},
|
||||
});
|
||||
|
||||
const recent = await caller.messages.getRecent({ generalId: general.id });
|
||||
const old = await caller.messages.getOld({
|
||||
generalId: general.id,
|
||||
type: 'diplomacy',
|
||||
to: 20,
|
||||
});
|
||||
|
||||
expect(recent.permission).toBe(2);
|
||||
expect(recent.diplomacy[0]).toMatchObject({
|
||||
text: '(외교 메시지입니다)',
|
||||
option: { action: 'noAggression', invalid: true },
|
||||
});
|
||||
expect(old.diplomacy[0]).toMatchObject({
|
||||
text: '(외교 메시지입니다)',
|
||||
option: { action: 'noAggression', invalid: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('forces a non-diplomat foreign nation target back to the owned nation mailbox', async () => {
|
||||
const queryRaw = vi.fn(async () => [{ id: 51 }]);
|
||||
const findNation = vi.fn(async ({ where }: { where: { id: number } }) => ({
|
||||
id: where.id,
|
||||
name: where.id === 1 ? '위' : '촉',
|
||||
color: '#112233',
|
||||
meta: {},
|
||||
}));
|
||||
const { caller } = buildContext({
|
||||
$queryRaw: queryRaw,
|
||||
nation: {
|
||||
findMany: vi.fn(async () => []),
|
||||
findUnique: findNation,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await caller.messages.send({
|
||||
generalId: general.id,
|
||||
mailbox: 9002,
|
||||
text: '국가 메시지',
|
||||
});
|
||||
|
||||
expect(result.msgType).toBe('national');
|
||||
expect(queryRaw.mock.calls[0]?.slice(1)).toEqual(expect.arrayContaining([9001, 'national']));
|
||||
});
|
||||
|
||||
it('allows an ambassador to target a foreign nation mailbox as diplomacy', async () => {
|
||||
const ambassador = {
|
||||
...general,
|
||||
officerLevel: 1,
|
||||
meta: { permission: 'ambassador' },
|
||||
} as GeneralRow;
|
||||
const queryRaw = vi.fn(async () => [{ id: 52 }]);
|
||||
const { caller } = buildContext({
|
||||
$queryRaw: queryRaw,
|
||||
general: {
|
||||
findUnique: vi.fn(async () => ambassador),
|
||||
findMany: vi.fn(async () => []),
|
||||
},
|
||||
nation: {
|
||||
findMany: vi.fn(async () => []),
|
||||
findUnique: vi.fn(async ({ where }: { where: { id: number } }) => ({
|
||||
id: where.id,
|
||||
name: where.id === 1 ? '위' : '촉',
|
||||
color: '#112233',
|
||||
meta: {},
|
||||
})),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await caller.messages.send({
|
||||
generalId: ambassador.id,
|
||||
mailbox: 9002,
|
||||
text: '외교 메시지',
|
||||
});
|
||||
|
||||
expect(result.msgType).toBe('diplomacy');
|
||||
expect(queryRaw.mock.calls[0]?.slice(1)).toEqual(expect.arrayContaining([9002, 'diplomacy']));
|
||||
});
|
||||
|
||||
it('blocks private messages between foreign ambassadors', async () => {
|
||||
const ambassador = {
|
||||
...general,
|
||||
officerLevel: 1,
|
||||
meta: { permission: 'ambassador' },
|
||||
} as GeneralRow;
|
||||
const foreignAmbassador = {
|
||||
...ambassador,
|
||||
id: 8,
|
||||
userId: 'user-8',
|
||||
name: '상대 외교관',
|
||||
nationId: 2,
|
||||
} as GeneralRow;
|
||||
const { caller } = buildContext({
|
||||
general: {
|
||||
findUnique: vi.fn(async ({ where }: { where: { id: number } }) =>
|
||||
where.id === ambassador.id ? ambassador : foreignAmbassador
|
||||
),
|
||||
findMany: vi.fn(async () => []),
|
||||
},
|
||||
nation: {
|
||||
findMany: vi.fn(async () => []),
|
||||
findUnique: vi.fn(async ({ where }: { where: { id: number } }) => ({
|
||||
id: where.id,
|
||||
name: where.id === 1 ? '위' : '촉',
|
||||
color: '#112233',
|
||||
meta: {},
|
||||
})),
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
caller.messages.send({
|
||||
generalId: ambassador.id,
|
||||
mailbox: foreignAmbassador.id,
|
||||
text: '개인 메시지',
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: 'FORBIDDEN',
|
||||
message: '외교권자끼리는 메시지를 보낼 수 없습니다.',
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['public', { noSendPublicMsg: 1 }, 9999, '공개 메세지를 보낼 수 없습니다.'],
|
||||
['private', { noSendPrivateMsg: 1 }, 8, '개인 메세지를 보낼 수 없습니다.'],
|
||||
])('enforces the general %s-message penalty', async (_type, penalty, mailbox, message) => {
|
||||
const penalized = { ...general, penalty } as GeneralRow;
|
||||
const { caller } = buildContext({
|
||||
general: {
|
||||
findUnique: vi.fn(async () => penalized),
|
||||
findMany: vi.fn(async () => []),
|
||||
},
|
||||
nation: {
|
||||
findMany: vi.fn(async () => []),
|
||||
findUnique: vi.fn(async () => ({ id: 1, name: '위', color: '#fff', meta: {} })),
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
caller.messages.send({
|
||||
generalId: penalized.id,
|
||||
mailbox,
|
||||
text: '차단 메시지',
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'FORBIDDEN', message });
|
||||
});
|
||||
|
||||
it('enforces the legacy private-message interval through Redis without touching lifecycle', async () => {
|
||||
const redis = {
|
||||
set: vi.fn(async () => null),
|
||||
publish: vi.fn(async () => 1),
|
||||
};
|
||||
const { caller } = buildContext(
|
||||
{
|
||||
nation: {
|
||||
findMany: vi.fn(async () => []),
|
||||
findUnique: vi.fn(async () => ({ id: 1, name: '위', color: '#fff', meta: {} })),
|
||||
},
|
||||
},
|
||||
{ redis }
|
||||
);
|
||||
|
||||
await expect(
|
||||
caller.messages.send({
|
||||
generalId: general.id,
|
||||
mailbox: 8,
|
||||
text: '너무 빠른 메시지',
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: 'TOO_MANY_REQUESTS',
|
||||
message: '개인메세지는 2초당 1건만 보낼 수 있습니다!',
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks sends for a muted authenticated user independently of general permission', async () => {
|
||||
const mutedAuth = {
|
||||
...auth,
|
||||
sanctions: { mutedUntil: '2099-01-01T00:00:00.000Z' },
|
||||
};
|
||||
const { caller } = buildContext({}, { auth: mutedAuth });
|
||||
|
||||
await expect(
|
||||
caller.messages.send({
|
||||
generalId: general.id,
|
||||
mailbox: 9999,
|
||||
text: '사용자 mute',
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: 'FORBIDDEN',
|
||||
message: '메시지 전송이 제한된 계정입니다.',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects every remaining general-scoped message mutation for another user general', async () => {
|
||||
const foreignGeneral = { ...general, userId: 'user-8' } as GeneralRow;
|
||||
const { caller } = buildContext({
|
||||
general: {
|
||||
findUnique: vi.fn(async () => foreignGeneral),
|
||||
findMany: vi.fn(async () => []),
|
||||
},
|
||||
});
|
||||
|
||||
await expect(caller.messages.getContacts({ generalId: foreignGeneral.id })).rejects.toMatchObject({
|
||||
code: 'FORBIDDEN',
|
||||
});
|
||||
await expect(
|
||||
caller.messages.readLatest({
|
||||
generalId: foreignGeneral.id,
|
||||
type: 'private',
|
||||
messageId: 1,
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'FORBIDDEN' });
|
||||
await expect(caller.messages.delete({ generalId: foreignGeneral.id, messageId: 1 })).rejects.toMatchObject({
|
||||
code: 'FORBIDDEN',
|
||||
});
|
||||
await expect(
|
||||
caller.messages.respond({
|
||||
generalId: foreignGeneral.id,
|
||||
messageId: 1,
|
||||
response: true,
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'FORBIDDEN' });
|
||||
});
|
||||
|
||||
it('persists latest-read updates through the monotonic upsert', async () => {
|
||||
const { caller, executeRaw } = buildContext();
|
||||
|
||||
@@ -154,6 +444,49 @@ describe('messages router missing-flow compatibility', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('lets the sender delete a manual diplomacy copy without deleting the receiver copy', async () => {
|
||||
const queryRaw = vi.fn(async () => [
|
||||
{
|
||||
id: 25,
|
||||
mailbox: 9001,
|
||||
type: 'diplomacy',
|
||||
src: 9001,
|
||||
dest: 9002,
|
||||
time: new Date(),
|
||||
valid_until: new Date('9999-12-31T00:00:00Z'),
|
||||
message: {
|
||||
src: {
|
||||
generalId: general.id,
|
||||
generalName: general.name,
|
||||
nationId: 1,
|
||||
nationName: '위',
|
||||
color: '#fff',
|
||||
icon: '',
|
||||
},
|
||||
dest: {
|
||||
generalId: 0,
|
||||
generalName: '',
|
||||
nationId: 2,
|
||||
nationName: '촉',
|
||||
color: '#000',
|
||||
icon: '',
|
||||
},
|
||||
text: '일반 외교 메시지',
|
||||
option: { receiverMessageID: 26 },
|
||||
},
|
||||
},
|
||||
]);
|
||||
const { caller, updateMany } = buildContext({ $queryRaw: queryRaw });
|
||||
|
||||
const result = await caller.messages.delete({ generalId: general.id, messageId: 25 });
|
||||
|
||||
expect(result.deletedIds).toEqual([25]);
|
||||
expect(updateMany).toHaveBeenCalledWith({
|
||||
where: { id: { in: [25] } },
|
||||
data: { validUntil: expect.any(Date) },
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects deleting another general message', async () => {
|
||||
const queryRaw = vi.fn(async () => [
|
||||
{
|
||||
|
||||
@@ -14,8 +14,12 @@ const integration = describe.skipIf(!databaseUrl);
|
||||
const bettingId = 990_071;
|
||||
const concurrentBettingId = 990_072;
|
||||
const generalId = 9_971;
|
||||
const otherGeneralId = 9_972;
|
||||
const nationId = 990_071;
|
||||
const otherNationId = 990_072;
|
||||
const userId = 'nation-betting-router-user';
|
||||
const otherUserId = 'nation-betting-router-other-user';
|
||||
const noGeneralUserId = 'nation-betting-router-no-general-user';
|
||||
|
||||
const auth: GameSessionTokenPayload = {
|
||||
version: 1,
|
||||
@@ -32,12 +36,34 @@ const auth: GameSessionTokenPayload = {
|
||||
sanctions: {},
|
||||
};
|
||||
|
||||
const otherAuth: GameSessionTokenPayload = {
|
||||
...auth,
|
||||
sessionId: 'nation-betting-router-other-session',
|
||||
user: {
|
||||
...auth.user,
|
||||
id: otherUserId,
|
||||
username: 'other-bettor',
|
||||
displayName: 'Other Bettor',
|
||||
},
|
||||
};
|
||||
|
||||
const noGeneralAuth: GameSessionTokenPayload = {
|
||||
...auth,
|
||||
sessionId: 'nation-betting-router-no-general-session',
|
||||
user: {
|
||||
...auth.user,
|
||||
id: noGeneralUserId,
|
||||
username: 'no-general',
|
||||
displayName: 'No General',
|
||||
},
|
||||
};
|
||||
|
||||
integration('nation betting router', () => {
|
||||
let db: GamePrismaClient;
|
||||
let closeDb: (() => Promise<void>) | undefined;
|
||||
let worldStateId: number;
|
||||
|
||||
const buildContext = (requestId: string): GameApiContext => {
|
||||
const buildContext = (requestId: string, actorAuth: GameSessionTokenPayload | null = auth): GameApiContext => {
|
||||
const redisClient = {
|
||||
get: async () => null,
|
||||
set: async () => null,
|
||||
@@ -52,7 +78,7 @@ integration('nation betting router', () => {
|
||||
uploadDir: 'uploads',
|
||||
uploadPath: '/uploads',
|
||||
uploadPublicUrl: null,
|
||||
auth,
|
||||
auth: actorAuth,
|
||||
accessTokenStore: new RedisAccessTokenStore(redisClient, 'che:2'),
|
||||
flushStore: new InMemoryFlushStore(),
|
||||
gameTokenSecret: 'test-secret',
|
||||
@@ -64,33 +90,55 @@ integration('nation betting router', () => {
|
||||
await connector.connect();
|
||||
db = connector.prisma;
|
||||
closeDb = () => connector.disconnect();
|
||||
await db.inputEvent.deleteMany({ where: { actorUserId: userId } });
|
||||
await db.inputEvent.deleteMany({ where: { actorUserId: { in: [userId, otherUserId, noGeneralUserId] } } });
|
||||
await db.nationBetting.deleteMany({ where: { id: { in: [bettingId, concurrentBettingId] } } });
|
||||
await db.rankData.deleteMany({ where: { generalId } });
|
||||
await db.inheritanceLog.deleteMany({ where: { userId } });
|
||||
await db.inheritancePoint.deleteMany({ where: { userId } });
|
||||
await db.general.deleteMany({ where: { id: generalId } });
|
||||
await db.nation.deleteMany({ where: { id: nationId } });
|
||||
await db.rankData.deleteMany({ where: { generalId: { in: [generalId, otherGeneralId] } } });
|
||||
await db.inheritanceLog.deleteMany({ where: { userId: { in: [userId, otherUserId] } } });
|
||||
await db.inheritancePoint.deleteMany({ where: { userId: { in: [userId, otherUserId] } } });
|
||||
await db.general.deleteMany({ where: { id: { in: [generalId, otherGeneralId] } } });
|
||||
await db.nation.deleteMany({ where: { id: { in: [nationId, otherNationId] } } });
|
||||
|
||||
await db.nation.create({
|
||||
data: {
|
||||
id: nationId,
|
||||
name: '베팅국',
|
||||
color: '#123456',
|
||||
level: 2,
|
||||
},
|
||||
await db.nation.createMany({
|
||||
data: [
|
||||
{
|
||||
id: nationId,
|
||||
name: '베팅국',
|
||||
color: '#123456',
|
||||
level: 2,
|
||||
},
|
||||
{
|
||||
id: otherNationId,
|
||||
name: '다른베팅국',
|
||||
color: '#654321',
|
||||
level: 6,
|
||||
},
|
||||
],
|
||||
});
|
||||
await db.general.create({
|
||||
data: {
|
||||
id: generalId,
|
||||
userId,
|
||||
name: '베팅장수',
|
||||
nationId,
|
||||
cityId: 1,
|
||||
npcState: 0,
|
||||
turnTime: new Date('0200-01-01T00:00:00.000Z'),
|
||||
meta: {},
|
||||
},
|
||||
await db.general.createMany({
|
||||
data: [
|
||||
{
|
||||
id: generalId,
|
||||
userId,
|
||||
name: '베팅장수',
|
||||
nationId,
|
||||
cityId: 1,
|
||||
npcState: 0,
|
||||
officerLevel: 0,
|
||||
turnTime: new Date('0200-01-01T00:00:00.000Z'),
|
||||
meta: {},
|
||||
},
|
||||
{
|
||||
id: otherGeneralId,
|
||||
userId: otherUserId,
|
||||
name: '다른국가수뇌',
|
||||
nationId: otherNationId,
|
||||
cityId: 1,
|
||||
npcState: 0,
|
||||
officerLevel: 12,
|
||||
turnTime: new Date('0200-01-01T00:00:00.000Z'),
|
||||
meta: {},
|
||||
},
|
||||
],
|
||||
});
|
||||
const world = await db.worldState.create({
|
||||
data: {
|
||||
@@ -132,19 +180,22 @@ integration('nation betting router', () => {
|
||||
candidates: [{ title: '베팅국', info: '', isHtml: true, aux: { nation: nationId } }],
|
||||
},
|
||||
});
|
||||
await db.inheritancePoint.create({
|
||||
data: { userId, key: 'previous', value: 1_000 },
|
||||
await db.inheritancePoint.createMany({
|
||||
data: [
|
||||
{ userId, key: 'previous', value: 1_000 },
|
||||
{ userId: otherUserId, key: 'previous', value: 500 },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await db.inputEvent.deleteMany({ where: { actorUserId: userId } });
|
||||
await db.inputEvent.deleteMany({ where: { actorUserId: { in: [userId, otherUserId, noGeneralUserId] } } });
|
||||
await db.nationBetting.deleteMany({ where: { id: { in: [bettingId, concurrentBettingId] } } });
|
||||
await db.rankData.deleteMany({ where: { generalId } });
|
||||
await db.inheritanceLog.deleteMany({ where: { userId } });
|
||||
await db.inheritancePoint.deleteMany({ where: { userId } });
|
||||
await db.general.deleteMany({ where: { id: generalId } });
|
||||
await db.nation.deleteMany({ where: { id: nationId } });
|
||||
await db.rankData.deleteMany({ where: { generalId: { in: [generalId, otherGeneralId] } } });
|
||||
await db.inheritanceLog.deleteMany({ where: { userId: { in: [userId, otherUserId] } } });
|
||||
await db.inheritancePoint.deleteMany({ where: { userId: { in: [userId, otherUserId] } } });
|
||||
await db.general.deleteMany({ where: { id: { in: [generalId, otherGeneralId] } } });
|
||||
await db.nation.deleteMany({ where: { id: { in: [nationId, otherNationId] } } });
|
||||
await db.worldState.delete({ where: { id: worldStateId } });
|
||||
await closeDb?.();
|
||||
});
|
||||
@@ -229,12 +280,107 @@ integration('nation betting router', () => {
|
||||
}),
|
||||
]);
|
||||
expect(results.map((result) => result.status).sort()).toEqual(['fulfilled', 'rejected']);
|
||||
expect(await db.nationBet.aggregate({ where: { bettingId: concurrentBettingId }, _sum: { amount: true } }))
|
||||
.toMatchObject({ _sum: { amount: 600 } });
|
||||
expect(
|
||||
await db.nationBet.aggregate({ where: { bettingId: concurrentBettingId }, _sum: { amount: true } })
|
||||
).toMatchObject({ _sum: { amount: 600 } });
|
||||
expect(
|
||||
await db.inheritancePoint.findUniqueOrThrow({
|
||||
where: { userId_key: { userId, key: 'previous' } },
|
||||
})
|
||||
).toMatchObject({ value: 250 });
|
||||
});
|
||||
|
||||
it('requires authentication and an owned player general for every betting operation', async () => {
|
||||
await expect(
|
||||
appRouter.createCaller(buildContext('nation-betting-anonymous-list', null)).betting.getList({
|
||||
req: 'bettingNation',
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
|
||||
await expect(
|
||||
appRouter
|
||||
.createCaller(buildContext('nation-betting-anonymous-detail', null))
|
||||
.betting.getDetail({ bettingId })
|
||||
).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
|
||||
await expect(
|
||||
appRouter.createCaller(buildContext('nation-betting-anonymous-bet', null)).betting.bet({
|
||||
bettingId,
|
||||
bettingType: [0],
|
||||
amount: 10,
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
|
||||
await expect(
|
||||
appRouter.createCaller(buildContext('nation-betting-no-general-list', noGeneralAuth)).betting.getList({
|
||||
req: 'bettingNation',
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'NOT_FOUND', message: 'General not found' });
|
||||
await expect(
|
||||
appRouter
|
||||
.createCaller(buildContext('nation-betting-no-general-detail', noGeneralAuth))
|
||||
.betting.getDetail({ bettingId })
|
||||
).rejects.toMatchObject({ code: 'NOT_FOUND', message: 'General not found' });
|
||||
await expect(
|
||||
appRouter.createCaller(buildContext('nation-betting-no-general-bet', noGeneralAuth)).betting.bet({
|
||||
bettingId,
|
||||
bettingType: [0],
|
||||
amount: 10,
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'NOT_FOUND', message: 'General not found' });
|
||||
});
|
||||
|
||||
it('allows generals across nation and office levels while isolating each session user bet', async () => {
|
||||
await expect(
|
||||
appRouter.createCaller(buildContext('nation-betting-other-list', otherAuth)).betting.getList({
|
||||
req: 'bettingNation',
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
result: true,
|
||||
bettingList: {
|
||||
[bettingId]: { name: '천통국 예상' },
|
||||
},
|
||||
});
|
||||
await expect(
|
||||
appRouter.createCaller(buildContext('nation-betting-other-bet', otherAuth)).betting.bet({
|
||||
bettingId,
|
||||
bettingType: [0],
|
||||
amount: 100,
|
||||
})
|
||||
).resolves.toEqual({ result: true });
|
||||
|
||||
const [firstUserDetail, otherUserDetail] = await Promise.all([
|
||||
appRouter.createCaller(buildContext('nation-betting-first-user-detail')).betting.getDetail({ bettingId }),
|
||||
appRouter
|
||||
.createCaller(buildContext('nation-betting-other-user-detail', otherAuth))
|
||||
.betting.getDetail({ bettingId }),
|
||||
]);
|
||||
expect(firstUserDetail.myBetting).toEqual([['[0]', 150]]);
|
||||
expect(otherUserDetail.myBetting).toEqual([['[0]', 100]]);
|
||||
expect(firstUserDetail.bettingDetail).toEqual([['[0]', 250]]);
|
||||
expect(otherUserDetail.bettingDetail).toEqual([['[0]', 250]]);
|
||||
|
||||
expect(
|
||||
await db.nationBet.findUniqueOrThrow({
|
||||
where: {
|
||||
bettingId_userId_selectionKey: {
|
||||
bettingId,
|
||||
userId: otherUserId,
|
||||
selectionKey: '[0]',
|
||||
},
|
||||
},
|
||||
})
|
||||
).toMatchObject({
|
||||
generalId: otherGeneralId,
|
||||
userId: otherUserId,
|
||||
amount: 100,
|
||||
});
|
||||
expect(
|
||||
await db.inheritancePoint.findUniqueOrThrow({
|
||||
where: { userId_key: { userId: otherUserId, key: 'previous' } },
|
||||
})
|
||||
).toMatchObject({ value: 400 });
|
||||
expect(
|
||||
await db.rankData.findUniqueOrThrow({
|
||||
where: { generalId_type: { generalId: otherGeneralId, type: 'inherit_spent_dyn' } },
|
||||
})
|
||||
).toMatchObject({ nationId: otherNationId, value: 100 });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user