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
This commit is contained in:
Forbes
2026-02-13 15:18:46 -06:00
parent c9b081b8f8
commit 7c838bdf5e
2 changed files with 194 additions and 5 deletions

View File

@@ -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)
}
}

View File

@@ -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())
}
}