240 lines
8.8 KiB
TypeScript
240 lines
8.8 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest';
|
|
import type {
|
|
RedditAccountPrivateRecord,
|
|
RedditAccountPublicRecord,
|
|
} from '../src/database/account-repositories.js';
|
|
import {
|
|
accountBrowserProfileDir,
|
|
RedditAccountRuntime,
|
|
RedditAccountRuntimeFactory,
|
|
RedditAccountRuntimeManager,
|
|
type RedditAccountRuntimeResources,
|
|
} from '../src/services/reddit-account-runtime.js';
|
|
|
|
function account(
|
|
key: string,
|
|
overrides: Partial<RedditAccountPrivateRecord> = {},
|
|
): RedditAccountPrivateRecord {
|
|
const accountId = `@t2_${key}:reddit.com`;
|
|
return {
|
|
accountId,
|
|
redditUsername: key,
|
|
matrixUserId: accountId,
|
|
status: 'verified',
|
|
isActive: true,
|
|
cookiesJson: JSON.stringify([{ name: 'reddit_session', value: key }]),
|
|
matrixCredentialsJson: JSON.stringify({
|
|
homeserver: 'https://matrix.redditspace.com',
|
|
accessToken: `token-${key}`,
|
|
userId: accountId,
|
|
capturedAt: 1,
|
|
}),
|
|
createdAt: 1,
|
|
updatedAt: 2,
|
|
lastVerifiedAt: 2,
|
|
lastImportedAt: 2,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function accountStore(initial: RedditAccountPrivateRecord[]) {
|
|
const records = new Map(initial.map((record) => [record.accountId, record]));
|
|
return {
|
|
records,
|
|
repository: {
|
|
listPublic: (): RedditAccountPublicRecord[] => [...records.values()],
|
|
get: (accountId: string): RedditAccountPrivateRecord | null => records.get(accountId) ?? null,
|
|
},
|
|
};
|
|
}
|
|
|
|
const resources = {} as RedditAccountRuntimeResources;
|
|
|
|
describe('RedditAccountRuntimeManager', () => {
|
|
it('starts enabled accounts concurrently as independent runtimes', async () => {
|
|
const first = account('first');
|
|
const second = account('second');
|
|
const store = accountStore([first, second]);
|
|
const starts: string[] = [];
|
|
const releases = new Map<string, () => void>();
|
|
const factory = new RedditAccountRuntimeFactory(async (session) => new RedditAccountRuntime(
|
|
session,
|
|
{ ...resources },
|
|
{
|
|
start: () => new Promise<void>((resolve) => {
|
|
starts.push(session.accountId);
|
|
releases.set(session.accountId, resolve);
|
|
}),
|
|
stop: vi.fn(),
|
|
},
|
|
));
|
|
const manager = new RedditAccountRuntimeManager(store.repository, factory);
|
|
|
|
const starting = manager.startEnabled();
|
|
await vi.waitFor(() => expect(starts).toHaveLength(2));
|
|
expect(new Set(starts)).toEqual(new Set([first.accountId, second.accountId]));
|
|
releases.get(first.accountId)!();
|
|
releases.get(second.accountId)!();
|
|
await starting;
|
|
|
|
expect(manager.list()).toEqual([
|
|
expect.objectContaining({ accountId: first.accountId, state: 'running' }),
|
|
expect.objectContaining({ accountId: second.accountId, state: 'running' }),
|
|
]);
|
|
expect(manager.get(first.accountId)?.resources).not.toBe(manager.get(second.accountId)?.resources);
|
|
});
|
|
|
|
it('does not start disabled or login-required accounts', async () => {
|
|
const enabled = account('enabled');
|
|
const disabled = account('disabled', { isActive: false, status: 'disabled' });
|
|
const expired = account('expired', { status: 'login-required' });
|
|
const store = accountStore([enabled, disabled, expired]);
|
|
const built: string[] = [];
|
|
const factory = new RedditAccountRuntimeFactory(async (session) => {
|
|
built.push(session.accountId);
|
|
return new RedditAccountRuntime(session, { ...resources }, { start: vi.fn(), stop: vi.fn() });
|
|
});
|
|
const manager = new RedditAccountRuntimeManager(store.repository, factory);
|
|
|
|
await manager.startEnabled();
|
|
|
|
expect(built).toEqual([enabled.accountId]);
|
|
expect(manager.list()).toHaveLength(1);
|
|
});
|
|
|
|
it('replaces only the refreshed account while other accounts keep running', async () => {
|
|
const first = account('first');
|
|
const second = account('second');
|
|
const store = accountStore([first, second]);
|
|
const events: string[] = [];
|
|
const generations = new Map<string, number>();
|
|
const factory = new RedditAccountRuntimeFactory(async (session) => {
|
|
const generation = (generations.get(session.accountId) ?? 0) + 1;
|
|
generations.set(session.accountId, generation);
|
|
return new RedditAccountRuntime(session, { ...resources }, {
|
|
start: () => { events.push(`start:${session.accountId}:${generation}`); },
|
|
stop: () => { events.push(`stop:${session.accountId}:${generation}`); },
|
|
});
|
|
});
|
|
const manager = new RedditAccountRuntimeManager(store.repository, factory);
|
|
await manager.startEnabled();
|
|
const secondRuntime = manager.get(second.accountId);
|
|
events.length = 0;
|
|
store.records.set(first.accountId, account('first', { updatedAt: 99 }));
|
|
|
|
await manager.replace(first.accountId);
|
|
|
|
expect(events).toEqual([
|
|
`stop:${first.accountId}:1`,
|
|
`start:${first.accountId}:2`,
|
|
]);
|
|
expect(manager.get(first.accountId)?.snapshot().updatedAt).toBe(99);
|
|
expect(manager.get(second.accountId)).toBe(secondRuntime);
|
|
expect(manager.get(second.accountId)?.snapshot().state).toBe('running');
|
|
});
|
|
|
|
it('serializes repeated refreshes for one account', async () => {
|
|
const stored = account('same');
|
|
const store = accountStore([stored]);
|
|
const events: string[] = [];
|
|
let generation = 0;
|
|
const factory = new RedditAccountRuntimeFactory(async (session) => {
|
|
generation += 1;
|
|
const ownGeneration = generation;
|
|
return new RedditAccountRuntime(session, { ...resources }, {
|
|
start: async () => { events.push(`start:${ownGeneration}`); },
|
|
stop: async () => { events.push(`stop:${ownGeneration}`); },
|
|
});
|
|
});
|
|
const manager = new RedditAccountRuntimeManager(store.repository, factory);
|
|
await manager.startEnabled();
|
|
events.length = 0;
|
|
|
|
await Promise.all([manager.replace(stored.accountId), manager.replace(stored.accountId)]);
|
|
|
|
expect(events).toEqual(['stop:1', 'start:2', 'stop:2', 'start:3']);
|
|
expect(manager.get(stored.accountId)?.snapshot().state).toBe('running');
|
|
});
|
|
|
|
it('rebuilds the previous session when a refreshed runtime fails to start', async () => {
|
|
const stored = account('recoverable');
|
|
const store = accountStore([stored]);
|
|
let generation = 0;
|
|
const starts: number[] = [];
|
|
const stops: number[] = [];
|
|
const factory = new RedditAccountRuntimeFactory(async (session) => {
|
|
generation += 1;
|
|
const ownGeneration = generation;
|
|
return new RedditAccountRuntime(session, { ...resources }, {
|
|
start: () => {
|
|
starts.push(ownGeneration);
|
|
if (ownGeneration === 2) throw new Error('replacement failed');
|
|
},
|
|
stop: () => { stops.push(ownGeneration); },
|
|
});
|
|
});
|
|
const manager = new RedditAccountRuntimeManager(store.repository, factory);
|
|
await manager.startEnabled();
|
|
|
|
await expect(manager.replace(stored.accountId)).rejects.toThrow('replacement failed');
|
|
|
|
expect(generation).toBe(3);
|
|
expect(starts).toEqual([1, 2, 3]);
|
|
expect(stops).toEqual([1, 2]);
|
|
expect(manager.get(stored.accountId)?.snapshot().state).toBe('running');
|
|
expect(manager.get(stored.accountId)?.session).toMatchObject({
|
|
accountId: stored.accountId,
|
|
updatedAt: stored.updatedAt,
|
|
});
|
|
});
|
|
|
|
it('stops different accounts concurrently', async () => {
|
|
const first = account('first');
|
|
const second = account('second');
|
|
const store = accountStore([first, second]);
|
|
const stops: string[] = [];
|
|
const releases = new Map<string, () => void>();
|
|
const factory = new RedditAccountRuntimeFactory(async (session) => new RedditAccountRuntime(
|
|
session,
|
|
{ ...resources },
|
|
{
|
|
start: vi.fn(),
|
|
stop: () => new Promise<void>((resolve) => {
|
|
stops.push(session.accountId);
|
|
releases.set(session.accountId, resolve);
|
|
}),
|
|
},
|
|
));
|
|
const manager = new RedditAccountRuntimeManager(store.repository, factory);
|
|
await manager.startEnabled();
|
|
|
|
const stopping = manager.stopAll();
|
|
await vi.waitFor(() => expect(stops).toHaveLength(2));
|
|
releases.get(first.accountId)!();
|
|
releases.get(second.accountId)!();
|
|
await stopping;
|
|
|
|
expect(manager.list()).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('account runtime isolation', () => {
|
|
it('derives distinct deterministic browser profiles from account IDs', () => {
|
|
const first = accountBrowserProfileDir('./data/browser', '@t2_first:reddit.com');
|
|
const second = accountBrowserProfileDir('./data/browser', '@t2_second:reddit.com');
|
|
|
|
expect(first).not.toBe(second);
|
|
expect(first).toBe(accountBrowserProfileDir('./data/browser', '@t2_first:reddit.com'));
|
|
expect(first).toContain('accounts');
|
|
});
|
|
|
|
it('rejects a stored session whose Reddit account and Matrix identity differ', async () => {
|
|
const mismatched = account('first', { matrixUserId: '@t2_other:reddit.com' });
|
|
const factory = new RedditAccountRuntimeFactory(vi.fn());
|
|
|
|
await expect(factory.create(mismatched)).rejects.toThrow(
|
|
'Stored Reddit account and Matrix identities do not match',
|
|
);
|
|
});
|
|
});
|