package httpapi import ( "bytes" "context" "encoding/json" "fmt" "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 TestPublicCreateTicketIgnoresDuplicateFromEndedSession(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) } server, err := New(db, config.Config{ Environment: "development", EncryptionKey: bytes.Repeat([]byte{0x41}, 32), PhoneHMACKey: bytes.Repeat([]byte{0x42}, 32), }, logger) if err != nil { t.Fatal(err) } now := time.Date(2026, 7, 28, 1, 0, 0, 0, time.UTC) server.now = func() time.Time { return now } var actor model.User if err := db.Where("username = ?", model.PublicVisitorUsername).First(&actor).Error; err != nil { t.Fatal(err) } projectID := uuid.NewString() project := model.Project{ ID: projectID, Code: strings.ToUpper("PUB" + uuid.NewString()[:6]), Name: "Public duplicate regression", Status: model.ProjectRunning, Timezone: "Asia/Shanghai", TicketPrefix: "A", CallBatchSize: 5, CallMode: model.CallModeBoth, MaxCallTicketCount: 100, DefaultCallPeopleCount: 1, MaxCallPeopleCount: 100, MinPartySize: 1, MaxPartySize: 10, GracePeriodMinutes: 5, ETAMode: model.ETAFixedBatch, AverageBatchIntervalSeconds: 60, ContinuousRatePerMinute: 2, ETABufferMinutes: 5, ETAIntervalSeconds: 60, VisitorNotice: "", DeviceSimulationMode: "DISABLED", CreatedAt: now, UpdatedAt: now, } if err := db.Create(&project).Error; err != nil { t.Fatal(err) } endedAt := now.Add(-12 * time.Hour) oldSession := model.QueueSession{ ID: uuid.NewString(), ProjectID: projectID, BusinessDate: now.AddDate(0, 0, -1), Status: "ENDED", NextTicketNumber: 2, Revision: 1, OpenedAt: now.Add(-24 * time.Hour), ClosedAt: &endedAt, CreatedAt: now.Add(-24 * time.Hour), UpdatedAt: endedAt, } currentSession := model.QueueSession{ ID: uuid.NewString(), ProjectID: projectID, BusinessDate: now, Status: "RUNNING", NextTicketNumber: 1, Revision: 0, OpenedAt: now, CreatedAt: now, UpdatedAt: now, } if err := db.Create(&oldSession).Error; err != nil { t.Fatal(err) } if err := db.Create(¤tSession).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) oldTicket := model.QueueTicket{ ID: uuid.NewString(), ProjectID: projectID, QueueSessionID: oldSession.ID, TicketNumber: 1, DisplayNumber: "00001", PartySize: 1, PublicTokenHash: security.HashToken(uuid.NewString()), PhoneCiphertext: phoneCiphertext, PhoneNonce: phoneNonce, PhoneHMAC: &phoneHMAC, Honorific: "游客", Status: model.TicketWaiting, JoinedAt: oldSession.OpenedAt, PersonalDataPurgeAt: now.Add(30 * 24 * time.Hour), CreatedBy: actor.ID, CreatedAt: oldSession.OpenedAt, UpdatedAt: oldSession.OpenedAt, } if err := db.Create(&oldTicket).Error; err != nil { t.Fatal(err) } recorder := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPost, "/api/public/projects/"+projectID+"/tickets", strings.NewReader(`{"phone":"13800138000","honorific":"游客","party_size":1,"allow_duplicate":false}`)) request.Header.Set("Idempotency-Key", "public-ended-session-"+uuid.NewString()) server.Handler().ServeHTTP(recorder, request) if recorder.Code != http.StatusCreated { t.Fatalf("status = %d, want 201; body = %s", recorder.Code, recorder.Body.String()) } duplicateRecorder := httptest.NewRecorder() duplicateRequest := httptest.NewRequest(http.MethodPost, "/api/public/projects/"+projectID+"/tickets", strings.NewReader(`{"phone":"13800138000","honorific":"游客","party_size":1,"allow_duplicate":false}`)) duplicateRequest.Header.Set("Idempotency-Key", "public-current-session-"+uuid.NewString()) server.Handler().ServeHTTP(duplicateRecorder, duplicateRequest) if duplicateRecorder.Code != http.StatusConflict || !strings.Contains(duplicateRecorder.Body.String(), `"code":"DUPLICATE_PHONE"`) { t.Fatalf("current-session duplicate status = %d, want 409 DUPLICATE_PHONE; body = %s", duplicateRecorder.Code, duplicateRecorder.Body.String()) } calledAt := now result := db.Model(&model.QueueTicket{}). Where("project_id = ? AND queue_session_id = ? AND phone_hmac = ? AND status = ?", projectID, currentSession.ID, phoneHMAC, model.TicketWaiting). Updates(map[string]any{"status": model.TicketCalled, "called_at": calledAt, "updated_at": calledAt}) if result.Error != nil { t.Fatal(result.Error) } if result.RowsAffected != 1 { t.Fatalf("called tickets = %d, want 1", result.RowsAffected) } afterCallRecorder := httptest.NewRecorder() afterCallRequest := httptest.NewRequest(http.MethodPost, "/api/public/projects/"+projectID+"/tickets", strings.NewReader(`{"phone":"13800138000","honorific":"游客","party_size":1,"allow_duplicate":false}`)) afterCallRequest.Header.Set("Idempotency-Key", "public-after-call-"+uuid.NewString()) server.Handler().ServeHTTP(afterCallRecorder, afterCallRequest) if afterCallRecorder.Code != http.StatusCreated { t.Fatalf("after-call status = %d, want 201; body = %s", afterCallRecorder.Code, afterCallRecorder.Body.String()) } server.publicTicketProjectLimiter = newQueryLimiter(server.now, 10, time.Minute) server.publicTicketPhoneLimiter = newQueryLimiter(server.now, 1, time.Minute) var replayBody, replayKey string for index := 0; index < 6; index++ { body := fmt.Sprintf(`{"phone":"1390000%04d","party_size":1}`, index) key := "same-mobile-ip-" + uuid.NewString() recorder := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPost, "/api/public/projects/"+projectID+"/tickets", strings.NewReader(body)) request.RemoteAddr = "198.51.100.27:42000" request.Header.Set("Idempotency-Key", key) server.Handler().ServeHTTP(recorder, request) if recorder.Code != http.StatusCreated { t.Fatalf("same-IP distinct phone %d status = %d, want 201; body = %s", index, recorder.Code, recorder.Body.String()) } if index == 0 { replayBody, replayKey = body, key } } replayRecorder := httptest.NewRecorder() replayRequest := httptest.NewRequest(http.MethodPost, "/api/public/projects/"+projectID+"/tickets", strings.NewReader(replayBody)) replayRequest.RemoteAddr = "198.51.100.27:42000" replayRequest.Header.Set("Idempotency-Key", replayKey) server.Handler().ServeHTTP(replayRecorder, replayRequest) if replayRecorder.Code != http.StatusCreated { t.Fatalf("idempotent replay status = %d, want 201; body = %s", replayRecorder.Code, replayRecorder.Body.String()) } limitedRecorder := httptest.NewRecorder() limitedRequest := httptest.NewRequest(http.MethodPost, "/api/public/projects/"+projectID+"/tickets", strings.NewReader(replayBody)) limitedRequest.RemoteAddr = "203.0.113.44:42000" limitedRequest.Header.Set("Idempotency-Key", "new-intent-"+uuid.NewString()) server.Handler().ServeHTTP(limitedRecorder, limitedRequest) if limitedRecorder.Code != http.StatusTooManyRequests { t.Fatalf("same project/phone new intent status = %d, want 429; body = %s", limitedRecorder.Code, limitedRecorder.Body.String()) } } func TestInternalPhoneLookupUsesLatestActiveSessionPerProject(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) } server, err := New(db, config.Config{ Environment: "production", EncryptionKey: bytes.Repeat([]byte{0x51}, 32), PhoneHMACKey: bytes.Repeat([]byte{0x52}, 32), }, logger) if err != nil { t.Fatal(err) } now := time.Date(2026, 7, 28, 2, 42, 35, 0, time.UTC) server.now = func() time.Time { return now } var actor model.User if err := db.Where("username = ?", model.PublicVisitorUsername).First(&actor).Error; err != nil { t.Fatal(err) } projectID := uuid.NewString() project := model.Project{ ID: projectID, Code: strings.ToUpper("LOOK" + uuid.NewString()[:6]), Name: "Phone lookup duplicate regression", Status: model.ProjectRunning, Timezone: "Asia/Shanghai", TicketPrefix: "A", CallBatchSize: 5, CallMode: model.CallModeBoth, MaxCallTicketCount: 100, DefaultCallPeopleCount: 1, MaxCallPeopleCount: 100, MinPartySize: 1, MaxPartySize: 10, GracePeriodMinutes: 5, ETAMode: model.ETAFixedBatch, AverageBatchIntervalSeconds: 60, ContinuousRatePerMinute: 2, ETABufferMinutes: 5, ETAIntervalSeconds: 60, VisitorNotice: "", DeviceSimulationMode: "DISABLED", CreatedAt: now, UpdatedAt: now, } if err := db.Create(&project).Error; err != nil { t.Fatal(err) } staleSession := model.QueueSession{ ID: uuid.NewString(), ProjectID: projectID, BusinessDate: now.AddDate(0, 0, -1), Status: "RUNNING", NextTicketNumber: 2, Revision: 1, OpenedAt: now.Add(-24 * time.Hour), CreatedAt: now.Add(-24 * time.Hour), UpdatedAt: now.Add(-24 * time.Hour), } currentSession := model.QueueSession{ ID: uuid.NewString(), ProjectID: projectID, BusinessDate: now, Status: "RUNNING", NextTicketNumber: 3, Revision: 3, OpenedAt: now, CreatedAt: now, UpdatedAt: now, } if err := db.Create(&staleSession).Error; err != nil { t.Fatal(err) } if err := db.Create(¤tSession).Error; err != nil { t.Fatal(err) } phone := "18286070628" phoneCiphertext, phoneNonce, err := server.cipher.Encrypt(phone, []byte("phone:"+projectID)) if err != nil { t.Fatal(err) } phoneHMAC := server.cipher.Digest(phone) ticketFixtures := []struct { session model.QueueSession ticketNumber int displayNumber string }{ {session: staleSession, ticketNumber: 1, displayNumber: "00001"}, {session: currentSession, ticketNumber: 1, displayNumber: "00001"}, {session: currentSession, ticketNumber: 2, displayNumber: "00002"}, } for _, fixture := range ticketFixtures { ticket := model.QueueTicket{ ID: uuid.NewString(), ProjectID: projectID, QueueSessionID: fixture.session.ID, TicketNumber: fixture.ticketNumber, DisplayNumber: fixture.displayNumber, PartySize: 1, PublicTokenHash: security.HashToken(uuid.NewString()), PhoneCiphertext: phoneCiphertext, PhoneNonce: phoneNonce, PhoneHMAC: &phoneHMAC, Honorific: "游客", Status: model.TicketWaiting, JoinedAt: fixture.session.OpenedAt, PersonalDataPurgeAt: now.Add(30 * 24 * time.Hour), CreatedBy: actor.ID, CreatedAt: fixture.session.OpenedAt, UpdatedAt: fixture.session.OpenedAt, } if err := db.Create(&ticket).Error; err != nil { t.Fatal(err) } } recorder := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPost, "/api/internal/status/search", strings.NewReader(`{"phone":"18286070628"}`)) request.RemoteAddr = "10.23.4.5:42000" server.Handler().ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status = %d, want 200; body = %s", recorder.Code, recorder.Body.String()) } var response struct { Tickets []struct { TicketNumber string `json:"ticket_number"` Revision int64 `json:"revision"` } `json:"tickets"` } if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil { t.Fatal(err) } if len(response.Tickets) != 2 { t.Fatalf("tickets = %#v, want only the two latest-session tickets", response.Tickets) } if response.Tickets[0].TicketNumber != "00001" || response.Tickets[1].TicketNumber != "00002" { t.Fatalf("tickets = %#v, want distinct current-session tickets 00001 and 00002", response.Tickets) } for _, ticket := range response.Tickets { if ticket.Revision != currentSession.Revision { t.Fatalf("ticket = %#v, want current session revision %d", ticket, currentSession.Revision) } } displayRecorder := httptest.NewRecorder() displayRequest := httptest.NewRequest(http.MethodGet, "/api/display/"+strings.ToLower(project.Code)+"/snapshot", nil) server.Handler().ServeHTTP(displayRecorder, displayRequest) if displayRecorder.Code != http.StatusOK { t.Fatalf("display status = %d, want 200; body = %s", displayRecorder.Code, displayRecorder.Body.String()) } var displayResponse struct { IssuedTicketCount int `json:"issued_ticket_count"` LatestTicketNumber *string `json:"latest_ticket_number"` } if err := json.Unmarshal(displayRecorder.Body.Bytes(), &displayResponse); err != nil { t.Fatal(err) } if displayResponse.IssuedTicketCount != 2 || displayResponse.LatestTicketNumber == nil || *displayResponse.LatestTicketNumber != "00002" { t.Fatalf("display metrics = %#v, want issued 2 and latest 00002", displayResponse) } }