/** * @file Prüft Anhangsvorschauen, Upload-Zustände, Wiederholungen und das Verwerfen von Entwürfen. */ import test from "node:test"; import assert from "node:assert/strict"; import { appendAttachments, createAttachmentComposer } from "../public/js/attachments.js"; import {createDraftStore} from "../public/js/draftStore.js"; class Element { constructor() { this.children = []; this.listeners = {}; this.classList = { add() { }, remove() { } }; } append(...children) { this.children.push(...children); } replaceChildren(...children) { this.children = children; } setAttribute() { } addEventListener(name, listener) { this.listeners[name] = listener; } click() { this.clicked = true; } } test("uploaded draft references recover after reload and unavailable images cannot be sent", async t => { const elements = Object.fromEntries(["attachImage", "imagePicker", "uploadPreviews", "composer"].map(id => [id, new Element()])); const originalDocument = globalThis.document; t.after(() => { globalThis.document = originalDocument; }); globalThis.document = {getElementById: id => elements[id], querySelector: () => elements.composer, createElement: () => new Element()}; t.mock.method(globalThis, "fetch", async () => Response.json({maxFiles: 5, maxSizeMb: 10})); const values = new Map(); const storage = {getItem: key => values.get(key), setItem: (key, value) => values.set(key, value)}; const saved = createDraftStore("alice", {storage}); const metadata = {id: "12345678-1234-1234-1234-123456789abc", originalName: "draft.png"}; saved.setAttachments(7, [metadata]); const composer = createAttachmentComposer({onChange() {}, onError: message => assert.fail(message)}); composer.setDraftStore(createDraftStore("alice", {storage})); composer.restoreDraft(7); assert.equal(elements.uploadPreviews.children[0].children[0].src, `/attachments/${metadata.id}`); assert.equal(composer.pending, true); elements.uploadPreviews.children[0].children[0].onload(); assert.equal(composer.pending, false); assert.deepEqual(composer.attachments, [metadata]); elements.uploadPreviews.children[0].children[0].onerror(); assert.equal(composer.pending, true); assert.deepEqual(composer.attachments, []); assert.deepEqual(createDraftStore("alice", {storage}).attachments(7), [metadata]); elements.uploadPreviews.children[0].children.at(-1).onclick(); assert.equal(composer.pending, false); assert.deepEqual(createDraftStore("alice", {storage}).attachments(7), []); }); test("composer preview, pending state, upload failure/retry, removal, paste, and history rendering", async t => { const originalDocument = globalThis.document, originalFetch = globalThis.fetch; t.after(() => { globalThis.document = originalDocument; globalThis.fetch = originalFetch; }); const elements = Object.fromEntries(["attachImage", "imagePicker", "uploadPreviews", "composer"].map(id => [id, new Element()])); globalThis.document = { getElementById: id => elements[id], querySelector: () => elements.composer, createElement: () => new Element() }; let completeUpload; let changes = 0; globalThis.fetch = async (url) => { if (url === "/uploads/config") return Response.json({ maxFiles: 5, maxSizeMb: 10 }); if (url === "/uploads") return new Promise(resolve => { completeUpload = resolve; }); return Response.json({ success: true }); }; const composer = createAttachmentComposer({ onChange: () => changes++, onError: message => assert.fail(message) }); elements.attachImage.onclick(); assert.equal(elements.imagePicker.clicked, true); elements.imagePicker.files = [new File(["image fixture"], "screenshot.png", { type: "image/png" })]; elements.imagePicker.onchange(); assert.equal(composer.pending, true); assert.equal(composer.attachments.length, 0); assert.equal(elements.uploadPreviews.children.length, 1); const tick = () => new Promise(resolve => setTimeout(resolve, 0)); completeUpload(Response.json({ error: "Malformed image" }, { status: 400 })); await tick(); assert.match(elements.uploadPreviews.children[0].children[1].textContent, /Malformed image/); elements.uploadPreviews.children[0].children[2].onclick(); const metadata = { id: "12345678-1234-1234-1234-123456789abc", originalName: "screenshot.png" }; completeUpload(Response.json(metadata, { status: 201 })); await tick(); assert.equal(composer.pending, false); assert.deepEqual(composer.attachments, [metadata]); composer.setBusy(true); assert.equal(elements.attachImage.disabled, true); composer.setBusy(false); elements.uploadPreviews.children[0].children.at(-1).onclick(); assert.equal(composer.attachments.length, 0); assert.equal(elements.uploadPreviews.hidden, true); let prevented = false; elements.composer.listeners.paste({ clipboardData: { files: [], getData: () => "text" }, preventDefault: () => { prevented = true; } }); assert.equal(prevented, false); const history = new Element(); appendAttachments(history); assert.equal(history.children.length, 0); appendAttachments(history, [metadata]); assert.equal(history.children[0].children[0].href, `/attachments/${metadata.id}`); assert.ok(changes > 4); }); test("discarding a chat draft aborts upload, deletes temporary files, and serializes Retry", async t => { const originalDocument = globalThis.document, originalFetch = globalThis.fetch; t.after(() => { globalThis.document = originalDocument; globalThis.fetch = originalFetch; }); const elements = Object.fromEntries(["attachImage", "imagePicker", "uploadPreviews", "composer"].map(id => [id, new Element()])); globalThis.document = {getElementById: id => elements[id], querySelector: () => elements.composer, createElement: () => new Element()}; let uploads = 0; let aborted = false; const deletes = []; globalThis.fetch = (url, options = {}) => { if (url === "/uploads/config") return Promise.resolve(Response.json({maxFiles: 5, maxSizeMb: 10})); if (url.startsWith("/attachments/")) { deletes.push(url); return Promise.resolve(Response.json({success: true})); } uploads++; if (uploads === 1) return Promise.resolve(Response.json({error: "Try again"}, {status: 503})); if (uploads === 2) return new Promise((_resolve, reject) => options.signal.addEventListener("abort", () => { aborted = true; reject(new DOMException("Aborted", "AbortError")); }, {once: true})); return Promise.resolve(Response.json({id: `00000000-0000-0000-0000-${String(uploads).padStart(12, "0")}`})); }; const composer = createAttachmentComposer({onChange() {}, onError: message => assert.fail(message)}); const tick = () => new Promise(resolve => setTimeout(resolve, 0)); const addFile = name => { elements.imagePicker.files = [new File(["fixture"], name, {type: "image/png"})]; elements.imagePicker.onchange(); }; addFile("pending.png"); await tick(); const retry = elements.uploadPreviews.children[0].children[2]; retry.onclick(); retry.onclick(); assert.equal(uploads, 2, "Repeated Retry clicks start only one upload"); composer.discard(); await tick(); assert.equal(aborted, true); assert.equal(composer.pending, false); assert.equal(elements.uploadPreviews.hidden, true); addFile("temporary.png"); await tick(); const temporaryId = composer.attachments[0].id; composer.discard(); assert.deepEqual(deletes, [`/attachments/${temporaryId}`]); addFile("accepted.png"); await tick(); composer.clear(); assert.deepEqual(deletes, [`/attachments/${temporaryId}`], "Sent attachments are retained"); }); test("attachments stay with their chat while uploads finish after switching", async t => { const originalDocument = globalThis.document, originalFetch = globalThis.fetch; t.after(() => { globalThis.document = originalDocument; globalThis.fetch = originalFetch; }); const elements = Object.fromEntries(["attachImage", "imagePicker", "uploadPreviews", "composer"].map(id => [id, new Element()])); globalThis.document = {getElementById: id => elements[id], querySelector: () => elements.composer, createElement: () => new Element()}; let completeUpload; const deletes = []; globalThis.fetch = (url) => { if (url === "/uploads/config") return Promise.resolve(Response.json({maxFiles: 5, maxSizeMb: 10})); if (url === "/uploads") return new Promise(resolve => { completeUpload = resolve; }); deletes.push(url); return Promise.resolve(Response.json({success: true})); }; const composer = createAttachmentComposer({onChange() {}, onError: message => assert.fail(message)}); elements.imagePicker.files = [new File(["fixture"], "draft.png", {type: "image/png"})]; elements.imagePicker.onchange(); composer.saveDraft(1); composer.restoreDraft(2); assert.equal(composer.attachments.length, 0); const metadata = {id: "12345678-1234-1234-1234-123456789abc"}; completeUpload(Response.json(metadata)); await new Promise(resolve => setTimeout(resolve, 0)); assert.deepEqual(deletes, []); composer.saveDraft(2); composer.restoreDraft(1); assert.deepEqual(composer.attachments, [metadata]); composer.discard(); });