Skip to content

feat(subscriptions): typed get/update/pause/resume lifecycle (#31) - #52

Merged
karlwaldman merged 1 commit into
mainfrom
feat/31-subscription-lifecycle
Sep 13, 2026
Merged

karlwaldman merged 1 commit into
mainfrom
feat/31-subscription-lifecycle

Conversation

@karlwaldman

Copy link
Copy Markdown
Member

Closes #31.

Premise check (primary sources, 2026-09-13)

Claim Source Result
Member routes exist oilpriceapi-api origin/main config/routes.rb:571-580 resources :subscriptions, only: [:index, :show, :create, :update, :destroy] with member { post :pause; post :resume }
What /v1/subscriptions is app/controllers/v1/subscriptions_controller.rb, app/models/watch.rb Agent watches (scheduled price snapshots). The model is named Watch to avoid colliding with Billing::Subscription. No member action touches Stripe or billing, so a live smoke was permitted.
Wire shapes Production, test key account, raw curl Below. Types match the existing Subscription struct. No new fields were declared.

Observed wire shapes (production)

  • GET /v1/subscriptions/:id, PATCH, POST .../pause, POST .../resume → 200 {"status":"success","data":{"subscription":{id,name,codes,interval_seconds,status,deliver_webhook,source,tool_name,last_evaluated_at,next_run_at,created_at}}}. last_evaluated_at is null until the evaluator runs.
  • Pause on an already-paused watch → 200, still paused. Resume → 200, active, next_run_at re-armed.
  • PATCH {} → 200, no change.
  • Unknown or foreign ID (including non-UUID) on GET/PATCH/pause/resume → 404 {"error":{"code":"NOT_FOUND","message":"Subscription not found","status":404,"request_id":...,"docs":...}}.
  • PATCH {"codes":["NOT_A_REAL_CODE"]} → 422 {"status":"fail","data":{"error":"VALIDATION_ERROR","message":"Codes contains invalid commodity codes: NOT_A_REAL_CODE","details":{"codes":[...]}}}.
  • PATCH {"interval_seconds":1} → 422 VALIDATION_ERROR "below your plan minimum of 60 seconds". Create returns a 402 upgrade trigger for the same violation; update does not.
  • PATCH {"status":"bogus"} → 500. Filed as OilpriceAPI/oilpriceapi-api#8471. The SDK does not expose status on update, so the SDK cannot trigger it.

Changes

  • GetSubscription, UpdateSubscription(id, SubscriptionUpdate), PauseSubscription, ResumeSubscription, sharing one subscriptionMember helper.
  • ID validation before any request: blank, ., .., control characters → *InvalidInputError{Field:"id"}. The ID is url.PathEscaped into one segment and still goes through resolveEndpoint (fix: resolveEndpoint path contract — spaces over-rejected, percent-encoded ".." under-rejected #47 contract). DeleteSubscription now uses the same validator; DeleteSubscription(".") previously sent DELETE /v1/subscriptions/. and returned success against the fake server.
  • SubscriptionUpdate uses pointer fields, so only set fields are sent and false/"" can be sent explicitly. Empty update, empty or blank codes, and non-positive interval → *InvalidInputError. status is deliberately not exposed; use pause/resume.
  • Malformed success: a 2xx that is not JSON, has no data.subscription.id, or names a different ID → *MalformedResponseError. The ID comparison is case-insensitive because the server matches UUIDs that way.
  • Typed errors preserved: 404 → *NotFoundError; 422/402/403 → *APIError with the body intact; 5xx → *ServerError.
  • Writes (PATCH/POST) are never retried (v1.6.0 fix(retry): restrict retries to safe requests and bound Retry-After (#33) #37). This is asserted for 503 and for client timeout.
  • release_contract_test.go: skips a Keep a Changelog [Unreleased] heading. Before this change the guard failed on it (latest changelog version "Unreleased" does not match SDK Version "1.7.0"), which blocked the required Unreleased entry. Version is unchanged.
  • CHANGELOG [Unreleased] and the README method table. No version bump, tag or release.

TDD evidence

Red 1: tests before any implementation (verbatim)

# github.com/OilpriceAPI/oilpriceapi-go [github.com/OilpriceAPI/oilpriceapi-go.test]
./subscription_lifecycle_test.go:83:21: client.GetSubscription undefined (type *Client has no field or method GetSubscription)
./subscription_lifecycle_test.go:109:12: undefined: SubscriptionUpdate
...
FAIL	github.com/OilpriceAPI/oilpriceapi-go [build failed]

Red 2: against a naive implementation copying the existing DeleteSubscription pattern (verbatim excerpt)

A compile failure proves little, so the tests were also run against the obvious existing-convention code.

--- FAIL: TestSubscriptionLifecycleValidatesBeforeNetwork (0.00s)
    --- FAIL: TestSubscriptionLifecycleValidatesBeforeNetwork/get_blank_id (0.00s)
        subscription_lifecycle_test.go:268: expected *InvalidInputError, got *errors.errorString: EOF
    --- FAIL: TestSubscriptionLifecycleValidatesBeforeNetwork/get_dot_id (0.00s)
        subscription_lifecycle_test.go:268: expected *InvalidInputError, got *errors.errorString: EOF
    --- FAIL: TestSubscriptionLifecycleValidatesBeforeNetwork/get_control_char_id (0.00s)
        subscription_lifecycle_test.go:268: expected *InvalidInputError, got *errors.errorString: EOF
    --- FAIL: TestSubscriptionLifecycleValidatesBeforeNetwork/delete_dot_id (0.00s)
        subscription_lifecycle_test.go:268: expected *InvalidInputError, got <nil>: <nil>
    --- FAIL: TestSubscriptionLifecycleValidatesBeforeNetwork/update_no_fields (0.00s)
        subscription_lifecycle_test.go:268: expected *InvalidInputError, got *errors.errorString: EOF
    --- FAIL: TestSubscriptionLifecycleValidatesBeforeNetwork/update_zero_interval (0.00s)
        subscription_lifecycle_test.go:268: expected *InvalidInputError, got *errors.errorString: EOF
    subscription_lifecycle_test.go:276: validation failures must not reach the network; server saw 9 request(s)
--- FAIL: TestSubscriptionLifecycleMalformedSuccess (0.00s)
    --- FAIL: TestSubscriptionLifecycleMalformedSuccess/not_json (0.00s)
        subscription_lifecycle_test.go:387: expected *MalformedResponseError, got *json.SyntaxError: invalid character '<' looking for beginning of value (result <nil>)
    --- FAIL: TestSubscriptionLifecycleMalformedSuccess/missing_subscription (0.00s)
        subscription_lifecycle_test.go:387: expected *MalformedResponseError, got <nil>: <nil> (result &{Status:success Data:{Subscription:{ID: Name: Codes:[] IntervalSeconds:0 Status: DeliverWebhook:false Source: ToolName: LastEvaluatedAt: NextRunAt: CreatedAt:}}})
    --- FAIL: TestSubscriptionLifecycleMalformedSuccess/different_id (0.00s)
        subscription_lifecycle_test.go:387: expected *MalformedResponseError, got <nil>: <nil> (result &{Status:success Data:{Subscription:{ID:00000000-0000-0000-0000-000000000000 Name:w ...}}})
FAIL	github.com/OilpriceAPI/oilpriceapi-go	1.251s

19 subtests failed. The 404, 422, entitlement, write-not-retried, cancellation and timeout tests passed against the naive code, because the shared transport already provides that behaviour. They are kept as regression guards for the new methods.

Green (verbatim)

--- PASS: TestReleaseMetadataMatchesSDKVersion (0.00s)
--- PASS: TestGetSubscription (0.00s)
--- PASS: TestUpdateSubscriptionSendsOnlySetFields (0.00s)
--- PASS: TestPauseAndResumeSubscription (0.00s)
--- PASS: TestSubscriptionMemberIDIsEscapedIntoOneSegment (0.00s)
--- PASS: TestSubscriptionMemberAcceptsCaseInsensitiveIDMatch (0.00s)
--- PASS: TestSubscriptionLifecycleValidatesBeforeNetwork (0.00s)
--- PASS: TestSubscriptionLifecycleNotFound (0.00s)
--- PASS: TestUpdateSubscriptionValidationError (0.00s)
--- PASS: TestSubscriptionLifecycleEntitlementErrors (0.00s)
--- PASS: TestSubscriptionLifecycleMalformedSuccess (0.00s)
--- PASS: TestSubscriptionLifecycleWritesAreNotRetried (0.00s)
--- PASS: TestGetSubscriptionRetriesTransientFailure (1.00s)
--- PASS: TestSubscriptionLifecycleHonoursCancellation (0.10s)
--- PASS: TestSubscriptionLifecycleClientTimeout (0.10s)
--- PASS: TestDeleteSubscription (0.00s)
ok  	github.com/OilpriceAPI/oilpriceapi-go	2.253s

Full checks:

  • go test -race -count=1 ./... -v → 388 PASS, 0 FAIL, 0 SKIP (baseline 336 / 0).
  • go vet ./... and go vet -tags live ./... → clean.
  • gofmt -l . → empty.

Test fidelity: the 404, 422, 200 and null-timestamp bodies in subscription_lifecycle_test.go are copied from the production responses above. The 402 body follows AgentUpgradeTriggers#render_agent_upgrade_required. Production currently emits that 402 on create, not update, so treat it as a shape guard. The 403 body is a generic plan-gate shape.

Live lifecycle smoke: RAN, passed

TestLiveSubscriptionLifecycle (build tag live) runs create → get → update (name + codes) → pause → resume → delete → get 404 against production with the test key. It creates its own watch (86400s interval, WTI_USD), and t.Cleanup deletes it on failure or skip.

=== RUN   TestLiveGetSubscriptions
    live_test.go:279: account has 1 subscription(s)
--- PASS: TestLiveGetSubscriptions (1.33s)
=== RUN   TestLiveSubscriptionLifecycle
--- PASS: TestLiveSubscriptionLifecycle (8.86s)
PASS
ok  	github.com/OilpriceAPI/oilpriceapi-go	10.197s

Cleanup was verified afterwards with a read-only list. The account holds the 1 pre-existing watch (mcp-live-contract-1788524538194) and no leftovers. The pre-implementation curl probe also deleted its own watch (HTTP 204).

Issues filed while doing this

Not merged. No release.

🤖 Generated with Claude Code

https://claude.ai/code/session_015ao5paex73xXvuM424Libo

Add GetSubscription, UpdateSubscription, PauseSubscription and
ResumeSubscription for the /v1/subscriptions/{id} member routes, typed
from production wire shapes observed on 2026-09-13.

- IDs are validated (blank, ".", "..", control characters) before any
  request and escaped into one path segment; DeleteSubscription shares
  the validator.
- SubscriptionUpdate sends only set fields; empty update, empty/blank
  codes and non-positive intervals are refused client-side.
- A 2xx without data.subscription.id, or for another ID, returns
  MalformedResponseError instead of a zero-value struct.
- Writes are never retried; 404/422/402/403 keep their typed errors.
- Live lifecycle smoke (build tag live) cleans up via t.Cleanup.
- Release contract test skips a Keep a Changelog [Unreleased] heading.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015ao5paex73xXvuM424Libo
@coderabbitai

coderabbitai Bot commented Sep 13, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 38af2ee5-93e7-41d6-9c8b-85e164ae1157


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@karlwaldman
karlwaldman merged commit 8d025bd into main Sep 13, 2026
9 checks passed
@karlwaldman
karlwaldman deleted the feat/31-subscription-lifecycle branch September 13, 2026 20:10
@karlwaldman karlwaldman mentioned this pull request Oct 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[P2][Sprint 2][Subscriptions] Complete get, update, pause, and resume lifecycle

1 participant