package api import ( "archive/zip" "bytes" "encoding/json" "fmt" "io" "mime/multipart" "net/http" "os" "testing" "time" "github.com/google/uuid" "github.com/mish/dealspace/lib" ) // createTestProjectWithAccess creates a project and grants the user IB admin access func createTestProjectWithAccess(t *testing.T, db *lib.DB, cfg *lib.Config, userID string) string { t.Helper() projectID := uuid.New().String() now := time.Now().UnixMilli() // Create project entry key, _ := lib.DeriveProjectKey(cfg.MasterKey, projectID) summary, _ := lib.Pack(key, "Test Import Project") data, _ := lib.Pack(key, `{"name": "Test Import Project"}`) db.Conn.Exec( `INSERT INTO entries (entry_id, project_id, parent_id, type, depth, search_key, search_key2, summary, data, stage, assignee_id, return_to_id, origin_id, version, deleted_at, deleted_by, key_version, created_at, updated_at, created_by) VALUES (?,?,?,?,?, ?,?,?,?,?, ?,?,?, ?,?,?,?, ?,?,?)`, projectID, projectID, "", lib.TypeProject, 0, nil, nil, summary, data, lib.StagePreDataroom, "", "", "", 1, nil, nil, 1, now, now, userID, ) // Grant access lib.AccessGrant(db, &lib.Access{ ID: uuid.New().String(), ProjectID: projectID, UserID: userID, Role: lib.RoleIBAdmin, Ops: "rwdm", CanGrant: true, GrantedBy: "system", GrantedAt: now, }) return projectID } // uploadFile performs a multipart file upload func uploadFile(t *testing.T, client *http.Client, url, token, filename, contentType string, content []byte, formFields map[string]string) *http.Response { t.Helper() var buf bytes.Buffer writer := multipart.NewWriter(&buf) // Add form fields for key, val := range formFields { writer.WriteField(key, val) } // Add file part, err := writer.CreateFormFile("file", filename) if err != nil { t.Fatalf("create form file: %v", err) } part.Write(content) writer.Close() req, _ := http.NewRequest("POST", url, &buf) req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Content-Type", writer.FormDataContentType()) resp, err := client.Do(req) if err != nil { t.Fatalf("upload request failed: %v", err) } return resp } func TestImportRequestsCSV(t *testing.T) { server, db, cfg, cleanup := testServerSetup(t) defer cleanup() client := &http.Client{} // Create user userID := uuid.New().String() now := time.Now().UnixMilli() user := &lib.User{ UserID: userID, Email: "import@test.com", Name: "Import Test", Password: "", Active: true, CreatedAt: now, UpdatedAt: now, } lib.UserCreate(db, user) projectID := createTestProjectWithAccess(t, db, cfg, userID) token := getToken(t, client, server.URL, "import@test.com") // Create a valid CSV csvContent := `Section,Item #,Description,Priority Financial,1.1,Provide audited financial statements,High Financial,1.2,Revenue breakdown by product,Medium Legal,2.1,List of all pending litigation,Critical Legal,2.2,Employment contracts for executives,Low HR,3.1,Organization chart,Nice to have` resp := uploadFile(t, client, server.URL+"/api/projects/"+projectID+"/requests/import", token, "requests.csv", "text/csv", []byte(csvContent), nil) defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) t.Fatalf("expected 200, got %d: %s", resp.StatusCode, string(body)) } var importResp map[string]interface{} json.NewDecoder(resp.Body).Decode(&importResp) imported := int(importResp["imported"].(float64)) if imported != 5 { t.Errorf("expected 5 imported, got %d", imported) } skipped := int(importResp["skipped"].(float64)) if skipped != 0 { t.Errorf("expected 0 skipped, got %d", skipped) } sections := importResp["sections"].([]interface{}) if len(sections) != 3 { t.Errorf("expected 3 sections, got %d: %v", len(sections), sections) } } func TestImportRequestsXLSX(t *testing.T) { server, db, cfg, cleanup := testServerSetup(t) defer cleanup() client := &http.Client{} userID := uuid.New().String() now := time.Now().UnixMilli() user := &lib.User{ UserID: userID, Email: "xlsx@test.com", Name: "XLSX Test", Password: "", Active: true, CreatedAt: now, UpdatedAt: now, } lib.UserCreate(db, user) projectID := createTestProjectWithAccess(t, db, cfg, userID) token := getToken(t, client, server.URL, "xlsx@test.com") // Create a minimal valid XLSX xlsxContent := createMinimalXLSX([][]string{ {"Section", "Item #", "Description", "Priority"}, {"Finance", "F-1", "Cash flow statement", "High"}, {"Finance", "F-2", "Balance sheet", "Medium"}, }) resp := uploadFile(t, client, server.URL+"/api/projects/"+projectID+"/requests/import", token, "requests.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", xlsxContent, nil) defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) t.Fatalf("expected 200, got %d: %s", resp.StatusCode, string(body)) } var importResp map[string]interface{} json.NewDecoder(resp.Body).Decode(&importResp) imported := int(importResp["imported"].(float64)) if imported != 2 { t.Errorf("expected 2 imported, got %d", imported) } } // createMinimalXLSX creates a minimal valid XLSX file from rows func createMinimalXLSX(rows [][]string) []byte { var buf bytes.Buffer zw := zip.NewWriter(&buf) // [Content_Types].xml ct := ` ` w, _ := zw.Create("[Content_Types].xml") w.Write([]byte(ct)) // _rels/.rels rels := ` ` w, _ = zw.Create("_rels/.rels") w.Write([]byte(rels)) // xl/_rels/workbook.xml.rels wbrels := ` ` w, _ = zw.Create("xl/_rels/workbook.xml.rels") w.Write([]byte(wbrels)) // xl/workbook.xml wb := ` ` w, _ = zw.Create("xl/workbook.xml") w.Write([]byte(wb)) // Collect all strings and build shared strings var allStrings []string stringIndex := make(map[string]int) for _, row := range rows { for _, cell := range row { if _, exists := stringIndex[cell]; !exists { stringIndex[cell] = len(allStrings) allStrings = append(allStrings, cell) } } } // xl/sharedStrings.xml ssb := bytes.NewBufferString(` `) for _, s := range allStrings { ssb.WriteString("" + escapeXML(s) + "") } ssb.WriteString("") w, _ = zw.Create("xl/sharedStrings.xml") w.Write(ssb.Bytes()) // xl/worksheets/sheet1.xml sheetb := bytes.NewBufferString(` `) for ri, row := range rows { sheetb.WriteString(fmt.Sprintf(``, ri+1)) for ci, cell := range row { colLetter := string(rune('A' + ci)) cellRef := fmt.Sprintf("%s%d", colLetter, ri+1) idx := stringIndex[cell] sheetb.WriteString(fmt.Sprintf(`%d`, cellRef, idx)) } sheetb.WriteString("") } sheetb.WriteString("") w, _ = zw.Create("xl/worksheets/sheet1.xml") w.Write(sheetb.Bytes()) zw.Close() return buf.Bytes() } func escapeXML(s string) string { var buf bytes.Buffer for _, r := range s { switch r { case '<': buf.WriteString("<") case '>': buf.WriteString(">") case '&': buf.WriteString("&") case '"': buf.WriteString(""") case '\'': buf.WriteString("'") default: buf.WriteRune(r) } } return buf.String() } func TestImportSmartHeaderDetection(t *testing.T) { server, db, cfg, cleanup := testServerSetup(t) defer cleanup() client := &http.Client{} userID := uuid.New().String() now := time.Now().UnixMilli() user := &lib.User{ UserID: userID, Email: "smartheader@test.com", Name: "Smart Header", Password: "", Active: true, CreatedAt: now, UpdatedAt: now, } lib.UserCreate(db, user) projectID := createTestProjectWithAccess(t, db, cfg, userID) token := getToken(t, client, server.URL, "smartheader@test.com") // CSV with 3 title rows before the actual header csvContent := `Project XYZ Due Diligence Request List Confidential - Internal Use Only Generated: 2026-02-28 Section,Item #,Information Requested,Priority Financial,1.1,Audited financials 3 years,High Financial,1.2,Monthly management reports,Medium` resp := uploadFile(t, client, server.URL+"/api/projects/"+projectID+"/requests/import", token, "with-title-rows.csv", "text/csv", []byte(csvContent), nil) defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) t.Fatalf("expected 200, got %d: %s", resp.StatusCode, string(body)) } var importResp map[string]interface{} json.NewDecoder(resp.Body).Decode(&importResp) // Should detect header at row 4 (index 3) and import 2 rows imported := int(importResp["imported"].(float64)) if imported != 2 { t.Errorf("expected 2 imported (header detection), got %d", imported) } } func TestImportModeReplace(t *testing.T) { server, db, cfg, cleanup := testServerSetup(t) defer cleanup() client := &http.Client{} userID := uuid.New().String() now := time.Now().UnixMilli() user := &lib.User{ UserID: userID, Email: "replace@test.com", Name: "Replace Test", Password: "", Active: true, CreatedAt: now, UpdatedAt: now, } lib.UserCreate(db, user) projectID := createTestProjectWithAccess(t, db, cfg, userID) token := getToken(t, client, server.URL, "replace@test.com") // First import csv1 := `Section,Item #,Description,Priority Finance,1,First batch item 1,High Finance,2,First batch item 2,Medium` resp := uploadFile(t, client, server.URL+"/api/projects/"+projectID+"/requests/import", token, "batch1.csv", "text/csv", []byte(csv1), map[string]string{"mode": "add"}) resp.Body.Close() // Second import with mode=replace csv2 := `Section,Item #,Description,Priority Legal,A,Second batch only,High` resp = uploadFile(t, client, server.URL+"/api/projects/"+projectID+"/requests/import", token, "batch2.csv", "text/csv", []byte(csv2), map[string]string{"mode": "replace"}) resp.Body.Close() // List requests - should only have 1 (from second batch) req, _ := http.NewRequest("GET", server.URL+"/api/projects/"+projectID+"/requests", nil) req.Header.Set("Authorization", "Bearer "+token) resp, err := client.Do(req) if err != nil { t.Fatalf("list requests failed: %v", err) } defer resp.Body.Close() var requests []map[string]interface{} json.NewDecoder(resp.Body).Decode(&requests) if len(requests) != 1 { t.Errorf("after replace, expected 1 request, got %d", len(requests)) } if len(requests) > 0 && requests[0]["section"] != "Legal" { t.Errorf("expected Legal section, got %v", requests[0]["section"]) } } func TestImportModeAdd(t *testing.T) { server, db, cfg, cleanup := testServerSetup(t) defer cleanup() client := &http.Client{} userID := uuid.New().String() now := time.Now().UnixMilli() user := &lib.User{ UserID: userID, Email: "addmode@test.com", Name: "Add Mode", Password: "", Active: true, CreatedAt: now, UpdatedAt: now, } lib.UserCreate(db, user) projectID := createTestProjectWithAccess(t, db, cfg, userID) token := getToken(t, client, server.URL, "addmode@test.com") // First import csv1 := `Section,Item #,Description,Priority Finance,1,First item,High` resp := uploadFile(t, client, server.URL+"/api/projects/"+projectID+"/requests/import", token, "add1.csv", "text/csv", []byte(csv1), map[string]string{"mode": "add"}) resp.Body.Close() // Second import with mode=add (accumulate) csv2 := `Section,Item #,Description,Priority Legal,2,Second item,Medium` resp = uploadFile(t, client, server.URL+"/api/projects/"+projectID+"/requests/import", token, "add2.csv", "text/csv", []byte(csv2), map[string]string{"mode": "add"}) resp.Body.Close() // List requests - should have 2 (accumulated) req, _ := http.NewRequest("GET", server.URL+"/api/projects/"+projectID+"/requests", nil) req.Header.Set("Authorization", "Bearer "+token) resp, err := client.Do(req) if err != nil { t.Fatalf("list requests failed: %v", err) } defer resp.Body.Close() var requests []map[string]interface{} json.NewDecoder(resp.Body).Decode(&requests) if len(requests) != 2 { t.Errorf("after add mode, expected 2 requests, got %d", len(requests)) } } func TestListRequests(t *testing.T) { server, db, cfg, cleanup := testServerSetup(t) defer cleanup() client := &http.Client{} userID := uuid.New().String() now := time.Now().UnixMilli() user := &lib.User{ UserID: userID, Email: "list@test.com", Name: "List Test", Password: "", Active: true, CreatedAt: now, UpdatedAt: now, } lib.UserCreate(db, user) projectID := createTestProjectWithAccess(t, db, cfg, userID) token := getToken(t, client, server.URL, "list@test.com") // Import some requests csv := `Section,Item #,Description,Priority Alpha,A-1,First alpha item,High Alpha,A-2,Second alpha item,Medium Beta,B-1,First beta item,Low` resp := uploadFile(t, client, server.URL+"/api/projects/"+projectID+"/requests/import", token, "list.csv", "text/csv", []byte(csv), nil) resp.Body.Close() // List requests req, _ := http.NewRequest("GET", server.URL+"/api/projects/"+projectID+"/requests", nil) req.Header.Set("Authorization", "Bearer "+token) resp, err := client.Do(req) if err != nil { t.Fatalf("list requests failed: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } var requests []map[string]interface{} json.NewDecoder(resp.Body).Decode(&requests) if len(requests) != 3 { t.Errorf("expected 3 requests, got %d", len(requests)) } // Verify structure for _, r := range requests { if r["entry_id"] == nil || r["entry_id"] == "" { t.Error("request should have entry_id") } if r["section"] == nil { t.Error("request should have section") } if r["priority"] == nil { t.Error("request should have priority") } if r["status"] == nil { t.Error("request should have status") } } } func TestPriorityNormalization(t *testing.T) { server, db, cfg, cleanup := testServerSetup(t) defer cleanup() client := &http.Client{} userID := uuid.New().String() now := time.Now().UnixMilli() user := &lib.User{ UserID: userID, Email: "priority@test.com", Name: "Priority Test", Password: "", Active: true, CreatedAt: now, UpdatedAt: now, } lib.UserCreate(db, user) projectID := createTestProjectWithAccess(t, db, cfg, userID) token := getToken(t, client, server.URL, "priority@test.com") // CSV with various priority formats csv := `Section,Item #,Description,Priority Test,1,Critical priority,CRITICAL Test,2,Nice to have priority,Nice to have Test,3,Normal priority,Normal Test,4,Urgent priority,Urgent Test,5,High priority,High Test,6,Low priority,Low Test,7,Optional priority,optional` resp := uploadFile(t, client, server.URL+"/api/projects/"+projectID+"/requests/import", token, "priority.csv", "text/csv", []byte(csv), nil) resp.Body.Close() // List and verify priority normalization req, _ := http.NewRequest("GET", server.URL+"/api/projects/"+projectID+"/requests", nil) req.Header.Set("Authorization", "Bearer "+token) resp, err := client.Do(req) if err != nil { t.Fatalf("list requests failed: %v", err) } defer resp.Body.Close() var requests []map[string]interface{} json.NewDecoder(resp.Body).Decode(&requests) expected := map[string]string{ "Critical priority": "high", // CRITICAL → high "Nice to have priority": "low", // Nice to have → low "Normal priority": "medium", "Urgent priority": "high", // Urgent → high "High priority": "high", "Low priority": "low", "Optional priority": "low", // optional → low } for _, r := range requests { title := r["title"].(string) if expectedPri, ok := expected[title]; ok { if r["priority"] != expectedPri { t.Errorf("%s: expected priority %q, got %q", title, expectedPri, r["priority"]) } } } } func TestImportNoFile(t *testing.T) { server, db, cfg, cleanup := testServerSetup(t) defer cleanup() client := &http.Client{} userID := uuid.New().String() now := time.Now().UnixMilli() user := &lib.User{ UserID: userID, Email: "nofile@test.com", Name: "No File", Password: "", Active: true, CreatedAt: now, UpdatedAt: now, } lib.UserCreate(db, user) projectID := createTestProjectWithAccess(t, db, cfg, userID) token := getToken(t, client, server.URL, "nofile@test.com") // Try import without file req, _ := http.NewRequest("POST", server.URL+"/api/projects/"+projectID+"/requests/import", nil) req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Content-Type", "multipart/form-data") resp, err := client.Do(req) if err != nil { t.Fatalf("request failed: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusBadRequest { t.Errorf("expected 400 for missing file, got %d", resp.StatusCode) } } func TestImportAccessDenied(t *testing.T) { server, db, cfg, cleanup := testServerSetup(t) defer cleanup() client := &http.Client{} // Create project owner ownerID := uuid.New().String() now := time.Now().UnixMilli() owner := &lib.User{ UserID: ownerID, Email: "owner@test.com", Name: "Owner", Password: "", Active: true, CreatedAt: now, UpdatedAt: now, } lib.UserCreate(db, owner) projectID := createTestProjectWithAccess(t, db, cfg, ownerID) // Create buyer (read-only, no write access) buyerID := uuid.New().String() buyer := &lib.User{ UserID: buyerID, Email: "buyer-readonly@test.com", Name: "Buyer", Password: "", Active: true, CreatedAt: now, UpdatedAt: now, } lib.UserCreate(db, buyer) lib.AccessGrant(db, &lib.Access{ ID: uuid.New().String(), ProjectID: projectID, UserID: buyerID, Role: lib.RoleBuyerMember, Ops: "r", // read only CanGrant: false, GrantedBy: "system", GrantedAt: now, }) buyerToken := getToken(t, client, server.URL, "buyer-readonly@test.com") csv := `Section,Item #,Description,Priority Test,1,Test item,High` resp := uploadFile(t, client, server.URL+"/api/projects/"+projectID+"/requests/import", buyerToken, "denied.csv", "text/csv", []byte(csv), nil) defer resp.Body.Close() if resp.StatusCode != http.StatusForbidden { t.Errorf("expected 403 for buyer import, got %d", resp.StatusCode) } } func TestListRequestsAccessDenied(t *testing.T) { server, db, cfg, cleanup := testServerSetup(t) defer cleanup() client := &http.Client{} // Create project ownerID := uuid.New().String() now := time.Now().UnixMilli() owner := &lib.User{ UserID: ownerID, Email: "listowner@test.com", Name: "List Owner", Password: "", Active: true, CreatedAt: now, UpdatedAt: now, } lib.UserCreate(db, owner) projectID := createTestProjectWithAccess(t, db, cfg, ownerID) // Create user with no access noAccessID := uuid.New().String() noAccess := &lib.User{ UserID: noAccessID, Email: "noaccess-list@test.com", Name: "No Access", Password: "", Active: true, CreatedAt: now, UpdatedAt: now, } lib.UserCreate(db, noAccess) noAccessToken := getToken(t, client, server.URL, "noaccess-list@test.com") req, _ := http.NewRequest("GET", server.URL+"/api/projects/"+projectID+"/requests", nil) req.Header.Set("Authorization", "Bearer "+noAccessToken) resp, err := client.Do(req) if err != nil { t.Fatalf("request failed: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusForbidden { t.Errorf("expected 403 for no access list, got %d", resp.StatusCode) } } // This test file doesn't exist in the original but is mentioned, so skip it func init() { // Ensure tests don't fail due to missing template directory os.MkdirAll("/tmp/dealspace-test-templates", 0755) }