Skip to content

Commit

Permalink
Add tests to event cache
Browse files Browse the repository at this point in the history
  • Loading branch information
jveski committed Feb 27, 2024
1 parent 91adb58 commit 3ae8eeb
Show file tree
Hide file tree
Showing 58 changed files with 23,763 additions and 119 deletions.
4 changes: 4 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ require (
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/go-resty/resty/v2 v2.7.0 // indirect
github.com/gofrs/uuid v4.4.0+incompatible // indirect
github.com/golang-jwt/jwt/v4 v4.5.0 // indirect
Expand All @@ -34,16 +35,19 @@ require (
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect
github.com/opentracing/opentracing-go v1.2.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_model v0.5.0 // indirect
github.com/prometheus/common v0.45.0 // indirect
github.com/prometheus/procfs v0.12.0 // indirect
github.com/segmentio/ksuid v1.0.4 // indirect
github.com/shopspring/decimal v1.3.1 // indirect
github.com/stretchr/objx v0.5.0 // indirect
github.com/stretchr/testify v1.8.4 // indirect
github.com/teambition/rrule-go v1.8.2 // indirect
golang.org/x/crypto v0.17.0 // indirect
golang.org/x/net v0.18.0 // indirect
golang.org/x/sys v0.15.0 // indirect
golang.org/x/text v0.14.0 // indirect
google.golang.org/protobuf v1.31.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
1 change: 1 addition & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
Expand Down
6 changes: 4 additions & 2 deletions internal/conf/env.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package conf

import (
"log"
"time"

"github.com/kelseyhightower/envconfig"
)
Expand Down Expand Up @@ -33,8 +34,9 @@ type Env struct {
DocusealToken string `split_words:"true"`

// Discord
DiscordGuildID string `split_words:"true"`
DiscordBotToken string `split_words:"true"`
DiscordGuildID string `split_words:"true"`
DiscordBotToken string `split_words:"true"`
DiscordInterval time.Duration `split_words:"true" default:"60s"`

// Age (secrets encrpytion)
AgePublicKey string `split_words:"true"`
Expand Down
162 changes: 131 additions & 31 deletions internal/events/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,66 +7,158 @@ import (
"io"
"log"
"net/http"
"sort"
"strings"
"sync"
"time"

"github.com/TheLab-ms/profile/internal/conf"
"github.com/teambition/rrule-go"
)

// EventCache polls Discord events, caches them in-memory, and materializes recurring events.
type EventCache struct {
mut sync.Mutex
state []*Event
state []*event

Env *conf.Env
env *conf.Env
baseURL string
}

func (e *EventCache) GetEvents() []*Event {
func NewCache(env *conf.Env) *EventCache {
return &EventCache{env: env, baseURL: "https://discord.com"}
}

func (e *EventCache) GetEvents(until time.Time) ([]*Event, error) {
now := time.Now()

e.mut.Lock()
defer e.mut.Unlock()
return e.state
events := e.state
e.mut.Unlock()

// Expand the recurrence of every event
var expanded []*Event
for _, event := range events {
// Support a magic location string to designate members only events
membersOnly := strings.Contains(strings.ToLower(event.Name), "(member event)")

if event.Recurrence == nil {
expanded = append(expanded, &Event{
Name: event.Name,
Description: event.Description,
Start: event.Start.UTC().Unix(),
End: event.End.UTC().Unix(),
MembersOnly: membersOnly,
})
continue
}

// Expand out the recurring events into a slice of start times
ropts := rrule.ROption{
Freq: rrule.Frequency(event.Recurrence.Freq),
Interval: event.Recurrence.Interval,
Dtstart: event.Recurrence.Start.UTC(),
Bymonth: event.Recurrence.ByMonth,
}
for _, day := range event.Recurrence.ByWeekday {
// annoying that the library doesn't expose days of the week as ints - they line up with discord's representation anyway
switch day {
case 0:
ropts.Byweekday = append(ropts.Byweekday, rrule.MO)
case 1:
ropts.Byweekday = append(ropts.Byweekday, rrule.TU)
case 2:
ropts.Byweekday = append(ropts.Byweekday, rrule.WE)
case 3:
ropts.Byweekday = append(ropts.Byweekday, rrule.TH)
case 4:
ropts.Byweekday = append(ropts.Byweekday, rrule.FR)
case 5:
ropts.Byweekday = append(ropts.Byweekday, rrule.SA)
case 6:
ropts.Byweekday = append(ropts.Byweekday, rrule.SU)
}
}
rule, err := rrule.NewRRule(ropts)
if err != nil {
return nil, fmt.Errorf("expanding recurring events: %w", err)
}

// Our expansion ends either when the "until" is reached or when the event's recurrence ends
var end time.Time
if event.Recurrence.End != nil {
end = *event.Recurrence.End
} else {
end = until
}

// Calculate the end time by adding the duration of the event to the start time
times := rule.Between(now, end, true)
duration := event.End.Sub(event.Start)
for _, start := range times {
expanded = append(expanded, &Event{
Name: event.Name,
Description: event.Description,
Start: start.UTC().Unix(),
End: start.Add(duration).Unix(),
MembersOnly: membersOnly,
})
}
}

sort.Slice(expanded, func(i, j int) bool { return expanded[i].Start < expanded[j].Start })
return expanded, nil
}

func (e *EventCache) Start() {
func (e *EventCache) Start(ctx context.Context) {
// Don't run if not configured
if e.Env.DiscordBotToken == "" || e.Env.DiscordGuildID == "" {
if e.env.DiscordBotToken == "" || e.env.DiscordGuildID == "" {
return
}

go func() {
ticker := time.NewTicker(time.Minute)
ticker := time.NewTicker(e.env.DiscordInterval)
defer ticker.Stop()

for range ticker.C {
list := e.listEvents()

e.mut.Lock()
if list == nil && e.state == nil {
log.Fatalf("failed to warm Discord events cache, and no previous cache was set - exiting")
e.mut.Unlock()
return
}
if list == nil {
e.mut.Unlock()
for {
e.fillCache(ctx)
select {
case <-ticker.C:
case <-ctx.Done():
return
}
e.state = list
log.Printf("updated cache of %d events", len(list))
e.mut.Unlock()
}
}()
}

func (e *EventCache) listEvents() []*Event {
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
func (e *EventCache) fillCache(ctx context.Context) {
list := e.listEvents(ctx)

e.mut.Lock()
if list == nil && e.state == nil {
log.Fatalf("failed to warm Discord events cache, and no previous cache was set - exiting")
e.mut.Unlock()
return
}
if list == nil {
e.mut.Unlock()
return
}
e.state = list
log.Printf("updated cache of %d events", len(list))
e.mut.Unlock()
}

func (e *EventCache) listEvents(ctx context.Context) []*event {
ctx, cancel := context.WithTimeout(ctx, time.Second*10)
defer cancel()

req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("https://discord.com/api/v10/guilds/%s/scheduled-events", e.Env.DiscordGuildID), nil)
req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("%s/api/v10/guilds/%s/scheduled-events", e.baseURL, e.env.DiscordGuildID), nil)
if err != nil {
log.Printf("error creating request to list discord events: %s", err)
return nil
}
req.Header.Add("Authorization", "Bot "+e.Env.DiscordBotToken)
req.Header.Add("Authorization", "Bot "+e.env.DiscordBotToken)

resp, err := http.DefaultClient.Do(req)
if err != nil {
Expand All @@ -81,7 +173,7 @@ func (e *EventCache) listEvents() []*Event {
return nil
}

events := []*Event{}
events := []*event{}
err = json.NewDecoder(resp.Body).Decode(&events)
if err != nil {
log.Printf("got invalid json back from discord: %s", err)
Expand All @@ -91,24 +183,32 @@ func (e *EventCache) listEvents() []*Event {
return events
}

// Event is a partial representation of the Discord scheduled events API schema.
type Event struct {
// event is a partial representation of the Discord scheduled events API schema.
type event struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Start time.Time `json:"scheduled_start_time"`
End time.Time `json:"scheduled_end_time"`
Recurrence *Recurrence `json:"recurrence_rule"`
Recurrence *recurrence `json:"recurrence_rule"`
Metadata struct {
Location string `json:"location"`
} `json:"entity_metadata"`
}

type Recurrence struct {
type recurrence struct {
Start time.Time `json:"start"`
End *time.Time `json:"end"`
Freq int `json:"frequency"`
Interval int `json:"interval"`
ByWeekday []int `json:"by_weekday"`
ByMonth []int `json:"by_month"`
}

type Event struct {
Name string `json:"name"`
Description string `json:"description"`
Start int64 `json:"start"`
End int64 `json:"end"`
MembersOnly bool `json:"membersOnly"`
}
51 changes: 51 additions & 0 deletions internal/events/events_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package events

import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"

"github.com/TheLab-ms/profile/internal/conf"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestHappyPath(t *testing.T) {
env := &conf.Env{
DiscordGuildID: "test-guild",
DiscordBotToken: "test-bot-token",
}
c := NewCache(env)

// Serve a fake discord API
svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v10/guilds/test-guild/scheduled-events", r.URL.Path)

file, err := os.Open("fixtures/events.json")
require.NoError(t, err)
io.Copy(w, file)
file.Close()
}))
t.Cleanup(svr.Close)
c.baseURL = svr.URL

c.fillCache(context.Background())

// Get 30 days of events from when the fixture was captured
until := time.Unix(1709006369, 0).Add(time.Hour * 24 * 30)
events, err := c.GetEvents(until)
require.NoError(t, err)

by, err := json.Marshal(&events)
require.NoError(t, err)

// Compare the cache against a fixture
expect, err := os.ReadFile("fixtures/events.exp.json")
require.NoError(t, err)
assert.JSONEq(t, string(expect), string(by))
}
Loading

0 comments on commit 3ae8eeb

Please sign in to comment.