140 lines
7.0 KiB
Go
140 lines
7.0 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"log/slog"
|
|
"net/http/httptest"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"calllinesystem/server/internal/config"
|
|
"calllinesystem/server/internal/database"
|
|
"calllinesystem/server/internal/model"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
func TestHistoryEndpointsPostgresIntegration(t *testing.T) {
|
|
dsn := 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)
|
|
}
|
|
keys := bytes.Repeat([]byte{0x41}, 32)
|
|
hmacKey := bytes.Repeat([]byte{0x42}, 32)
|
|
server, err := New(db, config.Config{EncryptionKey: keys, PhoneHMACKey: hmacKey}, logger)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
now := time.Date(2026, 7, 16, 12, 0, 0, 0, time.UTC)
|
|
server.now = func() time.Time { return now }
|
|
userID, projectID, sessionID, ticketID, batchID := uuid.NewString(), uuid.NewString(), uuid.NewString(), uuid.NewString(), uuid.NewString()
|
|
user := model.User{ID: userID, Username: "history_admin_" + userID[:8], PasswordHash: "unused", Role: model.RoleAdmin, Active: true, CreatedAt: now, UpdatedAt: now}
|
|
project := model.Project{ID: projectID, Code: strings.ToUpper("HIST" + userID[:4]), Name: "历史测试项目", Status: model.ProjectEnded, 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(&user).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.Create(&project).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
session := model.QueueSession{ID: sessionID, ProjectID: projectID, BusinessDate: now, Status: "ENDED", NextTicketNumber: 2, Revision: 2, OpenedAt: now.Add(-4 * time.Hour), ClosedAt: func() *time.Time { value := now.Add(-time.Hour); return &value }(), CreatedAt: now, UpdatedAt: now}
|
|
if err := db.Create(&session).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
phone := "+8613812345678"
|
|
phoneCiphertext, phoneNonce, err := server.cipher.Encrypt(phone, []byte("phone:"+projectID))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
phoneHMAC := server.cipher.Digest(phone)
|
|
joinedAt := now.Add(-3 * time.Hour)
|
|
calledAt := now.Add(-2 * time.Hour)
|
|
completedAt := now.Add(-time.Hour)
|
|
ticket := model.QueueTicket{ID: ticketID, ProjectID: projectID, QueueSessionID: sessionID, TicketNumber: 1, DisplayNumber: "00001", PartySize: 3, PublicTokenHash: uuid.NewString()[:8] + string(bytes.Repeat([]byte("a"), 56)), PhoneCiphertext: phoneCiphertext, PhoneNonce: phoneNonce, PhoneHMAC: &phoneHMAC, Honorific: "先生", Status: model.TicketCompleted, JoinedAt: joinedAt, CalledAt: &calledAt, CompletedAt: &completedAt, PersonalDataPurgeAt: now.Add(30 * 24 * time.Hour), CreatedBy: userID, CreatedAt: joinedAt, UpdatedAt: completedAt}
|
|
if err := db.Create(&ticket).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
batch := model.CallBatch{ID: batchID, ProjectID: projectID, QueueSessionID: sessionID, BatchSequence: 1, Revision: 1, Status: "COMPLETED", CallMode: model.CallModeTicket, RequestedCount: 1, TicketCount: 1, PeopleCount: 3, RequestedBy: userID, CalledAt: calledAt, CompletedAt: &completedAt, CreatedAt: calledAt}
|
|
if err := db.Create(&batch).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.Create(&model.CallBatchTicket{CallBatchID: batchID, TicketID: ticketID, ProjectID: projectID, Position: 1, CreatedAt: calledAt}).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
requestContext := context.WithValue(context.Background(), principalKey, principal{User: user})
|
|
|
|
request := httptest.NewRequest("GET", "/api/admin/history/tickets?from=2026-07-10&to=2026-07-16", nil).WithContext(requestContext)
|
|
recorder := httptest.NewRecorder()
|
|
server.adminHistoryTickets(recorder, request)
|
|
if recorder.Code != 200 {
|
|
t.Fatalf("history tickets status = %d, body = %s", recorder.Code, recorder.Body.String())
|
|
}
|
|
var tickets map[string]any
|
|
if err := json.Unmarshal(recorder.Body.Bytes(), &tickets); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
items := tickets["items"].([]any)
|
|
item := items[0].(map[string]any)
|
|
if item["ticket_number"] != "00001" || item["phone"] != nil || item["phone_masked"] != "****5678" || item["batch_sequence"] != float64(1) {
|
|
t.Fatalf("history ticket projection = %#v", item)
|
|
}
|
|
|
|
detailRequest := httptest.NewRequest("GET", "/api/admin/history/tickets/"+ticketID+"?reveal=true", nil).WithContext(requestContext)
|
|
detailRequest.SetPathValue("id", ticketID)
|
|
detailRecorder := httptest.NewRecorder()
|
|
server.adminHistoryTicket(detailRecorder, detailRequest)
|
|
if detailRecorder.Code != 200 || !bytes.Contains(detailRecorder.Body.Bytes(), []byte(phone)) {
|
|
t.Fatalf("history ticket detail status = %d, body = %s", detailRecorder.Code, detailRecorder.Body.String())
|
|
}
|
|
|
|
batchRequest := httptest.NewRequest("GET", "/api/admin/history/batches/"+batchID, nil).WithContext(requestContext)
|
|
batchRequest.SetPathValue("id", batchID)
|
|
batchRecorder := httptest.NewRecorder()
|
|
server.adminHistoryBatch(batchRecorder, batchRequest)
|
|
if batchRecorder.Code != 200 || !bytes.Contains(batchRecorder.Body.Bytes(), []byte("00001")) {
|
|
t.Fatalf("history batch detail status = %d, body = %s", batchRecorder.Code, batchRecorder.Body.String())
|
|
}
|
|
|
|
summaryRequest := httptest.NewRequest("GET", "/api/admin/history/summary?from=2026-07-10&to=2026-07-16", nil).WithContext(requestContext)
|
|
summaryRecorder := httptest.NewRecorder()
|
|
server.adminHistorySummary(summaryRecorder, summaryRequest)
|
|
if summaryRecorder.Code != 200 || !bytes.Contains(summaryRecorder.Body.Bytes(), []byte(`"total_tickets":1`)) {
|
|
t.Fatalf("history summary status = %d, body = %s", summaryRecorder.Code, summaryRecorder.Body.String())
|
|
}
|
|
var summary map[string]any
|
|
if err := json.Unmarshal(summaryRecorder.Body.Bytes(), &summary); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(summary["daily"].([]any)) != 1 || len(summary["projects"].([]any)) != 1 || len(summary["peak_hours"].([]any)) != 1 {
|
|
t.Fatalf("history dashboard summary = %#v", summary)
|
|
}
|
|
hourly := summary["hourly"].([]any)
|
|
if len(hourly) != 2 {
|
|
t.Fatalf("history hourly summary = %#v, want separate issue and call hours", hourly)
|
|
}
|
|
firstHour := hourly[0].(map[string]any)
|
|
secondHour := hourly[1].(map[string]any)
|
|
if firstHour["hour"] != float64(17) || firstHour["issued_tickets"] != float64(1) || firstHour["called_tickets"] != float64(0) || secondHour["hour"] != float64(18) || secondHour["issued_tickets"] != float64(0) || secondHour["called_tickets"] != float64(1) {
|
|
t.Fatalf("history hourly issue/call separation = %#v / %#v", firstHour, secondHour)
|
|
}
|
|
}
|