Files
BountyBoard/internal/sync/manager_integration_test.go
etalon 7de1086725 fix: ticketing identity lookup after BSON round trip + WeKan live test
- live testing against a real WeKan v9.36 found that the driver decodes
  nested extra.ticketingIdentities as bson.D, so identity lookups silently
  returned empty and consultants were skipped; handle bson.D/bson.M/map
- integration regression test pinning the BSON round trip
- opt-in live test (go test -tags=wekanlive) verifying TestConnection,
  FetchUpdated (assignee + member fallback), key listing, since-filter and
  unknown-identity rejection against a real instance

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 21:25:04 +02:00

258 lines
7.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)
}
}
// TestTicketingIdentityBSONRoundTrip pins a live-found bug: the driver
// decodes nested extra documents as bson.D (not map[string]any), which used
// to make identity lookups silently return "".
func TestTicketingIdentityBSONRoundTrip(t *testing.T) {
_, st, _, consultant := newSyncStack(t)
ctx := context.Background()
if _, err := st.DB.Collection("users").UpdateOne(ctx,
bson.M{"_id": consultant.ID},
bson.M{"$set": bson.M{"extra": bson.M{"ticketingIdentities": bson.M{
"jira": "cons@corp.example", "wekan": "cons-wekan",
}}}}); err != nil {
t.Fatal(err)
}
fresh, err := st.UserByID(ctx, consultant.ID)
if err != nil {
t.Fatal(err)
}
if got := ticketingIdentity(fresh, domain.TicketingJira); got != "cons@corp.example" {
t.Fatalf("jira identity after BSON round trip = %q", got)
}
if got := ticketingIdentity(fresh, domain.TicketingWekan); got != "cons-wekan" {
t.Fatalf("wekan identity after BSON round trip = %q", got)
}
if got := ticketingIdentity(fresh, domain.TicketingAzure); got != "" {
t.Fatalf("unset identity = %q, want empty", got)
}
}
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)
}
}