fix(game-api): 장수 소유자 확인 메시지를 복구
Ref와 같이 확인자와 확인 대상에게 시스템 개인 메시지를 저장하고, 두 mailbox 변경을 durable journal에 기록한다. 실패 경계와 실제 PostgreSQL 저장 회귀 테스트를 포함한다.
This commit is contained in:
@@ -7,12 +7,12 @@ import {
|
||||
ItemLoader,
|
||||
isItemKey,
|
||||
loadWarTraitModules,
|
||||
sendMessage,
|
||||
WarTraitLoader,
|
||||
WAR_TRAIT_KEYS,
|
||||
isWarTraitKey,
|
||||
} from '@sammo-ts/logic';
|
||||
import type { InheritBuffType } from '@sammo-ts/logic';
|
||||
import type { ItemSlot } from '@sammo-ts/logic';
|
||||
import type { InheritBuffType, ItemSlot, MessageDraft, MessageRecordDraft } from '@sammo-ts/logic';
|
||||
import { simpleSerialize } from '@sammo-ts/logic/war/utils.js';
|
||||
import { resolveLegacyCompatibleUniqueConfig } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js';
|
||||
import {
|
||||
@@ -28,6 +28,9 @@ import {
|
||||
} from '../../services/inheritance.js';
|
||||
import type { GameApiContext, WorldStateRow } from '../../context.js';
|
||||
import { openAuctionWithDaemon } from '../../auction/open.js';
|
||||
import { buildTargetFromGeneral } from '../../messages/targets.js';
|
||||
import { insertMessage } from '../../messages/store.js';
|
||||
import { loadCurrentGameTime } from '../../services/gameClock.js';
|
||||
|
||||
const BUFF_KEYS: InheritBuffType[] = [
|
||||
'warAvoidRatio',
|
||||
@@ -881,11 +884,8 @@ export const inheritRouter = router({
|
||||
}
|
||||
|
||||
const [general, target] = await Promise.all([
|
||||
ctx.db.general.findFirst({ where: { userId }, select: { id: true } }),
|
||||
ctx.db.general.findUnique({
|
||||
where: { id: input.targetGeneralId },
|
||||
select: { id: true, name: true, userId: true, meta: true },
|
||||
}),
|
||||
ctx.db.general.findFirst({ where: { userId } }),
|
||||
ctx.db.general.findUnique({ where: { id: input.targetGeneralId } }),
|
||||
]);
|
||||
if (!general) {
|
||||
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '장수가 존재하지 않습니다.' });
|
||||
@@ -909,6 +909,42 @@ export const inheritRouter = router({
|
||||
worldState.currentMonth,
|
||||
`${inheritConst.inheritCheckOwnerPoint} 포인트로 장수 소유자 확인`
|
||||
);
|
||||
|
||||
const [generalTarget, checkedTarget, gameTime] = await Promise.all([
|
||||
buildTargetFromGeneral(ctx.db, general),
|
||||
buildTargetFromGeneral(ctx.db, target),
|
||||
loadCurrentGameTime(ctx.db),
|
||||
]);
|
||||
const systemTarget: MessageDraft['src'] = {
|
||||
generalId: 0,
|
||||
generalName: '',
|
||||
nationId: 0,
|
||||
nationName: 'System',
|
||||
color: '#000000',
|
||||
icon: '',
|
||||
};
|
||||
const validUntil = new Date('9999-12-31T00:00:00.000Z');
|
||||
const sendSystemPrivateMessage = async (dest: MessageDraft['dest'], text: string): Promise<void> => {
|
||||
await sendMessage(
|
||||
{
|
||||
insertMessage: (draft: MessageRecordDraft) => insertMessage(ctx.db, draft),
|
||||
},
|
||||
{
|
||||
msgType: 'private',
|
||||
src: systemTarget,
|
||||
dest,
|
||||
text,
|
||||
time: gameTime.now,
|
||||
validUntil,
|
||||
option: {},
|
||||
},
|
||||
{ sendDestOnly: true }
|
||||
);
|
||||
ctx.changeJournal?.mark('messages.mailbox', dest.generalId);
|
||||
};
|
||||
|
||||
await sendSystemPrivateMessage(generalTarget, `${target.name}의 소유자는 ${ownerName} 입니다.`);
|
||||
await sendSystemPrivateMessage(checkedTarget, '소유자명이 누군가에 의해 확인되었습니다.');
|
||||
return { ok: true, ownerName, targetName: target.name };
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import { describe, expect, it } from 'vitest';
|
||||
const classifications = {
|
||||
durableJournal: [
|
||||
'betting.bet',
|
||||
'inherit.checkOwner',
|
||||
'messages.delete',
|
||||
'messages.respond',
|
||||
'messages.send',
|
||||
@@ -36,7 +37,6 @@ const classifications = {
|
||||
'diplomacy.respondLetter',
|
||||
'diplomacy.rollbackLetter',
|
||||
'diplomacy.sendLetter',
|
||||
'inherit.checkOwner',
|
||||
'join.getSelectionPool',
|
||||
'join.listPossessCandidates',
|
||||
'messages.readLatest',
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||
import { createGamePostgresConnector, type GamePrismaClient, type RedisConnector } from '@sammo-ts/infra';
|
||||
|
||||
import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
|
||||
import { InMemoryBattleSimTransport } from '../src/battleSim/inMemoryTransport.js';
|
||||
import type { GameApiContext } from '../src/context.js';
|
||||
import { InMemoryTurnDaemonTransport } from '../src/daemon/inMemoryTransport.js';
|
||||
import { InMemoryFlushStore } from '../src/auth/flushStore.js';
|
||||
import { appRouter } from '../src/router.js';
|
||||
|
||||
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
|
||||
const integration = describe.skipIf(!databaseUrl);
|
||||
const actorGeneralId = 8_701;
|
||||
const checkedGeneralId = 8_702;
|
||||
const actorNationId = 871;
|
||||
const checkedNationId = 872;
|
||||
const actorUserId = 'inherit-owner-message-actor';
|
||||
const checkedUserId = 'inherit-owner-message-checked';
|
||||
|
||||
const auth: GameSessionTokenPayload = {
|
||||
version: 1,
|
||||
profile: 'che:inherit-owner-message',
|
||||
issuedAt: '2026-08-19T00:00:00.000Z',
|
||||
expiresAt: '2026-08-20T00:00:00.000Z',
|
||||
sessionId: 'inherit-owner-message-session',
|
||||
user: {
|
||||
id: actorUserId,
|
||||
username: actorUserId,
|
||||
displayName: '확인자 계정',
|
||||
roles: ['user'],
|
||||
},
|
||||
sanctions: {},
|
||||
};
|
||||
|
||||
const hasMailboxChange = (payload: unknown): boolean => {
|
||||
if (!payload || typeof payload !== 'object' || !('changes' in payload)) return false;
|
||||
const changes = (payload as { changes?: unknown }).changes;
|
||||
if (!Array.isArray(changes)) return false;
|
||||
const mailboxes = new Set([actorGeneralId, checkedGeneralId]);
|
||||
return changes.some(
|
||||
(change) =>
|
||||
Array.isArray(change) &&
|
||||
change[0] === 'messages.mailbox' &&
|
||||
typeof change[1] === 'number' &&
|
||||
mailboxes.has(change[1])
|
||||
);
|
||||
};
|
||||
|
||||
integration('inherit owner lookup private messages', () => {
|
||||
let db: GamePrismaClient;
|
||||
let closeDb: (() => Promise<void>) | undefined;
|
||||
let worldStateId: number;
|
||||
|
||||
const buildContext = (requestId: string): GameApiContext => {
|
||||
const redisClient = {
|
||||
get: async () => null,
|
||||
set: async () => null,
|
||||
};
|
||||
return {
|
||||
requestId,
|
||||
db,
|
||||
redis: redisClient as unknown as RedisConnector['client'],
|
||||
turnDaemon: new InMemoryTurnDaemonTransport(),
|
||||
battleSim: new InMemoryBattleSimTransport(),
|
||||
profile: { id: 'che', scenario: 'inherit-owner-message', name: 'che:inherit-owner-message' },
|
||||
uploadDir: 'uploads',
|
||||
uploadPath: '/uploads',
|
||||
uploadPublicUrl: null,
|
||||
auth,
|
||||
accessTokenStore: new RedisAccessTokenStore(redisClient, 'che:inherit-owner-message'),
|
||||
flushStore: new InMemoryFlushStore(),
|
||||
gameTokenSecret: 'test-secret',
|
||||
readModelOutbox: { wake: vi.fn() },
|
||||
};
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
const connector = createGamePostgresConnector({ url: databaseUrl! });
|
||||
await connector.connect();
|
||||
db = connector.prisma;
|
||||
closeDb = () => connector.disconnect();
|
||||
|
||||
await db.inputEvent.deleteMany({ where: { actorUserId } });
|
||||
await db.message.deleteMany({ where: { mailbox: { in: [actorGeneralId, checkedGeneralId] } } });
|
||||
await db.inheritanceLog.deleteMany({ where: { userId: actorUserId } });
|
||||
await db.inheritancePoint.deleteMany({ where: { userId: actorUserId } });
|
||||
await db.general.deleteMany({ where: { id: { in: [actorGeneralId, checkedGeneralId] } } });
|
||||
await db.nation.deleteMany({ where: { id: { in: [actorNationId, checkedNationId] } } });
|
||||
await db.readModelRevision.deleteMany({
|
||||
where: { domain: 'messages.mailbox', entityId: { in: [actorGeneralId, checkedGeneralId] } },
|
||||
});
|
||||
|
||||
await db.nation.createMany({
|
||||
data: [
|
||||
{ id: actorNationId, name: '확인국', color: '#123456', level: 2 },
|
||||
{ id: checkedNationId, name: '피확인국', color: '#654321', level: 3 },
|
||||
],
|
||||
});
|
||||
await db.general.createMany({
|
||||
data: [
|
||||
{
|
||||
id: actorGeneralId,
|
||||
userId: actorUserId,
|
||||
name: '확인장수',
|
||||
nationId: actorNationId,
|
||||
cityId: 1,
|
||||
npcState: 0,
|
||||
turnTime: new Date('0200-01-01T00:00:00.000Z'),
|
||||
meta: { ownerName: '확인자 계정' },
|
||||
},
|
||||
{
|
||||
id: checkedGeneralId,
|
||||
userId: checkedUserId,
|
||||
name: '피확인장수',
|
||||
nationId: checkedNationId,
|
||||
cityId: 1,
|
||||
npcState: 0,
|
||||
turnTime: new Date('0200-01-01T00:00:00.000Z'),
|
||||
meta: { ownerName: '피확인 계정' },
|
||||
},
|
||||
],
|
||||
});
|
||||
await db.inheritancePoint.create({
|
||||
data: { userId: actorUserId, key: 'previous', value: 1_500 },
|
||||
});
|
||||
const world = await db.worldState.create({
|
||||
data: {
|
||||
scenarioCode: 'inherit-owner-message',
|
||||
currentYear: 200,
|
||||
currentMonth: 4,
|
||||
tickSeconds: 600,
|
||||
config: { const: { inheritCheckOwnerPoint: 1_000 } },
|
||||
meta: { isUnited: 0 },
|
||||
},
|
||||
});
|
||||
worldStateId = world.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
const outboxes = await db.readModelOutbox.findMany({ select: { id: true, payload: true } });
|
||||
const outboxIds = outboxes.filter(({ payload }) => hasMailboxChange(payload)).map(({ id }) => id);
|
||||
if (outboxIds.length > 0) {
|
||||
await db.readModelOutbox.deleteMany({ where: { id: { in: outboxIds } } });
|
||||
}
|
||||
await db.inputEvent.deleteMany({ where: { actorUserId } });
|
||||
await db.message.deleteMany({ where: { mailbox: { in: [actorGeneralId, checkedGeneralId] } } });
|
||||
await db.inheritanceLog.deleteMany({ where: { userId: actorUserId } });
|
||||
await db.inheritancePoint.deleteMany({ where: { userId: actorUserId } });
|
||||
await db.general.deleteMany({ where: { id: { in: [actorGeneralId, checkedGeneralId] } } });
|
||||
await db.nation.deleteMany({ where: { id: { in: [actorNationId, checkedNationId] } } });
|
||||
await db.readModelRevision.deleteMany({
|
||||
where: { domain: 'messages.mailbox', entityId: { in: [actorGeneralId, checkedGeneralId] } },
|
||||
});
|
||||
await db.worldState.delete({ where: { id: worldStateId } });
|
||||
await closeDb?.();
|
||||
});
|
||||
|
||||
it('commits the point charge, log, and both Ref-compatible private messages', async () => {
|
||||
const requestId = 'integration:inherit-owner-message:success';
|
||||
await expect(
|
||||
appRouter.createCaller(buildContext(requestId)).inherit.checkOwner({ targetGeneralId: checkedGeneralId })
|
||||
).resolves.toEqual({
|
||||
ok: true,
|
||||
ownerName: '피확인 계정',
|
||||
targetName: '피확인장수',
|
||||
});
|
||||
|
||||
await expect(
|
||||
db.inheritancePoint.findUniqueOrThrow({
|
||||
where: { userId_key: { userId: actorUserId, key: 'previous' } },
|
||||
})
|
||||
).resolves.toMatchObject({ value: 500 });
|
||||
await expect(db.inheritanceLog.findMany({ where: { userId: actorUserId } })).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
year: 200,
|
||||
month: 4,
|
||||
text: '1000 포인트로 장수 소유자 확인',
|
||||
}),
|
||||
]);
|
||||
|
||||
const messages = await db.message.findMany({
|
||||
where: { mailbox: { in: [actorGeneralId, checkedGeneralId] } },
|
||||
orderBy: { mailbox: 'asc' },
|
||||
});
|
||||
expect(messages).toHaveLength(2);
|
||||
expect(
|
||||
messages.map(({ mailbox, type, src, dest, message }) => ({ mailbox, type, src, dest, message }))
|
||||
).toEqual([
|
||||
{
|
||||
mailbox: actorGeneralId,
|
||||
type: 'private',
|
||||
src: 0,
|
||||
dest: actorGeneralId,
|
||||
message: expect.objectContaining({
|
||||
src: expect.objectContaining({ generalId: 0, nationName: 'System' }),
|
||||
dest: expect.objectContaining({ generalId: actorGeneralId, generalName: '확인장수' }),
|
||||
text: '피확인장수의 소유자는 피확인 계정 입니다.',
|
||||
}),
|
||||
},
|
||||
{
|
||||
mailbox: checkedGeneralId,
|
||||
type: 'private',
|
||||
src: 0,
|
||||
dest: checkedGeneralId,
|
||||
message: expect.objectContaining({
|
||||
src: expect.objectContaining({ generalId: 0, nationName: 'System' }),
|
||||
dest: expect.objectContaining({ generalId: checkedGeneralId, generalName: '피확인장수' }),
|
||||
text: '소유자명이 누군가에 의해 확인되었습니다.',
|
||||
}),
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(
|
||||
db.inputEvent.findUniqueOrThrow({ where: { requestId: `${requestId}:inherit.checkOwner` } })
|
||||
).resolves.toMatchObject({ status: 'SUCCEEDED', actorUserId });
|
||||
await expect(
|
||||
db.readModelRevision.findMany({
|
||||
where: { domain: 'messages.mailbox', entityId: { in: [actorGeneralId, checkedGeneralId] } },
|
||||
orderBy: { entityId: 'asc' },
|
||||
})
|
||||
).resolves.toEqual([
|
||||
expect.objectContaining({ domain: 'messages.mailbox', entityId: actorGeneralId, revision: 1n }),
|
||||
expect.objectContaining({ domain: 'messages.mailbox', entityId: checkedGeneralId, revision: 1n }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,9 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ChangeJournal } from '@sammo-ts/common';
|
||||
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||
import type { RedisConnector } from '@sammo-ts/infra';
|
||||
import type { MessagePayload } from '@sammo-ts/logic';
|
||||
|
||||
import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
|
||||
import { InMemoryFlushStore } from '../src/auth/flushStore.js';
|
||||
@@ -91,6 +93,14 @@ const worldState = {
|
||||
updatedAt: new Date('2026-07-26T00:00:00Z'),
|
||||
};
|
||||
|
||||
interface CapturedMessage {
|
||||
mailbox: number;
|
||||
type: string;
|
||||
src: number;
|
||||
dest: number;
|
||||
payload: MessagePayload;
|
||||
}
|
||||
|
||||
const buildContext = (options: {
|
||||
auth?: GameSessionTokenPayload | null;
|
||||
general?: GeneralRow | null;
|
||||
@@ -123,8 +133,30 @@ const buildContext = (options: {
|
||||
const: options.configConst,
|
||||
},
|
||||
};
|
||||
const messageRows: CapturedMessage[] = [];
|
||||
const queryRaw = vi.fn(async (query: unknown, ...values: unknown[]) => {
|
||||
const queryStrings = Array.isArray(query)
|
||||
? query.map(String)
|
||||
: ((query as { strings?: readonly string[] } | null)?.strings ?? []);
|
||||
const sql = queryStrings.join(' ');
|
||||
if (sql.includes('INSERT INTO message')) {
|
||||
const payload = JSON.parse(String(values[8])) as MessagePayload;
|
||||
messageRows.push({
|
||||
mailbox: Number(values[0]),
|
||||
type: String(values[1]),
|
||||
src: Number(values[2]),
|
||||
dest: Number(values[3]),
|
||||
payload,
|
||||
});
|
||||
return [{ id: 100 + messageRows.length }];
|
||||
}
|
||||
if (sql.includes('FROM inheritance_point')) {
|
||||
return [{ value: options.inheritancePoint ?? 10_000 }];
|
||||
}
|
||||
throw new Error(`Unexpected raw query in inherit router fixture: ${sql}`);
|
||||
});
|
||||
const db = {
|
||||
$queryRaw: vi.fn(async () => [{ value: options.inheritancePoint ?? 10_000 }]),
|
||||
$queryRaw: queryRaw,
|
||||
worldState: {
|
||||
findFirst: vi.fn(async () => activeWorldState),
|
||||
},
|
||||
@@ -137,6 +169,11 @@ const buildContext = (options: {
|
||||
target?.id === where.id ? target : null
|
||||
),
|
||||
},
|
||||
nation: {
|
||||
findUnique: vi.fn(async ({ where }: { where: { id: number } }) =>
|
||||
where.id === 1 ? { id: 1, name: '촉', color: '#ff0000' } : null
|
||||
),
|
||||
},
|
||||
inheritancePoint: {
|
||||
upsert: pointUpsert,
|
||||
},
|
||||
@@ -156,8 +193,10 @@ const buildContext = (options: {
|
||||
},
|
||||
'che:default'
|
||||
);
|
||||
const changeJournal = new ChangeJournal();
|
||||
const context: GameApiContext = {
|
||||
db: db as unknown as DatabaseClient,
|
||||
changeJournal,
|
||||
redis: {} as RedisConnector['client'],
|
||||
turnDaemon: { requestCommand } as unknown as TurnDaemonTransport,
|
||||
battleSim: {} as GameApiContext['battleSim'],
|
||||
@@ -170,7 +209,16 @@ const buildContext = (options: {
|
||||
flushStore: new InMemoryFlushStore(),
|
||||
gameTokenSecret: 'test-secret',
|
||||
};
|
||||
return { context, requestCommand, pointUpsert, logCreate, findMany, inheritanceLogFindMany };
|
||||
return {
|
||||
context,
|
||||
requestCommand,
|
||||
pointUpsert,
|
||||
logCreate,
|
||||
findMany,
|
||||
inheritanceLogFindMany,
|
||||
messageRows,
|
||||
changeJournal,
|
||||
};
|
||||
};
|
||||
|
||||
describe('inherit router actor and permission boundaries', () => {
|
||||
@@ -182,6 +230,10 @@ describe('inherit router actor and permission boundaries', () => {
|
||||
await expect(caller.inherit.buyHiddenBuff({ type: 'warAvoidRatio', level: 1 })).rejects.toMatchObject({
|
||||
code: 'UNAUTHORIZED',
|
||||
});
|
||||
await expect(caller.inherit.checkOwner({ targetGeneralId: 8 })).rejects.toMatchObject({
|
||||
code: 'UNAUTHORIZED',
|
||||
});
|
||||
expect(fixture.messageRows).toHaveLength(0);
|
||||
expect(fixture.requestCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -476,6 +528,68 @@ describe('inherit router actor and permission boundaries', () => {
|
||||
text: '1000 포인트로 장수 소유자 확인',
|
||||
},
|
||||
});
|
||||
expect(fixture.messageRows).toHaveLength(2);
|
||||
expect(fixture.messageRows).toEqual([
|
||||
expect.objectContaining({
|
||||
mailbox: 7,
|
||||
type: 'private',
|
||||
src: 0,
|
||||
dest: 7,
|
||||
payload: expect.objectContaining({
|
||||
src: expect.objectContaining({ generalId: 0, nationName: 'System' }),
|
||||
dest: expect.objectContaining({ generalId: 7, generalName: '유비' }),
|
||||
text: '조조의 소유자는 위유저 입니다.',
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
mailbox: 8,
|
||||
type: 'private',
|
||||
src: 0,
|
||||
dest: 8,
|
||||
payload: expect.objectContaining({
|
||||
src: expect.objectContaining({ generalId: 0, nationName: 'System' }),
|
||||
dest: expect.objectContaining({ generalId: 8, generalName: '조조' }),
|
||||
text: '소유자명이 누군가에 의해 확인되었습니다.',
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
expect(fixture.changeJournal.snapshot()).toEqual([
|
||||
{ domain: 'messages.mailbox', entityId: 7 },
|
||||
{ domain: 'messages.mailbox', entityId: 8 },
|
||||
]);
|
||||
expect(fixture.requestCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not charge or send messages when the owner lookup target is the actor', async () => {
|
||||
const fixture = buildContext({
|
||||
inheritancePoint: 1_500,
|
||||
target: buildGeneral({ id: 7, userId: 'user-1', name: '유비' }),
|
||||
});
|
||||
|
||||
await expect(
|
||||
appRouter.createCaller(fixture.context).inherit.checkOwner({ targetGeneralId: 7 })
|
||||
).rejects.toMatchObject({
|
||||
code: 'BAD_REQUEST',
|
||||
message: '자신의 정보는 확인할 수 없습니다.',
|
||||
});
|
||||
expect(fixture.pointUpsert).not.toHaveBeenCalled();
|
||||
expect(fixture.logCreate).not.toHaveBeenCalled();
|
||||
expect(fixture.messageRows).toHaveLength(0);
|
||||
expect(fixture.changeJournal.snapshot()).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not charge or send messages when inheritance points are insufficient', async () => {
|
||||
const fixture = buildContext({ inheritancePoint: 999 });
|
||||
|
||||
await expect(
|
||||
appRouter.createCaller(fixture.context).inherit.checkOwner({ targetGeneralId: 8 })
|
||||
).rejects.toMatchObject({
|
||||
code: 'BAD_REQUEST',
|
||||
message: '유산 포인트가 부족합니다.',
|
||||
});
|
||||
expect(fixture.pointUpsert).not.toHaveBeenCalled();
|
||||
expect(fixture.logCreate).not.toHaveBeenCalled();
|
||||
expect(fixture.messageRows).toHaveLength(0);
|
||||
expect(fixture.changeJournal.snapshot()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user