WIP
This commit is contained in:
119
test/chat-store.mjs
Normal file
119
test/chat-store.mjs
Normal file
@@ -0,0 +1,119 @@
|
||||
// Unit check for src/chat/store.js. No stack, no network: writeMessage,
|
||||
// setDocumentUrl and dropDocument are handed a recording updateFn and the
|
||||
// SPARQL they produce is asserted on and parsed (sparqljs), so a syntax slip
|
||||
// fails here and not against the auth layer.
|
||||
//
|
||||
// npm test (or: node --import ./dev-stubs/register.js test/chat-store.mjs)
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import { writeMessage, setDocumentUrl, dropDocument } from '../src/chat/store.js';
|
||||
|
||||
let failures = 0;
|
||||
async function check(name, fn) {
|
||||
try {
|
||||
await fn();
|
||||
console.log(`ok - ${name}`);
|
||||
} catch (e) {
|
||||
failures++;
|
||||
console.error(`FAIL - ${name}\n ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const CONVERSATION = 'http://data.lblod.info/id/chat-conversations/9ab4';
|
||||
const ASSISTANT = 'http://data.lblod.info/id/chat-agents/rapportassistent';
|
||||
const DOCUMENT = 'http://data.lblod.info/id/chat-documents/7d02';
|
||||
|
||||
// A recording update fn. `Parser` (sparqljs) is asserted later: every string
|
||||
// the store builds must be a valid SPARQL update.
|
||||
const { Parser } = await import('sparqljs');
|
||||
const parse = (sparql) => new Parser().parse(sparql);
|
||||
|
||||
function recorder() {
|
||||
const calls = [];
|
||||
return {
|
||||
calls,
|
||||
async updateFn(sparql) { calls.push(sparql); },
|
||||
};
|
||||
}
|
||||
|
||||
await check('writeMessage: both types, the container, the uuid', async () => {
|
||||
const { calls, updateFn } = recorder();
|
||||
const { uri, id, documents } = await writeMessage(updateFn, {
|
||||
conversationUri: CONVERSATION, content: 'Hallo?', maker: ASSISTANT,
|
||||
});
|
||||
const q = calls[0];
|
||||
assert.ok(q.includes('<http://rdfs.org/sioc/ns#Post>'), 'has sioc:Post');
|
||||
assert.ok(q.includes('<http://rdfs.org/sioc/types#InstantMessage>'), 'has sioct:InstantMessage');
|
||||
assert.ok(q.includes('<http://rdfs.org/sioc/ns#has_container>'), 'has sioc:has_container');
|
||||
assert.ok(q.includes(`<${CONVERSATION}>`), 'names the conversation');
|
||||
assert.ok(q.includes(id), 'carries the message uuid');
|
||||
assert.ok(q.trim().startsWith('DELETE'), 'touches the conversation');
|
||||
assert.ok(!q.includes('http://purl.org/dc/terms/title'), 'no title when none given');
|
||||
assert.ok(!q.includes('activitystreams'), 'no attachments when none given');
|
||||
assert.equal(documents.length, 0);
|
||||
assert.ok(uri.endsWith(id));
|
||||
});
|
||||
|
||||
await check('writeMessage: the title on the first question', async () => {
|
||||
const { calls, updateFn } = recorder();
|
||||
await writeMessage(updateFn, {
|
||||
conversationUri: CONVERSATION, content: 'Hoeveel meldingen?',
|
||||
maker: ASSISTANT, title: 'Hoeveel meldingen?',
|
||||
});
|
||||
assert.ok(calls[0].includes('http://purl.org/dc/terms/title'), 'writes dct:title');
|
||||
});
|
||||
|
||||
await check('writeMessage: an attachment without url is pending, with url is ready', async () => {
|
||||
const { calls, updateFn } = recorder();
|
||||
const { documents } = await writeMessage(updateFn, {
|
||||
conversationUri: CONVERSATION, content: 'Ik maak het rapport.',
|
||||
maker: ASSISTANT, attachments: [{ name: 'meldingen.csv', mediaType: 'text/csv' }],
|
||||
});
|
||||
const pending = calls[0];
|
||||
assert.ok(pending.includes('https://www.w3.org/ns/activitystreams#name'), 'names the file');
|
||||
assert.ok(pending.includes('https://www.w3.org/ns/activitystreams#mediaType'), 'types the file');
|
||||
assert.ok(!pending.includes('https://www.w3.org/ns/activitystreams#url'), 'a pending file has no url');
|
||||
assert.ok(documents[0].uri.endsWith(documents[0].id), 'the document uri ends in its uuid');
|
||||
|
||||
const r2 = recorder();
|
||||
await writeMessage(r2.updateFn, {
|
||||
conversationUri: CONVERSATION, content: 'Klaar.',
|
||||
maker: ASSISTANT,
|
||||
attachments: [{ name: 'meldingen.csv', mediaType: 'text/csv', url: '/files/8c31/download' }],
|
||||
});
|
||||
assert.ok(r2.calls[0].includes('https://www.w3.org/ns/activitystreams#url'), 'a ready file has a url');
|
||||
});
|
||||
|
||||
await check('setDocumentUrl and dropDocument name the document', async () => {
|
||||
const r1 = recorder();
|
||||
await setDocumentUrl(r1.updateFn, DOCUMENT, '/files/8c31/download', CONVERSATION);
|
||||
assert.ok(r1.calls[0].includes(`<${DOCUMENT}>`), 'setDocumentUrl names the document');
|
||||
assert.ok(r1.calls[0].includes('sioc/ns#last_activity_date'), 'touches the conversation');
|
||||
|
||||
const r2 = recorder();
|
||||
await dropDocument(r2.updateFn, DOCUMENT);
|
||||
assert.ok(r2.calls[0].includes(`<${DOCUMENT}>`), 'dropDocument names the document');
|
||||
assert.ok(r2.calls[0].includes('?message'), 'drops the link from the message');
|
||||
});
|
||||
|
||||
await check('every recorded string parses as a SPARQL update', async () => {
|
||||
for (const maker of [recorder]) {
|
||||
const r = maker();
|
||||
await writeMessage(r.updateFn, {
|
||||
conversationUri: CONVERSATION, content: 'x', maker: ASSISTANT, title: 'x',
|
||||
attachments: [{ name: 'a.csv', mediaType: 'text/csv' }],
|
||||
});
|
||||
await writeMessage(r.updateFn, { conversationUri: CONVERSATION, content: 'y', maker: ASSISTANT });
|
||||
await setDocumentUrl(r.updateFn, DOCUMENT, '/files/8c31/download', CONVERSATION);
|
||||
await dropDocument(r.updateFn, DOCUMENT);
|
||||
for (const [i, sparql] of r.calls.entries()) {
|
||||
try {
|
||||
parse(sparql);
|
||||
} catch (e) {
|
||||
assert.fail(`update ${i} does not parse: ${e.message}\n${sparql}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
process.exit(failures ? 1 : 0);
|
||||
133
test/chat-turn.mjs
Normal file
133
test/chat-turn.mjs
Normal file
@@ -0,0 +1,133 @@
|
||||
// Integration check for the turn endpoint (src/chat/index.js). No stack: the
|
||||
// SPARQL endpoint is a stub in this process that answers like the auth layer
|
||||
// (JSON bindings plus the mu-auth-allowed-groups response header), and the
|
||||
// module is mounted on a real express app.
|
||||
//
|
||||
// npm test (or: node --import ./dev-stubs/register.js test/chat-turn.mjs)
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import http from 'node:http';
|
||||
import express from 'express';
|
||||
|
||||
const PORT = 8899;
|
||||
|
||||
let failures = 0;
|
||||
async function check(name, fn) {
|
||||
try {
|
||||
await fn();
|
||||
console.log(`ok - ${name}`);
|
||||
} catch (e) {
|
||||
failures++;
|
||||
console.error(`FAIL - ${name}\n ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// --- the stub auth layer ------------------------------------------------------
|
||||
|
||||
const KNOWN_ID = '11111111-1111-1111-1111-111111111111';
|
||||
const CONVERSATION = 'http://data.lblod.info/id/chat-conversations/9ab4';
|
||||
const ASSISTANT = 'http://data.lblod.info/id/chat-agents/rapportassistent';
|
||||
const GROUPS = '[{"variables":["account"],"name":"chat-owner"}]';
|
||||
|
||||
const requests = [];
|
||||
const stub = http.createServer((req, res) => {
|
||||
let body = '';
|
||||
req.on('data', (c) => { body += c; });
|
||||
req.on('end', () => {
|
||||
requests.push({ method: req.method, url: req.url, headers: req.headers, body });
|
||||
res.setHeader('Content-Type', 'application/sparql-results+json');
|
||||
res.setHeader('mu-auth-allowed-groups', GROUPS);
|
||||
if (req.url === '/sparql' && /SELECT/.test(body)) {
|
||||
if (/prov%23SoftwareAgent|prov#SoftwareAgent/.test(body)) {
|
||||
res.end(JSON.stringify({ results: { bindings: [
|
||||
{ agent: { value: ASSISTANT } },
|
||||
] } }));
|
||||
} else if (body.includes(KNOWN_ID)) {
|
||||
res.end(JSON.stringify({ results: { bindings: [
|
||||
{ conversation: { value: CONVERSATION }, creator: { value: 'http://data.lblod.info/id/gebruiker/1' } },
|
||||
] } }));
|
||||
} else {
|
||||
res.end(JSON.stringify({ results: { bindings: [] } }));
|
||||
}
|
||||
} else {
|
||||
res.end('null');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise((r) => stub.listen(PORT, r));
|
||||
process.env.MU_SPARQL_ENDPOINT = `http://localhost:${PORT}/sparql`;
|
||||
|
||||
const { mountChat } = await import('../src/chat/index.js');
|
||||
const app = express();
|
||||
const writes = [];
|
||||
mountChat(app, {
|
||||
path: '/assistant',
|
||||
answer: async (turn) => {
|
||||
writes.push(turn.content);
|
||||
return `Je schreef: ${turn.content}`;
|
||||
},
|
||||
});
|
||||
const server = app.listen(0);
|
||||
const base = `http://localhost:${server.address().port}`;
|
||||
|
||||
const post = (path, body, headers = {}) => fetch(`${base}${path}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'mu-session-id': 'http://mu.semte.ch/sessions/x', ...headers },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
await check('blank content is 400', async () => {
|
||||
const res = await post('/assistant/conversations/00000000-0000-0000-0000-000000000000/turns', { content: ' ' });
|
||||
assert.equal(res.status, 400);
|
||||
});
|
||||
|
||||
await check('an unreadable conversation is 404', async () => {
|
||||
const res = await post('/assistant/conversations/00000000-0000-0000-0000-000000000000/turns', { content: 'Hallo?' });
|
||||
assert.equal(res.status, 404);
|
||||
});
|
||||
|
||||
await check('a turn answers 202 with the message id, then writes the echo', async () => {
|
||||
requests.length = 0;
|
||||
const res = await post(`/assistant/conversations/${KNOWN_ID}/turns`, { content: 'Hallo?' });
|
||||
assert.equal(res.status, 202);
|
||||
const { id } = await res.json();
|
||||
assert.match(id, /^[0-9a-f-]{36}$/);
|
||||
|
||||
// the answer hook runs after the 202; give it a moment
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
const updates = requests.filter((r) => r.method === 'POST' && /DELETE|INSERT/.test(r.body)).map((r) => r.body);
|
||||
assert.ok(updates.length >= 2, 'the question and the echo are written');
|
||||
assert.ok(updates[0].includes('Hallo?'), 'the question first');
|
||||
assert.ok(updates.some((u) => u.includes('Je schreef: Hallo?')), 'the echo after');
|
||||
// the identity carries past the 202
|
||||
assert.ok(requests.slice(1).every((r) => r.headers['mu-auth-allowed-groups'] === GROUPS),
|
||||
'every write carries the captured groups');
|
||||
const sessionRead = requests[0];
|
||||
assert.equal(sessionRead.headers['mu-session-id'], 'http://mu.semte.ch/sessions/x');
|
||||
});
|
||||
|
||||
await check('a throwing hook writes the failure line', async () => {
|
||||
const app2 = express();
|
||||
mountChat(app2, { path: '/assistant', answer: async () => { throw new Error('boom'); } });
|
||||
const server2 = app2.listen(0);
|
||||
const base2 = `http://localhost:${server2.address().port}`;
|
||||
const requests2 = [];
|
||||
// route the stub's answers through a fresh recorder by watching requests on the shared stub
|
||||
const before = requests.length;
|
||||
const res = await fetch(`${base2}/assistant/conversations/${KNOWN_ID}/turns`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'mu-session-id': 'http://mu.semte.ch/sessions/x' },
|
||||
body: JSON.stringify({ content: 'Breekt dit?' }),
|
||||
});
|
||||
assert.equal(res.status, 202);
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
const after = requests.slice(before).filter((r) => /DELETE|INSERT/.test(r.body)).map((r) => r.body);
|
||||
assert.ok(after.some((u) => u.includes('Er ging iets mis. Probeer het opnieuw.')),
|
||||
'the failure message is written');
|
||||
server2.close();
|
||||
});
|
||||
|
||||
server.close();
|
||||
stub.close();
|
||||
process.exit(failures ? 1 : 0);
|
||||
40
test/import-boundaries.mjs
Normal file
40
test/import-boundaries.mjs
Normal file
@@ -0,0 +1,40 @@
|
||||
// The drop-in guarantee (plan step 1.1): src/chat/ imports nothing from the
|
||||
// rest of this service, and nothing there imports it back.
|
||||
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
||||
import { join, dirname, relative } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const CHAT = join(root, 'src', 'chat');
|
||||
const BACK = join(root, 'src', 'runner'), LLM = join(root, 'src', 'llm'), DB = join(root, 'src', 'db.js');
|
||||
|
||||
function walk(dir) {
|
||||
return readdirSync(dir).flatMap(f => {
|
||||
const p = join(dir, f);
|
||||
return statSync(p).isDirectory() ? walk(p) : [p];
|
||||
});
|
||||
}
|
||||
|
||||
let bad = 0;
|
||||
for (const f of walk(CHAT)) {
|
||||
const text = readFileSync(f, 'utf8');
|
||||
for (const m of text.matchAll(/from\s+['"](\.[^'"]+)['"]/g)) {
|
||||
const target = new URL(m[1], 'file://' + f).pathname;
|
||||
if (target === DB || target.startsWith(BACK) || target.startsWith(LLM)) {
|
||||
console.error(`FAIL - ${relative(root, f)} imports ${relative(root, target)}`);
|
||||
bad++;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const dir of [BACK, LLM]) {
|
||||
for (const f of walk(dir)) {
|
||||
const text = readFileSync(f, 'utf8');
|
||||
if (/from\s+['"]\.\.\/chat/.test(text)) {
|
||||
console.error(`FAIL - ${relative(root, f)} imports the chat back`);
|
||||
bad++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(bad ? `${bad} boundary violations` : 'ok - chat keeps to itself');
|
||||
process.exit(bad ? 1 : 0);
|
||||
Reference in New Issue
Block a user