mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 00:49:41 +02:00
feat(tabs): COD-359 add owner-scoped tab layouts
This commit is contained in:
@@ -0,0 +1,244 @@
|
||||
/** @fileoverview Trusted recipient selection for legacy session-order SSE compatibility. */
|
||||
import type { FastifyReply } from 'fastify';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { SessionOrderProjectionChange } from '../src/tab-layout-service.js';
|
||||
import { CleanupManager } from '../src/utils/index.js';
|
||||
import { sessionOrderPayloadFor } from '../src/web/session-order-sse.js';
|
||||
import { SseStreamManager } from '../src/web/sse-stream-manager.js';
|
||||
|
||||
function changeWith(
|
||||
changedOwnerOrders: Record<string, string[]>,
|
||||
globalOrder: string[],
|
||||
globalChanged: boolean
|
||||
): SessionOrderProjectionChange {
|
||||
return { changedOwnerOrders, globalOrder, globalChanged };
|
||||
}
|
||||
|
||||
function client() {
|
||||
const writes: string[] = [];
|
||||
return {
|
||||
writes,
|
||||
reply: { raw: { write: (chunk: string) => (writes.push(chunk), true) } } as unknown as FastifyReply,
|
||||
};
|
||||
}
|
||||
|
||||
function backpressuredClient(options: { throwAfterBackpressure?: boolean } = {}) {
|
||||
const writes: string[] = [];
|
||||
let firstWrite = true;
|
||||
let onDrain: (() => void) | undefined;
|
||||
const raw = {
|
||||
write(chunk: string) {
|
||||
if (!firstWrite && options.throwAfterBackpressure) throw new Error('client disconnected');
|
||||
writes.push(chunk);
|
||||
if (firstWrite) {
|
||||
firstWrite = false;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
once(event: string, callback: () => void) {
|
||||
if (event === 'drain') onDrain = callback;
|
||||
return raw;
|
||||
},
|
||||
};
|
||||
return {
|
||||
writes,
|
||||
reply: { raw } as unknown as FastifyReply,
|
||||
drain: () => {
|
||||
const callback = onDrain;
|
||||
onDrain = undefined;
|
||||
callback?.();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('legacy session-order SSE routing', () => {
|
||||
it('selects an owner slice for the matching regular user and nothing for another user', () => {
|
||||
const change = changeWith({ alice: ['a2', 'a1'] }, ['a2', 'b1', 'a1'], true);
|
||||
expect(sessionOrderPayloadFor({ username: 'alice', role: 'user' }, change)).toEqual({ order: ['a2', 'a1'] });
|
||||
expect(sessionOrderPayloadFor({ username: 'bob', role: 'user' }, change)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not treat inherited object properties as changed owner slices', () => {
|
||||
const change = changeWith({ alice: ['a1'] }, ['a1'], true);
|
||||
expect(sessionOrderPayloadFor({ username: 'constructor', role: 'user' }, change)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('selects the global projection for admins even when only interleaving changed', () => {
|
||||
const change = changeWith({}, ['b1', 'a1'], true);
|
||||
expect(sessionOrderPayloadFor({ username: 'admin', role: 'admin' }, change)).toEqual({ order: ['b1', 'a1'] });
|
||||
});
|
||||
|
||||
it('uses the global projection for identity-less single-user clients', () => {
|
||||
const change = changeWith({ '@single': ['s2', 's1'] }, ['s2', 's1'], true);
|
||||
expect(sessionOrderPayloadFor(undefined, change)).toEqual({ order: ['s2', 's1'] });
|
||||
});
|
||||
|
||||
it('delivers owner slices to every matching device, the global order to admins, and nothing to other users', () => {
|
||||
const cleanup = new CleanupManager();
|
||||
const manager = new SseStreamManager({ getSessionStateWithRespawn: () => null }, cleanup);
|
||||
const alicePhone = client();
|
||||
const aliceDesktop = client();
|
||||
const bob = client();
|
||||
const admin = client();
|
||||
manager.addClient(alicePhone.reply, null, false, undefined, { username: 'alice', role: 'user' });
|
||||
manager.addClient(aliceDesktop.reply, null, false, undefined, { username: 'alice', role: 'user' });
|
||||
manager.addClient(bob.reply, null, false, undefined, { username: 'bob', role: 'user' });
|
||||
manager.addClient(admin.reply, null, false, undefined, { username: 'root', role: 'admin' });
|
||||
|
||||
manager.broadcastSessionOrder(changeWith({ alice: ['a2', 'a1'] }, ['a2', 'b1', 'a1'], true));
|
||||
|
||||
const ownerFrame = 'event: session:orderChanged\ndata: {"order":["a2","a1"]}\n\n';
|
||||
expect(alicePhone.writes).toEqual([ownerFrame]);
|
||||
expect(aliceDesktop.writes).toEqual([ownerFrame]);
|
||||
expect(bob.writes).toEqual([]);
|
||||
expect(admin.writes).toEqual(['event: session:orderChanged\ndata: {"order":["a2","b1","a1"]}\n\n']);
|
||||
cleanup.dispose();
|
||||
});
|
||||
|
||||
it('delivers a global-only interleaving change to admins only', () => {
|
||||
const cleanup = new CleanupManager();
|
||||
const manager = new SseStreamManager({ getSessionStateWithRespawn: () => null }, cleanup);
|
||||
const alice = client();
|
||||
const admin = client();
|
||||
manager.addClient(alice.reply, null, false, undefined, { username: 'alice', role: 'user' });
|
||||
manager.addClient(admin.reply, null, false, undefined, { username: 'root', role: 'admin' });
|
||||
|
||||
manager.broadcastSessionOrder(changeWith({}, ['b1', 'a1'], true));
|
||||
|
||||
expect(alice.writes).toEqual([]);
|
||||
expect(admin.writes).toEqual(['event: session:orderChanged\ndata: {"order":["b1","a1"]}\n\n']);
|
||||
cleanup.dispose();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
label: 'order-only repair',
|
||||
change: changeWith({ alice: ['a', 'b'] }, ['a', 'bob-1', 'b'], true),
|
||||
ownerOrder: ['a', 'b'],
|
||||
globalOrder: ['a', 'bob-1', 'b'],
|
||||
},
|
||||
{
|
||||
label: 'legacy-only deletion',
|
||||
change: changeWith({ alice: [] }, ['bob-1'], true),
|
||||
ownerOrder: [],
|
||||
globalOrder: ['bob-1'],
|
||||
},
|
||||
])('delivers an $label correction to same-owner devices and admins only', ({ change, ownerOrder, globalOrder }) => {
|
||||
const cleanup = new CleanupManager();
|
||||
const manager = new SseStreamManager({ getSessionStateWithRespawn: () => null }, cleanup);
|
||||
const alicePhone = client();
|
||||
const aliceDesktop = client();
|
||||
const bob = client();
|
||||
const admin = client();
|
||||
manager.addClient(alicePhone.reply, null, false, undefined, { username: 'alice', role: 'user' });
|
||||
manager.addClient(aliceDesktop.reply, null, false, undefined, { username: 'alice', role: 'user' });
|
||||
manager.addClient(bob.reply, null, false, undefined, { username: 'bob', role: 'user' });
|
||||
manager.addClient(admin.reply, null, false, undefined, { username: 'root', role: 'admin' });
|
||||
|
||||
manager.broadcastSessionOrder(change);
|
||||
|
||||
const ownerFrame = `event: session:orderChanged\ndata: ${JSON.stringify({ order: ownerOrder })}\n\n`;
|
||||
expect(alicePhone.writes).toEqual([ownerFrame]);
|
||||
expect(aliceDesktop.writes).toEqual([ownerFrame]);
|
||||
expect(bob.writes).toEqual([]);
|
||||
expect(admin.writes).toEqual([`event: session:orderChanged\ndata: ${JSON.stringify({ order: globalOrder })}\n\n`]);
|
||||
cleanup.dispose();
|
||||
});
|
||||
|
||||
it('skips an inherited-key username without starving later matching and admin recipients', () => {
|
||||
const cleanup = new CleanupManager();
|
||||
const manager = new SseStreamManager({ getSessionStateWithRespawn: () => null }, cleanup);
|
||||
const constructorUser = client();
|
||||
const alice = client();
|
||||
const admin = client();
|
||||
manager.addClient(constructorUser.reply, null, false, undefined, { username: 'constructor', role: 'user' });
|
||||
manager.addClient(alice.reply, null, false, undefined, { username: 'alice', role: 'user' });
|
||||
manager.addClient(admin.reply, null, false, undefined, { username: 'root', role: 'admin' });
|
||||
|
||||
expect(() => manager.broadcastSessionOrder(changeWith({ alice: ['a1'] }, ['a1'], true))).not.toThrow();
|
||||
|
||||
expect(constructorUser.writes).toEqual([]);
|
||||
expect(alice.writes).toEqual(['event: session:orderChanged\ndata: {"order":["a1"]}\n\n']);
|
||||
expect(admin.writes).toEqual(['event: session:orderChanged\ndata: {"order":["a1"]}\n\n']);
|
||||
cleanup.dispose();
|
||||
});
|
||||
|
||||
it('coalesces the latest filtered owner order while backpressured and flushes it on drain', () => {
|
||||
const cleanup = new CleanupManager();
|
||||
const manager = new SseStreamManager({ getSessionStateWithRespawn: () => null }, cleanup);
|
||||
const blockedAlice = backpressuredClient();
|
||||
const liveAlice = client();
|
||||
const bob = client();
|
||||
const admin = client();
|
||||
manager.addClient(blockedAlice.reply, null, false, undefined, { username: 'alice', role: 'user' });
|
||||
manager.addClient(liveAlice.reply, null, false, undefined, { username: 'alice', role: 'user' });
|
||||
manager.addClient(bob.reply, null, false, undefined, { username: 'bob', role: 'user' });
|
||||
manager.addClient(admin.reply, null, false, undefined, { username: 'root', role: 'admin' });
|
||||
|
||||
manager.broadcast('test:prime-backpressure', {}, { username: 'alice' });
|
||||
for (const writes of [blockedAlice.writes, liveAlice.writes, bob.writes, admin.writes]) writes.length = 0;
|
||||
|
||||
manager.broadcastSessionOrder(changeWith({ alice: ['a2', 'a1'] }, ['a2', 'b1', 'a1'], true));
|
||||
manager.broadcastSessionOrder(changeWith({ alice: ['a1', 'a2'] }, ['b1', 'a1', 'a2'], true));
|
||||
|
||||
expect(blockedAlice.writes).toEqual([]);
|
||||
expect(liveAlice.writes).toEqual([
|
||||
'event: session:orderChanged\ndata: {"order":["a2","a1"]}\n\n',
|
||||
'event: session:orderChanged\ndata: {"order":["a1","a2"]}\n\n',
|
||||
]);
|
||||
expect(bob.writes).toEqual([]);
|
||||
expect(admin.writes).toEqual([
|
||||
'event: session:orderChanged\ndata: {"order":["a2","b1","a1"]}\n\n',
|
||||
'event: session:orderChanged\ndata: {"order":["b1","a1","a2"]}\n\n',
|
||||
]);
|
||||
|
||||
blockedAlice.drain();
|
||||
|
||||
expect(blockedAlice.writes).toEqual([
|
||||
'event: session:needsRefresh\ndata: {}\n\n',
|
||||
'event: session:orderChanged\ndata: {"order":["a1","a2"]}\n\n',
|
||||
]);
|
||||
cleanup.dispose();
|
||||
});
|
||||
|
||||
it('clears a queued owner order when a backpressured client disconnects', () => {
|
||||
const cleanup = new CleanupManager();
|
||||
const manager = new SseStreamManager({ getSessionStateWithRespawn: () => null }, cleanup);
|
||||
const alice = backpressuredClient();
|
||||
manager.addClient(alice.reply, null, false, undefined, { username: 'alice', role: 'user' });
|
||||
manager.broadcast('test:prime-backpressure', {}, { username: 'alice' });
|
||||
alice.writes.length = 0;
|
||||
manager.broadcastSessionOrder(changeWith({ alice: ['a2', 'a1'] }, ['a2', 'a1'], true));
|
||||
|
||||
manager.removeClient(alice.reply);
|
||||
alice.drain();
|
||||
|
||||
expect(alice.writes).toEqual([]);
|
||||
cleanup.dispose();
|
||||
});
|
||||
|
||||
it('isolates a drain write failure from later healthy recipients', () => {
|
||||
const cleanup = new CleanupManager();
|
||||
const manager = new SseStreamManager({ getSessionStateWithRespawn: () => null }, cleanup);
|
||||
const brokenAlice = backpressuredClient({ throwAfterBackpressure: true });
|
||||
const liveAlice = client();
|
||||
const admin = client();
|
||||
manager.addClient(brokenAlice.reply, null, false, undefined, { username: 'alice', role: 'user' });
|
||||
manager.addClient(liveAlice.reply, null, false, undefined, { username: 'alice', role: 'user' });
|
||||
manager.addClient(admin.reply, null, false, undefined, { username: 'root', role: 'admin' });
|
||||
manager.broadcast('test:prime-backpressure', {}, { username: 'alice' });
|
||||
brokenAlice.writes.length = 0;
|
||||
liveAlice.writes.length = 0;
|
||||
admin.writes.length = 0;
|
||||
manager.broadcastSessionOrder(changeWith({ alice: ['a2', 'a1'] }, ['a2', 'a1'], true));
|
||||
|
||||
expect(() => brokenAlice.drain()).not.toThrow();
|
||||
expect(manager.clientCount).toBe(2);
|
||||
|
||||
manager.broadcastSessionOrder(changeWith({ alice: ['a1', 'a2'] }, ['a1', 'a2'], true));
|
||||
expect(liveAlice.writes.at(-1)).toBe('event: session:orderChanged\ndata: {"order":["a1","a2"]}\n\n');
|
||||
expect(admin.writes.at(-1)).toBe('event: session:orderChanged\ndata: {"order":["a1","a2"]}\n\n');
|
||||
cleanup.dispose();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user