외교 메시지 응답과 알림 권한 정리
This commit is contained in:
@@ -63,7 +63,7 @@ const isFixtureOutboxPayload = (payload: unknown): boolean => {
|
||||
changes.some(
|
||||
(change) =>
|
||||
Array.isArray(change) &&
|
||||
change[0] === 'messages.mailbox' &&
|
||||
(change[0] === 'messages.mailbox' || change[0] === 'messages.diplomacyMailbox') &&
|
||||
fixtureMailboxes.includes(change[1] as (typeof fixtureMailboxes)[number])
|
||||
)
|
||||
);
|
||||
@@ -94,7 +94,10 @@ integration('diplomacy document message persistence', () => {
|
||||
await db.inputEvent.deleteMany({ where: { requestId: { startsWith: requestPrefix } } });
|
||||
await deleteFixtureOutboxes();
|
||||
await db.readModelRevision.deleteMany({
|
||||
where: { domain: 'messages.mailbox', entityId: { in: [...fixtureMailboxes] } },
|
||||
where: {
|
||||
domain: { in: ['messages.mailbox', 'messages.diplomacyMailbox'] },
|
||||
entityId: { in: [...fixtureMailboxes] },
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -575,7 +578,10 @@ integration('diplomacy document message persistence', () => {
|
||||
await expect(db.message.count({ where: { mailbox: { in: [...fixtureMailboxes] } } })).resolves.toBe(0);
|
||||
await expect(
|
||||
db.readModelRevision.count({
|
||||
where: { domain: 'messages.mailbox', entityId: { in: [...fixtureMailboxes] } },
|
||||
where: {
|
||||
domain: { in: ['messages.mailbox', 'messages.diplomacyMailbox'] },
|
||||
entityId: { in: [...fixtureMailboxes] },
|
||||
},
|
||||
})
|
||||
).resolves.toBe(0);
|
||||
await expect(
|
||||
|
||||
@@ -81,7 +81,7 @@ const isFixtureOutboxPayload = (payload: unknown): boolean => {
|
||||
changes.some(
|
||||
(change) =>
|
||||
Array.isArray(change) &&
|
||||
change[0] === 'messages.mailbox' &&
|
||||
(change[0] === 'messages.mailbox' || change[0] === 'messages.diplomacyMailbox') &&
|
||||
fixtureMailboxes.includes(change[1] as (typeof fixtureMailboxes)[number])
|
||||
)
|
||||
);
|
||||
@@ -101,7 +101,10 @@ const cleanup = async (): Promise<void> => {
|
||||
});
|
||||
await db.inputEvent.deleteMany({ where: { actorUserId: userId } });
|
||||
await db.readModelRevision.deleteMany({
|
||||
where: { domain: 'messages.mailbox', entityId: { in: [...fixtureMailboxes] } },
|
||||
where: {
|
||||
domain: { in: ['messages.mailbox', 'messages.diplomacyMailbox'] },
|
||||
entityId: { in: [...fixtureMailboxes] },
|
||||
},
|
||||
});
|
||||
const outboxes = await db.readModelOutbox.findMany({ select: { id: true, payload: true } });
|
||||
const outboxIds = outboxes.filter(({ payload }) => isFixtureOutboxPayload(payload)).map(({ id }) => id);
|
||||
|
||||
@@ -368,8 +368,8 @@ describe('messages router missing-flow compatibility', () => {
|
||||
expect(result.msgType).toBe('diplomacy');
|
||||
expect(queryRaw).toHaveBeenCalledTimes(2);
|
||||
expect(changeJournal.snapshot()).toEqual([
|
||||
{ domain: 'messages.mailbox', entityId: 9000 },
|
||||
{ domain: 'messages.mailbox', entityId: 9001 },
|
||||
{ domain: 'messages.diplomacyMailbox', entityId: 9000 },
|
||||
{ domain: 'messages.diplomacyMailbox', entityId: 9001 },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -837,6 +837,7 @@ describe('messages router missing-flow compatibility', () => {
|
||||
proposerCurrentNationId?: number;
|
||||
proposerNationMeta?: Record<string, unknown>;
|
||||
diplomacyState?: number;
|
||||
invalid?: boolean;
|
||||
response?: boolean;
|
||||
cities?: Array<{ id: number; nationId: number; frontState: number }>;
|
||||
}) => {
|
||||
@@ -891,6 +892,7 @@ describe('messages router missing-flow compatibility', () => {
|
||||
text: '외교 제안',
|
||||
option: {
|
||||
action,
|
||||
...(options?.invalid ? { invalid: true } : {}),
|
||||
...(action === 'noAggression' ? { year: 201, month: 2 } : {}),
|
||||
},
|
||||
},
|
||||
@@ -1075,6 +1077,21 @@ describe('messages router missing-flow compatibility', () => {
|
||||
};
|
||||
};
|
||||
|
||||
it('rejects a response to a tombstoned diplomatic prompt before any state mutation', async () => {
|
||||
const setup = buildDiplomaticContext({ invalid: true });
|
||||
|
||||
await expect(
|
||||
setup.caller.messages.respond({
|
||||
generalId: setup.actor.id,
|
||||
messageId: 31,
|
||||
response: true,
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'BAD_REQUEST', message: '삭제된 메시지에는 응답할 수 없습니다.' });
|
||||
expect(setup.diplomacyUpdate).not.toHaveBeenCalled();
|
||||
expect(setup.messageUpdateMany).not.toHaveBeenCalled();
|
||||
expect(setup.requestCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts a diplomatic prompt atomically and applies the legacy non-aggression effects', async () => {
|
||||
const setup = buildDiplomaticContext();
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
toPublicRealtimeEvent as convertPublicRealtimeEvent,
|
||||
} from '../src/realtime/publicEvent.js';
|
||||
|
||||
const viewer = { generalId: 7, cityId: 3, nationId: 2 } as const;
|
||||
const viewer = { generalId: 7, cityId: 3, nationId: 2, canReadDiplomacy: true } as const;
|
||||
const refreshGrant = 'opaque-grant';
|
||||
const toPublicRealtimeEvent = (event: RealtimeEvent, identities: Parameters<typeof convertPublicRealtimeEvent>[1]) =>
|
||||
convertPublicRealtimeEvent(event, identities, () => refreshGrant);
|
||||
@@ -198,6 +198,37 @@ describe('public realtime event privacy boundary', () => {
|
||||
expect(toPublicRealtimeEvent({ ...event, mailbox: MESSAGE_MAILBOX_NATIONAL_BASE + 8 }, [viewer])).toBeNull();
|
||||
});
|
||||
|
||||
it('suppresses diplomacy-only wake-ups for viewers without secret-message access', () => {
|
||||
const mailbox = MESSAGE_MAILBOX_NATIONAL_BASE + viewer.nationId;
|
||||
const blockedViewer = { ...viewer, canReadDiplomacy: false };
|
||||
expect(
|
||||
toPublicRealtimeEvent(
|
||||
{
|
||||
type: 'messageCreated',
|
||||
at: '2026-09-04T00:00:00Z',
|
||||
mailbox,
|
||||
msgType: 'diplomacy',
|
||||
messageId: 1,
|
||||
senderId: 2,
|
||||
},
|
||||
[blockedViewer]
|
||||
)
|
||||
).toBeNull();
|
||||
expect(
|
||||
toPublicRealtimeEvent({ type: 'messagesChanged', mailboxes: [], diplomacyMailboxes: [mailbox] }, [
|
||||
blockedViewer,
|
||||
])
|
||||
).toBeNull();
|
||||
expect(
|
||||
toPublicRealtimeEvent({ type: 'messagesChanged', mailboxes: [mailbox], diplomacyMailboxes: [mailbox] }, [
|
||||
blockedViewer,
|
||||
])
|
||||
).toEqual({ type: 'messagesInvalidated', refreshGrant });
|
||||
expect(
|
||||
toPublicRealtimeEvent({ type: 'messagesChanged', mailboxes: [], diplomacyMailboxes: [mailbox] }, [viewer])
|
||||
).toEqual({ type: 'messagesInvalidated', refreshGrant });
|
||||
});
|
||||
|
||||
it('redacts durable mailbox wake-ups to one viewer-safe boolean event', () => {
|
||||
const event: RealtimeEvent = {
|
||||
type: 'messagesChanged',
|
||||
@@ -225,6 +256,12 @@ describe('public realtime event privacy boundary', () => {
|
||||
viewer
|
||||
)
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldReloadRealtimeViewerIdentity(
|
||||
turnEvent({ ...createEmptyRealtimeReadModelChanges(), nationIds: [viewer.nationId] }),
|
||||
viewer
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('merges previous and committed identities across an ownership transition', () => {
|
||||
|
||||
@@ -12,6 +12,7 @@ const payload = (
|
||||
| 'access.general'
|
||||
| 'dashboard.global'
|
||||
| 'messages.mailbox'
|
||||
| 'messages.diplomacyMailbox'
|
||||
| 'tournament'
|
||||
| 'betting'
|
||||
) => ({
|
||||
@@ -19,7 +20,11 @@ const payload = (
|
||||
changes: [
|
||||
[
|
||||
domain,
|
||||
domain === 'front.general' || domain === 'access.general' ? 7 : domain === 'messages.mailbox' ? 9999 : 0,
|
||||
domain === 'front.general' || domain === 'access.general'
|
||||
? 7
|
||||
: domain === 'messages.mailbox' || domain === 'messages.diplomacyMailbox'
|
||||
? 9999
|
||||
: 0,
|
||||
'1',
|
||||
],
|
||||
],
|
||||
@@ -114,6 +119,25 @@ describe('ReadModelOutboxWorker', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('labels diplomacy-only mailbox wake-ups for viewer permission filtering', async () => {
|
||||
const fixture = createFixture([{ id: 15n, payload: payload('messages.diplomacyMailbox'), attempts: 1 }]);
|
||||
const worker = new ReadModelOutboxWorker(fixture.db, fixture.redis, 'che:default', {
|
||||
owner: 'worker-test',
|
||||
intervalMs: 60_000,
|
||||
});
|
||||
|
||||
worker.start();
|
||||
await vi.waitFor(() => expect(fixture.executeRaw).toHaveBeenCalledTimes(1));
|
||||
await worker.stop();
|
||||
|
||||
expect(fixture.incr).not.toHaveBeenCalled();
|
||||
expect(JSON.parse(String(fixture.publish.mock.calls[0]?.[1]))).toEqual({
|
||||
type: 'messagesChanged',
|
||||
mailboxes: [],
|
||||
diplomacyMailboxes: [9999],
|
||||
});
|
||||
});
|
||||
|
||||
it('coalesces repeated wakeups into one trailing batch and waits for it on shutdown', async () => {
|
||||
let releaseFirst: (() => void) | undefined;
|
||||
const first = new Promise<readonly object[]>((resolve) => {
|
||||
|
||||
Reference in New Issue
Block a user