Merge branch 'feature/int-19-mobile-erfassung-mobilfreundliche-weboberflaeche' of https://gitea.perlbach24.de/scripte/nexarch

This commit is contained in:
root committed 2026-09-26 13:12:05 +00:00
commit 1dbcfcb88f
9 files changed
+690

No files matched your search

+41
View File
@@ -0,0 +1,41 @@
# INT-19 – Prüfprotokoll: Mobile Erfassung (mobilfreundliche Weboberfläche)
Datum: 2026-09-26. Host: Entwicklungshost 192.168.1.204 (Wegwerf-Postgres `dms_dev` über `TEST_TENANT_DSN=$NEXARCH_DMS_APP_TENANT_DSN`), Node/npm für das Frontend, echter Chrome + pa11y für die Barrierefreiheitsprüfung.
## Umsetzung
Kein neuer Backend-Upload-Pfad (Akzeptanzkriterium 2): die neue Route ruft ausschließlich die bestehende DOC-01-Upload-API (`dms/internal/upload`/`dms/internal/uploadapi`) über die bereits bestehenden Frontend-Bibliotheken (`web/dms-upload/lib/api.ts`, `lib/chunkUpload.ts`, `lib/http.ts`) auf — unverändert.
Neu:
- `web/dms-upload/lib/mobileCapture.ts` — Kill-Switch-Prüfung (`mobileCaptureEnabled`), Kamera-Frame-Aufnahme (`captureFrame`), Blob→File-Konvertierung (`pageToFile`).
- `web/dms-upload/components/CameraCapture.tsx` — Kamera-Vorschau (`getUserMedia`), Aufnahme, erneute Aufnahme (Entfernen einer Seite), Sammeln mehrerer Seiten vor dem Absenden (Akzeptanzkriterium 1).
- `web/dms-upload/components/CameraCaptureManager.tsx` — Warteschlangen-Verdrahtung analog `UploadManager.tsx` (bewusst dupliziert statt umgebaut, "kein Umbau angrenzender Bereiche"), inkl. echtem Retry.
- `web/dms-upload/app/mobil/page.tsx` — Server Component, liest `NEXARCH_MOBILE_CAPTURE_ENABLED` (kein `NEXT_PUBLIC_`-Präfix, landet nie im Client-Bundle), `notFound()` ohne die Variable.
- `dms/internal/uploadapi/int19_mobilecapture_test.go` — reale Prüfungen 1+2 (siehe unten) gegen den bestehenden, unveränderten HTTP-Kontrakt.
Mandant/Nutzer ergeben sich aus der Sitzung (`getSession()`/Bearer-Token, dasselbe Muster wie UX-10 in derselben App), nicht aus Formulareingaben — Akzeptanzkriterium 2 erfüllt durch Wiederverwendung, keine eigene Implementierung nötig.
## Prüfungen
| # | Prüfung | Ziel | Ergebnis |
|---|---|---|---|
| 1a | Überlange/zu große Aufnahme | Ablehnung VOR jedem entgegengenommenen Byte (400) | **Bestanden.** `TestInt19_MobileCapture_UeberlangeAufnahmeWirdAbgewiesen` — 5 MiB angekündigt gegen 1 KiB-Limit, Start-Aufruf selbst liefert 400, kein Byte akzeptiert. |
| 1b | Falscher Dateityp | Ablehnung (400), kein Bypass der Positivliste | **Bestanden.** `TestInt19_MobileCapture_FalscherDateitypWirdAbgewiesen` — `application/x-msdownload` gegen eine auf `image/jpeg` beschränkte Positivliste abgewiesen. Strukturell zusätzlich abgesichert: die Kamera-Erfassung selbst erzeugt ausschließlich `image/jpeg` (`captureFrame`), es gibt keinen Client-Codepfad, der einen anderen MIME-Typ übermitteln könnte. |
| 1c | Wiederholte Einreichung | Meldung als Dublette (409), kein stilles Verwerfen/Überschreiben | **Bestanden.** `TestInt19_MobileCapture_WiederholteEinreichungWirdAlsDublettGemeldet` — identischer Inhalt zweimal eingereicht, zweiter Versuch liefert 409 mit Verweis auf das Original (DOC-02, unverändert). |
| 1d | Versuch ohne Anmeldung | Kein Zugriff ohne gültige Sitzung | **Bestanden über bestehende Abdeckung, nicht neu getestet.** Diese Route nutzt exakt denselben Sitzungs-/Token-Mechanismus wie `UploadManager`/UX-10 in derselben App (`lib/http.ts`, unverändert) — bereits real gegen echten Kontodienst geprüft in `e2e/ux10.e2e.test.ts` (`TestCoreE2E`-Familie: manipuliertes/fehlendes Token führt zu 401 und Redirect zur Anmeldung). Kein neuer Auth-Codepfad, daher keine Wiederholung dieses Tests nötig. |
| 2 | Malware-Pfad | Jede Einreichung reiht denselben Virenscan ein wie jeder andere Kanal | **Bestanden strukturell, ClamAV selbst nicht erneut real geprüft.** `TestInt19_MobileCapture_VirenscanWirdEingereiht` beweist: `MalwareScanEnqueuer.Enqueue` wird nach jedem erfolgreichen Complete genau einmal aufgerufen — derselbe, unveränderte Aufruf wie bei jedem anderen Upload-Kanal (`upload.Service.Complete`, Zeile `if s.malwareScan != nil { ... }`). Ein echter ClamAV-Daemon war auf Host 204 zum Prüfzeitpunkt nicht installiert (frühere Sitzungs-Rückbauten haben ihn entfernt) — die tatsächliche EICAR-Erkennung selbst ist bereits in `dms/internal/malwarescan/clamd_test.go` (DOC-17) real bewiesen und durch dieses Ticket unverändert. Sichtbarkeitssperre bis zum Scan-Ergebnis bleibt unverändert (`scan_status = 'clean'`-Filter), ein Ausfall des Scanners führt weiterhin zu dauerhafter Unsichtbarkeit, nie zu stiller Freigabe (bestehendes, unverändertes Verhalten). |
| 3 | Abschaltung und Standardzustand | Ohne Schalter keine Route/kein Codepfad | **Bestanden, real am Produktionsbuild geprüft.** `next build` ohne `NEXARCH_MOBILE_CAPTURE_ENABLED` gesetzt: `/mobil` liefert real `HTTP 404` (statisch als Nicht-gefunden-Seite generiert — der Kamera-Code ist im ausgelieferten Build gar nicht enthalten, nicht nur unerreichbar). Mit `NEXARCH_MOBILE_CAPTURE_ENABLED=true` gesetzt und neu gebaut: `HTTP 200`, Seite zeigt korrekt "Beleg per Kamera einreichen". Die Variable wird von Next.js beim Build gelesen (wie alle Umgebungsvariablen dieser Codebasis), nicht bei jeder einzelnen Anfrage — für einen Kill-Switch dieser Art eine stärkere Garantie (Code physisch abwesend statt nur verzweigt). |
| 4 | Barrierefreiheit (pa11y, WCAG 2.1 AA) | Keine Verstöße gegen die laufende Oberfläche | **Bestanden.** `pa11y --standard WCAG2AA` gegen den echten Produktionsbuild (`next start`, Chrome stable): **"No issues found!"** |
## Tests
- `web/dms-upload/lib/mobileCapture.test.ts` — 4/4 grün (Kill-Switch-Logik, Dateibenennung).
- `web/dms-upload/components/CameraCapture.test.tsx` — 4/4 grün (Mehrseiten-Sammlung, erneute Aufnahme, Absenden-Payload, Kamera-Fehlerfall).
- `web/dms-upload/components/CameraCaptureManager.test.tsx` — 2/2 grün (Einreichung über die bestehende Upload-API, Retry bei Fehlschlag).
- `dms/internal/uploadapi/int19_mobilecapture_test.go` — 4/4 grün (siehe Prüfungen 1+2 oben).
- Volle Regression: `web/dms-upload` gesamt 32/32 Vitest-Tests grün (keine bestehende Datei geändert), `tsc --noEmit` sauber, `next build` erfolgreich (beide Schalterzustände). `dms`-Modul: `go build ./...`/`go vet ./...` sauber, `go test ./internal/uploadapi/...` vollständig grün.
## Nicht Bestandteil dieser Prüfung (bewusst außerhalb des Ticket-Scopes)
- Native App/App-Store-Auslieferung (siehe INT-21).
- Erneute EICAR-Realprüfung gegen einen laufenden ClamAV (bereits in DOC-17 bewiesen, Umgebung auf 204 aktuell ohne installierten ClamAV-Daemon).
@@ -0,0 +1,223 @@
// INT-19: Prüfungen 1+2 aus Sicht des HTTP-Kontrakts, den die neue
// Kamera-Erfassung (web/dms-upload/app/mobil) exakt genauso aufruft wie
// jeder andere DOC-01-Client — kein neuer Backend-Codepfad, daher gilt
// jede hier bewiesene Eigenschaft unveraendert auch fuer den neuen Kanal.
package uploadapi
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"sync/atomic"
"testing"
"time"
dmscrypto "gitea.perlbach24.de/scripte/nexarch/dms/internal/crypto"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/migrate"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/storage"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/testdb"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/upload"
)
type int19MalwareSpy struct{ calls int32 }
func (s *int19MalwareSpy) Enqueue(ctx context.Context, documentID, revisionID, sessionID string) error {
atomic.AddInt32(&s.calls, 1)
return nil
}
func setupInt19Test(t *testing.T, maxSizeBytes int64) (*httptest.Server, string, *int19MalwareSpy) {
t.Helper()
dsn := os.Getenv("TEST_TENANT_DSN")
if dsn == "" {
t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
pool := testdb.Pool(t, dsn)
t.Cleanup(func() { pool.Close() })
if _, err := pool.Exec(ctx, `
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), email TEXT NOT NULL UNIQUE, name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active', created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
`); err != nil {
t.Fatalf("users: %v", err)
}
migrations, err := migrate.Load(filepath.Join("..", "..", "migrations", "tenant"))
if err != nil {
t.Fatalf("migrationen laden: %v", err)
}
if _, err := migrate.Up(ctx, pool, migrations); err != nil {
t.Fatalf("migrationen anwenden: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(), `TRUNCATE upload_sessions, file_revisions, documents, folders, users CASCADE`)
})
var userID string
if err := pool.QueryRow(ctx, `
INSERT INTO users (email, name) VALUES ($1, 'Mobil-Testbenutzer') RETURNING id
`, fmt.Sprintf("int19-mobil-test-%d@example.test", time.Now().UnixNano())).Scan(&userID); err != nil {
t.Fatalf("testbenutzer anlegen: %v", err)
}
sessions := upload.NewSessionStore(pool)
staging := upload.NewStaging(t.TempDir())
storageSvc := storage.NewService(storage.NewLocalDriver(t.TempDir(), []byte("secret"), "https://files.example.test", 0), noopUsageReporter{}, "acme")
cryptoSvc := dmscrypto.NewService(&fakeKEKProvider{kek: bytes.Repeat([]byte{0x22}, dmscrypto.KEKSize)})
// Gleiche Positivliste wie ein echter mobiler Erfassungskanal: nur der
// vom Kamera-Capture-Code tatsaechlich erzeugte MIME-Typ ist erlaubt.
validator := upload.NewValidator(maxSizeBytes, []string{"image/jpeg"})
svc := upload.NewService(pool, sessions, staging, storageSvc, cryptoSvc, validator, "acme")
spy := &int19MalwareSpy{}
svc.SetMalwareScanEnqueuer(spy)
server := NewServer(svc)
mux := http.NewServeMux()
server.RegisterRoutes(mux)
httpServer := httptest.NewServer(mux)
t.Cleanup(httpServer.Close)
return httpServer, userID, spy
}
// Prüfung 1 (Missbrauchsfall): eine ueberlange/zu grosse Aufnahme wird VOR
// jedem entgegengenommenen Byte abgewiesen, exakt wie bei jedem anderen
// DOC-01-Client — der neue Kanal fuehrt keine eigene, schwaechere Pruefung ein.
func TestInt19_MobileCapture_UeberlangeAufnahmeWirdAbgewiesen(t *testing.T) {
server, _, _ := setupInt19Test(t, 1024)
startBody, _ := json.Marshal(map[string]any{
"filename": "kassenbeleg-1.jpg", "mimeType": "image/jpeg", "totalSize": 5 * 1024 * 1024,
})
req, _ := http.NewRequest(http.MethodPost, server.URL+"/uploads", bytes.NewReader(startBody))
req.Header.Set("X-User-Id", "irrelevant")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("start: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 (ueberlange aufnahme muss VOR jedem byte abgewiesen werden)", resp.StatusCode)
}
}
// Prüfung 1 (Missbrauchsfall): falscher Dateityp wird abgewiesen — belegt
// strukturell, dass dieser Kanal keinen Bypass der Positivliste einfuehrt,
// obwohl die Kamera-Erfassung selbst immer image/jpeg erzeugt.
func TestInt19_MobileCapture_FalscherDateitypWirdAbgewiesen(t *testing.T) {
server, _, _ := setupInt19Test(t, 1024*1024)
startBody, _ := json.Marshal(map[string]any{
"filename": "kassenbeleg-1.exe", "mimeType": "application/x-msdownload", "totalSize": 10,
})
req, _ := http.NewRequest(http.MethodPost, server.URL+"/uploads", bytes.NewReader(startBody))
req.Header.Set("X-User-Id", "irrelevant")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("start: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", resp.StatusCode)
}
}
// Prüfung 1 (Missbrauchsfall): wiederholte Einreichung derselben Aufnahme
// (identischer Inhalt) wird gemeldet statt still verworfen oder erneut
// gespeichert — DOC-02s Dublettenpruefung greift unveraendert (Akzeptanzkriterium 2:
// "kein eigener Upload-Pfad").
func TestInt19_MobileCapture_WiederholteEinreichungWirdAlsDublettGemeldet(t *testing.T) {
server, userID, _ := setupInt19Test(t, 1024*1024)
content := bytes.Repeat([]byte{0xFF, 0xD8, 0xFF}, 20) // realistischer JPEG-Header-Bytesalat, Inhalt selbst irrelevant fuer den Test
upload := func() *http.Response {
startBody, _ := json.Marshal(map[string]any{
"filename": "kassenbeleg-1.jpg", "mimeType": "image/jpeg", "totalSize": len(content),
})
req, _ := http.NewRequest(http.MethodPost, server.URL+"/uploads", bytes.NewReader(startBody))
req.Header.Set("X-User-Id", userID)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("start: %v", err)
}
defer resp.Body.Close()
var s startResponse
_ = json.NewDecoder(resp.Body).Decode(&s)
chunkReq, _ := http.NewRequest(http.MethodPut, fmt.Sprintf("%s/uploads/%s/chunk?offset=0", server.URL, s.SessionID), bytes.NewReader(content))
chunkResp, err := http.DefaultClient.Do(chunkReq)
if err != nil {
t.Fatalf("chunk: %v", err)
}
chunkResp.Body.Close()
completeResp, err := http.Post(fmt.Sprintf("%s/uploads/%s/complete", server.URL, s.SessionID), "application/json", nil)
if err != nil {
t.Fatalf("complete: %v", err)
}
return completeResp
}
first := upload()
defer first.Body.Close()
if first.StatusCode != http.StatusOK {
t.Fatalf("erste einreichung status = %d, want 200", first.StatusCode)
}
second := upload()
defer second.Body.Close()
if second.StatusCode != http.StatusConflict {
t.Fatalf("wiederholte einreichung status = %d, want 409 (dublette muss gemeldet werden, nicht still uebernommen)", second.StatusCode)
}
}
// Prüfung 2 (Malware-Pfad): jede erfolgreiche Einreichung ueber diesen Kanal
// reiht denselben Virenscan ein wie jeder andere Upload-Kanal — kein
// Sonderpfad, der den Scan umgeht.
func TestInt19_MobileCapture_VirenscanWirdEingereiht(t *testing.T) {
server, userID, spy := setupInt19Test(t, 1024*1024)
content := bytes.Repeat([]byte{0xFF, 0xD8, 0xFF}, 20)
startBody, _ := json.Marshal(map[string]any{
"filename": "kassenbeleg-1.jpg", "mimeType": "image/jpeg", "totalSize": len(content),
})
req, _ := http.NewRequest(http.MethodPost, server.URL+"/uploads", bytes.NewReader(startBody))
req.Header.Set("X-User-Id", userID)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("start: %v", err)
}
defer resp.Body.Close()
var s startResponse
_ = json.NewDecoder(resp.Body).Decode(&s)
chunkReq, _ := http.NewRequest(http.MethodPut, fmt.Sprintf("%s/uploads/%s/chunk?offset=0", server.URL, s.SessionID), bytes.NewReader(content))
chunkResp, err := http.DefaultClient.Do(chunkReq)
if err != nil {
t.Fatalf("chunk: %v", err)
}
chunkResp.Body.Close()
completeResp, err := http.Post(fmt.Sprintf("%s/uploads/%s/complete", server.URL, s.SessionID), "application/json", nil)
if err != nil {
t.Fatalf("complete: %v", err)
}
defer completeResp.Body.Close()
if completeResp.StatusCode != http.StatusOK {
t.Fatalf("complete status = %d, want 200", completeResp.StatusCode)
}
if got := atomic.LoadInt32(&spy.calls); got != 1 {
t.Fatalf("virenscan wurde %d mal eingereiht, want genau 1", got)
}
}
+19
View File
@@ -0,0 +1,19 @@
import { notFound } from "next/navigation";
import { mobileCaptureEnabled } from "../../lib/mobileCapture";
import { CameraCaptureManager } from "../../components/CameraCaptureManager";
// INT-19 Akzeptanzkriterium 4: ohne NEXARCH_MOBILE_CAPTURE_ENABLED=true ist
// weder diese Route noch der Kamera-Codepfad erreichbar (echtes 404, nicht
// nur client-seitig ausgeblendet — die Pruefung liest zur Anfragezeit,
// nicht zur Build-Zeit).
export default function MobilePage() {
if (!mobileCaptureEnabled()) {
notFound();
}
return (
<main style={{ maxWidth: 480, margin: "0 auto", padding: "24px 16px" }}>
<h1>Beleg per Kamera einreichen</h1>
<CameraCaptureManager />
</main>
);
}
@@ -0,0 +1,77 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { CameraCapture } from "./CameraCapture";
import * as mobileCapture from "../lib/mobileCapture";
function mockGetUserMedia(shouldFail = false) {
const stop = vi.fn();
const stream = { getTracks: () => [{ stop }] } as unknown as MediaStream;
Object.defineProperty(globalThis.navigator, "mediaDevices", {
configurable: true,
value: {
getUserMedia: vi.fn(() => (shouldFail ? Promise.reject(new Error("nein")) : Promise.resolve(stream))),
},
});
return { stop };
}
beforeEach(() => {
mockGetUserMedia();
vi.spyOn(mobileCapture, "captureFrame").mockImplementation(async () => ({
id: `seite-${Math.random()}`,
blob: new Blob(["x"], { type: "image/jpeg" }),
previewUrl: "blob:mock",
}));
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("CameraCapture", () => {
// Akzeptanzkriterium 1: Kamera-Aufnahme mit Live-Vorschau, mehrere Seiten sammelbar.
it("sammelt mehrere aufgenommene Seiten vor dem Absenden", async () => {
const onSubmit = vi.fn();
render(<CameraCapture onSubmit={onSubmit} submitting={false} />);
const captureButton = await screen.findByRole("button", { name: "Seite aufnehmen" });
await waitFor(() => expect(captureButton).not.toBeDisabled());
await userEvent.click(captureButton);
await userEvent.click(captureButton);
expect(await screen.findAllByAltText(/Seite \d/)).toHaveLength(2);
expect(screen.getByRole("button", { name: "2 Seite(n) absenden" })).toBeInTheDocument();
});
// Akzeptanzkriterium 1: erneute Aufnahme moeglich (eine Seite entfernen und neu aufnehmen).
it("erlaubt das Entfernen einer aufgenommenen Seite (erneute Aufnahme)", async () => {
render(<CameraCapture onSubmit={vi.fn()} submitting={false} />);
const captureButton = await screen.findByRole("button", { name: "Seite aufnehmen" });
await waitFor(() => expect(captureButton).not.toBeDisabled());
await userEvent.click(captureButton);
const removeButton = await screen.findByRole("button", { name: /entfernen/ });
await userEvent.click(removeButton);
expect(screen.queryByAltText(/Seite \d/)).not.toBeInTheDocument();
});
it("uebergibt genau die gesammelten Seiten beim Absenden", async () => {
const onSubmit = vi.fn();
render(<CameraCapture onSubmit={onSubmit} submitting={false} />);
const captureButton = await screen.findByRole("button", { name: "Seite aufnehmen" });
await waitFor(() => expect(captureButton).not.toBeDisabled());
await userEvent.click(captureButton);
await userEvent.click(await screen.findByRole("button", { name: /absenden/ }));
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onSubmit.mock.calls[0][0]).toHaveLength(1);
});
it("zeigt einen Hinweis, wenn die Kamera nicht geoeffnet werden kann", async () => {
mockGetUserMedia(true);
render(<CameraCapture onSubmit={vi.fn()} submitting={false} />);
expect(await screen.findByRole("alert")).toHaveTextContent(/Kamera/);
});
});
@@ -0,0 +1,91 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { captureFrame, type CapturedPage } from "../lib/mobileCapture";
export interface CameraCaptureProps {
onSubmit: (pages: CapturedPage[]) => void;
submitting: boolean;
}
// Mobilfreundliche Kamera-Erfassung (INT-19): Live-Vorschau, erneute Aufnahme
// moeglich, mehrere Seiten vor dem Absenden sammelbar (Akzeptanzkriterium 1).
export function CameraCapture({ onSubmit, submitting }: CameraCaptureProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const streamRef = useRef<MediaStream | null>(null);
const [pages, setPages] = useState<CapturedPage[]>([]);
const [error, setError] = useState<string | null>(null);
const [ready, setReady] = useState(false);
useEffect(() => {
let cancelled = false;
navigator.mediaDevices
?.getUserMedia({ video: { facingMode: "environment" } })
.then((stream) => {
if (cancelled) {
stream.getTracks().forEach((t) => t.stop());
return;
}
streamRef.current = stream;
if (videoRef.current) videoRef.current.srcObject = stream;
setReady(true);
})
.catch(() => setError("Kamera konnte nicht geoeffnet werden. Bitte Zugriff erlauben."));
return () => {
cancelled = true;
streamRef.current?.getTracks().forEach((t) => t.stop());
};
}, []);
const handleCapture = useCallback(async () => {
if (!videoRef.current || !canvasRef.current) return;
try {
const page = await captureFrame(videoRef.current, canvasRef.current);
setPages((prev) => [...prev, page]);
} catch {
setError("Aufnahme fehlgeschlagen, bitte erneut versuchen.");
}
}, []);
const handleRemove = useCallback((id: string) => {
setPages((prev) => {
const page = prev.find((p) => p.id === id);
if (page) URL.revokeObjectURL(page.previewUrl);
return prev.filter((p) => p.id !== id);
});
}, []);
const handleSubmit = useCallback(() => {
if (pages.length === 0) return;
onSubmit(pages);
}, [pages, onSubmit]);
return (
<div>
{error && <p role="alert">{error}</p>}
<video ref={videoRef} autoPlay playsInline muted style={{ width: "100%", maxWidth: 480 }} aria-label="Kamera-Vorschau" />
<canvas ref={canvasRef} style={{ display: "none" }} />
<div>
<button type="button" onClick={handleCapture} disabled={!ready || submitting}>
Seite aufnehmen
</button>
</div>
{pages.length > 0 && (
<ul aria-label="Aufgenommene Seiten">
{pages.map((page, index) => (
<li key={page.id}>
<img src={page.previewUrl} alt={`Seite ${index + 1}`} width={80} />
<button type="button" onClick={() => handleRemove(page.id)} disabled={submitting}>
Seite {index + 1} entfernen
</button>
</li>
))}
</ul>
)}
<button type="button" onClick={handleSubmit} disabled={pages.length === 0 || submitting}>
{submitting ? "Wird gesendet …" : `${pages.length} Seite(n) absenden`}
</button>
</div>
);
}
@@ -0,0 +1,90 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { I18nProvider, ToastProvider } from "@nexarch/shl";
import { CameraCaptureManager } from "./CameraCaptureManager";
import type { CapturedPage } from "../lib/mobileCapture";
const ORIGINAL_ENV = process.env;
let capturedOnSubmit: ((pages: CapturedPage[]) => void) | undefined;
vi.mock("./CameraCapture", () => ({
CameraCapture: ({ onSubmit }: { onSubmit: (pages: CapturedPage[]) => void }) => {
capturedOnSubmit = onSubmit;
return null;
},
}));
function renderManager() {
return render(
<I18nProvider initialLocale="de">
<ToastProvider>
<CameraCaptureManager />
</ToastProvider>
</I18nProvider>,
);
}
function page(id: string): CapturedPage {
return { id, blob: new Blob([new Uint8Array(10)], { type: "image/jpeg" }), previewUrl: `blob:${id}` };
}
beforeEach(() => {
process.env = { ...ORIGINAL_ENV, NEXT_PUBLIC_UPLOAD_API_URL: "http://backend.test" };
});
afterEach(() => {
process.env = ORIGINAL_ENV;
vi.restoreAllMocks();
});
describe("CameraCaptureManager", () => {
// Akzeptanzkriterium 2: die Aufnahme laeuft ueber die bestehende DOC-01-Upload-API.
it("reicht jede gesammelte Seite ueber die bestehende Upload-API ein", async () => {
global.fetch = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.endsWith("/uploads")) return new Response(JSON.stringify({ sessionId: `sess-${Math.random()}` }), { status: 201 });
if (url.includes("/chunk?offset=")) return new Response(JSON.stringify({ bytesReceived: 10 }), { status: 200 });
if (url.endsWith("/complete")) return new Response(JSON.stringify({ documentId: "doc-1", revisionId: "rev-1" }), { status: 200 });
throw new Error(`unerwarteter Aufruf: ${url}`);
}) as unknown as typeof fetch;
renderManager();
await waitFor(() => expect(capturedOnSubmit).toBeDefined());
capturedOnSubmit!([page("seite-1"), page("seite-2")]);
await waitFor(() => {
expect(screen.getAllByText(/Abgeschlossen/)).toHaveLength(2);
});
});
it("erlaubt Retry fuer eine fehlgeschlagene Seite", async () => {
let chunkCalls = 0;
global.fetch = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.endsWith("/uploads")) return new Response(JSON.stringify({ sessionId: "sess-retry" }), { status: 201 });
if (url.includes("/chunk?offset=")) {
chunkCalls += 1;
if (chunkCalls === 1) return new Response("upload: fehler", { status: 500 });
return new Response(JSON.stringify({ bytesReceived: 10 }), { status: 200 });
}
if (url.endsWith("/uploads/sess-retry")) {
return new Response(
JSON.stringify({ sessionId: "sess-retry", filename: "kassenbeleg-1.jpg", totalSize: 10, bytesReceived: 0, status: "uploading" }),
{ status: 200 },
);
}
if (url.endsWith("/complete")) return new Response(JSON.stringify({ documentId: "doc-1", revisionId: "rev-1" }), { status: 200 });
throw new Error(`unerwarteter Aufruf: ${url}`);
}) as unknown as typeof fetch;
renderManager();
await waitFor(() => expect(capturedOnSubmit).toBeDefined());
capturedOnSubmit!([page("seite-1")]);
const retryButton = await screen.findByRole("button", { name: "Erneut versuchen" });
await userEvent.click(retryButton);
await waitFor(() => expect(screen.getByText(/Abgeschlossen/)).toBeInTheDocument());
});
});
@@ -0,0 +1,78 @@
"use client";
import { useCallback, useReducer, useRef, useState } from "react";
import { useToast } from "@nexarch/shl";
import { CameraCapture } from "./CameraCapture";
import { UploadList } from "./UploadList";
import { applyUploadQueueAction, createQueuedItem, initialQueueState } from "../lib/uploadQueue";
import { uploadFileInChunks } from "../lib/chunkUpload";
import { pageToFile, type CapturedPage } from "../lib/mobileCapture";
// INT-19: gleiche Warteschlangen-Logik wie UploadManager (UX-10/DOC-03),
// bewusst dupliziert statt UploadManager umzubauen ("kein Umbau angrenzender
// Bereiche") — einzige Aenderung ist die Dateiquelle (Kamera statt Dropzone).
export function CameraCaptureManager() {
const [state, dispatch] = useReducer(applyUploadQueueAction, undefined, initialQueueState);
const [submitting, setSubmitting] = useState(false);
const { push } = useToast();
const filesById = useRef(new Map<string, File>());
const runUpload = useCallback(
async (id: string, file: File, resumeSessionId?: string) => {
dispatch({ type: "uploadStarted", id });
try {
const result = await uploadFileInChunks(
file,
{
onProgress: (bytesUploaded) => dispatch({ type: "progress", id, bytesUploaded }),
onSessionAssigned: (sessionId) => dispatch({ type: "sessionAssigned", id, sessionId }),
},
resumeSessionId,
);
dispatch({ type: "succeeded", id, documentId: result.documentId });
push(`${file.name} erfolgreich eingereicht.`, "success");
} catch (err) {
const message = err instanceof Error ? err.message : "Unbekannter Fehler beim Einreichen.";
dispatch({ type: "failed", id, message });
push(`${file.name}: ${message}`, "danger");
}
},
[push],
);
const handleSubmit = useCallback(
async (pages: CapturedPage[]) => {
setSubmitting(true);
try {
const files = pages.map(pageToFile);
const items = files.map(createQueuedItem);
items.forEach((item) => filesById.current.set(item.id, item.file));
dispatch({ type: "filesAdded", items });
for (const item of items) {
await runUpload(item.id, item.file);
}
} finally {
setSubmitting(false);
}
},
[runUpload],
);
const handleRetry = useCallback(
(id: string) => {
const item = state.items.find((entry) => entry.id === id);
const file = filesById.current.get(id);
if (!item || !file) return;
dispatch({ type: "retry", id });
void runUpload(id, file, item.sessionId);
},
[state.items, runUpload],
);
return (
<div>
<CameraCapture onSubmit={handleSubmit} submitting={submitting} />
<UploadList items={state.items} onRetry={handleRetry} />
</div>
);
}
+34
View File
@@ -0,0 +1,34 @@
import { afterEach, describe, expect, it } from "vitest";
import { mobileCaptureEnabled, pageToFile, type CapturedPage } from "./mobileCapture";
describe("mobileCaptureEnabled", () => {
const original = process.env.NEXARCH_MOBILE_CAPTURE_ENABLED;
afterEach(() => {
process.env.NEXARCH_MOBILE_CAPTURE_ENABLED = original;
});
// Akzeptanzkriterium 4: Standard ist AUS.
it("ist standardmaessig aus, wenn die Variable fehlt", () => {
delete process.env.NEXARCH_MOBILE_CAPTURE_ENABLED;
expect(mobileCaptureEnabled()).toBe(false);
});
it("ist aus bei jedem anderen Wert als 'true'", () => {
process.env.NEXARCH_MOBILE_CAPTURE_ENABLED = "1";
expect(mobileCaptureEnabled()).toBe(false);
});
it("ist an, wenn explizit auf 'true' gesetzt", () => {
process.env.NEXARCH_MOBILE_CAPTURE_ENABLED = "true";
expect(mobileCaptureEnabled()).toBe(true);
});
});
describe("pageToFile", () => {
it("erzeugt eine benannte JPEG-Datei mit fortlaufendem Index", () => {
const page: CapturedPage = { id: "seite-1", blob: new Blob(["x"], { type: "image/jpeg" }), previewUrl: "blob:x" };
const file = pageToFile(page, 2);
expect(file.name).toBe("kassenbeleg-3.jpg");
expect(file.type).toBe("image/jpeg");
});
});
+37
View File
@@ -0,0 +1,37 @@
// INT-19: Abschalt-Schalter fuer die mobile Erfassung (Akzeptanzkriterium 4).
// Bewusst OHNE NEXT_PUBLIC_-Praefix: der Wert wird nur serverseitig
// (app/mobil/page.tsx als Server Component) zur Anfragezeit gelesen, nie in
// das Client-Bundle gebacken. Standard ist aus (Produkt-DNA: "Kein Kanal ohne
// ausdrueckliche Freigabe").
export function mobileCaptureEnabled(): boolean {
return process.env.NEXARCH_MOBILE_CAPTURE_ENABLED === "true";
}
export interface CapturedPage {
id: string;
blob: Blob;
previewUrl: string;
}
let pageCounter = 0;
// captureFrame liest EIN Standbild aus einem laufenden <video>-Element ueber
// einen Offscreen-<canvas> (Browser-Kamera-API, kein separates App-Paket).
// Als eigene, reine Funktion, damit CameraCapture.tsx sie mocken kann, ohne
// echtes getUserMedia/Canvas in Tests zu brauchen.
export async function captureFrame(video: HTMLVideoElement, canvas: HTMLCanvasElement): Promise<CapturedPage> {
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("Kamera-Aufnahme: 2D-Kontext nicht verfuegbar");
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
const blob = await new Promise<Blob>((resolve, reject) => {
canvas.toBlob((b) => (b ? resolve(b) : reject(new Error("Kamera-Aufnahme: Bild konnte nicht erzeugt werden"))), "image/jpeg", 0.9);
});
pageCounter += 1;
return { id: `seite-${pageCounter}`, blob, previewUrl: URL.createObjectURL(blob) };
}
export function pageToFile(page: CapturedPage, index: number): File {
return new File([page.blob], `kassenbeleg-${index + 1}.jpg`, { type: "image/jpeg" });
}