292 lines
8.2 KiB
TypeScript
292 lines
8.2 KiB
TypeScript
import type { IncomingMessage, RequestListener, ServerResponse } from 'node:http';
|
|
|
|
import { IDBFactory, IDBKeyRange } from 'fake-indexeddb';
|
|
import {
|
|
BrowserDocumentStore,
|
|
BrowserRuntimeStore,
|
|
RuntimeAppendConflictError,
|
|
} from '@openmaic/storage';
|
|
import { HttpRuntimeStore, HttpRuntimeStoreError } from '@openmaic/storage/runtime/http';
|
|
import { createStorageHttpHandler } from '@openmaic/storage/server';
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import { APP_RUNTIME_PAYLOAD_VALIDATORS } from '@/lib/runtime/payload-validators';
|
|
|
|
const NOW = '2026-08-06T00:00:00.000Z';
|
|
|
|
function validPayload(operationId = 'legacy-import:valid') {
|
|
return {
|
|
payloadVersion: 1,
|
|
operationId,
|
|
operation: {
|
|
kind: 'legacy_snapshot_imported',
|
|
source: {
|
|
kind: 'stage.whiteboard',
|
|
fingerprint: `sha256:${'a'.repeat(64)}`,
|
|
},
|
|
whiteboard: {
|
|
id: 'board-1',
|
|
viewportSize: 1000,
|
|
viewportRatio: 0.5625,
|
|
elements: [],
|
|
},
|
|
},
|
|
} as const;
|
|
}
|
|
|
|
function validElementPayload(operationId = 'element-add:valid') {
|
|
return {
|
|
payloadVersion: 1,
|
|
operationId,
|
|
operation: {
|
|
kind: 'element_added',
|
|
element: {
|
|
id: 'text-added',
|
|
type: 'text',
|
|
left: 40,
|
|
top: 50,
|
|
width: 240,
|
|
height: 60,
|
|
rotate: 0,
|
|
content: 'learner text',
|
|
defaultFontName: 'Inter',
|
|
defaultColor: '#000000',
|
|
},
|
|
},
|
|
} as const;
|
|
}
|
|
|
|
function handlerFetch(handler: RequestListener): typeof globalThis.fetch {
|
|
return async (input, init) => {
|
|
const request = new Request(input, init);
|
|
const url = new URL(request.url);
|
|
const body = await request.text();
|
|
const fakeRequest = {
|
|
method: request.method,
|
|
url: `${url.pathname}${url.search}`,
|
|
headers: Object.fromEntries(request.headers.entries()),
|
|
async *[Symbol.asyncIterator]() {
|
|
if (body !== '') yield Buffer.from(body);
|
|
},
|
|
} as unknown as IncomingMessage;
|
|
return new Promise<Response>((resolve, reject) => {
|
|
let status = 200;
|
|
let headers: Record<string, string> = {};
|
|
const fakeResponse = {
|
|
get headersSent() {
|
|
return false;
|
|
},
|
|
writeHead(nextStatus: number, nextHeaders?: Record<string, string>) {
|
|
status = nextStatus;
|
|
headers = nextHeaders ?? {};
|
|
return this;
|
|
},
|
|
end(chunk?: string | Buffer) {
|
|
resolve(
|
|
new Response(status === 204 ? null : chunk?.toString(), {
|
|
status,
|
|
headers,
|
|
}),
|
|
);
|
|
return this;
|
|
},
|
|
destroy(error?: Error) {
|
|
reject(error ?? new Error('response destroyed'));
|
|
return this;
|
|
},
|
|
} as unknown as ServerResponse;
|
|
handler(fakeRequest, fakeResponse);
|
|
});
|
|
};
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.resetModules();
|
|
vi.stubGlobal('indexedDB', new IDBFactory());
|
|
vi.stubGlobal('IDBKeyRange', IDBKeyRange);
|
|
});
|
|
|
|
describe('default BrowserRuntimeStore app payload wiring', () => {
|
|
it('retains chat/quiz gates and adds the whiteboard gate', async () => {
|
|
const { getRuntimeStore } = await import('@/lib/runtime/store');
|
|
const store = getRuntimeStore();
|
|
const now = '2026-08-06T00:00:00.000Z';
|
|
for (const kind of ['chat', 'quizAttempt', 'whiteboard']) {
|
|
await store.createSession({
|
|
id: `${kind}-session`,
|
|
kind,
|
|
stageId: 'stage-1',
|
|
learnerKey: 'learner-1',
|
|
status: 'active',
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
});
|
|
}
|
|
|
|
await expect(
|
|
store.appendRecord({
|
|
id: 'bad-chat',
|
|
sessionId: 'chat-session',
|
|
createdAt: now,
|
|
payload: { role: 'assistant' },
|
|
}),
|
|
).rejects.toThrow('ChatMessageSkeleton');
|
|
await expect(
|
|
store.appendRecord({
|
|
id: 'bad-quiz',
|
|
sessionId: 'quizAttempt-session',
|
|
createdAt: now,
|
|
payload: { phase: 'draft' },
|
|
}),
|
|
).rejects.toThrow('QuizAttemptSkeleton');
|
|
await expect(
|
|
store.appendRecord({
|
|
id: 'bad-whiteboard',
|
|
sessionId: 'whiteboard-session',
|
|
createdAt: now,
|
|
payload: { payloadVersion: 1 },
|
|
}),
|
|
).rejects.toThrow('invalid runtime record');
|
|
await expect(
|
|
store.appendRecord(
|
|
{
|
|
id: 'element-add:valid',
|
|
sessionId: 'whiteboard-session',
|
|
createdAt: now,
|
|
payload: validElementPayload(),
|
|
},
|
|
{ expectedLastSeq: null },
|
|
),
|
|
).resolves.toMatchObject({ seq: 0 });
|
|
await expect(
|
|
store.appendRecord(
|
|
{
|
|
id: 'element-add:invalid-target',
|
|
sessionId: 'whiteboard-session',
|
|
createdAt: now,
|
|
payload: {
|
|
...validElementPayload('element-add:invalid-target'),
|
|
operation: {
|
|
...validElementPayload().operation,
|
|
whiteboardId: 'model-selected-board',
|
|
},
|
|
},
|
|
},
|
|
{ expectedLastSeq: 0 },
|
|
),
|
|
).rejects.toThrow('invalid runtime record');
|
|
});
|
|
|
|
it('enforces the same whiteboard validator and CAS through HTTP', async () => {
|
|
const runtime = new BrowserRuntimeStore({
|
|
indexedDB: new IDBFactory(),
|
|
dbName: 'whiteboard-http-wiring',
|
|
payloadValidators: APP_RUNTIME_PAYLOAD_VALIDATORS,
|
|
});
|
|
const documents = new BrowserDocumentStore({ indexedDB: new IDBFactory() });
|
|
const handler = createStorageHttpHandler(runtime, documents, {
|
|
authenticate: async () => ({ learnerKey: 'learner-1' }),
|
|
authorizeDocuments: async () => false,
|
|
payloadValidators: APP_RUNTIME_PAYLOAD_VALIDATORS,
|
|
});
|
|
const client = new HttpRuntimeStore({
|
|
baseUrl: 'http://whiteboard-storage.invalid',
|
|
fetch: handlerFetch(handler),
|
|
});
|
|
await client.createSession({
|
|
id: 'http-whiteboard',
|
|
kind: 'whiteboard',
|
|
stageId: 'stage-1',
|
|
learnerKey: 'learner-1',
|
|
status: 'active',
|
|
createdAt: NOW,
|
|
updatedAt: NOW,
|
|
});
|
|
await expect(
|
|
client.appendRecord(
|
|
{
|
|
id: 'http-invalid',
|
|
sessionId: 'http-whiteboard',
|
|
createdAt: NOW,
|
|
payload: { payloadVersion: 1 },
|
|
},
|
|
{ expectedLastSeq: null },
|
|
),
|
|
).rejects.toMatchObject({
|
|
name: HttpRuntimeStoreError.name,
|
|
status: 400,
|
|
code: 'VALIDATION_FAILED',
|
|
});
|
|
const httpCommitted = await client.appendRecord(
|
|
{
|
|
id: 'http-record',
|
|
sessionId: 'http-whiteboard',
|
|
createdAt: NOW,
|
|
payload: validPayload('legacy-import:http'),
|
|
},
|
|
{ expectedLastSeq: null },
|
|
);
|
|
expect(httpCommitted.seq).toBe(0);
|
|
|
|
await client.createSession({
|
|
id: 'http-whiteboard-element',
|
|
kind: 'whiteboard',
|
|
stageId: 'stage-1',
|
|
learnerKey: 'learner-1',
|
|
status: 'active',
|
|
createdAt: NOW,
|
|
updatedAt: NOW,
|
|
});
|
|
await expect(
|
|
client.appendRecord(
|
|
{
|
|
id: 'element-add:http',
|
|
sessionId: 'http-whiteboard-element',
|
|
createdAt: NOW,
|
|
payload: validElementPayload('element-add:http'),
|
|
},
|
|
{ expectedLastSeq: null },
|
|
),
|
|
).resolves.toMatchObject({ seq: 0 });
|
|
await expect(
|
|
client.appendRecord(
|
|
{
|
|
id: 'element-add:http-invalid',
|
|
sessionId: 'http-whiteboard-element',
|
|
createdAt: NOW,
|
|
payload: {
|
|
...validElementPayload('element-add:http-invalid'),
|
|
operation: {
|
|
...validElementPayload().operation,
|
|
whiteboardId: 'model-selected-board',
|
|
},
|
|
},
|
|
},
|
|
{ expectedLastSeq: 0 },
|
|
),
|
|
).rejects.toMatchObject({
|
|
name: HttpRuntimeStoreError.name,
|
|
status: 400,
|
|
code: 'VALIDATION_FAILED',
|
|
});
|
|
const stale = await client
|
|
.appendRecord(
|
|
{
|
|
id: 'http-stale',
|
|
sessionId: 'http-whiteboard',
|
|
createdAt: NOW,
|
|
payload: validPayload('legacy-import:stale'),
|
|
},
|
|
{ expectedLastSeq: null },
|
|
)
|
|
.catch((error: unknown) => error);
|
|
expect(stale).toBeInstanceOf(RuntimeAppendConflictError);
|
|
expect(stale).toMatchObject({
|
|
name: RuntimeAppendConflictError.name,
|
|
sessionId: 'http-whiteboard',
|
|
expectedLastSeq: null,
|
|
actualLastSeq: 0,
|
|
});
|
|
});
|
|
});
|