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