import { expect, test } from "@playwright/test"; import "dotenv/config"; import { createHash } from "node:crypto"; import { stat } from "node:fs/promises"; import { prisma } from "@/lib/db"; import { anhangPfad } from "@/server/services/anhaenge"; import { SEED_TAG, SEED_VORLAGE, anmelden, kundeWaehlen, variablenFuellen, vorlageWaehlen } from "./helfer"; /** * Fotos und Dateien an einem Schritt, durch die Oberflaeche (#032). * * Dieser Ablauf gibt es, weil der Dienst-Test ihn nicht ersetzen konnte. Der * Schritt hat **zwei** Felder `datei` in einem Formular — „Foto“ und „Datei“ —, * und `FormData.get` lieferte das erste, also das leere: wer „Datei“ waehlte, * bekam „Keine Datei gewählt“, waehrend „Foto“ ging. Neunzehn gruene * Dienst-Tests sahen davon nichts, weil keiner zwei Felder schickt. Gefunden hat * es die Klickprobe am gebauten Stand; festgehalten wird es hier. * * Geprueft wird deshalb bewusst **ueber den zweiten Knopf** und danach, ob es * wirklich steht: Neuladen, Zeile in der Datenbank, Datei auf der Platte, und * die Pruefsumme der ausgelieferten Bytes gegen die gespeicherte. */ const STEMPEL = Date.now(); /* Ein echtes 2x2-PNG. Eine leere Datei wuerde der Dienst zu Recht abweisen. */ const PNG = Buffer.from( "iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAACZgbYnAAAAEUlEQVR4nGP8z8DAwMDEwAAAEAAHAAo7/UkAAAAASUVORK5CYII=", "base64", ); /* Der zweite Ablauf arbeitet am Lauf des ersten weiter – also der Reihe nach. */ test.describe.serial("Anhänge an einem Schritt", () => { let laufId = ""; test("über „Datei“ hochladen, neu laden, entfernen", async ({ page }) => { test.slow(); await anmelden(page); await page.goto("/laeufe/starten"); await vorlageWaehlen(page, SEED_VORLAGE); const geraet = await prisma.geraet.findUniqueOrThrow({ where: { tagCode: SEED_TAG }, select: { id: true, kundeId: true }, }); await kundeWaehlen(page, geraet.kundeId); await page.selectOption('select[name="geraetId"]', geraet.id); await page.locator("details summary", { hasText: "Titel, Ticket" }).click(); await page.getByLabel("Titel", { exact: true }).fill(`ZZ E2E Anhang ${STEMPEL}`); await variablenFuellen(page); await page.getByRole("button", { name: "Lauf anlegen" }).click(); await page.waitForURL(/\/laeufe\/(?!starten)[^/]+$/, { timeout: 30_000 }); laufId = new URL(page.url()).pathname.split("/").pop() ?? ""; /* * Beginnen, wie der Techniker es taete. Anhaengen ginge auch am offenen * Lauf — `lesend` haengt am Archiv und am Recht, nicht am Status —, aber * genau so wird die Oberflaeche benutzt. */ await page.getByRole("button", { name: "Lauf beginnen" }).click(); await expect(page.getByRole("button", { name: "Lauf abschließen" })).toBeVisible(); for (const kopf of await page.locator("button[aria-expanded]").all()) { if ((await kopf.getAttribute("aria-expanded")) === "false") await kopf.click(); } /* * Feld 0 ist „Foto“, Feld 1 ist „Datei“ – beide heissen `datei` und stehen * im selben Formular. Genau der zweite Weg war kaputt, also geht der Ablauf * ueber ihn. */ const felder = page.locator('input[name="datei"]'); expect(await felder.count()).toBeGreaterThan(1); await felder.nth(1).setInputFiles({ name: "serverraum.png", mimeType: "image/png", buffer: PNG }); const miniatur = page.locator('img[src^="/api/anhaenge/"]').first(); await expect(miniatur).toBeVisible({ timeout: 30_000 }); await expect(page.getByText("serverraum.png").first()).toBeVisible(); // Neu laden: steht es wirklich, oder nur im Zustand der Seite? await page.reload(); await expect(page.locator('img[src^="/api/anhaenge/"]').first()).toBeVisible({ timeout: 30_000 }); const zeile = await prisma.anhang.findFirstOrThrow({ where: { laufId }, select: { id: true, kundeId: true, laufId: true, dateiname: true, medientyp: true, groesse: true, pruefsumme: true, }, }); expect(zeile).toMatchObject({ dateiname: "serverraum.png", medientyp: "image/png", groesse: PNG.byteLength, pruefsumme: createHash("sha256").update(PNG).digest("hex"), }); // Und auf der Platte, unter dem Pfad aus der Kennung. const pfad = anhangPfad(zeile); expect((await stat(pfad)).size).toBe(PNG.byteLength); // Die Auslieferung prueft Recht und Mandant selbst – und liefert die Bytes, // die hereingingen. const antwort = await page.request.get(`/api/anhaenge/${zeile.id}`); expect(antwort.status()).toBe(200); expect(antwort.headers()["content-type"]).toBe("image/png"); expect(antwort.headers()["x-content-type-options"]).toBe("nosniff"); expect( createHash("sha256") .update(await antwort.body()) .digest("hex"), ).toBe(zeile.pruefsumme); // Entfernen nimmt Zeile **und** Datei. await page.getByRole("button", { name: "serverraum.png entfernen" }).click(); await expect(page.locator('img[src^="/api/anhaenge/"]')).toHaveCount(0, { timeout: 30_000 }); expect(await prisma.anhang.count({ where: { laufId } })).toBe(0); await expect(stat(pfad)).rejects.toThrow(); }); test("weist ein SVG ab, auch wenn es am Dateiwähler vorbei kommt", async ({ page }) => { test.slow(); await anmelden(page); expect(laufId).not.toBe(""); await page.goto(`/laeufe/${laufId}`); for (const kopf of await page.locator("button[aria-expanded]").all()) { if ((await kopf.getAttribute("aria-expanded")) === "false") await kopf.click(); } await page .locator('input[name="datei"]') .nth(1) .setInputFiles({ name: "boeses.svg", mimeType: "image/svg+xml", buffer: Buffer.from(''), }); /* * `accept` haelt im Dateiwaehler nur die Finger ab. Dass ein SVG nicht * hereinkommt, muss der Server sagen – ein SVG ist ein Dokument mit * Skripten, das ein Betrachter ausfuehrt. */ await expect(page.getByText(/lassen sich nicht anhängen/)).toBeVisible({ timeout: 30_000 }); expect(await prisma.anhang.count({ where: { laufId } })).toBe(0); }); });