package httpapi import ( "bytes" "context" "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" ) func TestPausedProjectBlocksNewTicketsButAllowsCallingPostgresIntegration(t *testing.T) { dsn := strings.TrimSpace(os.Getenv("TEST_DATABASE_URL")) if dsn == "" { t.Skip("TEST_DATABASE_URL is not set") } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) 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) } testDB := db.Begin() if testDB.Error != nil { t.Fatal(testDB.Error) } defer testDB.Rollback() server, err := New(testDB, config.Config{ Environment: "development", EncryptionKey: bytes.Repeat([]byte{0x81}, 32), PhoneHMACKey: bytes.Repeat([]byte{0x82}, 32), }, logger) if err != nil { t.Fatal(err) } now := time.Date(2026, 7, 30, 2, 0, 0, 0, time.UTC) server.now = func() time.Time { return now } projectID := uuid.NewString() project := model.Project{ ID: projectID, Code: "PAUSE" + strings.ToUpper(uuid.NewString()[:6]), Name: "Pause calling regression", Status: model.ProjectRunning, 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: 60, ContinuousRatePerMinute: 1, ETAIntervalSeconds: 60, DeviceSimulationMode: "DISABLED", CreatedAt: now, UpdatedAt: now, } if err := testDB.Create(&project).Error; err != nil { t.Fatal(err) } staff := model.User{ ID: uuid.NewString(), Username: "pause_staff_" + uuid.NewString()[:8], PasswordHash: "unused", Role: model.RoleStaff, Active: true, CreatedAt: now, UpdatedAt: now, } if err := testDB.Create(&staff).Error; err != nil { t.Fatal(err) } if err := testDB.Create(&model.UserProject{UserID: staff.ID, ProjectID: projectID, CreatedAt: now}).Error; err != nil { t.Fatal(err) } businessDate, err := server.businessDateFor(project) if err != nil { t.Fatal(err) } session := model.QueueSession{ ID: uuid.NewString(), ProjectID: projectID, BusinessDate: businessDate, Status: "RUNNING", NextTicketNumber: 2, Revision: 1, OpenedAt: now, CreatedAt: now, UpdatedAt: now, } if err := testDB.Create(&session).Error; err != nil { t.Fatal(err) } phone := "13800138000" phoneCiphertext, phoneNonce, err := server.cipher.Encrypt(phone, []byte("phone:"+projectID)) if err != nil { t.Fatal(err) } phoneHMAC := server.cipher.Digest(phone) ticket := model.QueueTicket{ ID: uuid.NewString(), ProjectID: projectID, QueueSessionID: session.ID, TicketNumber: 1, DisplayNumber: "00001", PartySize: 2, PublicTokenHash: security.HashToken(uuid.NewString()), PhoneCiphertext: phoneCiphertext, PhoneNonce: phoneNonce, PhoneHMAC: &phoneHMAC, Honorific: "游客", Status: model.TicketWaiting, JoinedAt: now, PersonalDataPurgeAt: now.Add(30 * 24 * time.Hour), CreatedBy: staff.ID, CreatedAt: now, UpdatedAt: now, } if err := testDB.Create(&ticket).Error; err != nil { t.Fatal(err) } if err := testDB.Model(&model.Project{}).Where("id = ?", projectID). Updates(map[string]any{"status": model.ProjectPaused, "updated_at": now}).Error; err != nil { t.Fatal(err) } t.Run("blocks staff ticket creation", func(t *testing.T) { recorder := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPost, "/api/staff/projects/"+projectID+"/tickets", strings.NewReader(`{"phone":"13900139000","honorific":"游客","party_size":1}`)) request.SetPathValue("id", projectID) request.Header.Set("Idempotency-Key", "paused-staff-ticket-"+uuid.NewString()) request = request.WithContext(context.WithValue(request.Context(), principalKey, principal{User: staff})) server.createTicket(recorder, request) if recorder.Code != http.StatusConflict || !strings.Contains(recorder.Body.String(), `"code":"PROJECT_NOT_RUNNING"`) { t.Fatalf("status = %d, want 409 PROJECT_NOT_RUNNING; body = %s", recorder.Code, recorder.Body.String()) } }) t.Run("blocks public ticket creation", func(t *testing.T) { recorder := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPost, "/api/public/projects/"+projectID+"/tickets", strings.NewReader(`{"phone":"13700137000","honorific":"游客","party_size":1}`)) request.Header.Set("Idempotency-Key", "paused-public-ticket-"+uuid.NewString()) server.Handler().ServeHTTP(recorder, request) if recorder.Code != http.StatusConflict || !strings.Contains(recorder.Body.String(), `"code":"PROJECT_NOT_RUNNING"`) { t.Fatalf("status = %d, want 409 PROJECT_NOT_RUNNING; body = %s", recorder.Code, recorder.Body.String()) } }) var ticketCount int64 if err := testDB.Model(&model.QueueTicket{}).Where("project_id = ?", projectID).Count(&ticketCount).Error; err != nil { t.Fatal(err) } if ticketCount != 1 { t.Fatalf("ticket count = %d, want the original ticket only", ticketCount) } t.Run("allows calling the existing queue", func(t *testing.T) { recorder := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPost, "/api/staff/projects/"+projectID+"/call-next", strings.NewReader(`{"expected_revision":1,"mode":"TICKET","count":1}`)) request.SetPathValue("id", projectID) request.Header.Set("Idempotency-Key", "paused-call-next-"+uuid.NewString()) request = request.WithContext(context.WithValue(request.Context(), principalKey, principal{User: staff})) server.callNext(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status = %d, want 200; body = %s", recorder.Code, recorder.Body.String()) } var updatedTicket model.QueueTicket if err := testDB.First(&updatedTicket, "id = ?", ticket.ID).Error; err != nil { t.Fatal(err) } if updatedTicket.Status != model.TicketCalled { t.Fatalf("ticket status = %s, want %s", updatedTicket.Status, model.TicketCalled) } }) }