package httpapi import ( "bytes" "errors" "io" "log/slog" "net/http" "net/http/httptest" "os" "strings" "testing" "time" "calllinesystem/server/internal/config" "calllinesystem/server/internal/database" "calllinesystem/server/internal/model" "calllinesystem/server/internal/security" "github.com/google/uuid" "gorm.io/gorm" ) func TestDeleteProjectRouteRequiresAdminAuthentication(t *testing.T) { server := &Server{ logger: slog.New(slog.NewTextHandler(io.Discard, nil)), now: func() time.Time { return time.Date(2026, 8, 13, 0, 0, 0, 0, time.UTC) }, } recorder := httptest.NewRecorder() request := httptest.NewRequest(http.MethodDelete, "/api/admin/projects/11111111-1111-4111-8111-111111111111", nil) server.Handler().ServeHTTP(recorder, request) if recorder.Code != http.StatusUnauthorized { t.Fatalf("delete project without admin session status = %d, want 401; body = %s", recorder.Code, recorder.Body.String()) } } func TestDeleteProjectPostgresIntegration(t *testing.T) { dsn := strings.TrimSpace(os.Getenv("TEST_DATABASE_URL")) if dsn == "" { t.Skip("TEST_DATABASE_URL is not set") } logger := slog.New(slog.NewTextHandler(io.Discard, nil)) ctx := t.Context() db, err := database.Open(ctx, dsn, logger) if err != nil { t.Fatal(err) } defer database.Close(db) sqlDB, err := database.SQLDB(db) if err != nil { t.Fatal(err) } if err := database.Migrate(ctx, sqlDB, logger); err != nil { t.Fatal(err) } server, err := New(db, config.Config{ EncryptionKey: bytes.Repeat([]byte{0x61}, 32), PhoneHMACKey: bytes.Repeat([]byte{0x62}, 32), SessionCookieName: "queue_session", SessionTTL: time.Hour, }, logger) if err != nil { t.Fatal(err) } now := time.Date(2026, 8, 13, 10, 0, 0, 0, time.UTC) server.now = func() time.Time { return now } password := "AdminPassword123!" passwordHash, err := security.HashPassword(password) if err != nil { t.Fatal(err) } suffix := strings.ReplaceAll(uuid.NewString(), "-", "")[:10] admin := model.User{ ID: uuid.NewString(), Username: "delete_admin_" + suffix, PasswordHash: passwordHash, Role: model.RoleAdmin, Active: true, CreatedAt: now, UpdatedAt: now, } if err := db.Create(&admin).Error; err != nil { t.Fatal(err) } newProject := func(code, name string) model.Project { return model.Project{ ID: uuid.NewString(), Code: code, Name: name, Status: model.ProjectNotOpen, Timezone: "Asia/Shanghai", TicketPrefix: "A", CallBatchSize: 1, CallMode: model.CallModeBoth, MaxCallTicketCount: 100, DefaultCallPeopleCount: 1, MaxCallPeopleCount: 100, MinPartySize: 1, MaxPartySize: 10, GracePeriodMinutes: 5, ETAMode: model.ETAFixedBatch, AverageBatchIntervalSeconds: 300, ContinuousRatePerMinute: 1, ETAIntervalSeconds: 60, VisitorNotice: model.DefaultVisitorNotice, DeviceSimulationMode: "DISABLED", CreatedAt: now, UpdatedAt: now, } } unused := newProject("DEL"+strings.ToUpper(suffix), "unused deletion project") history := newProject("HIS"+strings.ToUpper(suffix), "history deletion project") history.Status = model.ProjectEnded notEnded := newProject("RUN"+strings.ToUpper(suffix), "running history project") notEnded.Status = model.ProjectRunning if err := db.Create(&unused).Error; err != nil { t.Fatal(err) } if err := db.Create(&history).Error; err != nil { t.Fatal(err) } if err := db.Create(¬Ended).Error; err != nil { t.Fatal(err) } grant := model.UserProject{UserID: admin.ID, ProjectID: unused.ID, CreatedAt: now} if err := db.Create(&grant).Error; err != nil { t.Fatal(err) } if err := db.Create(&model.UserProject{UserID: admin.ID, ProjectID: history.ID, CreatedAt: now}).Error; err != nil { t.Fatal(err) } createdAudit := func(projectID string) model.AuditEntry { return model.AuditEntry{ ID: uuid.NewString(), ProjectID: &projectID, ActorUserID: &admin.ID, Action: "PROJECT_CREATED", EntityType: "PROJECT", EntityID: &projectID, Details: []byte(`{"source":"integration-test"}`), RequestID: "integration-create", UserAgent: "integration-test", RetainUntil: now.AddDate(1, 0, 0), CreatedAt: now, } } unusedCreatedAudit := createdAudit(unused.ID) historyCreatedAudit := createdAudit(history.ID) if err := db.Create(&unusedCreatedAudit).Error; err != nil { t.Fatal(err) } if err := db.Create(&historyCreatedAudit).Error; err != nil { t.Fatal(err) } queueSession := model.QueueSession{ ID: uuid.NewString(), ProjectID: history.ID, BusinessDate: now, Status: "ENDED", NextTicketNumber: 1, Revision: 0, OpenedAt: now, CreatedAt: now, UpdatedAt: now, } if err := db.Create(&queueSession).Error; err != nil { t.Fatal(err) } notEndedSession := queueSession notEndedSession.ID = uuid.NewString() notEndedSession.ProjectID = notEnded.ID if err := db.Create(¬EndedSession).Error; err != nil { t.Fatal(err) } loginRecorder := httptest.NewRecorder() loginRequest := httptest.NewRequest(http.MethodPost, "/api/admin/auth/login", strings.NewReader(`{"username":"`+admin.Username+`","password":"`+password+`"}`)) server.Handler().ServeHTTP(loginRecorder, loginRequest) if loginRecorder.Code != http.StatusOK { t.Fatalf("admin login status = %d, want 200; body = %s", loginRecorder.Code, loginRecorder.Body.String()) } var adminCookie *http.Cookie for _, cookie := range loginRecorder.Result().Cookies() { if cookie.Name == server.authCookieName(model.RoleAdmin) { adminCookie = cookie break } } if adminCookie == nil { t.Fatal("admin login did not set the admin session cookie") } deleteRequest := func(projectID string) *httptest.ResponseRecorder { recorder := httptest.NewRecorder() request := httptest.NewRequest(http.MethodDelete, "/api/admin/projects/"+projectID, nil) request.AddCookie(adminCookie) server.Handler().ServeHTTP(recorder, request) return recorder } t.Run("unused project is deleted while audit history is retained", func(t *testing.T) { response := deleteRequest(unused.ID) if response.Code != http.StatusNoContent { t.Fatalf("delete unused project status = %d, want 204; body = %s", response.Code, response.Body.String()) } if response.Body.Len() != 0 { t.Fatalf("delete unused project body = %q, want empty", response.Body.String()) } if err := db.First(&model.Project{}, "id = ?", unused.ID).Error; !errors.Is(err, gorm.ErrRecordNotFound) { t.Fatalf("deleted project lookup error = %v, want record not found", err) } var grantCount int64 if err := db.Model(&model.UserProject{}).Where("project_id = ?", unused.ID).Count(&grantCount).Error; err != nil { t.Fatal(err) } if grantCount != 0 { t.Fatalf("project grants after deletion = %d, want 0", grantCount) } var retained model.AuditEntry if err := db.First(&retained, "id = ?", unusedCreatedAudit.ID).Error; err != nil { t.Fatalf("load retained PROJECT_CREATED audit: %v", err) } if retained.ProjectID != nil { t.Fatalf("retained PROJECT_CREATED project_id = %v, want nil", *retained.ProjectID) } var deletedAudit model.AuditEntry if err := db.Where("action = ? AND entity_type = ? AND entity_id = ?", "PROJECT_DELETED", "PROJECT", unused.ID). First(&deletedAudit).Error; err != nil { t.Fatalf("load PROJECT_DELETED audit: %v", err) } if deletedAudit.ProjectID != nil { t.Fatalf("PROJECT_DELETED project_id = %v, want nil", *deletedAudit.ProjectID) } if deletedAudit.EntityID == nil || *deletedAudit.EntityID != unused.ID { t.Fatalf("PROJECT_DELETED entity_id = %v, want %s", deletedAudit.EntityID, unused.ID) } if !bytes.Contains(deletedAudit.Details, []byte(unused.Code)) { t.Fatalf("PROJECT_DELETED details = %s, want deleted project snapshot", deletedAudit.Details) } }) t.Run("ended project with queue history is archived intact", func(t *testing.T) { response := deleteRequest(history.ID) if response.Code != http.StatusNoContent { t.Fatalf("archive history project status = %d, want 204; body = %s", response.Code, response.Body.String()) } var archived model.Project if err := db.First(&archived, "id = ?", history.ID).Error; err != nil { t.Fatalf("history project was not retained: %v", err) } if archived.ArchivedAt == nil || !archived.ArchivedAt.Equal(now) { t.Fatalf("archived_at = %v, want %v", archived.ArchivedAt, now) } var archivedGrantCount int64 if err := db.Model(&model.UserProject{}).Where("project_id = ?", history.ID).Count(&archivedGrantCount).Error; err != nil { t.Fatal(err) } if archivedGrantCount != 0 { t.Fatalf("archived project grants = %d, want 0", archivedGrantCount) } if err := db.First(&model.QueueSession{}, "id = ?", queueSession.ID).Error; err != nil { t.Fatalf("queue session was not retained: %v", err) } var retained model.AuditEntry if err := db.First(&retained, "id = ?", historyCreatedAudit.ID).Error; err != nil { t.Fatalf("history audit was not retained: %v", err) } if retained.ProjectID == nil || *retained.ProjectID != history.ID { t.Fatalf("history audit project_id = %v, want %s", retained.ProjectID, history.ID) } var archivedAudit model.AuditEntry if err := db.Where("action = ? AND entity_id = ?", "PROJECT_ARCHIVED", history.ID).First(&archivedAudit).Error; err != nil { t.Fatalf("load PROJECT_ARCHIVED audit: %v", err) } if archivedAudit.ProjectID == nil || *archivedAudit.ProjectID != history.ID { t.Fatalf("PROJECT_ARCHIVED project_id = %v, want %s", archivedAudit.ProjectID, history.ID) } second := deleteRequest(history.ID) if second.Code != http.StatusNotFound { t.Fatalf("second archive status = %d, want 404; body = %s", second.Code, second.Body.String()) } requestAdmin := func(method, path, body string) *httptest.ResponseRecorder { recorder := httptest.NewRecorder() request := httptest.NewRequest(method, path, strings.NewReader(body)) request.AddCookie(adminCookie) server.Handler().ServeHTTP(recorder, request) return recorder } profile := requestAdmin(http.MethodPut, "/api/admin/projects/"+history.ID, `{"name":"archived","code":"`+history.Code+`","timezone":"Asia/Shanghai","ticket_prefix":"A"}`) if profile.Code != http.StatusNotFound { t.Fatalf("update archived project status = %d, want 404; body = %s", profile.Code, profile.Body.String()) } settings := requestAdmin(http.MethodPut, "/api/admin/projects/"+history.ID+"/settings", `{"status":"ENDED"}`) if settings.Code != http.StatusNotFound { t.Fatalf("update archived settings status = %d, want 404; body = %s", settings.Code, settings.Body.String()) } createUser := requestAdmin(http.MethodPost, "/api/admin/users", `{"username":"archived_grant_`+suffix+`","password":"Password123!","role":"STAFF","project_ids":["`+history.ID+`"]}`) if createUser.Code != http.StatusUnprocessableEntity || !strings.Contains(createUser.Body.String(), `"code":"INVALID_PROJECT"`) { t.Fatalf("grant archived project status = %d, body = %s; want 422 INVALID_PROJECT", createUser.Code, createUser.Body.String()) } adminUsers := requestAdmin(http.MethodGet, "/api/admin/users", "") if adminUsers.Code != http.StatusOK { t.Fatalf("admin users status = %d, want 200; body = %s", adminUsers.Code, adminUsers.Body.String()) } if strings.Contains(adminUsers.Body.String(), history.ID) { t.Fatalf("admin users leaked archived project id: %s", adminUsers.Body.String()) } }) t.Run("project with history must be ended before archival", func(t *testing.T) { response := deleteRequest(notEnded.ID) if response.Code != http.StatusConflict || !strings.Contains(response.Body.String(), `"code":"PROJECT_MUST_BE_ENDED"`) { t.Fatalf("archive running project status = %d, body = %s; want 409 PROJECT_MUST_BE_ENDED", response.Code, response.Body.String()) } var retained model.Project if err := db.First(&retained, "id = ?", notEnded.ID).Error; err != nil { t.Fatal(err) } if retained.ArchivedAt != nil { t.Fatalf("running project archived_at = %v, want nil", retained.ArchivedAt) } }) }