package store
import (
"fmt"
"path/filepath"
"testing"
)
// TestNotificationDedup guards the "same grant notified twice" path: a
// second CreateNotification for the same (Principal, FromDomain, Repo,
// Actor) while the first is still pending must refresh it in place, not
// pile up a duplicate.
func TestNotificationDedup(t *testing.T) {
s, err := Open(filepath.Join(t.TempDir(), "gitfed.db"))
if err != nil {
t.Fatal(err)
}
defer s.Close()
n := Notification{Principal: "bob@ailleurs.net", FromDomain: "chez-moi.fr", Repo: "alice/x", Role: "read", Actor: "alice@chez-moi.fr"}
if err := s.CreateNotification(n); err != nil {
t.Fatal(err)
}
n.Role = "write"
if err := s.CreateNotification(n); err != nil {
t.Fatal(err)
}
all, err := s.ListNotifications("bob@ailleurs.net")
if err != nil {
t.Fatal(err)
}
if len(all) != 1 {
t.Fatalf("got %d notifications, want 1 (second create should have refreshed the first)", len(all))
}
if all[0].Role != "write" {
t.Fatalf("role = %q, want the refreshed value %q", all[0].Role, "write")
}
}
// TestNotificationDedupKeepsDistinctMRsSeparate guards against a real bug
// caught while adding mr_opened/mr_comment notifications: the original
// dedup key (Principal, FromDomain, Repo, Actor) didn't include the MR
// number, so a comment notification on MR #2 would have collapsed into
// (and silently overwritten) a still-pending notification about MR #1 from
// the same commenter on the same repo — the recipient would only ever see
// the most recent MR, never both.
func TestNotificationDedupKeepsDistinctMRsSeparate(t *testing.T) {
s, err := Open(filepath.Join(t.TempDir(), "gitfed.db"))
if err != nil {
t.Fatal(err)
}
defer s.Close()
base := Notification{Principal: "alice@local.test", FromDomain: "local.test", Repo: "alice/repo", Actor: "bob@local.test", Kind: NotificationMRComment}
n1 := base
n1.MRNumber, n1.Title = 1, "First MR"
n2 := base
n2.MRNumber, n2.Title = 2, "Second MR"
if err := s.CreateNotification(n1); err != nil {
t.Fatal(err)
}
if err := s.CreateNotification(n2); err != nil {
t.Fatal(err)
}
all, err := s.ListNotifications("alice@local.test")
if err != nil {
t.Fatal(err)
}
if len(all) != 2 {
t.Fatalf("got %d notifications, want 2 — MR #1 and MR #2 must stay distinct", len(all))
}
// A second comment on the SAME MR must still collapse, same as before.
n1Again := n1
n1Again.Title = "First MR (retitled)"
if err := s.CreateNotification(n1Again); err != nil {
t.Fatal(err)
}
all, err = s.ListNotifications("alice@local.test")
if err != nil {
t.Fatal(err)
}
if len(all) != 2 {
t.Fatalf("got %d notifications after a second comment on MR #1, want still 2", len(all))
}
}
// TestNotificationCapEvictsOldest reproduces the gap the audit found: with
// no cap, any instance could bloat a real user's slice of the store
// indefinitely by sending distinct fake claims (nothing about receiving a
// notification requires the underlying grant to be real). Once the cap is
// hit, the oldest entry must be evicted to make room for a new one rather
// than the new one being silently dropped — otherwise spam could bury a
// legitimate notification behind itself.
func TestNotificationCapEvictsOldest(t *testing.T) {
s, err := Open(filepath.Join(t.TempDir(), "gitfed.db"))
if err != nil {
t.Fatal(err)
}
defer s.Close()
const principal = "bob@ailleurs.net"
for i := 0; i < MaxNotificationsPerPrincipal; i++ {
n := Notification{Principal: principal, FromDomain: "chez-moi.fr", Repo: fmt.Sprintf("alice/repo-%d", i), Role: "read", Actor: "alice@chez-moi.fr"}
if err := s.CreateNotification(n); err != nil {
t.Fatalf("create %d: %v", i, err)
}
}
all, err := s.ListNotifications(principal)
if err != nil {
t.Fatal(err)
}
if len(all) != MaxNotificationsPerPrincipal {
t.Fatalf("got %d notifications, want the cap %d", len(all), MaxNotificationsPerPrincipal)
}
// One more, distinct from all the others, should evict the oldest
// rather than being dropped or growing past the cap.
newest := Notification{Principal: principal, FromDomain: "chez-moi.fr", Repo: "alice/one-too-many", Role: "read", Actor: "alice@chez-moi.fr"}
if err := s.CreateNotification(newest); err != nil {
t.Fatal(err)
}
all, err = s.ListNotifications(principal)
if err != nil {
t.Fatal(err)
}
if len(all) != MaxNotificationsPerPrincipal {
t.Fatalf("got %d notifications after exceeding the cap, want it to stay at %d", len(all), MaxNotificationsPerPrincipal)
}
found := false
for _, n := range all {
if n.Repo == "alice/one-too-many" {
found = true
}
if n.Repo == "alice/repo-0" {
t.Fatal("oldest notification (repo-0) should have been evicted, but is still present")
}
}
if !found {
t.Fatal("newest notification was dropped instead of the oldest being evicted")
}
}