package deliberi
import (
"context"
"errors"
"io"
"log/slog"
"path/filepath"
"sync"
"sync/atomic"
"testing"
"time"
deldb "tangled.org/core/deliberi/db"
models "tangled.org/core/deliberi/models"
"tangled.org/core/orm"
)
const (
invitee = "did:plc:invitee"
knotDid = "did:web:knot.example"
repoDid = "did:plc:repo"
collabUri = "at://" + repoDid + "/sh.tangled.repo.collaboratorInvite/" + invitee
otherUri = "at://" + repoDid + "/sh.tangled.repo.collaboratorInvite/did:plc:olaren"
)
var offerMade = time.Date(2026, 8, 30, 12, 0, 0, 0, time.UTC)
type fakeLister struct {
collabs []InviteOffer
pending []string
truncated bool
err error
calls *atomic.Int64
}
func (f *fakeLister) ListCollaboratorInvitesBy(ctx context.Context, subjectDid string) (InviteAnswer, error) {
if f.calls != nil {
f.calls.Add(1)
}
if f.err != nil {
return InviteAnswer{}, f.err
}
return InviteAnswer{Offers: f.collabs, Pending: f.pending, Truncated: f.truncated}, nil
}
func offerAt(uri, admin string) InviteOffer {
return InviteOffer{
Uri: uri,
KnotDid: knotDid,
RepoDid: repoDid,
AddedBy: admin,
CreatedAt: offerMade,
}
}
func newTestInviteSync(t *testing.T, l inviteLister) *InviteSync {
t.Helper()
database, err := deldb.Make(context.Background(), filepath.Join(t.TempDir(), "x.db"))
if err != nil {
t.Fatalf("make db: %v", err)
}
t.Cleanup(func() { database.Close() })
return NewInviteSync(database, l, slog.New(slog.NewTextHandler(io.Discard, nil)))
}
func inviteRows(t *testing.T, s *InviteSync, did string) []*models.Notification {
t.Helper()
rows, err := deldb.GetNotifications(s.db, did, 0, orm.FilterIn("type", inviteNotificationTypes))
if err != nil {
t.Fatalf("read invite rows: %v", err)
}
return rows
}
func expire(s *InviteSync, did string) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.lastRun, did)
}
func TestReconcileCreatesRowFromOffer(t *testing.T) {
s := newTestInviteSync(t, &fakeLister{
collabs: []InviteOffer{offerAt(collabUri, "did:plc:boltless")},
})
s.Reconcile(context.Background(), invitee)
rows := inviteRows(t, s, invitee)
if len(rows) != 1 {
t.Fatalf("row count = %d, want 1 (one per offer)", len(rows))
}
row := rows[0]
if row.AtUri != collabUri {
t.Errorf("atUri = %q, want the offer's record %q", row.AtUri, collabUri)
}
if row.Type != models.NotificationTypeCollaboratorInvited {
t.Errorf("type = %q, want %q", row.Type, models.NotificationTypeCollaboratorInvited)
}
if row.ActorDid != "did:plc:boltless" {
t.Errorf("actorDid = %q, want inviting admin", row.ActorDid)
}
if row.RepoDid != repoDid {
t.Errorf("repoDid = %q, want %q", row.RepoDid, repoDid)
}
if row.KnotDid != knotDid {
t.Errorf("knotDid = %q, want knot serving this offer", row.KnotDid)
}
if !row.Created.Equal(offerMade) {
t.Errorf("created = %s, want offer's own %s", row.Created, offerMade)
}
}
func TestReconcileDeletesOnlyWhatBobbinVouchesFor(t *testing.T) {
cases := []struct {
name string
then func(l *fakeLister)
want int
}{
{"offer withdrawn", func(l *fakeLister) { l.collabs = nil }, 0},
{
"offering knot pending",
func(l *fakeLister) { l.collabs, l.pending = nil, []string{knotDid} },
1,
},
{
"answer truncated",
func(l *fakeLister) {
l.collabs, l.truncated = []InviteOffer{offerAt(otherUri, "did:plc:akshay")}, true
},
2,
},
{"bobbin can't answer", func(l *fakeLister) { l.err = errors.New("bobbin unreachable") }, 1},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
lister := &fakeLister{collabs: []InviteOffer{offerAt(collabUri, "did:plc:akshay")}}
s := newTestInviteSync(t, lister)
s.Reconcile(context.Background(), invitee)
if got := len(inviteRows(t, s, invitee)); got != 1 {
t.Fatalf("row count after offer = %d, want 1", got)
}
tc.then(lister)
expire(s, invitee)
s.Reconcile(context.Background(), invitee)
if got := len(inviteRows(t, s, invitee)); got != tc.want {
t.Fatalf("row count once %s = %d, want %d", tc.name, got, tc.want)
}
})
}
}
func TestReconcileDeletesOncePendingKnotIsRead(t *testing.T) {
lister := &fakeLister{
collabs: []InviteOffer{offerAt(collabUri, "did:plc:akshay")},
pending: []string{knotDid},
}
s := newTestInviteSync(t, lister)
s.Reconcile(context.Background(), invitee)
lister.collabs, lister.pending = nil, nil
expire(s, invitee)
s.Reconcile(context.Background(), invitee)
if got := len(inviteRows(t, s, invitee)); got != 0 {
t.Fatalf("row count once bobbin has read knot = %d, want 0", got)
}
}
func TestReconcileReofferAfterWithdrawal(t *testing.T) {
lister := &fakeLister{collabs: []InviteOffer{offerAt(collabUri, "did:plc:akshay")}}
s := newTestInviteSync(t, lister)
s.Reconcile(context.Background(), invitee)
lister.collabs = nil
expire(s, invitee)
s.Reconcile(context.Background(), invitee)
reoffer := offerAt(collabUri, "did:plc:akshay")
reoffer.CreatedAt = time.Date(2026, 8, 31, 9, 15, 0, 0, time.UTC)
lister.collabs = []InviteOffer{reoffer}
expire(s, invitee)
s.Reconcile(context.Background(), invitee)
rows := inviteRows(t, s, invitee)
if len(rows) != 1 {
t.Fatalf("row count after re-offer = %d, want 1", len(rows))
}
if !rows[0].Created.Equal(reoffer.CreatedAt) {
t.Fatalf("created = %s, want re-offer's %s", rows[0].Created, reoffer.CreatedAt)
}
}
func TestReconcileSkipsSelfInvite(t *testing.T) {
s := newTestInviteSync(t, &fakeLister{collabs: []InviteOffer{offerAt(collabUri, invitee)}})
s.Reconcile(context.Background(), invitee)
if got := len(inviteRows(t, s, invitee)); got != 0 {
t.Fatalf("row count for self-invite = %d, want 0", got)
}
}
func TestReconcileAsksBobbinOncePerWindow(t *testing.T) {
var calls atomic.Int64
s := newTestInviteSync(t, &fakeLister{calls: &calls})
var racers sync.WaitGroup
racers.Add(16)
for range 16 {
go func() {
defer racers.Done()
s.Reconcile(context.Background(), invitee)
}()
}
racers.Wait()
if got := calls.Load(); got != 1 {
t.Fatalf("listings across 16 concurrent bells = %d, want 1 (one pass)", got)
}
expire(s, invitee)
s.Reconcile(context.Background(), invitee)
if got := calls.Load(); got != 2 {
t.Fatalf("listings once window passes = %d, want 2", got)
}
}
func TestReconcileForgetsRecipientsPastWindow(t *testing.T) {
s := newTestInviteSync(t, &fakeLister{})
s.Reconcile(context.Background(), "did:plc:one")
s.Reconcile(context.Background(), "did:plc:two")
s.mu.Lock()
stale := time.Now().Add(-2 * inviteReconcileInterval)
s.lastRun["did:plc:one"] = stale
s.swept = stale
s.mu.Unlock()
s.Reconcile(context.Background(), "did:plc:three")
s.mu.Lock()
defer s.mu.Unlock()
if _, held := s.lastRun["did:plc:one"]; held {
t.Fatal("recipient who didn't read this window is still held in debounce")
}
if _, held := s.lastRun["did:plc:two"]; !held {
t.Fatal("sweep took recipient still inside window")
}
}