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>
This commit is contained in:
@@ -15,3 +15,4 @@
|
|||||||
- Phase 12 (polish): seed script (make seed: demo customer + 1 admin + 2 consultants + 6 devs + pools + conversations, idempotent), forgot/reset password (SMTP-gated, one-shot TTL tokens, anti-enumeration), profile hover cards, keyboard shortcuts (g b / g m / g t / g h / focus search), bulk archive endpoint, hand-written api/openapi.yaml served at /api/docs, make backup/restore via mongodump, README w/ runbook + Caddy & nginx samples (WS + client_max_body_size), extended register→login→board smoke script — all tests green.
|
- Phase 12 (polish): seed script (make seed: demo customer + 1 admin + 2 consultants + 6 devs + pools + conversations, idempotent), forgot/reset password (SMTP-gated, one-shot TTL tokens, anti-enumeration), profile hover cards, keyboard shortcuts (g b / g m / g t / g h / focus search), bulk archive endpoint, hand-written api/openapi.yaml served at /api/docs, make backup/restore via mongodump, README w/ runbook + Caddy & nginx samples (WS + client_max_body_size), extended register→login→board smoke script — all tests green.
|
||||||
- Phase 13 (test & deploy): full unit (10 pkgs) + integration (12 pkgs) + contract suites green; scripts/acceptance.sh automates the §13 checklist live and PASSES end-to-end incl. a real Claude Code AI work-performer run; readyz verified 503/200 across a Mongo stop/start; smoke PASS; stack left running with mocks profile. Fixes: APP_INTERNAL_URL for in-network callbacks/signed URLs, work-performer runs as node user (claude refuses root), sudo HOME gotcha documented, UFW rule for container→host callbacks.
|
- Phase 13 (test & deploy): full unit (10 pkgs) + integration (12 pkgs) + contract suites green; scripts/acceptance.sh automates the §13 checklist live and PASSES end-to-end incl. a real Claude Code AI work-performer run; readyz verified 503/200 across a Mongo stop/start; smoke PASS; stack left running with mocks profile. Fixes: APP_INTERNAL_URL for in-network callbacks/signed URLs, work-performer runs as node user (claude refuses root), sudo HOME gotcha documented, UFW rule for container→host callbacks.
|
||||||
- Post-deploy: ANTHROPIC_API_KEY wired into .env (gitignored) — atomizer now produces real claude-sonnet-4-6 subdivisions (verified live, sum=1.0). Added WeKan ticketing source: connector (login or pre-issued token, board=projectKey, cards assigned to consultant's wekan username w/ member fallback, archived skipped, modifiedAt since-filter, token reuse), fake-server unit tests, admin wizard fields, OpenAPI/README updates.
|
- Post-deploy: ANTHROPIC_API_KEY wired into .env (gitignored) — atomizer now produces real claude-sonnet-4-6 subdivisions (verified live, sum=1.0). Added WeKan ticketing source: connector (login or pre-issued token, board=projectKey, cards assigned to consultant's wekan username w/ member fallback, archived skipped, modifiedAt since-filter, token reuse), fake-server unit tests, admin wizard fields, OpenAPI/README updates.
|
||||||
|
- WeKan live verification against /opt/wekan (v9.36): connector live test green (assignee + member-fallback import, since-filter, identity rejection); full app E2E green (test-connection, customer create, sync worker import, orphan flagging after upstream unassign, real LLM subdivision of a WeKan card). Fixed live-found bug: nested extra.ticketingIdentities decodes as bson.D, identity lookup now handles bson.D/bson.M/map (regression test added).
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
|
||||||
"bountyboard/internal/domain"
|
"bountyboard/internal/domain"
|
||||||
"bountyboard/internal/files"
|
"bountyboard/internal/files"
|
||||||
"bountyboard/internal/metrics"
|
"bountyboard/internal/metrics"
|
||||||
@@ -287,12 +289,24 @@ func ticketingIdentity(u *domain.User, t domain.TicketingType) string {
|
|||||||
if t == domain.TicketingDemo {
|
if t == domain.TicketingDemo {
|
||||||
return u.Email // demo accepts any identity
|
return u.Email // demo accepts any identity
|
||||||
}
|
}
|
||||||
ids, _ := u.Extra["ticketingIdentities"].(map[string]any)
|
// The driver decodes the nested document as bson.D when the field type
|
||||||
if ids == nil {
|
// is `any`; JSON-built values arrive as map[string]any. Handle both.
|
||||||
return ""
|
switch ids := u.Extra["ticketingIdentities"].(type) {
|
||||||
}
|
case map[string]any:
|
||||||
v, _ := ids[string(t)].(string)
|
v, _ := ids[string(t)].(string)
|
||||||
return v
|
return v
|
||||||
|
case bson.M:
|
||||||
|
v, _ := ids[string(t)].(string)
|
||||||
|
return v
|
||||||
|
case bson.D:
|
||||||
|
for _, e := range ids {
|
||||||
|
if e.Key == string(t) {
|
||||||
|
v, _ := e.Value.(string)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func contentHash(t Ticket) string {
|
func contentHash(t Ticket) string {
|
||||||
|
|||||||
@@ -201,6 +201,35 @@ func TestOrphanFlagging(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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) {
|
func TestSyncSkipsConsultantWithoutIdentity(t *testing.T) {
|
||||||
_, st, customer, _ := newSyncStack(t)
|
_, st, customer, _ := newSyncStack(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
//go:build wekanlive
|
||||||
|
|
||||||
|
// Live verification of the WeKan connector against a real instance:
|
||||||
|
//
|
||||||
|
// WEKAN_URL=http://127.0.0.1:8546 WEKAN_BOARD=<boardId> \
|
||||||
|
// WEKAN_USER=… WEKAN_PASS=… WEKAN_IDENTITY=<wekan username> \
|
||||||
|
// go test -tags=wekanlive -count=1 ./internal/sync/ -run TestWekanLive -v
|
||||||
|
package sync
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"bountyboard/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWekanLive(t *testing.T) {
|
||||||
|
url, board := os.Getenv("WEKAN_URL"), os.Getenv("WEKAN_BOARD")
|
||||||
|
user, pass := os.Getenv("WEKAN_USER"), os.Getenv("WEKAN_PASS")
|
||||||
|
identity := os.Getenv("WEKAN_IDENTITY")
|
||||||
|
if url == "" || board == "" || user == "" || pass == "" || identity == "" {
|
||||||
|
t.Skip("set WEKAN_URL, WEKAN_BOARD, WEKAN_USER, WEKAN_PASS, WEKAN_IDENTITY")
|
||||||
|
}
|
||||||
|
conn, err := NewConnector(domain.TicketingWekan, url, board,
|
||||||
|
Credentials{"username": user, "password": pass})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if err := conn.TestConnection(ctx); err != nil {
|
||||||
|
t.Fatalf("TestConnection: %v", err)
|
||||||
|
}
|
||||||
|
t.Log("✓ test connection")
|
||||||
|
|
||||||
|
tickets, err := conn.FetchUpdated(ctx, identity, time.Unix(0, 0))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FetchUpdated: %v", err)
|
||||||
|
}
|
||||||
|
if len(tickets) == 0 {
|
||||||
|
t.Fatal("expected at least one assigned card")
|
||||||
|
}
|
||||||
|
for _, tk := range tickets {
|
||||||
|
if tk.Key == "" || tk.Title == "" || tk.URL == "" {
|
||||||
|
t.Errorf("incomplete ticket: %+v", tk)
|
||||||
|
}
|
||||||
|
t.Logf("✓ ticket %s %q url=%s list=%v", tk.Key, tk.Title, tk.URL, tk.Raw["list"])
|
||||||
|
}
|
||||||
|
|
||||||
|
keys, err := conn.ListAssignedKeys(ctx, identity)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListAssignedKeys: %v", err)
|
||||||
|
}
|
||||||
|
if len(keys) != len(tickets) {
|
||||||
|
t.Fatalf("keys (%d) and tickets (%d) disagree", len(keys), len(tickets))
|
||||||
|
}
|
||||||
|
t.Logf("✓ %d assigned keys", len(keys))
|
||||||
|
|
||||||
|
// recent watermark filters everything out
|
||||||
|
future, err := conn.FetchUpdated(ctx, identity, time.Now().Add(time.Hour))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(future) != 0 {
|
||||||
|
t.Fatalf("future since-watermark returned %d tickets", len(future))
|
||||||
|
}
|
||||||
|
t.Log("✓ since-filter")
|
||||||
|
|
||||||
|
// unknown identity errors
|
||||||
|
if _, err := conn.FetchUpdated(ctx, "definitely-not-a-member", time.Unix(0, 0)); err == nil {
|
||||||
|
t.Fatal("unknown identity must error")
|
||||||
|
}
|
||||||
|
t.Log("✓ unknown identity rejected")
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user