Something went wrong. Try again.
Monorepo for Tangled tangled.org
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255package 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") }}