Merge branch 'feature/int-19-mobile-erfassung-mobilfreundliche-weboberflaeche' of https://gitea.perlbach24.de/scripte/nexarch
This commit is contained in:
commit
1dbcfcb88f
9 files changed
+690
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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" });
|
||||
}
|
||||
Reference in new issue
Block a user