Skip to content

Commit 9d8e9cc

Browse files
committed
Add webhook sink MVP and fix env overrides recursion
1 parent 8909552 commit 9d8e9cc

5 files changed

Lines changed: 134 additions & 0 deletions

File tree

analytics/aggregation_additional_test.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,3 +52,21 @@ func TestAggregationEngineWeeklyMonthly(t *testing.T) {
5252
t.Fatalf("unexpected monthly agg: %+v", monthly)
5353
}
5454
}
55+
56+
func TestComprehensiveMetricsTopMetrics(t *testing.T) {
57+
metrics := NewComprehensiveMetrics()
58+
now := time.Now().UTC()
59+
metrics.OnEvent(core.Event{Type: core.EventPointsAdded, UserID: "u1", Metric: core.MetricXP, Delta: 10, Time: now})
60+
metrics.OnEvent(core.Event{Type: core.EventPointsAdded, UserID: "u1", Metric: core.MetricPoints, Delta: 20, Time: now})
61+
metrics.OnEvent(core.Event{Type: core.EventBadgeAwarded, UserID: "u1", Badge: "b1", Time: now})
62+
63+
top := metrics.GetTopMetrics(5)
64+
totalPoints, ok := top["total_points_awarded"].(int64)
65+
if !ok || totalPoints != 30 {
66+
t.Fatalf("unexpected total points: %v", top["total_points_awarded"])
67+
}
68+
totalBadges, ok := top["total_badges_awarded"].(int64)
69+
if !ok || totalBadges != 1 {
70+
t.Fatalf("unexpected total badges: %v", top["total_badges_awarded"])
71+
}
72+
}

config/config_test.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,21 @@ func TestLoad(t *testing.T) {
2424
assert.Equal(t, "json", cfg.Logging.Format)
2525
}
2626

27+
func TestLoad_WithEnvOverrides(t *testing.T) {
28+
t.Setenv("GAMIFYKIT_SECURITY_API_KEYS", "k1,k2")
29+
t.Setenv("GAMIFYKIT_SECURITY_RATE_LIMIT_ENABLED", "true")
30+
t.Setenv("GAMIFYKIT_SECURITY_RATE_LIMIT_RPM", "100")
31+
t.Setenv("GAMIFYKIT_SECURITY_RATE_LIMIT_BURST", "50")
32+
cfg, err := Load()
33+
require.NoError(t, err)
34+
require.Len(t, cfg.Security.APIKeys, 2)
35+
assert.Contains(t, cfg.Security.APIKeys, "k1")
36+
assert.Contains(t, cfg.Security.APIKeys, "k2")
37+
assert.True(t, cfg.Security.EnableRateLimit)
38+
assert.Equal(t, 100, cfg.Security.RateLimit.RequestsPerMinute)
39+
assert.Equal(t, 50, cfg.Security.RateLimit.BurstSize)
40+
}
41+
2742
func TestLoadFromFile(t *testing.T) {
2843
// Create a temporary config file
2944
configContent := `{

config/env.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,16 @@ func loadFromEnvRecursive(v interface{}, prefix string) error {
3232
field := val.Field(i)
3333
fieldType := typ.Field(i)
3434

35+
// Recurse into nested structs to honor their env tags
36+
if field.Kind() == reflect.Struct {
37+
if field.CanAddr() {
38+
if err := loadFromEnvRecursive(field.Addr().Interface(), prefix); err != nil {
39+
return err
40+
}
41+
}
42+
continue
43+
}
44+
3545
// Get the env tag
3646
envTag := fieldType.Tag.Get("env")
3747
if envTag == "" {

integrations/webhook/webhook.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
package webhook
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/json"
7+
"net/http"
8+
"time"
9+
10+
"gamifykit/core"
11+
)
12+
13+
// Sink posts domain events to configured HTTP endpoints.
14+
// It is synchronous for determinism; keep handlers fast or wrap with buffering if needed.
15+
type Sink struct {
16+
client *http.Client
17+
endpoints []string
18+
}
19+
20+
// Option configures a Sink.
21+
type Option func(*Sink)
22+
23+
// WithClient overrides the HTTP client (defaults to 2s timeout).
24+
func WithClient(c *http.Client) Option {
25+
return func(s *Sink) {
26+
if c != nil {
27+
s.client = c
28+
}
29+
}
30+
}
31+
32+
// New creates a webhook sink.
33+
func New(endpoints []string, opts ...Option) *Sink {
34+
s := &Sink{
35+
client: &http.Client{Timeout: 2 * time.Second},
36+
}
37+
for _, opt := range opts {
38+
opt(s)
39+
}
40+
s.endpoints = append([]string{}, endpoints...)
41+
return s
42+
}
43+
44+
// OnEvent posts the event JSON to all endpoints; errors are ignored for now (MVP).
45+
func (s *Sink) OnEvent(e core.Event) {
46+
if len(s.endpoints) == 0 {
47+
return
48+
}
49+
body, err := json.Marshal(e)
50+
if err != nil {
51+
return
52+
}
53+
for _, ep := range s.endpoints {
54+
req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, ep, bytes.NewReader(body))
55+
if err != nil {
56+
continue
57+
}
58+
req.Header.Set("Content-Type", "application/json")
59+
_, _ = s.client.Do(req)
60+
}
61+
}
62+
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package webhook
2+
3+
import (
4+
"io/ioutil"
5+
"net/http"
6+
"net/http/httptest"
7+
"sync/atomic"
8+
"testing"
9+
10+
"gamifykit/core"
11+
)
12+
13+
func TestSink_OnEventPostsToEndpoints(t *testing.T) {
14+
var hits int32
15+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
16+
atomic.AddInt32(&hits, 1)
17+
_, _ = ioutil.ReadAll(r.Body)
18+
_ = r.Body.Close()
19+
}))
20+
defer srv.Close()
21+
22+
sink := New([]string{srv.URL})
23+
sink.OnEvent(core.NewPointsAdded("u1", core.MetricXP, 5, 5))
24+
25+
if atomic.LoadInt32(&hits) != 1 {
26+
t.Fatalf("expected 1 hit, got %d", hits)
27+
}
28+
}
29+

0 commit comments

Comments
 (0)