Add support for message history pagination, preserve scroll in rendering, and improve draft/message workflows.
CI / check (22) (push) Successful in 9m34s
CI / check (24) (push) Successful in 9m34s

This commit is contained in:
2026-10-06 00:32:18 +02:00
parent 1c0f06716e
commit 387c0ac2e7
40 changed files with 2374 additions and 1314 deletions
+96
View File
@@ -116,6 +116,102 @@ async function request(baseUrl, path, {method = "GET", body, jar} = {}) {
return {status: response.status, json};
}
test("public health checks reach the backend without exposing diagnostics or requiring a session", async t => {
const server = await startTestServer();
t.after(() => server.close());
let checks = 0;
server.app.locals.modelService.checkHealth = async () => { checks++; return {ok: checks === 1, backendUrl: "private-backend", error: "private detail"}; };
assert.deepEqual(await request(server.baseUrl, "/health"), {status: 200, json: {ok: true}});
assert.deepEqual(await request(server.baseUrl, "/health"), {status: 503, json: {ok: false}});
assert.equal(checks, 2);
assert.equal((await request(server.baseUrl, "/admin/diagnostics")).status, 403);
});
test("new passwords are limited to 72 UTF-8 bytes while legacy credentials remain usable", async t => {
const server = await startTestServer({config: {authRateLimitMax: 100}});
t.after(() => server.close());
for (const password of ["a".repeat(73), "é".repeat(37), "😀".repeat(19)]) {
const result = await request(server.baseUrl, "/register", {method: "POST", body: {username: "too-long", password}});
assert.equal(result.status, 400);
assert.equal(result.json.code, "PASSWORD_TOO_LONG");
assert.equal(server.app.locals.repositories.getUser("too-long"), undefined);
}
const jar = new CookieJar();
const boundary = "é".repeat(36);
assert.equal((await request(server.baseUrl, "/register", {method: "POST", jar, body: {username: "boundary", password: boundary}})).status, 200);
const change = newPassword => request(server.baseUrl, "/settings/password", {method: "PUT", jar, body: {currentPassword: boundary, newPassword}});
assert.equal((await change("a".repeat(73))).json.code, "PASSWORD_TOO_LONG");
assert.equal((await change("😀".repeat(19))).json.code, "PASSWORD_TOO_LONG");
assert.equal((await change("a".repeat(72))).status, 200);
const legacy = "a".repeat(72) + "existing suffix";
server.app.locals.repositories.insertUser("legacy", await bcrypt.hash(legacy, 4));
assert.equal((await request(server.baseUrl, "/login", {method: "POST", jar, body: {username: "legacy", password: legacy}})).status, 200);
assert.equal((await request(server.baseUrl, "/settings/password", {method: "PUT", jar, body: {currentPassword: legacy, newPassword: "new-password"}})).status, 200);
});
test("history pages are bounded, preserve branch identity and batch message metadata", async t => {
const server = await startTestServer(); t.after(() => server.close());
const jar = new CookieJar();
await request(server.baseUrl, "/register", {method: "POST", jar, body: {username: "paged", password: "password123"}});
const repo = server.app.locals.repositories;
const chat = repo.createChat("paged", "Long conversation");
let parent = null;
const ids = [];
for (let index = 0; index < 125; index++) {
parent = Number(repo.insertBranchChatMessage(chat.id, index % 2 ? "bot" : "user", `Message ${index}`, null, "test", "complete", null, parent).lastInsertRowid);
ids.push(parent);
}
repo.saveMessageVersion(chat.id, ids[123], {content: "Alternate answer", modelName: "other-model"});
const sibling = Number(repo.insertBranchChatMessage(chat.id, "bot", "Other branch", null, "test", "complete", null, ids[122]).lastInsertRowid);
repo.updateChatActiveLeaf(chat.id, "paged", parent);
t.mock.method(repo, "listMessageVersions", () => assert.fail("History should batch versions"));
t.mock.method(repo, "listSiblingChatMessages", () => assert.fail("History should batch siblings"));
t.mock.method(server.app.locals.attachmentService, "list", () => assert.fail("History should batch attachments"));
const first = await request(server.baseUrl, `/chats/${chat.id}/messages`, {jar});
assert.equal(first.status, 200); assert.equal(first.json.messages.length, 50);
assert.equal(first.json.messages[0].id, ids[75]);
const answer = first.json.messages.find(message => message.id === ids[123]);
assert.equal(answer.versionCount, 2); assert.equal(answer.nextSiblingId, sibling);
const page = first.json.pagination;
const second = await request(server.baseUrl, `/chats/${chat.id}/messages?before=${page.before}&leaf=${page.leafId}`, {jar});
assert.deepEqual(second.json.messages.map(message => message.id), ids.slice(25, 75));
const third = await request(server.baseUrl, `/chats/${chat.id}/messages?before=${second.json.pagination.before}&leaf=${page.leafId}`, {jar});
assert.deepEqual(third.json.messages.map(message => message.id), ids.slice(0, 25));
assert.equal(third.json.pagination.hasMore, false);
const oldMatch = await request(server.baseUrl, `/chats/${chat.id}/messages?around=${ids[4]}`, {jar});
assert.equal(oldMatch.json.matchOnBranch, true); assert.equal(oldMatch.json.pagination.hasNewer, true);
assert.equal(oldMatch.json.messages.at(-1).id, ids[4]);
assert.equal((await request(server.baseUrl, `/chats/${chat.id}/messages?before=${sibling}`, {jar})).status, 400);
repo.updateChatActiveLeaf(chat.id, "paged", sibling);
assert.equal((await request(server.baseUrl, `/chats/${chat.id}/messages?before=${page.before}&leaf=${page.leafId}`, {jar})).status, 409);
assert.equal((await request(server.baseUrl, `/chats/${chat.id}/messages?limit=101`, {jar})).status, 400);
assert.equal((await request(server.baseUrl, `/chats/${chat.id}/branches`, {jar})).json.nodes.length, 126);
const stranger = new CookieJar();
await request(server.baseUrl, "/register", {method: "POST", jar: stranger, body: {username: "stranger", password: "password123"}});
for (const suffix of ["messages", "branches", `messages/${ids[123]}/versions`, "memories"]) {
assert.equal((await request(server.baseUrl, `/chats/${chat.id}/${suffix}`, {jar: stranger})).status, 404);
}
});
test("conversation memory exposes context estimates and supports validated summary corrections", async t => {
const server = await startTestServer(); t.after(() => server.close());
const jar = new CookieJar();
await request(server.baseUrl, "/register", {method: "POST", jar, body: {username: "memory-editor", password: "password123"}});
const repo = server.app.locals.repositories;
const chat = repo.createChat("memory-editor", "Memory");
repo.createChatMemory("memory-editor", chat, "Prefers concise answers.");
repo.updateChatContextSummary(chat.id, "memory-editor", "Old summary", 0);
const loaded = (await request(server.baseUrl, `/chats/${chat.id}/memories`, {jar})).json;
assert.equal(loaded.summary, "Old summary"); assert.ok(loaded.context.estimatedTokens > 0);
const update = body => request(server.baseUrl, `/chats/${chat.id}/summary`, {method: "PUT", jar, body});
assert.equal((await update({summary: "Corrected summary", previousSummary: "Old summary"})).status, 200);
assert.equal(repo.getChat(chat.id, "memory-editor").context_summary, "Corrected summary");
assert.equal((await update({summary: "Stale edit", previousSummary: "Old summary"})).status, 409);
assert.equal((await update({summary: "x".repeat(loaded.summaryMaxChars + 1)})).status, 400);
assert.equal((await update({summary: "", previousSummary: "Corrected summary"})).status, 200);
assert.equal(repo.getChat(chat.id, "memory-editor").context_summary, null);
});
test("auth flow validates inputs and persists session", async () => {
const server = await startTestServer();
const jar = new CookieJar();