package store import ( "strings" "testing" "time" ) // notificationWhere is the one predicate the log, its totals, its daily chart and the page // count all read with. It is worth pinning hard for two reasons: a placeholder numbered // wrong is a query that either fails or, worse, filters on the wrong argument, and a clause // that drifts between the four readers is a page whose total disagrees with its own table. func TestNotificationWhereIsEmptyByDefault(t *testing.T) { where, args := notificationWhere(NotificationLogFilter{}) if where != "TRUE" { t.Fatalf("where = %q, want an unfiltered predicate", where) } if len(args) != 0 { t.Fatalf("args = %v, want none", args) } } func TestNotificationWhereNumbersPlaceholdersInOrder(t *testing.T) { from := time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC) to := time.Date(2026, 8, 19, 0, 0, 0, 0, time.UTC) where, args := notificationWhere(NotificationLogFilter{ UserID: "u1", Kinds: []string{"show-return", "watch-time-week"}, Channels: []string{"in-app"}, Statuses: []string{"failed", "skipped"}, Sources: []string{"watch-time-digest"}, Query: "bear", From: from, To: to, }) // One placeholder per argument, in the order the arguments are appended. The search // clause reuses its placeholder across four columns, which is why the count of distinct // placeholders is what matters rather than the count of "$". for i := range args { marker := "$" + itoa(i+1) if !strings.Contains(where, marker) { t.Fatalf("clause %q never uses %s; the arguments and the placeholders disagree", where, marker) } } if len(args) != 8 { t.Fatalf("args = %d, want 8", len(args)) } if args[0] != "u1" { t.Fatalf("args[0] = %v, want the user id first", args[0]) } if args[len(args)-1] != "%bear%" { t.Fatalf("args[last] = %v, want the wrapped search term", args[len(args)-1]) } if args[6] != to { t.Fatalf("args[6] = %v, want the upper bound", args[6]) } } // Every filter combines with AND. A page whose controls quietly ORed together would answer // a different question from the one the filter bar describes. func TestNotificationWhereCombinesWithAnd(t *testing.T) { where, _ := notificationWhere(NotificationLogFilter{ UserID: "u1", Statuses: []string{"failed"}, }) if strings.Contains(where, " OR emby_user_id") { t.Fatalf("clause %q ORs its filters together", where) } if strings.Count(where, " AND ") != 2 { t.Fatalf("clause %q does not AND both filters", where) } } // An empty list is not a filter. Sending `ANY('{}')` would match nothing, so a page whose // dropdown is on "any" would show an empty table. func TestNotificationWhereIgnoresEmptyLists(t *testing.T) { where, args := notificationWhere(NotificationLogFilter{ Kinds: []string{}, Channels: nil, Statuses: []string{}, Query: " ", }) if where != "TRUE" || len(args) != 0 { t.Fatalf("where = %q args = %v; an unset filter must not narrow anything", where, args) } } // The search box covers the four columns an operator half-remembers something from, and it // must use one placeholder for all of them — repeating the argument four times would put // the later filters' placeholders out of step. func TestNotificationWhereSearchesFourColumnsWithOneArgument(t *testing.T) { where, args := notificationWhere(NotificationLogFilter{Query: "timeout"}) for _, column := range []string{"title ILIKE", "body ILIKE", "detail ILIKE", "username ILIKE"} { if !strings.Contains(where, column) { t.Errorf("search does not cover %s", column) } } if len(args) != 1 { t.Fatalf("args = %d, want one shared search argument", len(args)) } if strings.Count(where, "$1") != 4 { t.Fatalf("clause %q does not reuse $1 across all four columns", where) } } // The retention constant is what the console derives its widest window from, so a change to // one that is not a change to the other would offer a range the prune has already emptied. func TestNotificationRetentionIsWholeDays(t *testing.T) { if NotificationRetention%(24*time.Hour) != 0 { t.Fatalf("retention %v is not a whole number of days", NotificationRetention) } if days := int(NotificationRetention / (24 * time.Hour)); days != 90 { t.Fatalf("retention = %d days, want 90", days) } } func TestClampTextBoundsAStoredString(t *testing.T) { if got := clampText("short"); got != "short" { t.Fatalf("clampText shortened an ordinary string to %q", got) } long := strings.Repeat("é", notificationTextLimit+50) got := clampText(long) // Counted in runes, not bytes: a body in Japanese must not be cut at a third of an // English one's length, and never mid-character. if runes := []rune(got); len(runes) != notificationTextLimit+1 { t.Fatalf("clamped to %d runes, want %d plus the ellipsis", len(runes), notificationTextLimit) } if !strings.HasSuffix(got, "…") { t.Fatal("a clamped string does not say that it was clamped") } }