4e01b64bbd
- 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>
229 lines
6.7 KiB
Go
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)
|
|
}
|
|
}
|