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") } }