需求:无需查询公示 Token,直接通过项目编码打开单项目公示页。 实现:规范化项目编码并按 code 查询,同时保留既有 Token 哈希查询;补充前后端及集成回归测试。
254 lines
9.0 KiB
Go
254 lines
9.0 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"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 TestCurrentDayViewsIgnoreYesterdayRunningSessionPostgresIntegration(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{0x71}, 32),
|
|
PhoneHMACKey: bytes.Repeat([]byte{0x72}, 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 }
|
|
shanghai := time.FixedZone("Asia/Shanghai", 8*60*60)
|
|
yesterday := time.Date(2026, 7, 29, 0, 0, 0, 0, shanghai)
|
|
|
|
displayToken := uuid.NewString() + uuid.NewString()
|
|
displayTokenHash := security.HashToken(displayToken)
|
|
projectID := uuid.NewString()
|
|
project := model.Project{
|
|
ID: projectID, Code: "ROLL" + strings.ToUpper(uuid.NewString()[:6]), Name: "Rollover 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,
|
|
ExperiencedPeopleStart: 10, DisplayTokenHash: &displayTokenHash,
|
|
DeviceSimulationMode: "DISABLED", CreatedAt: now, UpdatedAt: now,
|
|
}
|
|
if err := db.Create(&project).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
user := model.User{
|
|
ID: uuid.NewString(), Username: "roll_" + uuid.NewString()[:8], PasswordHash: "unused",
|
|
Role: model.RoleStaff, Active: true, CreatedAt: now, UpdatedAt: now,
|
|
}
|
|
if err := db.Create(&user).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.Create(&model.UserProject{UserID: user.ID, ProjectID: projectID, CreatedAt: now}).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
session := model.QueueSession{
|
|
ID: uuid.NewString(), ProjectID: projectID, BusinessDate: yesterday,
|
|
Status: "RUNNING", NextTicketNumber: 2, Revision: 1,
|
|
OpenedAt: yesterday, CreatedAt: yesterday, UpdatedAt: yesterday,
|
|
}
|
|
if err := db.Create(&session).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
phone, err := security.NormalizePhone("13800138000")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
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: yesterday,
|
|
PersonalDataPurgeAt: now.Add(30 * 24 * time.Hour), CreatedBy: user.ID,
|
|
CreatedAt: yesterday, UpdatedAt: yesterday,
|
|
}
|
|
if err := db.Create(&ticket).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
t.Run("staff queue", func(t *testing.T) {
|
|
request := httptest.NewRequest(http.MethodGet, "/api/staff/projects/"+projectID+"/queue", nil)
|
|
request.SetPathValue("id", projectID)
|
|
request = request.WithContext(context.WithValue(request.Context(), principalKey, principal{User: user}))
|
|
recorder := httptest.NewRecorder()
|
|
server.queueSnapshot(recorder, request)
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String())
|
|
}
|
|
var response struct {
|
|
Session any `json:"session"`
|
|
Metrics struct {
|
|
WaitingTickets int64 `json:"waiting_ticket_count"`
|
|
WaitingPeople int64 `json:"waiting_people_count"`
|
|
} `json:"metrics"`
|
|
}
|
|
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if response.Session != nil || response.Metrics.WaitingTickets != 0 || response.Metrics.WaitingPeople != 0 {
|
|
t.Fatalf("today staff queue reused yesterday: %#v", response)
|
|
}
|
|
})
|
|
|
|
t.Run("display", func(t *testing.T) {
|
|
recorder := httptest.NewRecorder()
|
|
request := httptest.NewRequest(http.MethodGet, "/api/display/"+displayToken+"/snapshot", nil)
|
|
server.Handler().ServeHTTP(recorder, request)
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String())
|
|
}
|
|
var response displaySnapshotDTO
|
|
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if response.WaitingTicketCount != 0 || response.WaitingPeopleCount != 0 {
|
|
t.Fatalf("today display reused yesterday: %#v", response)
|
|
}
|
|
if response.ExperiencedPeople != int64(project.ExperiencedPeopleStart) {
|
|
t.Fatalf("experienced people = %d, want configured start %d", response.ExperiencedPeople, project.ExperiencedPeopleStart)
|
|
}
|
|
})
|
|
|
|
t.Run("display by project code", func(t *testing.T) {
|
|
recorder := httptest.NewRecorder()
|
|
request := httptest.NewRequest(http.MethodGet, "/api/display/"+strings.ToLower(project.Code)+"/snapshot", nil)
|
|
server.Handler().ServeHTTP(recorder, request)
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String())
|
|
}
|
|
var response displaySnapshotDTO
|
|
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if response.Project.ID != project.ID {
|
|
t.Fatalf("project = %q, want %q", response.Project.ID, project.ID)
|
|
}
|
|
})
|
|
|
|
t.Run("admin project", func(t *testing.T) {
|
|
projection, _, _, _, _, err := server.adminProjectProjection(context.Background(), project)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if projection["waiting_ticket_count"] != int64(0) || projection["waiting_people_count"] != int64(0) {
|
|
t.Fatalf("today admin project reused yesterday: %#v", projection)
|
|
}
|
|
if projection["experienced_people"] != int64(project.ExperiencedPeopleStart) {
|
|
t.Fatalf("experienced people = %#v, want configured start %d", projection["experienced_people"], project.ExperiencedPeopleStart)
|
|
}
|
|
})
|
|
|
|
t.Run("admin overview", func(t *testing.T) {
|
|
recorder := httptest.NewRecorder()
|
|
request := httptest.NewRequest(http.MethodGet, "/api/admin/overview", nil)
|
|
server.adminOverview(recorder, request)
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String())
|
|
}
|
|
var response struct {
|
|
Summary struct {
|
|
WaitingTickets int64 `json:"waiting_ticket_count"`
|
|
WaitingPeople int64 `json:"waiting_people_count"`
|
|
} `json:"summary"`
|
|
Totals struct {
|
|
ActiveSessions int64 `json:"active_sessions"`
|
|
WaitingTickets int64 `json:"waiting_ticket_count"`
|
|
WaitingPeople int64 `json:"waiting_people_count"`
|
|
} `json:"totals"`
|
|
Projects []struct {
|
|
ID string `json:"id"`
|
|
WaitingTickets int64 `json:"waiting_ticket_count"`
|
|
WaitingPeople int64 `json:"waiting_people_count"`
|
|
} `json:"projects"`
|
|
ActiveTickets []struct {
|
|
ID string `json:"id"`
|
|
} `json:"active_tickets"`
|
|
}
|
|
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if response.Summary.WaitingTickets != 0 || response.Summary.WaitingPeople != 0 ||
|
|
response.Totals.ActiveSessions != 0 || response.Totals.WaitingTickets != 0 || response.Totals.WaitingPeople != 0 {
|
|
t.Fatalf("today admin overview totals reused yesterday data: %#v", response)
|
|
}
|
|
for _, item := range response.Projects {
|
|
if item.ID == projectID && (item.WaitingTickets != 0 || item.WaitingPeople != 0) {
|
|
t.Fatalf("today admin overview reused yesterday project data: %#v", item)
|
|
}
|
|
}
|
|
for _, item := range response.ActiveTickets {
|
|
if item.ID == ticket.ID {
|
|
t.Fatalf("today admin overview returned yesterday active ticket: %#v", item)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("phone lookup", func(t *testing.T) {
|
|
recorder := httptest.NewRecorder()
|
|
request := httptest.NewRequest(http.MethodPost, "/api/internal/status/search",
|
|
strings.NewReader(`{"phone":"13800138000"}`))
|
|
server.statusByPhone(recorder, request, false)
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String())
|
|
}
|
|
var response struct {
|
|
Tickets []json.RawMessage `json:"tickets"`
|
|
}
|
|
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(response.Tickets) != 0 {
|
|
t.Fatalf("today phone lookup returned yesterday tickets: %s", recorder.Body.String())
|
|
}
|
|
})
|
|
}
|