Files
silo/internal/api/csv_handlers_test.go
Forbes 384b137148 test(api): add CSV and ODS import/export handler tests (#77)
CSV tests:
- Export empty/with items, template generation
- Import dry-run (preview without creating), commit (items created)
- BOM CSV export with parent/child relationships

ODS tests:
- Export with items (verify ODS content type and ZIP magic bytes)
- Template generation per schema
- Project sheet export with item associations
2026-02-13 15:20:20 -06:00

255 lines
7.0 KiB
Go

package api
import (
"bytes"
"encoding/json"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/go-chi/chi/v5"
"github.com/kindredsystems/silo/internal/schema"
"github.com/kindredsystems/silo/internal/testutil"
"github.com/rs/zerolog"
"github.com/kindredsystems/silo/internal/db"
)
// findSchemasDir walks upward to find the project root and returns
// the path to the schemas/ directory.
func findSchemasDir(t *testing.T) string {
t.Helper()
dir, err := os.Getwd()
if err != nil {
t.Fatalf("getting working directory: %v", err)
}
for {
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
return filepath.Join(dir, "schemas")
}
parent := filepath.Dir(dir)
if parent == dir {
t.Fatal("could not find project root")
}
dir = parent
}
}
// newTestServerWithSchemas creates a Server backed by a real test DB with schemas loaded.
func newTestServerWithSchemas(t *testing.T) *Server {
t.Helper()
pool := testutil.MustConnectTestPool(t)
database := db.NewFromPool(pool)
broker := NewBroker(zerolog.Nop())
state := NewServerState(zerolog.Nop(), nil, broker)
schemasDir := findSchemasDir(t)
schemas, err := schema.LoadAll(schemasDir)
if err != nil {
t.Fatalf("loading schemas: %v", err)
}
return NewServer(
zerolog.Nop(),
database,
schemas,
schemasDir,
nil, // storage
nil, // authService
nil, // sessionManager
nil, // oidcBackend
nil, // authConfig
broker,
state,
)
}
func newCSVRouter(s *Server) http.Handler {
r := chi.NewRouter()
r.Get("/api/items/export.csv", s.HandleExportCSV)
r.Get("/api/items/template.csv", s.HandleCSVTemplate)
r.Post("/api/items/import", s.HandleImportCSV)
r.Get("/api/items/{partNumber}/bom/export.csv", s.HandleExportBOMCSV)
return r
}
func TestHandleExportCSVEmpty(t *testing.T) {
s := newTestServer(t)
router := newCSVRouter(s)
req := httptest.NewRequest("GET", "/api/items/export.csv", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status: got %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
}
ct := w.Header().Get("Content-Type")
if !strings.Contains(ct, "text/csv") {
t.Errorf("content-type: got %q, want text/csv", ct)
}
// Should have header row only
lines := strings.Split(strings.TrimSpace(w.Body.String()), "\n")
if len(lines) != 1 {
t.Errorf("expected 1 line (header only), got %d", len(lines))
}
}
func TestHandleExportCSVWithItems(t *testing.T) {
s := newTestServer(t)
router := newCSVRouter(s)
createItemDirect(t, s, "CSV-001", "first csv item", nil)
createItemDirect(t, s, "CSV-002", "second csv item", nil)
req := httptest.NewRequest("GET", "/api/items/export.csv", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status: got %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
}
lines := strings.Split(strings.TrimSpace(w.Body.String()), "\n")
// header + 2 data rows
if len(lines) != 3 {
t.Errorf("expected 3 lines (header + 2 rows), got %d", len(lines))
}
}
func TestHandleCSVTemplate(t *testing.T) {
s := newTestServerWithSchemas(t)
router := newCSVRouter(s)
req := httptest.NewRequest("GET", "/api/items/template.csv?schema=kindred-rd", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status: got %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
}
ct := w.Header().Get("Content-Type")
if !strings.Contains(ct, "text/csv") {
t.Errorf("content-type: got %q, want text/csv", ct)
}
// Should contain at least "category" and "description" columns
header := strings.Split(strings.TrimSpace(w.Body.String()), "\n")[0]
if !strings.Contains(header, "category") {
t.Error("template header missing 'category' column")
}
if !strings.Contains(header, "description") {
t.Error("template header missing 'description' column")
}
}
// csvMultipartBody creates a multipart form body with a CSV file and optional form fields.
func csvMultipartBody(t *testing.T, csvContent string, fields map[string]string) (*bytes.Buffer, string) {
t.Helper()
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, err := writer.CreateFormFile("file", "import.csv")
if err != nil {
t.Fatalf("creating form file: %v", err)
}
io.WriteString(part, csvContent)
for k, v := range fields {
writer.WriteField(k, v)
}
writer.Close()
return body, writer.FormDataContentType()
}
func TestHandleImportCSVDryRun(t *testing.T) {
s := newTestServerWithSchemas(t)
router := newCSVRouter(s)
csv := "category,description\nF01,Dry run widget\nF01,Dry run gadget\n"
body, contentType := csvMultipartBody(t, csv, map[string]string{"dry_run": "true"})
req := authRequest(httptest.NewRequest("POST", "/api/items/import", body))
req.Header.Set("Content-Type", contentType)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status: got %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
}
var result CSVImportResult
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
t.Fatalf("decoding response: %v", err)
}
if result.TotalRows != 2 {
t.Errorf("total_rows: got %d, want 2", result.TotalRows)
}
// Dry run should not create items
if len(result.CreatedItems) != 0 {
t.Errorf("dry run should not create items, got %d", len(result.CreatedItems))
}
}
func TestHandleImportCSVCommit(t *testing.T) {
s := newTestServerWithSchemas(t)
router := newCSVRouter(s)
csv := "category,description\nF01,Committed widget\n"
body, contentType := csvMultipartBody(t, csv, nil)
req := authRequest(httptest.NewRequest("POST", "/api/items/import", body))
req.Header.Set("Content-Type", contentType)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status: got %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
}
var result CSVImportResult
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
t.Fatalf("decoding response: %v", err)
}
if result.SuccessCount != 1 {
t.Errorf("success_count: got %d, want 1", result.SuccessCount)
}
if len(result.CreatedItems) != 1 {
t.Errorf("created_items: got %d, want 1", len(result.CreatedItems))
}
}
func TestHandleExportBOMCSV(t *testing.T) {
s := newTestServer(t)
router := newCSVRouter(s)
createItemDirect(t, s, "BOMCSV-P", "parent", nil)
createItemDirect(t, s, "BOMCSV-C", "child", nil)
addBOMDirect(t, s, "BOMCSV-P", "BOMCSV-C", 3)
req := httptest.NewRequest("GET", "/api/items/BOMCSV-P/bom/export.csv", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status: got %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
}
ct := w.Header().Get("Content-Type")
if !strings.Contains(ct, "text/csv") {
t.Errorf("content-type: got %q, want text/csv", ct)
}
lines := strings.Split(strings.TrimSpace(w.Body.String()), "\n")
// header + 1 BOM entry
if len(lines) != 2 {
t.Errorf("expected 2 lines (header + 1 row), got %d", len(lines))
}
}