Files
BountyBoard/internal/sync/manager_integration_test.go
T
etalon 4e01b64bbd feat: ticketing sync workers with idempotent import and orphan flagging (phase 6)
- task domain model + exhaustive §4.4 status machine tests (single source of truth)
- per-customer polling workers reconciled every 30s from the customers
  collection; panic-safe runs; manual sync-now trigger; status provider
  for the admin panel
- §5.3 upsert keyed on (system, key, customerId): refreshes content while
  status=imported, records upstream_changed timeline + notification after
- attachments cached into GridFS via connector-authorized downloads
- orphaned tickets flagged with timeline + consultant notification, never
  deleted; idempotent across polls
- consultant identity from users.extra.ticketingIdentities per system
- notifications store (insert/list/unread-count/mark-read)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 19:03:13 +02:00

229 lines
6.7 KiB
Go

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