200 lines
6.0 KiB
Go
200 lines
6.0 KiB
Go
package icssub
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
ical "github.com/emersion/go-ical"
|
|
)
|
|
|
|
const sampleICS = "BEGIN:VCALENDAR\r\n" +
|
|
"VERSION:2.0\r\n" +
|
|
"PRODID:-//nidus//test//EN\r\n" +
|
|
"BEGIN:VEVENT\r\n" +
|
|
"UID:ev1@nidus.test\r\n" +
|
|
"DTSTAMP:20260101T000000Z\r\n" +
|
|
"DTSTART:20260805T090000Z\r\n" +
|
|
"DTEND:20260805T100000Z\r\n" +
|
|
"SUMMARY:Original title\r\n" +
|
|
"END:VEVENT\r\n" +
|
|
"END:VCALENDAR\r\n"
|
|
|
|
func mustParseEvent(raw string) ical.Event {
|
|
cal, err := ical.NewDecoder(strings.NewReader(raw)).Decode()
|
|
if err != nil {
|
|
panic("mustParseEvent: " + err.Error())
|
|
}
|
|
evs := cal.Events()
|
|
if len(evs) == 0 {
|
|
panic("mustParseEvent: no events")
|
|
}
|
|
return evs[0]
|
|
}
|
|
|
|
// TestCacheFirstFetchPopulatesEntry verifies that the very first Get for a
|
|
// URL does a foreground fetch and returns the parsed calendar.
|
|
func TestCacheFirstFetchPopulatesEntry(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(sampleICS))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
c := NewCache(time.Hour)
|
|
cal, err := c.Get(srv.URL)
|
|
if err != nil {
|
|
t.Fatalf("first Get: %v", err)
|
|
}
|
|
if len(cal.Events()) == 0 {
|
|
t.Fatalf("expected >=1 event, got 0")
|
|
}
|
|
}
|
|
|
|
// TestCacheStaleReturnsWithBackgroundRefresh verifies that once a cached
|
|
// entry is stale, Get keeps returning the *stale* copy immediately while a
|
|
// background refresh is spawned in a separate goroutine (rather than
|
|
// blocking the caller on the network).
|
|
func TestCacheStaleReturnsWithBackgroundRefresh(t *testing.T) {
|
|
var hits atomic.Int32
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
hits.Add(1)
|
|
time.Sleep(50 * time.Millisecond) // make the fetch slow enough to
|
|
w.WriteHeader(http.StatusOK) // observe as background work
|
|
_, _ = w.Write([]byte(sampleICS))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
c := NewCache(1 * time.Millisecond) // very short TTL
|
|
if _, err := c.Get(srv.URL); err != nil {
|
|
t.Fatalf("first Get: %v", err)
|
|
}
|
|
firstHits := hits.Load()
|
|
|
|
// Force staleness.
|
|
time.Sleep(3 * time.Millisecond)
|
|
|
|
// Subsequent Get must return the stale copy immediately without waiting
|
|
// for the slow server to respond — if it blocked, this call would take
|
|
// >= 50ms, which we bound with a deadline below via the hit counter.
|
|
st := time.Now()
|
|
cal, err := c.Get(srv.URL)
|
|
if err != nil {
|
|
t.Fatalf("second Get: %v", err)
|
|
}
|
|
if len(cal.Events()) == 0 {
|
|
t.Fatalf("expected event in stale response")
|
|
}
|
|
if time.Since(st) > 40*time.Millisecond {
|
|
t.Fatalf("second Get appears to have blocked on the network for %v", time.Since(st))
|
|
}
|
|
|
|
// Wait for the background refresh to complete.
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for hits.Load() == firstHits && time.Now().Before(deadline) {
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
if hits.Load() == firstHits {
|
|
t.Fatalf("background refresh did not fire (hits stayed at %d)", firstHits)
|
|
}
|
|
}
|
|
|
|
// TestCacheConcurrentFirstFetchCoalesce ensures N concurrent first calls
|
|
// for the same URL coalesce to a small number of actual origin fetches.
|
|
func TestCacheConcurrentFirstFetchCoalesce(t *testing.T) {
|
|
var hits atomic.Int32
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
hits.Add(1)
|
|
time.Sleep(40 * time.Millisecond) // widen the coalescing window
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(sampleICS))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
c := NewCache(time.Hour)
|
|
|
|
const n = 8
|
|
errs := make([]error, n)
|
|
var wg sync.WaitGroup
|
|
start := make(chan struct{})
|
|
for i := 0; i < n; i++ {
|
|
wg.Add(1)
|
|
go func(i int) {
|
|
defer wg.Done()
|
|
<-start
|
|
_, err := c.Get(srv.URL)
|
|
errs[i] = err
|
|
}(i)
|
|
}
|
|
close(start)
|
|
wg.Wait()
|
|
for _, err := range errs {
|
|
if err != nil {
|
|
t.Fatalf("goroutine error: %v", err)
|
|
}
|
|
}
|
|
// Without coalescing we'd see n=8 hits; with it we expect a small
|
|
// constant (the first caller fetches; the rest either return the
|
|
// in-progress result or kick off a coalesced background refresh).
|
|
if hits.Load() > 3 {
|
|
t.Fatalf("expected coalescing to reduce fetches (got %d)", hits.Load())
|
|
}
|
|
}
|
|
|
|
// TestEventIDStable verifies that the same event hashes to the same ID
|
|
// across calls, and is the right shape (32 hex chars + ".ics").
|
|
func TestEventIDStable(t *testing.T) {
|
|
ev := mustParseEvent(sampleICS)
|
|
id1 := EventID(ev)
|
|
id2 := EventID(ev)
|
|
if id1 != id2 {
|
|
t.Fatalf("EventID not stable: %q vs %q", id1, id2)
|
|
}
|
|
if !strings.HasSuffix(id1, ".ics") {
|
|
t.Fatalf("EventID should end in .ics, got %q", id1)
|
|
}
|
|
if len(id1) != 32+4 {
|
|
t.Fatalf("expected 32 hex chars + .ics, got %q (len %d)", id1, len(id1))
|
|
}
|
|
}
|
|
|
|
// TestEventIDDiffersForDifferentEvents verifies that two events with
|
|
// different titles or different start times hash to different IDs, while
|
|
// two events with only a different UID hash to the same ID.
|
|
func TestEventIDDiffersForDifferentEvents(t *testing.T) {
|
|
base := EventID(mustParseEvent(sampleICS))
|
|
|
|
// Different title.
|
|
rawA := strings.Replace(sampleICS, "SUMMARY:Original title", "SUMMARY:Other title", 1)
|
|
if EventID(mustParseEvent(rawA)) == base {
|
|
t.Fatal("same start, different title must hash differently")
|
|
}
|
|
|
|
// Different start time.
|
|
rawB := strings.Replace(sampleICS, "DTSTART:20260805T090000Z", "DTSTART:20260806T090000Z", 1)
|
|
if EventID(mustParseEvent(rawB)) == base {
|
|
t.Fatal("different start times must hash differently")
|
|
}
|
|
|
|
// Same start, same title, different UID → same hash.
|
|
rawC := strings.Replace(sampleICS, "UID:ev1@nidus.test", "UID:ev2@nidus.test", 1)
|
|
if EventID(mustParseEvent(rawC)) != base {
|
|
t.Fatal("UID should not affect the hash — same start/dur/title must be equal")
|
|
}
|
|
}
|
|
|
|
// TestEventIDWorksWithoutUID verifies EventID still produces a value (from
|
|
// DTSTART+DTEND+SUMMARY) when the event has no UID property.
|
|
func TestEventIDWorksWithoutUID(t *testing.T) {
|
|
raw := strings.Replace(sampleICS, "UID:ev1@nidus.test\r\n", "", 1)
|
|
ev := mustParseEvent(raw)
|
|
id := EventID(ev)
|
|
if id == "" {
|
|
t.Fatal("EventID should be non-empty even without UID")
|
|
}
|
|
}
|