- Event model + ToJSON - EventSink interface + ChannelEventSink (non-blocking Submit) - event_repo: batch INSERT ON CONFLICT DO NOTHING dedup - event_service: 7-type whitelist + 1KB props limit + ReceivedAt auto-fill - event_flusher: 100/1s batch + sync metric_recent_level_ups on level_up - metric_weekly + metric_upcoming workers (5min/15min with pg_try_advisory_lock) - partitioner: 7-day pre-create + 30-day cleanup (00:05 create / 00:30 cleanup) - 22 unit + integration tests (model/repo/service/sink/worker) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
125 lines
3.6 KiB
Go
125 lines
3.6 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/topfans/backend/services/statisticService/model"
|
|
"github.com/topfans/backend/services/statisticService/sink"
|
|
)
|
|
|
|
// mockSink 记录所有 submit 的事件
|
|
type mockSink struct {
|
|
events []*model.Event
|
|
err error
|
|
}
|
|
|
|
func (m *mockSink) Submit(ctx context.Context, e *model.Event) error {
|
|
if m.err != nil {
|
|
return m.err
|
|
}
|
|
m.events = append(m.events, e)
|
|
return nil
|
|
}
|
|
|
|
func (m *mockSink) SubmitBatch(ctx context.Context, es []*model.Event) error {
|
|
if m.err != nil {
|
|
return m.err
|
|
}
|
|
m.events = append(m.events, es...)
|
|
return nil
|
|
}
|
|
|
|
func (m *mockSink) Close() error { return nil }
|
|
|
|
func TestEventService_TrackEvent_Success(t *testing.T) {
|
|
ms := &mockSink{}
|
|
svc := NewEventService(ms, []string{"asset.like", "asset.mint"})
|
|
resp, err := svc.TrackEvent(context.Background(), &model.Event{
|
|
EventID: "u1", UserID: 1, StarID: 1, EventType: "asset.like",
|
|
OccurredAt: time.Now(), Properties: map[string]string{},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if resp.Accepted != 1 || resp.Rejected != 0 {
|
|
t.Fatalf("got accepted=%d rejected=%d, want 1/0", resp.Accepted, resp.Rejected)
|
|
}
|
|
if len(ms.events) != 1 {
|
|
t.Fatal("event not submitted")
|
|
}
|
|
if ms.events[0].ReceivedAt.IsZero() {
|
|
t.Fatal("ReceivedAt should be set by service")
|
|
}
|
|
}
|
|
|
|
func TestEventService_TrackEvent_InvalidEventType(t *testing.T) {
|
|
ms := &mockSink{}
|
|
svc := NewEventService(ms, []string{"asset.like"})
|
|
_, err := svc.TrackEvent(context.Background(), &model.Event{EventID: "u1", EventType: "evil.type"})
|
|
if err == nil {
|
|
t.Fatal("expected error for invalid event type")
|
|
}
|
|
if !errors.Is(err, ErrInvalidEventType) {
|
|
t.Fatalf("expected ErrInvalidEventType, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestEventService_TrackEvent_EmptyEventID(t *testing.T) {
|
|
ms := &mockSink{}
|
|
svc := NewEventService(ms, []string{"asset.like"})
|
|
_, err := svc.TrackEvent(context.Background(), &model.Event{EventType: "asset.like"})
|
|
if !errors.Is(err, ErrInvalidEventID) {
|
|
t.Fatalf("expected ErrInvalidEventID, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestEventService_TrackEvent_PropertiesTooLarge(t *testing.T) {
|
|
ms := &mockSink{}
|
|
svc := NewEventService(ms, []string{"asset.like"})
|
|
big := make(map[string]string)
|
|
for i := 0; i < 200; i++ {
|
|
big["k"+string(rune(i))] = string(make([]byte, 100))
|
|
}
|
|
_, err := svc.TrackEvent(context.Background(), &model.Event{
|
|
EventID: "u1", EventType: "asset.like", Properties: big,
|
|
})
|
|
if !errors.Is(err, ErrPropertiesTooLarge) {
|
|
t.Fatalf("expected ErrPropertiesTooLarge, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestEventService_TrackEvent_SinkError(t *testing.T) {
|
|
ms := &mockSink{err: sink.ErrChannelFull}
|
|
svc := NewEventService(ms, []string{"asset.like"})
|
|
resp, err := svc.TrackEvent(context.Background(), &model.Event{
|
|
EventID: "u1", EventType: "asset.like",
|
|
})
|
|
if err != nil {
|
|
t.Fatal("expected no error (degraded to rejected), got", err)
|
|
}
|
|
if resp.Accepted != 0 || resp.Rejected != 1 {
|
|
t.Fatalf("expected 0/1, got %d/%d", resp.Accepted, resp.Rejected)
|
|
}
|
|
}
|
|
|
|
func TestEventService_BatchTrackEvent_Mixed(t *testing.T) {
|
|
ms := &mockSink{}
|
|
svc := NewEventService(ms, []string{"asset.like", "asset.mint"})
|
|
events := []*model.Event{
|
|
{EventID: "1", EventType: "asset.like", Properties: map[string]string{}},
|
|
{EventID: "2", EventType: "asset.mint", Properties: map[string]string{}},
|
|
{EventID: "3", EventType: "evil.type"}, // 拒绝
|
|
{EventID: "", EventType: "asset.like"}, // 拒绝
|
|
}
|
|
resp, err := svc.BatchTrackEvent(context.Background(), events)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if resp.Accepted != 2 || resp.Rejected != 2 {
|
|
t.Fatalf("got %d/%d, want 2/2", resp.Accepted, resp.Rejected)
|
|
}
|
|
}
|