diff --git a/internal/api/audit_handlers_test.go b/internal/api/audit_handlers_test.go new file mode 100644 index 0000000..c9e06c4 --- /dev/null +++ b/internal/api/audit_handlers_test.go @@ -0,0 +1,106 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" +) + +func newAuditRouter(s *Server) http.Handler { + r := chi.NewRouter() + r.Get("/api/audit/completeness", s.HandleAuditCompleteness) + r.Get("/api/audit/completeness/{partNumber}", s.HandleAuditItemDetail) + return r +} + +func TestHandleAuditCompletenessEmpty(t *testing.T) { + s := newTestServerWithSchemas(t) + router := newAuditRouter(s) + + req := httptest.NewRequest("GET", "/api/audit/completeness", 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()) + } +} + +func TestHandleAuditCompleteness(t *testing.T) { + s := newTestServerWithSchemas(t) + router := newAuditRouter(s) + + createItemDirect(t, s, "AUD-001", "audit item 1", nil) + createItemDirect(t, s, "AUD-002", "audit item 2", nil) + + req := httptest.NewRequest("GET", "/api/audit/completeness", 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()) + } + + var resp map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decoding response: %v", err) + } + // Should have items array + items, ok := resp["items"] + if !ok { + t.Fatal("response missing 'items' key") + } + itemList, ok := items.([]any) + if !ok { + t.Fatal("'items' is not an array") + } + if len(itemList) < 2 { + t.Errorf("expected at least 2 audit items, got %d", len(itemList)) + } +} + +func TestHandleAuditItemDetail(t *testing.T) { + s := newTestServerWithSchemas(t) + router := newAuditRouter(s) + + cost := 50.0 + createItemDirect(t, s, "AUDDET-001", "audit detail item", &cost) + + req := httptest.NewRequest("GET", "/api/audit/completeness/AUDDET-001", 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()) + } + + var resp map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decoding response: %v", err) + } + if resp["part_number"] != "AUDDET-001" { + t.Errorf("part_number: got %v, want %q", resp["part_number"], "AUDDET-001") + } + if _, ok := resp["score"]; !ok { + t.Error("response missing 'score' field") + } + if _, ok := resp["tier"]; !ok { + t.Error("response missing 'tier' field") + } +} + +func TestHandleAuditItemDetailNotFound(t *testing.T) { + s := newTestServerWithSchemas(t) + router := newAuditRouter(s) + + req := httptest.NewRequest("GET", "/api/audit/completeness/NOPE-999", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("status: got %d, want %d", w.Code, http.StatusNotFound) + } +} diff --git a/internal/api/auth_handlers_test.go b/internal/api/auth_handlers_test.go new file mode 100644 index 0000000..323a437 --- /dev/null +++ b/internal/api/auth_handlers_test.go @@ -0,0 +1,206 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/go-chi/chi/v5" + "github.com/kindredsystems/silo/internal/auth" + "github.com/kindredsystems/silo/internal/db" + "github.com/kindredsystems/silo/internal/schema" + "github.com/kindredsystems/silo/internal/testutil" + "github.com/rs/zerolog" +) + +// newAuthTestServer creates a Server with a real auth service (for token tests). +func newAuthTestServer(t *testing.T) *Server { + t.Helper() + pool := testutil.MustConnectTestPool(t) + database := db.NewFromPool(pool) + users := db.NewUserRepository(database) + tokens := db.NewTokenRepository(database) + authSvc := auth.NewService(zerolog.Nop(), users, tokens) + broker := NewBroker(zerolog.Nop()) + state := NewServerState(zerolog.Nop(), nil, broker) + return NewServer( + zerolog.Nop(), + database, + map[string]*schema.Schema{}, + "", // schemasDir + nil, // storage + authSvc, // authService + nil, // sessionManager + nil, // oidcBackend + nil, // authConfig + broker, + state, + ) +} + +// ensureTestUser creates a user in the DB and returns their ID. +func ensureTestUser(t *testing.T, s *Server, username string) string { + t.Helper() + u := &db.User{ + Username: username, + DisplayName: "Test " + username, + Email: username + "@test.local", + AuthSource: "local", + Role: "admin", + } + users := db.NewUserRepository(s.db) + if err := users.Upsert(context.Background(), u); err != nil { + t.Fatalf("upserting user: %v", err) + } + return u.ID +} + +func newAuthRouter(s *Server) http.Handler { + r := chi.NewRouter() + r.Get("/api/auth/me", s.HandleGetCurrentUser) + r.Post("/api/auth/tokens", s.HandleCreateToken) + r.Get("/api/auth/tokens", s.HandleListTokens) + r.Delete("/api/auth/tokens/{id}", s.HandleRevokeToken) + r.Get("/api/auth/config", s.HandleAuthConfig) + return r +} + +func TestHandleGetCurrentUser(t *testing.T) { + s := newTestServer(t) + router := newAuthRouter(s) + + req := authRequest(httptest.NewRequest("GET", "/api/auth/me", 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()) + } + + var resp map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decoding response: %v", err) + } + if resp["username"] != "testadmin" { + t.Errorf("username: got %v, want %q", resp["username"], "testadmin") + } + if resp["role"] != "admin" { + t.Errorf("role: got %v, want %q", resp["role"], "admin") + } +} + +func TestHandleGetCurrentUserUnauth(t *testing.T) { + s := newTestServer(t) + router := newAuthRouter(s) + + // No auth context + req := httptest.NewRequest("GET", "/api/auth/me", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("status: got %d, want %d", w.Code, http.StatusUnauthorized) + } +} + +func TestHandleAuthConfig(t *testing.T) { + s := newTestServer(t) + router := newAuthRouter(s) + + req := httptest.NewRequest("GET", "/api/auth/config", 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()) + } + + var resp map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decoding response: %v", err) + } + // With nil oidc and nil authConfig, both should be false + if resp["oidc_enabled"] != false { + t.Errorf("oidc_enabled: got %v, want false", resp["oidc_enabled"]) + } +} + +func TestHandleCreateAndListTokens(t *testing.T) { + s := newAuthTestServer(t) + router := newAuthRouter(s) + + // Create a user in the DB so token generation can associate + userID := ensureTestUser(t, s, "tokenuser") + + // Inject user with the DB-assigned ID + u := &auth.User{ + ID: userID, + Username: "tokenuser", + DisplayName: "Test tokenuser", + Role: auth.RoleAdmin, + AuthSource: "local", + } + + // Create token + body := `{"name":"test-token"}` + req := httptest.NewRequest("POST", "/api/auth/tokens", strings.NewReader(body)) + req = req.WithContext(auth.ContextWithUser(req.Context(), u)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusCreated { + t.Fatalf("create token status: got %d, want %d; body: %s", w.Code, http.StatusCreated, w.Body.String()) + } + + var createResp map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &createResp); err != nil { + t.Fatalf("decoding create response: %v", err) + } + if createResp["token"] == nil || createResp["token"] == "" { + t.Error("expected token in response") + } + tokenID, _ := createResp["id"].(string) + + // List tokens + req = httptest.NewRequest("GET", "/api/auth/tokens", nil) + req = req.WithContext(auth.ContextWithUser(req.Context(), u)) + w = httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("list tokens status: got %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String()) + } + + var tokens []map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &tokens); err != nil { + t.Fatalf("decoding list response: %v", err) + } + if len(tokens) != 1 { + t.Errorf("expected 1 token, got %d", len(tokens)) + } + + // Revoke token + req = httptest.NewRequest("DELETE", "/api/auth/tokens/"+tokenID, nil) + req = req.WithContext(auth.ContextWithUser(req.Context(), u)) + w = httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusNoContent { + t.Errorf("revoke token status: got %d, want %d; body: %s", w.Code, http.StatusNoContent, w.Body.String()) + } + + // List again — should be empty + req = httptest.NewRequest("GET", "/api/auth/tokens", nil) + req = req.WithContext(auth.ContextWithUser(req.Context(), u)) + w = httptest.NewRecorder() + router.ServeHTTP(w, req) + + json.Unmarshal(w.Body.Bytes(), &tokens) + if len(tokens) != 0 { + t.Errorf("expected 0 tokens after revoke, got %d", len(tokens)) + } +} diff --git a/internal/api/bom_handlers_test.go b/internal/api/bom_handlers_test.go index 0e94b3e..d928351 100644 --- a/internal/api/bom_handlers_test.go +++ b/internal/api/bom_handlers_test.go @@ -55,12 +55,15 @@ func newTestRouter(s *Server) http.Handler { func createItemDirect(t *testing.T, s *Server, pn, desc string, cost *float64) { t.Helper() item := &db.Item{ - PartNumber: pn, - ItemType: "part", - Description: desc, - StandardCost: cost, + PartNumber: pn, + ItemType: "part", + Description: desc, } - if err := s.items.Create(context.Background(), item, nil); err != nil { + var props map[string]any + if cost != nil { + props = map[string]any{"standard_cost": *cost} + } + if err := s.items.Create(context.Background(), item, props); err != nil { t.Fatalf("creating item %s: %v", pn, err) } } 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/file_handlers_test.go b/internal/api/file_handlers_test.go new file mode 100644 index 0000000..38405de --- /dev/null +++ b/internal/api/file_handlers_test.go @@ -0,0 +1,186 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/go-chi/chi/v5" + "github.com/kindredsystems/silo/internal/db" +) + +// newFileRouter creates a chi router with file-related routes for testing. +func newFileRouter(s *Server) http.Handler { + r := chi.NewRouter() + r.Route("/api/items/{partNumber}", func(r chi.Router) { + r.Get("/files", s.HandleListItemFiles) + r.Post("/files", s.HandleAssociateItemFile) + r.Delete("/files/{fileId}", s.HandleDeleteItemFile) + r.Put("/thumbnail", s.HandleSetItemThumbnail) + r.Post("/file", s.HandleUploadFile) + r.Get("/file/{revision}", s.HandleDownloadFile) + }) + r.Post("/api/uploads/presign", s.HandlePresignUpload) + return r +} + +// createFileDirect creates a file record directly via the DB for test setup. +func createFileDirect(t *testing.T, s *Server, itemID, filename string) *db.ItemFile { + t.Helper() + f := &db.ItemFile{ + ItemID: itemID, + Filename: filename, + ContentType: "application/octet-stream", + Size: 1024, + ObjectKey: "items/" + itemID + "/files/" + filename, + } + if err := s.itemFiles.Create(context.Background(), f); err != nil { + t.Fatalf("creating file %s: %v", filename, err) + } + return f +} + +func TestHandleListItemFiles(t *testing.T) { + s := newTestServer(t) + router := newFileRouter(s) + + createItemDirect(t, s, "FAPI-001", "file list item", nil) + item, _ := s.items.GetByPartNumber(context.Background(), "FAPI-001") + + createFileDirect(t, s, item.ID, "drawing.pdf") + createFileDirect(t, s, item.ID, "model.step") + + req := httptest.NewRequest("GET", "/api/items/FAPI-001/files", 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()) + } + + var files []itemFileResponse + if err := json.Unmarshal(w.Body.Bytes(), &files); err != nil { + t.Fatalf("decoding response: %v", err) + } + if len(files) != 2 { + t.Errorf("expected 2 files, got %d", len(files)) + } +} + +func TestHandleListItemFilesNotFound(t *testing.T) { + s := newTestServer(t) + router := newFileRouter(s) + + req := httptest.NewRequest("GET", "/api/items/NONEXISTENT/files", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("status: got %d, want %d", w.Code, http.StatusNotFound) + } +} + +func TestHandleDeleteItemFile(t *testing.T) { + s := newTestServer(t) + router := newFileRouter(s) + + createItemDirect(t, s, "FDEL-API-001", "delete file item", nil) + item, _ := s.items.GetByPartNumber(context.Background(), "FDEL-API-001") + f := createFileDirect(t, s, item.ID, "removable.bin") + + req := authRequest(httptest.NewRequest("DELETE", "/api/items/FDEL-API-001/files/"+f.ID, nil)) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusNoContent { + t.Errorf("status: got %d, want %d; body: %s", w.Code, http.StatusNoContent, w.Body.String()) + } +} + +func TestHandleDeleteItemFileCrossItem(t *testing.T) { + s := newTestServer(t) + router := newFileRouter(s) + + // Create two items, attach file to item A + createItemDirect(t, s, "CROSS-A", "item A", nil) + createItemDirect(t, s, "CROSS-B", "item B", nil) + itemA, _ := s.items.GetByPartNumber(context.Background(), "CROSS-A") + f := createFileDirect(t, s, itemA.ID, "belongs-to-a.pdf") + + // Try to delete via item B — should fail + req := authRequest(httptest.NewRequest("DELETE", "/api/items/CROSS-B/files/"+f.ID, nil)) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("status: got %d, want %d", w.Code, http.StatusNotFound) + } +} + +func TestHandlePresignUploadNoStorage(t *testing.T) { + s := newTestServer(t) // storage is nil + router := newFileRouter(s) + + body := `{"filename":"test.bin","content_type":"application/octet-stream","size":1024}` + req := authRequest(httptest.NewRequest("POST", "/api/uploads/presign", strings.NewReader(body))) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusServiceUnavailable { + t.Errorf("status: got %d, want %d; body: %s", w.Code, http.StatusServiceUnavailable, w.Body.String()) + } +} + +func TestHandleUploadFileNoStorage(t *testing.T) { + s := newTestServer(t) // storage is nil + router := newFileRouter(s) + + createItemDirect(t, s, "UPNS-001", "upload no storage", nil) + + req := authRequest(httptest.NewRequest("POST", "/api/items/UPNS-001/file", strings.NewReader("fake"))) + req.Header.Set("Content-Type", "multipart/form-data") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusServiceUnavailable { + t.Errorf("status: got %d, want %d; body: %s", w.Code, http.StatusServiceUnavailable, w.Body.String()) + } +} + +func TestHandleAssociateFileNoStorage(t *testing.T) { + s := newTestServer(t) // storage is nil + router := newFileRouter(s) + + createItemDirect(t, s, "ASSNS-001", "associate no storage", nil) + + body := `{"object_key":"uploads/tmp/abc/test.bin","filename":"test.bin"}` + req := authRequest(httptest.NewRequest("POST", "/api/items/ASSNS-001/files", strings.NewReader(body))) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusServiceUnavailable { + t.Errorf("status: got %d, want %d; body: %s", w.Code, http.StatusServiceUnavailable, w.Body.String()) + } +} + +func TestHandleSetThumbnailNoStorage(t *testing.T) { + s := newTestServer(t) // storage is nil + router := newFileRouter(s) + + createItemDirect(t, s, "THNS-001", "thumbnail no storage", nil) + + body := `{"object_key":"uploads/tmp/abc/thumb.png"}` + req := authRequest(httptest.NewRequest("PUT", "/api/items/THNS-001/thumbnail", strings.NewReader(body))) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusServiceUnavailable { + t.Errorf("status: got %d, want %d; body: %s", w.Code, http.StatusServiceUnavailable, w.Body.String()) + } +} 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) + } +} diff --git a/internal/api/revision_handlers_test.go b/internal/api/revision_handlers_test.go new file mode 100644 index 0000000..b64d782 --- /dev/null +++ b/internal/api/revision_handlers_test.go @@ -0,0 +1,222 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/go-chi/chi/v5" +) + +func newRevisionRouter(s *Server) http.Handler { + r := chi.NewRouter() + r.Route("/api/items/{partNumber}", func(r chi.Router) { + r.Get("/revisions", s.HandleListRevisions) + r.Get("/revisions/compare", s.HandleCompareRevisions) + r.Get("/revisions/{revision}", s.HandleGetRevision) + r.Post("/revisions", s.HandleCreateRevision) + r.Patch("/revisions/{revision}", s.HandleUpdateRevision) + r.Post("/revisions/{revision}/rollback", s.HandleRollbackRevision) + }) + return r +} + +func TestHandleListRevisions(t *testing.T) { + s := newTestServer(t) + router := newRevisionRouter(s) + + createItemDirect(t, s, "REV-API-001", "revision list", nil) + + req := httptest.NewRequest("GET", "/api/items/REV-API-001/revisions", 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()) + } + + var revisions []RevisionResponse + if err := json.Unmarshal(w.Body.Bytes(), &revisions); err != nil { + t.Fatalf("decoding response: %v", err) + } + if len(revisions) != 1 { + t.Errorf("expected 1 revision (initial), got %d", len(revisions)) + } +} + +func TestHandleListRevisionsNotFound(t *testing.T) { + s := newTestServer(t) + router := newRevisionRouter(s) + + req := httptest.NewRequest("GET", "/api/items/NOEXIST/revisions", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("status: got %d, want %d", w.Code, http.StatusNotFound) + } +} + +func TestHandleGetRevision(t *testing.T) { + s := newTestServer(t) + router := newRevisionRouter(s) + + createItemDirect(t, s, "REVGET-001", "get revision", nil) + + req := httptest.NewRequest("GET", "/api/items/REVGET-001/revisions/1", 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()) + } + + var rev RevisionResponse + if err := json.Unmarshal(w.Body.Bytes(), &rev); err != nil { + t.Fatalf("decoding response: %v", err) + } + if rev.RevisionNumber != 1 { + t.Errorf("revision_number: got %d, want 1", rev.RevisionNumber) + } +} + +func TestHandleGetRevisionNotFound(t *testing.T) { + s := newTestServer(t) + router := newRevisionRouter(s) + + createItemDirect(t, s, "REVNF-001", "rev not found", nil) + + req := httptest.NewRequest("GET", "/api/items/REVNF-001/revisions/99", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("status: got %d, want %d", w.Code, http.StatusNotFound) + } +} + +func TestHandleCreateRevision(t *testing.T) { + s := newTestServer(t) + router := newRevisionRouter(s) + + createItemDirect(t, s, "REVCR-001", "create revision", nil) + + body := `{"properties":{"material":"steel"},"comment":"added material"}` + req := authRequest(httptest.NewRequest("POST", "/api/items/REVCR-001/revisions", strings.NewReader(body))) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusCreated { + t.Fatalf("status: got %d, want %d; body: %s", w.Code, http.StatusCreated, w.Body.String()) + } + + var rev RevisionResponse + if err := json.Unmarshal(w.Body.Bytes(), &rev); err != nil { + t.Fatalf("decoding response: %v", err) + } + if rev.RevisionNumber != 2 { + t.Errorf("revision_number: got %d, want 2", rev.RevisionNumber) + } +} + +func TestHandleUpdateRevision(t *testing.T) { + s := newTestServer(t) + router := newRevisionRouter(s) + + createItemDirect(t, s, "REVUP-001", "update revision", nil) + + body := `{"status":"released","labels":["production"]}` + req := authRequest(httptest.NewRequest("PATCH", "/api/items/REVUP-001/revisions/1", strings.NewReader(body))) + req.Header.Set("Content-Type", "application/json") + 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 rev RevisionResponse + if err := json.Unmarshal(w.Body.Bytes(), &rev); err != nil { + t.Fatalf("decoding response: %v", err) + } + if rev.Status != "released" { + t.Errorf("status: got %q, want %q", rev.Status, "released") + } + if len(rev.Labels) != 1 || rev.Labels[0] != "production" { + t.Errorf("labels: got %v, want [production]", rev.Labels) + } +} + +func TestHandleCompareRevisions(t *testing.T) { + s := newTestServer(t) + router := newRevisionRouter(s) + + // Create item with properties, then create second revision with changed properties + cost := 10.0 + createItemDirect(t, s, "REVCMP-001", "compare revisions", &cost) + + body := `{"properties":{"standard_cost":20,"material":"aluminum"},"comment":"updated cost"}` + req := authRequest(httptest.NewRequest("POST", "/api/items/REVCMP-001/revisions", strings.NewReader(body))) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != http.StatusCreated { + t.Fatalf("create rev 2: status %d; body: %s", w.Code, w.Body.String()) + } + + // Compare rev 1 vs rev 2 + req = httptest.NewRequest("GET", "/api/items/REVCMP-001/revisions/compare?from=1&to=2", 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()) + } + + var diff RevisionDiffResponse + if err := json.Unmarshal(w.Body.Bytes(), &diff); err != nil { + t.Fatalf("decoding response: %v", err) + } + if diff.FromRevision != 1 || diff.ToRevision != 2 { + t.Errorf("revisions: got from=%d to=%d, want from=1 to=2", diff.FromRevision, diff.ToRevision) + } +} + +func TestHandleRollbackRevision(t *testing.T) { + s := newTestServer(t) + router := newRevisionRouter(s) + + createItemDirect(t, s, "REVRB-001", "rollback test", nil) + + // Create rev 2 + body := `{"properties":{"version":"v2"},"comment":"version 2"}` + req := authRequest(httptest.NewRequest("POST", "/api/items/REVRB-001/revisions", strings.NewReader(body))) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != http.StatusCreated { + t.Fatalf("create rev 2: status %d; body: %s", w.Code, w.Body.String()) + } + + // Rollback to rev 1 — should create rev 3 + body = `{"comment":"rolling back"}` + req = authRequest(httptest.NewRequest("POST", "/api/items/REVRB-001/revisions/1/rollback", strings.NewReader(body))) + req.Header.Set("Content-Type", "application/json") + w = httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusCreated { + t.Fatalf("status: got %d, want %d; body: %s", w.Code, http.StatusCreated, w.Body.String()) + } + + var rev RevisionResponse + if err := json.Unmarshal(w.Body.Bytes(), &rev); err != nil { + t.Fatalf("decoding response: %v", err) + } + if rev.RevisionNumber != 3 { + t.Errorf("revision_number: got %d, want 3", rev.RevisionNumber) + } +} diff --git a/internal/api/schema_handlers_test.go b/internal/api/schema_handlers_test.go new file mode 100644 index 0000000..19794d5 --- /dev/null +++ b/internal/api/schema_handlers_test.go @@ -0,0 +1,100 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" +) + +func newSchemaRouter(s *Server) http.Handler { + r := chi.NewRouter() + r.Get("/api/schemas", s.HandleListSchemas) + r.Get("/api/schemas/{name}", s.HandleGetSchema) + r.Get("/api/schemas/{name}/form", s.HandleGetFormDescriptor) + return r +} + +func TestHandleListSchemas(t *testing.T) { + s := newTestServerWithSchemas(t) + router := newSchemaRouter(s) + + req := httptest.NewRequest("GET", "/api/schemas", 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()) + } + + var schemas []map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &schemas); err != nil { + t.Fatalf("decoding response: %v", err) + } + if len(schemas) == 0 { + t.Error("expected at least 1 schema") + } +} + +func TestHandleGetSchema(t *testing.T) { + s := newTestServerWithSchemas(t) + router := newSchemaRouter(s) + + req := httptest.NewRequest("GET", "/api/schemas/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()) + } + + var schema map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &schema); err != nil { + t.Fatalf("decoding response: %v", err) + } + if schema["name"] != "kindred-rd" { + t.Errorf("name: got %v, want %q", schema["name"], "kindred-rd") + } +} + +func TestHandleGetSchemaNotFound(t *testing.T) { + s := newTestServerWithSchemas(t) + router := newSchemaRouter(s) + + req := httptest.NewRequest("GET", "/api/schemas/nonexistent", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("status: got %d, want %d", w.Code, http.StatusNotFound) + } +} + +func TestHandleGetFormDescriptor(t *testing.T) { + s := newTestServerWithSchemas(t) + router := newSchemaRouter(s) + + req := httptest.NewRequest("GET", "/api/schemas/kindred-rd/form", 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()) + } + + var form map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &form); err != nil { + t.Fatalf("decoding response: %v", err) + } + // Form descriptor should have fields + if _, ok := form["fields"]; !ok { + // Some schemas may use "categories" or "segments" instead + if _, ok := form["categories"]; !ok { + if _, ok := form["segments"]; !ok { + t.Error("form descriptor missing fields/categories/segments key") + } + } + } +} diff --git a/internal/db/item_files_test.go b/internal/db/item_files_test.go new file mode 100644 index 0000000..ea26865 --- /dev/null +++ b/internal/db/item_files_test.go @@ -0,0 +1,121 @@ +package db + +import ( + "context" + "testing" +) + +func TestItemFileCreate(t *testing.T) { + database := mustConnectTestDB(t) + itemRepo := NewItemRepository(database) + fileRepo := NewItemFileRepository(database) + ctx := context.Background() + + item := &Item{PartNumber: "FILE-001", ItemType: "part", Description: "file test"} + if err := itemRepo.Create(ctx, item, nil); err != nil { + t.Fatalf("Create item: %v", err) + } + + f := &ItemFile{ + ItemID: item.ID, + Filename: "drawing.pdf", + ContentType: "application/pdf", + Size: 12345, + ObjectKey: "items/FILE-001/files/abc/drawing.pdf", + } + if err := fileRepo.Create(ctx, f); err != nil { + t.Fatalf("Create file: %v", err) + } + if f.ID == "" { + t.Error("expected file ID to be set") + } + if f.CreatedAt.IsZero() { + t.Error("expected created_at to be set") + } +} + +func TestItemFileListByItem(t *testing.T) { + database := mustConnectTestDB(t) + itemRepo := NewItemRepository(database) + fileRepo := NewItemFileRepository(database) + ctx := context.Background() + + item := &Item{PartNumber: "FLIST-001", ItemType: "part", Description: "file list test"} + itemRepo.Create(ctx, item, nil) + + for i, name := range []string{"a.pdf", "b.step"} { + fileRepo.Create(ctx, &ItemFile{ + ItemID: item.ID, + Filename: name, + ContentType: "application/octet-stream", + Size: int64(i * 1000), + ObjectKey: "items/FLIST-001/files/" + name, + }) + } + + files, err := fileRepo.ListByItem(ctx, item.ID) + if err != nil { + t.Fatalf("ListByItem: %v", err) + } + if len(files) != 2 { + t.Errorf("expected 2 files, got %d", len(files)) + } +} + +func TestItemFileGet(t *testing.T) { + database := mustConnectTestDB(t) + itemRepo := NewItemRepository(database) + fileRepo := NewItemFileRepository(database) + ctx := context.Background() + + item := &Item{PartNumber: "FGET-001", ItemType: "part", Description: "file get test"} + itemRepo.Create(ctx, item, nil) + + f := &ItemFile{ + ItemID: item.ID, + Filename: "model.FCStd", + ContentType: "application/x-freecad", + Size: 99999, + ObjectKey: "items/FGET-001/files/xyz/model.FCStd", + } + fileRepo.Create(ctx, f) + + got, err := fileRepo.Get(ctx, f.ID) + if err != nil { + t.Fatalf("Get: %v", err) + } + if got.Filename != "model.FCStd" { + t.Errorf("filename: got %q, want %q", got.Filename, "model.FCStd") + } + if got.Size != 99999 { + t.Errorf("size: got %d, want %d", got.Size, 99999) + } +} + +func TestItemFileDelete(t *testing.T) { + database := mustConnectTestDB(t) + itemRepo := NewItemRepository(database) + fileRepo := NewItemFileRepository(database) + ctx := context.Background() + + item := &Item{PartNumber: "FDEL-001", ItemType: "part", Description: "file delete test"} + itemRepo.Create(ctx, item, nil) + + f := &ItemFile{ + ItemID: item.ID, + Filename: "temp.bin", + ContentType: "application/octet-stream", + Size: 100, + ObjectKey: "items/FDEL-001/files/tmp/temp.bin", + } + fileRepo.Create(ctx, f) + + if err := fileRepo.Delete(ctx, f.ID); err != nil { + t.Fatalf("Delete: %v", err) + } + + _, err := fileRepo.Get(ctx, f.ID) + if err == nil { + t.Error("expected error after delete, got nil") + } +} diff --git a/internal/db/items_edge_test.go b/internal/db/items_edge_test.go new file mode 100644 index 0000000..a851d20 --- /dev/null +++ b/internal/db/items_edge_test.go @@ -0,0 +1,281 @@ +package db + +import ( + "context" + "fmt" + "strings" + "testing" +) + +func TestItemCreateDuplicatePartNumber(t *testing.T) { + database := mustConnectTestDB(t) + repo := NewItemRepository(database) + ctx := context.Background() + + item := &Item{PartNumber: "DUP-001", ItemType: "part", Description: "first"} + if err := repo.Create(ctx, item, nil); err != nil { + t.Fatalf("Create: %v", err) + } + + dup := &Item{PartNumber: "DUP-001", ItemType: "part", Description: "duplicate"} + err := repo.Create(ctx, dup, nil) + if err == nil { + t.Fatal("expected error for duplicate part number, got nil") + } + if !strings.Contains(err.Error(), "23505") && !strings.Contains(err.Error(), "duplicate") { + t.Errorf("expected duplicate key error, got: %v", err) + } +} + +func TestItemDelete(t *testing.T) { + database := mustConnectTestDB(t) + repo := NewItemRepository(database) + ctx := context.Background() + + item := &Item{PartNumber: "HDEL-001", ItemType: "part", Description: "hard delete"} + if err := repo.Create(ctx, item, nil); err != nil { + t.Fatalf("Create: %v", err) + } + + if err := repo.Delete(ctx, item.ID); err != nil { + t.Fatalf("Delete: %v", err) + } + + got, err := repo.GetByID(ctx, item.ID) + if err != nil { + t.Fatalf("GetByID after delete: %v", err) + } + if got != nil { + t.Error("expected nil after hard delete") + } +} + +func TestItemListPagination(t *testing.T) { + database := mustConnectTestDB(t) + repo := NewItemRepository(database) + ctx := context.Background() + + for i := 0; i < 5; i++ { + item := &Item{ + PartNumber: fmt.Sprintf("PAGE-%04d", i), + ItemType: "part", + Description: fmt.Sprintf("page item %d", i), + } + if err := repo.Create(ctx, item, nil); err != nil { + t.Fatalf("Create #%d: %v", i, err) + } + } + + // Fetch page of 2 with offset 2 + items, err := repo.List(ctx, ListOptions{Limit: 2, Offset: 2}) + if err != nil { + t.Fatalf("List: %v", err) + } + if len(items) != 2 { + t.Errorf("expected 2 items, got %d", len(items)) + } +} + +func TestItemListSearch(t *testing.T) { + database := mustConnectTestDB(t) + repo := NewItemRepository(database) + ctx := context.Background() + + repo.Create(ctx, &Item{PartNumber: "SRCH-001", ItemType: "part", Description: "alpha widget"}, nil) + repo.Create(ctx, &Item{PartNumber: "SRCH-002", ItemType: "part", Description: "beta gadget"}, nil) + repo.Create(ctx, &Item{PartNumber: "SRCH-003", ItemType: "part", Description: "alpha gizmo"}, nil) + + items, err := repo.List(ctx, ListOptions{Search: "alpha"}) + if err != nil { + t.Fatalf("List: %v", err) + } + if len(items) != 2 { + t.Errorf("expected 2 items matching 'alpha', got %d", len(items)) + } +} + +func TestRevisionStatusUpdate(t *testing.T) { + database := mustConnectTestDB(t) + repo := NewItemRepository(database) + ctx := context.Background() + + item := &Item{PartNumber: "STAT-001", ItemType: "part", Description: "status test"} + if err := repo.Create(ctx, item, map[string]any{"v": 1}); err != nil { + t.Fatalf("Create: %v", err) + } + + status := "released" + if err := repo.UpdateRevisionStatus(ctx, item.ID, 1, &status, nil); err != nil { + t.Fatalf("UpdateRevisionStatus: %v", err) + } + + rev, err := repo.GetRevision(ctx, item.ID, 1) + if err != nil { + t.Fatalf("GetRevision: %v", err) + } + if rev.Status != "released" { + t.Errorf("status: got %q, want %q", rev.Status, "released") + } +} + +func TestRevisionLabelsUpdate(t *testing.T) { + database := mustConnectTestDB(t) + repo := NewItemRepository(database) + ctx := context.Background() + + item := &Item{PartNumber: "LBL-001", ItemType: "part", Description: "label test"} + if err := repo.Create(ctx, item, nil); err != nil { + t.Fatalf("Create: %v", err) + } + + labels := []string{"prototype", "urgent"} + if err := repo.UpdateRevisionStatus(ctx, item.ID, 1, nil, labels); err != nil { + t.Fatalf("UpdateRevisionStatus: %v", err) + } + + rev, err := repo.GetRevision(ctx, item.ID, 1) + if err != nil { + t.Fatalf("GetRevision: %v", err) + } + if len(rev.Labels) != 2 { + t.Errorf("labels count: got %d, want 2", len(rev.Labels)) + } +} + +func TestRevisionCompare(t *testing.T) { + database := mustConnectTestDB(t) + repo := NewItemRepository(database) + ctx := context.Background() + + item := &Item{PartNumber: "CMP-001", ItemType: "part", Description: "compare test"} + if err := repo.Create(ctx, item, map[string]any{"color": "red", "weight": 10}); err != nil { + t.Fatalf("Create: %v", err) + } + + // Rev 2: change color, remove weight, add size + repo.CreateRevision(ctx, &Revision{ + ItemID: item.ID, + Properties: map[string]any{"color": "blue", "size": "large"}, + }) + + diff, err := repo.CompareRevisions(ctx, item.ID, 1, 2) + if err != nil { + t.Fatalf("CompareRevisions: %v", err) + } + + if len(diff.Added) == 0 { + t.Error("expected added fields (size)") + } + if len(diff.Removed) == 0 { + t.Error("expected removed fields (weight)") + } + if len(diff.Changed) == 0 { + t.Error("expected changed fields (color)") + } +} + +func TestRevisionRollback(t *testing.T) { + database := mustConnectTestDB(t) + repo := NewItemRepository(database) + ctx := context.Background() + + item := &Item{PartNumber: "RBK-001", ItemType: "part", Description: "rollback test"} + if err := repo.Create(ctx, item, map[string]any{"version": "original"}); err != nil { + t.Fatalf("Create: %v", err) + } + + // Rev 2: change property + repo.CreateRevision(ctx, &Revision{ + ItemID: item.ID, + Properties: map[string]any{"version": "modified"}, + }) + + // Rollback to rev 1 — should create rev 3 + comment := "rollback to rev 1" + rev3, err := repo.CreateRevisionFromExisting(ctx, item.ID, 1, comment, nil) + if err != nil { + t.Fatalf("CreateRevisionFromExisting: %v", err) + } + if rev3.RevisionNumber != 3 { + t.Errorf("revision number: got %d, want 3", rev3.RevisionNumber) + } + + // Rev 3 should have rev 1's properties + got, err := repo.GetRevision(ctx, item.ID, 3) + if err != nil { + t.Fatalf("GetRevision: %v", err) + } + if got.Properties["version"] != "original" { + t.Errorf("rolled back version: got %v, want %q", got.Properties["version"], "original") + } +} + +func TestProjectItemAssociationsByCode(t *testing.T) { + database := mustConnectTestDB(t) + projRepo := NewProjectRepository(database) + itemRepo := NewItemRepository(database) + ctx := context.Background() + + proj := &Project{Code: "BYTAG", Name: "Tag Project"} + projRepo.Create(ctx, proj) + + item := &Item{PartNumber: "TAG-001", ItemType: "part", Description: "taggable"} + itemRepo.Create(ctx, item, nil) + + // Tag by code + if err := projRepo.AddItemToProjectByCode(ctx, item.ID, "BYTAG"); err != nil { + t.Fatalf("AddItemToProjectByCode: %v", err) + } + + projects, err := projRepo.GetProjectsForItem(ctx, item.ID) + if err != nil { + t.Fatalf("GetProjectsForItem: %v", err) + } + if len(projects) != 1 { + t.Fatalf("expected 1 project, got %d", len(projects)) + } + if projects[0].Code != "BYTAG" { + t.Errorf("project code: got %q, want %q", projects[0].Code, "BYTAG") + } + + // Untag by code + if err := projRepo.RemoveItemFromProjectByCode(ctx, item.ID, "BYTAG"); err != nil { + t.Fatalf("RemoveItemFromProjectByCode: %v", err) + } + + projects, _ = projRepo.GetProjectsForItem(ctx, item.ID) + if len(projects) != 0 { + t.Errorf("expected 0 projects after removal, got %d", len(projects)) + } +} + +func TestListByProject(t *testing.T) { + database := mustConnectTestDB(t) + projRepo := NewProjectRepository(database) + itemRepo := NewItemRepository(database) + ctx := context.Background() + + proj := &Project{Code: "FILT", Name: "Filter Project"} + projRepo.Create(ctx, proj) + + // Create 3 items, tag only 2 + for i := 0; i < 3; i++ { + item := &Item{ + PartNumber: fmt.Sprintf("FILT-%04d", i), + ItemType: "part", + Description: fmt.Sprintf("filter item %d", i), + } + itemRepo.Create(ctx, item, nil) + if i < 2 { + projRepo.AddItemToProjectByCode(ctx, item.ID, "FILT") + } + } + + items, err := itemRepo.List(ctx, ListOptions{Project: "FILT"}) + if err != nil { + t.Fatalf("List with project filter: %v", err) + } + if len(items) != 2 { + t.Errorf("expected 2 items in project FILT, got %d", len(items)) + } +}