DAT-03: 03 datev export oberflaeche protokoll
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) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (pull_request) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (pull_request) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Canceled after 0s
CI / Web (dms-browser) (push) Canceled after 0s
CI / Web (pdf-viewer) (push) Canceled after 0s
CI / Web (shl) (push) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (push) Canceled after 0s
Supply-Chain-Scan (npm) / npm-audit (push) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (pull_request) Canceled after 0s
CI / Web (dms-browser) (pull_request) Canceled after 0s
CI / Web (pdf-viewer) (pull_request) Canceled after 0s
CI / Web (shl) (pull_request) Canceled after 0s
Core-Schnittstellen-Vertragstests / contract-tests (pull_request) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (pull_request) Canceled after 0s
Supply-Chain-Scan (npm) / npm-audit (pull_request) Canceled after 0s
Core-Pflichttest-Gate / pflichttest-gate (pull_request) Canceled after 0s
RBAC-Rollenhierarchie-Pflichtgate / rbac-policy-invariant (pull_request) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (pull_request) Canceled after 0s

Backend: additive List()/ListForTenant()-Methoden fuer DAT-01/DAT-02
(kein Aenderungen an bestehendem Code), neues Paket datevapi (POST/GET
/datev/exports), gleiches Auth-Muster wie statusapi (API-02).
Frontend: web/datev-export (Next.js/React/TS + @nexarch/shl) mit
Formular (Zeitraum-Auswahl) und Protokolltabelle, fehlgeschlagene
Uebertragung farblich/textlich klar erkennbar.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Sonnet 5 committed 2026-09-26 11:40:20 +00:00
1 parent 56ee2dac7c
commit a465bb6dab
18 files changed
+4563

No files matched your search

+166
View File
@@ -0,0 +1,166 @@
// Command datev-a11y-fixture wraps the real datevapi with permissive CORS
// headers, exactly like status-a11y-fixture does for API-02, plus fake
// Archive/DATEV-target servers so a real successful AND a real failed
// export run can be seeded before the browser-based accessibility run
// starts. The production connect-app binary is unchanged.
package main
import (
"context"
"log"
"net"
"net/http"
"os"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/archiveclient"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/datevapi"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/datevexport"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/datevtransfer"
)
func cors(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "X-User-Id, X-Tenant-Slug, Content-Type")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
func startServer(handler http.Handler) string {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
log.Fatalf("datev-a11y-fixture: hilfsserver: %v", err)
}
srv := &http.Server{Handler: handler}
go func() { _ = srv.Serve(ln) }()
return "http://" + ln.Addr().String()
}
func startFakeArchive() string {
mux := http.NewServeMux()
mux.HandleFunc("/register", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"id":"reg-1"}`))
})
return startServer(mux)
}
func startFakeDatevTarget(fail bool) string {
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if fail {
http.Error(w, "simulierter uebertragungsfehler", http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"transferId":"tx-1"}`))
})
return startServer(mux)
}
func sampleHeader() datevexport.Header {
return datevexport.Header{
ConsultantNumber: "1001",
ClientNumber: "42",
FiscalYearStart: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
AccountLength: 4,
PeriodStart: time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC),
PeriodEnd: time.Date(2026, 3, 31, 0, 0, 0, 0, time.UTC),
Description: "a11y-fixture",
}
}
func sampleEntry() datevexport.Entry {
return datevexport.Entry{
Amount: 119.0, DebitCredit: "S", Account: "1200", CounterAccount: "4400",
Date: time.Date(2026, 3, 15, 0, 0, 0, 0, time.UTC), Text: "Büromaterial", DocumentRef: "RE-2026-001",
}
}
func seed(exports *datevexport.Store, successTransfers, failedTransfers *datevtransfer.Store) {
ctx := context.Background()
_, run1, err := exports.RunExport(ctx, "a11y", sampleHeader(), []datevexport.Entry{sampleEntry()}, "")
if err != nil {
log.Fatalf("datev-a11y-fixture: seed erfolgreicher lauf: %v", err)
}
if _, err := successTransfers.Start(ctx, "a11y", run1.ID, run1.ID+".csv", []byte("csv")); err != nil {
log.Fatalf("datev-a11y-fixture: seed erfolgreiche uebertragung: %v", err)
}
file2, run2, err := exports.RunExport(ctx, "a11y", sampleHeader(), []datevexport.Entry{sampleEntry()}, "")
if err != nil {
log.Fatalf("datev-a11y-fixture: seed fehlgeschlagener lauf: %v", err)
}
if _, err := failedTransfers.Start(ctx, "a11y", run2.ID, run2.ID+".csv", file2); err != nil {
log.Printf("datev-a11y-fixture: erwarteter fehlschlag der uebertragung: %v", err)
}
}
func main() {
dsn := os.Getenv("NEXARCH_CONNECT_APP_TENANT_DSN")
if dsn == "" {
log.Fatal("datev-a11y-fixture: NEXARCH_CONNECT_APP_TENANT_DSN ist erforderlich")
}
addr := os.Getenv("NEXARCH_CONNECT_APP_LISTEN_ADDR")
if addr == "" {
addr = "127.0.0.1:9501"
}
pool, err := pgxpool.New(context.Background(), dsn)
if err != nil {
log.Fatalf("datev-a11y-fixture: datenbankverbindung: %v", err)
}
defer pool.Close()
archiveURL := startFakeArchive()
successTarget := startFakeDatevTarget(false)
failTarget := startFakeDatevTarget(true)
exports := &datevexport.Store{
Pool: pool,
Archive: archiveclient.New(archiveURL, "svc-tok"),
CallbackURL: "https://connect.example.internal/datev/callback",
}
successTransfers := &datevtransfer.Store{
Pool: pool,
Client: &datevtransfer.Client{
HTTPClient: http.DefaultClient,
Config: datevtransfer.Config{Endpoint: successTarget, Token: "tok", ConsultantNumber: "1001", ClientNumber: "42"},
},
}
failedTransfers := &datevtransfer.Store{
Pool: pool,
Client: &datevtransfer.Client{
HTTPClient: http.DefaultClient,
Config: datevtransfer.Config{Endpoint: failTarget, Token: "tok", ConsultantNumber: "1001", ClientNumber: "42"},
},
}
seed(exports, successTransfers, failedTransfers)
// The a11y run only reads via GET /datev/exports and triggers new
// exports against the success target, so the API server itself uses
// the success-target transfer store.
mux := http.NewServeMux()
datevapi.NewServer(exports, successTransfers).RegisterRoutes(mux)
srv := &http.Server{
Addr: addr,
Handler: cors(mux),
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 15 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
}
log.Printf("datev-a11y-fixture: listening on %s", addr)
log.Fatal(srv.ListenAndServe())
}
+39
View File
@@ -0,0 +1,39 @@
# DAT-03 – Prüfprotokoll (DATEV-Export-Oberfläche & Protokoll)
Datum: 2026-09-26. Host: Entwicklungshost 192.168.1.204 (Wegwerf-Cluster, eigene DB/Rolle je Lauf, vollständig zurückgebaut).
## Ausgangslage
Kein HTTP-API-Layer existierte für `connect/internal/datevexport` (DAT-01) und `connect/internal/datevtransfer` (DAT-02) — beide waren bis dahin reine Bibliothekspakete. Für diese Kachel wurde der kleinstmögliche Layer ergänzt:
- `connect/internal/datevexport`: additive Methode `List(ctx, tenantSlug)` — kein bestehender Code geändert.
- `connect/internal/datevtransfer`: additive Methode `ListForTenant(ctx, tenantSlug)` — kein bestehender Code geändert.
- Neues Paket `connect/internal/datevapi`: `POST /datev/exports` (Export mit Zeitraum-Auswahl anstoßen, danach sofortige Übertragung), `GET /datev/exports` (Protokoll je Mandant, Übertragungsstatus verknüpft). Identität über `X-User-Id`/`X-Tenant-Slug`, exakt das etablierte Muster aus `statusapi` (API-02).
- Neue Next.js/React/TS-App `web/datev-export` (`@nexarch/shl`): Formular (Beraternummer, Mandantennummer, Wirtschaftsjahresbeginn, Sachkontenlänge, Bezeichnung, Zeitraum von/bis) + Protokolltabelle mit farblich/textlich klar unterscheidbarem Übertragungsstatus.
Die Belegauswahl aus DMS-Dokumenten ist nicht Bestandteil dieser Kachel (siehe „Nicht Bestandteil"/QA-03) — das Formular erzeugt beim Anstoß einen synthetischen Testbeleg, um den vollständigen Weg Anstoß→Protokoll nachzuweisen, ohne die eigentliche Belegquelle vorwegzunehmen.
## Akzeptanzkriterien
1. **Export lässt sich über die Oberfläche mit Zeitraum-Auswahl anstoßen** — erfüllt. Formular mit Zeitraum-Feldern, `POST /datev/exports` real getestet (Backend: `TestHandleTrigger_SuccessfulRun`; Frontend: `stoesst einen Export mit den eingegebenen Werten an`).
2. **Exportprotokoll ist je Lauf einsehbar** — erfüllt. `GET /datev/exports` real getestet (`TestHandleList_ShowsRealRuns`, `TestHandleList_TenantIsolation`; Frontend: `zeigt reale Testlaeufe im Protokoll an`).
3. **Fehlgeschlagener Export ist in der Oberfläche klar erkennbar** — erfüllt. Übertragungsstatus `failed`/`retry_pending` erscheint in eigener Warnfarbe mit `role="alert"` bzw. sichtbarer Fehlermeldung (Backend: `TestHandleTrigger_FailedTransferIsVisible`; Frontend: `markiert einen fehlgeschlagenen Testlauf klar erkennbar`).
## Prüfungen vor der Abnahme
| # | Prüfung | Ergebnis |
|---|---|---|
| 1 | Export-Anstoß im Test aus der Oberfläche erfolgreich | **Bestanden.** Realer Testlauf über das Formular (Frontend-Test mit `fireEvent`), Backend erzeugt echten `datev_exports`-Datensatz + Archiv-Registrierung (RET-05-Fake) + Übertragungsversuch gegen ein Fake-DATEV-Zielsystem. |
| 2 | Protokollansicht zeigt reale Testläufe korrekt an | **Bestanden.** Zwei echte Läufe (ein erfolgreicher, ein fehlgeschlagener) über die a11y-Fixture (`connect/cmd/datev-a11y-fixture`, dieselbe Postgres-DB wie die Backend-Tests) erzeugt und via `GET /datev/exports` in der laufenden Oberfläche real abgerufen — siehe pa11y-Lauf unten, der exakt diese zwei Zustände sah. |
| 3 | Fehlgeschlagener Testlauf ist visuell klar unterscheidbar | **Bestanden.** Zweiter Lauf zeigt `Fehlgeschlagen`-Text in Warnfarbe (`role="alert"`) mit der realen Fehlermeldung (`datevtransfer: zielsystem antwortete 503: ...`) sichtbar in der Fehlerdiagnose-Spalte, klar abgesetzt vom `Erfolgreich`-Lauf. |
## Echte Testläufe
- Backend: `go test ./internal/datevapi/...` — 5/5 grün (Trigger erfolgreich, Identität erforderlich, fehlgeschlagene Übertragung sichtbar, Protokoll zeigt reale Läufe, Mandantentrennung). Volle `connect`-Modul-Regression (`go test ./... -p 1`, `-p 1` wegen einer bekannten, vorbestehenden Parallelitätskollision bei der `pgcrypto`-Extension zwischen unabhängigen Testpaketen, keine Regression durch diese Kachel) — alle 17 Pakete grün.
- Frontend: `npx vitest run` — 4/4 grün. `npx tsc --noEmit` — sauber. `npx next build` — erfolgreich (Turbopack/`@nexarch/shl`-Symlink-Fix angewendet: echte Kopie statt Symlink in `node_modules/@nexarch/shl`, sonst schlägt die React-Auflösung fehl).
- Barrierefreiheit: echter `pa11y --standard WCAG2AA` (Chrome stable, `--no-sandbox`) gegen den laufenden Produktionsbuild (`next start`), bedient von `connect/cmd/datev-a11y-fixture` (reales Postgres, zwei real geseedete Läufe — ein Erfolg, ein Fehlschlag über einen echten Fake-DATEV-Zielserver mit HTTP 503) — **„No issues found!"**.
## Nicht Bestandteil dieser Kachel
- `QA-03` Prüfgate ERP/DATEV/M365 — nicht Teil dieser Kachel, nur die Schnittstelle (`datevapi`) steht bereit.
- Belegauswahl aus DMS-Dokumenten — Formular erzeugt einen synthetischen Testbeleg je Anstoß, echte Beleg-Herkunft ist eine spätere Kachel.
+194
View File
@@ -0,0 +1,194 @@
// Package datevapi is the HTTP binding for DAT-03: triggering a DATEV
// export by period and viewing the export/transfer protocol. Identity
// comes from X-User-Id/X-Tenant-Slug, same transitional pattern as
// statusapi (API-02) — no change to datevexport/datevtransfer.
package datevapi
import (
"encoding/json"
"net/http"
"time"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/datevexport"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/datevtransfer"
)
type Server struct {
Exports *datevexport.Store
Transfers *datevtransfer.Store
}
func NewServer(exports *datevexport.Store, transfers *datevtransfer.Store) *Server {
return &Server{Exports: exports, Transfers: transfers}
}
func (s *Server) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /datev/exports", s.requireIdentity(s.handleTrigger))
mux.HandleFunc("GET /datev/exports", s.requireIdentity(s.handleList))
}
func (s *Server) requireIdentity(next func(w http.ResponseWriter, r *http.Request, tenantSlug string)) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("X-User-Id") == "" {
writeError(w, http.StatusUnauthorized, "X-User-Id fehlt")
return
}
tenant := r.Header.Get("X-Tenant-Slug")
if tenant == "" {
writeError(w, http.StatusUnauthorized, "X-Tenant-Slug fehlt")
return
}
next(w, r, tenant)
}
}
type triggerRequest struct {
ConsultantNumber string `json:"consultant_number"`
ClientNumber string `json:"client_number"`
FiscalYearStart string `json:"fiscal_year_start"`
AccountLength int `json:"account_length"`
Description string `json:"description"`
PeriodStart string `json:"period_start"`
PeriodEnd string `json:"period_end"`
Entries []datevexport.Entry `json:"entries"`
}
type runResponse struct {
ID string `json:"id"`
PeriodStart string `json:"period_start"`
PeriodEnd string `json:"period_end"`
EntryCount int `json:"entry_count"`
RetentionClass string `json:"retention_class"`
CreatedAt string `json:"created_at"`
TransferStatus string `json:"transfer_status,omitempty"`
TransferError string `json:"transfer_error,omitempty"`
}
func (s *Server) handleTrigger(w http.ResponseWriter, r *http.Request, tenantSlug string) {
var req triggerRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "ungueltige anfrage")
return
}
periodStart, err := time.Parse("2006-01-02", req.PeriodStart)
if err != nil {
writeError(w, http.StatusBadRequest, "period_start ungueltig, erwartet YYYY-MM-DD")
return
}
periodEnd, err := time.Parse("2006-01-02", req.PeriodEnd)
if err != nil {
writeError(w, http.StatusBadRequest, "period_end ungueltig, erwartet YYYY-MM-DD")
return
}
fiscalYearStart, err := time.Parse("2006-01-02", req.FiscalYearStart)
if err != nil {
writeError(w, http.StatusBadRequest, "fiscal_year_start ungueltig, erwartet YYYY-MM-DD")
return
}
if len(req.Entries) == 0 {
writeError(w, http.StatusBadRequest, "keine belege im export")
return
}
header := datevexport.Header{
ConsultantNumber: req.ConsultantNumber,
ClientNumber: req.ClientNumber,
FiscalYearStart: fiscalYearStart,
AccountLength: req.AccountLength,
PeriodStart: periodStart,
PeriodEnd: periodEnd,
Description: req.Description,
}
file, run, err := s.Exports.RunExport(r.Context(), tenantSlug, header, req.Entries, "")
if err != nil {
writeError(w, http.StatusUnprocessableEntity, err.Error())
return
}
resp := runResponse{
ID: run.ID,
PeriodStart: run.PeriodStart.Format("2006-01-02"),
PeriodEnd: run.PeriodEnd.Format("2006-01-02"),
EntryCount: run.EntryCount,
RetentionClass: run.RetentionClass,
CreatedAt: run.CreatedAt.Format(time.RFC3339),
}
if s.Transfers != nil {
transfer, terr := s.Transfers.Start(r.Context(), tenantSlug, run.ID, run.ID+".csv", file)
if terr != nil {
resp.TransferStatus = string(datevtransfer.StatusFailed)
resp.TransferError = terr.Error()
} else {
resp.TransferStatus = string(transfer.Status)
resp.TransferError = transfer.LastError
}
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(resp)
}
type protocolEntry struct {
runResponse
}
func (s *Server) handleList(w http.ResponseWriter, r *http.Request, tenantSlug string) {
runs, err := s.Exports.List(r.Context(), tenantSlug)
if err != nil {
writeError(w, http.StatusInternalServerError, "exportlaeufe konnten nicht geladen werden")
return
}
var transfers []datevtransferListItem
if s.Transfers != nil {
ts, err := s.Transfers.ListForTenant(r.Context(), tenantSlug)
if err != nil {
writeError(w, http.StatusInternalServerError, "transfers konnten nicht geladen werden")
return
}
for _, t := range ts {
transfers = append(transfers, datevtransferListItem{exportRunID: t.ExportRunID, status: string(t.Status), lastError: t.LastError})
}
}
byRun := map[string]datevtransferListItem{}
for _, t := range transfers {
if _, ok := byRun[t.exportRunID]; !ok {
byRun[t.exportRunID] = t
}
}
out := make([]runResponse, 0, len(runs))
for _, run := range runs {
resp := runResponse{
ID: run.ID,
PeriodStart: run.PeriodStart.Format("2006-01-02"),
PeriodEnd: run.PeriodEnd.Format("2006-01-02"),
EntryCount: run.EntryCount,
RetentionClass: run.RetentionClass,
CreatedAt: run.CreatedAt.Format(time.RFC3339),
}
if t, ok := byRun[run.ID]; ok {
resp.TransferStatus = t.status
resp.TransferError = t.lastError
}
out = append(out, resp)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(out)
}
type datevtransferListItem struct {
exportRunID string
status string
lastError string
}
func writeError(w http.ResponseWriter, status int, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(map[string]string{"error": msg})
}
+267
View File
@@ -0,0 +1,267 @@
package datevapi
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"os"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/archiveclient"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/datevexport"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/datevtransfer"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/migrate"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/testdb"
)
func fakeArchiveRegister(t *testing.T) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/register" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]string{"id": "reg-1"})
}))
}
func fakeDatevTarget(t *testing.T, fail bool) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if fail {
http.Error(w, "simulierter fehler", http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]string{"transferId": "tx-1"})
}))
}
func openTestPool(t *testing.T) *pgxpool.Pool {
t.Helper()
dsn := os.Getenv("TEST_TENANT_DSN")
if dsn == "" {
t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest uebersprungen")
}
pool := testdb.Pool(t, dsn)
dir, _ := os.Getwd()
migDir := ""
for i := 0; i < 6; i++ {
p := dir + "/migrations"
if fi, err := os.Stat(p); err == nil && fi.IsDir() {
migDir = p
break
}
dir += "/.."
}
if migDir == "" {
t.Fatal("connect/migrations nicht gefunden")
}
migrations, err := migrate.Load(migDir)
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)
}
return pool
}
func newTestServer(t *testing.T, transferFails bool) (*Server, func()) {
t.Helper()
pool := openTestPool(t)
archiveSrv := fakeArchiveRegister(t)
datevSrv := fakeDatevTarget(t, transferFails)
exports := &datevexport.Store{
Pool: pool,
Archive: archiveclient.New(archiveSrv.URL, "svc-tok"),
CallbackURL: "https://connect.example.internal/datev/callback",
}
transfers := &datevtransfer.Store{
Pool: pool,
Client: &datevtransfer.Client{
HTTPClient: http.DefaultClient,
Config: datevtransfer.Config{Endpoint: datevSrv.URL, Token: "tok", ConsultantNumber: "1001", ClientNumber: "42"},
},
}
return NewServer(exports, transfers), func() {
archiveSrv.Close()
datevSrv.Close()
}
}
func validTriggerBody() []byte {
body := map[string]any{
"consultant_number": "1001",
"client_number": "42",
"fiscal_year_start": "2026-01-01",
"account_length": 4,
"description": "März 2026",
"period_start": "2026-03-01",
"period_end": "2026-03-31",
"entries": []map[string]any{
{
"Amount": 119.0, "DebitCredit": "S", "Account": "1200", "CounterAccount": "4400",
"Date": "2026-03-15T00:00:00Z", "Text": "Büromaterial", "DocumentRef": "RE-2026-001",
},
},
}
b, _ := json.Marshal(body)
return b
}
// AC1: Export laesst sich ueber die Oberflaeche (per Anfrage an diesen
// Endpunkt) mit Zeitraum-Auswahl anstossen.
func TestHandleTrigger_SuccessfulRun(t *testing.T) {
srv, cleanup := newTestServer(t, false)
defer cleanup()
mux := http.NewServeMux()
srv.RegisterRoutes(mux)
req := httptest.NewRequest(http.MethodPost, "/datev/exports", bytes.NewReader(validTriggerBody()))
req.Header.Set("X-User-Id", "u1")
req.Header.Set("X-Tenant-Slug", "acme")
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("erwartet 201, bekam %d: %s", rec.Code, rec.Body.String())
}
var resp runResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("antwort dekodieren: %v", err)
}
if resp.EntryCount != 1 {
t.Fatalf("erwartet 1 Beleg, bekam %d", resp.EntryCount)
}
if resp.TransferStatus != string(datevtransfer.StatusSuccess) {
t.Fatalf("erwartet transfer-status success, bekam %q", resp.TransferStatus)
}
}
func TestHandleTrigger_RequiresIdentity(t *testing.T) {
srv, cleanup := newTestServer(t, false)
defer cleanup()
mux := http.NewServeMux()
srv.RegisterRoutes(mux)
req := httptest.NewRequest(http.MethodPost, "/datev/exports", bytes.NewReader(validTriggerBody()))
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("erwartet 401 ohne Identitaet, bekam %d", rec.Code)
}
}
// AC3: Fehlgeschlagener Export ist ueber den Transfer-Status klar erkennbar.
func TestHandleTrigger_FailedTransferIsVisible(t *testing.T) {
srv, cleanup := newTestServer(t, true)
defer cleanup()
mux := http.NewServeMux()
srv.RegisterRoutes(mux)
req := httptest.NewRequest(http.MethodPost, "/datev/exports", bytes.NewReader(validTriggerBody()))
req.Header.Set("X-User-Id", "u1")
req.Header.Set("X-Tenant-Slug", "acme")
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("erwartet 201 (Exportlauf wird trotz Uebertragungsfehler protokolliert), bekam %d: %s", rec.Code, rec.Body.String())
}
var resp runResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("antwort dekodieren: %v", err)
}
if resp.TransferStatus != string(datevtransfer.StatusRetryPending) && resp.TransferStatus != string(datevtransfer.StatusFailed) {
t.Fatalf("erwartet fehlgeschlagenen/retry-status, bekam %q", resp.TransferStatus)
}
if resp.TransferError == "" {
t.Fatal("erwartet sichtbare Fehlermeldung bei fehlgeschlagenem Transfer")
}
}
// AC2: Exportprotokoll ist je Lauf einsehbar, inkl. echter Testlaeufe.
func TestHandleList_ShowsRealRuns(t *testing.T) {
srv, cleanup := newTestServer(t, false)
defer cleanup()
mux := http.NewServeMux()
srv.RegisterRoutes(mux)
triggerReq := httptest.NewRequest(http.MethodPost, "/datev/exports", bytes.NewReader(validTriggerBody()))
triggerReq.Header.Set("X-User-Id", "u1")
triggerReq.Header.Set("X-Tenant-Slug", "acme-list")
triggerRec := httptest.NewRecorder()
mux.ServeHTTP(triggerRec, triggerReq)
if triggerRec.Code != http.StatusCreated {
t.Fatalf("vorbereitung fehlgeschlagen: %d %s", triggerRec.Code, triggerRec.Body.String())
}
listURL := &url.URL{Path: "/datev/exports"}
listReq := httptest.NewRequest(http.MethodGet, listURL.String(), nil)
listReq.Header.Set("X-User-Id", "u1")
listReq.Header.Set("X-Tenant-Slug", "acme-list")
listRec := httptest.NewRecorder()
mux.ServeHTTP(listRec, listReq)
if listRec.Code != http.StatusOK {
t.Fatalf("erwartet 200, bekam %d", listRec.Code)
}
var runs []runResponse
if err := json.NewDecoder(listRec.Body).Decode(&runs); err != nil {
t.Fatalf("antwort dekodieren: %v", err)
}
if len(runs) == 0 {
t.Fatal("erwartet mindestens einen Exportlauf im Protokoll")
}
if runs[0].TransferStatus != string(datevtransfer.StatusSuccess) {
t.Fatalf("erwartet transfer-status success im Protokoll, bekam %q", runs[0].TransferStatus)
}
}
// Tenant isolation: another tenant's runs must not leak into this list.
func TestHandleList_TenantIsolation(t *testing.T) {
srv, cleanup := newTestServer(t, false)
defer cleanup()
mux := http.NewServeMux()
srv.RegisterRoutes(mux)
triggerReq := httptest.NewRequest(http.MethodPost, "/datev/exports", bytes.NewReader(validTriggerBody()))
triggerReq.Header.Set("X-User-Id", "u1")
triggerReq.Header.Set("X-Tenant-Slug", "tenant-a-iso")
triggerRec := httptest.NewRecorder()
mux.ServeHTTP(triggerRec, triggerReq)
if triggerRec.Code != http.StatusCreated {
t.Fatalf("vorbereitung fehlgeschlagen: %d", triggerRec.Code)
}
listReq := httptest.NewRequest(http.MethodGet, "/datev/exports", nil)
listReq.Header.Set("X-User-Id", "u1")
listReq.Header.Set("X-Tenant-Slug", "tenant-b-iso")
listRec := httptest.NewRecorder()
mux.ServeHTTP(listRec, listReq)
var runs []runResponse
if err := json.NewDecoder(listRec.Body).Decode(&runs); err != nil {
t.Fatalf("antwort dekodieren: %v", err)
}
for _, r := range runs {
if r.ID == "" {
continue
}
}
// tenant-b-iso must see none of tenant-a-iso's runs.
if len(runs) != 0 {
t.Fatalf("mandantentrennung verletzt: tenant-b-iso sieht %d Laeufe eines fremden Mandanten", len(runs))
}
}
+31
View File
@@ -0,0 +1,31 @@
package datevexport
import (
"context"
"fmt"
)
// List returns a tenant's export runs, newest first — additive to
// RunExport, used by DAT-03's protocol view. No change to RunExport.
func (s *Store) List(ctx context.Context, tenantSlug string) ([]Run, error) {
rows, err := s.Pool.Query(ctx, `
SELECT id, tenant_slug, period_start, period_end, entry_count, retention_class, coalesce(archive_object_id, ''), created_at
FROM datev_exports
WHERE tenant_slug = $1
ORDER BY created_at DESC
`, tenantSlug)
if err != nil {
return nil, fmt.Errorf("datevexport: exportlaeufe auflisten: %w", err)
}
defer rows.Close()
var runs []Run
for rows.Next() {
var r Run
if err := rows.Scan(&r.ID, &r.TenantSlug, &r.PeriodStart, &r.PeriodEnd, &r.EntryCount, &r.RetentionClass, &r.ArchiveObjectID, &r.CreatedAt); err != nil {
return nil, fmt.Errorf("datevexport: exportlauf lesen: %w", err)
}
runs = append(runs, r)
}
return runs, rows.Err()
}
+32
View File
@@ -0,0 +1,32 @@
package datevtransfer
import (
"context"
"fmt"
)
// ListForTenant returns a tenant's transfers, newest first — additive to
// Start/Retry/Get, used by DAT-03's protocol view to show transmission
// status per export run. No change to existing methods.
func (s *Store) ListForTenant(ctx context.Context, tenantSlug string) ([]Transfer, error) {
rows, err := s.Pool.Query(ctx, `
SELECT id, tenant_slug, export_run_id, status, attempt, coalesce(last_error, ''), retry_at, coalesce(transfer_ref, ''), created_at, updated_at
FROM datev_transfers
WHERE tenant_slug = $1
ORDER BY created_at DESC
`, tenantSlug)
if err != nil {
return nil, fmt.Errorf("datevtransfer: transfers auflisten: %w", err)
}
defer rows.Close()
var out []Transfer
for rows.Next() {
var t Transfer
if err := rows.Scan(&t.ID, &t.TenantSlug, &t.ExportRunID, &t.Status, &t.Attempt, &t.LastError, &t.RetryAt, &t.TransferRef, &t.CreatedAt, &t.UpdatedAt); err != nil {
return nil, fmt.Errorf("datevtransfer: transfer lesen: %w", err)
}
out = append(out, t)
}
return out, rows.Err()
}
+1
View File
@@ -0,0 +1 @@
+117
View File
@@ -0,0 +1,117 @@
import { describe, expect, it, vi, afterEach } from "vitest";
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
import { I18nProvider, ThemeProvider, ToastProvider } from "@nexarch/shl";
import ExportsPage from "./page";
import { UnauthorizedError } from "../../lib/api";
vi.mock("../../lib/api", async () => {
const actual = await vi.importActual<typeof import("../../lib/api")>("../../lib/api");
return { ...actual, fetchExportRuns: vi.fn(), triggerExport: vi.fn() };
});
import { fetchExportRuns, triggerExport } from "../../lib/api";
function renderPage() {
return render(
<ThemeProvider>
<I18nProvider initialLocale="de">
<ToastProvider>
<ExportsPage />
</ToastProvider>
</I18nProvider>
</ThemeProvider>
);
}
describe("ExportsPage", () => {
afterEach(() => {
vi.clearAllMocks();
});
it("zeigt eine explizite Meldung ohne Anmeldung", async () => {
vi.mocked(fetchExportRuns).mockRejectedValue(new UnauthorizedError("nicht angemeldet"));
renderPage();
await waitFor(() => {
expect(screen.getByRole("alert").textContent).toContain("Anmeldung erforderlich");
});
});
// AC2: Exportprotokoll ist je Lauf einsehbar.
it("zeigt reale Testlaeufe im Protokoll an", async () => {
vi.mocked(fetchExportRuns).mockResolvedValue([
{
id: "run-1-abcdef",
period_start: "2026-03-01",
period_end: "2026-03-31",
entry_count: 3,
retention_class: "gobd_10y",
created_at: new Date().toISOString(),
transfer_status: "success",
},
]);
renderPage();
await waitFor(() => {
expect(screen.getByText("Erfolgreich")).toBeTruthy();
expect(screen.getByText("2026-03-01 – 2026-03-31")).toBeTruthy();
});
});
// AC3 + Pruefung 3: fehlgeschlagener Testlauf ist visuell klar
// unterscheidbar (eigenes role="alert" Element, eigene Farbe/Text).
it("markiert einen fehlgeschlagenen Testlauf klar erkennbar", async () => {
vi.mocked(fetchExportRuns).mockResolvedValue([
{
id: "run-2-abcdef",
period_start: "2026-04-01",
period_end: "2026-04-30",
entry_count: 1,
retention_class: "gobd_10y",
created_at: new Date().toISOString(),
transfer_status: "failed",
transfer_error: "übertragung an datev-zielsystem abgelehnt",
},
]);
renderPage();
await waitFor(() => {
expect(screen.getByText("Fehlgeschlagen")).toBeTruthy();
expect(screen.getByText("übertragung an datev-zielsystem abgelehnt")).toBeTruthy();
});
});
// AC1 + Pruefung 1: Export laesst sich ueber die Oberflaeche mit
// Zeitraum-Auswahl anstossen.
it("stoesst einen Export mit den eingegebenen Werten an", async () => {
vi.mocked(fetchExportRuns).mockResolvedValue([]);
vi.mocked(triggerExport).mockResolvedValue({
id: "run-new",
period_start: "2026-03-01",
period_end: "2026-03-31",
entry_count: 1,
retention_class: "gobd_10y",
created_at: new Date().toISOString(),
transfer_status: "success",
});
renderPage();
await waitFor(() => expect(screen.getByText("Noch keine Exportläufe.")).toBeTruthy());
fireEvent.change(screen.getByLabelText("Beraternummer"), { target: { value: "1001" } });
fireEvent.change(screen.getByLabelText("Mandantennummer"), { target: { value: "42" } });
fireEvent.change(screen.getByLabelText("Wirtschaftsjahresbeginn"), { target: { value: "2026-01-01" } });
fireEvent.change(screen.getByLabelText("Bezeichnung"), { target: { value: "März 2026" } });
fireEvent.change(screen.getByLabelText("Zeitraum von"), { target: { value: "2026-03-01" } });
fireEvent.change(screen.getByLabelText("Zeitraum bis"), { target: { value: "2026-03-31" } });
fireEvent.click(screen.getByText("Export anstoßen"));
await waitFor(() => {
expect(triggerExport).toHaveBeenCalledWith(
expect.objectContaining({
consultant_number: "1001",
client_number: "42",
period_start: "2026-03-01",
period_end: "2026-03-31",
})
);
});
});
});
+222
View File
@@ -0,0 +1,222 @@
"use client";
import { useEffect, useState } from "react";
import { Table, TextField, useToast, type TableColumn } from "@nexarch/shl";
import {
ApiError,
ExportRun,
UnauthorizedError,
fetchExportRuns,
triggerExport,
} from "../../lib/api";
const TRANSFER_LABEL: Record<string, string> = {
success: "Erfolgreich",
failed: "Fehlgeschlagen",
retry_pending: "Wiederholung ausstehend",
pending: "Ausstehend",
};
function transferColor(status?: string): string {
switch (status) {
case "success":
return "var(--shl-color-success, #1a7f37)";
case "failed":
return "var(--shl-color-danger, #c02020)";
case "retry_pending":
return "var(--shl-color-warning, #9a6700)";
default:
return "var(--shl-color-text-secondary, #57606a)";
}
}
interface FormState {
consultantNumber: string;
clientNumber: string;
fiscalYearStart: string;
accountLength: string;
description: string;
periodStart: string;
periodEnd: string;
}
const emptyForm: FormState = {
consultantNumber: "",
clientNumber: "",
fiscalYearStart: "",
accountLength: "4",
description: "",
periodStart: "",
periodEnd: "",
};
export default function ExportsPage() {
const { push } = useToast();
const [runs, setRuns] = useState<ExportRun[] | null>(null);
const [unauthorized, setUnauthorized] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
const [form, setForm] = useState<FormState>(emptyForm);
const [submitting, setSubmitting] = useState(false);
function load() {
fetchExportRuns()
.then((r) => {
setRuns(r);
setUnauthorized(false);
})
.catch((err) => {
if (err instanceof UnauthorizedError) {
setUnauthorized(true);
return;
}
const message = err instanceof ApiError || err instanceof Error ? err.message : "Exportprotokoll konnte nicht geladen werden.";
setLoadError(message);
push(message, "danger");
});
}
useEffect(load, []);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setSubmitting(true);
try {
await triggerExport({
consultant_number: form.consultantNumber,
client_number: form.clientNumber,
fiscal_year_start: form.fiscalYearStart,
account_length: Number(form.accountLength),
description: form.description,
period_start: form.periodStart,
period_end: form.periodEnd,
// Belegauswahl aus DMS ist nicht Bestandteil dieser Kachel (siehe
// Nicht-Bestandteil/QA-03) — ein Testbeleg demonstriert den
// vollstaendigen Anstoss-bis-Protokoll-Weg.
entries: [
{
Amount: 1,
DebitCredit: "S",
Account: "1200",
CounterAccount: "4400",
Date: new Date(form.periodStart || Date.now()).toISOString(),
Text: "Testbeleg (manueller Anstoss)",
DocumentRef: `manuell-${Date.now()}`,
},
],
});
push("Export angestossen.", "success");
load();
} catch (err) {
const message = err instanceof ApiError || err instanceof Error ? err.message : "Export konnte nicht angestossen werden.";
push(message, "danger");
} finally {
setSubmitting(false);
}
}
if (unauthorized) {
return (
<main style={{ maxWidth: 960, margin: "40px auto", padding: "0 16px" }}>
<p role="alert">Anmeldung erforderlich, um DATEV-Exporte zu sehen.</p>
</main>
);
}
const columns: TableColumn<ExportRun>[] = [
{ key: "id", header: "Lauf", render: (r) => r.id.slice(0, 8) },
{ key: "period", header: "Zeitraum", render: (r) => `${r.period_start} – ${r.period_end}` },
{ key: "entry_count", header: "Belege", render: (r) => String(r.entry_count) },
{
key: "transfer_status",
header: "Übertragungsstatus",
render: (r) =>
r.transfer_status ? (
<span style={{ color: transferColor(r.transfer_status), fontWeight: 600 }} role={r.transfer_status === "failed" ? "alert" : undefined}>
{TRANSFER_LABEL[r.transfer_status] ?? r.transfer_status}
</span>
) : (
<span style={{ color: "var(--shl-color-text-secondary, #57606a)" }}>—</span>
),
},
{
key: "transfer_error",
header: "Fehlerdiagnose",
render: (r) => (r.transfer_error ? <span role="status">{r.transfer_error}</span> : "—"),
},
{
key: "created_at",
header: "Erstellt",
render: (r) => new Date(r.created_at).toLocaleString("de-DE"),
},
];
return (
<main style={{ maxWidth: 960, margin: "40px auto", padding: "0 16px" }}>
<h1>DATEV-Export</h1>
<form onSubmit={handleSubmit} style={{ marginBottom: 32, display: "flex", flexDirection: "column", gap: 12, maxWidth: 480 }}>
<TextField
label="Beraternummer"
value={form.consultantNumber}
onChange={(e) => setForm({ ...form, consultantNumber: e.target.value })}
required
/>
<TextField
label="Mandantennummer"
value={form.clientNumber}
onChange={(e) => setForm({ ...form, clientNumber: e.target.value })}
required
/>
<TextField
label="Wirtschaftsjahresbeginn"
type="date"
value={form.fiscalYearStart}
onChange={(e) => setForm({ ...form, fiscalYearStart: e.target.value })}
required
/>
<TextField
label="Sachkontenlänge"
type="number"
value={form.accountLength}
onChange={(e) => setForm({ ...form, accountLength: e.target.value })}
required
/>
<TextField
label="Bezeichnung"
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
required
/>
<TextField
label="Zeitraum von"
type="date"
value={form.periodStart}
onChange={(e) => setForm({ ...form, periodStart: e.target.value })}
required
/>
<TextField
label="Zeitraum bis"
type="date"
value={form.periodEnd}
onChange={(e) => setForm({ ...form, periodEnd: e.target.value })}
required
/>
<button type="submit" disabled={submitting}>
{submitting ? "Wird angestossen …" : "Export anstoßen"}
</button>
</form>
<h2>Exportprotokoll</h2>
{loadError && <p role="alert">{loadError}</p>}
{runs && runs.length === 0 && <p>Noch keine Exportläufe.</p>}
{runs && runs.length > 0 && (
<Table
columns={columns}
rows={runs}
rowKey={(r) => r.id}
caption="Protokoll aller DATEV-Exportläufe mit Übertragungsstatus"
/>
)}
</main>
);
}
+37
View File
@@ -0,0 +1,37 @@
import Script from "next/script";
import { ThemeProvider, I18nProvider, ToastProvider, typography, themeInitScript, focusVisibleStyles } from "@nexarch/shl";
export const metadata = {
title: "NEXARCH DATEV-Export",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="de">
<head>
<style>{focusVisibleStyles}</style>
<Script id="shl-theme-init" strategy="beforeInteractive">
{themeInitScript()}
</Script>
</head>
<body
style={{
fontFamily: typography.fontFamily,
margin: 0,
background: "var(--shl-color-background, #ffffff)",
color: "var(--shl-color-text-primary, #14181f)",
}}
>
<ThemeProvider>
<I18nProvider initialLocale="de">
<ToastProvider>{children}</ToastProvider>
</I18nProvider>
</ThemeProvider>
</body>
</html>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function IndexPage() {
redirect("/exports");
}
+83
View File
@@ -0,0 +1,83 @@
// Duenner Client fuer DAT-03 (connect/internal/datevapi) — enthaelt keine
// eigene Geschaeftslogik, nur Datenabruf/-versand. Identitaet kommt
// uebergangsweise (wie bei web/connect-status) aus Umgebungsvariablen,
// die als Header durchgereicht werden.
export class UnauthorizedError extends Error {}
export class ApiError extends Error {}
function apiBase(): string {
const base = process.env.NEXT_PUBLIC_CONNECT_API_URL;
if (!base) {
throw new Error("NEXT_PUBLIC_CONNECT_API_URL ist nicht gesetzt (Umgebungsvariable erforderlich)");
}
return base;
}
function identityHeaders(): Record<string, string> {
const userID = process.env.NEXT_PUBLIC_USER_ID;
const tenant = process.env.NEXT_PUBLIC_TENANT_SLUG;
const headers: Record<string, string> = {};
if (userID) headers["X-User-Id"] = userID;
if (tenant) headers["X-Tenant-Slug"] = tenant;
return headers;
}
async function req<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${apiBase()}${path}`, {
...init,
headers: {
"Content-Type": "application/json",
...identityHeaders(),
...init?.headers,
},
});
if (res.status === 401) {
throw new UnauthorizedError("Anmeldung erforderlich, um DATEV-Exporte zu sehen.");
}
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new ApiError(body.error ?? `Anfrage fehlgeschlagen (${res.status})`);
}
return res.json().catch(() => undefined as T);
}
export interface ExportEntry {
Amount: number;
DebitCredit: "S" | "H";
Account: string;
CounterAccount: string;
Date: string;
Text: string;
DocumentRef: string;
}
export interface TriggerExportRequest {
consultant_number: string;
client_number: string;
fiscal_year_start: string;
account_length: number;
description: string;
period_start: string;
period_end: string;
entries: ExportEntry[];
}
export interface ExportRun {
id: string;
period_start: string;
period_end: string;
entry_count: number;
retention_class: string;
created_at: string;
transfer_status?: string;
transfer_error?: string;
}
export function triggerExport(req_: TriggerExportRequest): Promise<ExportRun> {
return req("/datev/exports", { method: "POST", body: JSON.stringify(req_) });
}
export function fetchExportRuns(): Promise<ExportRun[]> {
return req("/datev/exports");
}
+5
View File
@@ -0,0 +1,5 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
transpilePackages: ["@nexarch/shl"],
};
export default nextConfig;
+3304
View File
File diff suppressed because it is too large. Load diff
+26
View File
@@ -0,0 +1,26 @@
{
"name": "nexarch-datev-export",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"test": "vitest run"
},
"dependencies": {
"@nexarch/shl": "file:../shl",
"next": "16.3.5",
"react": "19.3.0",
"react-dom": "19.3.0"
},
"devDependencies": {
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "16.3.3",
"@types/node": "^22.20.4",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"jsdom": "24.1.0",
"typescript": "5.5.3",
"vitest": "^5.0.1"
}
}
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": false,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts"],
"exclude": ["node_modules"]
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "jsdom",
setupFiles: ["./vitest.setup.ts"],
},
});
+5
View File
@@ -0,0 +1,5 @@
import "@testing-library/jest-dom/vitest";
import { afterEach } from "vitest";
import { cleanup } from "@testing-library/react";
afterEach(cleanup);