test: cover features since 053a9c0d + repair test tooling
Add ~330 tests across server (Jest), client (Vitest), editor-ext (Vitest)
and packages/mcp (node:test) for the gitmost features added since
053a9c0d: AI chat, AI agent roles, public-share assistant, MCP per-user
auth, HTML embed, page templates/embed, realtime tree, tree
expand/collapse, and the AI-settings UI.
Test-tooling fixes (prerequisite, were silently hiding coverage):
- Repair 3 page-template specs broken by the 11-arg TransclusionService
constructor; they never compiled, so template access-control / content
-leak / unsync-strip coverage was fictitious.
- Build @docmost/editor-ext before server tests via a `pretest` hook;
the stale dist omitted the new HtmlEmbed/PageEmbed exports (TS2305).
- Let jest resolve the .tsx email templates: add `tsx` to
moduleFileExtensions and widen the ts-jest transform to (t|j)sx?.
Behaviour-preserving "extract pure core" refactors that the tests drive:
- server: resolveShareAssistantRequest + uiMessageTextLength
(public-share controller), decideBasicGate + mapAuthResultToResponse
(mcp), buildErrorAssistantRecord (ai-chat), jsonbObject export (roles).
- client: render-raw-html + shouldExecute/canEdit, decide-embed-state,
page-embed picker utils, tree-socket reducers, open/close branch maps,
isEndpointConfigured/resolveKeyField; buildTreeWithChildren now treats
a permission-trimmed orphan as a root instead of crashing.
Deferred (need a test DB or HTTP harness, documented in the specs):
repo-level Postgres integration tests and the public-share XFF E2E.
Pre-existing DI/lib0-ESM suite failures are untouched and out of scope.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@ import { PageWsListener } from './page-ws.listener';
|
||||
import { WsTreeService } from '../ws-tree.service';
|
||||
import {
|
||||
PageEvent,
|
||||
PageMovedEvent,
|
||||
TreeNodeSnapshot,
|
||||
} from '../../database/listeners/page.listener';
|
||||
|
||||
@@ -93,3 +94,139 @@ describe('PageWsListener.onPageCreated', () => {
|
||||
expect(wsTree.broadcastRefetchRoot).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PageWsListener delete/move/restore handlers', () => {
|
||||
let listener: PageWsListener;
|
||||
let wsTree: {
|
||||
broadcastPageCreated: jest.Mock;
|
||||
broadcastPageDeleted: jest.Mock;
|
||||
broadcastPageMoved: jest.Mock;
|
||||
broadcastRefetchRoot: jest.Mock;
|
||||
};
|
||||
let warnSpy: jest.SpyInstance;
|
||||
|
||||
const secondSnapshot: TreeNodeSnapshot = {
|
||||
id: 'page-2',
|
||||
slugId: 'slug-2',
|
||||
title: 'World',
|
||||
icon: '📁',
|
||||
position: 'a2',
|
||||
spaceId: 'space-1',
|
||||
parentPageId: null,
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
wsTree = {
|
||||
broadcastPageCreated: jest.fn().mockResolvedValue(undefined),
|
||||
broadcastPageDeleted: jest.fn().mockResolvedValue(undefined),
|
||||
broadcastPageMoved: jest.fn().mockResolvedValue(undefined),
|
||||
broadcastRefetchRoot: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
PageWsListener,
|
||||
{ provide: WsTreeService, useValue: wsTree },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
listener = module.get<PageWsListener>(PageWsListener);
|
||||
// The PAGE_RESTORED-without-spaceId branch logs a warning; silence + assert.
|
||||
warnSpy = jest
|
||||
.spyOn(listener['logger'], 'warn')
|
||||
.mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
// --- onPageDeleted (PAGE_SOFT_DELETED / PAGE_DELETED) ---
|
||||
|
||||
it('onPageDeleted with N `pages`: one broadcastPageDeleted per page', async () => {
|
||||
const event: PageEvent = {
|
||||
pageIds: ['page-1', 'page-2'],
|
||||
workspaceId: 'ws-1',
|
||||
pages: [snapshot, secondSnapshot],
|
||||
};
|
||||
|
||||
await listener.onPageDeleted(event);
|
||||
|
||||
expect(wsTree.broadcastPageDeleted).toHaveBeenCalledTimes(2);
|
||||
expect(wsTree.broadcastPageDeleted).toHaveBeenNthCalledWith(1, snapshot);
|
||||
expect(wsTree.broadcastPageDeleted).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
secondSnapshot,
|
||||
);
|
||||
});
|
||||
|
||||
it('onPageDeleted with an EMPTY `pages` array: no broadcast', async () => {
|
||||
const event: PageEvent = {
|
||||
pageIds: ['page-1'],
|
||||
workspaceId: 'ws-1',
|
||||
pages: [],
|
||||
};
|
||||
|
||||
await listener.onPageDeleted(event);
|
||||
|
||||
expect(wsTree.broadcastPageDeleted).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('onPageDeleted with UNDEFINED `pages`: no broadcast (no crash)', async () => {
|
||||
const event: PageEvent = {
|
||||
pageIds: ['page-1'],
|
||||
workspaceId: 'ws-1',
|
||||
};
|
||||
|
||||
await listener.onPageDeleted(event);
|
||||
|
||||
expect(wsTree.broadcastPageDeleted).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// --- onPageMoved (PAGE_MOVED) ---
|
||||
|
||||
it('onPageMoved: forwards the whole event to a single broadcastPageMoved', async () => {
|
||||
const event: PageMovedEvent = {
|
||||
workspaceId: 'ws-1',
|
||||
oldParentId: 'old-parent',
|
||||
hasChildren: false,
|
||||
node: { ...snapshot, parentPageId: 'new-parent', position: 'a5' },
|
||||
};
|
||||
|
||||
await listener.onPageMoved(event);
|
||||
|
||||
expect(wsTree.broadcastPageMoved).toHaveBeenCalledTimes(1);
|
||||
expect(wsTree.broadcastPageMoved).toHaveBeenCalledWith(event);
|
||||
});
|
||||
|
||||
// --- onPageRestored (PAGE_RESTORED) ---
|
||||
|
||||
it('onPageRestored WITHOUT spaceId: warns and does NOT refetch', async () => {
|
||||
const event: PageEvent = {
|
||||
pageIds: ['page-1'],
|
||||
workspaceId: 'ws-1',
|
||||
};
|
||||
|
||||
await listener.onPageRestored(event);
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledTimes(1);
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('PAGE_RESTORED'),
|
||||
);
|
||||
expect(wsTree.broadcastRefetchRoot).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('onPageRestored WITH spaceId: one broadcastRefetchRoot scoped to the space', async () => {
|
||||
const event: PageEvent = {
|
||||
pageIds: ['page-1'],
|
||||
workspaceId: 'ws-1',
|
||||
spaceId: 'space-9',
|
||||
};
|
||||
|
||||
await listener.onPageRestored(event);
|
||||
|
||||
expect(warnSpy).not.toHaveBeenCalled();
|
||||
expect(wsTree.broadcastRefetchRoot).toHaveBeenCalledTimes(1);
|
||||
expect(wsTree.broadcastRefetchRoot).toHaveBeenCalledWith('space-9');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { CACHE_MANAGER } from '@nestjs/cache-manager';
|
||||
import { WsService } from './ws.service';
|
||||
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
|
||||
import {
|
||||
getSpaceRoomName,
|
||||
WS_SPACE_RESTRICTION_CACHE_PREFIX,
|
||||
WS_CACHE_TTL_MS,
|
||||
} from './ws.utils';
|
||||
|
||||
/**
|
||||
* WsService server-side unit tests (M7 item 2):
|
||||
* - spaceHasRestrictions cache lifecycle (miss -> read+set with TTL; hit ->
|
||||
* no re-read; documents the stale-false window).
|
||||
* - broadcastToAuthorizedUsers fan-out (authorized-only delivery, multi-socket
|
||||
* fan-out per user, sockets with no userId skipped).
|
||||
*
|
||||
* Both private methods are exercised through their public entry points:
|
||||
* spaceHasRestrictions via emitTreeEvent, broadcastToAuthorizedUsers via
|
||||
* emitToAuthorizedUsers. WsService is constructed with mocked cache + repo and a
|
||||
* mocked socket.io server, so no live infra is needed.
|
||||
*/
|
||||
|
||||
describe('WsService.spaceHasRestrictions (cache lifecycle, via emitTreeEvent)', () => {
|
||||
let service: WsService;
|
||||
let pagePermissionRepo: {
|
||||
hasRestrictedPagesInSpace: jest.Mock;
|
||||
hasRestrictedAncestor: jest.Mock;
|
||||
getUserIdsWithPageAccess: jest.Mock;
|
||||
};
|
||||
let cache: { get: jest.Mock; set: jest.Mock; del: jest.Mock };
|
||||
let roomEmit: jest.Mock;
|
||||
|
||||
beforeEach(async () => {
|
||||
pagePermissionRepo = {
|
||||
hasRestrictedPagesInSpace: jest.fn(),
|
||||
hasRestrictedAncestor: jest.fn(),
|
||||
getUserIdsWithPageAccess: jest.fn(),
|
||||
};
|
||||
cache = {
|
||||
get: jest.fn().mockResolvedValue(null),
|
||||
set: jest.fn().mockResolvedValue(undefined),
|
||||
del: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
WsService,
|
||||
{ provide: PagePermissionRepo, useValue: pagePermissionRepo },
|
||||
{ provide: CACHE_MANAGER, useValue: cache },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<WsService>(WsService);
|
||||
|
||||
roomEmit = jest.fn();
|
||||
const server = {
|
||||
to: jest.fn().mockReturnValue({ emit: roomEmit }),
|
||||
in: jest.fn().mockReturnValue({ fetchSockets: jest.fn() }),
|
||||
};
|
||||
service.setServer(server as never);
|
||||
});
|
||||
|
||||
const cacheKey = (spaceId: string): string =>
|
||||
`${WS_SPACE_RESTRICTION_CACHE_PREFIX}${spaceId}`;
|
||||
|
||||
it('first call MISSES the cache -> reads the repo and sets it with WS_CACHE_TTL_MS', async () => {
|
||||
cache.get.mockResolvedValue(null); // miss
|
||||
pagePermissionRepo.hasRestrictedPagesInSpace.mockResolvedValue(true);
|
||||
pagePermissionRepo.hasRestrictedAncestor.mockResolvedValue(false);
|
||||
|
||||
await service.emitTreeEvent('space-1', 'page-1', { op: 'x' });
|
||||
|
||||
expect(cache.get).toHaveBeenCalledWith(cacheKey('space-1'));
|
||||
expect(pagePermissionRepo.hasRestrictedPagesInSpace).toHaveBeenCalledTimes(1);
|
||||
expect(pagePermissionRepo.hasRestrictedPagesInSpace).toHaveBeenCalledWith(
|
||||
'space-1',
|
||||
);
|
||||
// The freshly-read verdict is cached with the 30s TTL.
|
||||
expect(cache.set).toHaveBeenCalledWith(
|
||||
cacheKey('space-1'),
|
||||
true,
|
||||
WS_CACHE_TTL_MS,
|
||||
);
|
||||
});
|
||||
|
||||
it('second call HITS the cache -> the repo is NOT re-read', async () => {
|
||||
// Cache hit returns false (no restrictions) -> open-space fast path.
|
||||
cache.get.mockResolvedValue(false);
|
||||
|
||||
await service.emitTreeEvent('space-1', 'page-1', { op: 'x' });
|
||||
|
||||
expect(cache.get).toHaveBeenCalledWith(cacheKey('space-1'));
|
||||
// The whole point of the cache: no repo read on a hit.
|
||||
expect(pagePermissionRepo.hasRestrictedPagesInSpace).not.toHaveBeenCalled();
|
||||
expect(cache.set).not.toHaveBeenCalled();
|
||||
// false verdict -> broadcast to the whole room (open-space fast path).
|
||||
expect(roomEmit).toHaveBeenCalledWith('message', { op: 'x' });
|
||||
});
|
||||
|
||||
it('a cached `false` is returned even when restrictions now exist (the stale window)', async () => {
|
||||
// The cache says "no restrictions" (false) but the repo, if asked, would now
|
||||
// say true. spaceHasRestrictions trusts the cached false and never re-reads —
|
||||
// this documents the up-to-TTL stale window the production comment warns about
|
||||
// (a payload can fan out room-wide until the cache is invalidated/expires).
|
||||
cache.get.mockResolvedValue(false);
|
||||
pagePermissionRepo.hasRestrictedPagesInSpace.mockResolvedValue(true);
|
||||
|
||||
await service.emitTreeEvent('space-1', 'page-1', { op: 'stale' });
|
||||
|
||||
expect(pagePermissionRepo.hasRestrictedPagesInSpace).not.toHaveBeenCalled();
|
||||
// Treated as open -> the event is broadcast to the WHOLE room.
|
||||
expect(roomEmit).toHaveBeenCalledWith('message', { op: 'stale' });
|
||||
});
|
||||
|
||||
it('caches a `false` verdict too (so the next emit hits, not re-reads)', async () => {
|
||||
cache.get.mockResolvedValueOnce(null); // first call: miss
|
||||
pagePermissionRepo.hasRestrictedPagesInSpace.mockResolvedValue(false);
|
||||
|
||||
await service.emitTreeEvent('space-2', 'page-9', { op: 'y' });
|
||||
|
||||
expect(cache.set).toHaveBeenCalledWith(
|
||||
cacheKey('space-2'),
|
||||
false,
|
||||
WS_CACHE_TTL_MS,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WsService.broadcastToAuthorizedUsers fan-out (via emitToAuthorizedUsers)', () => {
|
||||
let service: WsService;
|
||||
let pagePermissionRepo: {
|
||||
hasRestrictedPagesInSpace: jest.Mock;
|
||||
hasRestrictedAncestor: jest.Mock;
|
||||
getUserIdsWithPageAccess: jest.Mock;
|
||||
};
|
||||
let cache: { get: jest.Mock; set: jest.Mock; del: jest.Mock };
|
||||
let fetchSockets: jest.Mock;
|
||||
let serverIn: jest.Mock;
|
||||
|
||||
beforeEach(async () => {
|
||||
pagePermissionRepo = {
|
||||
hasRestrictedPagesInSpace: jest.fn(),
|
||||
hasRestrictedAncestor: jest.fn(),
|
||||
getUserIdsWithPageAccess: jest.fn(),
|
||||
};
|
||||
cache = {
|
||||
get: jest.fn().mockResolvedValue(null),
|
||||
set: jest.fn().mockResolvedValue(undefined),
|
||||
del: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
WsService,
|
||||
{ provide: PagePermissionRepo, useValue: pagePermissionRepo },
|
||||
{ provide: CACHE_MANAGER, useValue: cache },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<WsService>(WsService);
|
||||
|
||||
fetchSockets = jest.fn();
|
||||
serverIn = jest.fn().mockReturnValue({ fetchSockets });
|
||||
const server = {
|
||||
to: jest.fn().mockReturnValue({ emit: jest.fn() }),
|
||||
in: serverIn,
|
||||
};
|
||||
service.setServer(server as never);
|
||||
});
|
||||
|
||||
it('only sockets whose userId is in getUserIdsWithPageAccess receive the event', async () => {
|
||||
pagePermissionRepo.getUserIdsWithPageAccess.mockResolvedValue(['user-ok']);
|
||||
|
||||
const okEmit = jest.fn();
|
||||
const noEmit = jest.fn();
|
||||
fetchSockets.mockResolvedValue([
|
||||
{ id: 's1', data: { userId: 'user-ok' }, emit: okEmit },
|
||||
{ id: 's2', data: { userId: 'user-no' }, emit: noEmit },
|
||||
]);
|
||||
|
||||
const data = { operation: 'moveTreeNode' };
|
||||
await service.emitToAuthorizedUsers('space-1', 'page-1', data);
|
||||
|
||||
// The authorized set is resolved from the candidate userIds present on the
|
||||
// sockets (deduped), then only those users' sockets get the event.
|
||||
expect(pagePermissionRepo.getUserIdsWithPageAccess).toHaveBeenCalledWith(
|
||||
'page-1',
|
||||
expect.arrayContaining(['user-ok', 'user-no']),
|
||||
);
|
||||
expect(okEmit).toHaveBeenCalledWith('message', data);
|
||||
expect(noEmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('a user with TWO sockets receives the event on BOTH (userSocketMap fan-out)', async () => {
|
||||
pagePermissionRepo.getUserIdsWithPageAccess.mockResolvedValue(['user-ok']);
|
||||
|
||||
const tab1 = jest.fn();
|
||||
const tab2 = jest.fn();
|
||||
fetchSockets.mockResolvedValue([
|
||||
{ id: 's1', data: { userId: 'user-ok' }, emit: tab1 },
|
||||
{ id: 's2', data: { userId: 'user-ok' }, emit: tab2 },
|
||||
]);
|
||||
|
||||
const data = { operation: 'moveTreeNode' };
|
||||
await service.emitToAuthorizedUsers('space-1', 'page-1', data);
|
||||
|
||||
// Both of the authorized user's sockets (e.g. two browser tabs) receive it.
|
||||
expect(tab1).toHaveBeenCalledWith('message', data);
|
||||
expect(tab2).toHaveBeenCalledWith('message', data);
|
||||
// The candidate set is deduped to a single userId even with two sockets.
|
||||
expect(pagePermissionRepo.getUserIdsWithPageAccess).toHaveBeenCalledWith(
|
||||
'page-1',
|
||||
['user-ok'],
|
||||
);
|
||||
});
|
||||
|
||||
it('a socket with NO userId is skipped (not a candidate, never emitted to)', async () => {
|
||||
pagePermissionRepo.getUserIdsWithPageAccess.mockResolvedValue(['user-ok']);
|
||||
|
||||
const okEmit = jest.fn();
|
||||
const anonEmit = jest.fn();
|
||||
fetchSockets.mockResolvedValue([
|
||||
{ id: 's1', data: { userId: 'user-ok' }, emit: okEmit },
|
||||
// Unauthenticated socket: no userId -> excluded from the candidate map.
|
||||
{ id: 's2', data: {}, emit: anonEmit },
|
||||
]);
|
||||
|
||||
const data = { operation: 'moveTreeNode' };
|
||||
await service.emitToAuthorizedUsers('space-1', 'page-1', data);
|
||||
|
||||
expect(okEmit).toHaveBeenCalledWith('message', data);
|
||||
expect(anonEmit).not.toHaveBeenCalled();
|
||||
// The no-userId socket is not even offered as a candidate to the repo.
|
||||
expect(pagePermissionRepo.getUserIdsWithPageAccess).toHaveBeenCalledWith(
|
||||
'page-1',
|
||||
['user-ok'],
|
||||
);
|
||||
});
|
||||
|
||||
it('no sockets in the room -> no repo lookup, no emit', async () => {
|
||||
fetchSockets.mockResolvedValue([]);
|
||||
|
||||
await service.emitToAuthorizedUsers('space-1', 'page-1', { op: 'x' });
|
||||
|
||||
expect(pagePermissionRepo.getUserIdsWithPageAccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('routes through the space room name', async () => {
|
||||
pagePermissionRepo.getUserIdsWithPageAccess.mockResolvedValue([]);
|
||||
fetchSockets.mockResolvedValue([
|
||||
{ id: 's1', data: { userId: 'u' }, emit: jest.fn() },
|
||||
]);
|
||||
|
||||
await service.emitToAuthorizedUsers('space-7', 'page-1', { op: 'x' });
|
||||
|
||||
expect(serverIn).toHaveBeenCalledWith(getSpaceRoomName('space-7'));
|
||||
});
|
||||
});
|
||||
@@ -329,3 +329,109 @@ describe('WsService.emitTreeEvent', () => {
|
||||
expect(anonEmit).toHaveBeenCalledWith('message', data);
|
||||
});
|
||||
});
|
||||
|
||||
describe('move-into-restricted disjointness contract (WsTreeService + real WsService)', () => {
|
||||
// CONTRACT: a move under a restricted ancestor PARTITIONS the room. The
|
||||
// authorized set (gets the moveTreeNode via emitToAuthorizedUsers) and its
|
||||
// complement (gets the deleteTreeNode via emitDeleteToUnauthorized) are
|
||||
// disjoint and together cover every socket — and an anonymous (no-userId)
|
||||
// socket lands in the delete set. We wire a REAL WsService (only its repo,
|
||||
// cache and socket server mocked) so both broadcasts run against the SAME fixed
|
||||
// socket set, the way they do in production.
|
||||
let treeService: WsTreeService;
|
||||
let pagePermissionRepo: {
|
||||
hasRestrictedPagesInSpace: jest.Mock;
|
||||
hasRestrictedAncestor: jest.Mock;
|
||||
getUserIdsWithPageAccess: jest.Mock;
|
||||
};
|
||||
|
||||
// Fixed room: two authorized users (one with two sockets), one unauthorized
|
||||
// user, one anonymous socket.
|
||||
const moveSeen: string[] = [];
|
||||
const deleteSeen: string[] = [];
|
||||
|
||||
const mkSocket = (id: string, userId: string | undefined) => ({
|
||||
id,
|
||||
data: userId ? { userId } : {},
|
||||
emit: jest.fn((_event: string, payload: { operation: string }) => {
|
||||
if (payload.operation === 'moveTreeNode') moveSeen.push(id);
|
||||
if (payload.operation === 'deleteTreeNode') deleteSeen.push(id);
|
||||
}),
|
||||
});
|
||||
|
||||
const sockets = [
|
||||
mkSocket('s-ok-1', 'user-ok'), // authorized, tab 1
|
||||
mkSocket('s-ok-2', 'user-ok'), // authorized, tab 2 (fan-out)
|
||||
mkSocket('s-no', 'user-no'), // unauthorized
|
||||
mkSocket('s-anon', undefined), // anonymous (no userId)
|
||||
];
|
||||
|
||||
beforeEach(async () => {
|
||||
moveSeen.length = 0;
|
||||
deleteSeen.length = 0;
|
||||
|
||||
pagePermissionRepo = {
|
||||
hasRestrictedPagesInSpace: jest.fn().mockResolvedValue(true),
|
||||
// The move destination IS under a restricted ancestor.
|
||||
hasRestrictedAncestor: jest.fn().mockResolvedValue(true),
|
||||
// Only user-ok is authorized to see the page.
|
||||
getUserIdsWithPageAccess: jest.fn().mockResolvedValue(['user-ok']),
|
||||
};
|
||||
const cache = {
|
||||
get: jest.fn().mockResolvedValue(null),
|
||||
set: jest.fn().mockResolvedValue(undefined),
|
||||
del: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
WsTreeService,
|
||||
WsService,
|
||||
{ provide: PagePermissionRepo, useValue: pagePermissionRepo },
|
||||
{ provide: CACHE_MANAGER, useValue: cache },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
const wsService = module.get<WsService>(WsService);
|
||||
const server = {
|
||||
to: jest.fn().mockReturnValue({ emit: jest.fn() }),
|
||||
in: jest.fn().mockReturnValue({
|
||||
fetchSockets: jest.fn().mockResolvedValue(sockets),
|
||||
}),
|
||||
};
|
||||
wsService.setServer(server as never);
|
||||
|
||||
treeService = module.get<WsTreeService>(WsTreeService);
|
||||
});
|
||||
|
||||
it('authorized set (move) and complement (delete) partition the room; anon is in delete', async () => {
|
||||
const event: PageMovedEvent = {
|
||||
workspaceId: 'ws-1',
|
||||
oldParentId: 'old-parent',
|
||||
hasChildren: false,
|
||||
node: { ...snapshot, parentPageId: 'restricted-parent', position: 'a5' },
|
||||
};
|
||||
|
||||
await treeService.broadcastPageMoved(event);
|
||||
|
||||
const moveSet = new Set(moveSeen);
|
||||
const deleteSet = new Set(deleteSeen);
|
||||
|
||||
// Authorized user's BOTH sockets got the move; nobody else did.
|
||||
expect(moveSet).toEqual(new Set(['s-ok-1', 's-ok-2']));
|
||||
// Everyone else (unauthorized + anonymous) got the delete.
|
||||
expect(deleteSet).toEqual(new Set(['s-no', 's-anon']));
|
||||
|
||||
// DISJOINT: no socket received both a move and a delete.
|
||||
const intersection = [...moveSet].filter((id) => deleteSet.has(id));
|
||||
expect(intersection).toEqual([]);
|
||||
|
||||
// PARTITION: the two sets together cover every socket in the room exactly.
|
||||
const union = new Set([...moveSet, ...deleteSet]);
|
||||
expect(union).toEqual(new Set(sockets.map((s) => s.id)));
|
||||
|
||||
// The anonymous socket specifically lands in the DELETE set, never the move.
|
||||
expect(deleteSet.has('s-anon')).toBe(true);
|
||||
expect(moveSet.has('s-anon')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user