Files
XQKqueue/server/internal/httpapi/project_delete_integration_test.go

239 lines
8.6 KiB
Go

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")
if err := db.Create(&unused).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&history).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)
}
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)
}
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("project with queue history is rejected intact", func(t *testing.T) {
response := deleteRequest(history.ID)
if response.Code != http.StatusConflict {
t.Fatalf("delete history project status = %d, want 409; body = %s", response.Code, response.Body.String())
}
if !strings.Contains(response.Body.String(), `"code":"PROJECT_HAS_HISTORY"`) {
t.Fatalf("delete history project body = %s, want PROJECT_HAS_HISTORY", response.Body.String())
}
if !strings.Contains(response.Body.String(), "不能删除") || !strings.Contains(response.Body.String(), "结束") {
t.Fatalf("delete history project message is not actionable Chinese: %s", response.Body.String())
}
if err := db.First(&model.Project{}, "id = ?", history.ID).Error; err != nil {
t.Fatalf("history project was not retained: %v", err)
}
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 deletedAuditCount int64
if err := db.Model(&model.AuditEntry{}).
Where("action = ? AND entity_id = ?", "PROJECT_DELETED", history.ID).
Count(&deletedAuditCount).Error; err != nil {
t.Fatal(err)
}
if deletedAuditCount != 0 {
t.Fatalf("PROJECT_DELETED audit count for retained project = %d, want 0", deletedAuditCount)
}
})
}