From 7c838bdf5ebd17619a9b0b40f30cd57a11818da8 Mon Sep 17 00:00:00 2001 From: Forbes Date: Fri, 13 Feb 2026 15:18:46 -0600 Subject: [PATCH] test(api): add file handler tests and fix createItemDirect helper (#76) - Test ListItemFiles, DeleteItemFile with real DB - Test cross-item file deletion guard (404) - Test storage-unavailable paths: presign, upload, associate, thumbnail (503) - Fix createItemDirect: StandardCost moved to revision properties --- internal/api/bom_handlers_test.go | 13 +- internal/api/file_handlers_test.go | 186 +++++++++++++++++++++++++++++ 2 files changed, 194 insertions(+), 5 deletions(-) create mode 100644 internal/api/file_handlers_test.go 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/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()) + } +}