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:
106
internal/api/audit_handlers_test.go
Normal file
106
internal/api/audit_handlers_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
206
internal/api/auth_handlers_test.go
Normal file
206
internal/api/auth_handlers_test.go
Normal 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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
254
internal/api/csv_handlers_test.go
Normal file
254
internal/api/csv_handlers_test.go
Normal 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))
|
||||
}
|
||||
}
|
||||
186
internal/api/file_handlers_test.go
Normal file
186
internal/api/file_handlers_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
90
internal/api/ods_handlers_test.go
Normal file
90
internal/api/ods_handlers_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
222
internal/api/revision_handlers_test.go
Normal file
222
internal/api/revision_handlers_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
100
internal/api/schema_handlers_test.go
Normal file
100
internal/api/schema_handlers_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
121
internal/db/item_files_test.go
Normal file
121
internal/db/item_files_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
281
internal/db/items_edge_test.go
Normal file
281
internal/db/items_edge_test.go
Normal 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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user