hyapp-server/services/statistics-service/internal/app/activity_template_flow_test.go
2026-07-15 09:43:33 +08:00

202 lines
8.2 KiB
Go

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 + "&region_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(&regionData); 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
}