Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion adapters/threads-style/scripts/publish.star
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
})

Expand Down
25 changes: 16 additions & 9 deletions internal/engine/stripe_connect_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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"])
Expand All @@ -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)
Expand All @@ -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" {
Expand All @@ -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 {
Expand Down
28 changes: 17 additions & 11 deletions internal/engine/threads_style_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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.
Expand Down Expand Up @@ -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"])
Expand Down
Loading