CSV tests: - Export empty/with items, template generation - Import dry-run (preview without creating), commit (items created) - BOM CSV export with parent/child relationships ODS tests: - Export with items (verify ODS content type and ZIP magic bytes) - Template generation per schema - Project sheet export with item associations
255 lines
7.0 KiB
Go
255 lines
7.0 KiB
Go
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))
|
|
}
|
|
}
|