diff --git a/package.json b/package.json index 350eda5..239cb74 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "test:indexeddb": "npm run test:indexeddb-persistence && npm run test:indexeddb-sync && npm run test:indexeddb-migration", "test:persistence-matrix": "npm run test:p0 && npm run test:runtime-history && npm run test:graph-persistence && npm run test:indexeddb", "test:stable": "node scripts/run-test-suite.mjs", + "test:authority:e2e": "node tests/e2e/authority-server-primary.mjs", "test:all": "npm run test:stable", "check": "node scripts/check-syntax.mjs" }, diff --git a/tests/e2e/authority-server-primary.mjs b/tests/e2e/authority-server-primary.mjs new file mode 100644 index 0000000..fde9666 --- /dev/null +++ b/tests/e2e/authority-server-primary.mjs @@ -0,0 +1,428 @@ +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; + +import { probeAuthorityCapabilities } from "../../runtime/authority-capabilities.js"; +import { AuthorityGraphStore } from "../../sync/authority-graph-store.js"; +import { + deleteAuthorityTriviumNodes, + filterAuthorityTriviumNodes, + normalizeAuthorityVectorConfig, + purgeAuthorityTriviumNamespace, + queryAuthorityTriviumNeighbors, + searchAuthorityTriviumNodes, + syncAuthorityTriviumLinks, + upsertAuthorityTriviumEntries, +} from "../../vector/authority-vector-primary-adapter.js"; +import { + buildAuthorityJobIdempotencyKey, + createAuthorityJobAdapter, +} from "../../maintenance/authority-job-adapter.js"; +import { createAuthorityBlobAdapter } from "../../maintenance/authority-blob-adapter.js"; + +const env = process.env; +const baseUrl = String(env.AUTHORITY_E2E_BASE_URL || "").trim(); + +if (!baseUrl) { + console.log("authority-server-primary E2E skipped: set AUTHORITY_E2E_BASE_URL to run against a real Authority server"); + process.exit(0); +} + +function parsePositiveInteger(value, fallback, min = 1, max = Number.MAX_SAFE_INTEGER) { + const parsed = Number(value); + if (!Number.isFinite(parsed)) return fallback; + return Math.min(max, Math.max(min, Math.trunc(parsed))); +} + +function parseJsonObject(value, fallback = {}) { + if (!value) return fallback; + try { + const parsed = JSON.parse(String(value)); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : fallback; + } catch { + return fallback; + } +} + +function resolveBaseUrl(value) { + const normalized = String(value || "").replace(/\/+$/g, ""); + if (/^https?:\/\//i.test(normalized)) return normalized; + const origin = String(env.AUTHORITY_E2E_ORIGIN || "").replace(/\/+$/g, ""); + if (origin && normalized.startsWith("/")) return `${origin}${normalized}`; + throw new Error("AUTHORITY_E2E_BASE_URL must be absolute, or set AUTHORITY_E2E_ORIGIN for relative plugin paths"); +} + +function createHeaderProvider() { + const staticHeaders = parseJsonObject(env.AUTHORITY_E2E_HEADER_JSON, {}); + const token = String(env.AUTHORITY_E2E_TOKEN || "").trim(); + const cookie = String(env.AUTHORITY_E2E_COOKIE || "").trim(); + return () => ({ + ...staticHeaders, + ...(token ? { Authorization: /^Bearer\s+/i.test(token) ? token : `Bearer ${token}` } : {}), + ...(cookie ? { Cookie: cookie } : {}), + }); +} + +function createFetchWithTimeout(timeoutMs) { + return async (url, options = {}) => { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(new Error(`Authority E2E request timeout after ${timeoutMs}ms`)), timeoutMs); + try { + return await fetch(url, { + ...options, + signal: controller.signal, + }); + } finally { + clearTimeout(timer); + } + }; +} + +function createContractNode(id, title, nowMs) { + return { + id, + type: "fact", + fields: { + title, + summary: `${title} generated by Authority server-primary E2E contract smoke`, + }, + seqRange: [1, 1], + scope: { + layer: "global", + ownerType: "system", + ownerId: "authority-e2e", + bucket: "contract", + regionKey: "authority-e2e-region", + }, + storySegmentId: "authority-e2e-segment", + importance: 0.8, + archived: false, + updatedAt: nowMs, + }; +} + +function createContractGraph(chatId, runId) { + const nowMs = Date.now(); + const nodeA = createContractNode(`${runId}-node-a`, "Authority E2E Alpha", nowMs); + const nodeB = createContractNode(`${runId}-node-b`, "Authority E2E Beta", nowMs); + return { + meta: { + schemaVersion: 1, + chatId, + deviceId: "authority-e2e", + revision: 1, + lastModified: nowMs, + nodeCount: 2, + edgeCount: 1, + tombstoneCount: 0, + }, + nodes: [nodeA, nodeB], + edges: [ + { + id: `${runId}-edge-a-b`, + fromId: nodeA.id, + toId: nodeB.id, + relation: "related", + type: "semantic", + strength: 0.7, + updatedAt: nowMs, + }, + ], + tombstones: [], + state: { + lastProcessedFloor: 1, + extractionCount: 1, + }, + }; +} + +function buildVectorEntries(graph) { + return graph.nodes.map((node, index) => ({ + nodeId: node.id, + index, + hash: `${node.id}:hash`, + text: `${node.fields.title}. ${node.fields.summary}`, + })); +} + +async function runStep(name, fn) { + const startedAt = Date.now(); + try { + const result = await fn(); + const durationMs = Date.now() - startedAt; + console.log(`authority E2E ${name}: ok (${durationMs}ms)`); + return { name, ok: true, durationMs, result }; + } catch (error) { + const durationMs = Date.now() - startedAt; + console.error(`authority E2E ${name}: failed (${durationMs}ms)`); + console.error(error?.stack || error?.message || String(error)); + throw error; + } +} + +const resolvedBaseUrl = resolveBaseUrl(baseUrl); +const timeoutMs = parsePositiveInteger(env.AUTHORITY_E2E_TIMEOUT_MS, 15000, 1000, 300000); +const jobWaitTimeoutMs = parsePositiveInteger(env.AUTHORITY_E2E_JOB_WAIT_TIMEOUT_MS, 15000, 1000, 300000); +const headerProvider = createHeaderProvider(); +const fetchImpl = createFetchWithTimeout(timeoutMs); +const runId = String(env.AUTHORITY_E2E_RUN_ID || `authority-e2e-${Date.now()}-${randomUUID().slice(0, 8)}`) + .replace(/[^A-Za-z0-9._:-]+/g, "-"); +const chatId = String(env.AUTHORITY_E2E_CHAT_ID || `st-bme-${runId}`); +const namespace = String(env.AUTHORITY_E2E_NAMESPACE || `st-bme-e2e-${runId}`); +const collectionId = String(env.AUTHORITY_E2E_COLLECTION_ID || `${namespace}::${chatId}`); +const blobPath = String(env.AUTHORITY_E2E_BLOB_PATH || `st-bme/e2e/${runId}/contract.json`); +const graph = createContractGraph(chatId, runId); + +const context = { + baseUrl: resolvedBaseUrl, + chatId, + namespace, + collectionId, + blobPath, +}; + +console.log(`authority-server-primary E2E started: ${JSON.stringify(context)}`); + +await runStep("probe", async () => { + const state = await probeAuthorityCapabilities({ + settings: { authorityBaseUrl: resolvedBaseUrl }, + fetchImpl, + headerProvider, + allowRelativeUrl: false, + }); + assert.equal(state.installed, true); + assert.equal(state.healthy, true); + return { + endpoint: state.endpoint, + features: state.features, + missingFeatures: state.missingFeatures, + }; +}); + +await runStep("sql", async () => { + const store = new AuthorityGraphStore(chatId, { + baseUrl: resolvedBaseUrl, + fetchImpl, + headerProvider, + }); + try { + await store.open(); + const importResult = await store.importSnapshot(graph, { + mode: "replace", + preserveRevision: true, + markSyncDirty: false, + }); + assert.equal(importResult.imported.nodes, graph.nodes.length); + assert.equal(importResult.imported.edges, graph.edges.length); + + const commitResult = await store.commitDelta( + { + upsertNodes: [createContractNode(`${runId}-node-c`, "Authority E2E Gamma", Date.now())], + runtimeMetaPatch: { authorityE2eRunId: runId }, + }, + { reason: "authority-e2e-contract", markSyncDirty: false }, + ); + assert.ok(commitResult.revision >= importResult.revision); + + const snapshot = await store.exportSnapshot({ includeTombstones: false }); + assert.equal(snapshot.meta.chatId, chatId); + assert.ok(snapshot.nodes.some((node) => node.id === graph.nodes[0].id)); + assert.ok(snapshot.nodes.some((node) => node.id === `${runId}-node-c`)); + return { + revision: snapshot.meta.revision, + nodes: snapshot.nodes.length, + edges: snapshot.edges.length, + }; + } finally { + await store.clearAll().catch(() => null); + await store.close().catch(() => null); + } +}); + +await runStep("trivium", async () => { + const config = normalizeAuthorityVectorConfig({ authorityBaseUrl: resolvedBaseUrl }); + const entries = buildVectorEntries(graph); + await purgeAuthorityTriviumNamespace(config, { + namespace, + collectionId, + chatId, + fetchImpl, + headerProvider, + }).catch(() => null); + + try { + const upsertResult = await upsertAuthorityTriviumEntries(graph, config, entries, { + namespace, + collectionId, + chatId, + modelScope: "authority-e2e", + revision: 1, + fetchImpl, + headerProvider, + }); + assert.equal(upsertResult.upserted, entries.length); + + const linkResult = await syncAuthorityTriviumLinks(graph, config, { + namespace, + collectionId, + chatId, + revision: 1, + fetchImpl, + headerProvider, + }); + assert.equal(linkResult.linked, graph.edges.length); + + const searchResults = await searchAuthorityTriviumNodes(graph, "Authority E2E Alpha", config, { + namespace, + collectionId, + chatId, + topK: 5, + fetchImpl, + headerProvider, + }); + assert.ok(Array.isArray(searchResults)); + + const filteredIds = await filterAuthorityTriviumNodes(config, { + namespace, + collectionId, + chatId, + topK: 10, + where: { chatId, archived: false }, + searchText: "Authority E2E", + fetchImpl, + headerProvider, + }); + assert.ok(Array.isArray(filteredIds)); + + const neighborIds = await queryAuthorityTriviumNeighbors(config, [graph.nodes[0].id], { + namespace, + collectionId, + chatId, + topK: 5, + fetchImpl, + headerProvider, + }); + assert.ok(Array.isArray(neighborIds)); + + return { + upserted: upsertResult.upserted, + linked: linkResult.linked, + searchResults: searchResults.length, + filteredIds: filteredIds.length, + neighborIds: neighborIds.length, + }; + } finally { + await deleteAuthorityTriviumNodes(config, graph.nodes.map((node) => node.id), { + namespace, + collectionId, + chatId, + fetchImpl, + headerProvider, + }).catch(() => null); + await purgeAuthorityTriviumNamespace(config, { + namespace, + collectionId, + chatId, + fetchImpl, + headerProvider, + }).catch(() => null); + } +}); + +await runStep("jobs", async () => { + const adapter = createAuthorityJobAdapter( + { + authorityBaseUrl: resolvedBaseUrl, + pollIntervalMs: 500, + waitTimeoutMs: jobWaitTimeoutMs, + }, + { + fetchImpl, + headerProvider, + }, + ); + const listBefore = await adapter.listPage({ limit: 5 }); + assert.ok(Array.isArray(listBefore.jobs)); + + const kind = String(env.AUTHORITY_E2E_JOB_KIND || "authority.vector.rebuild"); + const idempotencyKey = buildAuthorityJobIdempotencyKey({ + kind, + chatId, + collectionId, + revision: 1, + }); + const submitted = await adapter.submit( + kind, + { + chatId, + collectionId, + namespace, + modelScope: "authority-e2e", + source: "authority-e2e-contract", + purge: false, + dryRun: true, + contractSmoke: true, + idempotencyKey, + }, + { idempotencyKey }, + ); + assert.ok(submitted.id); + + const waited = await adapter.waitForCompletion(submitted.id, { + timeoutMs: jobWaitTimeoutMs, + pollIntervalMs: 500, + }); + assert.ok(waited.id || waited.status); + + const requeued = await adapter.requeue(submitted.id, { safe: true }); + assert.ok(requeued.id || requeued.status); + + const listAfter = await adapter.listPage({ limit: 5 }); + assert.ok(Array.isArray(listAfter.jobs)); + return { + listBefore: listBefore.jobs.length, + submitted: submitted.id, + waitedStatus: waited.status, + requeuedStatus: requeued.status, + listAfter: listAfter.jobs.length, + }; +}); + +await runStep("blob", async () => { + const adapter = createAuthorityBlobAdapter( + { authorityBaseUrl: resolvedBaseUrl, authorityBlobNamespace: namespace }, + { fetchImpl, headerProvider }, + ); + const payload = { + runId, + chatId, + collectionId, + createdAt: new Date().toISOString(), + graph: { + nodes: graph.nodes.length, + edges: graph.edges.length, + }, + }; + try { + const writeResult = await adapter.writeJson(blobPath, payload, { + metadata: { chatId, runId, purpose: "authority-e2e-contract" }, + }); + assert.equal(writeResult.ok, true); + + const statResult = await adapter.stat(blobPath); + assert.equal(statResult.exists, true); + + const readResult = await adapter.readJson(blobPath); + assert.equal(readResult.exists, true); + assert.equal(readResult.payload.runId, runId); + return { + path: writeResult.path, + size: writeResult.size, + etag: writeResult.etag, + }; + } finally { + const deleteResult = await adapter.delete(blobPath).catch(() => null); + if (deleteResult) assert.equal(deleteResult.ok, true); + } +}); + +console.log("authority-server-primary E2E passed");