feat: add historical data management
This commit is contained in:
78
server/internal/httpapi/history_test.go
Normal file
78
server/internal/httpapi/history_test.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestParseHistoryFilterUsesRecentSevenDaysByDefault(t *testing.T) {
|
||||
server := &Server{now: func() time.Time { return time.Date(2026, 7, 16, 8, 0, 0, 0, time.UTC) }}
|
||||
filter, err := server.parseHistoryFilter(httptest.NewRequest("GET", "/api/admin/history/tickets", nil), historyTicketStatuses)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if filter.From != "2026-07-10" || filter.To != "2026-07-16" || filter.Page != 1 || filter.PageSize != 20 {
|
||||
t.Fatalf("default history filter = %#v", filter)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHistoryFilterRejectsUnsafeRangeAndPageSize(t *testing.T) {
|
||||
server := &Server{now: time.Now}
|
||||
for _, path := range []string{
|
||||
"/api/admin/history/tickets?from=2026-01-01&to=2026-02-01",
|
||||
"/api/admin/history/tickets?from=2026-07-10&to=2026-07-16&page_size=101",
|
||||
} {
|
||||
if _, err := server.parseHistoryFilter(httptest.NewRequest("GET", path, nil), historyTicketStatuses); err == nil {
|
||||
t.Fatalf("expected history filter to be rejected: %s", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoryTicketViewMasksPersonalDataAndHonorsAnonymization(t *testing.T) {
|
||||
server, ticket := encryptedTicketFixture(t)
|
||||
row := historyTicketRow{
|
||||
ID: ticket.ID, ProjectID: ticket.ProjectID, ProjectName: "云岭漂流", ProjectCode: "RIDE",
|
||||
QueueSessionID: "session-a", BusinessDate: time.Date(2026, 7, 16, 0, 0, 0, 0, time.UTC),
|
||||
TicketNumber: 42, DisplayNumber: ticket.DisplayNumber, PartySize: ticket.PartySize, Status: ticket.Status,
|
||||
JoinedAt: ticket.JoinedAt, PhoneCiphertext: ticket.PhoneCiphertext, PhoneNonce: ticket.PhoneNonce, PhoneHMAC: ticket.PhoneHMAC,
|
||||
LastNameCiphertext: ticket.LastNameCiphertext, LastNameNonce: ticket.LastNameNonce, Honorific: ticket.Honorific,
|
||||
}
|
||||
masked, err := server.historyTicketView(row, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if masked["phone"] != nil || masked["last_name"] != nil || masked["phone_masked"] != "****5678" {
|
||||
t.Fatalf("masked history view leaked personal data: %#v", masked)
|
||||
}
|
||||
revealed, err := server.historyTicketView(row, true)
|
||||
if err != nil || revealed["phone"] != "+8613812345678" || revealed["last_name"] != "林" {
|
||||
t.Fatalf("revealed history view = %#v, err = %v", revealed, err)
|
||||
}
|
||||
marked := time.Date(2026, 10, 14, 0, 0, 0, 0, time.UTC)
|
||||
row.HistoryAnonymizedAt = &marked
|
||||
anonymized, err := server.historyTicketView(row, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if anonymized["personal_data_status"] != "ANONYMIZED" || anonymized["phone"] != nil || anonymized["phone_masked"] != nil {
|
||||
t.Fatalf("anonymized history view = %#v", anonymized)
|
||||
}
|
||||
if anonymized["honorific"] != nil {
|
||||
t.Fatalf("anonymized history view leaked honorific: %#v", anonymized)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistorySummaryAlertsUseMinimumSampleAndOperationalThresholds(t *testing.T) {
|
||||
longWait := 15.0 * 60
|
||||
alerts := historySummaryAlerts([]historyProjectRow{
|
||||
{ProjectID: "project-alert", ProjectName: "峡谷漂流", TotalTickets: 10, MissedTickets: 3, CompletedTickets: 6, AverageWait: &longWait},
|
||||
{ProjectID: "project-small", ProjectName: "小样本项目", TotalTickets: 4, MissedTickets: 4, CompletedTickets: 0, AverageWait: &longWait},
|
||||
})
|
||||
if len(alerts) != 1 {
|
||||
t.Fatalf("alerts = %#v, want one alert after minimum sample filtering", alerts)
|
||||
}
|
||||
if alerts[0]["type"] != "MISSED_RATE" || alerts[0]["project_id"] != "project-alert" {
|
||||
t.Fatalf("alert = %#v", alerts[0])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user