Merge pull request 'test: add test coverage for DB, file handlers, CSV/ODS, and API endpoints' (#86) from test-coverage-batch into main

Reviewed-on: #86
This commit was merged in pull request #86.
This commit is contained in:
2026-02-14 14:50:38 +00:00
10 changed files with 1574 additions and 5 deletions

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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