fix: 시즌별 설문 알림 상태를 분리
설문 확인 cursor와 메인 실시간 탭 범위를 reset 고유 serverId로 구분한다. 설문 생성의 daemon 비의존 outbox 경로와 이전 시즌 cursor 회귀를 테스트한다.
This commit is contained in:
@@ -43,6 +43,7 @@ export const zWorldStateConfig = z.object({
|
|||||||
export type WorldStateConfig = z.infer<typeof zWorldStateConfig>;
|
export type WorldStateConfig = z.infer<typeof zWorldStateConfig>;
|
||||||
|
|
||||||
export const zWorldStateMeta = z.object({
|
export const zWorldStateMeta = z.object({
|
||||||
|
serverId: z.string().optional(),
|
||||||
starttime: z.string().optional(),
|
starttime: z.string().optional(),
|
||||||
opentime: z.string().optional(),
|
opentime: z.string().optional(),
|
||||||
preopenAt: z.string().optional(),
|
preopenAt: z.string().optional(),
|
||||||
|
|||||||
@@ -885,11 +885,16 @@ export const generalRouter = router({
|
|||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
const worldMeta = asRecord(worldState.meta);
|
const worldMeta = asRecord(worldState.meta);
|
||||||
|
const serverId =
|
||||||
|
typeof worldMeta.serverId === 'string' && worldMeta.serverId.trim()
|
||||||
|
? worldMeta.serverId.trim()
|
||||||
|
: ctx.profile?.name || 'game';
|
||||||
const rawLastExecuted = worldMeta.lastTurnTime ?? worldMeta.turntime;
|
const rawLastExecuted = worldMeta.lastTurnTime ?? worldMeta.turntime;
|
||||||
const parsedLastExecuted =
|
const parsedLastExecuted =
|
||||||
typeof rawLastExecuted === 'string' || rawLastExecuted instanceof Date ? new Date(rawLastExecuted) : null;
|
typeof rawLastExecuted === 'string' || rawLastExecuted instanceof Date ? new Date(rawLastExecuted) : null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
serverId,
|
||||||
onlineUserCount: onlineGenerals.length,
|
onlineUserCount: onlineGenerals.length,
|
||||||
onlineNations,
|
onlineNations,
|
||||||
onlineGenerals: myOnlineGenerals,
|
onlineGenerals: myOnlineGenerals,
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ export const lobbyRouter = router({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
serverId: worldState.meta.serverId?.trim() || ctx.profile?.name || 'game',
|
||||||
year: worldState.currentYear,
|
year: worldState.currentYear,
|
||||||
month: worldState.currentMonth,
|
month: worldState.currentMonth,
|
||||||
userCnt,
|
userCnt,
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ describe('lobby season state', () => {
|
|||||||
.createCaller(
|
.createCaller(
|
||||||
buildContext(
|
buildContext(
|
||||||
{
|
{
|
||||||
|
serverId: 'che_260819_season',
|
||||||
preopenAt: '2026-08-19 22:00:00',
|
preopenAt: '2026-08-19 22:00:00',
|
||||||
opentime: '2026-08-19 23:00:00',
|
opentime: '2026-08-19 23:00:00',
|
||||||
scenarioMeta: { title: '【가상모드27-b】 아시아 명장전(비급)' },
|
scenarioMeta: { title: '【가상모드27-b】 아시아 명장전(비급)' },
|
||||||
@@ -101,6 +102,7 @@ describe('lobby season state', () => {
|
|||||||
.lobby.info();
|
.lobby.info();
|
||||||
|
|
||||||
expect(result).toMatchObject({
|
expect(result).toMatchObject({
|
||||||
|
serverId: 'che_260819_season',
|
||||||
preopenAt: '2026-08-19 22:00:00',
|
preopenAt: '2026-08-19 22:00:00',
|
||||||
opentime: '2026-08-19 23:00:00',
|
opentime: '2026-08-19 23:00:00',
|
||||||
scenarioTitle: '【가상모드27-b】 아시아 명장전(비급)',
|
scenarioTitle: '【가상모드27-b】 아시아 명장전(비급)',
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ const buildContext = (options: { auth?: GameSessionTokenPayload | null; hasVoted
|
|||||||
findFirst: vi.fn(async () => ({
|
findFirst: vi.fn(async () => ({
|
||||||
tickSeconds: 3600,
|
tickSeconds: 3600,
|
||||||
meta: {
|
meta: {
|
||||||
|
serverId: 'che_260819_front',
|
||||||
lastTurnTime: '2026-07-26T10:00:00.000Z',
|
lastTurnTime: '2026-07-26T10:00:00.000Z',
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
@@ -87,6 +88,7 @@ describe('general.getFrontStatus', () => {
|
|||||||
const result = await caller.general.getFrontStatus();
|
const result = await caller.general.getFrontStatus();
|
||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
|
serverId: 'che_260819_front',
|
||||||
onlineUserCount: 3,
|
onlineUserCount: 3,
|
||||||
onlineNations: '【촉】, 【위】',
|
onlineNations: '【촉】, 【위】',
|
||||||
onlineGenerals: '유비, 관우',
|
onlineGenerals: '유비, 관우',
|
||||||
|
|||||||
@@ -6,10 +6,23 @@ import type { ReadModelOutboxDatabase } from '@sammo-ts/infra';
|
|||||||
import { ReadModelOutboxWorker } from '../src/realtime/outboxWorker.js';
|
import { ReadModelOutboxWorker } from '../src/realtime/outboxWorker.js';
|
||||||
|
|
||||||
const payload = (
|
const payload = (
|
||||||
domain: 'front.general' | 'access.general' | 'dashboard.global' | 'messages.mailbox' | 'tournament' | 'betting'
|
domain:
|
||||||
|
| 'front.global'
|
||||||
|
| 'front.general'
|
||||||
|
| 'access.general'
|
||||||
|
| 'dashboard.global'
|
||||||
|
| 'messages.mailbox'
|
||||||
|
| 'tournament'
|
||||||
|
| 'betting'
|
||||||
) => ({
|
) => ({
|
||||||
version: 1,
|
version: 1,
|
||||||
changes: [[domain, domain === 'front.general' || domain === 'access.general' ? 7 : domain === 'messages.mailbox' ? 9999 : 0, '1']],
|
changes: [
|
||||||
|
[
|
||||||
|
domain,
|
||||||
|
domain === 'front.general' || domain === 'access.general' ? 7 : domain === 'messages.mailbox' ? 9999 : 0,
|
||||||
|
'1',
|
||||||
|
],
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
const createFixture = (rows: readonly object[]) => {
|
const createFixture = (rows: readonly object[]) => {
|
||||||
@@ -26,6 +39,23 @@ const createFixture = (rows: readonly object[]) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe('ReadModelOutboxWorker', () => {
|
describe('ReadModelOutboxWorker', () => {
|
||||||
|
it('publishes a survey-style global front-status invalidation without a turn daemon', async () => {
|
||||||
|
const fixture = createFixture([{ id: 10n, payload: payload('front.global'), 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.updateMany).toHaveBeenCalledTimes(1));
|
||||||
|
await worker.stop();
|
||||||
|
|
||||||
|
expect(JSON.parse(String(fixture.publish.mock.calls[0]?.[1]))).toMatchObject({
|
||||||
|
type: 'readModelChanged',
|
||||||
|
changes: { frontStatusChanged: true },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('publishes a legacy internal readModelChanged event and acknowledges the durable row', async () => {
|
it('publishes a legacy internal readModelChanged event and acknowledges the durable row', async () => {
|
||||||
const fixture = createFixture([{ id: 11n, payload: payload('front.general'), attempts: 1 }]);
|
const fixture = createFixture([{ id: 11n, payload: payload('front.general'), attempts: 1 }]);
|
||||||
const worker = new ReadModelOutboxWorker(fixture.db, fixture.redis, 'che:default', {
|
const worker = new ReadModelOutboxWorker(fixture.db, fixture.redis, 'che:default', {
|
||||||
|
|||||||
@@ -241,6 +241,7 @@ describe('vote router actor and permission boundaries', () => {
|
|||||||
).resolves.toEqual({ ok: true });
|
).resolves.toEqual({ ok: true });
|
||||||
|
|
||||||
expect(fixture.changeJournal.snapshot()).toEqual([{ domain: 'front.global', entityId: 0 }]);
|
expect(fixture.changeJournal.snapshot()).toEqual([{ domain: 'front.global', entityId: 0 }]);
|
||||||
|
expect(fixture.requestCommand).not.toHaveBeenCalled();
|
||||||
expect(fixture.redisIncr).not.toHaveBeenCalled();
|
expect(fixture.redisIncr).not.toHaveBeenCalled();
|
||||||
expect(fixture.redisPublish).not.toHaveBeenCalled();
|
expect(fixture.redisPublish).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ type NavigationFixture = {
|
|||||||
refCommandCategories?: boolean;
|
refCommandCategories?: boolean;
|
||||||
currentYear?: number;
|
currentYear?: number;
|
||||||
currentMonth?: number;
|
currentMonth?: number;
|
||||||
|
serverId?: string;
|
||||||
scenarioTitle?: string;
|
scenarioTitle?: string;
|
||||||
nationColor?: string;
|
nationColor?: string;
|
||||||
lastExecuted?: string | null;
|
lastExecuted?: string | null;
|
||||||
@@ -401,6 +402,7 @@ const installFixture = async (page: Page, state: NavigationFixture) => {
|
|||||||
if (operation === 'lobby.info') {
|
if (operation === 'lobby.info') {
|
||||||
return response({
|
return response({
|
||||||
myGeneral: { id: 7, name: '메뉴검증장수' },
|
myGeneral: { id: 7, name: '메뉴검증장수' },
|
||||||
|
serverId: state.serverId ?? 'che_fixture_season',
|
||||||
year: state.currentYear ?? 185,
|
year: state.currentYear ?? 185,
|
||||||
month: state.currentMonth ?? 1,
|
month: state.currentMonth ?? 1,
|
||||||
turnTerm: 10,
|
turnTerm: 10,
|
||||||
@@ -547,6 +549,7 @@ const installFixture = async (page: Page, state: NavigationFixture) => {
|
|||||||
}
|
}
|
||||||
if (operation === 'general.getFrontStatus') {
|
if (operation === 'general.getFrontStatus') {
|
||||||
return response({
|
return response({
|
||||||
|
serverId: state.serverId ?? 'che_fixture_season',
|
||||||
onlineUserCount: 1,
|
onlineUserCount: 1,
|
||||||
onlineNations: '위(1)',
|
onlineNations: '위(1)',
|
||||||
onlineGenerals: '메뉴검증장수',
|
onlineGenerals: '메뉴검증장수',
|
||||||
@@ -836,6 +839,32 @@ const persistArtifact = async (page: Page, name: string) => {
|
|||||||
]);
|
]);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
test('scopes the new-survey notice cursor to the reset-specific server ID', async ({ page }) => {
|
||||||
|
const state: NavigationFixture = {
|
||||||
|
officerLevel: 5,
|
||||||
|
permission: 2,
|
||||||
|
nationLevel: 3,
|
||||||
|
stage: 0,
|
||||||
|
npcMode: 1,
|
||||||
|
serverId: 'che_260819_new_season',
|
||||||
|
latestVote: { id: 1, title: '가오픈 설문', hasVoted: false },
|
||||||
|
generalMeCalls: 0,
|
||||||
|
operations: [],
|
||||||
|
};
|
||||||
|
await installFixture(page, state);
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
localStorage.setItem('state.che.lastVote', '99');
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitForMain(page);
|
||||||
|
|
||||||
|
await expect(page.locator('.survey-notice')).toContainText('새로운 설문조사가 있습니다.');
|
||||||
|
await expect
|
||||||
|
.poll(() => page.evaluate(() => localStorage.getItem('state.che_260819_new_season.lastVote')))
|
||||||
|
.toBe('1');
|
||||||
|
expect(await page.evaluate(() => localStorage.getItem('state.che.lastVote'))).toBe('99');
|
||||||
|
});
|
||||||
|
|
||||||
test('desktop menus preserve ref columns, prefix-safe routes, and controlled dropdown behavior', async ({ page }) => {
|
test('desktop menus preserve ref columns, prefix-safe routes, and controlled dropdown behavior', async ({ page }) => {
|
||||||
const state: NavigationFixture = {
|
const state: NavigationFixture = {
|
||||||
officerLevel: 5,
|
officerLevel: 5,
|
||||||
|
|||||||
@@ -293,7 +293,9 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
surveyNotice.value = null;
|
surveyNotice.value = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const serverId = session.profile?.split(':', 1)[0] ?? 'game';
|
// Ref scopes this cursor with the reset-specific UniqueConst::$serverID.
|
||||||
|
// A profile name is stable across seasons, while vote IDs restart after reset.
|
||||||
|
const serverId = nextStatus.serverId;
|
||||||
const storageKey = `state.${serverId}.lastVote`;
|
const storageKey = `state.${serverId}.lastVote`;
|
||||||
const lastSeenVoteId = Number.parseInt(window.localStorage.getItem(storageKey) ?? '0', 10);
|
const lastSeenVoteId = Number.parseInt(window.localStorage.getItem(storageKey) ?? '0', 10);
|
||||||
if (latestVote.id <= (Number.isFinite(lastSeenVoteId) ? lastSeenVoteId : 0)) {
|
if (latestVote.id <= (Number.isFinite(lastSeenVoteId) ? lastSeenVoteId : 0)) {
|
||||||
@@ -1079,7 +1081,10 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
|
|
||||||
const profile = session.profile ?? 'game';
|
const profile = session.profile ?? 'game';
|
||||||
const account = session.user?.id ?? `general-${generalId.value}`;
|
const account = session.user?.id ?? `general-${generalId.value}`;
|
||||||
const scope = `${encodeURIComponent(profile)}:${encodeURIComponent(account)}`;
|
// Do not let a tab that survived a reset exchange stale read-model patches
|
||||||
|
// with tabs belonging to the next season of the same profile.
|
||||||
|
const serverId = frontStatus.value?.serverId ?? profile;
|
||||||
|
const scope = `${encodeURIComponent(serverId)}:${encodeURIComponent(profile)}:${encodeURIComponent(account)}`;
|
||||||
if (realtimeCoordinator && realtimeCoordinatorScope === scope) return;
|
if (realtimeCoordinator && realtimeCoordinatorScope === scope) return;
|
||||||
|
|
||||||
closeRealtimeCoordinator();
|
closeRealtimeCoordinator();
|
||||||
@@ -1243,6 +1248,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
session.user?.id,
|
session.user?.id,
|
||||||
generalId.value,
|
generalId.value,
|
||||||
accessLimited.value,
|
accessLimited.value,
|
||||||
|
frontStatus.value?.serverId,
|
||||||
],
|
],
|
||||||
([active, enabled, ready, hasGeneral, , , , , limited]) => {
|
([active, enabled, ready, hasGeneral, , , , , limited]) => {
|
||||||
realtimeStatus.value = !enabled || limited ? 'paused' : realtimeStatus.value;
|
realtimeStatus.value = !enabled || limited ? 'paused' : realtimeStatus.value;
|
||||||
|
|||||||
Reference in New Issue
Block a user