Gitfed
bastien-mrq/gitfed / internal / store / notifications_test.go
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")
	}
}