202 lines
8.2 KiB
Go
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 + "®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
|
|
}
|