diff --git a/internal/api/csv_handlers_test.go b/internal/api/csv_handlers_test.go new file mode 100644 index 0000000..9dd703b --- /dev/null +++ b/internal/api/csv_handlers_test.go @@ -0,0 +1,254 @@ +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)) + } +} diff --git a/internal/api/ods_handlers_test.go b/internal/api/ods_handlers_test.go new file mode 100644 index 0000000..596997e --- /dev/null +++ b/internal/api/ods_handlers_test.go @@ -0,0 +1,90 @@ +package api + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/go-chi/chi/v5" + "github.com/kindredsystems/silo/internal/db" +) + +func newODSRouter(s *Server) http.Handler { + r := chi.NewRouter() + r.Get("/api/items/export.ods", s.HandleExportODS) + r.Get("/api/items/template.ods", s.HandleODSTemplate) + r.Post("/api/items/import.ods", s.HandleImportODS) + r.Get("/api/projects/{code}/sheet.ods", s.HandleProjectSheetODS) + return r +} + +func TestHandleExportODS(t *testing.T) { + s := newTestServerWithSchemas(t) + router := newODSRouter(s) + + createItemDirect(t, s, "ODS-001", "ods export item", nil) + + req := httptest.NewRequest("GET", "/api/items/export.ods", 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, "application/vnd.oasis.opendocument.spreadsheet") { + t.Errorf("content-type: got %q, want ODS type", ct) + } + + // ODS is a ZIP file — first 2 bytes should be PK + body := w.Body.Bytes() + if len(body) < 2 || body[0] != 'P' || body[1] != 'K' { + t.Error("response body does not start with PK (ZIP magic)") + } +} + +func TestHandleODSTemplate(t *testing.T) { + s := newTestServerWithSchemas(t) + router := newODSRouter(s) + + req := httptest.NewRequest("GET", "/api/items/template.ods?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, "application/vnd.oasis.opendocument.spreadsheet") { + t.Errorf("content-type: got %q, want ODS type", ct) + } +} + +func TestHandleProjectSheetODS(t *testing.T) { + s := newTestServerWithSchemas(t) + router := newODSRouter(s) + + // Create project and item + ctx := httptest.NewRequest("GET", "/", nil).Context() + proj := &db.Project{Code: "ODSPR", Name: "ODS Project"} + s.projects.Create(ctx, proj) + createItemDirect(t, s, "ODSPR-001", "project sheet item", nil) + item, _ := s.items.GetByPartNumber(ctx, "ODSPR-001") + s.projects.AddItemToProject(ctx, item.ID, proj.ID) + + req := httptest.NewRequest("GET", "/api/projects/ODSPR/sheet.ods", 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, "application/vnd.oasis.opendocument.spreadsheet") { + t.Errorf("content-type: got %q, want ODS type", ct) + } +}