Files
Bookra/apps/backend/internal/export/service_test.go
T
Tomáš DvořákandDevin 1cd14046de chore(tooling): oxlint + oxfmt frontend, lefthook gate, govulncheck (#21)
- apps/frontend: oxlint 1.85 (typescript/unicorn/oxc — no react rules,
  SolidJS), oxfmt scripts; lint script now oxlint && tsc --noEmit
- backend: gofmt; bump x/text 0.39.0, quic-go 0.59.1, pgx 5.9.2
  (govulncheck 0 reachable vulns, was 3)
- ci: go vet + govulncheck in Go job (frontend job already gates via
  lint:frontend)
- lefthook pre-push: frontend lint + gofmt + vet/test + gitleaks

Authored By: TDvorak <[email protected]>

Generated with [Devin](https://devin.ai)

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-26 16:41:35 +02:00

247 lines
7.7 KiB
Go

package export
import (
"bytes"
"context"
"encoding/csv"
"strings"
"testing"
"time"
"bookra/apps/backend/internal/bookings"
"bookra/apps/backend/internal/db"
"bookra/apps/backend/internal/domain"
)
var demoPrincipal = domain.Principal{Subject: "demo-owner", Role: "owner"}
func newTestService(repo *db.MemoryRepository) *Service {
return NewService(repo, bookings.NewService(repo, nil, "http://localhost:3000"))
}
const demoTenantID = "5d6b3551-0a3e-4b86-bdf0-e9df20a47148"
func readCSV(t *testing.T, buf *bytes.Buffer) [][]string {
t.Helper()
records, err := csv.NewReader(strings.NewReader(strings.TrimPrefix(buf.String(), "\ufeff"))).ReadAll()
if err != nil {
t.Fatalf("parse csv: %v\n%s", err, buf.String())
}
return records
}
func TestWriteBookingsCSVResolvesLabelsAndEscapes(t *testing.T) {
repo := db.NewMemoryRepository()
serviceID := "d5d76a61-3d49-467c-8dd4-bf61ee754e39" // "Signature treatment"
staffID := "6936c444-c7d0-4a7d-b596-a9b72d2f4fc0" // "Demo Stylist"
locationID := "659f1cc0-a850-46d6-b3b8-cb15d55d8daf"
_, err := repo.CreateBooking(context.Background(), db.CreateBookingParams{
TenantID: demoTenantID,
ServiceID: &serviceID,
StaffID: &staffID,
LocationID: &locationID,
CustomerName: "Novák, Jiří",
CustomerEmail: "[email protected]",
CustomerPhone: "+420111222333",
StartsAt: time.Now().Add(24 * time.Hour),
EndsAt: time.Now().Add(25 * time.Hour),
Status: "confirmed",
Reference: "BK-CSV-1",
ManageToken: "tok",
Notes: "note with \"quotes\"",
})
if err != nil {
t.Fatalf("create booking: %v", err)
}
var buf bytes.Buffer
svc := newTestService(repo)
from := time.Now().Add(-time.Hour)
to := time.Now().Add(72 * time.Hour)
if err := svc.WriteBookingsCSV(context.Background(), demoPrincipal, &buf, from, to); err != nil {
t.Fatalf("write csv: %v", err)
}
if !bytes.HasPrefix(buf.Bytes(), utf8BOM) {
t.Fatal("expected UTF-8 BOM for Excel compatibility")
}
records := readCSV(t, &buf)
if len(records) != 2 {
t.Fatalf("expected header + 1 row, got %d rows", len(records))
}
row := records[1]
if row[0] != "BK-CSV-1" || row[1] != "confirmed" {
t.Fatalf("unexpected reference/status: %v", row[:2])
}
if row[2] != "Novák, Jiří" {
t.Fatalf("expected escaped customer name, got %q", row[2])
}
if row[5] != "Signature treatment" || row[6] != "Demo Stylist" || row[7] != "Main Studio" {
t.Fatalf("expected resolved labels, got service=%q staff=%q location=%q", row[5], row[6], row[7])
}
if row[11] != `note with "quotes"` {
t.Fatalf("expected escaped notes, got %q", row[11])
}
}
func TestWriteBookingsCSVRangeExcludesOutOfWindow(t *testing.T) {
repo := db.NewMemoryRepository()
_, err := repo.CreateBooking(context.Background(), db.CreateBookingParams{
TenantID: demoTenantID,
CustomerName: "Far Future",
CustomerEmail: "[email protected]",
StartsAt: time.Now().Add(200 * 24 * time.Hour),
EndsAt: time.Now().Add(200*24*time.Hour + time.Hour),
Status: "confirmed",
Reference: "BK-CSV-FAR",
ManageToken: "tok",
})
if err != nil {
t.Fatalf("create booking: %v", err)
}
var buf bytes.Buffer
svc := newTestService(repo)
from := time.Now().Add(-time.Hour)
to := time.Now().Add(24 * time.Hour) // far-future booking outside
if err := svc.WriteBookingsCSV(context.Background(), demoPrincipal, &buf, from, to); err != nil {
t.Fatalf("write csv: %v", err)
}
for _, record := range readCSV(t, &buf) {
if record[0] == "BK-CSV-FAR" {
t.Fatal("expected range to exclude far-future booking")
}
}
}
func TestWriteCustomersCSVPagesAndStrips(t *testing.T) {
repo := db.NewMemoryRepository()
var buf bytes.Buffer
svc := newTestService(repo)
if err := svc.WriteCustomersCSV(context.Background(), demoPrincipal, &buf); err != nil {
t.Fatalf("write csv: %v", err)
}
records := readCSV(t, &buf)
if len(records) < 2 {
t.Fatalf("expected header + seeded customers, got %d rows", len(records))
}
if records[0][0] != "name" || records[0][4] != "created_at" {
t.Fatalf("unexpected header: %v", records[0])
}
found := false
for _, record := range records[1:] {
if record[0] == "Alice Johnson" && record[1] == "[email protected]" {
found = true
}
}
if !found {
t.Fatal("expected seeded customer Alice Johnson in export")
}
}
func TestExportRequiresMembership(t *testing.T) {
repo := db.NewMemoryRepository()
svc := newTestService(repo)
var buf bytes.Buffer
err := svc.WriteCustomersCSV(context.Background(), domain.Principal{Subject: "nobody"}, &buf)
if err != ErrTenantMembership {
t.Fatalf("expected ErrTenantMembership, got %v", err)
}
if buf.Len() != 0 {
t.Fatal("expected no output on membership failure")
}
}
func TestWriteStaffReportCSV(t *testing.T) {
repo := db.NewMemoryRepository()
serviceID := "d5d76a61-3d49-467c-8dd4-bf61ee754e39"
staffID := "6936c444-c7d0-4a7d-b596-a9b72d2f4fc0"
now := time.Now().UTC()
seed := func(status string, offsetHours int) {
_, err := repo.CreateBooking(context.Background(), db.CreateBookingParams{
TenantID: demoTenantID,
ServiceID: &serviceID,
StaffID: &staffID,
CustomerName: "Report Customer",
CustomerEmail: "[email protected]",
StartsAt: now.Add(time.Duration(offsetHours) * time.Hour),
EndsAt: now.Add(time.Duration(offsetHours+1) * time.Hour),
Status: status,
Reference: "BK-REP-" + status,
})
if err != nil {
t.Fatalf("seed booking: %v", err)
}
}
seed("confirmed", -5)
seed("confirmed", -20)
seed("cancelled", -30)
var buf bytes.Buffer
svc := newTestService(repo)
if err := svc.WriteStaffReportCSV(context.Background(), demoPrincipal, &buf, 30); err != nil {
t.Fatalf("staff report csv: %v", err)
}
records := readCSV(t, &buf)
if len(records) != 2 {
t.Fatalf("expected header + 1 staff row, got %d rows", len(records))
}
row := records[1]
if row[0] != "Demo Stylist" {
t.Fatalf("expected staff name, got %q", row[0])
}
if row[1] != "3" || row[2] != "2" || row[3] != "1" {
t.Fatalf("expected bookings=3 completed=2 cancelled=1, got %v", row[1:4])
}
if row[4] != "120" {
t.Fatalf("expected 120 booked minutes, got %q", row[4])
}
if row[7] != "240000" {
t.Fatalf("expected 240000 revenue cents, got %q", row[7])
}
}
func TestWriteServiceReportCSV(t *testing.T) {
repo := db.NewMemoryRepository()
serviceID := "d5d76a61-3d49-467c-8dd4-bf61ee754e39"
sessionID := "4bf74c12-44dd-45ca-86bb-b104f16f2435"
now := time.Now().UTC()
seed := func(service *string, session *string, status string, ref string) {
_, err := repo.CreateBooking(context.Background(), db.CreateBookingParams{
TenantID: demoTenantID,
ServiceID: service,
ClassSessionID: session,
CustomerName: "Svc Customer",
CustomerEmail: "[email protected]",
StartsAt: now.Add(-5 * time.Hour),
EndsAt: now.Add(-4 * time.Hour),
Status: status,
Reference: ref,
})
if err != nil {
t.Fatalf("seed booking: %v", err)
}
}
seed(&serviceID, nil, "confirmed", "BK-SVC-1")
seed(&serviceID, nil, "cancelled", "BK-SVC-2")
seed(nil, &sessionID, "confirmed", "BK-SVC-3")
var buf bytes.Buffer
svc := newTestService(repo)
if err := svc.WriteServiceReportCSV(context.Background(), demoPrincipal, &buf, 30); err != nil {
t.Fatalf("service report csv: %v", err)
}
records := readCSV(t, &buf)
if len(records) != 3 {
t.Fatalf("expected header + service row + class row, got %d rows", len(records))
}
svcRow := records[1]
if svcRow[0] != "Signature treatment" || svcRow[1] != "2" || svcRow[2] != "0" || svcRow[3] != "1" || svcRow[4] != "120000" {
t.Fatalf("unexpected service row: %v", svcRow)
}
classRow := records[2]
if classRow[0] != "class sessions" || classRow[1] != "1" {
t.Fatalf("unexpected class row: %v", classRow)
}
}