Files
patrickandClaude Sonnet 5.5 f5f0bd8673
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (pull_request) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (push) Failing after 57s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Successful in 5s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (push) Successful in 5s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Failing after 22s
CI / Web (dms-browser) (push) Failing after 1m23s
CI / Web (pdf-viewer) (push) Failing after 55s
CI / Web (shl) (push) Failing after 13s
Connect-Schnittstellen-Vertragstests / contract-tests (push) Successful in 10m42s
Supply-Chain-Scan (Go) / govulncheck (push) Failing after 1m10s
CI / Core-Modul (Build/Vet/Test) (pull_request) Failing after 58s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (pull_request) Successful in 4s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (pull_request) Successful in 4s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (pull_request) Failing after 24s
CI / Web (dms-browser) (pull_request) Failing after 1m25s
CI / Web (pdf-viewer) (pull_request) Failing after 54s
CI / Web (shl) (pull_request) Failing after 11s
Connect-Schnittstellen-Vertragstests / contract-tests (pull_request) Successful in 12m3s
Core-Schnittstellen-Vertragstests / contract-tests (pull_request) Successful in 11m40s
Supply-Chain-Scan (Go) / govulncheck (pull_request) Failing after 1m11s
Supply-Chain-Scan (npm) / npm-audit (pull_request) Failing after 1m49s
Core-Pflichttest-Gate / pflichttest-gate (pull_request) Successful in 3m23s
RBAC-Rollenhierarchie-Pflichtgate / rbac-policy-invariant (pull_request) Successful in 11m49s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Failing after 3s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (pull_request) Failing after 3s
DB-Integrationstests / db-tests (pull_request) Canceled after 0s
connect: testdb aus common statt interner Kopie
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 23:38:01 +01:00

278 lines
8.6 KiB
Go

package dms2importapi
import (
"archive/zip"
"bytes"
"context"
"encoding/json"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"gitea.perlbach24.de/scripte/nexarch/common/testdb"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/dms2import"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/fieldmapping"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/migrate"
)
const fixtureManifest = `[
{"model": "documents.correspondent", "pk": 1, "fields": {"name": "Stadtwerke Musterstadt"}},
{"model": "documents.document", "pk": 42, "fields": {
"title": "Stromrechnung September",
"correspondent": 1,
"created": "2026-09-01T10:00:00Z",
"content": "Betrag: 84,20 EUR"
}}
]`
func fixtureZip(t *testing.T) []byte {
t.Helper()
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
mw, err := zw.Create("manifest.json")
if err != nil {
t.Fatalf("create manifest.json: %v", err)
}
if _, err := mw.Write([]byte(fixtureManifest)); err != nil {
t.Fatalf("write manifest.json: %v", err)
}
ow, err := zw.Create("documents/originals/42_stromrechnung.pdf")
if err != nil {
t.Fatalf("create original: %v", err)
}
if _, err := ow.Write([]byte("%PDF-1.4 fake")); err != nil {
t.Fatalf("write original: %v", err)
}
if err := zw.Close(); err != nil {
t.Fatalf("close zip: %v", err)
}
return buf.Bytes()
}
func multipartUpload(t *testing.T, fields map[string]string, fileField, fileName string, fileData []byte) (*bytes.Buffer, string) {
t.Helper()
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
for k, v := range fields {
if err := mw.WriteField(k, v); err != nil {
t.Fatalf("write field %s: %v", k, err)
}
}
if fileField != "" {
fw, err := mw.CreateFormFile(fileField, fileName)
if err != nil {
t.Fatalf("create form file: %v", err)
}
if _, err := fw.Write(fileData); err != nil {
t.Fatalf("write file: %v", err)
}
}
if err := mw.Close(); err != nil {
t.Fatalf("close multipart writer: %v", err)
}
return &buf, mw.FormDataContentType()
}
func newTestServer(t *testing.T) (*Server, *http.ServeMux) {
t.Helper()
dsn := os.Getenv("TEST_TENANT_DSN")
if dsn == "" {
t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest uebersprungen")
}
pool := testdb.Pool(t, dsn)
root, err := filepath.Abs("../../migrations")
if err != nil {
t.Fatalf("migrationsverzeichnis: %v", err)
}
migrations, err := migrate.Load(root)
if err != nil {
t.Fatalf("migrationen laden: %v", err)
}
if _, err := migrate.Up(context.Background(), pool, migrations); err != nil {
t.Fatalf("migrationen anwenden: %v", err)
}
s := NewServer(fieldmapping.NewStore(pool), dms2import.NewStore(pool))
mux := http.NewServeMux()
s.RegisterRoutes(mux)
return s, mux
}
func authedRequest(method, url string, body *bytes.Buffer, contentType string) *http.Request {
// A typed-nil *bytes.Buffer passed as io.Reader is a non-nil interface
// wrapping a nil pointer; httptest.NewRequest then calls Len() on it
// and panics. Pass a genuinely nil io.Reader for GET-style callers
// instead.
var reader io.Reader
if body != nil {
reader = body
}
req := httptest.NewRequest(method, url, reader)
req.Header.Set("X-User-Id", "u1")
req.Header.Set("X-Tenant-Slug", "acme")
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
return req
}
// AC1/AC2: uploading a real export returns detected fields and a
// suggested mapping.
func TestHandlePreview_ReturnsSuggestedMapping(t *testing.T) {
_, mux := newTestServer(t)
body, ct := multipartUpload(t, nil, "file", "export.zip", fixtureZip(t))
req := authedRequest("POST", "/dms2/import/preview", body, ct)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", w.Code, w.Body.String())
}
var resp previewResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if resp.DocumentCount != 1 {
t.Errorf("document_count = %d, want 1", resp.DocumentCount)
}
found := false
for _, e := range resp.SuggestedMapping {
if e.NexarchField == "title" && e.ExternalField == "title" {
found = true
}
}
if !found {
t.Errorf("expected a title->title suggestion, got %+v", resp.SuggestedMapping)
}
}
func TestHandlePreview_RequiresIdentity(t *testing.T) {
_, mux := newTestServer(t)
body, ct := multipartUpload(t, nil, "file", "export.zip", fixtureZip(t))
req := httptest.NewRequest("POST", "/dms2/import/preview", body)
req.Header.Set("Content-Type", ct)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", w.Code)
}
}
// AC2 (manual correction) + AC3: a corrected mapping is used for the
// import, and the result (success/error counts) is visible afterwards.
func TestHandleExecute_UsesCorrectedMappingAndRecordsResult(t *testing.T) {
_, mux := newTestServer(t)
mapping, err := json.Marshal([]fieldmapping.Entry{
{NexarchField: "title", ExternalField: "title"},
{NexarchField: "sender", ExternalField: "correspondent"},
})
if err != nil {
t.Fatalf("marshal mapping: %v", err)
}
body, ct := multipartUpload(t, map[string]string{"mapping": string(mapping)}, "file", "export.zip", fixtureZip(t))
req := authedRequest("POST", "/dms2/import/execute", body, ct)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", w.Code, w.Body.String())
}
var resp executeResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if resp.SuccessCount != 1 || resp.ErrorCount != 0 {
t.Fatalf("counts = %+v", resp)
}
// AC3: the run stays readable afterwards.
getReq := authedRequest("GET", "/dms2/import/runs/"+resp.RunID, nil, "")
getW := httptest.NewRecorder()
mux.ServeHTTP(getW, getReq)
if getW.Code != http.StatusOK {
t.Fatalf("get run status = %d, body = %s", getW.Code, getW.Body.String())
}
var getResp executeResponse
if err := json.Unmarshal(getW.Body.Bytes(), &getResp); err != nil {
t.Fatalf("decode get-run response: %v", err)
}
if getResp.SuccessCount != 1 {
t.Errorf("stored run success_count = %d, want 1", getResp.SuccessCount)
}
}
// AC3 (error case): mapping the required field to a source field that
// this document has no value for produces a per-document error, not a
// silent success — "tags" is absent for pk 42 in the fixture (no
// documents.tag records), so its value is empty.
func TestHandleExecute_EmptyMappedRequiredFieldProducesError(t *testing.T) {
_, mux := newTestServer(t)
mapping, err := json.Marshal([]fieldmapping.Entry{
{NexarchField: "title", ExternalField: "tags"},
})
if err != nil {
t.Fatalf("marshal mapping: %v", err)
}
body, ct := multipartUpload(t, map[string]string{"mapping": string(mapping)}, "file", "export.zip", fixtureZip(t))
req := authedRequest("POST", "/dms2/import/execute", body, ct)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", w.Code, w.Body.String())
}
var resp executeResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if resp.SuccessCount != 0 || resp.ErrorCount != 1 {
t.Fatalf("counts = %+v, want 0 success / 1 error", resp)
}
if len(resp.Errors) != 1 {
t.Fatalf("expected 1 error message, got %v", resp.Errors)
}
}
func TestHandleExecute_UnknownNexarchFieldRejectedBeforeImport(t *testing.T) {
_, mux := newTestServer(t)
mapping, err := json.Marshal([]fieldmapping.Entry{
{NexarchField: "does_not_exist", ExternalField: "title"},
})
if err != nil {
t.Fatalf("marshal mapping: %v", err)
}
body, ct := multipartUpload(t, map[string]string{"mapping": string(mapping)}, "file", "export.zip", fixtureZip(t))
req := authedRequest("POST", "/dms2/import/execute", body, ct)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 for an unknown NEXARCH field, body = %s", w.Code, w.Body.String())
}
}
func TestHandleExecute_RequiresIdentity(t *testing.T) {
_, mux := newTestServer(t)
body, ct := multipartUpload(t, map[string]string{"mapping": "[]"}, "file", "export.zip", fixtureZip(t))
req := httptest.NewRequest("POST", "/dms2/import/execute", body)
req.Header.Set("Content-Type", ct)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", w.Code)
}
}
func TestHandleGetRun_NotFound(t *testing.T) {
_, mux := newTestServer(t)
req := authedRequest("GET", "/dms2/import/runs/does-not-exist", nil, "")
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", w.Code)
}
}