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, nil, // jobDefs "", // jobDefsDir ) } 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)) } }