253 lines
9.5 KiB
Go
253 lines
9.5 KiB
Go
package api
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/ponzischeme89/memby/server/internal/store"
|
|
)
|
|
|
|
// Auckland is the household this was written for, and the reason every boundary below is
|
|
// computed from a calendar rather than by subtracting hours: it moves twice a year.
|
|
func auckland(t *testing.T) *time.Location {
|
|
t.Helper()
|
|
location, err := time.LoadLocation("Pacific/Auckland")
|
|
if err != nil {
|
|
t.Skip("no timezone database on this machine")
|
|
}
|
|
return location
|
|
}
|
|
|
|
func TestWeekStartsOnTheLocalMonday(t *testing.T) {
|
|
location := auckland(t)
|
|
// Thursday 20 August 2026, local afternoon.
|
|
now := time.Date(2026, 8, 20, 15, 30, 0, 0, location)
|
|
start := weekStartIn(now, location)
|
|
if start.Weekday() != time.Monday {
|
|
t.Fatalf("week began on %s, want Monday", start.Weekday())
|
|
}
|
|
if got, want := start.Format("2006-01-02 15:04"), "2026-08-17 00:00"; got != want {
|
|
t.Fatalf("week start = %s, want %s", got, want)
|
|
}
|
|
}
|
|
|
|
func TestMondayIsItsOwnWeekStart(t *testing.T) {
|
|
location := auckland(t)
|
|
now := time.Date(2026, 8, 17, 0, 1, 0, 0, location)
|
|
if got := weekStartIn(now, location); !got.Equal(time.Date(2026, 8, 17, 0, 0, 0, 0, location)) {
|
|
t.Fatalf("Monday's week start = %s, want the same midnight", got)
|
|
}
|
|
}
|
|
|
|
// Sunday is the far end of the week, and reading it as day zero would report a whole week of
|
|
// viewing as the coming one's — on the very evening the summary is sent.
|
|
func TestSundayBelongsToTheWeekThatIsEnding(t *testing.T) {
|
|
location := auckland(t)
|
|
now := time.Date(2026, 8, 23, 20, 30, 0, 0, location) // Sunday evening
|
|
if got, want := weekStartIn(now, location).Format("2006-01-02"), "2026-08-17"; got != want {
|
|
t.Fatalf("Sunday's week start = %s, want %s", got, want)
|
|
}
|
|
}
|
|
|
|
// The week containing New Zealand's daylight-saving change is 25 hours long, and a boundary
|
|
// computed by subtracting 7*24h would land an hour inside the previous Sunday.
|
|
func TestAWeekSpanningTheClockChangeStillBeginsAtMidnight(t *testing.T) {
|
|
location := auckland(t)
|
|
// Daylight saving ends on the first Sunday of April; 5 April 2026 in this zone.
|
|
now := time.Date(2026, 4, 5, 21, 0, 0, 0, location)
|
|
start := weekStartIn(now, location)
|
|
if got, want := start.Format("2006-01-02 15:04"), "2026-03-30 00:00"; got != want {
|
|
t.Fatalf("week start across the clock change = %s, want %s", got, want)
|
|
}
|
|
}
|
|
|
|
func TestMonthStartIsLocalMidnightOnTheFirst(t *testing.T) {
|
|
location := auckland(t)
|
|
now := time.Date(2026, 8, 20, 15, 30, 0, 0, location)
|
|
if got, want := monthStartIn(now, location).Format("2006-01-02 15:04"), "2026-08-01 00:00"; got != want {
|
|
t.Fatalf("month start = %s, want %s", got, want)
|
|
}
|
|
}
|
|
|
|
func TestPreviousMonthIsClosedAtBothEnds(t *testing.T) {
|
|
location := auckland(t)
|
|
now := time.Date(2026, 1, 3, 21, 0, 0, 0, location)
|
|
from, to, key := previousMonth(now, location)
|
|
if got := from.Format("2006-01-02"); got != "2025-12-01" {
|
|
t.Fatalf("previous month began %s, want 2025-12-01", got)
|
|
}
|
|
if got := to.Format("2006-01-02"); got != "2026-01-01" {
|
|
t.Fatalf("previous month ended %s, want 2026-01-01", got)
|
|
}
|
|
if key != "2025-12" {
|
|
t.Fatalf("previous month key = %q, want 2025-12", key)
|
|
}
|
|
}
|
|
|
|
// The source key is the only thing preventing a repeat, so two different weeks must never
|
|
// produce the same one — the end of December being where a plain year-and-week would collide.
|
|
func TestWeekKeysAreDistinctAcrossTheNewYear(t *testing.T) {
|
|
location := auckland(t)
|
|
seen := map[string]string{}
|
|
for day := 0; day < 21; day++ {
|
|
now := time.Date(2025, 12, 22, 20, 0, 0, 0, location).AddDate(0, 0, day)
|
|
key := weekKey(now, location)
|
|
week := weekStartIn(now, location).Format("2006-01-02")
|
|
if previous, ok := seen[key]; ok && previous != week {
|
|
t.Fatalf("key %q names both the week of %s and the week of %s", key, previous, week)
|
|
}
|
|
seen[key] = week
|
|
}
|
|
if len(seen) != 3 {
|
|
t.Fatalf("21 days produced %d week keys, want 3", len(seen))
|
|
}
|
|
}
|
|
|
|
func TestTheWeeklySummaryIsDueOnlyOnSundayEvening(t *testing.T) {
|
|
location := auckland(t)
|
|
cases := []struct {
|
|
name string
|
|
when time.Time
|
|
want bool
|
|
}{
|
|
{"Sunday evening", time.Date(2026, 8, 23, 20, 0, 0, 0, location), true},
|
|
{"later on Sunday night", time.Date(2026, 8, 23, 23, 59, 0, 0, location), true},
|
|
{"Sunday afternoon", time.Date(2026, 8, 23, 15, 0, 0, 0, location), false},
|
|
{"Monday evening", time.Date(2026, 8, 24, 20, 0, 0, 0, location), false},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if got := weeklyDigestDue(tc.when, location); got != tc.want {
|
|
t.Fatalf("weeklyDigestDue = %v, want %v", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// A summary is worded, not printed. These are the cases somebody actually reads.
|
|
func TestWatchDurationWording(t *testing.T) {
|
|
cases := []struct {
|
|
in time.Duration
|
|
want string
|
|
}{
|
|
{0, "no time"},
|
|
{20 * time.Second, "no time"},
|
|
{40 * time.Second, "1 min"},
|
|
{45 * time.Minute, "45 min"},
|
|
{time.Hour, "1 hour"},
|
|
{3 * time.Hour, "3 hours"},
|
|
{3*time.Hour + 12*time.Minute, "3h 12m"},
|
|
{3*time.Hour + 12*time.Minute + 40*time.Second, "3h 13m"},
|
|
}
|
|
for _, tc := range cases {
|
|
if got := formatWatchDuration(tc.in); got != tc.want {
|
|
t.Fatalf("formatWatchDuration(%s) = %q, want %q", tc.in, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The month is dropped when it would repeat the week, which is what the first week of a month
|
|
// looks like — two identical figures in one sentence read as a fault.
|
|
func TestTheWeeklyMessageOmitsAMonthThatSaysNothingNew(t *testing.T) {
|
|
same := weeklyDigestMessage(2*time.Hour, 2*time.Hour, "")
|
|
if want := "You watched 2 hours this week."; same != want {
|
|
t.Fatalf("message = %q, want %q", same, want)
|
|
}
|
|
more := weeklyDigestMessage(2*time.Hour, 9*time.Hour, "Severance")
|
|
want := "You watched 2 hours this week, and 9 hours so far this month. Mostly Severance."
|
|
if more != want {
|
|
t.Fatalf("message = %q, want %q", more, want)
|
|
}
|
|
}
|
|
|
|
func TestTheMonthlyMessageNamesItsMonth(t *testing.T) {
|
|
got := monthlyDigestMessage(14*time.Hour+30*time.Minute, "December", "The Bear")
|
|
want := "You watched 14h 30m in December. Most of it on The Bear."
|
|
if got != want {
|
|
t.Fatalf("message = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
// A recorded Tracearr id outranks the name, so a viewer renamed in one system keeps their
|
|
// figures instead of quietly reporting zero.
|
|
func TestWatchTimeIsAttributedByRecordedIdentityBeforeName(t *testing.T) {
|
|
accounts := []watchTimeAccount{{ID: "emby-1", Username: "matt"}}
|
|
totals := []store.WatchTimeTotals{
|
|
{TracearrUserID: "tr-1", Username: "matthew", WeekMs: 90},
|
|
{TracearrUserID: "tr-2", Username: "matt", WeekMs: 5},
|
|
}
|
|
identities := map[string]store.RecommendationIdentity{
|
|
"emby-1": {TracearrUserID: "tr-1", Username: "matthew"},
|
|
}
|
|
got := attributeWatchTime(accounts, totals, identities)
|
|
if got["emby-1"].WeekMs != 90 {
|
|
t.Fatalf("attributed %d ms, want the renamed identity's 90", got["emby-1"].WeekMs)
|
|
}
|
|
}
|
|
|
|
func TestWatchTimeFallsBackToACaseInsensitiveName(t *testing.T) {
|
|
accounts := []watchTimeAccount{{ID: "emby-1", Username: "Matt"}}
|
|
totals := []store.WatchTimeTotals{{Username: "matt", WeekMs: 42}}
|
|
got := attributeWatchTime(accounts, totals, nil)
|
|
if got["emby-1"].WeekMs != 42 {
|
|
t.Fatalf("attributed %d ms, want 42", got["emby-1"].WeekMs)
|
|
}
|
|
}
|
|
|
|
// Somebody Tracearr has never heard of gets no entry at all, which is what lets the console
|
|
// say "not available" rather than drawing a row of confident zeroes.
|
|
func TestAnUnmatchedAccountIsAbsentRatherThanZero(t *testing.T) {
|
|
accounts := []watchTimeAccount{{ID: "emby-9", Username: "nobody"}}
|
|
got := attributeWatchTime(accounts, []store.WatchTimeTotals{{Username: "matt"}}, nil)
|
|
if _, ok := got["emby-9"]; ok {
|
|
t.Fatal("an unmatched account was attributed watch time")
|
|
}
|
|
if summary := summariseWatchTime(store.WatchTimeTotals{}, false); summary.Matched {
|
|
t.Fatal("an unmatched summary claimed a match")
|
|
}
|
|
}
|
|
|
|
func TestARangeIsLookedUpTheSameTwoWays(t *testing.T) {
|
|
windows := []store.WatchTimeRange{
|
|
{TracearrUserID: "tr-1", Username: "matthew", Ms: 900, TopTitle: "Severance"},
|
|
{TracearrUserID: "tr-2", Username: "sam", Ms: 60, TopTitle: "Bluey"},
|
|
}
|
|
byID, byName := indexWatchTimeRanges(windows)
|
|
|
|
renamed := lookupWatchTimeRange(byID, byName, store.RecommendationIdentity{TracearrUserID: "tr-1"}, "matt")
|
|
if renamed.Ms != 900 {
|
|
t.Fatalf("id lookup found %d ms, want 900", renamed.Ms)
|
|
}
|
|
byNameOnly := lookupWatchTimeRange(byID, byName, store.RecommendationIdentity{}, "SAM")
|
|
if byNameOnly.TopTitle != "Bluey" {
|
|
t.Fatalf("name lookup found %q, want Bluey", byNameOnly.TopTitle)
|
|
}
|
|
missing := lookupWatchTimeRange(byID, byName, store.RecommendationIdentity{}, "nobody")
|
|
if missing.Ms != 0 || missing.TopTitle != "" {
|
|
t.Fatalf("an unknown viewer resolved to %+v, want the zero window", missing)
|
|
}
|
|
}
|
|
|
|
// A summary somebody has switched off is withdrawn from the list they already have, and the
|
|
// unrelated kinds beside it are untouched.
|
|
func TestTurningTheSummaryOffHidesTheOnesAlreadySent(t *testing.T) {
|
|
notifications := []store.UserNotification{
|
|
{Kind: watchTimeWeeklyKind}, {Kind: watchTimeMonthlyKind},
|
|
{Kind: "show-return"}, {Kind: "other"},
|
|
}
|
|
prefs := store.DefaultNotificationPreferences()
|
|
prefs.WatchTimeDigest = false
|
|
filtered := filterStoredNotifications(notifications, prefs)
|
|
if len(filtered) != 2 {
|
|
t.Fatalf("kept %d notifications, want 2", len(filtered))
|
|
}
|
|
for _, notification := range filtered {
|
|
if notification.Kind == watchTimeWeeklyKind || notification.Kind == watchTimeMonthlyKind {
|
|
t.Fatalf("a watch-time summary survived the switch being off")
|
|
}
|
|
}
|
|
if kept := filterStoredNotifications(notifications, store.DefaultNotificationPreferences()); len(kept) != 4 {
|
|
t.Fatalf("the default preferences kept %d of 4 notifications", len(kept))
|
|
}
|
|
}
|