mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +00:00
145 lines
5.6 KiB
TypeScript
145 lines
5.6 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest';
|
|
import { reconcilePushBindings, releasePushBindings, type PushBinding, type PushRole } from '../push-bindings';
|
|
import { DemoJMAPClient } from '../demo/demo-client';
|
|
|
|
/** A client that counts what the reconciliation does to it. */
|
|
class FakeClient {
|
|
setups = 0;
|
|
closes = 0;
|
|
sse = false;
|
|
roles: PushRole[] = [];
|
|
constructor(readonly name: string, private readonly slots: { free: number }) {}
|
|
setupPushNotifications() {
|
|
this.setups++;
|
|
if (this.slots.free > 0) { this.slots.free--; this.sse = true; }
|
|
}
|
|
closePushNotifications() {
|
|
this.closes++;
|
|
if (this.sse) { this.slots.free++; this.sse = false; }
|
|
}
|
|
hasSSEStream() { return this.sse; }
|
|
}
|
|
|
|
function world(names: string[], freeSlots = 2) {
|
|
const slots = { free: freeSlots };
|
|
const clients = new Map(names.map((n) => [n, new FakeClient(n, slots)] as const));
|
|
const bindings = new Map<FakeClient, PushBinding>();
|
|
const run = (connected: string[], active: string | null) => reconcilePushBindings({
|
|
bindings,
|
|
clients: new Map(connected.map((n) => [n, clients.get(n)!] as const)),
|
|
activeAccountId: active,
|
|
listen: (c, role) => { c.roles.push(role); },
|
|
});
|
|
return { clients, bindings, run, slots };
|
|
}
|
|
|
|
describe('reconcilePushBindings', () => {
|
|
it('binds each login once however many times the effect re-runs while logins connect', () => {
|
|
const { clients, run } = world(['a', 'b', 'c', 'd']);
|
|
// A restore: the effect runs once per login that connects.
|
|
run(['a'], 'a');
|
|
run(['a', 'b'], 'a');
|
|
run(['a', 'b', 'c'], 'a');
|
|
run(['a', 'b', 'c', 'd'], 'a');
|
|
run(['a', 'b', 'c', 'd'], 'a');
|
|
for (const c of clients.values()) {
|
|
expect(c.setups, c.name).toBe(1);
|
|
expect(c.closes, c.name).toBe(0);
|
|
}
|
|
});
|
|
|
|
it('gives the active login a live stream before the background ones', () => {
|
|
const { clients, run } = world(['a', 'b', 'c'], 1);
|
|
run(['b', 'c', 'a'], 'a');
|
|
expect(clients.get('a')!.hasSSEStream()).toBe(true);
|
|
expect(clients.get('b')!.hasSSEStream()).toBe(false);
|
|
});
|
|
|
|
it('unbinds only the login that went away', () => {
|
|
const { clients, bindings, run } = world(['a', 'b', 'c']);
|
|
run(['a', 'b', 'c'], 'a');
|
|
run(['a', 'c'], 'a');
|
|
expect(clients.get('b')!.closes).toBe(1);
|
|
expect(clients.get('a')!.closes).toBe(0);
|
|
expect(clients.get('c')!.closes).toBe(0);
|
|
expect([...bindings.values()].map((b) => b.accountId).sort()).toEqual(['a', 'c']);
|
|
});
|
|
|
|
it('on an account switch touches only the two logins whose role changed', () => {
|
|
const { clients, run } = world(['a', 'b', 'c', 'd'], 1);
|
|
run(['a', 'b', 'c', 'd'], 'a');
|
|
const setupsBefore = [...clients.values()].map((c) => c.setups);
|
|
run(['a', 'b', 'c', 'd'], 'c');
|
|
// c had no stream: it takes a's, and a is re-bound as background.
|
|
expect(clients.get('c')!.hasSSEStream()).toBe(true);
|
|
expect(clients.get('c')!.roles.at(-1)).toBe('active');
|
|
expect(clients.get('a')!.roles.at(-1)).toBe('background');
|
|
expect(clients.get('b')!.setups).toBe(setupsBefore[1]);
|
|
expect(clients.get('d')!.setups).toBe(setupsBefore[3]);
|
|
});
|
|
|
|
it('only swaps the callback when the newly active login already has a stream', () => {
|
|
const { clients, run } = world(['a', 'b'], 2);
|
|
run(['a', 'b'], 'a');
|
|
run(['a', 'b'], 'b');
|
|
expect(clients.get('b')!.setups).toBe(1);
|
|
expect(clients.get('b')!.roles).toEqual(['background', 'active']);
|
|
expect(clients.get('a')!.roles).toEqual(['active', 'background']);
|
|
});
|
|
|
|
it('rebinds a login whose client was replaced', () => {
|
|
const { bindings, run, clients } = world(['a']);
|
|
run(['a'], 'a');
|
|
const old = clients.get('a')!;
|
|
const fresh = new FakeClient('a', { free: 2 });
|
|
reconcilePushBindings({
|
|
bindings, clients: new Map([['a', fresh]]), activeAccountId: 'a', listen: vi.fn(),
|
|
});
|
|
expect(old.closes).toBe(1);
|
|
expect(fresh.setups).toBe(1);
|
|
});
|
|
|
|
it('switches between demo logins, which have no live stream', () => {
|
|
const work = new DemoJMAPClient('work');
|
|
const personal = new DemoJMAPClient('personal');
|
|
const bindings = new Map<DemoJMAPClient, PushBinding>();
|
|
const roles: Array<[string, PushRole]> = [];
|
|
const onError = vi.fn();
|
|
const run = (active: string) => reconcilePushBindings({
|
|
bindings,
|
|
clients: new Map([['work', work], ['personal', personal]]),
|
|
activeAccountId: active,
|
|
listen: (c, role) => { roles.push([c === work ? 'work' : 'personal', role]); },
|
|
onError,
|
|
});
|
|
run('work');
|
|
expect(() => run('personal')).not.toThrow();
|
|
expect(() => run('work')).not.toThrow();
|
|
expect(onError).not.toHaveBeenCalled();
|
|
expect(bindings.get(work)?.role).toBe('active');
|
|
expect(bindings.get(personal)?.role).toBe('background');
|
|
});
|
|
|
|
it('treats a client without hasSSEStream as having no stream', () => {
|
|
const plain = (name: string) => ({ name, setupPushNotifications: vi.fn(), closePushNotifications: vi.fn() });
|
|
const a = plain('a');
|
|
const b = plain('b');
|
|
const bindings = new Map<ReturnType<typeof plain>, PushBinding>();
|
|
const run = (active: string) => reconcilePushBindings({
|
|
bindings, clients: new Map([['a', a], ['b', b]]), activeAccountId: active, listen: vi.fn(),
|
|
});
|
|
run('a');
|
|
expect(() => run('b')).not.toThrow();
|
|
expect(bindings.get(b)?.role).toBe('active');
|
|
expect(bindings.get(a)?.role).toBe('background');
|
|
});
|
|
|
|
it('releases everything on logout', () => {
|
|
const { clients, bindings, run } = world(['a', 'b']);
|
|
run(['a', 'b'], 'a');
|
|
releasePushBindings(bindings);
|
|
expect(bindings.size).toBe(0);
|
|
for (const c of clients.values()) expect(c.closes).toBe(1);
|
|
});
|
|
});
|