package app import ( "context" "encoding/json" "fmt" "net/http" "net/http/httptest" "runtime" "strings" "testing" "time" "hyapp/internal/testutil/mysqlschema" "hyapp/pkg/activitymq" mysqlstorage "hyapp/services/statistics-service/internal/storage/mysql" ) func TestStatisticsActivityTagExpressionOnlyIncludesModeledFacts(t *testing.T) { expression, err := activitymq.LegacyCompatibleTagExpression(statisticsActivityEventTypes()...) if err != nil { t.Fatalf("activity tag expression: %v", err) } for _, tag := range []string{ activitymq.TagActivityTemplateFact, activitymq.EventTypeGiftScored, activitymq.EventTypeTaskProgressed, activitymq.EventTypeTaskClaimed, activitymq.EventTypeActivityVisited, } { if !strings.Contains(expression, tag) { t.Fatalf("activity selector %q misses %q", expression, tag) } } } func TestActivityTemplateDataHTTPRejectsPartialUTCDay(t *testing.T) { start := time.Date(2026, time.July, 14, 1, 0, 0, 0, time.UTC).UnixMilli() request := httptest.NewRequest(http.MethodGet, fmt.Sprintf( "/internal/v1/statistics/activity-templates/data?app_code=lalu&template_id=template-1&published_version=2&start_ms=%d&end_ms=%d", start, start+24*time.Hour.Milliseconds(), ), nil) response := httptest.NewRecorder() (&queryHTTPServer{}).activityTemplateData(response, request) if response.Code != http.StatusBadRequest || !strings.Contains(response.Body.String(), "UTC 00:00") { t.Fatalf("unexpected partial-day response: status=%d body=%s", response.Code, response.Body.String()) } } func TestActivityTemplateMQProjectionAndQueryHTTPAreIdempotent(t *testing.T) { ctx := context.Background() _, file, _, _ := runtime.Caller(0) schema := mysqlschema.New(t, mysqlschema.Config{ InitDBPath: mysqlschema.InitDBPath(t, file, "../../deploy/mysql/initdb/001_statistics_service.sql"), DatabasePrefix: "hy_stats_activity_template", }) repo, err := mysqlstorage.Open(ctx, schema.DSN) if err != nil { t.Fatalf("open statistics repository: %v", err) } t.Cleanup(func() { _ = repo.Close() }) dayStart := time.Date(2026, time.July, 14, 0, 0, 0, 0, time.UTC) base := activitymq.ActivityTemplateFactMessage{ AppCode: "lalu", TemplateID: "template-1", TemplateCode: "summer_rank", PublishedVersion: 2, StatDay: "2026-07-14", RegionID: 210, OccurredAtMS: dayStart.Add(8 * time.Hour).UnixMilli(), } facts := []activitymq.ActivityTemplateFactMessage{ mergeActivityFact(base, activitymq.ActivityTemplateFactMessage{ EventID: "activity:visit:1", EventType: activitymq.EventTypeActivityVisited, UserID: 42, VisitCountDelta: 1, }), mergeActivityFact(base, activitymq.ActivityTemplateFactMessage{ EventID: "activity:gift:1", EventType: activitymq.EventTypeGiftScored, UserID: 42, GiftID: "rose", GiftCountDelta: 2, CoinAmountDelta: 200, }), mergeActivityFact(base, activitymq.ActivityTemplateFactMessage{ EventID: "activity:gift:2", EventType: activitymq.EventTypeGiftScored, UserID: 43, RegionID: 211, GiftID: "rose", GiftCountDelta: 1, CoinAmountDelta: 100, }), mergeActivityFact(base, activitymq.ActivityTemplateFactMessage{ EventID: "activity:task:1", EventType: activitymq.EventTypeTaskProgressed, UserID: 42, TaskKey: "send_gift", TaskType: "gift", ProgressDelta: 5, CompletedDelta: 1, }), mergeActivityFact(base, activitymq.ActivityTemplateFactMessage{ EventID: "activity:task:2", EventType: activitymq.EventTypeTaskProgressed, UserID: 43, RegionID: 211, TaskKey: "send_gift", TaskType: "gift", ProgressDelta: 2, }), mergeActivityFact(base, activitymq.ActivityTemplateFactMessage{ EventID: "activity:claim:1", EventType: activitymq.EventTypeTaskClaimed, UserID: 42, TaskKey: "send_gift", TaskType: "gift", ClaimedDelta: 1, }), } for _, fact := range facts { body, encodeErr := activitymq.EncodeActivityTemplateFactMessage(fact) if encodeErr != nil { t.Fatalf("encode %s: %v", fact.EventID, encodeErr) } if err := ConsumeActivityOutboxBody(ctx, repo, body); err != nil { t.Fatalf("consume %s: %v", fact.EventID, err) } // RocketMQ is at-least-once. Replaying the same event must leave all // counters and participant projections unchanged. if fact.EventID == "activity:gift:1" { if err := ConsumeActivityOutboxBody(ctx, repo, body); err != nil { t.Fatalf("consume duplicate %s: %v", fact.EventID, err) } } } server, err := newQueryHTTPServer("127.0.0.1:0", repo) if err != nil { t.Fatalf("create query server: %v", err) } t.Cleanup(func() { closeCtx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() _ = server.Close(closeCtx) }) go func() { _ = server.Run() }() url := fmt.Sprintf( "http://%s/internal/v1/statistics/activity-templates/data?app_code=lalu&template_id=template-1&published_version=2&start_ms=%d&end_ms=%d", server.listener.Addr().String(), dayStart.UnixMilli(), dayStart.Add(24*time.Hour).UnixMilli(), ) response, err := http.Get(url) if err != nil { t.Fatalf("query activity data: %v", err) } defer response.Body.Close() if response.StatusCode != http.StatusOK { t.Fatalf("unexpected status: %s", response.Status) } var data mysqlstorage.ActivityTemplateData if err := json.NewDecoder(response.Body).Decode(&data); err != nil { t.Fatalf("decode activity data: %v", err) } if data.Overview.Participants != 2 || data.Overview.VisitCount != 1 || data.Overview.GiftUsers != 2 || data.Overview.TaskUsers != 2 || data.Overview.GiftEventCount != 2 || data.Overview.GiftCount != 3 || data.Overview.CoinAmount != 300 || data.Overview.TaskProgress != 7 || data.Overview.TaskCompleted != 1 || data.Overview.TaskClaimed != 1 { t.Fatalf("unexpected overview: %+v", data.Overview) } if data.Granularity != "day" || data.Timezone != "UTC" || data.DimensionLimit != 100 || data.GiftSort != "coin_amount_desc,gift_count_desc,gift_id_asc" || data.TaskSort != "task_completed_desc,task_claimed_desc,task_key_asc" { t.Fatalf("unexpected query semantics: %+v", data) } if len(data.Trend) != 1 || data.Trend[0].Participants != 2 || data.Trend[0].VisitCount != 1 || data.Trend[0].GiftUsers != 2 || data.Trend[0].TaskUsers != 2 { t.Fatalf("unexpected trend: %+v", data.Trend) } if len(data.Gifts) != 1 || data.Gifts[0].GiftID != "rose" || data.Gifts[0].GiftUsers != 2 || data.Gifts[0].GiftCount != 3 { t.Fatalf("unexpected gifts: %+v", data.Gifts) } if len(data.Tasks) != 1 || data.Tasks[0].TaskKey != "send_gift" || data.Tasks[0].TaskUsers != 2 || data.Tasks[0].TaskClaimed != 1 { t.Fatalf("unexpected tasks: %+v", data.Tasks) } regionResponse, err := http.Get(url + "®ion_id=210") if err != nil { t.Fatalf("query regional activity data: %v", err) } defer regionResponse.Body.Close() if regionResponse.StatusCode != http.StatusOK { t.Fatalf("unexpected regional status: %s", regionResponse.Status) } var regionData mysqlstorage.ActivityTemplateData if err := json.NewDecoder(regionResponse.Body).Decode(®ionData); err != nil { t.Fatalf("decode regional activity data: %v", err) } if regionData.RegionID != 210 || regionData.Overview.Participants != 1 || regionData.Overview.VisitCount != 1 || regionData.Overview.GiftUsers != 1 || regionData.Overview.TaskUsers != 1 || regionData.Overview.GiftCount != 2 || regionData.Overview.CoinAmount != 200 || regionData.Overview.TaskProgress != 5 || regionData.Overview.TaskCompleted != 1 || regionData.Overview.TaskClaimed != 1 { t.Fatalf("unexpected regional overview: %+v", regionData.Overview) } } func mergeActivityFact(base, override activitymq.ActivityTemplateFactMessage) activitymq.ActivityTemplateFactMessage { if override.EventID != "" { base.EventID = override.EventID } if override.EventType != "" { base.EventType = override.EventType } if override.RegionID != 0 { base.RegionID = override.RegionID } base.UserID = override.UserID base.GiftID = override.GiftID base.TaskKey = override.TaskKey base.TaskType = override.TaskType base.GiftCountDelta = override.GiftCountDelta base.CoinAmountDelta = override.CoinAmountDelta base.ProgressDelta = override.ProgressDelta base.CompletedDelta = override.CompletedDelta base.ClaimedDelta = override.ClaimedDelta base.VisitCountDelta = override.VisitCountDelta if override.AttributionAtMS > 0 { base.AttributionAtMS = override.AttributionAtMS } return base }