//go:build integration package sync import ( "context" "io" "log/slog" "testing" "go.mongodb.org/mongo-driver/v2/bson" "bountyboard/internal/domain" "bountyboard/internal/files" "bountyboard/internal/metrics" "bountyboard/internal/store" "bountyboard/internal/testutil" "bountyboard/internal/ulid" ) func newSyncStack(t *testing.T) (*Manager, *store.Store, *domain.Customer, *domain.User) { t.Helper() db := testutil.MongoDB(t) if err := store.EnsureIndexes(t.Context(), db); err != nil { t.Fatal(err) } st := &store.Store{Client: db.Client(), DB: db} consultant := &domain.User{ ID: ulid.New(), Email: "cons@example.com", Name: "Cons", Roles: domain.Roles{Consultant: true}, } if err := st.CreateUser(t.Context(), consultant); err != nil { t.Fatal(err) } customer := &domain.Customer{ Name: "Demo Co", Ticketing: domain.Ticketing{ Type: domain.TicketingDemo, ProjectKey: "DEMO", PollIntervalSec: 60, }, ConsultantIDs: []string{consultant.ID}, DefaultBudget: 1500, } if err := st.CreateCustomer(t.Context(), customer); err != nil { t.Fatal(err) } log := slog.New(slog.NewTextHandler(io.Discard, nil)) m := NewManager(st, files.NewStore(db, 25), log, metrics.NewRegistry(), func(*domain.Customer) (Credentials, error) { return Credentials{}, nil }, nil) return m, st, customer, consultant } func countTasks(t *testing.T, st *store.Store, filter bson.M) int64 { t.Helper() n, err := st.DB.Collection("tasks").CountDocuments(t.Context(), filter) if err != nil { t.Fatal(err) } return n } func TestDemoSyncImportsAndIsIdempotent(t *testing.T) { m, st, customer, consultant := newSyncStack(t) ctx := context.Background() if err := m.SyncCustomer(ctx, customer); err != nil { t.Fatalf("first sync: %v", err) } if n := countTasks(t, st, bson.M{"customerId": customer.ID}); n != 5 { t.Fatalf("after first sync: %d tasks, want 5", n) } // idempotent: same tickets re-fetched (overlap window) don't duplicate for i := 0; i < 3; i++ { if err := m.SyncCustomer(ctx, customer); err != nil { t.Fatalf("re-sync %d: %v", i, err) } } if n := countTasks(t, st, bson.M{"customerId": customer.ID}); n != 5 { t.Fatalf("after re-syncs: %d tasks, want 5", n) } // imported tasks carry the right shape tasks, err := st.ListTasks(ctx, store.TaskFilter{CustomerID: customer.ID}) if err != nil { t.Fatal(err) } for _, task := range tasks { if task.Status != domain.StatusImported || task.Origin != domain.OriginImported { t.Errorf("task %s: status=%s origin=%s", task.ID, task.Status, task.Origin) } if task.ConsultantID != consultant.ID || task.RootID != task.ID { t.Errorf("task %s: consultant=%s root=%s", task.ID, task.ConsultantID, task.RootID) } if task.Budget != 1500 { t.Errorf("task %s: budget %v, want customer default 1500", task.ID, task.Budget) } if task.External == nil || task.External.System != "demo" { t.Errorf("task %s: external %+v", task.ID, task.External) } } // consultant got import notifications (5, once each) notifs, err := st.ListNotifications(ctx, consultant.ID, false, "", 100) if err != nil { t.Fatal(err) } imported := 0 for _, n := range notifs { if n.Kind == "ticket_imported" { imported++ } } if imported != 5 { t.Fatalf("import notifications = %d, want 5", imported) } } func TestSyncPreservesWorkAfterAtomization(t *testing.T) { m, st, customer, _ := newSyncStack(t) ctx := context.Background() if err := m.SyncCustomer(ctx, customer); err != nil { t.Fatal(err) } tasks, _ := st.ListTasks(ctx, store.TaskFilter{CustomerID: customer.ID}) target := tasks[0] // simulate atomization started + consultant edits if _, err := st.DB.Collection("tasks").UpdateOne(ctx, bson.M{"_id": target.ID}, bson.M{"$set": bson.M{ "status": domain.StatusAtomized, "title": "Consultant Edited Title", "external.contentHash": "different-upstream-hash", }}); err != nil { t.Fatal(err) } if err := m.SyncCustomer(ctx, customer); err != nil { t.Fatal(err) } fresh, err := st.TaskByID(ctx, target.ID) if err != nil { t.Fatal(err) } if fresh.Title != "Consultant Edited Title" { t.Fatalf("title clobbered after atomization: %q", fresh.Title) } var sawUpstreamChange bool for _, e := range fresh.Timeline { if e.Event == "upstream_changed" { sawUpstreamChange = true } } if !sawUpstreamChange { t.Fatal("timeline missing upstream_changed entry") } } func TestOrphanFlagging(t *testing.T) { m, st, customer, consultant := newSyncStack(t) ctx := context.Background() if err := m.SyncCustomer(ctx, customer); err != nil { t.Fatal(err) } // flip the demo project key to the orphan variant: DEMO-5 disappears if _, err := st.DB.Collection("customers").UpdateOne(ctx, bson.M{"_id": customer.ID}, bson.M{"$set": bson.M{"ticketing.projectKey": "DEMO-ORPHAN"}}); err != nil { t.Fatal(err) } fresh, err := st.CustomerByID(ctx, customer.ID) if err != nil { t.Fatal(err) } if err := m.SyncCustomer(ctx, fresh); err != nil { t.Fatal(err) } orphaned := countTasks(t, st, bson.M{"customerId": customer.ID, "external.orphaned": true}) if orphaned != 1 { t.Fatalf("orphaned = %d, want 1", orphaned) } // the task is NOT deleted (ยง5.3) if n := countTasks(t, st, bson.M{"customerId": customer.ID}); n != 5 { t.Fatalf("tasks after orphan = %d, want 5 (never deleted)", n) } // consultant notified once, and re-sync doesn't duplicate the flagging if err := m.SyncCustomer(ctx, fresh); err != nil { t.Fatal(err) } notifs, _ := st.ListNotifications(ctx, consultant.ID, false, "", 100) orphanNotifs := 0 for _, n := range notifs { if n.Kind == "ticket_orphaned" { orphanNotifs++ } } if orphanNotifs != 1 { t.Fatalf("orphan notifications = %d, want exactly 1", orphanNotifs) } } func TestSyncSkipsConsultantWithoutIdentity(t *testing.T) { _, st, customer, _ := newSyncStack(t) ctx := context.Background() // switch to jira: consultant has no jira identity in extra if _, err := st.DB.Collection("customers").UpdateOne(ctx, bson.M{"_id": customer.ID}, bson.M{"$set": bson.M{"ticketing.type": "jira", "ticketing.baseUrl": "https://nope.invalid"}}); err != nil { t.Fatal(err) } fresh, _ := st.CustomerByID(ctx, customer.ID) log := slog.New(slog.NewTextHandler(io.Discard, nil)) m := NewManager(st, files.NewStore(st.DB, 25), log, metrics.NewRegistry(), func(*domain.Customer) (Credentials, error) { return Credentials{"email": "c@x.y", "apiToken": "token"}, nil }, nil) // must not error: the consultant is skipped, nothing is ever fetched if err := m.SyncCustomer(ctx, fresh); err != nil { t.Fatalf("sync with missing identity should be a no-op, got %v", err) } if n := countTasks(t, st, bson.M{"customerId": customer.ID}); n != 0 { t.Fatalf("tasks = %d, want 0", n) } }