merge: 최신 main을 transport 권한과 durable 검증에 최종 통합한다
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
import { enqueuePrivateMessageWebPush, GamePrisma } from '@sammo-ts/infra';
|
||||
import type { MessagePayload, MessageRecordDraft, MessageType } from '@sammo-ts/logic';
|
||||
|
||||
import type { DatabaseClient } from '../context.js';
|
||||
import { loadCurrentGameTime } from '../services/gameClock.js';
|
||||
import { enqueuePrivateMessageWebPush } from '@sammo-ts/infra';
|
||||
|
||||
export interface MessageView {
|
||||
id: number;
|
||||
@@ -203,3 +203,26 @@ export const invalidateMessages = async (db: DatabaseClient, ids: number[]): Pro
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const tombstoneMessages = async (db: DatabaseClient, ids: number[]): Promise<void> => {
|
||||
const uniqueIds = Array.from(new Set(ids.filter((id) => Number.isInteger(id) && id > 0)));
|
||||
if (uniqueIds.length === 0) return;
|
||||
|
||||
await db.$executeRaw(
|
||||
GamePrisma.sql`
|
||||
UPDATE message
|
||||
SET message = jsonb_set(
|
||||
jsonb_set(message, '{text}', to_jsonb(${'삭제된 메시지입니다.'}::text), true),
|
||||
'{option}',
|
||||
(
|
||||
CASE
|
||||
WHEN jsonb_typeof(message->'option') = 'object' THEN message->'option'
|
||||
ELSE '{}'::jsonb
|
||||
END
|
||||
) || jsonb_build_object('invalid', true),
|
||||
true
|
||||
)
|
||||
WHERE id IN (${GamePrisma.join(uniqueIds)})
|
||||
`
|
||||
);
|
||||
};
|
||||
|
||||
@@ -56,6 +56,7 @@ const zGeneralSettings = z.object({
|
||||
use_treatment: z.number().int().optional(),
|
||||
use_auto_nation_turn: z.number().int().optional(),
|
||||
use_auto_nation_diplomacy: z.number().int().min(0).max(1).optional(),
|
||||
use_auto_nation_war: z.number().int().min(0).max(1).optional(),
|
||||
use_auto_nation_promotion: z.number().int().min(0).max(1).optional(),
|
||||
use_auto_nation_finance: z.number().int().min(0).max(1).optional(),
|
||||
use_auto_nation_capital: z.number().int().min(0).max(1).optional(),
|
||||
@@ -218,6 +219,7 @@ const resolveUserSettings = (meta: Record<string, unknown>) => {
|
||||
// Ref가 NPC 군주에게만 수행하던 국가 운영은 사용자 군주에게 opt-in이다.
|
||||
// 누락된 값은 신규 게임과 기존 장수 모두 안전한 기본값(사용 안함)으로 해석한다.
|
||||
use_auto_nation_diplomacy: readNumber(readSetting('use_auto_nation_diplomacy'), 0),
|
||||
use_auto_nation_war: readNumber(readSetting('use_auto_nation_war'), 0),
|
||||
use_auto_nation_promotion: readNumber(readSetting('use_auto_nation_promotion'), 0),
|
||||
use_auto_nation_finance: readNumber(readSetting('use_auto_nation_finance'), 0),
|
||||
use_auto_nation_capital: readNumber(readSetting('use_auto_nation_capital'), 0),
|
||||
@@ -693,7 +695,13 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
|
||||
export const generalRouter = router({
|
||||
adjustIcon: engineAuthedProcedure
|
||||
.input(
|
||||
z.object({ iconId: z.string().uuid().optional(), clientRequestId: z.string().uuid().optional() }).optional()
|
||||
z
|
||||
.object({
|
||||
iconId: z.string().uuid().optional(),
|
||||
resetToDefault: z.literal(true).optional(),
|
||||
clientRequestId: z.string().uuid().optional(),
|
||||
})
|
||||
.optional()
|
||||
)
|
||||
.mutation(({ ctx, input }) => {
|
||||
const userId = ctx.auth?.user.id;
|
||||
@@ -701,19 +709,41 @@ export const generalRouter = router({
|
||||
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
||||
}
|
||||
const selected = input?.iconId ? ctx.auth?.user.icons?.find((icon) => icon.id === input.iconId) : undefined;
|
||||
if (input?.iconId && (!selected || ctx.auth?.user.canUseGeneralPicture === false)) {
|
||||
const resetToDefault = input?.resetToDefault === true;
|
||||
if (resetToDefault && input?.iconId) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '아이콘 선택과 기본 아이콘 초기화를 함께 요청할 수 없습니다.' });
|
||||
}
|
||||
if (!resetToDefault && !input?.iconId) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '적용할 활성 전용 아이콘을 선택해 주세요.' });
|
||||
}
|
||||
if (
|
||||
resetToDefault &&
|
||||
(ctx.auth?.user.picture !== 'default.jpg' || ctx.auth?.user.imageServer !== 0)
|
||||
) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: '현재 계정 아이콘이 기본 아이콘이 아닙니다.' });
|
||||
}
|
||||
if (!resetToDefault && (!selected || ctx.auth?.user.canUseGeneralPicture === false)) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: '사용 가능한 내 전용 아이콘이 아닙니다.' });
|
||||
}
|
||||
const iconRevision = ctx.auth?.user.iconUpdatedAt ?? (resetToDefault ? undefined : selected!.createdAt);
|
||||
if (!iconRevision) {
|
||||
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '계정 아이콘 변경 시각을 확인할 수 없습니다.' });
|
||||
}
|
||||
const projection = resetToDefault
|
||||
? {
|
||||
picture: 'default.jpg',
|
||||
imageServer: 0,
|
||||
revision: iconRevision,
|
||||
}
|
||||
: {
|
||||
picture: selected!.picture,
|
||||
imageServer: selected!.imageServer,
|
||||
revision: iconRevision,
|
||||
};
|
||||
return adjustAccountIconForUser(
|
||||
ctx,
|
||||
userId,
|
||||
selected
|
||||
? {
|
||||
picture: selected.picture,
|
||||
imageServer: selected.imageServer,
|
||||
revision: ctx.auth?.user.iconUpdatedAt ?? selected.createdAt,
|
||||
}
|
||||
: undefined,
|
||||
projection,
|
||||
true,
|
||||
input?.clientRequestId ?? ctx.requestId
|
||||
);
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
WAR_TRAIT_KEYS,
|
||||
} from '@sammo-ts/logic';
|
||||
import { readInheritancePoint, resolveInheritConstants } from '../../services/inheritance.js';
|
||||
import { loadAuthoritativeAccountIcon } from '../../services/accountIconSync.js';
|
||||
import { loadCurrentGameTime } from '../../services/gameClock.js';
|
||||
import { getSelectionPoolStatus, resolveSelectionMaxGeneral } from '@sammo-ts/game-engine/turn/selectPoolService.js';
|
||||
import {
|
||||
@@ -515,15 +514,17 @@ export const joinRouter = router({
|
||||
if (input.iconId && (!selectedIcon || auth.user.canUseGeneralPicture === false)) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: '사용 가능한 내 전용 아이콘이 아닙니다.' });
|
||||
}
|
||||
const accountIcon = input.pic
|
||||
? selectedIcon
|
||||
// 유저 장수에는 인증 token의 활성 전용 아이콘을 명시적으로 고른 경우만
|
||||
// 그림을 적용한다. Gateway 대표 그림은 shared preset일 수 있으므로
|
||||
// iconId 없는 fallback으로 사용하지 않는다.
|
||||
const accountIcon =
|
||||
input.pic && selectedIcon
|
||||
? {
|
||||
picture: selectedIcon.picture,
|
||||
imageServer: selectedIcon.imageServer,
|
||||
revision: auth.user.iconUpdatedAt ?? selectedIcon.createdAt,
|
||||
}
|
||||
: await loadAuthoritativeAccountIcon(ctx, userId)
|
||||
: null;
|
||||
: null;
|
||||
const commandRequestId = resolveJoinCreateRequestId(ctx.requestId, userId, input.clientRequestId);
|
||||
const result = await requestJoinCreateCommand(ctx, {
|
||||
type: 'joinCreateGeneral',
|
||||
@@ -535,7 +536,7 @@ export const joinRouter = router({
|
||||
leadership: input.leadership,
|
||||
strength: input.strength,
|
||||
intel: input.intel,
|
||||
pic: input.pic,
|
||||
pic: accountIcon !== null,
|
||||
character: input.character,
|
||||
profileId: ctx.profile.id,
|
||||
...(accountIcon
|
||||
|
||||
@@ -19,8 +19,8 @@ import {
|
||||
fetchMessagesFromMailbox,
|
||||
fetchOldMessagesFromMailbox,
|
||||
fetchMessageById,
|
||||
invalidateMessages,
|
||||
insertMessage,
|
||||
tombstoneMessages,
|
||||
type MessageView,
|
||||
} from '../../messages/store.js';
|
||||
import { getOwnedGeneral } from '../shared/general.js';
|
||||
@@ -40,11 +40,7 @@ const redactDiplomacyMessages = (messages: MessageView[], permission: number): M
|
||||
}
|
||||
return {
|
||||
...message,
|
||||
text: '(외교 메시지입니다)',
|
||||
option: {
|
||||
...(message.option ?? {}),
|
||||
invalid: true,
|
||||
},
|
||||
text: '조회 권한이 없는 외교 메시지입니다.',
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -303,7 +299,7 @@ export const messagesRouter = router({
|
||||
message.id,
|
||||
...(shouldDeleteReceiverCopy && typeof receiverMessageId === 'number' ? [receiverMessageId] : []),
|
||||
];
|
||||
await invalidateMessages(ctx.db, ids);
|
||||
await tombstoneMessages(ctx.db, ids);
|
||||
const receiverMailbox =
|
||||
shouldDeleteReceiverCopy && typeof receiverMessageId === 'number' && message.msgType === 'private'
|
||||
? message.payload.dest.generalId
|
||||
|
||||
@@ -153,11 +153,17 @@ export const getPersonnelInfo = accessAuthedProcedure.query(async ({ ctx }) => {
|
||||
const canChangePermissions = me.officerLevel === 12;
|
||||
const ambassadors = canChangePermissions
|
||||
? permissionCandidates.filter(
|
||||
(candidate) => candidate.permission === 'ambassador' || candidate.maxPermission === 4
|
||||
(candidate) =>
|
||||
candidate.permission === 'ambassador' ||
|
||||
(candidate.permission === 'normal' && candidate.maxPermission === 4)
|
||||
)
|
||||
: [];
|
||||
const auditors = canChangePermissions
|
||||
? permissionCandidates.filter((candidate) => candidate.permission === 'auditor' || candidate.maxPermission >= 3)
|
||||
? permissionCandidates.filter(
|
||||
(candidate) =>
|
||||
candidate.permission === 'auditor' ||
|
||||
(candidate.permission === 'normal' && candidate.maxPermission >= 3)
|
||||
)
|
||||
: [];
|
||||
const generalNameMap = new Map(mappedGenerals.map((general) => [general.id, general.name]));
|
||||
const awards = {
|
||||
|
||||
@@ -433,7 +433,6 @@ export const voteRouter = router({
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const general = await getMyGeneral(ctx);
|
||||
const openerName = ctx.auth?.user.username ?? general.name;
|
||||
const options = normalizeOptions(input.options);
|
||||
if (options.length === 0) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '항목이 없습니다.' });
|
||||
@@ -488,7 +487,7 @@ export const voteRouter = router({
|
||||
${multipleOptions},
|
||||
${input.revealMode},
|
||||
${general.id},
|
||||
${openerName},
|
||||
${general.name},
|
||||
${gameTime.now},
|
||||
${gameTime.tick === null ? null : BigInt(gameTime.tick)},
|
||||
${endAt},
|
||||
|
||||
@@ -40,7 +40,7 @@ export const loadAuthoritativeAccountIcon = async (
|
||||
export const adjustAccountIconForUser = async (
|
||||
ctx: GameApiContext,
|
||||
userId: string,
|
||||
selected?: AccountIconProjection,
|
||||
selected: AccountIconProjection,
|
||||
enforceCooldown = true,
|
||||
requestKey?: string
|
||||
): Promise<{
|
||||
@@ -48,10 +48,8 @@ export const adjustAccountIconForUser = async (
|
||||
generalId: number | null;
|
||||
updated: boolean;
|
||||
}> => {
|
||||
const projection = selected ?? (await loadAuthoritativeAccountIcon(ctx, userId));
|
||||
const requestId = selected
|
||||
? `general:adjustIcon:${userId}:manual:${requestKey ?? `${projection.revision}:${encodeURIComponent(projection.picture)}`}`
|
||||
: `general:adjustIcon:${userId}:${projection.revision}`;
|
||||
const projection = selected;
|
||||
const requestId = `general:adjustIcon:${userId}:manual:${requestKey ?? `${projection.revision}:${encodeURIComponent(projection.picture)}`}`;
|
||||
try {
|
||||
const result = await ctx.turnDaemon.requestCommand({
|
||||
type: 'adjustGeneralIcon',
|
||||
|
||||
@@ -287,12 +287,16 @@ integration('generic general creation through the durable turn daemon', () => {
|
||||
accountIconUpdatedAt: '2026-07-30T00:00:00.000Z',
|
||||
});
|
||||
const createdAccess = await db.generalAccessLog.findUniqueOrThrow({ where: { generalId: created.id } });
|
||||
const acceptedEvent = await db.inputEvent.findUniqueOrThrow({
|
||||
where: { requestId: `join-create:${userId}:${clientRequestId}` },
|
||||
});
|
||||
if (!createdAccess.lastRefresh) {
|
||||
throw new Error('created general must have an initial access timestamp');
|
||||
}
|
||||
expect(createdAccess.lastRefresh).toEqual(acceptedEvent.createdAt);
|
||||
expect(
|
||||
new Date((created.meta as Record<string, unknown>).prestart_delete_after as string).getTime() -
|
||||
createdAccess.lastRefresh.getTime()
|
||||
acceptedEvent.createdAt.getTime()
|
||||
).toBe(2 * 5 * 60 * 1_000);
|
||||
expect(runtime!.world.getGeneralById(created.id)).toMatchObject({
|
||||
id: created.id,
|
||||
@@ -354,7 +358,7 @@ integration('generic general creation through the durable turn daemon', () => {
|
||||
attempts: 1,
|
||||
actorUserId: userId,
|
||||
});
|
||||
expect(access.lastRefresh?.getTime()).toBe(runtime!.world.getGameNow(event.createdAt).getTime());
|
||||
expect(access.lastRefresh?.getTime()).toBe(event.createdAt.getTime());
|
||||
const turnGridOffsetSeconds =
|
||||
((created.turnTime.getTime() - runtime!.world.getState().lastTurnTime.getTime()) / 1000 + 300) % 300;
|
||||
expect(turnGridOffsetSeconds).toBeGreaterThanOrEqual(35);
|
||||
|
||||
@@ -585,6 +585,7 @@ describe('in-game my information ownership', () => {
|
||||
use_treatment: 21,
|
||||
use_auto_nation_turn: 1,
|
||||
use_auto_nation_diplomacy: 0,
|
||||
use_auto_nation_war: 0,
|
||||
use_auto_nation_promotion: 0,
|
||||
use_auto_nation_finance: 0,
|
||||
use_auto_nation_capital: 0,
|
||||
@@ -600,6 +601,16 @@ describe('in-game my information ownership', () => {
|
||||
expect(fixture.db.general.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects an invalid automatic war setting before dispatching it to ENGINE', async () => {
|
||||
const requestCommand = vi.fn(async () => ({ type: 'setMySetting', ok: true, generalId: 7 }));
|
||||
const fixture = createContext({ requestCommand });
|
||||
|
||||
await expect(
|
||||
appRouter.createCaller(fixture.context).general.setMySetting({ use_auto_nation_war: 2 })
|
||||
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
|
||||
expect(requestCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sends settings directly to ENGINE without creating an API input event', async () => {
|
||||
const transaction = vi.fn(async () => {
|
||||
throw new Error('API transaction must not run');
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra';
|
||||
|
||||
import { tombstoneMessages } from '../src/messages/store.js';
|
||||
|
||||
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
|
||||
const integration = describe.skipIf(!databaseUrl);
|
||||
|
||||
integration('message deletion tombstone persistence', () => {
|
||||
let db: GamePrismaClient;
|
||||
let close: (() => Promise<void>) | undefined;
|
||||
|
||||
beforeAll(async () => {
|
||||
const schema = databaseUrl ? new URL(databaseUrl).searchParams.get('schema') : null;
|
||||
if (!schema?.endsWith('conditional_integration')) {
|
||||
throw new Error(`Unsafe schema: ${schema ?? '(missing)'}`);
|
||||
}
|
||||
const connector = createGamePostgresConnector({ url: databaseUrl! });
|
||||
await connector.connect();
|
||||
db = connector.prisma;
|
||||
close = () => connector.disconnect();
|
||||
});
|
||||
|
||||
afterAll(async () => close?.());
|
||||
|
||||
it('keeps sender and receiver rows readable while replacing their bodies', async () => {
|
||||
const rollback = new Error('rollback message tombstone fixture');
|
||||
await expect(
|
||||
db.$transaction(async (transaction) => {
|
||||
const validUntil = new Date('9999-12-31T00:00:00.000Z');
|
||||
const receiver = await transaction.message.create({
|
||||
data: {
|
||||
mailbox: 8,
|
||||
type: 'private',
|
||||
src: 7,
|
||||
dest: 8,
|
||||
time: new Date('2026-08-24T00:00:00.000Z'),
|
||||
validUntil,
|
||||
message: {
|
||||
src: { generalId: 7 },
|
||||
dest: { generalId: 8 },
|
||||
text: '수신 사본 원문',
|
||||
option: { senderMessageID: 0 },
|
||||
},
|
||||
},
|
||||
});
|
||||
const sender = await transaction.message.create({
|
||||
data: {
|
||||
mailbox: 7,
|
||||
type: 'private',
|
||||
src: 7,
|
||||
dest: 8,
|
||||
time: new Date('2026-08-24T00:00:00.000Z'),
|
||||
validUntil,
|
||||
message: {
|
||||
src: { generalId: 7 },
|
||||
dest: { generalId: 8 },
|
||||
text: '송신 사본 원문',
|
||||
option: { receiverMessageID: receiver.id },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await tombstoneMessages(transaction, [sender.id, receiver.id]);
|
||||
|
||||
const rows = await transaction.message.findMany({
|
||||
where: { id: { in: [sender.id, receiver.id] } },
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
expect(rows).toHaveLength(2);
|
||||
for (const row of rows) {
|
||||
expect(row.validUntil).toEqual(validUntil);
|
||||
expect(row.message).toMatchObject({
|
||||
text: '삭제된 메시지입니다.',
|
||||
option: { invalid: true },
|
||||
});
|
||||
expect(JSON.stringify(row.message)).not.toContain('사본 원문');
|
||||
}
|
||||
|
||||
throw rollback;
|
||||
})
|
||||
).rejects.toBe(rollback);
|
||||
});
|
||||
});
|
||||
@@ -176,13 +176,15 @@ describe('messages router missing-flow compatibility', () => {
|
||||
|
||||
expect(recent.permission).toBe(2);
|
||||
expect(recent.diplomacy[0]).toMatchObject({
|
||||
text: '(외교 메시지입니다)',
|
||||
option: { action: 'noAggression', invalid: true },
|
||||
text: '조회 권한이 없는 외교 메시지입니다.',
|
||||
option: { action: 'noAggression' },
|
||||
});
|
||||
expect(recent.diplomacy[0]?.option).not.toHaveProperty('invalid');
|
||||
expect(old.diplomacy[0]).toMatchObject({
|
||||
text: '(외교 메시지입니다)',
|
||||
option: { action: 'noAggression', invalid: true },
|
||||
text: '조회 권한이 없는 외교 메시지입니다.',
|
||||
option: { action: 'noAggression' },
|
||||
});
|
||||
expect(old.diplomacy[0]?.option).not.toHaveProperty('invalid');
|
||||
});
|
||||
|
||||
it('forces a non-diplomat foreign nation target back to the owned nation mailbox', async () => {
|
||||
@@ -585,15 +587,13 @@ describe('messages router missing-flow compatibility', () => {
|
||||
},
|
||||
]);
|
||||
const changeJournal = new ChangeJournal();
|
||||
const { caller, updateMany } = buildContext({ $queryRaw: queryRaw }, { changeJournal });
|
||||
const { caller, executeRaw, updateMany } = buildContext({ $queryRaw: queryRaw }, { changeJournal });
|
||||
|
||||
const result = await caller.messages.delete({ generalId: general.id, messageId: 21 });
|
||||
|
||||
expect(result.deletedIds).toEqual([21, 22]);
|
||||
expect(updateMany).toHaveBeenCalledWith({
|
||||
where: { id: { in: [21, 22] } },
|
||||
data: { validUntil: expect.any(Date) },
|
||||
});
|
||||
expect(executeRaw).toHaveBeenCalledOnce();
|
||||
expect(updateMany).not.toHaveBeenCalled();
|
||||
expect(changeJournal.snapshot()).toEqual([
|
||||
{ domain: 'messages.mailbox', entityId: 7 },
|
||||
{ domain: 'messages.mailbox', entityId: 8 },
|
||||
@@ -632,15 +632,13 @@ describe('messages router missing-flow compatibility', () => {
|
||||
},
|
||||
},
|
||||
]);
|
||||
const { caller, updateMany } = buildContext({ $queryRaw: queryRaw });
|
||||
const { caller, executeRaw, 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) },
|
||||
});
|
||||
expect(executeRaw).toHaveBeenCalledOnce();
|
||||
expect(updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects deleting another general message', async () => {
|
||||
|
||||
@@ -230,6 +230,45 @@ describe('nation personnel router', () => {
|
||||
expect(result.awards.eagles).toEqual([{ id: 30, name: '군사', value: 7 }]);
|
||||
});
|
||||
|
||||
it('keeps ambassador and auditor candidate pools mutually exclusive like Ref', async () => {
|
||||
const me = { ...baseGeneral, officerLevel: 12 };
|
||||
const rows = [
|
||||
listRow({ id: 22, name: '군주', officerLevel: 12 }),
|
||||
listRow({ id: 30, name: '현 외교권자', meta: { belong: 5, permission: 'ambassador' } }),
|
||||
listRow({ id: 31, name: '현 조언자', meta: { belong: 5, permission: 'auditor' } }),
|
||||
listRow({ id: 32, name: '일반 후보' }),
|
||||
listRow({ id: 33, name: '외교 금지', penalty: { noAmbassador: true } }),
|
||||
];
|
||||
const context = createContext({
|
||||
me,
|
||||
db: {
|
||||
nation: {
|
||||
findUnique: vi.fn(async () => ({
|
||||
id: 1,
|
||||
name: '위',
|
||||
color: '#777777',
|
||||
level: 3,
|
||||
typeCode: 'che_법가',
|
||||
capitalCityId: 1,
|
||||
meta: { chief_set: 0 },
|
||||
})),
|
||||
},
|
||||
city: { findMany: vi.fn(async () => []) },
|
||||
troop: { findMany: vi.fn(async () => []) },
|
||||
general: {
|
||||
findFirst: vi.fn(async () => me),
|
||||
findMany: vi.fn(async () => rows),
|
||||
},
|
||||
worldState: { findFirst: vi.fn(async () => ({ config: { stat: { chiefMin: 65 } } })) },
|
||||
rankData: { findMany: vi.fn(async () => []) },
|
||||
},
|
||||
});
|
||||
|
||||
const result = await appRouter.createCaller(context).nation.getPersonnelInfo();
|
||||
expect(result.permissionCandidates.ambassadors.map((candidate) => candidate.id)).toEqual([30, 32]);
|
||||
expect(result.permissionCandidates.auditors.map((candidate) => candidate.id)).toEqual([31, 32]);
|
||||
});
|
||||
|
||||
it('allows finance mutations only for a head officer or an eligible ambassador', async () => {
|
||||
const nationDb = {
|
||||
nation: {
|
||||
|
||||
@@ -388,45 +388,56 @@ describe('appRouter', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('applies the current Gateway database icon instead of stale token claims', async () => {
|
||||
it('rejects icon adjustment without an explicitly selected active icon', 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.picture = '장수/유비.jpg';
|
||||
auth.user.imageServer = 0;
|
||||
auth.user.iconUpdatedAt = '2026-07-30T09:00:00.000Z';
|
||||
const requestId = `general:adjustIcon:${auth.user.id}:${currentAccountIcon.revision}`;
|
||||
const accountIconGet = vi.fn(async () => ({
|
||||
revision: '2026-07-31T09:00:00.000Z',
|
||||
picture: '장수/유비.jpg',
|
||||
imageServer: 0,
|
||||
}));
|
||||
const caller = appRouter.createCaller(
|
||||
buildContext({
|
||||
auth,
|
||||
transport,
|
||||
accountIconGet,
|
||||
})
|
||||
);
|
||||
|
||||
await expect(caller.general.adjustIcon()).rejects.toMatchObject({ code: 'BAD_REQUEST' });
|
||||
await expect(caller.general.adjustIcon({ resetToDefault: true })).rejects.toMatchObject({ code: 'FORBIDDEN' });
|
||||
expect(accountIconGet).not.toHaveBeenCalled();
|
||||
expect(transport.commands).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('allows an explicit default reset only when the signed account projection is default', async () => {
|
||||
const transport = new InMemoryTurnDaemonTransport();
|
||||
const auth = buildAuth();
|
||||
const revision = '2026-07-31T09:00:00.000Z';
|
||||
auth.user.picture = 'default.jpg';
|
||||
auth.user.imageServer = 0;
|
||||
auth.user.iconUpdatedAt = revision;
|
||||
const requestId = `general:adjustIcon:${auth.user.id}:manual:${revision}:default.jpg`;
|
||||
transport.setCommandResult(requestId, {
|
||||
type: 'adjustGeneralIcon',
|
||||
ok: true,
|
||||
generalId: 1,
|
||||
updated: true,
|
||||
});
|
||||
const caller = appRouter.createCaller(
|
||||
buildContext({
|
||||
auth,
|
||||
transport,
|
||||
currentAccountIcon,
|
||||
})
|
||||
);
|
||||
const caller = appRouter.createCaller(buildContext({ auth, transport }));
|
||||
|
||||
await expect(caller.general.adjustIcon()).resolves.toEqual({
|
||||
await expect(caller.general.adjustIcon({ resetToDefault: true })).resolves.toMatchObject({
|
||||
ok: true,
|
||||
generalId: 1,
|
||||
updated: true,
|
||||
});
|
||||
expect(transport.commands.at(-1)?.command).toEqual({
|
||||
type: 'adjustGeneralIcon',
|
||||
expect(transport.commands.at(-1)?.command).toMatchObject({
|
||||
requestId,
|
||||
userId: auth.user.id,
|
||||
picture: 'latest.png',
|
||||
imageServer: 1,
|
||||
iconRevision: currentAccountIcon.revision,
|
||||
enforceCooldown: true,
|
||||
picture: 'default.jpg',
|
||||
imageServer: 0,
|
||||
iconRevision: revision,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -461,13 +472,13 @@ describe('appRouter', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects icon adjustment without auth or a current Gateway account', async () => {
|
||||
it('rejects icon adjustment without auth or a selected icon', 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' });
|
||||
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
|
||||
});
|
||||
|
||||
it('rejects unauthenticated or game-blocked auth status checks', async () => {
|
||||
@@ -581,30 +592,30 @@ describe('appRouter', () => {
|
||||
expect(transport.commands.at(-1)?.command).not.toHaveProperty('ownerIconRevision');
|
||||
});
|
||||
|
||||
it('uses the authoritative projection instead of stale token claims for picture creation', async () => {
|
||||
it('does not apply a shared Gateway representative when no active icon id was selected', 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.picture = '장수/유비.jpg';
|
||||
auth.user.imageServer = 0;
|
||||
auth.user.iconUpdatedAt = '2026-07-30T09:00:00.000Z';
|
||||
const accountIconGet = vi.fn(async () => ({
|
||||
revision: '2026-07-31T09:00:00.001Z',
|
||||
picture: '장수/유비.jpg',
|
||||
imageServer: 0,
|
||||
}));
|
||||
const caller = appRouter.createCaller(
|
||||
buildContext({
|
||||
state: buildWorldState(),
|
||||
auth,
|
||||
transport,
|
||||
currentAccountIcon: {
|
||||
revision,
|
||||
picture: 'latest.png',
|
||||
imageServer: 1,
|
||||
},
|
||||
accountIconGet,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -618,11 +629,11 @@ describe('appRouter', () => {
|
||||
clientRequestId,
|
||||
});
|
||||
|
||||
expect(transport.commands.at(-1)?.command).toMatchObject({
|
||||
ownerPicture: 'latest.png',
|
||||
ownerImageServer: 1,
|
||||
ownerIconRevision: revision,
|
||||
});
|
||||
expect(accountIconGet).not.toHaveBeenCalled();
|
||||
expect(transport.commands.at(-1)?.command).toMatchObject({ pic: false });
|
||||
expect(transport.commands.at(-1)?.command).not.toHaveProperty('ownerPicture');
|
||||
expect(transport.commands.at(-1)?.command).not.toHaveProperty('ownerImageServer');
|
||||
expect(transport.commands.at(-1)?.command).not.toHaveProperty('ownerIconRevision');
|
||||
});
|
||||
|
||||
it('creates a general with the selected authenticated icon and rejects another icon id', async () => {
|
||||
|
||||
@@ -257,18 +257,25 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
|
||||
const initial = await db.general.findFirstOrThrow({ where: { userId } });
|
||||
const initialRuntime = runtime!.world.getGeneralById(initial.id);
|
||||
const initialAccess = await db.generalAccessLog.findUniqueOrThrow({ where: { generalId: initial.id } });
|
||||
expect(initial).toMatchObject({ picture: 'default.jpg', imageServer: 0 });
|
||||
const acceptedEvent = await db.inputEvent.findFirstOrThrow({
|
||||
where: { actorUserId: userId, eventType: 'selectPoolCreate', status: 'SUCCEEDED' },
|
||||
orderBy: { sequence: 'desc' },
|
||||
});
|
||||
if (!initialAccess.lastRefresh) {
|
||||
throw new Error('selected general must have an initial access timestamp');
|
||||
}
|
||||
expect(initialAccess.lastRefresh).toEqual(acceptedEvent.createdAt);
|
||||
expect(
|
||||
new Date((initial.meta as Record<string, unknown>).prestart_delete_after as string).getTime() -
|
||||
initialAccess.lastRefresh.getTime()
|
||||
acceptedEvent.createdAt.getTime()
|
||||
).toBe(2 * 5 * 60 * 1_000);
|
||||
expect(initialRuntime).toMatchObject({
|
||||
id: initial.id,
|
||||
userId,
|
||||
name: initial.name,
|
||||
imageServer: initial.imageServer,
|
||||
imageServer: 0,
|
||||
picture: 'default.jpg',
|
||||
stats: {
|
||||
leadership: initial.leadership,
|
||||
strength: initial.strength,
|
||||
@@ -380,15 +387,15 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
|
||||
intel: target.intel,
|
||||
personalCode: initial.personalCode,
|
||||
specialCode: target.specialDomestic,
|
||||
imageServer: target.imageServer,
|
||||
picture: target.picture,
|
||||
imageServer: 0,
|
||||
picture: 'default.jpg',
|
||||
});
|
||||
expect(runtime!.world.getGeneralById(initial.id)).toMatchObject({
|
||||
id: initial.id,
|
||||
userId,
|
||||
name: target.generalName,
|
||||
imageServer: target.imageServer,
|
||||
picture: target.picture,
|
||||
imageServer: 0,
|
||||
picture: 'default.jpg',
|
||||
stats: {
|
||||
leadership: target.leadership,
|
||||
strength: target.strength,
|
||||
|
||||
@@ -267,7 +267,7 @@ describe('vote router actor and permission boundaries', () => {
|
||||
expect(fixture.redisPublish).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('publishes a global front-status projection after creating a survey', async () => {
|
||||
it('stores the authenticated general name and publishes a global projection after creating a survey', async () => {
|
||||
const auth = buildAuth(['admin.survey.open']);
|
||||
auth.user.username = 'admin-account';
|
||||
auth.user.displayName = '관리자 표시명';
|
||||
@@ -288,8 +288,9 @@ describe('vote router actor and permission boundaries', () => {
|
||||
const insert = fixture.queryRaw.mock.calls
|
||||
.map(([query]) => query)
|
||||
.find((query) => sqlText(query).includes('INSERT INTO vote_poll'));
|
||||
expect(insert?.values).toContain('admin-account');
|
||||
expect(insert?.values).not.toContain('관리자 장수');
|
||||
expect(insert?.values).toContain('관리자 장수');
|
||||
expect(insert?.values).not.toContain('admin-account');
|
||||
expect(insert?.values).not.toContain('관리자 표시명');
|
||||
});
|
||||
|
||||
it('binds current operational timestamps in every raw SQL vote writer', async () => {
|
||||
|
||||
Reference in New Issue
Block a user