Files
Immich/web/test/medium/mock/api.ts
T
Jason Rasmussen e14fcd0a52 feat: web medium tests (#32817)
* feat: web medium tests

* chore: pr feedback

* fix: merge
2026-11-03 19:39:30 +03:00

213 lines
7.3 KiB
TypeScript

import { Spy } from '#test/medium/mock/helpers.ts';
import { getOperation, hasOperation, matchOperation, toCall } from '#test/medium/mock/spec.ts';
import type { Call, Handler, HandlerContext, Handlers, OperationId } from '#test/medium/mock/types.ts';
import type { BrowserContext, Request, Route, WebSocketRoute } from '@playwright/test';
const apiPattern = /^https?:\/\/[^/]+\/api\//;
const socketPattern = /\/api\/socket\.io\//;
const customCssPattern = /^https?:\/\/[^/]+\/custom\.css$/;
const mapStylePattern = /^https:\/\/tiles\.immich\.cloud\//;
const emptyMapStyle = { version: 8, sources: {}, layers: [] };
const sockets = new WeakMap<BrowserContext, WebSocketRoute>();
const socketMockedContexts = new WeakSet<BrowserContext>();
const mockWebSocket = async (context: BrowserContext) => {
if (socketMockedContexts.has(context)) {
return;
}
socketMockedContexts.add(context);
await context.routeWebSocket(socketPattern, (socket) => {
sockets.set(context, socket);
socket.send('0{"sid":"mock","upgrades":[],"pingInterval":25000,"pingTimeout":20000,"maxPayload":1000000}');
socket.onMessage((message) => {
if (message === '2') {
socket.send('3');
} else if (typeof message === 'string' && message.startsWith('40')) {
socket.send('40{"sid":"mock"}');
}
});
});
};
const fulfill = async (route: Route, options: Parameters<Route['fulfill']>[0]) => {
try {
await route.fulfill(options);
} catch {
return;
}
};
const parseBody = (request: Request) => {
const data = request.postData();
if (data === null) {
return;
}
try {
return JSON.parse(data) as unknown;
} catch {
return data;
}
};
const mutationMethods = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
const readOperations = new Set<OperationId>(['searchAssets', 'getDownloadInfo', 'downloadArchive']);
export class OperationSpy<K extends OperationId> extends Spy<Call<K>> {
constructor(
readonly operationId: K,
readonly method: string,
) {
super(`api.${operationId}`);
}
get isMutation() {
return mutationMethods.has(this.method) && !readOperations.has(this.operationId);
}
}
type OperationSpies = { readonly [K in OperationId]: OperationSpy<K> };
export type MockApi = MockApiBase & OperationSpies;
export const createMockApi = (context: BrowserContext): MockApi => {
const api = new MockApiBase(context);
return new Proxy(api, {
get: (target, property, receiver) => {
if (Reflect.has(target, property)) {
return Reflect.get(target, property, receiver);
}
return typeof property === 'string' && hasOperation(property) ? target.spy(property as OperationId) : undefined;
},
}) as MockApi;
};
export class MockApiBase {
readonly unhandled: string[] = [];
readonly failures: string[] = [];
private readonly routedUrls = new Set<string>();
private readonly handlers = new Map<string, Handler<OperationId>>();
private readonly spies = new Map<string, OperationSpy<OperationId>>();
private readonly disposers: Array<() => Promise<void>> = [];
constructor(private readonly context: BrowserContext) {}
private async route(pattern: RegExp, handler: (route: Route, request: Request) => Promise<void>) {
await this.context.route(pattern, handler);
this.disposers.push(() => this.context.unroute(pattern));
}
spy(operationId: OperationId) {
let spy = this.spies.get(operationId);
if (!spy) {
spy = new OperationSpy(operationId, getOperation(operationId).method);
this.spies.set(operationId, spy);
}
return spy;
}
async install() {
await mockWebSocket(this.context);
await this.route(customCssPattern, (route) => route.fulfill({ body: '', contentType: 'text/css' }));
await this.route(mapStylePattern, (route) => route.fulfill({ json: emptyMapStyle }));
await this.route(apiPattern, (route, request) => this.handle(route, request));
}
async uninstall() {
for (const dispose of this.disposers.splice(0).reverse()) {
await dispose();
}
}
on(handlers: Handlers) {
for (const [operationId, handler] of Object.entries(handlers)) {
this.assertOperation(operationId);
this.handlers.set(operationId, handler as Handler<OperationId>);
}
return this;
}
off(operationId: OperationId) {
this.handlers.delete(operationId);
return this;
}
emit(event: string, payload?: unknown) {
const socket = sockets.get(this.context);
if (!socket) {
throw new Error('No websocket connection has been established by the page');
}
socket.send(`42${JSON.stringify(payload === undefined ? [event] : [event, payload])}`);
}
recordDownload(url: URL) {
if (this.routedUrls.has(url.href)) {
return;
}
const match = matchOperation('GET', url.pathname);
if (!match) {
this.unhandled.push(`GET ${url.pathname}${url.search} (download without a matching OpenAPI operation)`);
return;
}
const call = toCall(match.operation, match.params, url.searchParams, undefined);
this.spy(match.operation.operationId as OperationId).record(call as Call<OperationId>, { expected: true });
}
unexpectedCalls() {
return [...this.spies.values()].flatMap((spy) =>
spy.unexpected().map((call) => `${spy.method} ${spy.operationId} ${JSON.stringify(call)}`),
);
}
private assertOperation(operationId: string) {
if (!hasOperation(operationId)) {
throw new Error(`Unknown operationId: ${operationId}`);
}
}
private async handle(route: Route, request: Request) {
const url = new URL(request.url());
const method = request.method();
this.routedUrls.add(url.href);
const match = matchOperation(method, url.pathname);
if (!match) {
this.unhandled.push(`${method} ${url.pathname}${url.search} (no matching OpenAPI operation)`);
return fulfill(route, { status: 501, json: { message: 'No matching OpenAPI operation' } });
}
const { operation, params } = match;
const body = parseBody(request);
const call = toCall(operation, params, url.searchParams, body);
const spy = this.spy(operation.operationId as OperationId);
spy.record(call as Call<OperationId>, { expected: spy.isMutation });
const handler = this.handlers.get(operation.operationId);
if (!handler) {
this.unhandled.push(`${method} ${url.pathname}${url.search} (${operation.operationId})`);
return fulfill(route, { status: 501, json: { message: `No mock handler for ${operation.operationId}` } });
}
let result;
try {
result = await handler({ ...call, request } as HandlerContext<OperationId>);
} catch (error) {
const message = error instanceof Error ? (error.stack ?? error.message) : String(error);
this.failures.push(`${operation.operationId}: ${message}`);
return fulfill(route, { status: 500, json: { message: `Mock handler for ${operation.operationId} threw` } });
}
if (result && 'body' in result && result.body !== undefined) {
return fulfill(route, {
status: result.status ?? 200,
body: result.body,
contentType: result.contentType,
headers: result.headers,
});
}
if (result && 'json' in result && result.json !== undefined) {
return fulfill(route, { status: result.status ?? 200, json: result.json, headers: result.headers });
}
return fulfill(route, { status: result?.status ?? 204, headers: result?.headers });
}
}