130 lines
4.8 KiB
Go
130 lines
4.8 KiB
Go
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")
|
|
}
|
|
}
|