diff --git a/CHANGELOG.md b/CHANGELOG.md index 3cf1063d..d4493dc0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,20 @@ All notable changes to **stunt** are documented here. The format is based on ## [Unreleased] +## [0.39.0] — 2026-08-16 + +### Adapters + +- **threads-style**: TEXT media containers finish processing immediately. + Real Threads only requires polling container status for video/image + uploads — text posts are finished at creation, so real-world clients + create and publish back-to-back. The simulator was enforcing a 3-second + processing window on text too, rejecting `threads_publish` with the + "not finished processing" error. Found by dogfooding (omnipost's + production threads adapter driven end-to-end against stunt); the + `simulate_fail` branch and the status-poll endpoint keep the + derive-on-read machinery. + ## [0.38.0] — 2026-08-16 ### Adapters diff --git a/adapters/threads-style/scripts/publish.star b/adapters/threads-style/scripts/publish.star index 9325a0eb..1b0fa1a5 100644 --- a/adapters/threads-style/scripts/publish.star +++ b/adapters/threads-style/scripts/publish.star @@ -43,12 +43,16 @@ def on_create(req): now = clock.now_unix() cc = store_collection("containers") + # TEXT containers finish processing immediately (real Threads only makes + # you poll video/image uploads) — a text create -> publish back-to-back + # must succeed without a status poll. The poll endpoint + derive-on-read + # machinery stay for the simulate_fail branch. cc.insert({ "id": container_id, "text": text, "user_id": user_id, "status": "in_progress", - "_done_at": now + 3, + "_done_at": now, "_fail": _flag(body.get("simulate_fail", False)), }) diff --git a/internal/engine/stripe_connect_test.go b/internal/engine/stripe_connect_test.go index fda61103..3877e7e0 100644 --- a/internal/engine/stripe_connect_test.go +++ b/internal/engine/stripe_connect_test.go @@ -1085,7 +1085,10 @@ func TestStripeCnCapabilities(t *testing.T) { } // One day later the review window closes: capabilities activate. - stripeCnAdvanceClock(t, base, clockID, t0+24*3600+5) + // Anchored to the account's own created stamp (same CI second-boundary + // guard as the payout lifecycle test). + acctCreated := int64(acct["created"].(float64)) + stripeCnAdvanceClock(t, base, clockID, acctCreated+24*3600+5) body, status = getAuth(t, base+"/v1/accounts/"+acctID, devToken) if status != 200 { t.Fatalf("GET account after advance -> %d; %s", status, body) @@ -1458,15 +1461,19 @@ func TestStripeCnPayoutLifecycle(t *testing.T) { return po } - // Standard payout: pending at t0, arrival = t0 + 4 days, destination - // implicitly the default external account. + // Standard payout: pending at creation, arrival = created + 4 days, + // destination implicitly the default external account. All thresholds are + // anchored to the payout's OWN created stamp: the test clock offsets from + // real time at activation, so created = t0 + real elapsed — under CI load + // a second boundary can cross and t0-anchored exact assertions flake. p1 := createPayout(4000, "standard") p1ID, _ := p1["id"].(string) + p1Created := int64(p1["created"].(float64)) if p1["status"] != "pending" { - t.Fatalf("payout status at t0 = %v, want pending", p1["status"]) + t.Fatalf("payout status at creation = %v, want pending", p1["status"]) } - if p1["arrival_date"].(float64) != float64(t0+4*24*3600) { - t.Fatalf("standard arrival_date = %v, want %d", p1["arrival_date"], t0+4*24*3600) + if p1["arrival_date"].(float64) != float64(p1Created+4*24*3600) { + t.Fatalf("standard arrival_date = %v, want %d", p1["arrival_date"], p1Created+4*24*3600) } if dest, _ := p1["destination"].(string); !strings.HasPrefix(dest, "ba_") { t.Fatalf("payout destination = %v, want the default external account ba_*", p1["destination"]) @@ -1479,7 +1486,7 @@ func TestStripeCnPayoutLifecycle(t *testing.T) { } // +5s: still pending. - stripeCnAdvanceClock(t, base, clockID, t0+5) + stripeCnAdvanceClock(t, base, clockID, p1Created+5) body, status := getAuth(t, base+"/v1/payouts/"+p1ID, devToken) if status != 200 { t.Fatalf("GET payout (+5s) -> %d; %s", status, body) @@ -1491,7 +1498,7 @@ func TestStripeCnPayoutLifecycle(t *testing.T) { } // +15s: in_transit. - stripeCnAdvanceClock(t, base, clockID, t0+15) + stripeCnAdvanceClock(t, base, clockID, p1Created+15) body, _ = getAuth(t, base+"/v1/payouts/"+p1ID, devToken) _ = json.Unmarshal([]byte(body), &po) if po["status"] != "in_transit" { @@ -1502,7 +1509,7 @@ func TestStripeCnPayoutLifecycle(t *testing.T) { } // +61s: paid — exactly once, even across repeated reads. - stripeCnAdvanceClock(t, base, clockID, t0+61) + stripeCnAdvanceClock(t, base, clockID, p1Created+61) for i := 0; i < 3; i++ { body, status = getAuth(t, base+"/v1/payouts/"+p1ID, devToken) if status != 200 { diff --git a/internal/engine/threads_style_test.go b/internal/engine/threads_style_test.go index 6ef899ca..9118cad3 100644 --- a/internal/engine/threads_style_test.go +++ b/internal/engine/threads_style_test.go @@ -191,9 +191,11 @@ func TestThreadsStyleAdapter(t *testing.T) { t.Fatalf("missing text -> status %d, want 400", status) } - // ===== Container processing lifecycle (derive-on-read) ===== + // ===== Container processing lifecycle ===== - // A fresh container is in_progress and threads_publish is gated on finished. + // TEXT containers finish processing immediately (real Threads only makes + // you poll video/image uploads), so the status poll reports finished and + // a back-to-back create -> publish succeeds without any poll. body, status = getAuth(t, base+"/v1.0/"+containerID+"?fields=id,status", accessToken) if status != 200 { t.Fatalf("container status -> status %d, want 200; body %s", status, body) @@ -202,13 +204,13 @@ func TestThreadsStyleAdapter(t *testing.T) { if err := json.Unmarshal([]byte(body), &statusResp); err != nil { t.Fatalf("unmarshal container status: %v (body %s)", err, body) } - if statusResp["status"] != "in_progress" { - t.Fatalf("fresh container status = %v, want in_progress", statusResp["status"]) + if statusResp["status"] != "finished" { + t.Fatalf("fresh TEXT container status = %v, want finished", statusResp["status"]) } _, status = threadsPostNoBodyAuth(t, base+"/v1.0/"+userID+"/threads_publish?creation_id="+containerID, accessToken) - if status != 400 { - t.Fatalf("publish while in_progress -> status %d, want 400", status) + if status != 201 { + t.Fatalf("back-to-back publish -> status %d, want 201", status) } // Simulator-only failure injection: simulate_fail=true ends in error. @@ -345,12 +347,16 @@ func TestThreadsStyleAdapter(t *testing.T) { if !ok { t.Fatalf("engagement data = %v, want list", engagementResp["data"]) } - if len(engData) != 1 { - t.Fatalf("engagement data length = %d, want 1 (the published media)", len(engData)) + // Two published media exist (the lifecycle publish + the back-to-back one). + var post map[string]any + for _, e := range engData { + if m, ok := e.(map[string]any); ok && m["id"] == mediaID { + post = m + break + } } - post := engData[0].(map[string]any) - if post["id"] != mediaID { - t.Fatalf("engagement post id = %v, want %v", post["id"], mediaID) + if post == nil { + t.Fatalf("engagement data = %v, want the published media %v present", engagementResp["data"], mediaID) } if post["text"] != "Hello from the stunt test suite!" { t.Fatalf("engagement post text = %v, want original text", post["text"])