merge: 병합 직전 main을 외교 권한 복수 임명 수정에 통합한다
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)})
|
||||
`
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user