test(api): add CSV and ODS import/export handler tests (#77)
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
This commit is contained in:
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))
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user