diff --git a/docs/superpowers/plans/2026-07-06-ci-and-test-suite.md b/docs/superpowers/plans/2026-07-06-ci-and-test-suite.md deleted file mode 100644 index 8878c7f..0000000 --- a/docs/superpowers/plans/2026-07-06-ci-and-test-suite.md +++ /dev/null @@ -1,1976 +0,0 @@ -# CI Workflow and Test Suite Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Modern GitHub Actions CI plus a robust unit test suite and an integration suite that runs against a real Mastodon server, delivered as three stacked PRs. - -**Architecture:** PR1 (`ci-workflow`, off `main`) replaces the dead workflow with a matrix test/lint/dialyzer pipeline. PR2 (`unit-test-suite`, off `ci-workflow`) extracts the JSON→struct transformation into a testable module, refactors `Hunter.Api.Request` for pure-function testing, and extends Mox delegation coverage to the full API surface. PR3 (`integration-tests`, off `unit-test-suite`) adds a tagged integration suite driven by `HUNTER_BASE_URL`/`HUNTER_TOKEN`, a docker-compose Mastodon stack (nginx TLS in front, because Mastodon production mode forces SSL), and a CI job that boots it. - -**Tech Stack:** Elixir/ExUnit, Mox, Poison 6, HTTPoison 3, GitHub Actions (`erlef/setup-beam`), Docker Compose, Mastodon `v4.3.8` (pinned), nginx TLS sidecar. - -**Spec:** `docs/superpowers/specs/2026-07-06-ci-and-test-suite-design.md` - -## Global Constraints - -- Elixir requirement in `mix.exs` becomes `~> 1.15`; CI matrix floor is Elixir 1.15 / OTP 25, latest is Elixir 1.19 / OTP 28. If the floor pair fails in CI due to dep requirements, raise the floor to the oldest passing pair and record it in CHANGELOG.md. -- Keep Poison/HTTPoison — no HTTP/JSON stack migration. -- All code must pass `mix format --check-formatted`, `mix credo --strict`, and compile with `--warnings-as-errors`. -- Every commit message follows the repo's plain style (e.g. "add entity parsing tests") and ends with `Co-Authored-By: Claude Fable 5 `. -- `mix test` (no env vars) must stay fast and offline — integration tests are excluded by default. -- Mastodon docker image is pinned to `ghcr.io/mastodon/mastodon:v4.3.8`; version bumps are deliberate. (At execution time, if that tag does not exist, use the newest existing `v4.3.x` tag.) -- Stacked branches: `ci-workflow` → `unit-test-suite` → `integration-tests`. PR bases follow the stack. -- PR bodies end with `🤖 Generated with [Claude Code](https://claude.com/claude-code)`. - ---- - -# PR1: CI workflow (branch `ci-workflow`, base `main`) - -### Task 1: Branch, baseline, and commit the pending dep bumps - -The working tree already contains uncommitted bumps (`.tool-versions` → erlang 28 / elixir 1.19-otp-28; `mix.exs` → httpoison ~> 3.0, poison ~> 6.0; `mix.lock`). Verify they are green, then make them the branch's first commit. - -**Files:** -- Modify (already modified, commit as-is): `.tool-versions`, `mix.exs`, `mix.lock` - -- [ ] **Step 1: Create the branch** - -```bash -git checkout -b ci-workflow -``` - -- [ ] **Step 2: Verify the baseline is green** - -Run: `mix deps.get && mix compile --warnings-as-errors && mix test` -Expected: 0 failures. If compile warnings or test failures appear, STOP and fix them (systematic-debugging) before anything else — they are pre-existing breakage from the dep bumps and belong in this commit. - -- [ ] **Step 3: Commit** - -```bash -git add .tool-versions mix.exs mix.lock -git commit -m "update httpoison/poison and toolchain to erlang 28 / elixir 1.19" -``` - -### Task 2: mix.exs project hygiene for CI - -**Files:** -- Modify: `mix.exs` (project/0: `elixir` requirement and `dialyzer` config) -- Modify: `.gitignore` (add PLT dir) - -**Interfaces:** -- Produces: dialyzer PLT at `priv/plts/project.plt` — Task 3's CI cache and dialyzer job depend on this exact path. - -- [ ] **Step 1: Update `mix.exs`** - -In `project/0`, change `elixir: "~> 1.8"` to: - -```elixir - elixir: "~> 1.15", -``` - -Replace the `dialyzer:` keyword with (the `:race_conditions` flag was removed in modern OTP and errors out): - -```elixir - dialyzer: [ - plt_add_apps: [:mix, :ex_unit], - plt_file: {:no_warn, "priv/plts/project.plt"}, - flags: [:error_handling, :underspecs] - ] -``` - -- [ ] **Step 2: Add PLT dir to `.gitignore`** - -Append: - -``` -/priv/plts/ -``` - -- [ ] **Step 3: Verify compile, format, credo, and dialyzer** - -Run: `mkdir -p priv/plts && mix compile --warnings-as-errors && mix format --check-formatted && mix credo --strict && mix dialyzer` -Expected: compile/format/credo clean. First dialyzer run builds the PLT (several minutes). If dialyzer reports pre-existing warnings in `lib/`, do NOT fix them here — record the count; Task 3 makes the dialyzer job non-blocking only if it cannot be made green with `@dialyzer` ignores or a `.dialyzer_ignore.exs` file. Prefer an ignore file: - -```elixir -# .dialyzer_ignore.exs — pre-existing findings, tracked to be fixed separately -[] -``` - -and `ignore_warnings: ".dialyzer_ignore.exs"` added to the `dialyzer:` config with the actual entries dialyzer prints. - -- [ ] **Step 4: Run tests and commit** - -Run: `mix test` -Expected: PASS - -```bash -git add mix.exs .gitignore .dialyzer_ignore.exs 2>/dev/null || git add mix.exs .gitignore -git commit -m "require elixir 1.15+, fix dialyzer config for modern OTP" -``` - -### Task 3: Replace the workflow and open PR1 - -**Files:** -- Delete: `.github/workflows/elixir.yml` -- Create: `.github/workflows/ci.yml` - -**Interfaces:** -- Produces: workflow name `CI` with jobs `test`, `lint`, `dialyzer`. PR3's Task 12 appends an `integration` job to this same file. - -- [ ] **Step 1: Delete the old workflow and write `.github/workflows/ci.yml`** - -```bash -git rm .github/workflows/elixir.yml -``` - -```yaml -name: CI - -on: - push: - branches: [main] - pull_request: - workflow_dispatch: - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -permissions: - contents: read - -jobs: - test: - name: Test (Elixir ${{ matrix.elixir }} / OTP ${{ matrix.otp }}) - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - include: - - elixir: "1.15" - otp: "25" - - elixir: "1.19" - otp: "28" - steps: - - uses: actions/checkout@v4 - - uses: erlef/setup-beam@v1 - with: - elixir-version: ${{ matrix.elixir }} - otp-version: ${{ matrix.otp }} - - uses: actions/cache@v4 - with: - path: | - deps - _build - key: mix-${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-${{ hashFiles('mix.lock') }} - restore-keys: | - mix-${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}- - - run: mix deps.get - - run: mix deps.unlock --check-unused - - run: mix compile --warnings-as-errors - - run: mix test - - lint: - name: Lint - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: erlef/setup-beam@v1 - with: - version-file: .tool-versions - version-type: strict - - uses: actions/cache@v4 - with: - path: | - deps - _build - key: mix-lint-${{ runner.os }}-${{ hashFiles('.tool-versions', 'mix.lock') }} - restore-keys: | - mix-lint-${{ runner.os }}- - - run: mix deps.get - - run: mix format --check-formatted - - run: mix credo --strict - - dialyzer: - name: Dialyzer - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: erlef/setup-beam@v1 - with: - version-file: .tool-versions - version-type: strict - - uses: actions/cache@v4 - with: - path: | - deps - _build - priv/plts - key: mix-plt-${{ runner.os }}-${{ hashFiles('.tool-versions', 'mix.lock') }} - restore-keys: | - mix-plt-${{ runner.os }}- - - run: mix deps.get - - run: mkdir -p priv/plts - - run: mix dialyzer -``` - -Note: `.tool-versions` says `erlang 28` / `elixir 1.19-otp-28`. `version-type: strict` requires resolvable versions — if setup-beam rejects the loose `28`, pin `.tool-versions` to full versions (e.g. `erlang 28.0.1`, `elixir 1.19.0-otp-28`) in this task and keep `strict`. - -- [ ] **Step 2: Commit, push, and open PR1** - -```bash -git add .github/workflows/ci.yml -git commit -m "replace stale master-only workflow with matrix CI (test/lint/dialyzer)" -git push -u origin ci-workflow -gh pr create --base main --title "Modernize CI: matrix tests, lint, dialyzer" --body "$(cat <<'EOF' -Replaces the stale workflow (targeted the old `master` branch, so CI never ran) with a matrix pipeline. - -- Test matrix: Elixir 1.15/OTP 25 (new floor, `mix.exs` now requires `~> 1.15`) and Elixir 1.19/OTP 28 -- `mix deps.unlock --check-unused` re-enabled -- Lint job: `mix format --check-formatted` + `mix credo --strict` -- Dialyzer job with PLT caching; removed the retired `:race_conditions` flag -- Includes the pending httpoison 3.0 / poison 6.0 / toolchain bumps - -Part 1 of 3 stacked PRs implementing docs/superpowers/specs/2026-07-06-ci-and-test-suite-design.md (issue #13). - -🤖 Generated with [Claude Code](https://claude.com/claude-code) -EOF -)" -``` - -- [ ] **Step 3: Watch CI to green** - -Run: `gh pr checks --watch` -Expected: all jobs pass. If the 1.15/OTP 25 job fails on dep requirements, raise the floor per Global Constraints (edit matrix + `mix.exs` + note in CHANGELOG.md, commit "raise supported elixir floor to "), and re-push. - ---- - -# PR2: Unit test suite (branch `unit-test-suite`, base `ci-workflow`) - -```bash -git checkout ci-workflow && git checkout -b unit-test-suite -``` - -### Task 4: Entity fixtures + failing transformer tests - -**Files:** -- Create: `test/fixtures/account.json`, `test/fixtures/status.json`, `test/fixtures/notification.json`, `test/fixtures/context.json`, `test/fixtures/instance.json`, `test/fixtures/card.json`, `test/fixtures/relationship.json`, `test/fixtures/report.json`, `test/fixtures/result.json`, `test/fixtures/attachment.json` -- Create: `test/hunter/api/transformer_test.exs` - -**Interfaces:** -- Consumes: nothing (tests written first; module arrives in Task 5). -- Produces: `Hunter.Api.Transformer.transform(body :: String.t(), to :: atom) :: struct | [struct] | map` — the contract Task 5 implements. Fixtures are shared with nothing else (integration uses a live server). - -- [ ] **Step 1: Write the fixtures** - -`test/fixtures/account.json`: - -```json -{ - "id": "23634", - "username": "milmazz", - "acct": "milmazz", - "display_name": "Milton Mazzarri", - "locked": false, - "created_at": "2017-04-06T13:15:24.420Z", - "followers_count": 118, - "following_count": 178, - "statuses_count": 33, - "note": "

hunter author

", - "url": "https://mastodon.example/@milmazz", - "avatar": "https://mastodon.example/avatars/original/missing.png", - "avatar_static": "https://mastodon.example/avatars/original/missing.png", - "header": "https://mastodon.example/headers/original/missing.png", - "header_static": "https://mastodon.example/headers/original/missing.png" -} -``` - -`test/fixtures/attachment.json`: - -```json -{ - "id": "22345792", - "type": "image", - "url": "https://mastodon.example/media/image.png", - "preview_url": "https://mastodon.example/media/image_small.png", - "remote_url": null, - "text_url": "https://mastodon.example/media/xYzabc", - "meta": { - "original": { "width": 640, "height": 480 } - }, - "description": "test media" -} -``` - -`test/fixtures/status.json` (exercises every nested entity of `status_nested_struct/0`): - -```json -{ - "id": "103270115826048975", - "uri": "https://mastodon.example/users/milmazz/statuses/103270115826048975", - "url": "https://mastodon.example/@milmazz/103270115826048975", - "in_reply_to_id": null, - "in_reply_to_account_id": null, - "reblog": null, - "content": "

Testing #elixir with @kadaba

", - "created_at": "2019-12-08T03:48:33.901Z", - "reblogs_count": 6, - "favourites_count": 11, - "reblogged": false, - "favourited": false, - "muted": false, - "sensitive": false, - "spoiler_text": "", - "visibility": "public", - "language": "en", - "account": { - "id": "23634", - "username": "milmazz", - "acct": "milmazz", - "display_name": "Milton Mazzarri", - "url": "https://mastodon.example/@milmazz" - }, - "media_attachments": [ - { - "id": "22345792", - "type": "image", - "url": "https://mastodon.example/media/image.png", - "preview_url": "https://mastodon.example/media/image_small.png" - } - ], - "mentions": [ - { - "id": "8039", - "username": "kadaba", - "acct": "kadaba", - "url": "https://mastodon.example/@kadaba" - } - ], - "tags": [ - { - "name": "elixir", - "url": "https://mastodon.example/tags/elixir" - } - ], - "application": { - "name": "hunter", - "website": null - } -} -``` - -`test/fixtures/notification.json`: - -```json -{ - "id": "34975861", - "type": "mention", - "created_at": "2019-11-23T07:49:02.064Z", - "account": { - "id": "8039", - "username": "kadaba", - "acct": "kadaba" - }, - "status": { - "id": "103270115826048975", - "content": "

hello @milmazz

", - "visibility": "public" - } -} -``` - -`test/fixtures/context.json`: - -```json -{ - "ancestors": [ - { "id": "103270115826048970", "content": "

parent

", "visibility": "public" } - ], - "descendants": [ - { "id": "103270115826048999", "content": "

reply

", "visibility": "public" } - ] -} -``` - -`test/fixtures/instance.json`: - -```json -{ - "uri": "mastodon.example", - "title": "Mastodon Example", - "description": "A test instance", - "email": "admin@mastodon.example", - "version": "4.3.8", - "urls": { - "streaming_api": "wss://mastodon.example" - } -} -``` - -`test/fixtures/card.json`: - -```json -{ - "url": "https://elixir-lang.org/", - "title": "The Elixir programming language", - "description": "Elixir is a dynamic, functional language.", - "type": "link", - "author_name": "", - "author_url": "", - "provider_name": "elixir-lang.org", - "provider_url": "https://elixir-lang.org", - "image": "https://mastodon.example/preview_cards/image.png" -} -``` - -`test/fixtures/relationship.json`: - -```json -{ - "id": "8039", - "following": true, - "followed_by": false, - "blocking": false, - "muting": false, - "requested": false, - "domain_blocking": false -} -``` - -`test/fixtures/report.json`: - -```json -{ - "id": "48914", - "action_taken": false -} -``` - -`test/fixtures/result.json`: - -```json -{ - "accounts": [ - { "id": "23634", "username": "milmazz", "acct": "milmazz" } - ], - "statuses": [ - { "id": "103270115826048975", "content": "

Testing #elixir

", "visibility": "public" } - ], - "hashtags": ["elixir"] -} -``` - -- [ ] **Step 2: Write the failing test file** - -`test/hunter/api/transformer_test.exs`: - -```elixir -defmodule Hunter.Api.TransformerTest do - use ExUnit.Case, async: true - - alias Hunter.Api.Transformer - - test "decodes an account" do - account = transform("account", :account) - - assert %Hunter.Account{} = account - assert account.username == "milmazz" - assert account.acct == "milmazz" - assert account.display_name == "Milton Mazzarri" - assert account.followers_count == 118 - assert account.url == "https://mastodon.example/@milmazz" - end - - test "decodes a list of accounts" do - assert [%Hunter.Account{username: "milmazz"}] = - transform_list("account", :accounts) - end - - test "decodes a status with nested entities" do - status = transform("status", :status) - - assert %Hunter.Status{visibility: "public", language: "en"} = status - assert status.reblogs_count == 6 - assert %Hunter.Account{username: "milmazz"} = status.account - assert [%Hunter.Attachment{id: "22345792", type: "image"}] = status.media_attachments - assert [%Hunter.Mention{username: "kadaba", acct: "kadaba"}] = status.mentions - assert [%Hunter.Tag{name: "elixir"}] = status.tags - assert status.reblog == nil - end - - test "decodes a list of statuses" do - assert [%Hunter.Status{account: %Hunter.Account{username: "milmazz"}}] = - transform_list("status", :statuses) - end - - test "decodes a notification with nested account and status" do - notification = transform("notification", :notification) - - assert %Hunter.Notification{type: "mention"} = notification - assert %Hunter.Account{username: "kadaba"} = notification.account - assert %Hunter.Status{content: "

hello @milmazz

"} = notification.status - end - - test "decodes a list of notifications" do - assert [%Hunter.Notification{type: "mention"}] = - transform_list("notification", :notifications) - end - - test "decodes a context with status ancestors and descendants" do - context = transform("context", :context) - - assert %Hunter.Context{} = context - assert [%Hunter.Status{content: "

parent

"}] = context.ancestors - assert [%Hunter.Status{content: "

reply

"}] = context.descendants - end - - test "decodes an instance" do - instance = transform("instance", :instance) - - assert %Hunter.Instance{uri: "mastodon.example", version: "4.3.8"} = instance - assert instance.urls["streaming_api"] == "wss://mastodon.example" - end - - test "decodes a card" do - assert %Hunter.Card{title: "The Elixir programming language", type: "link"} = - transform("card", :card) - end - - test "decodes a relationship" do - assert %Hunter.Relationship{following: true, blocking: false} = - transform("relationship", :relationship) - end - - test "decodes a list of relationships" do - assert [%Hunter.Relationship{following: true}] = - transform_list("relationship", :relationships) - end - - test "decodes a report" do - assert %Hunter.Report{id: "48914", action_taken: false} = transform("report", :report) - end - - test "decodes a list of reports" do - assert [%Hunter.Report{id: "48914"}] = transform_list("report", :reports) - end - - test "decodes a search result with nested accounts and statuses" do - result = transform("result", :result) - - assert %Hunter.Result{hashtags: ["elixir"]} = result - assert [%Hunter.Account{username: "milmazz"}] = result.accounts - assert [%Hunter.Status{visibility: "public"}] = result.statuses - end - - test "decodes an attachment" do - attachment = transform("attachment", :attachment) - - assert %Hunter.Attachment{type: "image", description: "test media"} = attachment - assert attachment.meta["original"]["width"] == 640 - end - - test "falls back to a plain map for unknown entities" do - assert %{"id" => "48914"} = transform("report", :unknown) - end - - defp transform(fixture_name, to) do - fixture_name - |> fixture() - |> Transformer.transform(to) - end - - defp transform_list(fixture_name, to) do - Transformer.transform("[" <> fixture(fixture_name) <> "]", to) - end - - defp fixture(name) do - [__DIR__, "..", "..", "fixtures", name <> ".json"] - |> Path.join() - |> Path.expand() - |> File.read!() - end -end -``` - -- [ ] **Step 3: Run to verify it fails** - -Run: `mix test test/hunter/api/transformer_test.exs` -Expected: FAIL — `Hunter.Api.Transformer` is undefined. - -- [ ] **Step 4: Commit the red state** - -```bash -git add test/fixtures test/hunter/api/transformer_test.exs -git commit -m "add entity fixtures and failing transformer tests" -``` - -### Task 5: Extract `Hunter.Api.Transformer` - -**Files:** -- Create: `lib/hunter/api/transformer.ex` -- Modify: `lib/hunter/api/http_client.ex` (delete the private `transform/2` clauses and `status_nested_struct/0` / `notification_nested_struct/0`; call the new module) - -**Interfaces:** -- Consumes: contract from Task 4. -- Produces: `Hunter.Api.Transformer.transform/2`, used by `Hunter.Api.HTTPClient.request!/5`. - -- [ ] **Step 1: Create `lib/hunter/api/transformer.ex`** - -Move the bodies verbatim from `lib/hunter/api/http_client.ex` (the `defp transform` clauses at roughly lines 391–470 and the two nested-struct helpers): - -```elixir -defmodule Hunter.Api.Transformer do - @moduledoc """ - Decodes Mastodon API JSON payloads into Hunter entity structs. - """ - - def transform(body, :account), do: Poison.decode!(body, as: %Hunter.Account{}) - - def transform(body, :accounts), do: Poison.decode!(body, as: [%Hunter.Account{}]) - - def transform(body, :application), do: Poison.decode!(body, as: %Hunter.Application{}) - - def transform(body, :attachment), do: Poison.decode!(body, as: %Hunter.Attachment{}) - - def transform(body, :card), do: Poison.decode!(body, as: %Hunter.Card{}) - - def transform(body, :context) do - Poison.decode!( - body, - as: %Hunter.Context{ancestors: [status_nested_struct()], descendants: [status_nested_struct()]} - ) - end - - def transform(body, :instance), do: Poison.decode!(body, as: %Hunter.Instance{}) - - def transform(body, :notification), do: Poison.decode!(body, as: notification_nested_struct()) - - def transform(body, :notifications), do: Poison.decode!(body, as: [notification_nested_struct()]) - - def transform(body, :status), do: Poison.decode!(body, as: status_nested_struct()) - - def transform(body, :statuses), do: Poison.decode!(body, as: [status_nested_struct()]) - - def transform(body, :relationship), do: Poison.decode!(body, as: %Hunter.Relationship{}) - - def transform(body, :relationships), do: Poison.decode!(body, as: [%Hunter.Relationship{}]) - - def transform(body, :report), do: Poison.decode!(body, as: %Hunter.Report{}) - - def transform(body, :reports), do: Poison.decode!(body, as: [%Hunter.Report{}]) - - def transform(body, :result) do - Poison.decode!( - body, - as: %Hunter.Result{accounts: [%Hunter.Account{}], statuses: [status_nested_struct()]} - ) - end - - def transform(body, _), do: Poison.decode!(body) - - defp status_nested_struct do - %Hunter.Status{ - account: %Hunter.Account{}, - reblog: %Hunter.Status{}, - media_attachments: [%Hunter.Attachment{}], - mentions: [%Hunter.Mention{}], - tags: [%Hunter.Tag{}], - application: %Hunter.Application{} - } - end - - defp notification_nested_struct do - %Hunter.Notification{ - account: %Hunter.Account{}, - status: status_nested_struct() - } - end -end -``` - -Note two deliberate behavior improvements vs. the original (they make Task 4's nested-entity assertions pass): `:context` and `:result` now decode nested statuses with `status_nested_struct()` (the original used bare `%Hunter.Status{}`, leaving inner accounts/tags as plain maps), and `:notification`'s status uses `status_nested_struct()` too. `:reblog` stays `%Hunter.Status{}` inside `status_nested_struct/0` exactly as the original — do not recurse. - -- [ ] **Step 2: Wire `HTTPClient` to it** - -In `lib/hunter/api/http_client.ex`: add `alias Hunter.Api.Transformer` to the existing alias line, replace the `transform(body, to)` call inside `request!/5` with `Transformer.transform(body, to)`, and delete all `defp transform` clauses plus `status_nested_struct/0` and `notification_nested_struct/0`. - -- [ ] **Step 3: Run the whole suite** - -Run: `mix compile --warnings-as-errors && mix test` -Expected: PASS, including all transformer tests. - -- [ ] **Step 4: Format, lint, commit** - -Run: `mix format && mix credo --strict` - -```bash -git add lib/hunter/api/transformer.ex lib/hunter/api/http_client.ex -git commit -m "extract JSON-to-struct transformation into Hunter.Api.Transformer" -``` - -### Task 6: `Hunter.Api.Request` refactor + tests - -**Files:** -- Modify: `lib/hunter/api/request.ex` -- Create: `test/hunter/api/request_test.exs` - -**Interfaces:** -- Produces: public `Hunter.Api.Request.handle_response/1`, `process_request_body/1`, `process_request_header/1`. `request/5` and `request!/5` keep their exact signatures — `Hunter.Api.HTTPClient` and `Hunter.Client` call them unchanged. - -- [ ] **Step 1: Write the failing tests** - -`test/hunter/api/request_test.exs`: - -```elixir -defmodule Hunter.Api.RequestTest do - use ExUnit.Case, async: true - - alias Hunter.Api.Request - - describe "process_request_body/1" do - test "empty payload becomes an empty JSON object" do - assert Request.process_request_body([]) == "{}" - end - - test "multipart payloads pass through untouched" do - payload = {:multipart, [{:file, "/tmp/image.png"}]} - assert Request.process_request_body(payload) == payload - end - - test "binary payloads pass through untouched" do - assert Request.process_request_body(~s({"status":"hi"})) == ~s({"status":"hi"}) - end - - test "maps are JSON-encoded" do - assert Request.process_request_body(%{status: "hi"}) == ~s({"status":"hi"}) - end - end - - describe "process_request_header/1" do - test "sets JSON content-type and accept defaults" do - headers = Request.process_request_header([]) - - assert headers[:"Content-Type"] == "application/json" - assert headers[:Accept] == "Application/json; Charset=utf-8" - end - - test "caller headers are preserved and win over defaults" do - headers = - Request.process_request_header( - Authorization: "Bearer 123", - "Content-Type": "multipart/form-data" - ) - - assert headers[:Authorization] == "Bearer 123" - assert headers[:"Content-Type"] == "multipart/form-data" - end - end - - describe "handle_response/1" do - test "2xx responses return the body" do - assert Request.handle_response({:ok, %{status_code: 200, body: "ok"}}) == {:ok, "ok"} - assert Request.handle_response({:ok, %{status_code: 204, body: ""}}) == {:ok, ""} - end - - test "non-2xx responses return the body as error" do - body = ~s({"error":"Record not found"}) - assert Request.handle_response({:ok, %{status_code: 404, body: body}}) == {:error, body} - end - - test "transport errors return the reason" do - assert Request.handle_response({:error, %HTTPoison.Error{reason: :econnrefused}}) == - {:error, :econnrefused} - end - end -end -``` - -- [ ] **Step 2: Run to verify failure** - -Run: `mix test test/hunter/api/request_test.exs` -Expected: FAIL — the three functions are private/undefined. - -- [ ] **Step 3: Refactor `lib/hunter/api/request.ex`** - -```elixir -defmodule Hunter.Api.Request do - @moduledoc false - - def request(http_method, url, data \\ [], headers \\ [], options \\ []) do - body = process_request_body(data) - headers = process_request_header(headers) - - http_method - |> HTTPoison.request(url, body, headers, options) - |> handle_response() - end - - def request!(http_method, url, data \\ [], headers \\ [], options \\ []) do - case request(http_method, url, data, headers, options) do - {:ok, body} -> body - {:error, reason} -> raise Hunter.Error, reason: reason - end - end - - @doc false - def handle_response({:ok, %{status_code: status, body: body}}) when status in 200..299 do - {:ok, body} - end - - def handle_response({:ok, %{body: body}}), do: {:error, body} - - def handle_response({:error, %HTTPoison.Error{reason: reason}}), do: {:error, reason} - - @doc false - def process_request_body([]), do: "{}" - def process_request_body({:multipart, _} = data), do: data - def process_request_body(data) when is_binary(data), do: data - def process_request_body(data), do: Poison.encode!(data) - - @doc false - def process_request_header(data) do - Keyword.merge( - ["Content-Type": "application/json", Accept: "Application/json; Charset=utf-8"], - data - ) - end -end -``` - -- [ ] **Step 4: Run, format, commit** - -Run: `mix compile --warnings-as-errors && mix test && mix format --check-formatted && mix credo --strict` -Expected: PASS - -```bash -git add lib/hunter/api/request.ex test/hunter/api/request_test.exs -git commit -m "expose Request response/body/header handling and cover with tests" -``` - -### Task 7: `Hunter.Client`, `Hunter.Config`, and `Hunter.Error` tests - -**Files:** -- Create: `test/hunter/client_test.exs`, `test/hunter/config_test.exs`, `test/hunter/error_test.exs` - -- [ ] **Step 1: Write the tests** - -`test/hunter/client_test.exs`: - -```elixir -defmodule Hunter.ClientTest do - use ExUnit.Case, async: true - - alias Hunter.Client - - describe "new/1" do - test "builds a client with the given options" do - conn = Client.new(base_url: "https://example.com", bearer_token: "123456") - - assert %Client{base_url: "https://example.com", bearer_token: "123456"} = conn - end - - test "defaults the base_url from configuration" do - conn = Client.new(bearer_token: "123456") - - assert conn.base_url == Hunter.Config.api_base_url() - end - end - - test "user_agent/0 advertises hunter and its version" do - assert Client.user_agent() =~ "hunter" - end -end -``` - -Before writing assertions for `new/1` defaults, read `lib/hunter/client.ex:26` — `new/1` may not default `base_url`; if it simply takes options, keep only the first test and drop the default test. Adjust `user_agent/0` assertion to the actual format at `lib/hunter/client.ex:34`. - -`test/hunter/config_test.exs` (env-var fallbacks; `async: false` because it mutates global state — ExUnit runs sync modules serially after the async ones, so this cannot race the async suite): - -```elixir -defmodule Hunter.ConfigTest do - use ExUnit.Case, async: false - - alias Hunter.Config - - test "home/0 prefers the HUNTER_HOME environment variable" do - previous = System.get_env("HUNTER_HOME") - System.put_env("HUNTER_HOME", "/tmp/hunter-home") - - assert Config.home() == "/tmp/hunter-home" - - if previous do - System.put_env("HUNTER_HOME", previous) - else - System.delete_env("HUNTER_HOME") - end - end - - test "hunter_api/0 falls back to the HTTP client when unconfigured" do - Application.delete_env(:hunter, :hunter_api) - - try do - assert Config.hunter_api() == Hunter.Api.HTTPClient - after - Application.put_env(:hunter, :hunter_api, Hunter.ApiMock) - end - end -end -``` - -(`Config.api_base_url/0` and `http_options/0` defaults are already covered by the doctests in `test/hunter_test.exs`.) - -`test/hunter/error_test.exs`: - -```elixir -defmodule Hunter.ErrorTest do - use ExUnit.Case, async: true - - test "message renders the reason" do - error = %Hunter.Error{reason: :econnrefused} - - assert Exception.message(error) == ":econnrefused" - end - - test "raising with a reason" do - assert_raise Hunter.Error, ~s("boom"), fn -> - raise Hunter.Error, reason: "boom" - end - end -end -``` - -- [ ] **Step 2: Run** - -Run: `mix test test/hunter/client_test.exs test/hunter/error_test.exs` -Expected: PASS (these test existing code; if an assertion mismatches actual behavior, fix the assertion to the real behavior — do not change lib code in this task). - -- [ ] **Step 3: Commit** - -```bash -git add test/hunter/client_test.exs test/hunter/config_test.exs test/hunter/error_test.exs -git commit -m "add client, config, and error tests" -``` - -### Task 8: Complete `Hunter.Status` delegation coverage - -**Files:** -- Modify: `test/hunter/status_test.exs` (currently 3 tests: home timeline, public timeline, create status) - -**Interfaces:** -- Consumes: `Hunter.ApiMock` (configured in `test/test_helper.exs`), functions at `lib/hunter/status.ex:105-295`. - -- [ ] **Step 1: Extend `test/hunter/status_test.exs`** - -Keep the existing 3 tests and the existing `@conn`/`setup :verify_on_exit!` head; add: - -```elixir - test "returns a single status" do - expect(Hunter.ApiMock, :status, fn %Hunter.Client{}, 153_452 -> - %Status{id: "153452"} - end) - - assert %Status{id: "153452"} = Status.status(@conn, 153_452) - end - - test "destroys a status" do - expect(Hunter.ApiMock, :destroy_status, fn %Hunter.Client{}, 153_452 -> true end) - - assert Status.destroy_status(@conn, 153_452) - end - - test "reblogs and unreblogs a status" do - expect(Hunter.ApiMock, :reblog, fn %Hunter.Client{}, 153_452 -> - %Status{id: "153452", reblogged: true} - end) - - expect(Hunter.ApiMock, :unreblog, fn %Hunter.Client{}, 153_452 -> - %Status{id: "153452", reblogged: false} - end) - - assert %Status{reblogged: true} = Status.reblog(@conn, 153_452) - assert %Status{reblogged: false} = Status.unreblog(@conn, 153_452) - end - - test "favourites and unfavourites a status" do - expect(Hunter.ApiMock, :favourite, fn %Hunter.Client{}, 153_452 -> - %Status{id: "153452", favourited: true} - end) - - expect(Hunter.ApiMock, :unfavourite, fn %Hunter.Client{}, 153_452 -> - %Status{id: "153452", favourited: false} - end) - - assert %Status{favourited: true} = Status.favourite(@conn, 153_452) - assert %Status{favourited: false} = Status.unfavourite(@conn, 153_452) - end - - test "returns authenticated user's favourites" do - expect(Hunter.ApiMock, :favourites, fn %Hunter.Client{}, [] -> - [%Status{id: "153452"}] - end) - - assert [%Status{id: "153452"}] = Status.favourites(@conn) - end - - test "returns statuses from an account" do - # Status.statuses/3 converts options with Map.new/1 before delegating - expect(Hunter.ApiMock, :statuses, fn %Hunter.Client{}, 23_634, %{} -> - [%Status{id: "153452"}] - end) - - assert [%Status{}] = Status.statuses(@conn, 23_634) - end - - test "returns a hashtag timeline" do - expect(Hunter.ApiMock, :hashtag_timeline, fn %Hunter.Client{}, "elixir", [] -> - [%Status{id: "153452"}] - end) - - assert [%Status{}] = Status.hashtag_timeline(@conn, "elixir") - end - - test "propagates API errors" do - expect(Hunter.ApiMock, :status, fn %Hunter.Client{}, _id -> - raise Hunter.Error, reason: "Record not found" - end) - - assert_raise Hunter.Error, fn -> Status.status(@conn, 0) end - end -``` - -Signatures verified against `lib/hunter/status.ex`: only `statuses/3` converts options to a map; all others forward the keyword list unchanged. - -- [ ] **Step 2: Run and commit** - -Run: `mix test test/hunter/status_test.exs` -Expected: PASS - -```bash -git add test/hunter/status_test.exs -git commit -m "cover the full Hunter.Status API surface" -``` - -### Task 9: `Hunter.Relationship` tests - -**Files:** -- Create: `test/hunter/relationship_test.exs` - -- [ ] **Step 1: Write the test file** - -```elixir -defmodule Hunter.RelationshipTest do - use ExUnit.Case, async: true - - import Mox - - alias Hunter.Relationship - - @conn Hunter.Client.new(base_url: "https://example.com", bearer_token: "123456") - - setup :verify_on_exit! - - test "returns relationships to other accounts" do - expect(Hunter.ApiMock, :relationships, fn %Hunter.Client{}, [8039] -> - [%Relationship{id: "8039", following: true}] - end) - - assert [%Relationship{following: true}] = Relationship.relationships(@conn, [8039]) - end - - test "follows and unfollows an account" do - expect(Hunter.ApiMock, :follow, fn %Hunter.Client{}, 8039 -> - %Relationship{id: "8039", following: true} - end) - - expect(Hunter.ApiMock, :unfollow, fn %Hunter.Client{}, 8039 -> - %Relationship{id: "8039", following: false} - end) - - assert %Relationship{following: true} = Relationship.follow(@conn, 8039) - assert %Relationship{following: false} = Relationship.unfollow(@conn, 8039) - end - - test "blocks and unblocks an account" do - expect(Hunter.ApiMock, :block, fn %Hunter.Client{}, 8039 -> - %Relationship{id: "8039", blocking: true} - end) - - expect(Hunter.ApiMock, :unblock, fn %Hunter.Client{}, 8039 -> - %Relationship{id: "8039", blocking: false} - end) - - assert %Relationship{blocking: true} = Relationship.block(@conn, 8039) - assert %Relationship{blocking: false} = Relationship.unblock(@conn, 8039) - end - - test "mutes and unmutes an account" do - expect(Hunter.ApiMock, :mute, fn %Hunter.Client{}, 8039 -> - %Relationship{id: "8039", muting: true} - end) - - expect(Hunter.ApiMock, :unmute, fn %Hunter.Client{}, 8039 -> - %Relationship{id: "8039", muting: false} - end) - - assert %Relationship{muting: true} = Relationship.mute(@conn, 8039) - assert %Relationship{muting: false} = Relationship.unmute(@conn, 8039) - end -end -``` - -- [ ] **Step 2: Run and commit** - -Run: `mix test test/hunter/relationship_test.exs` -Expected: PASS - -```bash -git add test/hunter/relationship_test.exs -git commit -m "add relationship tests" -``` - -### Task 10: Remaining delegation coverage (Account extras, Report, Result, Context, Attachment, Domain) - -**Files:** -- Modify: `test/hunter/account_test.exs` -- Create: `test/hunter/report_test.exs`, `test/hunter/result_test.exs`, `test/hunter/context_test.exs`, `test/hunter/attachment_test.exs`, `test/hunter/domain_test.exs` - -- [ ] **Step 1: Extend `test/hunter/account_test.exs`** (append inside the module): - -```elixir - test "updates authenticated user's credentials" do - expect(Hunter.ApiMock, :update_credentials, fn %Hunter.Client{}, %{note: "new bio"} -> - %Account{username: "milmazz", note: "new bio"} - end) - - assert %Account{note: "new bio"} = Account.update_credentials(@conn, %{note: "new bio"}) - end - - test "searches for accounts" do - expect(Hunter.ApiMock, :search_account, fn %Hunter.Client{}, %{q: "milmazz"} -> - [%Account{username: "milmazz"}] - end) - - assert [%Account{username: "milmazz"}] = Account.search_account(@conn, %{q: "milmazz"}) - end - - test "returns blocked accounts" do - expect(Hunter.ApiMock, :blocks, fn %Hunter.Client{}, [] -> - [%Account{username: "spammer"}] - end) - - assert [%Account{username: "spammer"}] = Account.blocks(@conn) - end - - test "returns follow requests" do - expect(Hunter.ApiMock, :follow_requests, fn %Hunter.Client{}, [] -> - [%Account{username: "kadaba"}] - end) - - assert [%Account{username: "kadaba"}] = Account.follow_requests(@conn) - end - - test "returns muted accounts" do - expect(Hunter.ApiMock, :mutes, fn %Hunter.Client{}, [] -> - [%Account{username: "loud"}] - end) - - assert [%Account{username: "loud"}] = Account.mutes(@conn) - end - - test "accepts and rejects follow requests" do - expect(Hunter.ApiMock, :follow_request_action, 2, fn - %Hunter.Client{}, 8039, :authorize -> true - %Hunter.Client{}, 8039, :reject -> true - end) - - assert Account.accept_follow_request(@conn, 8039) - assert Account.reject_follow_request(@conn, 8039) - end -``` - -Wrapper names verified against `lib/hunter/account.ex:300,314`: `accept_follow_request/2` and `reject_follow_request/2` both delegate to the `follow_request_action` callback. - -- [ ] **Step 2: Create the five new test files** - -`test/hunter/report_test.exs`: - -```elixir -defmodule Hunter.ReportTest do - use ExUnit.Case, async: true - - import Mox - - alias Hunter.Report - - @conn Hunter.Client.new(base_url: "https://example.com", bearer_token: "123456") - - setup :verify_on_exit! - - test "returns authenticated user's reports" do - expect(Hunter.ApiMock, :reports, fn %Hunter.Client{} -> - [%Report{id: "48914", action_taken: false}] - end) - - assert [%Report{id: "48914"}] = Report.reports(@conn) - end - - test "reports an account" do - expect(Hunter.ApiMock, :report, fn %Hunter.Client{}, 8039, [153_452], "spam" -> - %Report{id: "48915", action_taken: false} - end) - - assert %Report{id: "48915"} = Report.report(@conn, 8039, [153_452], "spam") - end -end -``` - -`test/hunter/result_test.exs`: - -```elixir -defmodule Hunter.ResultTest do - use ExUnit.Case, async: true - - import Mox - - alias Hunter.Result - - @conn Hunter.Client.new(base_url: "https://example.com", bearer_token: "123456") - - setup :verify_on_exit! - - test "searches for content" do - expect(Hunter.ApiMock, :search, fn %Hunter.Client{}, "elixir", [] -> - %Result{accounts: [], statuses: [], hashtags: ["elixir"]} - end) - - assert %Result{hashtags: ["elixir"]} = Result.search(@conn, "elixir") - end -end -``` - -`test/hunter/context_test.exs`: - -```elixir -defmodule Hunter.ContextTest do - use ExUnit.Case, async: true - - import Mox - - alias Hunter.Context - - @conn Hunter.Client.new(base_url: "https://example.com", bearer_token: "123456") - - setup :verify_on_exit! - - test "returns the context of a status" do - expect(Hunter.ApiMock, :status_context, fn %Hunter.Client{}, 153_452 -> - %Context{ancestors: [], descendants: [%Hunter.Status{id: "153453"}]} - end) - - assert %Context{descendants: [%Hunter.Status{}]} = Context.status_context(@conn, 153_452) - end -end -``` - -`test/hunter/attachment_test.exs`: - -```elixir -defmodule Hunter.AttachmentTest do - use ExUnit.Case, async: true - - import Mox - - alias Hunter.Attachment - - @conn Hunter.Client.new(base_url: "https://example.com", bearer_token: "123456") - - setup :verify_on_exit! - - test "uploads a media file" do - expect(Hunter.ApiMock, :upload_media, fn %Hunter.Client{}, "image.png", [] -> - %Attachment{id: "22345792", type: "image"} - end) - - assert %Attachment{type: "image"} = Attachment.upload_media(@conn, "image.png") - end -end -``` - -`test/hunter/domain_test.exs`: - -```elixir -defmodule Hunter.DomainTest do - use ExUnit.Case, async: true - - import Mox - - alias Hunter.Domain - - @conn Hunter.Client.new(base_url: "https://example.com", bearer_token: "123456") - - setup :verify_on_exit! - - test "returns blocked domains" do - expect(Hunter.ApiMock, :blocked_domains, fn %Hunter.Client{}, [] -> - ["spam.example"] - end) - - assert ["spam.example"] = Domain.blocked_domains(@conn) - end - - test "blocks and unblocks a domain" do - expect(Hunter.ApiMock, :block_domain, fn %Hunter.Client{}, "spam.example" -> true end) - expect(Hunter.ApiMock, :unblock_domain, fn %Hunter.Client{}, "spam.example" -> true end) - - assert Domain.block_domain(@conn, "spam.example") - assert Domain.unblock_domain(@conn, "spam.example") - end -end -``` - -- [ ] **Step 3: Run the full suite, lint, commit** - -Run: `mix test && mix format --check-formatted && mix credo --strict` -Expected: PASS. Fix any expectation-head mismatches against the real signatures (read the entity module, not the test, as the source of truth). - -```bash -git add test/hunter -git commit -m "cover remaining API surface: account extras, report, result, context, attachment, domain" -``` - -### Task 11: Open PR2 - -- [ ] **Step 1: Push and create the stacked PR** - -```bash -git push -u origin unit-test-suite -gh pr create --base ci-workflow --title "Robust unit test suite (issue #13)" --body "$(cat <<'EOF' -Part 2 of 3 stacked PRs (base: #) implementing docs/superpowers/specs/2026-07-06-ci-and-test-suite-design.md. - -- Extracts JSON→struct decoding into `Hunter.Api.Transformer` and covers every entity with real Mastodon JSON fixtures (nested structs included — previously context/result/notification left inner entities as plain maps) -- Refactors `Hunter.Api.Request` so body building, header merging, and response handling are pure and unit-tested -- Extends Mox delegation tests to the full API surface: Status, Relationship, Account extras, Report, Result, Context, Attachment, Domain, plus error propagation -- Adds `Hunter.Client` / `Hunter.Error` tests - -Closes #13. - -🤖 Generated with [Claude Code](https://claude.com/claude-code) -EOF -)" -``` - -Replace `#` with the actual number from Task 3. - -- [ ] **Step 2: Watch checks** - -Run: `gh pr checks --watch` -Expected: green. - ---- - -# PR3: Integration suite + Mastodon-in-Docker CI (branch `integration-tests`, base `unit-test-suite`) - -```bash -git checkout unit-test-suite && git checkout -b integration-tests -``` - -### Task 12: Integration harness (exclusion + case template) - -**Files:** -- Modify: `test/test_helper.exs` -- Create: `test/support/integration_case.ex` -- Modify: `mix.exs` (`elixirc_paths` must include `test/support` in test env) - -**Interfaces:** -- Produces: `Hunter.IntegrationCase` — `use`-able case that provides `conn` (user 1) and `conn2` (user 2) in the test context, plus `eventually/2` for sidekiq-async assertions. Reads `HUNTER_BASE_URL`, `HUNTER_TOKEN`, `HUNTER_TOKEN2`. - -- [ ] **Step 1: Update `test/test_helper.exs`** - -```elixir -ExUnit.start(exclude: [:integration]) - -Mox.defmock(Hunter.ApiMock, for: Hunter.Api) -Application.put_env(:hunter, :hunter_api, Hunter.ApiMock) - -ExUnit.after_suite(fn _ -> - "../tmp" - |> Path.expand(__DIR__) - |> File.rm_rf() -end) -``` - -(Only the first line changes: `:integration` excluded by default; `mix test --only integration` overrides it.) - -- [ ] **Step 2: Update `mix.exs` elixirc_paths** - -Replace `elixirc_paths: ["lib"]` with: - -```elixir - elixirc_paths: elixirc_paths(Mix.env()), -``` - -and add inside the module: - -```elixir - defp elixirc_paths(:test), do: ["lib", "test/support"] - defp elixirc_paths(_), do: ["lib"] -``` - -- [ ] **Step 3: Create `test/support/integration_case.ex`** - -```elixir -defmodule Hunter.IntegrationCase do - @moduledoc """ - Case template for tests that run against a real Mastodon server. - - Requires `HUNTER_BASE_URL`, `HUNTER_TOKEN` and `HUNTER_TOKEN2` to be set; - run via `mix test --only integration` so the mock-based unit suite does not - run concurrently (the API adapter is swapped globally). - """ - - use ExUnit.CaseTemplate - - using do - quote do - import Hunter.IntegrationCase, only: [eventually: 1, eventually: 2] - - @moduletag :integration - @moduletag timeout: 120_000 - end - end - - setup_all do - base_url = fetch_env!("HUNTER_BASE_URL") - token = fetch_env!("HUNTER_TOKEN") - token2 = fetch_env!("HUNTER_TOKEN2") - - previous_api = Application.get_env(:hunter, :hunter_api) - previous_http = Application.get_env(:hunter, :http_options) - - Application.put_env(:hunter, :hunter_api, Hunter.Api.HTTPClient) - # The CI stack fronts Mastodon with a self-signed TLS cert. - Application.put_env(:hunter, :http_options, hackney: [:insecure], recv_timeout: 30_000) - - on_exit(fn -> - Application.put_env(:hunter, :hunter_api, previous_api) - Application.put_env(:hunter, :http_options, previous_http) - end) - - {:ok, - conn: Hunter.Client.new(base_url: base_url, bearer_token: token), - conn2: Hunter.Client.new(base_url: base_url, bearer_token: token2)} - end - - @doc """ - Retries `fun` until it returns without raising, for async server-side - effects (sidekiq). Raises the last error after `attempts` tries. - """ - def eventually(fun, attempts \\ 30) - - def eventually(fun, 1), do: fun.() - - def eventually(fun, attempts) do - fun.() - rescue - _ -> - Process.sleep(1_000) - eventually(fun, attempts - 1) - end - - defp fetch_env!(name) do - System.get_env(name) || - raise """ - #{name} is not set. - - Integration tests need a running Mastodon server. Locally: - - ./scripts/ci/setup_mastodon.sh - source scripts/ci/.env.hunter - mix test --only integration - """ - end -end -``` - -- [ ] **Step 4: Verify the unit suite still passes and integration is skipped** - -Run: `mix test` -Expected: same pass count as before, output shows `Excluding tags: [:integration]`. - -- [ ] **Step 5: Commit** - -```bash -git add test/test_helper.exs test/support/integration_case.ex mix.exs -git commit -m "add integration case template gated behind --only integration" -``` - -### Task 13: Integration tests - -**Files:** -- Create: `test/integration/mastodon_test.exs` - -**Interfaces:** -- Consumes: `Hunter.IntegrationCase` (Task 12). Server contract (Task 14): user 1 = `hunter`, user 2 = `kadaba`, both confirmed/approved, tokens with `read write follow` scopes. - -Spec deviation, intentional: the flow uses `Account.search_account` (endpoint still present in Mastodon 4.x) instead of `Result.search`, because `Hunter.Api.HTTPClient` targets `/api/v1/search`, which modern Mastodon removed (v2 only). File a follow-up issue for migrating `Result.search` to `/api/v2/search` when PR3 goes up — that's exactly the API drift these tests exist to catch. - -- [ ] **Step 1: Write `test/integration/mastodon_test.exs`** - -```elixir -defmodule Hunter.Integration.MastodonTest do - use Hunter.IntegrationCase, async: false - - alias Hunter.{Account, Attachment, Instance, Notification, Relationship, Status} - - @png Base.decode64!( - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==" - ) - - test "verifies credentials of both provisioned users", %{conn: conn, conn2: conn2} do - assert %Account{username: "hunter"} = Account.verify_credentials(conn) - assert %Account{username: "kadaba"} = Account.verify_credentials(conn2) - end - - test "fetches instance information", %{conn: conn} do - assert %Instance{uri: uri, version: version} = Instance.instance_info(conn) - assert is_binary(uri) - assert is_binary(version) - end - - test "status lifecycle: create, fetch, favourite, reblog, destroy", %{ - conn: conn, - conn2: conn2 - } do - text = "hunter integration test #hunterci" - - status = Status.create_status(conn, text) - assert %Status{id: id, content: content} = status - assert content =~ "hunterci" - - assert %Status{id: ^id} = Status.status(conn, id) - - assert %Status{favourited: true} = Status.favourite(conn2, id) - assert %Status{} = Status.unfavourite(conn2, id) - - assert %Status{reblogged: true} = Status.reblog(conn2, id) - assert %Status{} = Status.unreblog(conn2, id) - - assert Status.destroy_status(conn, id) - - # deletion is synchronous; a subsequent fetch is a 404, which request! raises - assert_raise Hunter.Error, fn -> Status.status(conn, id) end - end - - test "statuses appear on the local public timeline", %{conn: conn} do - %Status{id: id} = Status.create_status(conn, "timeline check #hunterci") - - eventually(fn -> - timeline = Status.public_timeline(conn, local: true) - assert Enum.any?(timeline, &(&1.id == id)) - end) - - Status.destroy_status(conn, id) - end - - test "follow, relationship, and notifications across accounts", %{conn: conn, conn2: conn2} do - %Account{username: "hunter"} = Account.verify_credentials(conn) - %Account{id: id2} = Account.verify_credentials(conn2) - - assert %Relationship{following: true} = Relationship.follow(conn, id2) - assert [%Relationship{following: true}] = Relationship.relationships(conn, [id2]) - - %Status{id: status_id} = Status.create_status(conn2, "hello @hunter #hunterci") - - eventually(fn -> - notifications = Notification.notifications(conn) - - assert Enum.any?(notifications, fn n -> - n.type == "mention" and n.account.username == "kadaba" - end) - end) - - assert %Relationship{following: false} = Relationship.unfollow(conn, id2) - Status.destroy_status(conn2, status_id) - end - - test "searches for accounts", %{conn: conn} do - accounts = Account.search_account(conn, %{q: "kadaba"}) - - assert Enum.any?(accounts, &(&1.username == "kadaba")) - end - - test "uploads media and attaches it to a status", %{conn: conn} do - path = Path.join(System.tmp_dir!(), "hunter-integration.png") - File.write!(path, @png) - - assert %Attachment{id: media_id, type: "image"} = Attachment.upload_media(conn, path) - - %Status{id: id, media_attachments: attachments} = - eventually(fn -> - Status.create_status(conn, "media test #hunterci", media_ids: [media_id]) - end) - - assert Enum.any?(attachments, &(&1.id == media_id)) - Status.destroy_status(conn, id) - after - File.rm(Path.join(System.tmp_dir!(), "hunter-integration.png")) - end -end -``` - -Before finalizing, read `lib/hunter/status.ex:105` for `create_status` options handling (the `media_ids` option) and `lib/hunter/attachment.ex:57` for the upload signature; adjust call shapes to match the real code. - -- [ ] **Step 2: Verify compile + unit suite unaffected** - -Run: `mix test` -Expected: integration tests listed as excluded; everything else passes. (Live verification happens in Task 14.) - -- [ ] **Step 3: Commit** - -```bash -git add test/integration/mastodon_test.exs -git commit -m "add integration suite against a live Mastodon server" -``` - -### Task 14: Mastodon docker stack + provisioning script, verified locally - -**Files:** -- Create: `docker-compose.ci.yml`, `scripts/ci/nginx.conf`, `scripts/ci/setup_mastodon.sh` (chmod +x) -- Modify: `.gitignore` (ignore generated env/cert files), `CONTRIBUTING.md` (how to run integration tests) - -**Interfaces:** -- Produces: script writes `scripts/ci/.env.hunter` containing `HUNTER_BASE_URL=https://localhost:3000`, `HUNTER_TOKEN=…`, `HUNTER_TOKEN2=…` (and appends the same to `$GITHUB_ENV` when set). Users: `hunter` / `kadaba` per Task 13's contract. - -- [ ] **Step 1: Write `docker-compose.ci.yml`** - -```yaml -# CI/local integration-test stack. Mastodon production mode forces SSL, so an -# nginx sidecar terminates TLS with a self-signed cert and forwards -# X-Forwarded-Proto: https. -services: - db: - image: postgres:15-alpine - environment: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres - healthcheck: - test: ["CMD", "pg_isready", "-U", "postgres"] - interval: 2s - timeout: 5s - retries: 30 - - redis: - image: redis:7-alpine - healthcheck: - test: ["CMD", "redis-cli", "ping"] - interval: 2s - timeout: 5s - retries: 30 - - web: - image: ghcr.io/mastodon/mastodon:v4.3.8 - env_file: scripts/ci/.env.mastodon - command: bundle exec puma -C config/puma.rb - depends_on: - db: - condition: service_healthy - redis: - condition: service_healthy - healthcheck: - test: ["CMD-SHELL", "curl -fsS http://localhost:3000/health || exit 1"] - interval: 5s - timeout: 5s - retries: 60 - - sidekiq: - image: ghcr.io/mastodon/mastodon:v4.3.8 - env_file: scripts/ci/.env.mastodon - command: bundle exec sidekiq - depends_on: - db: - condition: service_healthy - redis: - condition: service_healthy - - nginx: - image: nginx:1.27-alpine - ports: - - "3000:3000" - volumes: - - ./scripts/ci/nginx.conf:/etc/nginx/nginx.conf:ro - - ./scripts/ci/certs:/etc/nginx/certs:ro - depends_on: - web: - condition: service_healthy -``` - -- [ ] **Step 2: Write `scripts/ci/nginx.conf`** - -```nginx -events {} - -http { - server { - listen 3000 ssl; - - ssl_certificate /etc/nginx/certs/localhost.crt; - ssl_certificate_key /etc/nginx/certs/localhost.key; - - client_max_body_size 40m; - - location / { - proxy_pass http://web:3000; - proxy_set_header Host localhost; - proxy_set_header X-Forwarded-Proto https; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Real-IP $remote_addr; - } - } -} -``` - -- [ ] **Step 3: Write `scripts/ci/setup_mastodon.sh`** - -```bash -#!/usr/bin/env bash -# Boots a disposable Mastodon (docker-compose.ci.yml), provisions two users -# with OAuth tokens, and writes scripts/ci/.env.hunter with the env vars the -# integration suite needs. Idempotent: safe to re-run. -set -euo pipefail - -cd "$(dirname "$0")/../.." -COMPOSE="docker compose -f docker-compose.ci.yml" -CI_DIR=scripts/ci -MASTODON_ENV="$CI_DIR/.env.mastodon" -HUNTER_ENV="$CI_DIR/.env.hunter" - -# 1. Secrets (generated once; arbitrary values are fine for a throwaway box, -# but VAPID keys must be a real EC pair, so those come from the rake task). -if [ ! -f "$MASTODON_ENV" ]; then - cat > "$MASTODON_ENV" <> "$MASTODON_ENV" -fi - -# 2. Self-signed cert for the nginx TLS front. -mkdir -p "$CI_DIR/certs" -if [ ! -f "$CI_DIR/certs/localhost.crt" ]; then - openssl req -x509 -newkey rsa:2048 -nodes -days 30 \ - -keyout "$CI_DIR/certs/localhost.key" \ - -out "$CI_DIR/certs/localhost.crt" \ - -subj "/CN=localhost" -fi - -# 3. Database + app boot. -$COMPOSE up -d db redis -$COMPOSE run --rm web bundle exec rails db:prepare -$COMPOSE up -d web sidekiq nginx - -echo "Waiting for Mastodon to answer..." -for _ in $(seq 1 60); do - if curl -fsSk https://localhost:3000/health > /dev/null 2>&1; then - break - fi - sleep 2 -done -curl -fsSk https://localhost:3000/health > /dev/null - -# 4. Users + OAuth tokens. -create_user() { - # tootctl exits non-zero if the user exists; tolerate for idempotency. - $COMPOSE exec -T web bin/tootctl accounts create "$1" \ - --email "$1@example.com" --confirmed --approve > /dev/null 2>&1 || true -} -create_user hunter -create_user kadaba - -mint_token() { - $COMPOSE exec -T web bin/rails runner " - app = Doorkeeper::Application.find_or_create_by!(name: 'hunter-ci') do |a| - a.redirect_uri = 'urn:ietf:wg:oauth:2.0:oob' - a.scopes = 'read write follow' - end - user = User.find_by!(email: '$1@example.com') - token = Doorkeeper::AccessToken.find_or_create_by!( - application_id: app.id, resource_owner_id: user.id, revoked_at: nil - ) { |t| t.scopes = app.scopes.to_s } - puts token.token - " | tr -d '[:space:]' -} - -TOKEN1=$(mint_token hunter) -TOKEN2=$(mint_token kadaba) - -cat > "$HUNTER_ENV" <> "$GITHUB_ENV" -fi - -echo "Mastodon ready at https://localhost:3000 (users: hunter, kadaba)" -echo "Run: source $HUNTER_ENV && mix test --only integration" -``` - -```bash -chmod +x scripts/ci/setup_mastodon.sh -``` - -- [ ] **Step 4: Ignore generated files** - -Append to `.gitignore`: - -``` -/scripts/ci/.env.mastodon -/scripts/ci/.env.hunter -/scripts/ci/certs/ -``` - -- [ ] **Step 5: Run it locally and drive the suite green** - -Run: - -```bash -./scripts/ci/setup_mastodon.sh -source scripts/ci/.env.hunter # bash/zsh; for fish: use `bash -c '...'` or export manually -mix test --only integration -``` - -Expected: all integration tests pass. This step WILL surface surprises (image tag, tootctl flags, Doorkeeper API, endpoint drift, sidekiq timing) — iterate with systematic-debugging until green. Budget real time for it; do not skip local verification and hope CI works. If Docker is unavailable locally, say so explicitly in the task report and rely on Step 5 of Task 15 (CI iteration) instead. - -Teardown between attempts when needed: `docker compose -f docker-compose.ci.yml down -v` (add `rm scripts/ci/.env.mastodon` to regenerate secrets). - -- [ ] **Step 6: Document in CONTRIBUTING.md** - -Append a section: - -```markdown -## Running the test suite - -* `mix test` — fast, offline unit suite (integration tests are excluded). -* Integration tests run against a real Mastodon server: - - ./scripts/ci/setup_mastodon.sh - source scripts/ci/.env.hunter - mix test --only integration - - Requires Docker. The stack is disposable: `docker compose -f docker-compose.ci.yml down -v`. - You can also point the suite at any instance you own by exporting - `HUNTER_BASE_URL`, `HUNTER_TOKEN` and `HUNTER_TOKEN2` yourself. -``` - -- [ ] **Step 7: Commit** - -```bash -git add docker-compose.ci.yml scripts/ci/nginx.conf scripts/ci/setup_mastodon.sh .gitignore CONTRIBUTING.md -git commit -m "add disposable Mastodon stack for integration tests" -``` - -### Task 15: CI integration job + PR3 - -**Files:** -- Modify: `.github/workflows/ci.yml` (append job) - -- [ ] **Step 1: Append the `integration` job to `.github/workflows/ci.yml`** - -```yaml - integration: - name: Integration (real Mastodon) - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: erlef/setup-beam@v1 - with: - version-file: .tool-versions - version-type: strict - - uses: actions/cache@v4 - with: - path: | - deps - _build - key: mix-integration-${{ runner.os }}-${{ hashFiles('.tool-versions', 'mix.lock') }} - restore-keys: | - mix-integration-${{ runner.os }}- - - run: mix deps.get - - run: mix compile --warnings-as-errors - - name: Boot Mastodon and provision tokens - run: ./scripts/ci/setup_mastodon.sh - - name: Run integration tests - run: mix test --only integration - - name: Dump Mastodon logs on failure - if: failure() - run: docker compose -f docker-compose.ci.yml logs --tail 200 -``` - -- [ ] **Step 2: Commit and open PR3** - -```bash -git add .github/workflows/ci.yml -git commit -m "run integration suite against a real Mastodon server in CI" -git push -u origin integration-tests -gh pr create --base unit-test-suite --title "Integration tests against a real Mastodon server" --body "$(cat <<'EOF' -Part 3 of 3 stacked PRs (base: #) implementing docs/superpowers/specs/2026-07-06-ci-and-test-suite-design.md. - -- `test/integration/` suite (tagged, excluded from `mix test` by default), driven by `HUNTER_BASE_URL`/`HUNTER_TOKEN`/`HUNTER_TOKEN2` -- Disposable Mastodon stack (`docker-compose.ci.yml` + `scripts/ci/setup_mastodon.sh`): postgres, redis, mastodon v4.3.8 (pinned), sidekiq, nginx TLS front (production Mastodon forces SSL) -- CI `integration` job boots the stack and runs the suite on every PR -- Flow covered: credentials, instance info, status lifecycle, favourite/reblog, timelines, follow + notifications across two accounts, account search, media upload - -Known API drift found while writing this (follow-up issues): `/api/v1/search` (used by `Result.search`) and `/api/v1/follows` (`Account.follow_by_uri`) were removed in modern Mastodon. - -🤖 Generated with [Claude Code](https://claude.com/claude-code) -EOF -)" -``` - -Replace `#` with the actual number. - -- [ ] **Step 3: Watch checks; iterate in CI if needed** - -Run: `gh pr checks --watch` -Expected: all jobs green, including `Integration (real Mastodon)`. CI failures in the integration job: pull logs via the failure step's output (`gh run view --log-failed`), fix, push. Common suspects: image tag availability on ghcr, disk/memory on the runner, timing (raise `eventually` attempts or healthcheck retries). - -- [ ] **Step 4: File the API-drift follow-up issue** - -```bash -gh issue create --title "Migrate Result.search to /api/v2/search and drop removed endpoints" --body "$(cat <<'EOF' -Found while building the integration suite (spec: docs/superpowers/specs/2026-07-06-ci-and-test-suite-design.md): - -- `Hunter.Api.HTTPClient.search/3` targets `/api/v1/search`, removed in Mastodon 3.0+ (use `/api/v2/search`; note `hashtags` become objects instead of strings) -- `Hunter.Api.HTTPClient.follow_by_uri/2` targets `/api/v1/follows`, removed in Mastodon 4.0 (use `POST /api/v1/accounts/:id/follow` after resolving via search) -- `GET /api/v1/reports` was also removed - -🤖 Generated with [Claude Code](https://claude.com/claude-code) -EOF -)" -``` - ---- - -## Merge order - -PR1 → merge to `main`; retarget PR2 to `main` (GitHub does this automatically on branch deletion) → merge → retarget PR3 → merge. Use the superpowers:finishing-a-development-branch skill at each merge point. diff --git a/docs/superpowers/plans/2026-07-07-auth-fixes.md b/docs/superpowers/plans/2026-07-07-auth-fixes.md deleted file mode 100644 index 58bb87b..0000000 --- a/docs/superpowers/plans/2026-07-07-auth-fixes.md +++ /dev/null @@ -1,335 +0,0 @@ -# Auth Fixes Implementation Plan (#100 + #101) - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** `log_in/4` requests the app's registered scopes so tokens can write (#100), and `Hunter.Client.bearer_token` becomes `access_token` (#101) — one branch, one PR, 0.6.0. - -**Architecture:** Rename first (Task 2) so all later code uses the new field. Then `Hunter.Application` grows a `scopes` field that `create_app` fills with the *requested* scopes (Task 3), and `log_in` joins them into the password-grant `scope` parameter, omitting it when `scopes` is nil/empty so stale saved credentials keep today's behavior (Task 4). The regression proof is a live integration test running the README auth flow end-to-end (Task 5). - -**Tech Stack:** Elixir/ExUnit, Mox, Poison persistence, the docker-compose Mastodon stack (`tootctl` password reset for a known login). - -**Spec:** `docs/superpowers/specs/2026-07-07-auth-fixes-design.md` - -## Global Constraints - -- Branch `auth-fixes` off `main` (exists, spec committed at e18acf1). Single PR, base `main`. -- Hard rename: after Task 2, `git grep -n "bearer_token" -- lib test README.md` returns nothing (CHANGELOG history may keep old mentions; none exist today). -- `scope` parameter omitted from the token payload when `Application.scopes` is `nil` or `[]` (old saved credentials keep current behavior). -- Every commit passes `mix compile --warnings-as-errors && mix test && mix format --check-formatted && mix credo --strict`; run `mix dialyzer` once in Task 6 before pushing (struct/type changes). -- Integration verification: `./scripts/ci/setup_mastodon.sh` then `bash -c 'source scripts/ci/.env.hunter && mix test --only integration'`. -- Commit messages end with `Co-Authored-By: Claude Fable 5 `; PR body ends with `🤖 Generated with [Claude Code](https://claude.com/claude-code)` and says `Fixes #100. Closes #101.` - ---- - -### Task 1: restore the CHANGELOG entry lost in the #109 rebase-merge - -**Files:** -- Modify: `CHANGELOG.md` - -- [ ] **Step 1: Add the Bug fixes block** — under `## Unreleased`, after the `* Breaking changes` block (currently ending with the `reports/1` bullet around line 14), insert: - -```markdown - - * Bug fixes - - GET/DELETE request options now travel as query-string parameters instead - of JSON request bodies, which proxies routinely drop ([#74]) -``` - -and at the bottom of the file add the link reference (match the file's existing reference style; if none exists, plain `[#74]: https://github.com/milmazz/hunter/issues/74` on its own line at the end): - -```markdown -[#74]: https://github.com/milmazz/hunter/issues/74 -``` - -- [ ] **Step 2: Commit** - -```bash -git add CHANGELOG.md -git commit -m "restore #74 changelog entry dropped in the #109 rebase-merge - -Co-Authored-By: Claude Fable 5 " -``` - -### Task 2: #101 hard rename `bearer_token` → `access_token` - -**Files:** -- Modify: `lib/hunter/client.ex` (struct, @type, docs), `lib/hunter/api/http_client.ex` (`get_headers/1`, `log_in/4`, `log_in_oauth/3` constructors), doc examples in `lib/hunter.ex`, `lib/hunter/instance.ex`, `lib/hunter/account.ex`, `lib/hunter/card.ex`, `README.md`, `CHANGELOG.md` -- Modify (tests): `test/support/integration_case.ex` and every unit test file constructing a client: `test/hunter/{account,attachment,card,client,context,domain,instance,notification,relationship,report,result,status}_test.exs` - -**Interfaces:** -- Produces: `%Hunter.Client{base_url: String.t(), access_token: String.t()}` — Tasks 4-5 build clients with `access_token:`. - -- [ ] **Step 1: Mechanical rename** — replace the atom/field `bearer_token` with `access_token` in every file above. The complete list of change kinds: - - `lib/hunter/client.ex`: `defstruct [:base_url, :access_token]`, the `@type t` field, and both mentions in the moduledoc/`new/1` `## Options` docs. - - `lib/hunter/api/http_client.ex`: `defp get_headers(%Hunter.Client{access_token: token})`, and the two `%Hunter.Client{base_url: base_url, access_token: response["access_token"]}` constructors in `log_in/4` / `log_in_oauth/3`. - - Doc examples (`iex> Hunter.Client.new(bearer_token: …)`) in `lib/hunter.ex`, `lib/hunter/instance.ex`, `lib/hunter/account.ex`, `lib/hunter/card.ex` → `access_token:`. - - Every test file's `@conn Hunter.Client.new(base_url: "https://example.com", access_token: "123456")`, `client_test.exs`'s construction + assertion (`%Client{base_url: …, access_token: "123456"}`), and `integration_case.ex`'s two `Hunter.Client.new(base_url: base_url, access_token: token)` calls. - - `README.md`: every `bearer_token` occurrence in examples/prose. - -Use per-file edits, not a blind repo-wide sed — the string `"access_token"` (the JSON response key in `http_client.ex`) must stay untouched, and CHANGELOG/docs specs are out of scope. - -- [ ] **Step 2: Verify the rename is total** - -Run: `git grep -n "bearer_token" -- lib test README.md` -Expected: no output. - -- [ ] **Step 3: CHANGELOG breaking entry** — append to the `* Breaking changes` list in `## Unreleased`: - -```markdown - - `Hunter.Client` field `bearer_token` was renamed to `access_token` for - consistency with other Mastodon client libraries; update - `Hunter.Client.new(bearer_token: …)` calls to `access_token:` ([#101]) -``` - -with `[#101]: https://github.com/milmazz/hunter/issues/101` added to the link references. - -- [ ] **Step 4: Full gate** - -Run: `mix compile --warnings-as-errors && mix test && mix format --check-formatted && mix credo --strict` -Expected: green — 4 doctests, 76 tests (`--warnings-as-errors` plus the doctests catch any straggler). - -- [ ] **Step 5: Commit** - -```bash -git add -A -git commit -m "rename Client.bearer_token to access_token - -BREAKING: aligns with other Mastodon client libraries (#101). - -Co-Authored-By: Claude Fable 5 " -``` - -### Task 3: `Hunter.Application.scopes` + `create_app` stores requested scopes (TDD) - -**Files:** -- Modify: `lib/hunter/application.ex` (struct/@type/docs), `lib/hunter/api/http_client.ex:70-81` (`create_app/5`) -- Test: `test/hunter/application_test.exs` - -**Interfaces:** -- Produces: `%Hunter.Application{id, client_id, client_secret, scopes :: [String.t()] | nil}` — Task 4's `log_in` reads `scopes`; persistence via the existing `save_credentials`/`load_credentials` picks the field up automatically (Poison encodes/decodes struct fields). - -- [ ] **Step 1: Extend the persistence tests (failing first)** — in `test/hunter/application_test.exs`, the existing "store credentials" and "load persisted" tests build `%Hunter.Application{…}` values; add `scopes: ["read", "write"]` to the constructed app in BOTH tests and assert the loaded struct round-trips it: - -```elixir - assert %Hunter.Application{scopes: ["read", "write"]} = loaded -``` - -(match the test file's existing local variable names — read the file first; the assertion target is whatever `load_credentials` returns there). - -- [ ] **Step 2: Run to verify failure** - -Run: `mix test test/hunter/application_test.exs` -Expected: FAIL — `scopes` is not a key of `Hunter.Application` (KeyError at construction). - -- [ ] **Step 3: Add the field** — `lib/hunter/application.ex`: - -```elixir - defstruct [:id, :client_id, :client_secret, :scopes] -``` - -extend the `@type t` with `scopes: [String.t()] | nil` and add a `* scopes - scopes requested when the app was registered` line to the fields doc. - -- [ ] **Step 4: Store requested scopes in `create_app`** — `lib/hunter/api/http_client.ex`: - -```elixir - def create_app(name, redirect_uri, scopes, website, base_url) do - payload = %{ - client_name: name, - redirect_uris: redirect_uri, - scopes: Enum.join(scopes, " "), - website: website - } - - app = - "/api/v1/apps" - |> process_url(base_url) - |> request!(:application, :post, payload) - - %Hunter.Application{app | scopes: scopes} - end -``` - -(The requested list is authoritative — the v1 apps response cannot be trusted to echo scopes across server versions.) - -- [ ] **Step 5: Full gate and commit** - -Run: `mix compile --warnings-as-errors && mix test && mix format --check-formatted && mix credo --strict` -Expected: green. - -```bash -git add lib/hunter/application.ex lib/hunter/api/http_client.ex test/hunter/application_test.exs -git commit -m "record requested scopes on Hunter.Application - -Co-Authored-By: Claude Fable 5 " -``` - -### Task 4: `log_in/4` sends the app's scopes (#100 fix) - -**Files:** -- Modify: `lib/hunter/api/http_client.ex` (`log_in/4`, around line 288), `CHANGELOG.md` - -**Interfaces:** -- Consumes: `Hunter.Application.scopes` from Task 3. - -- [ ] **Step 1: Rewrite `log_in/4`** - -```elixir - def log_in(%Hunter.Application{} = app, username, password, base_url) do - payload = %{ - client_id: app.client_id, - client_secret: app.client_secret, - grant_type: "password", - username: username, - password: password - } - - payload = - case app.scopes do - scopes when is_list(scopes) and scopes != [] -> - Map.put(payload, :scope, Enum.join(scopes, " ")) - - _ -> - payload - end - - response = - "/oauth/token" - |> process_url(base_url) - |> request!(nil, :post, payload) - - %Hunter.Client{base_url: base_url, access_token: response["access_token"]} - end -``` - -(Behavior for `scopes: nil`/`[]` — the parameter is omitted, byte-identical to today, so stale saved credential files keep working. This is unit-untestable without a network; the live proof is Task 5.) - -- [ ] **Step 2: CHANGELOG bug-fix entry** — append under the `* Bug fixes` block from Task 1: - -```markdown - - `Hunter.log_in/4` now requests the scopes the app was registered with; - previously the token silently fell back to Mastodon's default `read` - scope, making every write action fail with "This action is outside the - authorized scopes" ([#100]). Re-run `create_app` once to refresh saved - credentials created by older hunter versions. -``` - -with `[#100]: https://github.com/milmazz/hunter/issues/100` in the link references. - -- [ ] **Step 3: Full gate and commit** - -Run: `mix compile --warnings-as-errors && mix test && mix format --check-formatted && mix credo --strict` -Expected: green. - -```bash -git add lib/hunter/api/http_client.ex CHANGELOG.md -git commit -m "request the app's registered scopes in the password grant - -Fixes the read-only tokens behind issue #100. - -Co-Authored-By: Claude Fable 5 " -``` - -### Task 5: known password in the stack + live README-flow regression test - -**Files:** -- Modify: `scripts/ci/setup_mastodon.sh`, `test/support/integration_case.ex`, `test/integration/mastodon_test.exs` - -**Interfaces:** -- Produces: `HUNTER_PASSWORD2` env var (kadaba's password) in `scripts/ci/.env.hunter` and `$GITHUB_ENV`; `password2` in the integration context. - -- [ ] **Step 1: setup script** — in `scripts/ci/setup_mastodon.sh`, after `TOKEN2=$(mint_token kadaba)`, add: - -```bash -PASSWORD2=$($COMPOSE exec -T web bin/tootctl accounts modify kadaba --reset-password \ - | awk '/New password:/ {print $3}') -``` - -and extend both output blocks: add `export HUNTER_PASSWORD2=$PASSWORD2` to the `$HUNTER_ENV` heredoc and `echo "HUNTER_PASSWORD2=$PASSWORD2"` to the `$GITHUB_ENV` block. - -- [ ] **Step 2: integration case** — in `test/support/integration_case.ex` `setup_all`, add `password2 = fetch_env!("HUNTER_PASSWORD2")` next to the other three, and `password2: password2` to the returned context keyword list. Update the module's `@moduledoc` env-var list to mention it. - -- [ ] **Step 3: the regression test** — append to `test/integration/mastodon_test.exs`: - -```elixir - test "README auth flow: create_app + log_in yields a token that can write", %{ - conn: conn, - password2: password2 - } do - app = - Hunter.create_app( - "hunter-auth-#{System.unique_integer([:positive])}", - "urn:ietf:wg:oauth:2.0:oob", - ["read", "write"], - nil, - api_base_url: conn.base_url - ) - - assert %Hunter.Application{scopes: ["read", "write"]} = app - - logged_in = Hunter.log_in(app, "kadaba@example.com", password2, conn.base_url) - assert %Hunter.Client{access_token: token} = logged_in - assert is_binary(token) - - %Status{id: id} = Status.create_status(logged_in, "auth flow works #hunterci") - Status.destroy_status(logged_in, id) - end -``` - -Before finalizing, confirm `Hunter.create_app/5` and `Hunter.log_in/4` delegate with these exact signatures (`grep -n "create_app\|def log_in\|defdelegate log_in" lib/hunter.ex`); adjust the calls if the top-level arity differs. - -- [ ] **Step 4: Run live** (this test FAILS against the pre-Task-4 code with the #100 error — if you want the red-state proof, run it once with Task 4's `log_in` change stashed; otherwise proceed): - -Run: `./scripts/ci/setup_mastodon.sh && bash -c 'source scripts/ci/.env.hunter && mix test --only integration'` -Expected: 10 tests, 0 failures. (The setup script must be re-run so `.env.hunter` gains `HUNTER_PASSWORD2` — the older env file will make `setup_all` raise its helpful error.) - -- [ ] **Step 5: Unit gate and commit** - -Run: `mix test && mix format --check-formatted && mix credo --strict` -Expected: green (integration excluded). - -```bash -git add scripts/ci/setup_mastodon.sh test/support/integration_case.ex test/integration/mastodon_test.exs -git commit -m "cover the README auth flow live: create_app, log_in, write - -Co-Authored-By: Claude Fable 5 " -``` - -### Task 6: dialyzer, PR, follow-up issue - -- [ ] **Step 1: Dialyzer** (struct/type changes across Tasks 2-4): - -Run: `mix dialyzer` -Expected: clean. Fix any finding before pushing (likely candidates: the changed `log_in/4` head or the new `scopes` type). - -- [ ] **Step 2: Push and open the PR** - -```bash -git push -u origin auth-fixes -gh pr create --base main --title "Auth fixes: request registered scopes on log_in; rename to access_token" --body "$(cat <<'EOF' -Fixes #100. Closes #101. - -- `log_in/4` now sends the app's registered scopes in the password grant. Previously the `scope` parameter was omitted, so Doorkeeper granted Mastodon's default `read` scope and every write action failed with "This action is outside the authorized scopes" — reproduced and verified live against Mastodon v4.3.8. `Hunter.Application` records the requested scopes (persisted by `save?: true`); stale credential files (`scopes: nil`) keep the old behavior until `create_app` is re-run. -- BREAKING: `Hunter.Client.bearer_token` → `access_token` (#101), aligned with other Mastodon client libraries. -- New live integration test covers the README auth flow end to end (create_app → log_in → post → delete), using a known password minted by the setup script (`HUNTER_PASSWORD2`). -- Restores the #74 changelog entry dropped in the #109 rebase-merge. - -🤖 Generated with [Claude Code](https://claude.com/claude-code) -EOF -)" -``` - -- [ ] **Step 3: File the deferred `log_in_oauth` issue** - -```bash -gh issue create --title "log_in_oauth: token exchange sends no redirect_uri" --body "$(cat <<'EOF' -Noticed while fixing #100: `Hunter.Api.HTTPClient.log_in_oauth/3` exchanges the authorization code without a `redirect_uri` parameter. Doorkeeper requires the token request's `redirect_uri` to match the authorization request's for the authorization_code grant, so this flow likely fails against real servers. Unverified (needs a browser authorization dance) — verify against the docker stack and fix; scopes for this grant come from the authorization itself, so no scope parameter is needed. - -🤖 Generated with [Claude Code](https://claude.com/claude-code) -EOF -)" -``` - -- [ ] **Step 4: Watch checks** - -Run: `gh pr checks --watch` -Expected: all 5 jobs green — note the integration job re-provisions from scratch, which exercises the new `HUNTER_PASSWORD2` script path in CI. diff --git a/docs/superpowers/plans/2026-07-07-query-params-and-api-drift.md b/docs/superpowers/plans/2026-07-07-query-params-and-api-drift.md deleted file mode 100644 index 28807a9..0000000 --- a/docs/superpowers/plans/2026-07-07-query-params-and-api-drift.md +++ /dev/null @@ -1,481 +0,0 @@ -# Query Params (#74) + API-Drift Cleanup (#106) Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** GET/DELETE options travel as real query-string parameters (fixes #74), then the API surface modern Mastodon removed is cleaned up (closes #106) — two stacked PRs. - -**Architecture:** PR A (`query-params`, exists off `main` with the spec committed) adds a pure `split_payload/2` routing function to `Hunter.Api.Request` so verb decides body-vs-params once, for every endpoint; `relationships/2` drops its hand-rolled query string. PR B (`api-drift`, off `query-params`) re-shapes `Result.hashtags` to v2 (`[%Hunter.Tag{}]`) and removes `follow_by_uri` and `reports/1` end to end. - -**Tech Stack:** Elixir/ExUnit, HTTPoison `params:` option, Mox, the live-Mastodon integration stack from `docker-compose.ci.yml`. - -**Spec:** `docs/superpowers/specs/2026-07-07-query-params-and-api-drift-design.md` - -## Global Constraints - -- Public signatures of `Request.request/5` and `request!/5` unchanged; POST/PATCH body behavior byte-identical. -- All breaking changes (hashtags shape, removed functions) documented in CHANGELOG.md under Unreleased (0.6.0). -- Every commit passes `mix compile --warnings-as-errors && mix test && mix format --check-formatted && mix credo --strict`; Task 6 (behaviour change) also runs `mix dialyzer`. -- Integration verification runs against the local stack: `./scripts/ci/setup_mastodon.sh` then `bash -c 'source scripts/ci/.env.hunter && mix test --only integration'`. If the stack was torn down, the script re-provisions from scratch (needs Docker running). -- Commit messages end with `Co-Authored-By: Claude Fable 5 `; PR bodies end with `🤖 Generated with [Claude Code](https://claude.com/claude-code)`. -- PR A body says `Fixes #74`; PR B body says `Closes #106`. - ---- - -# PR A: query parameters (branch `query-params`, base `main`) - -The branch already exists with the spec committed (`bfa56d9`). Work on it directly. - -### Task 1: `split_payload/2` in `Hunter.Api.Request` (TDD) - -**Files:** -- Modify: `lib/hunter/api/request.ex` -- Test: `test/hunter/api/request_test.exs` (append a describe block) - -**Interfaces:** -- Produces: `Hunter.Api.Request.split_payload(method :: atom, data) :: {body :: binary | tuple, params :: [{String.t(), String.t()}]}` — public, `@doc false`, like the module's other helpers. Task 2 and every GET/DELETE endpoint rely on it via `request/5`. - -- [ ] **Step 1: Append failing tests to `test/hunter/api/request_test.exs`** (inside the module, after the `handle_response/1` describe): - -```elixir - describe "split_payload/2" do - test "GET routes data to query params with an empty body" do - assert Request.split_payload(:get, limit: 1, local: true) == - {"", [{"limit", "1"}, {"local", "true"}]} - end - - test "DELETE routes data to query params" do - assert Request.split_payload(:delete, %{domain: "spam.example"}) == - {"", [{"domain", "spam.example"}]} - end - - test "list values encode as Rails-style repeated keys" do - assert Request.split_payload(:get, %{id: [1, 2]}) == - {"", [{"id[]", "1"}, {"id[]", "2"}]} - end - - test "empty data produces no params" do - assert Request.split_payload(:get, []) == {"", []} - assert Request.split_payload(:get, %{}) == {"", []} - end - - test "write verbs keep the JSON body and produce no params" do - assert Request.split_payload(:post, %{status: "hi"}) == {~s({"status":"hi"}), []} - assert Request.split_payload(:patch, []) == {"{}", []} - end - - test "multipart payloads pass through untouched on write verbs" do - payload = {:multipart, [{:file, "/tmp/image.png"}]} - assert Request.split_payload(:post, payload) == {payload, []} - end - end -``` - -- [ ] **Step 2: Run to verify failure** - -Run: `mix test test/hunter/api/request_test.exs` -Expected: FAIL — `Request.split_payload/2` is undefined; the 9 existing tests still pass. - -- [ ] **Step 3: Implement in `lib/hunter/api/request.ex`** - -Replace the `request/5` body and add the new functions (the rest of the module — `request!/5`, `handle_response/1`, `process_request_body/1`, `process_request_header/1` — stays exactly as-is): - -```elixir - def request(http_method, url, data \\ [], headers \\ [], options \\ []) do - {body, params} = split_payload(http_method, data) - headers = process_request_header(headers) - options = attach_params(options, params) - - http_method - |> HTTPoison.request(url, body, headers, options) - |> handle_response() - end -``` - -New public helper (place after `handle_response/1` clauses): - -```elixir - @doc false - def split_payload(method, data) when method in [:get, :delete] do - {"", encode_params(data)} - end - - def split_payload(_method, data), do: {process_request_body(data), []} -``` - -New private helpers (place at the bottom of the module): - -```elixir - defp attach_params(options, []), do: options - defp attach_params(options, params), do: Keyword.put(options, :params, params) - - defp encode_params(data) do - Enum.flat_map(data, fn - {key, values} when is_list(values) -> Enum.map(values, &{"#{key}[]", to_string(&1)}) - {key, value} -> [{to_string(key), to_string(value)}] - end) - end -``` - -Notes: values are stringified because hackney's query-string encoder requires binaries (integers/booleans would crash); `attach_params/2` leaves `options` untouched when there are no params so HTTPoison appends no bare `?`. - -- [ ] **Step 4: Run the full gate** - -Run: `mix compile --warnings-as-errors && mix test && mix format --check-formatted && mix credo --strict` -Expected: all green — 4 doctests, 79 tests (73 + 6 new), 0 failures. - -- [ ] **Step 5: Commit** - -```bash -git add lib/hunter/api/request.ex test/hunter/api/request_test.exs -git commit -m "route GET/DELETE options to query params instead of JSON bodies - -Fixes the transport for ~16 endpoints whose options only worked because -Rails happens to parse JSON bodies on GET/DELETE (#74). - -Co-Authored-By: Claude Fable 5 " -``` - -### Task 2: `relationships/2` drops its hand-rolled query string - -**Files:** -- Modify: `lib/hunter/api/http_client.ex:104-110` - -**Interfaces:** -- Consumes: `split_payload/2`'s array encoding from Task 1 (`%{id: ids}` → `id[]=…&id[]=…`). - -- [ ] **Step 1: Replace the function** - -Current code (lib/hunter/api/http_client.ex:104-110): - -```elixir - def relationships(conn, ids) do - ids_array = Enum.map(ids, fn id -> "id[]=#{id}&" end) - - "/api/v1/accounts/relationships?#{ids_array}" - |> process_url(conn) - |> request!(:relationships, :get, [], conn) - end -``` - -New code: - -```elixir - def relationships(conn, ids) do - "/api/v1/accounts/relationships" - |> process_url(conn) - |> request!(:relationships, :get, %{id: ids}, conn) - end -``` - -- [ ] **Step 2: Full gate** - -Run: `mix compile --warnings-as-errors && mix test && mix format --check-formatted && mix credo --strict` -Expected: green (the Mox-based relationship tests don't exercise HTTPClient; live verification is Task 3). - -- [ ] **Step 3: Commit** - -```bash -git add lib/hunter/api/http_client.ex -git commit -m "build relationships query via the params mechanism - -Co-Authored-By: Claude Fable 5 " -``` - -### Task 3: integration proof that params reach the server - -**Files:** -- Modify: `test/integration/mastodon_test.exs` (append one test before the final `end`) - -**Interfaces:** -- Consumes: `Hunter.IntegrationCase` (`conn` context, `eventually/2`); `Status.statuses/3` (converts options via `Map.new/1`, so keyword options are fine). - -- [ ] **Step 1: Append the test** - -```elixir - test "query parameters take effect server-side", %{conn: conn} do - %Account{id: account_id} = Account.verify_credentials(conn) - - %Status{id: id1} = Status.create_status(conn, "pagination one #hunterci") - %Status{id: id2} = Status.create_status(conn, "pagination two #hunterci") - - eventually(fn -> - assert [%Status{}] = Status.statuses(conn, account_id, limit: 1) - end) - - older = Status.statuses(conn, account_id, max_id: id2) - refute Enum.any?(older, &(&1.id == id2)) - assert Enum.any?(older, &(&1.id == id1)) - - Status.destroy_status(conn, id1) - Status.destroy_status(conn, id2) - end -``` - -(`limit: 1` must cap the response at exactly one status — the account has at least two; `max_id: id2` must exclude the anchor and include the older status. Under the old body-transport these still passed via the Rails quirk; the test pins the behavior so the transport can never silently regress to "params ignored".) - -- [ ] **Step 2: Run the integration suite against the local stack** - -Run: `./scripts/ci/setup_mastodon.sh && bash -c 'source scripts/ci/.env.hunter && mix test --only integration'` -Expected: 8 tests, 0 failures. (The setup script is idempotent; if the stack is already up it just re-mints tokens.) - -- [ ] **Step 3: Unit suite still green + format/credo** - -Run: `mix test && mix format --check-formatted && mix credo --strict` -Expected: green, integration excluded. - -- [ ] **Step 4: Commit** - -```bash -git add test/integration/mastodon_test.exs -git commit -m "assert query params take effect server-side - -Co-Authored-By: Claude Fable 5 " -``` - -### Task 4: open PR A - -- [ ] **Step 1: Push and create the PR** - -```bash -git push -u origin query-params -gh pr create --base main --title "Send GET/DELETE options as query parameters" --body "$(cat <<'EOF' -Fixes #74. - -Options for GET/DELETE requests were JSON-encoded into the request body; they only appeared to work because Rails parses JSON bodies on any verb — proxies and CDNs routinely drop them. Now: - -- `Request.split_payload/2` routes data by verb: GET/DELETE → HTTPoison `params:` (query string), write verbs → JSON body, byte-identical to before -- Array values encode Rails-style (`id[]=1&id[]=2`); `relationships/2` drops its hand-rolled query string and uses the same path -- Unit tests cover the routing table; a new integration test asserts `limit`/`max_id` actually take effect against a real Mastodon server - -PR 1 of 2 — the API-drift cleanup (#106) stacks on this because `/api/v2/search` needs `q` as a real query param. - -🤖 Generated with [Claude Code](https://claude.com/claude-code) -EOF -)" -``` - -- [ ] **Step 2: Watch checks** - -Run: `gh pr checks --watch` -Expected: all 5 jobs green (test ×2, lint, dialyzer, integration). On integration failure, `gh run view --log-failed` plus the workflow's Mastodon-log dump step; fix, push, re-watch. - ---- - -# PR B: API-drift cleanup (branch `api-drift`, base `query-params`) - -```bash -git checkout query-params && git checkout -b api-drift -``` - -### Task 5: `Result.hashtags` decodes v2 objects as `Hunter.Tag` structs (TDD) - -**Files:** -- Modify: `test/fixtures/result.json`, `test/hunter/api/transformer_test.exs`, `lib/hunter/api/transformer.ex`, `lib/hunter/result.ex`, `test/hunter/result_test.exs` - -- [ ] **Step 1: Update the fixture** — in `test/fixtures/result.json`, replace `"hashtags": ["elixir"]` with the v2 object shape: - -```json - "hashtags": [ - { "name": "elixir", "url": "https://mastodon.example/tags/elixir" } - ] -``` - -- [ ] **Step 2: Update the transformer test** — in `test/hunter/api/transformer_test.exs`, replace the result test: - -```elixir - test "decodes a search result with nested accounts, statuses, and hashtags" do - result = transform("result", :result) - - assert %Hunter.Result{} = result - assert [%Hunter.Account{username: "milmazz"}] = result.accounts - assert [%Hunter.Status{visibility: "public"}] = result.statuses - assert [%Hunter.Tag{name: "elixir", url: "https://mastodon.example/tags/elixir"}] = - result.hashtags - end -``` - -- [ ] **Step 3: Run to verify failure** - -Run: `mix test test/hunter/api/transformer_test.exs` -Expected: FAIL — hashtags decode as plain maps, not `%Hunter.Tag{}`. - -- [ ] **Step 4: Fix the transformer** — in `lib/hunter/api/transformer.ex`, the `:result` clause gains hashtags: - -```elixir - def transform(body, :result) do - Poison.decode!( - body, - as: %Hunter.Result{ - accounts: [%Hunter.Account{}], - statuses: [status_nested_struct()], - hashtags: [%Hunter.Tag{}] - } - ) - end -``` - -- [ ] **Step 5: Update `lib/hunter/result.ex`** — the typespec (lines 14-18) becomes: - -```elixir - @type t :: %__MODULE__{ - accounts: [Hunter.Account.t()], - statuses: [Hunter.Status.t()], - hashtags: [Hunter.Tag.t()] - } -``` - -Also update the `## Fields` moduledoc line for `hashtags` to say "list of matched `Hunter.Tag`" (matching the module's existing doc style). - -- [ ] **Step 6: Update the Mox test** — in `test/hunter/result_test.exs`, the expectation's return value becomes realistic: - -```elixir - expect(Hunter.ApiMock, :search, fn %Hunter.Client{}, "elixir", [] -> - %Result{accounts: [], statuses: [], hashtags: [%Hunter.Tag{name: "elixir"}]} - end) - - assert %Result{hashtags: [%Hunter.Tag{name: "elixir"}]} = Result.search(@conn, "elixir") -``` - -- [ ] **Step 7: Full gate and commit** - -Run: `mix compile --warnings-as-errors && mix test && mix format --check-formatted && mix credo --strict` -Expected: green. - -```bash -git add test/fixtures/result.json test/hunter/api/transformer_test.exs lib/hunter/api/transformer.ex lib/hunter/result.ex test/hunter/result_test.exs -git commit -m "decode v2 search hashtags as Hunter.Tag structs - -BREAKING: Result.hashtags was [String.t()], now [Hunter.Tag.t()] — -matching what /api/v2/search actually returns. - -Co-Authored-By: Claude Fable 5 " -``` - -### Task 6: remove `follow_by_uri` and `reports/1` - -**Files:** -- Modify: `lib/hunter.ex` (two blocks), `lib/hunter/account.ex:190-205`, `lib/hunter/report.ex:24-35`, `lib/hunter/api.ex` (two callback blocks), `lib/hunter/api/http_client.ex` (two functions), `lib/hunter/api/transformer.ex` (`:reports` clause), `test/hunter/account_test.exs`, `test/hunter/report_test.exs`, `test/hunter/api/transformer_test.exs` - -Each deletion removes the function together with its `@doc` block and `@spec`/`@callback`: - -- [ ] **Step 1: `lib/hunter.ex`** — delete the `follow_by_uri` block (the `@doc "Follow a remote user…"` through `defdelegate follow_by_uri(conn, uri), to: Hunter.Account`, around lines 88-98) and the `reports` block (`@doc "Retrieve a user's reports…"` through `defdelegate reports(conn), to: Hunter.Report`, around lines 673-682). - -- [ ] **Step 2: `lib/hunter/account.ex`** — delete the `follow_by_uri` block (doc + spec + def, around lines 190-205). - -- [ ] **Step 3: `lib/hunter/report.ex`** — delete the `reports/1` block (doc + spec + def, around lines 24-35). `report/4` stays. - -- [ ] **Step 4: `lib/hunter/api.ex`** — delete the `@callback follow_by_uri(conn :: Hunter.Client.t(), id :: non_neg_integer) :: Hunter.Account.t()` with its doc (around lines 85-94) and `@callback reports(conn :: Hunter.Client.t()) :: [Hunter.Report.t()]` with its doc (around lines 616-624). - -- [ ] **Step 5: `lib/hunter/api/http_client.ex`** — delete `follow_by_uri/2` (lines 40-44, targets the removed `/api/v1/follows`) and `reports/1` (lines 272-276, targets the removed `GET /api/v1/reports`). - -- [ ] **Step 6: `lib/hunter/api/transformer.ex`** — delete the `:reports` clause (`def transform(body, :reports), do: Poison.decode!(body, as: [%Hunter.Report{}])`). The `:report` clause stays (used by `report/4`). - -- [ ] **Step 7: Tests** — delete the `"following a remote user"` test from `test/hunter/account_test.exs`, the `"returns authenticated user's reports"` test from `test/hunter/report_test.exs`, and the `"decodes a list of reports"` test from `test/hunter/api/transformer_test.exs`. - -- [ ] **Step 8: Full gate including dialyzer** - -Run: `mix compile --warnings-as-errors && mix test && mix format --check-formatted && mix credo --strict && mix dialyzer` -Expected: all green — compile with warnings-as-errors proves no dangling references; dialyzer revalidates the shrunk behaviour. (Mox mocks are generated from the behaviour, so removed callbacks disappear automatically.) - -- [ ] **Step 9: Commit** - -```bash -git add lib test -git commit -m "remove follow_by_uri and reports listing - -BREAKING: POST /api/v1/follows and GET /api/v1/reports were removed -from Mastodon (4.0 and earlier); the functions could only return 404. -Filing reports via report/4 is unaffected. - -Co-Authored-By: Claude Fable 5 " -``` - -### Task 7: CHANGELOG + live v2-search integration test - -**Files:** -- Modify: `CHANGELOG.md`, `test/integration/mastodon_test.exs` - -- [ ] **Step 1: CHANGELOG** — under the existing `## Unreleased` → breaking-changes section (added during the CI work), append: - -```markdown -* `Hunter.Result.hashtags` is now a list of `Hunter.Tag` structs (the - `/api/v2/search` shape) instead of strings. -* Removed `Hunter.follow_by_uri/2` / `Hunter.Account.follow_by_uri/2`: - Mastodon 4.0 removed `POST /api/v1/follows`. Search for the account and - use `Hunter.follow/2` instead. -* Removed `Hunter.reports/1` / `Hunter.Report.reports/1`: Mastodon removed - `GET /api/v1/reports`. Filing reports via `Hunter.report/4` still works. -``` - -- [ ] **Step 2: Integration test** — in `test/integration/mastodon_test.exs`, add `Result` to the alias line (`alias Hunter.{Account, Attachment, Instance, Notification, Relationship, Result, Status}`) and append: - -```elixir - test "searches via /api/v2/search returning v2 shapes", %{conn: conn} do - %Status{id: id} = Status.create_status(conn, "tagged search probe #hunterci") - - eventually(fn -> - result = Result.search(conn, "hunterci") - assert Enum.any?(result.hashtags, &match?(%Hunter.Tag{name: "hunterci"}, &1)) - end) - - people = Result.search(conn, "kadaba") - assert Enum.any?(people.accounts, &(&1.username == "kadaba")) - - Status.destroy_status(conn, id) - end -``` - -(Status full-text search needs Elasticsearch, which the CI stack deliberately runs without (`ES_ENABLED=false`) — so the test asserts the hashtag and account facets, which work without ES, and does not assert on `result.statuses`.) - -- [ ] **Step 3: Run integration + unit suites** - -Run: `bash -c 'source scripts/ci/.env.hunter && mix test --only integration'` then `mix test && mix format --check-formatted && mix credo --strict` -Expected: 9 integration tests, 0 failures; unit suite green. - -- [ ] **Step 4: Commit** - -```bash -git add CHANGELOG.md test/integration/mastodon_test.exs -git commit -m "document 0.6.0 breaking changes and cover v2 search live - -Co-Authored-By: Claude Fable 5 " -``` - -### Task 8: open PR B, correct and close #106 - -- [ ] **Step 1: Push and create the PR** - -```bash -git push -u origin api-drift -gh pr create --base query-params --title "API-drift cleanup: v2 search shape, drop removed endpoints" --body "$(cat <<'EOF' -Closes #106. PR 2 of 2, stacked on #. - -- `Result.hashtags` now decodes as `[Hunter.Tag.t()]` — the actual `/api/v2/search` shape (breaking, 0.6.0) -- Removed `follow_by_uri` (`POST /api/v1/follows` was removed in Mastodon 4.0) and `reports/1` (`GET /api/v1/reports` removed); `report/4` filing is unaffected (breaking, 0.6.0) -- New live integration test covers v2 search (hashtag + account facets; status facet needs Elasticsearch, which the CI stack intentionally omits) - -Note: #106's claim that `Result.search` targets `/api/v1/search` was stale — the code already used v2. The real gaps were the query-param transport (fixed by the base PR) and the hashtag shape (fixed here). - -🤖 Generated with [Claude Code](https://claude.com/claude-code) -EOF -)" -``` - -Replace `#` with the number from Task 4. - -- [ ] **Step 2: Comment the correction on #106** - -```bash -gh issue comment 106 --body "Correction while implementing: \`Result.search\` already targets \`/api/v2/search\` — the first bullet of this issue was stale. The real remaining problems were (a) \`q\` traveling in a JSON GET body instead of the query string (fixed by the query-params PR) and (b) \`hashtags\` decoding as v1-style strings (fixed by the PR that closes this). \`follow_by_uri\` and \`GET /api/v1/reports\` removals were accurate." -``` - -- [ ] **Step 3: Watch checks** - -Run: `gh pr checks --watch` -Expected: all 5 jobs green. - ---- - -## Merge order - -PR A → `main` first, then PR B retargets and merges. If PR A is squash-merged, PR B needs the same sync applied to the previous stack (merge `origin/main` into `api-drift` with ours-preference after verifying tree identity) — or merge PR A with a merge commit to avoid it. diff --git a/docs/superpowers/plans/2026-07-08-endpoint-fixes.md b/docs/superpowers/plans/2026-07-08-endpoint-fixes.md deleted file mode 100644 index b80e0a1..0000000 --- a/docs/superpowers/plans/2026-07-08-endpoint-fixes.md +++ /dev/null @@ -1,454 +0,0 @@ -# Modern-Mastodon Endpoint Fixes Implementation Plan (#118) - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Fix the five endpoints that are broken or deprecated on modern Mastodon (cards, follow requests, notification dismiss, media v2, instance v2) before the 0.6.0 release. Fixes #118. - -**Architecture:** Each part is an isolated change to `Hunter.Api.HTTPClient` plus its entity/behaviour/test surface. Cards move from a removed endpoint to an embedded `Status.card` field; follow requests get the documented per-id paths and their real `Relationship` return; dismiss gets the documented path; media and instance move to their v2 endpoints (instance with a top-level struct reshape). Live integration coverage lands in one task at the end, exercising every changed path against real Mastodon. - -**Tech Stack:** Elixir/ExUnit, Mox, Poison `as:` decoding, the docker-compose Mastodon v4.3.8 stack. - -**Spec:** `docs/superpowers/specs/2026-07-08-endpoint-fixes-design.md` - -## Global Constraints - -- Branch `fix-api-drift-4x` (exists off `main`, spec committed at 38c2248). One PR, base `main`. **Do NOT merge the PR — the user reviews manually.** -- Every commit passes `mix compile --warnings-as-errors && mix test && mix format --check-formatted && mix credo --strict`; `mix dialyzer` once in Task 7 (struct + callback changes). -- Live verification in Task 6: `./scripts/ci/setup_mastodon.sh` then `bash -c 'source scripts/ci/.env.hunter && mix test --only integration'` — expect **13 tests, 0 failures** (12 existing + the follow-request test; the dismiss step folds into an existing test). -- Commit messages end with `Co-Authored-By: Claude Fable 5 `; the PR body ends with `🤖 Generated with [Claude Code](https://claude.com/claude-code)` and says `Fixes #118.` -- This branch will conflict with open PR #117 only in CHANGELOG.md — expected; do not try to avoid it. - ---- - -### Task 1: preview cards — embedded field in, removed endpoint out (TDD) - -**Files:** -- Modify: `test/fixtures/status.json`, `test/hunter/api/transformer_test.exs`, `lib/hunter/status.ex`, `lib/hunter/api/transformer.ex` -- Modify (removals): `lib/hunter.ex`, `lib/hunter/card.ex`, `lib/hunter/api.ex`, `lib/hunter/api/http_client.ex` -- Delete: `test/hunter/card_test.exs` - -**Interfaces:** -- Produces: `Hunter.Status` struct field `card: Hunter.Card.t() | nil`; `card_by_status` gone from the public API and the `Hunter.Api` behaviour. - -- [ ] **Step 1 (red): fixture + assertions.** In `test/fixtures/status.json`, add after the `"application"` object (inside the top-level object): - -```json - "card": { - "url": "https://elixir-lang.org/", - "title": "The Elixir programming language", - "description": "Elixir is a dynamic, functional language.", - "type": "link", - "image": "https://mastodon.example/preview_cards/image.png" - } -``` - -In `test/hunter/api/transformer_test.exs`, inside `test "decodes a status with nested entities"`, add after the tags assertion: - -```elixir - assert %Hunter.Card{title: "The Elixir programming language", type: "link"} = status.card -``` - -- [ ] **Step 2: verify red** - -Run: `mix test test/hunter/api/transformer_test.exs` -Expected: FAIL — `card` is not a key of `Hunter.Status` (or decodes as a plain map once the field exists; the KeyError comes first). - -- [ ] **Step 3: add the field.** `lib/hunter/status.ex`: append `:card` to the `defstruct` list, add `card: Hunter.Card.t() | nil` to `@type t`, and add `* card - preview card generated for links in the status, if any` to the moduledoc Fields list. - -- [ ] **Step 4: decode it.** In `lib/hunter/api/transformer.ex`'s `status_nested_struct/0`, add `card: %Hunter.Card{}` to the `%Hunter.Status{…}` map. - -- [ ] **Step 5: remove the dead endpoint.** Each removal takes the whole block (doc + spec/callback + function): - - `lib/hunter.ex`: the `card_by_status` `@doc`/`@spec`/`defdelegate` block. - - `lib/hunter/card.ex`: the `card_by_status` block (around lines 60-78; the doc, `@spec`, and `def`). The struct, moduledoc, and `@type` stay — update the moduledoc if it mentions retrieving cards by status. Remove the now-unused `alias Hunter.Config` if it becomes unused (compile with `--warnings-as-errors` will tell you). - - `lib/hunter/api.ex`: the `@callback card_by_status(...)` block (around lines 634-643). - - `lib/hunter/api/http_client.ex`: the `card_by_status/2` function (around lines 286-290). - - `lib/hunter/api/transformer.ex`: the `def transform(body, :card), do: ...` clause (now orphaned). - - `test/hunter/api/transformer_test.exs`: the `test "decodes a card"` block (card decoding is now covered through the status fixture). - - Delete `test/hunter/card_test.exs` (its only test mocks the removed function). - -- [ ] **Step 6: verify green + gate** - -Run: `mix compile --warnings-as-errors && mix test && mix format --check-formatted && mix credo --strict` -Expected: all green; `git grep -n "card_by_status" -- lib test` returns nothing. - -- [ ] **Step 7: Commit** - -```bash -git add -A -git commit -m "embed preview cards in Status, drop the removed card endpoint - -BREAKING: GET /api/v1/statuses/:id/card was removed in Mastodon 3.0; -the card ships inside the Status entity. Hunter.Status gains a card -field; card_by_status is gone (#118). - -Co-Authored-By: Claude Fable 5 " -``` - -### Task 2: follow requests — documented paths, Relationship return (TDD) - -**Files:** -- Modify: `lib/hunter/api/http_client.ex:64-68`, `lib/hunter/api.ex` (callback around line 168-180), `lib/hunter/account.ex` (both wrapper `@spec`s, around lines 299-315), `lib/hunter.ex` (both delegate `@spec`s, around lines 167-180) -- Test: `test/hunter/account_test.exs` (the "accepts and rejects follow requests" test) - -**Interfaces:** -- Produces: `accept_follow_request/2` and `reject_follow_request/2` returning `Hunter.Relationship.t()`; `follow_request_action` callback spec `(conn, id, action) :: Hunter.Relationship.t()`. - -- [ ] **Step 1 (red): update the Mox test.** Replace the body of `test "accepts and rejects follow requests"` in `test/hunter/account_test.exs`: - -```elixir - expect(Hunter.ApiMock, :follow_request_action, 2, fn - %Hunter.Client{}, 8039, :authorize -> %Hunter.Relationship{id: "8039", followed_by: true} - %Hunter.Client{}, 8039, :reject -> %Hunter.Relationship{id: "8039", followed_by: false} - end) - - assert %Hunter.Relationship{followed_by: true} = Account.accept_follow_request(@conn, 8039) - assert %Hunter.Relationship{followed_by: false} = Account.reject_follow_request(@conn, 8039) -``` - -This is red only in spirit (the wrappers pass anything through) — the REAL red is dialyzer disagreeing with the old `boolean` specs after Step 3; the ordering here exists to pin the new contract before touching specs. - -- [ ] **Step 2: fix the endpoint.** `lib/hunter/api/http_client.ex`: - -```elixir - def follow_request_action(conn, id, action) when action in [:authorize, :reject] do - "/api/v1/follow_requests/#{id}/#{action}" - |> process_url(conn) - |> request!(:relationship, :post, [], conn) - end -``` - -- [ ] **Step 3: fix the specs.** All return types `boolean` → `Hunter.Relationship.t()`: - - `lib/hunter/api.ex`: the `@callback follow_request_action(...)` return type. - - `lib/hunter/account.ex`: `@spec accept_follow_request(...)` and `@spec reject_follow_request(...)`. - - `lib/hunter.ex`: the same two delegate `@spec`s. Update the three `@doc` blocks if they promise a boolean. - -- [ ] **Step 4: gate + commit** - -Run: `mix compile --warnings-as-errors && mix test && mix format --check-formatted && mix credo --strict` - -```bash -git add -A -git commit -m "use the documented follow_requests authorize/reject endpoints - -BREAKING: the previous POST /api/v1/follow_requests/:action with an id -body matched no Mastodon version; the documented per-id paths return a -Relationship, and the wrappers' specs now say so (#118). - -Co-Authored-By: Claude Fable 5 " -``` - -### Task 3: notification dismiss path - -**Files:** -- Modify: `lib/hunter/api/http_client.ex:262-266` - -- [ ] **Step 1: fix the path.** - -```elixir - def clear_notification(conn, id) do - "/api/v1/notifications/#{id}/dismiss" - |> process_url(conn) - |> request!(nil, :post, [], conn) - end -``` - -(The old `/api/v1/notifications/dismiss/#{id}` matches no server version. Live proof lands in Task 6.) - -- [ ] **Step 2: gate + commit** - -Run: `mix compile --warnings-as-errors && mix test && mix format --check-formatted && mix credo --strict` - -```bash -git add lib/hunter/api/http_client.ex -git commit -m "fix the notification dismiss path - -POST /api/v1/notifications/:id/dismiss is the documented form; the -implemented /notifications/dismiss/:id matched no version (#118). - -Co-Authored-By: Claude Fable 5 " -``` - -### Task 4: media upload moves to v2 - -**Files:** -- Modify: `lib/hunter/api/http_client.ex` (the `upload_media/3` URL, around line 97), `lib/hunter/attachment.ex` (the `upload_media/3` `@doc`), `lib/hunter.ex` (the `upload_media` delegate `@doc`) - -- [ ] **Step 1: endpoint.** In `HTTPClient.upload_media/3`, change `"/api/v1/media"` to `"/api/v2/media"`. Multipart handling stays byte-identical. - -- [ ] **Step 2: document the async contract.** Add to BOTH `@doc` blocks (`Hunter.Attachment.upload_media/3` and the `Hunter.upload_media` delegate), matching each file's doc style: - -```markdown - **Note:** the v2 media endpoint processes large files asynchronously: the - returned attachment's `url` may be `nil` until the server finishes - processing (HTTP 202). The `id` can be attached to a status with - `create_status` as soon as processing completes. -``` - -- [ ] **Step 3: gate + commit** - -Run: `mix compile --warnings-as-errors && mix test && mix format --check-formatted && mix credo --strict` - -```bash -git add lib/hunter/api/http_client.ex lib/hunter/attachment.ex lib/hunter.ex -git commit -m "upload media via POST /api/v2/media - -The v1 endpoint has been deprecated since Mastodon 3.1.3; v2 processes -large files asynchronously (documented on upload_media) (#118). - -Co-Authored-By: Claude Fable 5 " -``` - -### Task 5: instance v2 (TDD) - -**Files:** -- Modify: `test/fixtures/instance.json`, `test/hunter/api/transformer_test.exs`, `lib/hunter/instance.ex`, `lib/hunter/api/http_client.ex` (the `instance_info/1` URL), `test/hunter/instance_test.exs` - -**Interfaces:** -- Produces: reshaped `Hunter.Instance` — `defstruct [:domain, :title, :version, :source_url, :description, :usage, :thumbnail, :languages, :configuration, :registrations, :contact, :rules]`, nested values as plain maps/lists. Task 6's live assertion uses `domain`/`version`. - -- [ ] **Step 1 (red): rewrite the fixture.** `test/fixtures/instance.json` becomes: - -```json -{ - "domain": "mastodon.example", - "title": "Mastodon Example", - "version": "4.3.8", - "source_url": "https://github.com/mastodon/mastodon", - "description": "A test instance", - "usage": { - "users": { "active_month": 2 } - }, - "thumbnail": { - "url": "https://mastodon.example/thumbnail.png" - }, - "languages": ["en"], - "configuration": { - "statuses": { "max_characters": 500 }, - "urls": { "streaming": "wss://mastodon.example" } - }, - "registrations": { - "enabled": true, - "approval_required": false - }, - "contact": { - "email": "admin@mastodon.example" - }, - "rules": [ - { "id": "1", "text": "Be excellent to each other" } - ] -} -``` - -Replace `test "decodes an instance"` in `test/hunter/api/transformer_test.exs`: - -```elixir - test "decodes a v2 instance" do - instance = transform("instance", :instance) - - assert %Hunter.Instance{domain: "mastodon.example", version: "4.3.8"} = instance - assert instance.contact["email"] == "admin@mastodon.example" - assert instance.configuration["urls"]["streaming"] == "wss://mastodon.example" - assert [%{"text" => "Be excellent to each other"}] = instance.rules - end -``` - -- [ ] **Step 2: verify red** - -Run: `mix test test/hunter/api/transformer_test.exs` -Expected: FAIL — `domain` is not a key of `Hunter.Instance`. - -- [ ] **Step 3: reshape the struct.** `lib/hunter/instance.ex`: replace the `defstruct`, `@type t`, and moduledoc Fields list with the v2 top-level shape (every field typed loosely — nested objects are plain maps in this pass, full modeling is #119): - -```elixir - defstruct [ - :domain, - :title, - :version, - :source_url, - :description, - :usage, - :thumbnail, - :languages, - :configuration, - :registrations, - :contact, - :rules - ] -``` - -with `@type t` fields: `domain: String.t()`, `title: String.t()`, `version: String.t()`, `source_url: String.t()`, `description: String.t()`, `usage: map`, `thumbnail: map`, `languages: [String.t()]`, `configuration: map`, `registrations: map`, `contact: map`, `rules: [map]`. - -- [ ] **Step 4: endpoint.** In `HTTPClient.instance_info/1`, change `"/api/v1/instance"` to `"/api/v2/instance"`. - -- [ ] **Step 5: update the Mox test.** In `test/hunter/instance_test.exs`, the mock's returned struct and the assertion switch from `uri:`-based fields to `%Hunter.Instance{domain: "example.com", version: "4.3.8"}` (read the file and keep its shape otherwise). - -- [ ] **Step 6: gate + commit** - -Run: `mix compile --warnings-as-errors && mix test && mix format --check-formatted && mix credo --strict` - -```bash -git add -A -git commit -m "fetch instance information via GET /api/v2/instance - -BREAKING: Hunter.Instance reshapes to the v2 entity's top-level fields -(domain, configuration, contact, ...); nested objects decode as plain -maps for now — full modeling is tracked by #119 (#118). - -Co-Authored-By: Claude Fable 5 " -``` - -### Task 6: live integration coverage - -**Files:** -- Modify: `test/integration/mastodon_test.exs` - -**Interfaces:** -- Consumes: `Hunter.IntegrationCase` (`conn`, `conn2`, `eventually/2`); the module's existing `destroy_quietly/2`/`unfollow_quietly/2` helpers; everything Tasks 1-5 produced. - -- [ ] **Step 1: instance assertion.** In `test "fetches instance information"`, replace the `uri` pattern with: - -```elixir - assert %Instance{domain: domain, version: version} = Instance.instance_info(conn) - assert is_binary(domain) - assert is_binary(version) -``` - -- [ ] **Step 2: dismiss step.** Rework the notification block in `test "follow, relationship, and notifications across accounts"` so the found notification is captured and dismissed: - -```elixir - notification = - eventually(fn -> - notifications = Notification.notifications(conn) - - case Enum.find(notifications, fn n -> - n.type == "mention" and n.account.username == "kadaba" - end) do - nil -> raise "mention notification not delivered yet" - notification -> notification - end - end) - - assert Notification.clear_notification(conn, notification.id) - refute Enum.any?(Notification.notifications(conn), &(&1.id == notification.id)) -``` - -- [ ] **Step 3: the follow-request test.** Append (add a `lock_quietly`-style net inline — see the helper in Step 4): - -```elixir - test "follow requests against a locked account", %{conn: conn, conn2: conn2} do - %Account{id: id1} = Account.verify_credentials(conn) - %Account{id: id2} = Account.verify_credentials(conn2) - - assert %Account{locked: true} = Account.update_credentials(conn2, %{locked: true}) - on_exit(fn -> unlock_quietly(conn2) end) - - assert %Relationship{requested: true, following: false} = Relationship.follow(conn, id2) - on_exit(fn -> unfollow_quietly(conn, id2) end) - - requesters = Account.follow_requests(conn2) - assert Enum.any?(requesters, &(&1.id == id1)) - - assert %Relationship{followed_by: true} = Account.accept_follow_request(conn2, id1) - - assert %Account{locked: false} = Account.update_credentials(conn2, %{locked: false}) - Relationship.unfollow(conn, id2) - end -``` - -- [ ] **Step 4: the unlock helper.** Next to the existing `*_quietly` helpers: - -```elixir - defp unlock_quietly(conn) do - Account.update_credentials(conn, %{locked: false}) - :ok - rescue - Hunter.Error -> :ok - end -``` - -Note: `update_credentials` is a PATCH with a map payload — verify the payload key reaches the server as expected (the existing Mox test uses `%{note: ...}`; live behavior is what counts here). If Mastodon rejects `%{locked: true}` via this path, check `docker compose -f docker-compose.ci.yml logs web --tail 50` and report rather than weakening the test. - -- [ ] **Step 5: run live** - -Run: `./scripts/ci/setup_mastodon.sh && bash -c 'source scripts/ci/.env.hunter && mix test --only integration'` -Expected: **13 tests, 0 failures.** These hit every changed endpoint: instance v2, media v2 (existing test), dismiss, follow-request authorize. Iterate systematically on failures (container logs, one change at a time); do not weaken assertions. - -- [ ] **Step 6: unit gate + commit** - -Run: `mix test && mix format --check-formatted && mix credo --strict` - -```bash -git add test/integration/mastodon_test.exs -git commit -m "cover the fixed endpoints live: instance v2, dismiss, follow requests - -Co-Authored-By: Claude Fable 5 " -``` - -### Task 7: CHANGELOG, dialyzer, PR (no merge) - -**Files:** -- Modify: `CHANGELOG.md` - -- [ ] **Step 1: CHANGELOG.** Under `## Unreleased` → `* Breaking changes`, append: - -```markdown - - Removed the `card_by_status` function (`Hunter` and `Hunter.Card`): - Mastodon 3.0 removed the endpoint. The preview card is now embedded in - `Hunter.Status` as the `card` field ([#118]) - - `accept_follow_request/2` and `reject_follow_request/2` now call the - documented per-id endpoints and return a `Hunter.Relationship` instead - of a boolean; the previous implementation matched no Mastodon version - and could only fail ([#118]) - - `Hunter.Instance` reshaped to the `GET /api/v2/instance` entity - (`domain`, `configuration`, `contact`, …); nested objects decode as - plain maps for now ([#118]) -``` - -Under `* Bug fixes`, append: - -```markdown - - Notification dismissal uses the documented - `POST /api/v1/notifications/:id/dismiss` path; the previous path - matched no Mastodon version ([#118]) - - Media uploads use `POST /api/v2/media` (v1 deprecated since Mastodon - 3.1.3); large files process asynchronously and the attachment `url` - may be `nil` until ready ([#118]) -``` - -Add `[#118]: https://github.com/milmazz/hunter/issues/118` to the link references. - -- [ ] **Step 2: dialyzer** - -Run: `mix dialyzer` -Expected: clean (callback/spec changes are the risk). Fix findings before pushing. - -- [ ] **Step 3: push + PR — do NOT merge** - -```bash -git add CHANGELOG.md -git commit -m "document the endpoint fixes in the changelog - -Co-Authored-By: Claude Fable 5 " -git push -u origin fix-api-drift-4x -gh pr create --base main --title "Fix endpoints broken or deprecated on modern Mastodon" --body "$(cat <<'EOF' -Fixes #118. Part of the Mastodon 4.6 API parity effort. - -- **Cards** (breaking): `card_by_status` removed (endpoint gone since Mastodon 3.0); `Hunter.Status` gains the embedded `card` field -- **Follow requests** (breaking): documented `/follow_requests/:id/authorize|reject` paths; returns `Hunter.Relationship` — the old implementation matched no Mastodon version, covered live for the first time (locked-account flow) -- **Notification dismiss**: documented `/notifications/:id/dismiss` path, exercised live -- **Media v2**: `POST /api/v2/media`; async-processing contract documented on `upload_media` -- **Instance v2** (breaking): `Hunter.Instance` reshaped to the v2 top-level entity; nested modeling deferred to #119 - -Live integration: 13/13 against Mastodon v4.3.8, including a new locked-account follow-request flow. Password-grant deprecation stays with #126. - -Note: conflicts with #117 in CHANGELOG.md only — whichever merges second rebases trivially. - -🤖 Generated with [Claude Code](https://claude.com/claude-code) -EOF -)" -``` - -- [ ] **Step 4: watch checks, report, stop** - -Run: `gh pr checks --watch` -Expected: all 5 jobs green. Report status. **Do not merge** — the user reviews manually. diff --git a/docs/superpowers/plans/2026-07-08-flatten-facade.md b/docs/superpowers/plans/2026-07-08-flatten-facade.md deleted file mode 100644 index 929a576..0000000 --- a/docs/superpowers/plans/2026-07-08-flatten-facade.md +++ /dev/null @@ -1,802 +0,0 @@ -# Flatten the Facade Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Collapse the three-layer `Hunter` → `Hunter.` → `Hunter.Api.HTTPClient` call chain into one deep public module (`Hunter`) and one transport module (`Hunter.Api.Request`), per the approved spec at `docs/superpowers/specs/2026-07-08-flatten-facade-design.md`. - -**Architecture:** Two stacked PRs. PR 1 (non-breaking): `Hunter.Api.Request` gains a conn-aware `request!/6`; `HTTPClient`'s endpoint bodies become one-line calls to it. PR 2 (breaking): each `defdelegate` in `hunter.ex` is replaced by the real body copied from `HTTPClient`; entity modules are stripped to pure structs; `HTTPClient` is deleted; tests are retargeted; CHANGELOG + 0.7.0. - -**Tech Stack:** Elixir, Req (`~> 0.6`), Poison, Req.Test/Plug for test stubs. - -## Global Constraints - -- CI gates every commit must survive: `mix compile --warnings-as-errors`, `mix test`, `mix format --check-formatted`, `mix credo --strict`, `mix dialyzer` (run dialyzer at least once per PR before opening it). -- One module per file; folders mirror the module hierarchy (repo convention). -- No `@deprecated` shims: entity endpoint functions are removed outright (spec decision). -- Entity structs, `@type t`, `@derive`, and field `@moduledoc`s are kept unchanged. -- Version bump: `0.6.0` → `0.7.0` (in PR 2 only). -- Behavior is preserved exactly — this is a move, not a rewrite. When in doubt, copy the existing body verbatim. -- Commit messages end with `Co-Authored-By: Claude Fable 5 `. - -## The Endpoint Inventory - -This table is the authoritative checklist for both PRs and the source for the CHANGELOG migration table. Every public `Hunter.*` function keeps its current name, arity, and defaults (they are already defined in `lib/hunter.ex` as `defdelegate`s). "Transform" is the atom passed to `Hunter.Api.Transformer.transform/2`. "Payload" is the 5th argument to the new `request!`. Functions marked **special** have non-uniform bodies written out in full inside the tasks. - -| Hunter function | Method | Path | Transform | Payload | -|---|---|---|---|---| -| `verify_credentials(conn)` | GET | `/api/v1/accounts/verify_credentials` | `:account` | `[]` | -| `update_credentials(conn, data)` | PATCH | `/api/v1/accounts/update_credentials` | `:account` | `data` | -| `account(conn, id)` | GET | `/api/v1/accounts/#{id}` | `:account` | `[]` | -| `followers(conn, id, options \\ [])` | GET | `/api/v1/accounts/#{id}/followers` | `:accounts` | `options` | -| `following(conn, id, options \\ [])` | GET | `/api/v1/accounts/#{id}/following` | `:accounts` | `options` | -| `search_account(conn, options)` | GET | `/api/v1/accounts/search` | `:accounts` | **special** (required `:q`, `:limit` default 40) | -| `blocks(conn, options \\ [])` | GET | `/api/v1/blocks` | `:accounts` | `options` | -| `follow_requests(conn, options \\ [])` | GET | `/api/v1/follow_requests` | `:accounts` | `options` | -| `mutes(conn, options \\ [])` | GET | `/api/v1/mutes` | `:accounts` | `options` | -| `accept_follow_request(conn, id)` | POST | `/api/v1/follow_requests/#{id}/authorize` | `:relationship` | `[]` | -| `reject_follow_request(conn, id)` | POST | `/api/v1/follow_requests/#{id}/reject` | `:relationship` | `[]` | -| `reblogged_by(conn, id, options \\ [])` | GET | `/api/v1/statuses/#{id}/reblogged_by` | `:accounts` | `options` | -| `favourited_by(conn, id, options \\ [])` | GET | `/api/v1/statuses/#{id}/favourited_by` | `:accounts` | `options` | -| `relationships(conn, ids)` | GET | `/api/v1/accounts/relationships` | `:relationships` | `%{id: ids}` | -| `follow(conn, id)` | POST | `/api/v1/accounts/#{id}/follow` | `:relationship` | `[]` | -| `unfollow(conn, id)` | POST | `/api/v1/accounts/#{id}/unfollow` | `:relationship` | `[]` | -| `block(conn, id)` | POST | `/api/v1/accounts/#{id}/block` | `:relationship` | `[]` | -| `unblock(conn, id)` | POST | `/api/v1/accounts/#{id}/unblock` | `:relationship` | `[]` | -| `mute(conn, id)` | POST | `/api/v1/accounts/#{id}/mute` | `:relationship` | `[]` | -| `unmute(conn, id)` | POST | `/api/v1/accounts/#{id}/unmute` | `:relationship` | `[]` | -| `create_app(name, redirect_uri, scopes, website, options)` | POST | `/api/v1/apps` | `:application` | **special** (bare base URL, `save?`) | -| `load_credentials(name)` | — | local file read | — | **special** | -| `new(options \\ [])` | — | local struct build | — | **special** | -| `user_agent()` | — | local | — | **special** | -| `log_in(app, username, password, base_url)` | POST | `/oauth/token` | `nil` | **special** (bare base URL) | -| `log_in_oauth(app, oauth_code, base_url)` | POST | `/oauth/token` | `nil` | **special** (bare base URL) | -| `upload_media(conn, file, options \\ [])` | POST | `/api/v2/media` | `:attachment` | **special** (multipart) | -| `media_attachment(conn, id)` | GET | `/api/v1/media/#{id}` | `:attachment` | `[]` | -| `update_media(conn, id, options \\ [])` | PUT | `/api/v1/media/#{id}` | `:attachment` | `Map.new(options)` | -| `delete_media(conn, id)` | DELETE | `/api/v1/media/#{id}` | `:empty` | `[]` | -| `search(conn, query, options \\ [])` | GET | `/api/v2/search` | `:result` | **special** (merge `q`) | -| `create_status(conn, status, options \\ [])` | POST | `/api/v1/statuses` | `:status` or `:scheduled_status` | **special** (idempotency header) | -| `status(conn, id)` | GET | `/api/v1/statuses/#{id}` | `:status` | `[]` | -| `statuses_by_ids(conn, ids)` | GET | `/api/v1/statuses` | `:statuses` | `%{id: ids}` | -| `edit_status(conn, id, status, options \\ [])` | PUT | `/api/v1/statuses/#{id}` | `:status` | `options \|> Keyword.put(:status, status) \|> Map.new()` | -| `status_history(conn, id)` | GET | `/api/v1/statuses/#{id}/history` | `:status_edits` | `[]` | -| `status_source(conn, id)` | GET | `/api/v1/statuses/#{id}/source` | `:status_source` | `[]` | -| `destroy_status(conn, id)` | DELETE | `/api/v1/statuses/#{id}` | `:empty` | `[]` | -| `bookmark(conn, id)` | POST | `/api/v1/statuses/#{id}/bookmark` | `:status` | `[]` | -| `unbookmark(conn, id)` | POST | `/api/v1/statuses/#{id}/unbookmark` | `:status` | `[]` | -| `pin(conn, id)` | POST | `/api/v1/statuses/#{id}/pin` | `:status` | `[]` | -| `unpin(conn, id)` | POST | `/api/v1/statuses/#{id}/unpin` | `:status` | `[]` | -| `mute_conversation(conn, id)` | POST | `/api/v1/statuses/#{id}/mute` | `:status` | `[]` | -| `unmute_conversation(conn, id)` | POST | `/api/v1/statuses/#{id}/unmute` | `:status` | `[]` | -| `bookmarks(conn, options \\ [])` | GET | `/api/v1/bookmarks` | `:statuses` | `options` | -| `translate_status(conn, id, options \\ [])` | POST | `/api/v1/statuses/#{id}/translate` | `:translation` | `Map.new(options)` | -| `reblog(conn, id)` | POST | `/api/v1/statuses/#{id}/reblog` | `:status` | `[]` | -| `unreblog(conn, id)` | POST | `/api/v1/statuses/#{id}/unreblog` | `:status` | `[]` | -| `favourite(conn, id)` | POST | `/api/v1/statuses/#{id}/favourite` | `:status` | `[]` | -| `unfavourite(conn, id)` | POST | `/api/v1/statuses/#{id}/unfavourite` | `:status` | `[]` | -| `favourites(conn, options \\ [])` | GET | `/api/v1/favourites` | `:statuses` | `options` | -| `statuses(conn, account_id, options \\ [])` | GET | `/api/v1/accounts/#{account_id}/statuses` | `:statuses` | `options` | -| `home_timeline(conn, options \\ [])` | GET | `/api/v1/timelines/home` | `:statuses` | `options` | -| `public_timeline(conn, options \\ [])` | GET | `/api/v1/timelines/public` | `:statuses` | `options` | -| `hashtag_timeline(conn, hashtag, options \\ [])` | GET | `/api/v1/timelines/tag/#{hashtag}` | `:statuses` | `options` | -| `list_timeline(conn, list_id, options \\ [])` | GET | `/api/v1/timelines/list/#{list_id}` | `:statuses` | `options` | -| `poll(conn, id)` | GET | `/api/v1/polls/#{id}` | `:poll` | `[]` | -| `vote(conn, id, choices)` | POST | `/api/v1/polls/#{id}/votes` | `:poll` | `%{choices: choices}` | -| `status_context(conn, id)` | GET | `/api/v1/statuses/#{id}/context` | `:context` | `[]` | -| `lists(conn)` | GET | `/api/v1/lists` | `:lists` | `[]` | -| `list(conn, id)` | GET | `/api/v1/lists/#{id}` | `:list` | `[]` | -| `create_list(conn, title, options \\ [])` | POST | `/api/v1/lists` | `:list` | `options \|> Keyword.put(:title, title) \|> Map.new()` | -| `update_list(conn, id, options)` | PUT | `/api/v1/lists/#{id}` | `:list` | `Map.new(options)` | -| `destroy_list(conn, id)` | DELETE | `/api/v1/lists/#{id}` | `:empty` | `[]` | -| `list_accounts(conn, id, options \\ [])` | GET | `/api/v1/lists/#{id}/accounts` | `:accounts` | `options` | -| `add_accounts_to_list(conn, id, account_ids)` | POST | `/api/v1/lists/#{id}/accounts` | `:empty` | `%{account_ids: account_ids}` | -| `remove_accounts_from_list(conn, id, account_ids)` | DELETE | `/api/v1/lists/#{id}/accounts` | `:empty` | `%{account_ids: account_ids}` | -| `account_lists(conn, account_id)` | GET | `/api/v1/accounts/#{account_id}/lists` | `:lists` | `[]` | -| `instance_info(conn)` | GET | `/api/v2/instance` | `:instance` | `[]` | -| `notifications(conn, options \\ [])` | GET | `/api/v1/notifications` | `:notifications` | `options` | -| `notification(conn, id)` | GET | `/api/v1/notifications/#{id}` | `:notification` | `[]` | -| `clear_notifications(conn)` | POST | `/api/v1/notifications/clear` | `:empty` | `[]` | -| `clear_notification(conn, id)` | POST | `/api/v1/notifications/#{id}/dismiss` | `:empty` | `[]` | -| `unread_count(conn)` | GET | `/api/v1/notifications/unread_count` | `nil` | **special** (`\|> Map.fetch!("count")`) | -| `notification_policy(conn)` | GET | `/api/v2/notifications/policy` | `:notification_policy` | `[]` | -| `update_notification_policy(conn, options)` | PATCH | `/api/v2/notifications/policy` | `:notification_policy` | `Map.new(options)` | -| `notification_requests(conn, options \\ [])` | GET | `/api/v1/notifications/requests` | `:notification_requests` | `options` | -| `notification_request(conn, id)` | GET | `/api/v1/notifications/requests/#{id}` | `:notification_request` | `[]` | -| `accept_notification_request(conn, id)` | POST | `/api/v1/notifications/requests/#{id}/accept` | `:empty` | `[]` | -| `dismiss_notification_request(conn, id)` | POST | `/api/v1/notifications/requests/#{id}/dismiss` | `:empty` | `[]` | -| `accept_notification_requests(conn, ids)` | POST | `/api/v1/notifications/requests/accept` | `:empty` | `%{id: ids}` | -| `dismiss_notification_requests(conn, ids)` | POST | `/api/v1/notifications/requests/dismiss` | `:empty` | `%{id: ids}` | -| `notification_requests_merged?(conn)` | GET | `/api/v1/notifications/requests/merged` | `nil` | **special** (`\|> Map.fetch!("merged")`) | -| `grouped_notifications(conn, options \\ [])` | GET | `/api/v2/notifications` | `:grouped_notifications` | `options` | -| `notification_group(conn, group_key)` | GET | `/api/v2/notifications/#{group_key}` | `:grouped_notifications` | `[]` | -| `dismiss_notification_group(conn, group_key)` | POST | `/api/v2/notifications/#{group_key}/dismiss` | `:empty` | `[]` | -| `notification_group_accounts(conn, group_key)` | GET | `/api/v2/notifications/#{group_key}/accounts` | `:accounts` | `[]` | -| `grouped_unread_count(conn)` | GET | `/api/v2/notifications/unread_count` | `nil` | **special** (`\|> Map.fetch!("count")`) | -| `create_push_subscription(conn, subscription, data \\ %{})` | POST | `/api/v1/push/subscription` | `:web_push_subscription` | `%{subscription: subscription, data: data}` | -| `push_subscription(conn)` | GET | `/api/v1/push/subscription` | `:web_push_subscription` | `[]` | -| `update_push_subscription(conn, data)` | PUT | `/api/v1/push/subscription` | `:web_push_subscription` | `%{data: data}` | -| `delete_push_subscription(conn)` | DELETE | `/api/v1/push/subscription` | `:empty` | `[]` | -| `report(conn, account_id, status_ids, comment)` | POST | `/api/v1/reports` | `:report` | `%{account_id: account_id, status_ids: status_ids, comment: comment}` | -| `blocked_domains(conn, options \\ [])` | GET | `/api/v1/domain_blocks` | `nil` | `options` | -| `block_domain(conn, domain)` | POST | `/api/v1/domain_blocks` | `:empty` | `%{domain: domain}` | -| `unblock_domain(conn, domain)` | DELETE | `/api/v1/domain_blocks` | `:empty` | `%{domain: domain}` | - -Entity modules to strip in PR 2 (functions + `alias Hunter.Api.HTTPClient` removed; struct/type/docs kept): `Account`, `Application`, `Attachment`, `Client`, `Context`, `Domain`, `Instance`, `List`, `Notification`, `Poll`, `Relationship`, `Report`, `Result`, `Status`, `WebPushSubscription`. - ---- - -# PR 1 — Transport merge (non-breaking) - -One deliberate refinement over the spec: the spec's `request!/5` gains an optional sixth `opts` argument (`headers: [{name, value}]`) because `create_status/3` must attach an `Idempotency-Key` header on top of the conn's auth header — there is no other caller-supplied-header case. - -Branch off `main`: - -```bash -git checkout main && git pull && git checkout -b refactor/transport-merge -``` - -### Task 1: Conn-aware `Request.request!/6` — failing tests - -**Files:** -- Modify: `test/hunter/api/request_test.exs` (full rewrite) - -**Interfaces:** -- Produces: the test-defined contract for `Hunter.Api.Request.request!(conn_or_base_url, method, path, to, payload \\ [], opts \\ [])` that Task 2 implements. `conn_or_base_url` is a `%Hunter.Client{}` or a bare base-URL string; `to` is the Transformer atom (or `nil` for raw decoded body); `opts` supports `headers: [{name, value}]` extra request headers. On non-2xx or transport error it raises `Hunter.Error`. It merges `Hunter.Config.req_options()` into every request (that is how the test env injects `plug: {Req.Test, Hunter.ReqStub}` — see `config/config.exs` test section / `test_helper.exs`). - -- [ ] **Step 1: Replace `test/hunter/api/request_test.exs` with tests for the new public surface** - -The old file tests `process_request_body/1`, `process_request_header/1`, `handle_response/1`, `split_payload/2`, and method-first `request/5` — all of which become private in this PR. The new file covers the same behaviors through `request!/6`, using the app-wide `Hunter.ReqStub` (installed by `Hunter.ReqCase`'s `stub_request/1`) rather than a per-module plug, because `request!/6` reads its Req options from `Hunter.Config.req_options()`: - -```elixir -defmodule Hunter.Api.RequestTest do - use Hunter.ReqCase, async: true - - alias Hunter.Api.Request - - @conn Hunter.Client.new(base_url: "https://mastodon.example", access_token: "123456") - - describe "request!/6 with a %Hunter.Client{}" do - test "GET joins the path onto base_url, encodes params, sets auth and accept headers" do - stub_request(fn conn -> - assert conn.method == "GET" - assert conn.host == "mastodon.example" - assert conn.request_path == "/api/v1/timelines/home" - assert conn.query_string == "limit=1&local=true" - assert Plug.Conn.get_req_header(conn, "authorization") == ["Bearer 123456"] - assert Plug.Conn.get_req_header(conn, "accept") == ["application/json; charset=utf-8"] - respond_with(conn, [%{id: "1"}]) - end) - - assert [%Hunter.Status{id: "1"}] = - Request.request!(@conn, :get, "/api/v1/timelines/home", :statuses, - limit: 1, - local: true - ) - end - - test "GET encodes list values as Rails-style repeated keys" do - stub_request(fn conn -> - assert conn.query_string == "id%5B%5D=1&id%5B%5D=2" - respond_with(conn, []) - end) - - assert [] = Request.request!(@conn, :get, "/api/v1/statuses", :statuses, %{id: [1, 2]}) - end - - test "POST sends a JSON body with the JSON content type" do - stub_request(fn conn -> - assert conn.method == "POST" - assert Plug.Conn.get_req_header(conn, "content-type") == ["application/json"] - assert read_json_body!(conn) == %{"status" => "hi"} - respond_with_fixture(conn, "status") - end) - - assert %Hunter.Status{} = - Request.request!(@conn, :post, "/api/v1/statuses", :status, %{status: "hi"}) - end - - test "empty payload on a write verb sends an empty JSON object" do - stub_request(fn conn -> - assert read_json_body!(conn) == %{} - respond_with_fixture(conn, "status") - end) - - assert %Hunter.Status{} = - Request.request!(@conn, :post, "/api/v1/statuses/1/reblog", :status) - end - - test "extra headers from opts are sent alongside the auth header" do - stub_request(fn conn -> - assert Plug.Conn.get_req_header(conn, "idempotency-key") == ["abc123"] - assert Plug.Conn.get_req_header(conn, "authorization") == ["Bearer 123456"] - respond_with_fixture(conn, "status") - end) - - assert %Hunter.Status{} = - Request.request!(@conn, :post, "/api/v1/statuses", :status, %{status: "hi"}, - headers: [{"idempotency-key", "abc123"}] - ) - end - - test "to: nil returns the JSON-decoded body without struct transformation" do - stub_request(fn conn -> respond_with(conn, %{count: 7}) end) - - assert %{"count" => 7} = - Request.request!(@conn, :get, "/api/v1/notifications/unread_count", nil) - end - - test "non-2xx responses raise Hunter.Error" do - stub_request(fn conn -> respond_with(conn, %{error: "Record not found"}, 404) end) - - assert_raise Hunter.Error, fn -> - Request.request!(@conn, :get, "/api/v1/statuses/0", :status) - end - end - end - - describe "request!/6 with a bare base URL" do - test "sends no authorization header" do - stub_request(fn conn -> - assert conn.request_path == "/api/v1/apps" - assert Plug.Conn.get_req_header(conn, "authorization") == [] - respond_with_fixture(conn, "application") - end) - - assert %Hunter.Application{} = - Request.request!("https://mastodon.example", :post, "/api/v1/apps", :application, %{ - client_name: "hunter" - }) - end - end -end -``` - -Notes for the implementer: -- `respond_with/2-3`, `respond_with_fixture/2-3`, `read_json_body!/1`, `stub_request/1` come from `Hunter.ReqCase` (`test/support/req_case.ex`). -- Check `test/fixtures/` for the exact fixture names: `status.json` and `application.json` are used above; if a name differs (`ls test/fixtures`), use the existing fixture and adjust the asserted struct fields accordingly. -- The multipart path is intentionally not tested here; `test/hunter/attachment_test.exs` (PR 2: retargeted to `Hunter.upload_media/3`) already covers it end-to-end. - -- [ ] **Step 2: Run the new tests to verify they fail** - -Run: `mix test test/hunter/api/request_test.exs` -Expected: FAIL — `Request.request!/4`..`/6` undefined or `FunctionClauseError` (the current `request!` expects a method atom as first argument). - -- [ ] **Step 3: Do not commit yet** — Task 2 commits the tests together with the implementation so no red commit lands on the branch. - -### Task 2: Implement `Request.request!/6` - -**Files:** -- Modify: `lib/hunter/api/request.ex` - -**Interfaces:** -- Consumes: `Hunter.Api.Transformer.transform/2`, `Hunter.Config.req_options/0`, `Hunter.Error`. -- Produces: `Hunter.Api.Request.request!(conn_or_base_url, method, path, to, payload \\ [], opts \\ [])` — the only public function of the module after Task 4. Tasks 3+ and all of PR 2 call exactly this. - -- [ ] **Step 1: Add the conn-aware `request!/6` to `lib/hunter/api/request.ex`** - -Add below the `@moduledoc` (and add the aliases). The existing `request/5` stays public for now — `HTTPClient` still calls it until Task 3: - -```elixir -defmodule Hunter.Api.Request do - @moduledoc """ - The single HTTP transport for Hunter. - - `request!/6` joins the endpoint path onto the base URL, sets - authentication headers from the `Hunter.Client` (none for a bare base - URL string), performs the request via `Req`, decodes the response - through `Hunter.Api.Transformer`, and raises `Hunter.Error` on failure. - """ - - alias Hunter.{Api.Transformer, Config} - - @doc """ - Performs a request against the Mastodon API and returns the transformed - entity. - - ## Parameters - - * `conn_or_base_url` - a `Hunter.Client` (authenticated) or a base URL - string (unauthenticated, e.g. app registration and OAuth flows) - * `method` - `:get`, `:post`, `:put`, `:patch` or `:delete` - * `path` - endpoint path, e.g. `"/api/v1/statuses"` - * `to` - `Hunter.Api.Transformer` target (e.g. `:status`, `:accounts`, - `:empty`), or `nil` for the JSON-decoded body untouched - * `payload` - query params for `:get`/`:delete`; JSON body (map or - keyword) or `{:form_multipart, parts}` for write verbs - * `opts` - `headers: [{name, value}]` extra request headers - - Raises `Hunter.Error` on non-2xx responses and transport errors. - """ - def request!(conn_or_base_url, method, path, to, payload \\ [], opts \\ []) do - url = url_for(conn_or_base_url, path) - headers = auth_headers(conn_or_base_url) ++ Keyword.get(opts, :headers, []) - - case request(method, url, payload, headers, Config.req_options()) do - {:ok, body} -> Transformer.transform(body, to) - {:error, reason} -> raise Hunter.Error, reason: reason - end - end - - defp url_for(%Hunter.Client{base_url: base_url}, path), do: base_url <> path - defp url_for(base_url, path) when is_binary(base_url), do: base_url <> path - - defp auth_headers(%Hunter.Client{access_token: token}), - do: [{"authorization", "Bearer #{token}"}] - - defp auth_headers(base_url) when is_binary(base_url), do: [] - - # ... existing request/5 and helpers below, unchanged for now ... -end -``` - -Delete the old `request!/5` (method-first) — nothing calls it (`grep -rn "Request.request!" lib/` before deleting to confirm; as of writing, `HTTPClient` only calls `Request.request/5` and defines its own private `request!`). - -- [ ] **Step 2: Run the Task 1 tests** - -Run: `mix test test/hunter/api/request_test.exs` -Expected: PASS (all tests). - -- [ ] **Step 3: Run the full suite and quality gates** - -Run: `mix compile --warnings-as-errors && mix test && mix format --check-formatted && mix credo --strict` -Expected: all green (`HTTPClient` and entity modules are untouched so far). - -- [ ] **Step 4: Commit** - -```bash -git add lib/hunter/api/request.ex test/hunter/api/request_test.exs -git commit -m "Add conn-aware Hunter.Api.Request.request!/6 - -Co-Authored-By: Claude Fable 5 " -``` - -### Task 3: Rewrite `HTTPClient` endpoints onto `request!/6` - -**Files:** -- Modify: `lib/hunter/api/http_client.ex` - -**Interfaces:** -- Consumes: `Request.request!/6` from Task 2. -- Produces: every `HTTPClient` endpoint function keeps its exact name/arity/return, with its body now a single `Request.request!` call — these bodies are what PR 2 copies into `hunter.ex` verbatim. - -- [ ] **Step 1: Apply the mechanical transformation to every endpoint function** - -Current shape → new shape: - -```elixir -# before -def followers(conn, id, options) do - "/api/v1/accounts/#{id}/followers" - |> process_url(conn) - |> request!(:accounts, :get, options, conn) -end - -# after -def followers(conn, id, options) do - Request.request!(conn, :get, "/api/v1/accounts/#{id}/followers", :accounts, options) -end -``` - -The argument mapping from the old private `request!(url, to, method, payload, conn)` pipeline to the new call is: `Request.request!(conn, method, path, to, payload)`. Apply it to every function in the file, using the Endpoint Inventory table as the checklist. Additional mechanical points: - -- Private helpers `status_action/3`, `follow_request_action/3`, and `retrieve_timeline/3` stay, rewritten the same way (their callers are unchanged), e.g.: - -```elixir -defp status_action(conn, id, action) do - Request.request!(conn, :post, "/api/v1/statuses/#{id}/#{action}", :status) -end -``` - -- Functions taking a bare `base_url` instead of `conn` pass it straight through as the first argument (`create_app/5`, `log_in/4`, `log_in_oauth/3`): - -```elixir -def create_app(name, redirect_uri, scopes, website, base_url) do - payload = %{ - client_name: name, - redirect_uris: redirect_uri, - scopes: Enum.join(scopes, " "), - website: website - } - - %Hunter.Application{} = - app = Request.request!(base_url, :post, "/api/v1/apps", :application, payload) - - %Hunter.Application{app | scopes: scopes, redirect_uri: redirect_uri} -end -``` - -```elixir -def log_in(%Hunter.Application{} = app, username, password, base_url) do - payload = %{ - client_id: app.client_id, - client_secret: app.client_secret, - grant_type: "password", - username: username, - password: password - } - - payload = - case app.scopes do - scopes when is_list(scopes) and scopes != [] -> - Map.put(payload, :scope, Enum.join(scopes, " ")) - - _ -> - payload - end - - response = Request.request!(base_url, :post, "/oauth/token", nil, payload) - - %Hunter.Client{base_url: base_url, access_token: response["access_token"]} -end -``` - -`log_in_oauth/3` follows the same pattern with its existing payload (grant_type `"authorization_code"`, `code`, and the `redirect_uri: app.redirect_uri || "urn:ietf:wg:oauth:2.0:oob"` fallback with its explanatory comment — keep the comment). - -- `create_status/3` uses the `headers:` opt for the idempotency key and keeps its `:scheduled_status` switch: - -```elixir -def create_status(conn, status, options) do - {idempotency_key, options} = Keyword.pop(options, :idempotency_key) - body = options |> Keyword.put(:status, status) |> Map.new() - - headers = - case idempotency_key do - nil -> [] - key -> [{"idempotency-key", key}] - end - - # scheduling a status returns a ScheduledStatus instead of a Status - to = if Keyword.has_key?(options, :scheduled_at), do: :scheduled_status, else: :status - - Request.request!(conn, :post, "/api/v1/statuses", to, body, headers: headers) -end -``` - -(The header name changes from the atom `:"Idempotency-Key"` to the string `"idempotency-key"` — HTTP header names are case-insensitive and Req normalizes to lowercase, so behavior is identical; `test/hunter/status_test.exs` asserts on the lowercased header if it covers this.) - -- `upload_media/3` keeps its multipart construction and comment, ending in `Request.request!(conn, :post, "/api/v2/media", :attachment, {:form_multipart, parts})`. -- The post-processed functions keep their pipes, e.g. `unread_count/1` becomes `Request.request!(conn, :get, "/api/v1/notifications/unread_count", nil) |> Map.fetch!("count")` (same for `notification_requests_merged?/1` → `"merged"`, `grouped_unread_count/1` → `"count"`). - -- [ ] **Step 2: Delete the now-dead private helpers** - -Remove from `http_client.ex`: `request!/5` (private), `get_headers/1`, `process_url/2`, and the `## Helpers` section. Update the top alias to `alias Hunter.Api.Request` only (drop `Transformer` and `Config` — they moved behind `Request.request!/6`). - -- [ ] **Step 3: Run the full suite** - -Run: `mix compile --warnings-as-errors && mix test` -Expected: PASS — every existing entity/facade test exercises these bodies through the unchanged public API. - -- [ ] **Step 4: Format and commit** - -```bash -mix format -git add lib/hunter/api/http_client.ex -git commit -m "Route HTTPClient endpoints through Request.request!/6 - -Co-Authored-By: Claude Fable 5 " -``` - -### Task 4: Privatize the low-level plumbing and open PR 1 - -**Files:** -- Modify: `lib/hunter/api/request.ex` - -- [ ] **Step 1: Make the old surface private** - -In `lib/hunter/api/request.ex`: `grep -rn "Request.request(" lib/ test/` to confirm no external callers remain, then change `def request(...)` to `defp request(...)` and remove the `@doc false` markers plus `def` → `defp` for `handle_response/1`, `split_payload/2`, `process_request_body/1`, `process_request_header/1`. The module's public surface is now exactly `request!/4..6`. - -- [ ] **Step 2: Full gate run** - -Run: `mix compile --warnings-as-errors && mix test && mix format --check-formatted && mix credo --strict && mix dialyzer` -Expected: all green. Dialyzer may need a PLT build on first run (several minutes). - -- [ ] **Step 3: Commit and open the PR** - -```bash -git add lib/hunter/api/request.ex -git commit -m "Make Request's low-level plumbing private - -Co-Authored-By: Claude Fable 5 " -git push -u origin refactor/transport-merge -gh pr create --base main --title "Merge the transport layer into Hunter.Api.Request" --body "..." -``` - -PR body: summarize PR 1 of the spec (`docs/superpowers/specs/2026-07-08-flatten-facade-design.md`): non-breaking; `Request` absorbs `HTTPClient`'s conn/URL/transform plumbing behind `request!/6`; `HTTPClient` endpoints are now one-liners; sets up the breaking flatten PR. End with the 🤖 attribution line. - ---- - -# PR 2 — The breaking flatten - -Branch off PR 1's branch (stacked): - -```bash -git checkout refactor/transport-merge && git checkout -b refactor/flatten-facade -``` - -(After PR 1 squash-merges, rebase with `git rebase --onto main refactor/transport-merge refactor/flatten-facade`.) - -**The per-domain recipe.** Tasks 5–11 all follow the same five-step cycle over a group of functions; each task lists its group's specifics. The recipe: - -1. **Retarget the domain's test file(s)** to call `Hunter.*` instead of the entity module: update the `alias`, replace `Account.followers(...)` → `Hunter.followers(...)` etc. Function names are identical on the facade except the two noted in Task 6. Struct pattern-matches (`%Account{...}`) keep working — keep the struct aliases. -2. **Run that test file** — it must PASS *before* any lib change (the facade already delegates). This proves the retarget is faithful. -3. **Flatten the facade functions**: in `lib/hunter.ex`, replace each `defdelegate name(args), to: Hunter.X` with `def name(args) do ... end`, where the body is copied verbatim from the current `lib/hunter/api/http_client.ex` function of the same name (after PR 1 these are single `Request.request!` calls; the Endpoint Inventory table is the cross-check). Where the entity module's function had logic of its own (each task lists these), that logic comes along too. Keep the existing `@doc`/`@spec` in `hunter.ex`; where the entity module's `@doc` for the same function has sections `hunter.ex` lacks (Options, Examples, Notes), copy those sections over verbatim before deleting the entity function. -4. **Strip the entity module(s)**: delete the endpoint functions and the `alias Hunter.Api.HTTPClient` (and now-unused aliases like `Config`); keep `@moduledoc` (minus any "main functions for working with X" phrasing — it's now just the entity), `@type t`, `@derive`, `defstruct`. Delete the corresponding functions from `http_client.ex` at the same time (they are dead once `hunter.ex` stops delegating). -5. **Run `mix compile --warnings-as-errors && mix test`, `mix format`, commit.** - -One-time setup for Task 5: add `alias Hunter.{Api.Request, Config}` after `@hunter_version` in `lib/hunter.ex`. - -### Task 5: Flatten client, auth, and apps - -**Files:** -- Modify: `lib/hunter.ex`, `lib/hunter/client.ex`, `lib/hunter/application.ex`, `lib/hunter/api/http_client.ex` -- Test: `test/hunter/client_test.exs`, `test/hunter/application_test.exs` - -**Interfaces:** -- Consumes: `Request.request!/6`. -- Produces: `Hunter.new/1`, `Hunter.user_agent/0`, `Hunter.log_in/4`, `Hunter.log_in_oauth/3`, `Hunter.create_app/5`, `Hunter.load_credentials/1` as real implementations; `Hunter.Client` and `Hunter.Application` as pure structs. - -Follow the per-domain recipe. Domain specifics: - -- [ ] **Step 1: Retarget `client_test.exs` and `application_test.exs` to `Hunter.*`; run them — PASS before lib changes** - -Run: `mix test test/hunter/client_test.exs test/hunter/application_test.exs` - -- [ ] **Step 2: Flatten in `lib/hunter.ex`** - -`new/1` and `user_agent/0` become local (note `user_agent` now calls `version/0` directly): - -```elixir -@spec new(Keyword.t()) :: Hunter.Client.t() -def new(options \\ []), do: struct(Hunter.Client, options) - -@spec user_agent() :: String.t() -def user_agent, do: "Hunter.Elixir/#{version()}" -``` - -`log_in/4` and `log_in_oauth/3` merge the `Hunter.Client` wrapper (the `base_url || Config.api_base_url()` fallback) with the `HTTPClient` body from PR 1 Task 3: - -```elixir -def log_in(%Hunter.Application{} = app, username, password, base_url \\ "https://mastodon.social") do - base_url = base_url || Config.api_base_url() - - payload = %{ - client_id: app.client_id, - client_secret: app.client_secret, - grant_type: "password", - username: username, - password: password - } - - payload = - case app.scopes do - scopes when is_list(scopes) and scopes != [] -> - Map.put(payload, :scope, Enum.join(scopes, " ")) - - _ -> - payload - end - - response = Request.request!(base_url, :post, "/oauth/token", nil, payload) - - %Hunter.Client{base_url: base_url, access_token: response["access_token"]} -end -``` - -`log_in_oauth/3` follows the same shape with its `base_url \\ "https://mastodon.social"` default and `Config.api_base_url()` fallback, and the payload from `http_client.ex` (grant_type `"authorization_code"`, `code: oauth_code`, `redirect_uri: app.redirect_uri || "urn:ietf:wg:oauth:2.0:oob"` — keep the Doorkeeper comment). - -`create_app/5` merges `Hunter.Application.create_app`'s `save?`/`api_base_url` handling with `HTTPClient.create_app`'s request: - -```elixir -def create_app( - client_name, - redirect_uris \\ "urn:ietf:wg:oauth:2.0:oob", - scopes \\ ["read"], - website \\ nil, - options \\ [] - ) do - {save?, options} = Keyword.pop(options, :save?, false) - base_url = Keyword.get(options, :api_base_url, Config.api_base_url()) - - payload = %{ - client_name: client_name, - redirect_uris: redirect_uris, - scopes: Enum.join(scopes, " "), - website: website - } - - %Hunter.Application{} = - app = Request.request!(base_url, :post, "/api/v1/apps", :application, payload) - - app = %Hunter.Application{app | scopes: scopes, redirect_uri: redirect_uris} - - if save?, do: save_credentials(client_name, app) - - app -end -``` - -`load_credentials/1` and the private `save_credentials/2` move verbatim from `lib/hunter/application.ex` (they are file I/O, not HTTP). - -- [ ] **Step 3: Strip `Hunter.Client` and `Hunter.Application` to structs; delete `create_app`, `log_in`, `log_in_oauth` from `http_client.ex`** - -- [ ] **Step 4: Run gates and commit** - -```bash -mix compile --warnings-as-errors && mix test && mix format -git add lib/hunter.ex lib/hunter/client.ex lib/hunter/application.ex lib/hunter/api/http_client.ex test/hunter/client_test.exs test/hunter/application_test.exs -git commit -m "Flatten client, auth, and app registration into Hunter - -Co-Authored-By: Claude Fable 5 " -``` - -### Task 6: Flatten accounts and relationships - -**Files:** -- Modify: `lib/hunter.ex`, `lib/hunter/account.ex`, `lib/hunter/relationship.ex`, `lib/hunter/api/http_client.ex` -- Test: `test/hunter/account_test.exs`, `test/hunter/relationship_test.exs` - -Follow the per-domain recipe for: `verify_credentials`, `update_credentials`, `account`, `followers`, `following`, `search_account`, `blocks`, `follow_requests`, `mutes`, `accept_follow_request`, `reject_follow_request`, `reblogged_by`, `favourited_by`, `relationships`, `follow`, `unfollow`, `block`, `unblock`, `mute`, `unmute`. - -Domain specifics: - -- `search_account/2` carries `Hunter.Account`'s opts-building: - -```elixir -def search_account(conn, options) do - opts = %{ - q: Keyword.fetch!(options, :q), - limit: Keyword.get(options, :limit, 40) - } - - Request.request!(conn, :get, "/api/v1/accounts/search", :accounts, opts) -end -``` - -- `accept_follow_request/2` and `reject_follow_request/2` are the only facade functions whose entity/HTTPClient counterpart has a different name (`follow_request_action/3`). Inline the action: - -```elixir -def accept_follow_request(conn, id) do - Request.request!(conn, :post, "/api/v1/follow_requests/#{id}/authorize", :relationship) -end - -def reject_follow_request(conn, id) do - Request.request!(conn, :post, "/api/v1/follow_requests/#{id}/reject", :relationship) -end -``` - -- The six relationship actions (`follow` … `unmute`) are uniform POSTs per the Endpoint Inventory table. -- Note `notification_test.exs` also aliases `Account` (for struct matches only) — leave it; it is retargeted in Task 10. - -Commit message: `Flatten accounts and relationships into Hunter`. - -### Task 7: Flatten statuses, polls, timelines, search, and context - -**Files:** -- Modify: `lib/hunter.ex`, `lib/hunter/status.ex`, `lib/hunter/poll.ex`, `lib/hunter/result.ex`, `lib/hunter/context.ex`, `lib/hunter/api/http_client.ex` -- Test: `test/hunter/status_test.exs`, `test/hunter/poll_test.exs`, `test/hunter/result_test.exs`, `test/hunter/context_test.exs` - -Follow the per-domain recipe for: `create_status`, `status`, `statuses_by_ids`, `edit_status`, `status_history`, `status_source`, `destroy_status`, `bookmark`, `unbookmark`, `pin`, `unpin`, `mute_conversation`, `unmute_conversation`, `bookmarks`, `translate_status`, `reblog`, `unreblog`, `favourite`, `unfavourite`, `favourites`, `statuses`, `home_timeline`, `public_timeline`, `hashtag_timeline`, `list_timeline`, `poll`, `vote`, `search`, `status_context`. - -Domain specifics: - -- `create_status/3` moves with its idempotency/scheduled logic exactly as written in PR 1 Task 3. -- The private helpers `status_action/3` and `retrieve_timeline/3` move from `http_client.ex` into `hunter.ex` as private functions (place them after the last function that uses them); the six status actions and four timelines keep calling them. -- `search/3` keeps `Hunter.Result.search`'s query merge: `options = options |> Keyword.merge(q: query) |> Map.new()` then `Request.request!(conn, :get, "/api/v2/search", :result, options)`. - -Commit message: `Flatten statuses, polls, timelines, search, and context into Hunter`. - -### Task 8: Flatten media attachments - -**Files:** -- Modify: `lib/hunter.ex`, `lib/hunter/attachment.ex`, `lib/hunter/api/http_client.ex` -- Test: `test/hunter/attachment_test.exs` - -Recipe over: `upload_media`, `media_attachment`, `update_media`, `delete_media`. `upload_media/3` moves with its multipart parts construction and the byte-streaming comment intact. - -Commit message: `Flatten media attachments into Hunter`. - -### Task 9: Flatten lists - -**Files:** -- Modify: `lib/hunter.ex`, `lib/hunter/list.ex`, `lib/hunter/api/http_client.ex` -- Test: `test/hunter/list_test.exs` - -Recipe over: `lists`, `list`, `create_list`, `update_list`, `destroy_list`, `list_accounts`, `add_accounts_to_list`, `remove_accounts_from_list`, `account_lists`. All uniform per the table (`create_list` builds `options |> Keyword.put(:title, title) |> Map.new()`). - -Commit message: `Flatten lists into Hunter`. - -### Task 10: Flatten notifications and push subscriptions - -**Files:** -- Modify: `lib/hunter.ex`, `lib/hunter/notification.ex`, `lib/hunter/web_push_subscription.ex`, `lib/hunter/api/http_client.ex` -- Test: `test/hunter/notification_test.exs`, `test/hunter/web_push_subscription_test.exs` - -Recipe over the 19 notification functions and 4 push functions in the table. The three post-processed functions keep their pipes: - -```elixir -def unread_count(conn) do - Request.request!(conn, :get, "/api/v1/notifications/unread_count", nil) - |> Map.fetch!("count") -end -``` - -(same shape for `notification_requests_merged?/1` with `"merged"` and `grouped_unread_count/1` with `"count"` against `/api/v2/notifications/unread_count`). - -Commit message: `Flatten notifications and push subscriptions into Hunter`. - -### Task 11: Flatten instance, domains, reports — delete `HTTPClient` - -**Files:** -- Modify: `lib/hunter.ex`, `lib/hunter/instance.ex`, `lib/hunter/domain.ex`, `lib/hunter/report.ex` -- Delete: `lib/hunter/api/http_client.ex` -- Test: `test/hunter/instance_test.exs`, `test/hunter/domain_test.exs`, `test/hunter/report_test.exs` - -Recipe over: `instance_info`, `blocked_domains`, `block_domain`, `unblock_domain`, `report`. `report/4` moves with its payload map; `blocked_domains` uses transform `nil` (the API returns bare strings). - -- [ ] **Step: after stripping these three entities, `http_client.ex` must be empty of functions — delete the file, then verify nothing references it:** - -Run: `grep -rn "HTTPClient" lib/ test/ README.md` -Expected: no matches. - -Run: `mix compile --warnings-as-errors && mix test && mix format --check-formatted` -Expected: all green. - -Commit message: `Flatten instance, domains, reports; delete Hunter.Api.HTTPClient`. - -### Task 12: Retarget the integration suite - -**Files:** -- Modify: `test/integration/mastodon_test.exs` (and `test/hunter/integration_case_test.exs` / `test/support/integration_case.ex` if they reference entity functions) - -- [ ] **Step 1: Replace entity calls with facade calls** - -Drop the `alias Hunter.{Account, Attachment, ...}` for *function* calls — every `Account.foo(...)`, `Status.foo(...)`, `Hunter.List.foo(...)`, `Hunter.Poll.foo(...)`, `Hunter.WebPushSubscription.foo(...)` call becomes `Hunter.foo(...)`. Keep aliases needed for struct pattern-matches (`%Account{id: ^id2}`). - -- [ ] **Step 2: Compile-check the excluded suite** - -Integration tests are tagged/excluded by default, but they still must compile: - -Run: `mix compile --warnings-as-errors && mix test --only integration --dry-run || mix test` -(If `--dry-run` is unsupported, `mix test` alone recompiles the file and proves it compiles; actually running the integration suite requires live credentials — do not attempt it.) - -- [ ] **Step 3: Commit** - -```bash -git add test/ -git commit -m "Point the integration suite at the Hunter facade - -Co-Authored-By: Claude Fable 5 " -``` - -### Task 13: CHANGELOG, version 0.7.0, README — open PR 2 - -**Files:** -- Modify: `CHANGELOG.md`, `mix.exs`, `README.md` - -- [ ] **Step 1: CHANGELOG entry** - -Add a `## 0.7.0` section: a **Breaking changes** block stating that all endpoint functions on entity modules were removed and live only on `Hunter`, with a migration table generated from the Endpoint Inventory — one row per removed entity module mapping old → new (e.g. `Hunter.Account.followers/3` → `Hunter.followers/3`; `Hunter.Client.new/1` → `Hunter.new/1`; `Hunter.Application.create_app/5` → `Hunter.create_app/5`; `Hunter.Client.log_in/4` → `Hunter.log_in/4`; …). Note that entity structs are unchanged and that `Hunter.Api.HTTPClient` (internal) was replaced by `Hunter.Api.Request.request!/6`. - -- [ ] **Step 2: Bump `@version "0.6.0"` → `"0.7.0"` in `mix.exs`** - -- [ ] **Step 3: Audit `README.md`** - -`grep -n "Hunter\." README.md` — rewrite any entity-module call (e.g. `Hunter.Application.load_credentials("hunter")` at line ~54 → `Hunter.load_credentials("hunter")`). - -- [ ] **Step 4: Docs build and final gates** - -Run: `mix docs && mix compile --warnings-as-errors && mix test && mix format --check-formatted && mix credo --strict && mix dialyzer` -Expected: all green; `mix docs` emits no warnings about broken references (entity-module function references in remaining docs would show up here). - -- [ ] **Step 5: Commit and open the stacked PR** - -```bash -git add CHANGELOG.md mix.exs README.md -git commit -m "Release prep: 0.7.0 breaking-changes changelog and README update - -Co-Authored-By: Claude Fable 5 " -git push -u origin refactor/flatten-facade -gh pr create --base refactor/transport-merge --title "Flatten the facade: all endpoints live on Hunter" --body "..." -``` - -PR body: link the spec, state the breaking change and migration table location (CHANGELOG), note the stacked base and that it re-bases onto `main` after PR 1 squash-merges. End with the 🤖 attribution line. diff --git a/docs/superpowers/plans/2026-07-09-account-extras.md b/docs/superpowers/plans/2026-07-09-account-extras.md deleted file mode 100644 index 0b9dbbe..0000000 --- a/docs/superpowers/plans/2026-07-09-account-extras.md +++ /dev/null @@ -1,758 +0,0 @@ -# Account Extras Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Expose eleven account-level Mastodon endpoints Hunter is missing: lookup/fetch, registration, relationship extras (note, remove-from-followers), and endorsements (issue #124). - -**Architecture:** Each endpoint is a thin function on the `Hunter` module (`lib/hunter.ex`) that delegates to `Hunter.Api.Request.request!/4-6`, exactly like the existing `account/2`, `followers/3`, `relationships/2`, `follow/2`. Responses decode through `Hunter.Api.Transformer` target atoms into existing entity structs. One new entity, `Hunter.FamiliarFollowers`, because that response is a distinct shape; everything else reuses `Account`, `Relationship`, `FeaturedTag`. - -**Tech Stack:** Elixir (1.16+), Req for HTTP, Poison for JSON. Tests use ExUnit with `Req.Test` stubs via the `Hunter.ReqCase` case template. - -## Global Constraints - -- Spec: `docs/superpowers/specs/2026-07-09-account-extras-design.md`. All eleven endpoints ship in a single PR (branch `account-extras-124`). -- One module per file; folders mirror the module hierarchy (no nested `defmodule`). -- Entity `id` fields are typed `String.t()`; id *parameters* accept `String.t() | non_neg_integer`. -- No `Hunter.Token` entity — registration returns a `Hunter.Client`. -- The deprecated account `pin`/`unpin` endorsement endpoints are NOT implemented. -- Run `mix format` before every commit. Full check: `mix test`, `mix credo`, `mix dialyzer`. -- Test conventions: `test/hunter/account_test.exs` uses `@conn Hunter.new(base_url: "https://mastodon.example", access_token: "123456")` and helpers `stub_request/1`, `respond_with_fixture/2-3`, `respond_with/2-3`, `read_json_body!/1` from `Hunter.ReqCase`. Repeated list params are asserted with `URI.encode_query([{"id[]", "1"}, ...])`. - -### Where things live (orientation for every task) - -- `lib/hunter.ex` — the facade; all endpoint functions. Every function follows this shape: - - ```elixir - @doc """ - Follow a user - - ## Parameters - - * `conn` - connection credentials - * `id` - user identifier - - """ - @spec follow(Hunter.Client.t(), String.t() | non_neg_integer) :: Hunter.Relationship.t() - def follow(conn, id) do - Request.request!(conn, :post, "/api/v1/accounts/#{id}/follow", :relationship) - end - ``` - - `Request` is already aliased at the top of the module. `request!(conn, method, path, transformer_target, payload \\ [], opts \\ [])`: for `:get` the payload becomes query params (a list value expands to repeated `key[]=` params); for `:post` it becomes a JSON body. Transformer target `nil` returns the JSON-decoded body as a plain map. - -- `lib/hunter/api/transformer.ex` — `Hunter.Api.Transformer.transform/2` clauses, one per target atom. Existing targets used here: `:account`, `:accounts`, `:relationship`, `:featured_tags`. Private helper `account_nested_struct/0` builds `%Hunter.Account{emojis: [...], fields: [...], roles: [...]}` for nested decoding. - -- Fixtures: `test/fixtures/account.json` (id `"23634"`, username `"milmazz"`), `test/fixtures/relationship.json` (id `"8039"`, `note: "college friend"`), `test/fixtures/featured_tag.json` (id `"627"`, name `"elixir"`). - ---- - -### Task 1: `Hunter.FamiliarFollowers` entity + transformer clause - -**Files:** -- Create: `lib/hunter/familiar_followers.ex` -- Create: `test/fixtures/familiar_followers.json` -- Modify: `lib/hunter/api/transformer.ex` -- Test: `test/hunter/api/transformer_test.exs` - -**Interfaces:** -- Consumes: `Hunter.Account` struct, `account_nested_struct/0` private helper in `Hunter.Api.Transformer`. -- Produces: `Hunter.FamiliarFollowers` struct with fields `id :: String.t()`, `accounts :: [Hunter.Account.t()]`; transformer target `:familiar_followers` that decodes a JSON **array** into `[%Hunter.FamiliarFollowers{}]`. Task 3 relies on both. - -- [ ] **Step 1: Create the fixture** - -Create `test/fixtures/familiar_followers.json` — note the top level is an array: - -```json -[ - { - "id": "8039", - "accounts": [ - { - "id": "23634", - "username": "milmazz", - "acct": "milmazz", - "display_name": "Milton Mazzarri", - "url": "https://mastodon.example/@milmazz" - } - ] - } -] -``` - -- [ ] **Step 2: Write the failing transformer test** - -In `test/hunter/api/transformer_test.exs`, add after the `"decodes a list of featured tags"` test (uses the existing `transform/2` private helper at the bottom of the file — NOT `transform_list/2`, because the fixture is already an array): - -```elixir -test "decodes familiar followers with nested accounts" do - assert [familiar] = transform("familiar_followers", :familiar_followers) - - assert %Hunter.FamiliarFollowers{id: "8039"} = familiar - assert [%Hunter.Account{username: "milmazz", acct: "milmazz"}] = familiar.accounts -end -``` - -- [ ] **Step 3: Run the test to verify it fails** - -Run: `mix test test/hunter/api/transformer_test.exs` -Expected: FAIL — the `:familiar_followers` target falls through to the catch-all `transform(body, _)` clause and returns plain maps, so the `%Hunter.FamiliarFollowers{}` match fails (the struct doesn't exist yet either). - -- [ ] **Step 4: Create the entity module** - -Create `lib/hunter/familiar_followers.ex`: - -```elixir -defmodule Hunter.FamiliarFollowers do - @moduledoc """ - FamiliarFollowers entity - - Accounts you follow that also follow a given account - - ## Fields - - * `id` - the account id these familiar followers relate to - * `accounts` - accounts you follow that also follow that account - - """ - - @type t :: %__MODULE__{ - id: String.t(), - accounts: [Hunter.Account.t()] - } - - @derive [Poison.Encoder] - defstruct [:id, :accounts] -end -``` - -- [ ] **Step 5: Add the transformer clause** - -In `lib/hunter/api/transformer.ex`, add directly after the `transform(body, :accounts)` clause: - -```elixir -def transform(body, :familiar_followers), - do: Poison.decode!(body, as: [%Hunter.FamiliarFollowers{accounts: [account_nested_struct()]}]) -``` - -- [ ] **Step 6: Run the test to verify it passes** - -Run: `mix test test/hunter/api/transformer_test.exs` -Expected: PASS (all tests in the file). - -- [ ] **Step 7: Format and commit** - -```bash -mix format -git add lib/hunter/familiar_followers.ex lib/hunter/api/transformer.ex test/fixtures/familiar_followers.json test/hunter/api/transformer_test.exs -git commit -m "feat: add Hunter.FamiliarFollowers entity and transformer target" -``` - ---- - -### Task 2: `lookup_account/2` and `accounts_by_ids/2` - -**Files:** -- Modify: `lib/hunter.ex` -- Test: `test/hunter/account_test.exs` - -**Interfaces:** -- Consumes: `Request.request!/5` (aliased in `lib/hunter.ex`), transformer targets `:account` and `:accounts`. -- Produces: `Hunter.lookup_account(conn, acct) :: Hunter.Account.t()` and `Hunter.accounts_by_ids(conn, ids) :: [Hunter.Account.t()]`. - -- [ ] **Step 1: Write the failing tests** - -In `test/hunter/account_test.exs`, add after the `"returns an account"` test: - -```elixir -test "looks up an account by acct" do - stub_request(fn conn -> - assert conn.method == "GET" - assert conn.request_path == "/api/v1/accounts/lookup" - assert conn.query_string == URI.encode_query([{"acct", "milmazz@mastodon.example"}]) - respond_with_fixture(conn, "account") - end) - - assert %Account{username: "milmazz"} = - Hunter.lookup_account(@conn, "milmazz@mastodon.example") -end - -test "returns multiple accounts by id with id[] params" do - stub_request(fn conn -> - assert conn.method == "GET" - assert conn.request_path == "/api/v1/accounts" - assert conn.query_string == URI.encode_query([{"id[]", "1"}, {"id[]", "2"}]) - respond_with_fixture(conn, "account", wrap: :list) - end) - - assert [%Account{username: "milmazz"}] = Hunter.accounts_by_ids(@conn, [1, 2]) -end -``` - -- [ ] **Step 2: Run the tests to verify they fail** - -Run: `mix test test/hunter/account_test.exs` -Expected: FAIL with `UndefinedFunctionError` for `Hunter.lookup_account/2` and `Hunter.accounts_by_ids/2`. - -- [ ] **Step 3: Implement the functions** - -In `lib/hunter.ex`, add directly after the `account/2` function (search for `def account(conn, id) do`; insert after its closing `end`): - -```elixir -@doc """ -Look up an account by its webfinger address, without requiring a search - -## Parameters - - * `conn` - connection credentials - * `acct` - the username or webfinger address (e.g. `user@domain`) to look up - -""" -@spec lookup_account(Hunter.Client.t(), String.t()) :: Hunter.Account.t() -def lookup_account(conn, acct) do - Request.request!(conn, :get, "/api/v1/accounts/lookup", :account, %{acct: acct}) -end - -@doc """ -Retrieve multiple accounts by id - -## Parameters - - * `conn` - connection credentials - * `ids` - list of account identifiers - -""" -@spec accounts_by_ids(Hunter.Client.t(), [String.t() | non_neg_integer]) :: [Hunter.Account.t()] -def accounts_by_ids(conn, ids) do - Request.request!(conn, :get, "/api/v1/accounts", :accounts, %{id: ids}) -end -``` - -- [ ] **Step 4: Run the tests to verify they pass** - -Run: `mix test test/hunter/account_test.exs` -Expected: PASS (all tests in the file). - -- [ ] **Step 5: Format and commit** - -```bash -mix format -git add lib/hunter.ex test/hunter/account_test.exs -git commit -m "feat: add lookup_account/2 and accounts_by_ids/2" -``` - ---- - -### Task 3: `familiar_followers/2` and `account_featured_tags/2` - -**Files:** -- Modify: `lib/hunter.ex` -- Test: `test/hunter/account_test.exs` - -**Interfaces:** -- Consumes: `Request.request!/4-5`, transformer targets `:familiar_followers` (added in Task 1) and `:featured_tags` (already exists), `Hunter.FamiliarFollowers` struct (Task 1), `test/fixtures/familiar_followers.json` (Task 1). -- Produces: `Hunter.familiar_followers(conn, ids) :: [Hunter.FamiliarFollowers.t()]` and `Hunter.account_featured_tags(conn, id) :: [Hunter.FeaturedTag.t()]`. - -- [ ] **Step 1: Write the failing tests** - -In `test/hunter/account_test.exs`, add after the `accounts_by_ids` test added in Task 2 (if executing out of order, add after the `"returns an account"` test): - -```elixir -test "returns familiar followers with id[] params" do - stub_request(fn conn -> - assert conn.method == "GET" - assert conn.request_path == "/api/v1/accounts/familiar_followers" - assert conn.query_string == URI.encode_query([{"id[]", "8039"}, {"id[]", "8040"}]) - respond_with_fixture(conn, "familiar_followers") - end) - - assert [%Hunter.FamiliarFollowers{id: "8039", accounts: [%Account{username: "milmazz"}]}] = - Hunter.familiar_followers(@conn, [8039, 8040]) -end - -test "returns an account's featured tags" do - stub_request(fn conn -> - assert conn.method == "GET" - assert conn.request_path == "/api/v1/accounts/23634/featured_tags" - respond_with_fixture(conn, "featured_tag", wrap: :list) - end) - - assert [%Hunter.FeaturedTag{name: "elixir", statuses_count: 20}] = - Hunter.account_featured_tags(@conn, 23_634) -end -``` - -Note: `familiar_followers.json` is already a JSON array, so no `wrap: :list` there. - -- [ ] **Step 2: Run the tests to verify they fail** - -Run: `mix test test/hunter/account_test.exs` -Expected: FAIL with `UndefinedFunctionError` for `Hunter.familiar_followers/2` and `Hunter.account_featured_tags/2`. - -- [ ] **Step 3: Implement the functions** - -In `lib/hunter.ex`, add directly after the `following/3` function (search for `def following(conn, id, options \\ []) do`; insert after its closing `end`): - -```elixir -@doc """ -Find out which of the accounts you follow also follow the given accounts - -## Parameters - - * `conn` - connection credentials - * `ids` - list of account identifiers - -""" -@spec familiar_followers(Hunter.Client.t(), [String.t() | non_neg_integer]) :: [ - Hunter.FamiliarFollowers.t() - ] -def familiar_followers(conn, ids) do - Request.request!(conn, :get, "/api/v1/accounts/familiar_followers", :familiar_followers, %{ - id: ids - }) -end - -@doc """ -Retrieve the hashtags an account is featuring on their profile - -## Parameters - - * `conn` - connection credentials - * `id` - account identifier - -""" -@spec account_featured_tags(Hunter.Client.t(), String.t() | non_neg_integer) :: [ - Hunter.FeaturedTag.t() - ] -def account_featured_tags(conn, id) do - Request.request!(conn, :get, "/api/v1/accounts/#{id}/featured_tags", :featured_tags) -end -``` - -- [ ] **Step 4: Run the tests to verify they pass** - -Run: `mix test test/hunter/account_test.exs` -Expected: PASS (all tests in the file). - -- [ ] **Step 5: Format and commit** - -```bash -mix format -git add lib/hunter.ex test/hunter/account_test.exs -git commit -m "feat: add familiar_followers/2 and account_featured_tags/2" -``` - ---- - -### Task 4: `set_account_note/3` and `remove_from_followers/2` - -**Files:** -- Modify: `lib/hunter.ex` -- Test: `test/hunter/account_test.exs` - -**Interfaces:** -- Consumes: `Request.request!/4-5`, transformer target `:relationship`, fixture `relationship.json` (id `"8039"`, `note: "college friend"`). -- Produces: `Hunter.set_account_note(conn, id, comment) :: Hunter.Relationship.t()` and `Hunter.remove_from_followers(conn, id) :: Hunter.Relationship.t()`. - -- [ ] **Step 1: Write the failing tests** - -In `test/hunter/account_test.exs`, add after the `"rejects a follow request"` test: - -```elixir -test "sets a private note on an account" do - stub_request(fn conn -> - assert conn.method == "POST" - assert conn.request_path == "/api/v1/accounts/8039/note" - assert %{"comment" => "college friend"} = read_json_body!(conn) - respond_with_fixture(conn, "relationship") - end) - - assert %Hunter.Relationship{id: "8039", note: "college friend"} = - Hunter.set_account_note(@conn, 8039, "college friend") -end - -test "removes an account from your followers" do - stub_request(fn conn -> - assert conn.method == "POST" - assert conn.request_path == "/api/v1/accounts/8039/remove_from_followers" - respond_with_fixture(conn, "relationship") - end) - - assert %Hunter.Relationship{id: "8039"} = Hunter.remove_from_followers(@conn, 8039) -end -``` - -- [ ] **Step 2: Run the tests to verify they fail** - -Run: `mix test test/hunter/account_test.exs` -Expected: FAIL with `UndefinedFunctionError` for `Hunter.set_account_note/3` and `Hunter.remove_from_followers/2`. - -- [ ] **Step 3: Implement the functions** - -In `lib/hunter.ex`, add directly after the `unfollow/2` function (search for `def unfollow(conn, id) do`; insert after its closing `end`): - -```elixir -@doc """ -Set a private note on an account - -## Parameters - - * `conn` - connection credentials - * `id` - account identifier - * `comment` - the note text; pass an empty string to clear the note - -""" -@spec set_account_note(Hunter.Client.t(), String.t() | non_neg_integer, String.t()) :: - Hunter.Relationship.t() -def set_account_note(conn, id, comment) do - Request.request!(conn, :post, "/api/v1/accounts/#{id}/note", :relationship, %{ - comment: comment - }) -end - -@doc """ -Remove an account from your followers - -## Parameters - - * `conn` - connection credentials - * `id` - account identifier - -""" -@spec remove_from_followers(Hunter.Client.t(), String.t() | non_neg_integer) :: - Hunter.Relationship.t() -def remove_from_followers(conn, id) do - Request.request!(conn, :post, "/api/v1/accounts/#{id}/remove_from_followers", :relationship) -end -``` - -- [ ] **Step 4: Run the tests to verify they pass** - -Run: `mix test test/hunter/account_test.exs` -Expected: PASS (all tests in the file). - -- [ ] **Step 5: Format and commit** - -```bash -mix format -git add lib/hunter.ex test/hunter/account_test.exs -git commit -m "feat: add set_account_note/3 and remove_from_followers/2" -``` - ---- - -### Task 5: Endorsements — `endorse/2`, `unendorse/2`, `endorsements/2`, `account_endorsements/3` - -**Files:** -- Modify: `lib/hunter.ex` -- Test: `test/hunter/account_test.exs` - -**Interfaces:** -- Consumes: `Request.request!/4-5`, transformer targets `:relationship` and `:accounts`. -- Produces: `Hunter.endorse(conn, id) :: Hunter.Relationship.t()`, `Hunter.unendorse(conn, id) :: Hunter.Relationship.t()`, `Hunter.endorsements(conn, opts \\ []) :: [Hunter.Account.t()]`, `Hunter.account_endorsements(conn, id, opts \\ []) :: [Hunter.Account.t()]`. - -Naming rationale (from the spec): `endorse`/`unendorse` parallel `follow`/`unfollow`. The existing `pin`/`unpin` on `Hunter` act on *statuses*, so there is no collision, and the deprecated account `pin`/`unpin` variants are not implemented. `endorsements/2` (your featured accounts) and `account_endorsements/3` (a given account's featured accounts) are distinct endpoints with distinct names. - -- [ ] **Step 1: Write the failing tests** - -In `test/hunter/account_test.exs`, add after the `remove_from_followers` test added in Task 4 (if executing out of order, add after the `"rejects a follow request"` test): - -```elixir -test "endorses an account" do - stub_request(fn conn -> - assert conn.method == "POST" - assert conn.request_path == "/api/v1/accounts/8039/endorse" - respond_with_fixture(conn, "relationship") - end) - - assert %Hunter.Relationship{id: "8039"} = Hunter.endorse(@conn, 8039) -end - -test "removes an account endorsement" do - stub_request(fn conn -> - assert conn.method == "POST" - assert conn.request_path == "/api/v1/accounts/8039/unendorse" - respond_with_fixture(conn, "relationship") - end) - - assert %Hunter.Relationship{id: "8039"} = Hunter.unendorse(@conn, 8039) -end - -test "returns your endorsed accounts with query params" do - stub_request(fn conn -> - assert conn.method == "GET" - assert conn.request_path == "/api/v1/endorsements" - assert conn.query_string == "limit=1" - respond_with_fixture(conn, "account", wrap: :list) - end) - - assert [%Account{username: "milmazz"}] = Hunter.endorsements(@conn, limit: 1) -end - -test "returns the accounts a given account is featuring" do - stub_request(fn conn -> - assert conn.method == "GET" - assert conn.request_path == "/api/v1/accounts/8039/endorsements" - assert conn.query_string == "limit=1" - respond_with_fixture(conn, "account", wrap: :list) - end) - - assert [%Account{username: "milmazz"}] = Hunter.account_endorsements(@conn, 8039, limit: 1) -end -``` - -- [ ] **Step 2: Run the tests to verify they fail** - -Run: `mix test test/hunter/account_test.exs` -Expected: FAIL with `UndefinedFunctionError` for `Hunter.endorse/2`, `Hunter.unendorse/2`, `Hunter.endorsements/2`, `Hunter.account_endorsements/3`. - -- [ ] **Step 3: Implement the functions** - -In `lib/hunter.ex`, add directly after the `remove_from_followers/2` function added in Task 4 (if executing out of order, add after `unfollow/2`'s closing `end`): - -```elixir -@doc """ -Feature an account on your profile - -## Parameters - - * `conn` - connection credentials - * `id` - account identifier - -""" -@spec endorse(Hunter.Client.t(), String.t() | non_neg_integer) :: Hunter.Relationship.t() -def endorse(conn, id) do - Request.request!(conn, :post, "/api/v1/accounts/#{id}/endorse", :relationship) -end - -@doc """ -Stop featuring an account on your profile - -## Parameters - - * `conn` - connection credentials - * `id` - account identifier - -""" -@spec unendorse(Hunter.Client.t(), String.t() | non_neg_integer) :: Hunter.Relationship.t() -def unendorse(conn, id) do - Request.request!(conn, :post, "/api/v1/accounts/#{id}/unendorse", :relationship) -end - -@doc """ -Retrieve the accounts you are featuring on your profile - -## Parameters - - * `conn` - connection credentials - * `options` - option list - -## Options - - * `max_id` - get a list of endorsements with id less than or equal this value - * `since_id` - get a list of endorsements with id greater than this value - * `limit` - maximum number of endorsements to get - -""" -@spec endorsements(Hunter.Client.t(), Keyword.t()) :: [Hunter.Account.t()] -def endorsements(conn, options \\ []) do - Request.request!(conn, :get, "/api/v1/endorsements", :accounts, options) -end - -@doc """ -Retrieve the accounts a given account is featuring on their profile - -## Parameters - - * `conn` - connection credentials - * `id` - account identifier - * `options` - option list - -## Options - - * `max_id` - get a list of endorsements with id less than or equal this value - * `since_id` - get a list of endorsements with id greater than this value - * `limit` - maximum number of endorsements to get - -""" -@spec account_endorsements(Hunter.Client.t(), String.t() | non_neg_integer, Keyword.t()) :: [ - Hunter.Account.t() - ] -def account_endorsements(conn, id, options \\ []) do - Request.request!(conn, :get, "/api/v1/accounts/#{id}/endorsements", :accounts, options) -end -``` - -- [ ] **Step 4: Run the tests to verify they pass** - -Run: `mix test test/hunter/account_test.exs` -Expected: PASS (all tests in the file). - -- [ ] **Step 5: Format and commit** - -```bash -mix format -git add lib/hunter.ex test/hunter/account_test.exs -git commit -m "feat: add endorsement endpoints" -``` - ---- - -### Task 6: `register_account/2` - -**Files:** -- Modify: `lib/hunter.ex` -- Test: `test/hunter/account_test.exs` - -**Interfaces:** -- Consumes: `Request.request!/5` with transformer target `nil` (returns the JSON-decoded body as a plain map — same technique as the existing `log_in/4`, which does `response = Request.request!(base_url, :post, "/oauth/token", nil, payload)` then builds `%Hunter.Client{base_url: base_url, access_token: response["access_token"]}`). -- Produces: `Hunter.register_account(conn, params) :: Hunter.Client.t()`. - -Design note (from the spec): the caller passes a `Hunter.Client` carrying the *app-level* access token (obtained from the client-credentials flow). The response is a Token JSON; we return a new `Hunter.Client` with the user-level `access_token`. No `Hunter.Token` entity. - -- [ ] **Step 1: Write the failing test** - -In `test/hunter/account_test.exs`, add after the `"updates authenticated user's credentials with a JSON body"` test: - -```elixir -test "registers an account and returns a client holding the new token" do - stub_request(fn conn -> - assert conn.method == "POST" - assert conn.request_path == "/api/v1/accounts" - assert Plug.Conn.get_req_header(conn, "authorization") == ["Bearer 123456"] - - assert %{ - "username" => "kadaba", - "email" => "kadaba@example.com", - "password" => "hunter2hunter2", - "agreement" => true, - "locale" => "en" - } = read_json_body!(conn) - - respond_with(conn, %{ - access_token: "brandnewtoken", - token_type: "Bearer", - scope: "read write follow", - created_at: 1_783_814_400 - }) - end) - - assert %Hunter.Client{base_url: "https://mastodon.example", access_token: "brandnewtoken"} = - Hunter.register_account(@conn, %{ - username: "kadaba", - email: "kadaba@example.com", - password: "hunter2hunter2", - agreement: true, - locale: "en" - }) -end -``` - -- [ ] **Step 2: Run the test to verify it fails** - -Run: `mix test test/hunter/account_test.exs` -Expected: FAIL with `UndefinedFunctionError` for `Hunter.register_account/2`. - -- [ ] **Step 3: Implement the function** - -In `lib/hunter.ex`, add directly before the `log_in/4` function (search for `@spec log_in(`; insert above its `@doc` block): - -```elixir -@doc """ -Register a new account and obtain its access token - -The given client must carry an *app-level* access token (from the OAuth -client-credentials flow); returns a new `Hunter.Client` holding the created -user's access token. - -## Parameters - - * `conn` - connection credentials with the app-level access token - * `params` - registration params - -## Possible keys for params - - * `username` - desired username - * `email` - the account owner's email address - * `password` - the account password - * `agreement` - whether the user agrees to the server rules and terms (must be `true`) - * `locale` - the language of the confirmation email (e.g. `"en"`) - * `reason` - (optional) why you want to join, when registrations require approval - -""" -@spec register_account(Hunter.Client.t(), map) :: Hunter.Client.t() -def register_account(conn, params) do - response = Request.request!(conn, :post, "/api/v1/accounts", nil, params) - - %Hunter.Client{base_url: conn.base_url, access_token: response["access_token"]} -end -``` - -- [ ] **Step 4: Run the test to verify it passes** - -Run: `mix test test/hunter/account_test.exs` -Expected: PASS (all tests in the file). - -- [ ] **Step 5: Format and commit** - -```bash -mix format -git add lib/hunter.ex test/hunter/account_test.exs -git commit -m "feat: add register_account/2" -``` - ---- - -### Task 7: CHANGELOG entry + full verification - -**Files:** -- Modify: `CHANGELOG.md` - -**Interfaces:** -- Consumes: all functions from Tasks 1–6. -- Produces: release notes; a fully verified branch. - -- [ ] **Step 1: Add the CHANGELOG entry** - -In `CHANGELOG.md`, under `## v0.7.0` → `* Features`, add as the FIRST bullet of the Features list (matching the style of the existing `- Notifications v2 ([#122]): ...` entries): - -```markdown - - Account extras ([#124]): `lookup_account/2`, `accounts_by_ids/2`, - `familiar_followers/2` (new `Hunter.FamiliarFollowers` entity), - `account_featured_tags/2`, `register_account/2` (returns a - `Hunter.Client` holding the new user's token), `set_account_note/3`, - `remove_from_followers/2`, and endorsements (`endorse/2`, - `unendorse/2`, `endorsements/2`, `account_endorsements/3`), all on - `Hunter` -``` - -Then add the link reference next to the existing ones near the bottom of the file (after the line `[#122]: https://github.com/milmazz/hunter/issues/122`): - -```markdown -[#124]: https://github.com/milmazz/hunter/issues/124 -``` - -- [ ] **Step 2: Run the full test suite** - -Run: `mix test` -Expected: 0 failures. - -- [ ] **Step 3: Run the linters** - -Run: `mix format --check-formatted && mix credo` -Expected: no formatting diffs, no credo issues in the changed files. - -Run: `mix dialyzer` -Expected: no new warnings (first run may take a while building the PLT). - -- [ ] **Step 4: Commit** - -```bash -git add CHANGELOG.md -git commit -m "docs: changelog entry for account extras (#124)" -``` - ---- - -## Self-Review Notes - -Spec coverage check: all eleven endpoint functions from the spec's table have a task (Task 2: `lookup_account`, `accounts_by_ids`; Task 3: `familiar_followers`, `account_featured_tags`; Task 4: `set_account_note`, `remove_from_followers`; Task 5: `endorse`, `unendorse`, `endorsements`, `account_endorsements`; Task 6: `register_account`). The new entity, transformer clause, fixture, and transformer test are Task 1. One test per endpoint lands in `test/hunter/account_test.exs`; the `register_account` test asserts the app bearer token and returned `Hunter.Client`; the transformer test covers `:familiar_followers`. Out-of-scope items (Token entity, deprecated pin/unpin) are excluded. diff --git a/docs/superpowers/plans/2026-07-10-oauth-modernization.md b/docs/superpowers/plans/2026-07-10-oauth-modernization.md deleted file mode 100644 index 2580b54..0000000 --- a/docs/superpowers/plans/2026-07-10-oauth-modernization.md +++ /dev/null @@ -1,1208 +0,0 @@ -# OAuth Modernization Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Bring Hunter's OAuth surface to Mastodon 4.3/4.4: token revocation, PKCE, client-credentials login, app credential verification, honest `CredentialApplication` handling, RFC 8414 discovery, OIDC userinfo; remove the password grant. - -**Architecture:** Every endpoint is a thin function on the `Hunter` facade delegating to `Hunter.Api.Request.request!/4-6` (see spec `docs/superpowers/specs/2026-07-10-oauth-modernization-design.md`). No new modules or entity structs; discovery/userinfo return plain maps (transformer `nil`), revoke uses `:empty` (returns `true`). - -**Tech Stack:** Elixir, Req (HTTP), Poison (JSON), Req.Test/Plug stubs for unit tests, docker-composed Mastodon for integration tests. - -## Global Constraints - -- All endpoint functions live on `Hunter` (`lib/hunter.ex`) — never on entity modules. -- Every network function defaults `base_url \\ "https://mastodon.social"` then applies `base_url = base_url || Config.api_base_url()`, matching `log_in_oauth/3`. -- Unit tests use `Hunter.ReqCase` (`stub_request/1`, `respond_with/2-3`, `read_json_body!/1`) with `api_base_url: "https://mastodon.example"`-style explicit URLs. -- Errors: non-2xx raises `Hunter.Error` via `Request.request!` — no extra handling. -- Run the full unit suite with `mix test` (integration tests are excluded by default) before each commit. - ---- - -### Task 1: `revoke_token/3` - -**Files:** -- Modify: `lib/hunter.ex` (insert after `log_in_oauth/3`, which ends near line 1968) -- Create: `test/hunter/oauth_test.exs` - -**Interfaces:** -- Consumes: `Request.request!(base_url, :post, path, :empty, payload)` → `true`; `Hunter.Application` struct fields `client_id`, `client_secret`. -- Produces: `Hunter.revoke_token(app :: Hunter.Application.t(), token :: String.t(), base_url :: String.t()) :: true` - -- [ ] **Step 1: Write the failing tests** - -Create `test/hunter/oauth_test.exs`: - -```elixir -defmodule Hunter.OAuthTest do - use Hunter.ReqCase, async: true - - @app %Hunter.Application{ - client_id: "abc", - client_secret: "def", - scopes: ["read", "write"], - redirect_uri: "urn:ietf:wg:oauth:2.0:oob" - } - - describe "revoke_token/3" do - test "revokes a token with client credentials" do - stub_request(fn conn -> - assert conn.method == "POST" - assert conn.request_path == "/oauth/revoke" - - assert %{ - "client_id" => "abc", - "client_secret" => "def", - "token" => "tok" - } = read_json_body!(conn) - - respond_with(conn, %{}) - end) - - assert Hunter.revoke_token(@app, "tok", "https://mastodon.example") == true - end - - test "a token that does not belong to the client raises Hunter.Error" do - stub_request(fn conn -> - respond_with(conn, %{error: "unauthorized_client"}, 403) - end) - - assert_raise Hunter.Error, fn -> - Hunter.revoke_token(@app, "someone-elses", "https://mastodon.example") - end - end - end -end -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `mix test test/hunter/oauth_test.exs` -Expected: 2 failures with `UndefinedFunctionError: function Hunter.revoke_token/3 is undefined` - -- [ ] **Step 3: Implement `revoke_token/3`** - -In `lib/hunter.ex`, immediately after the `log_in_oauth/3` function: - -```elixir -@doc """ -Revoke an access token - -## Parameters - - * `app` - application details (must be the client the token was issued - to), see: `Hunter.create_app/5` - * `token` - the access token to revoke - * `base_url` - API base url, default: `https://mastodon.social` - -Returns `true` on success. Raises `Hunter.Error` if the token does not -belong to the given client. -""" -@spec revoke_token(Hunter.Application.t(), String.t(), String.t()) :: true -def revoke_token(%Hunter.Application{} = app, token, base_url \\ "https://mastodon.social") do - base_url = base_url || Config.api_base_url() - - payload = %{ - client_id: app.client_id, - client_secret: app.client_secret, - token: token - } - - Request.request!(base_url, :post, "/oauth/revoke", :empty, payload) -end -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `mix test test/hunter/oauth_test.exs` -Expected: 2 tests, 0 failures. Then `mix test` — full suite green. - -- [ ] **Step 5: Commit** - -```bash -git add lib/hunter.ex test/hunter/oauth_test.exs -git commit -m "Add revoke_token/3 (POST /oauth/revoke)" -``` - ---- - -### Task 2: PKCE helpers — `generate_pkce/0` and `authorization_url/3` - -**Files:** -- Modify: `lib/hunter.ex` (insert after `revoke_token/3` from Task 1) -- Modify: `test/hunter/oauth_test.exs` - -**Interfaces:** -- Consumes: `Hunter.Application` fields `client_id`, `scopes`, `redirect_uris`, `redirect_uri`. -- Produces: - - `Hunter.generate_pkce() :: %{code_verifier: String.t(), code_challenge: String.t(), code_challenge_method: String.t()}` - - `Hunter.authorization_url(app :: Hunter.Application.t(), base_url :: String.t(), opts :: Keyword.t()) :: String.t()` - - Private `first_redirect_uri(app) :: String.t()` — reused by Task 3. - -- [ ] **Step 1: Write the failing tests** - -Add to `test/hunter/oauth_test.exs`: - -```elixir -describe "generate_pkce/0" do - test "returns an S256 verifier/challenge pair per RFC 7636" do - %{ - code_verifier: verifier, - code_challenge: challenge, - code_challenge_method: "S256" - } = Hunter.generate_pkce() - - assert String.length(verifier) == 43 - assert verifier =~ ~r/\A[A-Za-z0-9_-]+\z/ - assert challenge == Base.url_encode64(:crypto.hash(:sha256, verifier), padding: false) - end - - test "verifiers are unique across calls" do - assert Hunter.generate_pkce().code_verifier != Hunter.generate_pkce().code_verifier - end -end - -describe "authorization_url/3" do - test "builds the authorize URL with defaults from the app" do - url = Hunter.authorization_url(@app, "https://mastodon.example") - - %URI{scheme: "https", host: "mastodon.example", path: "/oauth/authorize", query: query} = - URI.parse(url) - - assert URI.decode_query(query) == %{ - "response_type" => "code", - "client_id" => "abc", - "redirect_uri" => "urn:ietf:wg:oauth:2.0:oob", - "scope" => "read write" - } - end - - test "forwards PKCE and extra params, preferring opts over app defaults" do - url = - Hunter.authorization_url(@app, "https://mastodon.example", - redirect_uri: "https://app.example/cb", - scope: "read", - code_challenge: "xyz", - code_challenge_method: "S256", - state: "opaque", - force_login: true - ) - - query = url |> URI.parse() |> Map.fetch!(:query) |> URI.decode_query() - - assert query == %{ - "response_type" => "code", - "client_id" => "abc", - "redirect_uri" => "https://app.example/cb", - "scope" => "read", - "code_challenge" => "xyz", - "code_challenge_method" => "S256", - "state" => "opaque", - "force_login" => "true" - } - end - - test "prefers the first entry of redirect_uris when present" do - app = %Hunter.Application{@app | redirect_uris: ["https://one.example/cb", "https://two.example/cb"]} - - query = - app - |> Hunter.authorization_url("https://mastodon.example") - |> URI.parse() - |> Map.fetch!(:query) - |> URI.decode_query() - - assert query["redirect_uri"] == "https://one.example/cb" - end -end -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `mix test test/hunter/oauth_test.exs` -Expected: new tests fail with `UndefinedFunctionError` for `Hunter.generate_pkce/0` and `Hunter.authorization_url/2`. - -- [ ] **Step 3: Implement the helpers** - -In `lib/hunter.ex`, after `revoke_token/3`: - -```elixir -@doc """ -Generate a PKCE verifier/challenge pair (RFC 7636, S256) - -Returns a map with `code_verifier`, `code_challenge` and -`code_challenge_method`. Pass the challenge params to -`authorization_url/3` and the verifier to `log_in_oauth/4`. -""" -@spec generate_pkce() :: %{ - code_verifier: String.t(), - code_challenge: String.t(), - code_challenge_method: String.t() - } -def generate_pkce do - verifier = Base.url_encode64(:crypto.strong_rand_bytes(32), padding: false) - - %{ - code_verifier: verifier, - code_challenge: Base.url_encode64(:crypto.hash(:sha256, verifier), padding: false), - code_challenge_method: "S256" - } -end - -@doc """ -Build the URL to send the user to for authorization (`GET /oauth/authorize`) - -## Parameters - - * `app` - application details, see: `Hunter.create_app/5` - * `base_url` - API base url, default: `https://mastodon.social` - * `opts` - optional params - -## Options - - * `redirect_uri` - overrides the app's registered redirect URI - * `scope` - space-separated scopes, defaults to the app's scopes - * `code_challenge` / `code_challenge_method` - PKCE params, see - `generate_pkce/0` - * `state` - opaque value returned to your redirect URI unchanged - * `force_login` - forces re-login when `true` - * `lang` - ISO 639-1 language code for the authorization form - -Builds a URL only; performs no request. -""" -@spec authorization_url(Hunter.Application.t(), String.t(), Keyword.t()) :: String.t() -def authorization_url(%Hunter.Application{} = app, base_url \\ "https://mastodon.social", opts \\ []) do - base_url = base_url || Config.api_base_url() - - query = - [ - response_type: "code", - client_id: app.client_id, - redirect_uri: first_redirect_uri(app), - scope: default_scope(app) - ] - |> Keyword.merge( - Keyword.take(opts, [ - :redirect_uri, - :scope, - :code_challenge, - :code_challenge_method, - :state, - :force_login, - :lang - ]) - ) - |> Enum.reject(fn {_key, value} -> is_nil(value) end) - |> URI.encode_query() - - base_url <> "/oauth/authorize?" <> query -end - -defp first_redirect_uri(%Hunter.Application{redirect_uris: [uri | _]}), do: uri - -defp first_redirect_uri(%Hunter.Application{redirect_uri: uri}) when is_binary(uri), do: uri - -# Doorkeeper rejects requests without a redirect_uri matching the -# registration; fall back to create_app's default for stale credentials -defp first_redirect_uri(_app), do: "urn:ietf:wg:oauth:2.0:oob" - -defp default_scope(%Hunter.Application{scopes: scopes}) when is_list(scopes) and scopes != [], - do: Enum.join(scopes, " ") - -defp default_scope(_app), do: nil -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `mix test test/hunter/oauth_test.exs` -Expected: all pass. Then `mix test` — full suite green. - -- [ ] **Step 5: Commit** - -```bash -git add lib/hunter.ex test/hunter/oauth_test.exs -git commit -m "Add PKCE helpers: generate_pkce/0 and authorization_url/3" -``` - ---- - -### Task 3: `code_verifier` option on `log_in_oauth` - -**Files:** -- Modify: `lib/hunter.ex:1951-1968` (the `log_in_oauth/3` function) -- Modify: `test/hunter/oauth_test.exs` -- Modify: `test/hunter/client_test.exs` (move the existing `log_in_oauth` describe block into `oauth_test.exs` unchanged, then extend) - -**Interfaces:** -- Consumes: `first_redirect_uri/1` from Task 2. -- Produces: `Hunter.log_in_oauth(app, code :: String.t(), base_url :: String.t(), opts :: Keyword.t()) :: Hunter.Client.t()` with `opts[:code_verifier]` forwarded. - -- [ ] **Step 1: Move existing coverage and write the failing tests** - -Delete the `describe "log_in_oauth/3"` block from `test/hunter/client_test.exs`. In `test/hunter/oauth_test.exs`, add `alias Hunter.Client` directly below `use Hunter.ReqCase, async: true`, then add: - -```elixir -describe "log_in_oauth/4" do - test "exchanges an authorization code for an access token" do - stub_request(fn conn -> - assert conn.method == "POST" - assert conn.request_path == "/oauth/token" - - body = read_json_body!(conn) - - assert %{ - "grant_type" => "authorization_code", - "code" => "auth-code", - "client_id" => "abc", - "client_secret" => "def", - "redirect_uri" => "urn:ietf:wg:oauth:2.0:oob" - } = body - - refute Map.has_key?(body, "code_verifier") - - respond_with(conn, %{access_token: "tok"}) - end) - - assert %Client{base_url: "https://mastodon.example", access_token: "tok"} = - Hunter.log_in_oauth(@app, "auth-code", "https://mastodon.example") - end - - test "forwards the PKCE code_verifier when given" do - stub_request(fn conn -> - assert %{ - "grant_type" => "authorization_code", - "code" => "auth-code", - "code_verifier" => "the-verifier" - } = read_json_body!(conn) - - respond_with(conn, %{access_token: "tok"}) - end) - - assert %Client{access_token: "tok"} = - Hunter.log_in_oauth(@app, "auth-code", "https://mastodon.example", - code_verifier: "the-verifier" - ) - end - - test "uses the first redirect_uris entry when present" do - stub_request(fn conn -> - assert %{"redirect_uri" => "https://one.example/cb"} = read_json_body!(conn) - respond_with(conn, %{access_token: "tok"}) - end) - - app = %Hunter.Application{@app | redirect_uris: ["https://one.example/cb"]} - assert %Client{} = Hunter.log_in_oauth(app, "auth-code", "https://mastodon.example") - end -end -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `mix test test/hunter/oauth_test.exs` -Expected: the `code_verifier` test fails with `UndefinedFunctionError: function Hunter.log_in_oauth/4 is undefined`; the `redirect_uris` test fails (current code reads only `redirect_uri`). - -- [ ] **Step 3: Update `log_in_oauth`** - -Replace the whole `log_in_oauth/3` definition (spec, doc, body) in `lib/hunter.ex` with: - -```elixir -@doc """ -Retrieve access token via the OAuth authorization-code flow - -## Parameters - - * `app` - application details, see: `Hunter.create_app/5` for more details. - * `oauth_code` - authorization code from the redirect (or the out-of-band - form) - * `base_url` - API base url, default: `https://mastodon.social` - * `opts` - optional params - -## Options - - * `code_verifier` - PKCE verifier matching the `code_challenge` sent to - `authorization_url/3`, see `generate_pkce/0` - -""" -@spec log_in_oauth(Hunter.Application.t(), String.t(), String.t(), Keyword.t()) :: - Hunter.Client.t() -def log_in_oauth( - %Hunter.Application{} = app, - oauth_code, - base_url \\ "https://mastodon.social", - opts \\ [] - ) do - base_url = base_url || Config.api_base_url() - - payload = %{ - client_id: app.client_id, - client_secret: app.client_secret, - grant_type: "authorization_code", - code: oauth_code, - redirect_uri: first_redirect_uri(app) - } - - payload = - case Keyword.fetch(opts, :code_verifier) do - {:ok, verifier} -> Map.put(payload, :code_verifier, verifier) - :error -> payload - end - - response = Request.request!(base_url, :post, "/oauth/token", nil, payload) - - %Hunter.Client{base_url: base_url, access_token: response["access_token"]} -end -``` - -(The `# Doorkeeper rejects...` comment moves with `first_redirect_uri/1` in Task 2 — remove the old inline comment here.) - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `mix test test/hunter/oauth_test.exs test/hunter/client_test.exs` -Expected: all pass. Then `mix test` — full suite green. - -- [ ] **Step 5: Commit** - -```bash -git add lib/hunter.ex test/hunter/oauth_test.exs test/hunter/client_test.exs -git commit -m "Support PKCE code_verifier in log_in_oauth" -``` - ---- - -### Task 4: `log_in_app/2` (client-credentials grant) - -**Files:** -- Modify: `lib/hunter.ex` (insert after `log_in_oauth/4`) -- Modify: `test/hunter/oauth_test.exs` - -**Interfaces:** -- Produces: `Hunter.log_in_app(app :: Hunter.Application.t(), base_url :: String.t()) :: Hunter.Client.t()` — used by `verify_app_credentials/1` callers and `register_account/2` callers. - -- [ ] **Step 1: Write the failing tests** - -Add to `test/hunter/oauth_test.exs`: - -```elixir -describe "log_in_app/2" do - test "exchanges client credentials for an app-level token" do - stub_request(fn conn -> - assert conn.method == "POST" - assert conn.request_path == "/oauth/token" - - assert %{ - "grant_type" => "client_credentials", - "client_id" => "abc", - "client_secret" => "def", - "scope" => "read write" - } = read_json_body!(conn) - - respond_with(conn, %{access_token: "app-tok"}) - end) - - assert %Client{base_url: "https://mastodon.example", access_token: "app-tok"} = - Hunter.log_in_app(@app, "https://mastodon.example") - end - - test "omits scope when the app has none" do - stub_request(fn conn -> - body = read_json_body!(conn) - refute Map.has_key?(body, "scope") - respond_with(conn, %{access_token: "app-tok"}) - end) - - app = %Hunter.Application{@app | scopes: nil} - assert %Client{access_token: "app-tok"} = Hunter.log_in_app(app, "https://mastodon.example") - end -end -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `mix test test/hunter/oauth_test.exs` -Expected: 2 failures with `UndefinedFunctionError: function Hunter.log_in_app/2 is undefined` - -- [ ] **Step 3: Implement `log_in_app/2`** - -In `lib/hunter.ex`, after `log_in_oauth/4`: - -```elixir -@doc """ -Retrieve an app-level access token via the client-credentials grant - -The returned client can call app-scoped endpoints such as -`verify_app_credentials/1` and `register_account/2`. - -## Parameters - - * `app` - application details, see: `Hunter.create_app/5` for more details. - * `base_url` - API base url, default: `https://mastodon.social` - -""" -@spec log_in_app(Hunter.Application.t(), String.t()) :: Hunter.Client.t() -def log_in_app(%Hunter.Application{} = app, base_url \\ "https://mastodon.social") do - base_url = base_url || Config.api_base_url() - - payload = %{ - client_id: app.client_id, - client_secret: app.client_secret, - grant_type: "client_credentials" - } - - payload = - case default_scope(app) do - nil -> payload - scope -> Map.put(payload, :scope, scope) - end - - response = Request.request!(base_url, :post, "/oauth/token", nil, payload) - - %Hunter.Client{base_url: base_url, access_token: response["access_token"]} -end -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `mix test test/hunter/oauth_test.exs` -Expected: all pass. Then `mix test` — full suite green. - -- [ ] **Step 5: Commit** - -```bash -git add lib/hunter.ex test/hunter/oauth_test.exs -git commit -m "Add log_in_app/2 client-credentials grant" -``` - ---- - -### Task 5: `verify_app_credentials/1` - -**Files:** -- Modify: `lib/hunter.ex` (insert after `create_app/5`, in the `## Application` section around line 305-393) -- Modify: `test/hunter/application_test.exs` - -**Interfaces:** -- Consumes: `Request.request!(conn, :get, path, :application)` → `Hunter.Application.t()`. -- Produces: `Hunter.verify_app_credentials(conn :: Hunter.Client.t()) :: Hunter.Application.t()` - -- [ ] **Step 1: Write the failing test** - -Add to `test/hunter/application_test.exs`: - -```elixir -test "verify_app_credentials/1 confirms the app token and decodes the app" do - stub_request(fn conn -> - assert conn.method == "GET" - assert conn.request_path == "/api/v1/apps/verify_credentials" - assert Plug.Conn.get_req_header(conn, "authorization") == ["Bearer app-tok"] - - respond_with(conn, %{ - name: "hunter", - website: nil, - scopes: ["read", "write"], - redirect_uris: ["urn:ietf:wg:oauth:2.0:oob"] - }) - end) - - conn = Hunter.new(base_url: "https://mastodon.example", access_token: "app-tok") - - assert %Hunter.Application{ - name: "hunter", - client_secret: nil, - scopes: ["read", "write"], - redirect_uris: ["urn:ietf:wg:oauth:2.0:oob"] - } = Hunter.verify_app_credentials(conn) -end -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `mix test test/hunter/application_test.exs` -Expected: 1 failure with `UndefinedFunctionError: function Hunter.verify_app_credentials/1 is undefined` - -- [ ] **Step 3: Implement `verify_app_credentials/1`** - -In `lib/hunter.ex`, after `create_app/5` (before `load_credentials/1`): - -```elixir -@doc """ -Confirm that the app-level token works - -## Parameters - - * `conn` - connection credentials holding an *app-level* access token, - see `log_in_app/2` - -Returns the `Hunter.Application` as the server sees it (never includes -`client_secret`; includes `scopes` and `redirect_uris` since Mastodon 4.3). -""" -@spec verify_app_credentials(Hunter.Client.t()) :: Hunter.Application.t() -def verify_app_credentials(conn) do - Request.request!(conn, :get, "/api/v1/apps/verify_credentials", :application) -end -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `mix test test/hunter/application_test.exs` -Expected: all pass. Then `mix test` — full suite green. - -- [ ] **Step 5: Commit** - -```bash -git add lib/hunter.ex test/hunter/application_test.exs -git commit -m "Add verify_app_credentials/1 (GET /api/v1/apps/verify_credentials)" -``` - ---- - -### Task 6: `create_app` handles `CredentialApplication` honestly - -**Files:** -- Modify: `lib/hunter.ex:342-369` (`create_app/5`) -- Modify: `test/hunter/application_test.exs` - -**Interfaces:** -- Consumes: `first_redirect_uri/1` is NOT used here; this task adds its own `List.wrap/1` handling. -- Produces: `Hunter.create_app(name, redirect_uris :: String.t() | [String.t()], scopes, website, options) :: Hunter.Application.t()` — server-returned `scopes`/`redirect_uris`/`redirect_uri` win; requested values only backfill `nil` fields. - -- [ ] **Step 1: Write the failing tests** - -Add to `test/hunter/application_test.exs`: - -```elixir -test "keeps the server's CredentialApplication fields (Mastodon 4.3+)" do - stub_request(fn conn -> - assert %{"redirect_uris" => ["https://one.example/cb", "https://two.example/cb"]} = - read_json_body!(conn) - - respond_with(conn, %{ - id: "1234", - name: "hunter", - client_id: "ci", - client_secret: "cs", - client_secret_expires_at: 0, - scopes: ["read"], - redirect_uris: ["https://one.example/cb", "https://two.example/cb"], - redirect_uri: "https://one.example/cb\nhttps://two.example/cb" - }) - end) - - app = - Hunter.create_app( - "hunter", - ["https://one.example/cb", "https://two.example/cb"], - ["read", "write"], - nil, - api_base_url: "https://mastodon.example" - ) - - # server values win over the requested ones - assert %Hunter.Application{ - client_secret_expires_at: 0, - scopes: ["read"], - redirect_uris: ["https://one.example/cb", "https://two.example/cb"], - redirect_uri: "https://one.example/cb\nhttps://two.example/cb" - } = app -end - -test "backfills scopes and redirect URIs on pre-4.3 responses" do - stub_request(fn conn -> - respond_with(conn, %{id: "1234", client_id: "ci", client_secret: "cs"}) - end) - - app = - Hunter.create_app("hunter", "urn:ietf:wg:oauth:2.0:oob", ["read"], nil, - api_base_url: "https://mastodon.example" - ) - - assert %Hunter.Application{ - scopes: ["read"], - redirect_uris: ["urn:ietf:wg:oauth:2.0:oob"], - redirect_uri: "urn:ietf:wg:oauth:2.0:oob" - } = app -end -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `mix test test/hunter/application_test.exs` -Expected: the 4.3 test fails (current code overwrites `scopes` with `["read", "write"]` and `redirect_uri` with the requested list); the backfill test fails on `redirect_uris` being `nil`. - -- [ ] **Step 3: Update `create_app/5`** - -Replace the body of `create_app/5` in `lib/hunter.ex` (keep the existing `@doc`; update its `redirect_uri` parameter line and example as shown): - -In the `@doc`, change the parameter line to: - -``` - * `redirect_uris` - where the user should be redirected after - authorization; a single URI or a list of URIs (Mastodon 4.3+), - default: `urn:ietf:wg:oauth:2.0:oob` (no redirect) -``` - -Then the code: - -```elixir -@spec create_app( - String.t(), - String.t() | [String.t()], - [String.t()], - nil | String.t(), - Keyword.t() - ) :: Hunter.Application.t() | no_return -def create_app( - client_name, - redirect_uris \\ "urn:ietf:wg:oauth:2.0:oob", - scopes \\ ["read"], - website \\ nil, - options \\ [] - ) do - {save?, options} = Keyword.pop(options, :save?, false) - base_url = Keyword.get(options, :api_base_url, Config.api_base_url()) - - payload = %{ - client_name: client_name, - redirect_uris: redirect_uris, - scopes: Enum.join(scopes, " "), - website: website - } - - %Hunter.Application{} = - app = Request.request!(base_url, :post, "/api/v1/apps", :application, payload) - - # Mastodon 4.3+ returns scopes/redirect_uris itself; only backfill the - # requested values for older servers that omit them - requested_uris = List.wrap(redirect_uris) - - app = %Hunter.Application{ - app - | scopes: app.scopes || scopes, - redirect_uris: app.redirect_uris || requested_uris, - redirect_uri: app.redirect_uri || List.first(requested_uris) - } - - if save?, do: save_credentials(client_name, app) - - app -end -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `mix test test/hunter/application_test.exs` -Expected: all pass — including the two pre-existing `create_app` tests (their stub responses omit `scopes`/`redirect_uris`, so backfill preserves their assertions). Then `mix test` — full suite green. - -- [ ] **Step 5: Commit** - -```bash -git add lib/hunter.ex test/hunter/application_test.exs -git commit -m "create_app: accept multiple redirect URIs, keep server CredentialApplication fields" -``` - ---- - -### Task 7: Discovery and OIDC — `oauth_server_metadata/1`, `userinfo/1` - -**Files:** -- Modify: `lib/hunter.ex` (insert after `log_in_app/2`) -- Modify: `test/hunter/oauth_test.exs` - -**Interfaces:** -- Consumes: transformer `nil` (raw decoded map) — same as `register_account/2`. -- Produces: `Hunter.oauth_server_metadata(base_url :: String.t()) :: map`; `Hunter.userinfo(conn :: Hunter.Client.t()) :: map` - -- [ ] **Step 1: Write the failing tests** - -Add to `test/hunter/oauth_test.exs`: - -```elixir -describe "oauth_server_metadata/1" do - test "fetches RFC 8414 metadata unauthenticated" do - stub_request(fn conn -> - assert conn.method == "GET" - assert conn.request_path == "/.well-known/oauth-authorization-server" - assert Plug.Conn.get_req_header(conn, "authorization") == [] - - respond_with(conn, %{ - issuer: "https://mastodon.example/", - authorization_endpoint: "https://mastodon.example/oauth/authorize", - token_endpoint: "https://mastodon.example/oauth/token", - scopes_supported: ["read", "write"], - code_challenge_methods_supported: ["S256"] - }) - end) - - metadata = Hunter.oauth_server_metadata("https://mastodon.example") - - assert metadata["issuer"] == "https://mastodon.example/" - assert metadata["code_challenge_methods_supported"] == ["S256"] - end -end - -describe "userinfo/1" do - test "fetches OIDC claims with the user token" do - stub_request(fn conn -> - assert conn.method == "GET" - assert conn.request_path == "/oauth/userinfo" - assert Plug.Conn.get_req_header(conn, "authorization") == ["Bearer 123456"] - - respond_with(conn, %{ - iss: "https://mastodon.example/", - sub: "https://mastodon.example/@kadaba", - preferred_username: "kadaba" - }) - end) - - conn = Hunter.new(base_url: "https://mastodon.example", access_token: "123456") - - assert %{"preferred_username" => "kadaba"} = Hunter.userinfo(conn) - end -end -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `mix test test/hunter/oauth_test.exs` -Expected: 2 failures with `UndefinedFunctionError`. - -- [ ] **Step 3: Implement both functions** - -In `lib/hunter.ex`, after `log_in_app/2`: - -```elixir -@doc """ -Fetch the instance's OAuth server metadata (RFC 8414) - -Use this to discover supported scopes, grant types and endpoints instead -of hardcoding them. Available since Mastodon 4.3. Does not require -authentication. - -## Parameters - - * `base_url` - API base url, default: `https://mastodon.social` - -Returns the decoded metadata map (`"issuer"`, `"authorization_endpoint"`, -`"token_endpoint"`, `"scopes_supported"`, -`"code_challenge_methods_supported"`, ...). -""" -@spec oauth_server_metadata(String.t()) :: map -def oauth_server_metadata(base_url \\ "https://mastodon.social") do - base_url = base_url || Config.api_base_url() - - Request.request!(base_url, :get, "/.well-known/oauth-authorization-server", nil) -end - -@doc """ -Fetch OIDC-style claims about the authenticated user - -Available since Mastodon 4.4; the token must carry the `profile` (or -`read`) scope. - -## Parameters - - * `conn` - connection credentials - -Returns the decoded claims map (`"iss"`, `"sub"`, `"name"`, -`"preferred_username"`, ...). -""" -@spec userinfo(Hunter.Client.t()) :: map -def userinfo(conn) do - Request.request!(conn, :get, "/oauth/userinfo", nil) -end -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `mix test test/hunter/oauth_test.exs` -Expected: all pass. Then `mix test` — full suite green. - -- [ ] **Step 5: Commit** - -```bash -git add lib/hunter.ex test/hunter/oauth_test.exs -git commit -m "Add oauth_server_metadata/1 and userinfo/1" -``` - ---- - -### Task 8: Remove `log_in/4`; update README and CHANGELOG - -**Files:** -- Modify: `lib/hunter.ex:1901-1941` (delete `log_in/4` — doc, spec, body) -- Modify: `test/hunter/client_test.exs` (delete the `describe "log_in/4"` block and the now-unused `@app` attribute if nothing else references it) -- Modify: `README.md:60-78` (the "Acquire an access token" section) -- Modify: `CHANGELOG.md` (Unreleased section) - -**Interfaces:** -- Consumes: everything from Tasks 1-7 (README documents the new flow). -- Produces: no `Hunter.log_in/4`; README shows the PKCE flow. - -- [ ] **Step 1: Delete `log_in/4` and its tests** - -Remove from `lib/hunter.ex` the whole block from `@doc """ Retrieve access token` through the end of `log_in/4` (currently lines 1901-1941). Remove the `describe "log_in/4"` block from `test/hunter/client_test.exs`; if `@app` is now unused in that file, remove it too. - -- [ ] **Step 2: Verify the suite still passes** - -Run: `mix test` -Expected: green, no warnings about `Hunter.log_in/4`. -Also run: `grep -rn "log_in(" lib README.md` — the only hits should be `log_in_oauth(` / `log_in_app(`. (`test/integration/mastodon_test.exs` still references it; Task 9 fixes that.) - -- [ ] **Step 3: Rewrite the README auth section** - -Replace README.md lines 60-78 (from `### Acquire an access token` through `Now you can use \`conn\` in any API request.`) with: - -````markdown -### Acquire an access token - -Mastodon uses the OAuth authorization-code flow, ideally with PKCE -(Mastodon 4.3+). Generate a challenge, send the user to the -authorization page, then exchange the code they bring back: - -```elixir -iex> pkce = Hunter.generate_pkce() -iex> Hunter.authorization_url(app, "https://example.com", -...> code_challenge: pkce.code_challenge, -...> code_challenge_method: pkce.code_challenge_method) -"https://example.com/oauth/authorize?response_type=code&client_id=..." -# the user authorizes there and receives a code -iex> conn = Hunter.log_in_oauth(app, "123456code", "https://example.com", -...> code_verifier: pkce.code_verifier) -%Hunter.Client{base_url: "https://example.com", - access_token: "123456"} -``` - -For app-level endpoints (registering accounts, verifying the app), use -the client-credentials grant: - -```elixir -iex> app_conn = Hunter.log_in_app(app, "https://example.com") -%Hunter.Client{base_url: "https://example.com", - access_token: "654321"} -iex> Hunter.verify_app_credentials(app_conn) -%Hunter.Application{name: "hunter", ...} -``` - -Tokens can be revoked when you are done with them: - -```elixir -iex> Hunter.revoke_token(app, conn.access_token, "https://example.com") -true -``` - -Now you can use `conn` in any API request. -```` - -- [ ] **Step 4: Update the CHANGELOG** - -In `CHANGELOG.md` under `## Unreleased`, add a Breaking changes section above Features, and extend Features: - -```markdown - * Breaking changes - - Remove `Hunter.log_in/4`: the OAuth password grant is no longer a - documented Mastodon flow. Use `Hunter.log_in_oauth/4` - (authorization code + PKCE) or `Hunter.log_in_app/2` - (client credentials) instead ([#126]) - - * Features - - OAuth modernization ([#126]): `revoke_token/3`, PKCE support - (`generate_pkce/0`, `authorization_url/3`, and a `code_verifier` - option on `log_in_oauth/4`), `log_in_app/2` (client-credentials - grant), `verify_app_credentials/1`, `oauth_server_metadata/1` - (RFC 8414) and `userinfo/1` (OIDC claims), all on `Hunter`. - `create_app/5` now accepts a list of redirect URIs and preserves - the server's `CredentialApplication` fields instead of - overwriting them -``` - -(Keep the existing `#124` Features bullet; add the new bullet alongside it.) The CHANGELOG uses reference-style links — add this line to the link block at the bottom of the file (around line 145), keeping it grouped with the other issue links: - -```markdown -[#126]: https://github.com/milmazz/hunter/issues/126 -``` - -- [ ] **Step 5: Run the suite and commit** - -Run: `mix test` -Expected: green. - -```bash -git add lib/hunter.ex test/hunter/client_test.exs README.md CHANGELOG.md -git commit -m "Remove the password grant (log_in/4)" -``` - ---- - -### Task 9: Integration coverage — provisioning script and tests - -**Files:** -- Modify: `scripts/ci/setup_mastodon.sh` -- Modify: `test/support/integration_case.ex` -- Modify: `test/integration/mastodon_test.exs` - -**Interfaces:** -- Consumes: all public functions from Tasks 1-8. -- Produces: env vars `HUNTER_OAUTH_PKCE_CODE`, `HUNTER_OAUTH_PKCE_VERIFIER`; integration context keys `pkce_code`, `pkce_verifier`. - -- [ ] **Step 1: Mint a PKCE-bound grant in `setup_mastodon.sh`** - -In `scripts/ci/setup_mastodon.sh`: - -(a) Change the `mint_token` scopes so `userinfo` works (two places inside the heredoc), from `'read write follow push'` to `'read write follow push profile'`, and the refresh guard from `unless app.scopes.to_s.include?('push')` to `unless app.scopes.to_s.include?('profile')`. Update the comment to `# profile scope added later; refresh pre-existing app/token rows in place`. Do the same for the token refresh condition — it already compares `token.scopes` to `app.scopes`, so no change needed there. - -(b) After the existing `OAUTH_PROVISION` block (line ~113), add: - -```bash -# A PKCE-bound grant: the verifier is generated here, its S256 challenge -# stored on the grant, and both travel to the test suite via env vars. -PKCE_VERIFIER=$(openssl rand -base64 32 | tr '+/' '-_' | tr -d '=') -PKCE_CHALLENGE=$(printf %s "$PKCE_VERIFIER" | openssl dgst -sha256 -binary | base64 | tr '+/' '-_' | tr -d '=') - -PKCE_CODE=$($COMPOSE exec -T -e PKCE_CHALLENGE="$PKCE_CHALLENGE" web bin/rails runner " - app = Doorkeeper::Application.find_by!(name: 'hunter-ci-oauth') - user = User.find_by!(email: 'kadaba@example.com') - grant = Doorkeeper::AccessGrant.create!( - application_id: app.id, resource_owner_id: user.id, - redirect_uri: app.redirect_uri, expires_in: 86_400, scopes: app.scopes.to_s, - code_challenge: ENV.fetch('PKCE_CHALLENGE'), code_challenge_method: 'S256' - ) - puts (grant.respond_to?(:plaintext_token) && grant.plaintext_token) || grant.token -" | tr -d '[:space:]') -``` - -(c) Add to the `$HUNTER_ENV` heredoc: - -```bash -export HUNTER_OAUTH_PKCE_CODE=$PKCE_CODE -export HUNTER_OAUTH_PKCE_VERIFIER=$PKCE_VERIFIER -``` - -(d) Add to the `$GITHUB_ENV` block: - -```bash - echo "HUNTER_OAUTH_PKCE_CODE=$PKCE_CODE" - echo "HUNTER_OAUTH_PKCE_VERIFIER=$PKCE_VERIFIER" -``` - -- [ ] **Step 2: Expose the new env vars in `Hunter.IntegrationCase`** - -In `test/support/integration_case.ex` `setup_all`, after the `oauth_code` fetch add: - -```elixir -pkce_code = fetch_env!("HUNTER_OAUTH_PKCE_CODE") -pkce_verifier = fetch_env!("HUNTER_OAUTH_PKCE_VERIFIER") -``` - -and extend the returned context: - -```elixir - oauth_code: oauth_code, - pkce_code: pkce_code, - pkce_verifier: pkce_verifier} -``` - -Also update the `@moduledoc` env list to mention `HUNTER_OAUTH_PKCE_CODE` and `HUNTER_OAUTH_PKCE_VERIFIER`. - -- [ ] **Step 3: Rework and add integration tests** - -In `test/integration/mastodon_test.exs`, replace the whole `"README auth flow: create_app + log_in yields a token that can write"` test (lines 433-455) with: - -```elixir -test "app credentials flow: create_app + log_in_app + verify_app_credentials", %{conn: conn} do - app_name = "hunter-auth-#{System.unique_integer([:positive])}" - - app = - Hunter.create_app(app_name, "urn:ietf:wg:oauth:2.0:oob", ["read", "write"], nil, - api_base_url: conn.base_url - ) - - assert %Hunter.Application{scopes: ["read", "write"]} = app - - app_conn = Hunter.log_in_app(app, conn.base_url) - assert %Hunter.Client{access_token: token} = app_conn - assert is_binary(token) - - assert %Hunter.Application{name: ^app_name} = Hunter.verify_app_credentials(app_conn) -end - -test "revoked app tokens stop working", %{conn: conn} do - app = - Hunter.create_app( - "hunter-revoke-#{System.unique_integer([:positive])}", - "urn:ietf:wg:oauth:2.0:oob", - ["read"], - nil, - api_base_url: conn.base_url - ) - - app_conn = Hunter.log_in_app(app, conn.base_url) - assert %Hunter.Application{} = Hunter.verify_app_credentials(app_conn) - - assert Hunter.revoke_token(app, app_conn.access_token, conn.base_url) == true - - assert_raise Hunter.Error, fn -> - Hunter.verify_app_credentials(app_conn) - end -end -``` - -After the existing `"OAuth authorization-code flow"` test (ends line 486), add: - -```elixir -test "PKCE authorization-code flow: verifier round-trips", %{ - conn: conn, - oauth_client_id: client_id, - oauth_client_secret: client_secret, - pkce_code: code, - pkce_verifier: verifier -} do - app = %Hunter.Application{ - client_id: client_id, - client_secret: client_secret, - scopes: ["read", "write"], - redirect_uri: "urn:ietf:wg:oauth:2.0:oob" - } - - logged_in = Hunter.log_in_oauth(app, code, conn.base_url, code_verifier: verifier) - assert %Hunter.Client{access_token: token} = logged_in - assert is_binary(token) - - %Status{id: id} = Hunter.create_status(logged_in, "pkce flow works #hunterci") - on_exit(fn -> destroy_quietly(logged_in, id) end) - Hunter.destroy_status(logged_in, id) -end - -test "oauth_server_metadata returns RFC 8414 metadata", %{conn: conn} do - metadata = Hunter.oauth_server_metadata(conn.base_url) - - assert is_map(metadata) - assert is_binary(metadata["issuer"]) - assert "S256" in metadata["code_challenge_methods_supported"] -end - -test "userinfo returns OIDC claims for the token's user", %{conn: conn} do - claims = Hunter.userinfo(conn) - - assert is_binary(claims["sub"]) - assert claims["preferred_username"] == "hunter" -end -``` - -- [ ] **Step 4: Verify** - -Run: `mix test` — unit suite green (integration tests are tagged out). - -If a local Docker daemon is available, run the integration suite: - -```bash -./scripts/ci/setup_mastodon.sh -source scripts/ci/.env.hunter -mix test --only integration -``` - -Expected: all integration tests pass. If Docker is not available, state that the integration run is pending CI — do not claim it passed. - -- [ ] **Step 5: Commit** - -```bash -git add scripts/ci/setup_mastodon.sh test/support/integration_case.ex test/integration/mastodon_test.exs -git commit -m "Integration coverage for PKCE, revocation, app credentials, discovery" -``` diff --git a/docs/superpowers/plans/2026-07-10-streaming.md b/docs/superpowers/plans/2026-07-10-streaming.md deleted file mode 100644 index 04ca4b6..0000000 --- a/docs/superpowers/plans/2026-07-10-streaming.md +++ /dev/null @@ -1,1196 +0,0 @@ -# Streaming API Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Real-time timeline/notification events over Mastodon's multiplexed WebSocket endpoint (issue #3): `Hunter.Streaming` with `connect/subscribe/unsubscribe/close/health?`, parsed entity payloads delivered to a subscriber pid. - -**Architecture:** One GenServer (`Hunter.Streaming.Connection`) owns the Mint conn + WebSocket state and performs the handshake synchronously in `init/1`; a pure module (`Hunter.Streaming.Event`) parses frames into entity structs via the existing `Hunter.Api.Transformer`. No supervision tree ships — callers supervise. No auto-reconnect in v1. - -**Tech Stack:** Elixir 1.16+, `mint_web_socket ~> 1.0` (new runtime dep; mint already present via Req/Finch), Poison for JSON. Tests: scripted in-process WebSocket server on `bandit` + `websock_adapter` (test-only deps), `Req.Test` stubs for `health?`, real Mastodon streaming service in CI. - -## Global Constraints - -- Spec: `docs/superpowers/specs/2026-07-10-streaming-design.md`. Branch: `streaming-3`. -- One module per file; folders mirror the module hierarchy (no nested `defmodule`). -- WebSocket only — no SSE endpoints, no auto-reconnect, no streaming-URL auto-discovery inside `connect/2`. -- Subscriber messages: `{:hunter_stream, connection_pid, %Hunter.Streaming.Event{}}` and `{:hunter_stream, connection_pid, {:closed, reason}}` with `reason :: {:remote, code} | {:error, term} | :local`; process exits `:normal` after any close. Server pings answered with pong, never surfaced. -- Stream names pass through verbatim — no client-side validation. -- Unknown event types are delivered with the raw payload, not dropped. -- `Hunter.EventStream` is deleted (breaking change, CHANGELOG under `## Unreleased`). -- Run `mix format` before every commit. Full gates: `mix test`, `mix format --check-formatted`, `mix credo`, `mix dialyzer`. -- Test output must be pristine (Bandit startup logs silenced with `startup_log: false`; expected `Logger.warning` output captured with `@tag :capture_log`). - -### Where things live (orientation for every task) - -- `lib/hunter/api/transformer.ex` — `Hunter.Api.Transformer.transform/2` clauses keyed by target atom; existing targets used here: `:status`, `:notification`, `:conversation`, `:announcement`. Entities decode with `Poison.decode!(body, as: struct)`. -- `lib/hunter/config.ex` — `Hunter.Config.req_options/0` returns the app-configured Req options keyword; the test env sets `plug: {Req.Test, Hunter.ReqStub}` (see `test/test_helper.exs`), so any Req call merging these options is stubbable with the `Hunter.ReqCase` helpers (`stub_request/1`, `respond_with/2-3`). -- `Hunter.Client` struct: exactly `%Hunter.Client{base_url: String.t(), access_token: String.t()}`. Tests construct it via `Hunter.new(base_url: ..., access_token: ...)`. -- Fixtures: `test/fixtures/status.json` (id `"103270115826048975"`), `notification.json` (type `"mention"`), `conversation.json` (id `"418450"`), `announcement.json` (id `"8"`, has a `reactions` array with `name: "bongoCat"`). -- `Hunter.Announcement.Reaction` struct exists at `lib/hunter/announcement/reaction.ex`. -- Integration tests: `test/integration/mastodon_test.exs` on `Hunter.IntegrationCase` (`@moduletag :integration`, excluded by default; setup_all provides `conn`/`conn2` against `HUNTER_BASE_URL` = `https://localhost:3000`, a self-signed-TLS nginx in front of Mastodon v4.3.8 — Req calls get `verify: :verify_none` via `req_options`, but `Hunter.Streaming.connect/2` talks Mint directly and needs its own `transport_opts: [verify: :verify_none]`). -- Mastodon WS frame shape: `{"stream": ["user"], "event": "update", "payload": ""}` — payload is double-encoded JSON except for `delete`/`announcement.delete` (plain id string) and payload-less events (`filters_changed`, `notifications_merged`). - ---- - -### Task 1: `Hunter.Streaming.Event` + `:announcement_reaction` transformer clause - -**Files:** -- Create: `lib/hunter/streaming/event.ex` -- Modify: `lib/hunter/api/transformer.ex` -- Test: `test/hunter/streaming/event_test.exs` - -**Interfaces:** -- Consumes: `Hunter.Api.Transformer.transform/2` targets `:status`, `:notification`, `:conversation`, `:announcement`, and the new `:announcement_reaction`. -- Produces: `Hunter.Streaming.Event.t` = `%Hunter.Streaming.Event{streams: [String.t()], type: String.t(), payload: term}`; `Hunter.Streaming.Event.parse(binary) :: {:ok, t} | {:error, term}`. Tasks 3, 4, and 6 rely on both. - -- [ ] **Step 1: Write the failing tests** - -Create `test/hunter/streaming/event_test.exs`: - -```elixir -defmodule Hunter.Streaming.EventTest do - use ExUnit.Case, async: true - - alias Hunter.Streaming.Event - - test "parses an update into a Status" do - assert {:ok, event} = Event.parse(frame("update", fixture("status"))) - - assert %Event{streams: ["user"], type: "update"} = event - assert %Hunter.Status{id: "103270115826048975", visibility: "public"} = event.payload - assert %Hunter.Account{username: "milmazz"} = event.payload.account - end - - test "parses a status.update into a Status" do - assert {:ok, %Event{type: "status.update", payload: %Hunter.Status{}}} = - Event.parse(frame("status.update", fixture("status"))) - end - - test "parses a notification into a Notification" do - assert {:ok, %Event{type: "notification", payload: payload}} = - Event.parse(frame("notification", fixture("notification"))) - - assert %Hunter.Notification{type: "mention"} = payload - end - - test "parses a conversation into a Conversation" do - assert {:ok, %Event{payload: %Hunter.Conversation{id: "418450"}}} = - Event.parse(frame("conversation", fixture("conversation"))) - end - - test "parses an announcement into an Announcement" do - assert {:ok, %Event{payload: %Hunter.Announcement{id: "8"}}} = - Event.parse(frame("announcement", fixture("announcement"))) - end - - test "parses an announcement.reaction into a Reaction" do - payload = ~s({"name": "bongoCat", "count": 9, "announcement_id": "8"}) - - assert {:ok, %Event{payload: %Hunter.Announcement.Reaction{name: "bongoCat", count: 9}}} = - Event.parse(frame("announcement.reaction", payload)) - end - - test "delete and announcement.delete carry the id string" do - assert {:ok, %Event{payload: "103270115826048975"}} = - Event.parse(frame("delete", "103270115826048975")) - - assert {:ok, %Event{payload: "8"}} = - Event.parse(frame("announcement.delete", "8")) - end - - test "payload-less events have a nil payload" do - assert {:ok, %Event{type: "filters_changed", payload: nil}} = - Event.parse(~s({"stream": ["user"], "event": "filters_changed"})) - - assert {:ok, %Event{type: "notifications_merged", payload: nil}} = - Event.parse(~s({"stream": ["user"], "event": "notifications_merged"})) - end - - test "unknown event types pass the payload through undecoded" do - assert {:ok, %Event{type: "brand.new", payload: "whatever"}} = - Event.parse(frame("brand.new", "whatever")) - end - - test "rejects malformed frames" do - assert {:error, _} = Event.parse("not json") - assert {:error, _} = Event.parse(~s({"stream": ["user"]})) - assert {:error, _} = Event.parse(frame("update", "not a status")) - end - - # Mastodon frames double-encode the payload: it is a JSON *string*. - defp frame(type, payload) do - Poison.encode!(%{"stream" => ["user"], "event" => type, "payload" => payload}) - end - - defp fixture(name) do - [__DIR__, "..", "..", "fixtures", name <> ".json"] - |> Path.join() - |> Path.expand() - |> File.read!() - end -end -``` - -- [ ] **Step 2: Run the tests to verify they fail** - -Run: `mix test test/hunter/streaming/event_test.exs` -Expected: FAIL — `Hunter.Streaming.Event` is not defined (compile error or UndefinedFunctionError). - -- [ ] **Step 3: Create the Event module** - -Create `lib/hunter/streaming/event.ex`: - -```elixir -defmodule Hunter.Streaming.Event do - @moduledoc """ - A parsed event from Mastodon's streaming WebSocket - - ## Fields - - * `streams` - the stream names this event was delivered on - * `type` - the event type, e.g. `"update"`, `"notification"`, `"delete"` - * `payload` - the decoded payload: an entity struct for known types, the - bare id string for `delete`/`announcement.delete`, `nil` for - payload-less events, or the raw payload for unknown types - - """ - - alias Hunter.Api.Transformer - - @type t :: %__MODULE__{ - streams: [String.t()], - type: String.t(), - payload: term - } - - defstruct [:streams, :type, :payload] - - @doc """ - Parses a raw WebSocket text frame into an event. - - Mastodon frames look like - `{"stream": ["user"], "event": "update", "payload": ""}` — - the payload is itself JSON-encoded, except for `delete` (a bare id) and - payload-less events. Unknown event types are passed through with the raw - payload so new server-side types keep flowing. - """ - @spec parse(binary) :: {:ok, t} | {:error, term} - def parse(frame) when is_binary(frame) do - case Poison.decode(frame) do - {:ok, %{"event" => type} = decoded} -> - payload = decode_payload(type, Map.get(decoded, "payload")) - {:ok, %__MODULE__{streams: Map.get(decoded, "stream", []), type: type, payload: payload}} - - {:ok, _other} -> - {:error, :missing_event} - - {:error, reason} -> - {:error, reason} - end - rescue - exception -> {:error, exception} - end - - defp decode_payload(_type, nil), do: nil - - defp decode_payload(type, payload) when type in ["update", "status.update"], - do: Transformer.transform(payload, :status) - - defp decode_payload("notification", payload), do: Transformer.transform(payload, :notification) - - defp decode_payload("conversation", payload), do: Transformer.transform(payload, :conversation) - - defp decode_payload("announcement", payload), do: Transformer.transform(payload, :announcement) - - defp decode_payload("announcement.reaction", payload), - do: Transformer.transform(payload, :announcement_reaction) - - defp decode_payload(type, payload) when type in ["delete", "announcement.delete"], do: payload - - defp decode_payload(_unknown, payload), do: payload -end -``` - -Note: `Transformer.transform/2` raises on invalid JSON (`Poison.decode!` inside); the `rescue` in `parse/1` converts that to `{:error, exception}` — this is what makes the `frame("update", "not a status")` test pass. - -- [ ] **Step 4: Add the transformer clause** - -In `lib/hunter/api/transformer.ex`, add directly after the `transform(body, :announcements)` clause: - -```elixir -def transform(body, :announcement_reaction), - do: Poison.decode!(body, as: %Hunter.Announcement.Reaction{}) -``` - -- [ ] **Step 5: Run the tests to verify they pass** - -Run: `mix test test/hunter/streaming/event_test.exs` -Expected: PASS (10 tests). - -- [ ] **Step 6: Format, run the full suite, commit** - -```bash -mix format -mix test -git add lib/hunter/streaming/event.ex lib/hunter/api/transformer.ex test/hunter/streaming/event_test.exs -git commit -m "feat: add Hunter.Streaming.Event frame parser" -``` - ---- - -### Task 2: `Hunter.Streaming.health?/2` - -**Files:** -- Create: `lib/hunter/streaming.ex` -- Test: `test/hunter/streaming_test.exs` - -**Interfaces:** -- Consumes: `Hunter.Config.req_options/0`, `Req.request/1`, `Hunter.Client` struct. -- Produces: `Hunter.Streaming.health?(Hunter.Client.t(), Keyword.t()) :: boolean` and the private `http_base_url/2` helper pattern. Task 3 adds `connect/2` and friends to this same file; Task 6 calls `health?/1` in integration. - -- [ ] **Step 1: Write the failing tests** - -Create `test/hunter/streaming_test.exs`: - -```elixir -defmodule Hunter.StreamingTest do - use Hunter.ReqCase, async: true - - @conn Hunter.new(base_url: "https://mastodon.example", access_token: "123456") - - describe "health?/2" do - test "is true when the streaming server answers OK" do - stub_request(fn conn -> - assert conn.method == "GET" - assert conn.request_path == "/api/v1/streaming/health" - respond_with(conn, "OK") - end) - - assert Hunter.Streaming.health?(@conn) - end - - test "is false on any other response" do - stub_request(fn conn -> respond_with(conn, "no", 404) end) - - refute Hunter.Streaming.health?(@conn) - end - - test "resolves a ws(s) url override to http(s)" do - stub_request(fn conn -> - assert conn.host == "streaming.example" - respond_with(conn, "OK") - end) - - assert Hunter.Streaming.health?(@conn, url: "wss://streaming.example") - end - end -end -``` - -Note: `respond_with(conn, "OK")` sends the body as-is (binary passthrough in `Hunter.ReqCase`), matching the real endpoint's plain-text response. - -- [ ] **Step 2: Run the tests to verify they fail** - -Run: `mix test test/hunter/streaming_test.exs` -Expected: FAIL — `Hunter.Streaming` is not defined. - -- [ ] **Step 3: Create the module** - -Create `lib/hunter/streaming.ex`: - -```elixir -defmodule Hunter.Streaming do - @moduledoc """ - Real-time events over Mastodon's multiplexed streaming WebSocket. - - `connect/2` opens a connection process linked to the caller; parsed - events arrive in the subscriber's mailbox as - `{:hunter_stream, connection_pid, %Hunter.Streaming.Event{}}` and a - single `{:hunter_stream, connection_pid, {:closed, reason}}` is sent - when the socket closes. There is no automatic reconnection: supervise - and restart the connection from the consuming application. - - Instances may serve streaming from a different host than the REST API; - discover it via `Hunter.instance_info/1` under - `configuration["urls"]["streaming"]` and pass it as the `:url` option. - """ - - alias Hunter.Config - - @doc """ - Checks the streaming server's health endpoint (Mastodon 2.5+). - - ## Parameters - - * `conn` - connection credentials - * `opts` - `url:` overrides the streaming base URL (`ws://`/`wss://` - accepted and mapped to `http://`/`https://`) - - Returns `true` only for a 200 response with an `OK` body; transport - errors return `false`. - """ - @spec health?(Hunter.Client.t(), Keyword.t()) :: boolean - def health?(%Hunter.Client{} = conn, opts \\ []) do - request = - [ - method: :get, - url: http_base_url(conn, opts) <> "/api/v1/streaming/health", - decode_body: false, - retry: false - ] ++ Config.req_options() - - case Req.request(request) do - {:ok, %Req.Response{status: 200, body: body}} when is_binary(body) -> - String.trim(body) == "OK" - - _other -> - false - end - end - - defp http_base_url(%Hunter.Client{base_url: base_url}, opts) do - case Keyword.fetch(opts, :url) do - {:ok, url} -> - url - |> String.replace_prefix("wss://", "https://") - |> String.replace_prefix("ws://", "http://") - |> String.trim_trailing("/") - - :error -> - String.trim_trailing(base_url, "/") - end - end -end -``` - -- [ ] **Step 4: Run the tests to verify they pass** - -Run: `mix test test/hunter/streaming_test.exs` -Expected: PASS (3 tests). - -- [ ] **Step 5: Format, run the full suite, commit** - -```bash -mix format -mix test -git add lib/hunter/streaming.ex test/hunter/streaming_test.exs -git commit -m "feat: add Hunter.Streaming.health?/2" -``` - ---- - -### Task 3: Connection handshake + event delivery (`connect/2`) - -**Files:** -- Modify: `mix.exs` (deps) -- Create: `lib/hunter/streaming/connection.ex` -- Create: `test/support/streaming_server.ex` -- Create: `test/support/streaming_server/handler.ex` -- Modify: `lib/hunter/streaming.ex` (add `connect/2` + `ws_uri/2`) -- Test: `test/hunter/streaming_test.exs` - -**Interfaces:** -- Consumes: `Hunter.Streaming.Event.parse/1` (Task 1), the `Hunter.Streaming` module file (Task 2). -- Produces: `Hunter.Streaming.connect(Hunter.Client.t(), Keyword.t()) :: {:ok, pid} | {:error, term}` with options `streams:` (list of `String.t()` or `{String.t(), Keyword.t()}`), `subscriber:` (pid, default `self()`), `url:` (ws(s) base URL override), `transport_opts:` (Mint transport options, e.g. `[verify: :verify_none]`); `Hunter.Streaming.Connection.start_link/1` taking `uri:, subscriber:, streams:, transport_opts:`; internal GenServer calls `{:control, type, stream, params}` and `:close` (Task 4 implements their public wrappers); the `Hunter.StreamingServer` test helper (`start/1` returning `{server_pid, port}`). Tasks 4 and 6 rely on all of this. - -- [ ] **Step 1: Add the dependencies** - -In `mix.exs`, change the deps list to: - -```elixir -defp deps do - [ - {:req, "~> 0.6"}, - {:poison, "~> 6.0"}, - {:mint_web_socket, "~> 1.0"}, - {:plug, "~> 1.16", only: :test}, - {:bandit, "~> 1.0", only: :test}, - {:websock_adapter, "~> 0.5", only: :test}, - {:ex_doc, "~> 0.40", only: :dev, runtime: false}, - {:dialyxir, "~> 1.0", only: :dev, runtime: false}, - {:credo, "~> 1.6", only: [:dev, :test], runtime: false} - ] -end -``` - -Run: `mix deps.get && mix compile` -Expected: deps resolve (mint is already in the lock via finch; mint_web_socket rides on it), clean compile. - -- [ ] **Step 2: Create the scripted WebSocket test server** - -Create `test/support/streaming_server.ex`: - -```elixir -defmodule Hunter.StreamingServer do - @moduledoc """ - In-process WebSocket server for streaming unit tests. - - `start/1` boots a Bandit listener on a random port whose plug reports the - upgrade request to the test process and hands the socket to - `Hunter.StreamingServer.Handler`. The test process receives: - - * `{:ws_request, path, query_params}` - the HTTP upgrade request - * `{:ws_connected, handler_pid}` - the socket is up; message this pid - to script the server (see the handler docs) - * `{:ws_frame, decoded_json}` - each text frame the client sent - * `{:ws_pong, data}` - the client answered a ping - - """ - - @behaviour Plug - - import Plug.Conn - - @impl Plug - def init(opts), do: opts - - @impl Plug - def call(conn, test_pid: test_pid) do - conn = fetch_query_params(conn) - send(test_pid, {:ws_request, conn.request_path, conn.query_params}) - - WebSockAdapter.upgrade(conn, Hunter.StreamingServer.Handler, %{test_pid: test_pid}, []) - end - - @doc """ - Starts the server under the test supervisor; returns `{pid, port}`. - """ - def start(test_pid) do - {:ok, server} = - Bandit.start_link( - plug: {__MODULE__, test_pid: test_pid}, - port: 0, - ip: :loopback, - startup_log: false - ) - - {:ok, {_ip, port}} = ThousandIsland.listener_info(server) - {server, port} - end -end -``` - -Create `test/support/streaming_server/handler.ex`: - -```elixir -defmodule Hunter.StreamingServer.Handler do - @moduledoc """ - Scriptable WebSock handler for `Hunter.StreamingServer`. - - Tests drive the socket by messaging the handler pid announced via - `{:ws_connected, pid}`: - - * `{:push_text, binary}` - send a text frame to the client - * `:ping_client` - send a ping frame - * `{:close, code}` - close the socket with `code` - - """ - - @behaviour WebSock - - @impl WebSock - def init(%{test_pid: test_pid} = state) do - send(test_pid, {:ws_connected, self()}) - {:ok, state} - end - - @impl WebSock - def handle_in({data, [opcode: :text]}, state) do - send(state.test_pid, {:ws_frame, Poison.decode!(data)}) - {:ok, state} - end - - @impl WebSock - def handle_info({:push_text, data}, state), do: {:push, {:text, data}, state} - def handle_info(:ping_client, state), do: {:push, {:ping, "hb"}, state} - def handle_info({:close, code}, state), do: {:stop, :normal, code, state} - - @impl WebSock - def handle_control({data, [opcode: :pong]}, state) do - send(state.test_pid, {:ws_pong, data}) - {:ok, state} - end - - def handle_control(_frame, state), do: {:ok, state} - - @impl WebSock - def terminate(_reason, _state), do: :ok -end -``` - -- [ ] **Step 3: Write the failing tests** - -Append to `test/hunter/streaming_test.exs` (inside the outer module, after the `health?/2` describe block): - -```elixir - describe "connect/2" do - test "handshakes with the access token and subscribes initial streams" do - {_server, port} = Hunter.StreamingServer.start(self()) - - assert {:ok, pid} = Hunter.Streaming.connect(client(), url: "ws://localhost:#{port}", streams: ["user", {"hashtag", tag: "elixir"}]) - - assert_receive {:ws_request, "/api/v1/streaming", %{"access_token" => "123456"}} - assert_receive {:ws_connected, _ws} - assert_receive {:ws_frame, %{"type" => "subscribe", "stream" => "user"}} - assert_receive {:ws_frame, %{"type" => "subscribe", "stream" => "hashtag", "tag" => "elixir"}} - assert Process.alive?(pid) - end - - test "delivers parsed events to the subscriber" do - {_server, port} = Hunter.StreamingServer.start(self()) - {:ok, pid} = Hunter.Streaming.connect(client(), url: "ws://localhost:#{port}") - assert_receive {:ws_connected, ws} - - status_json = File.read!(Path.expand(Path.join([__DIR__, "..", "fixtures", "status.json"]))) - frame = Poison.encode!(%{"stream" => ["user"], "event" => "update", "payload" => status_json}) - send(ws, {:push_text, frame}) - - assert_receive {:hunter_stream, ^pid, %Hunter.Streaming.Event{type: "update", payload: %Hunter.Status{id: "103270115826048975"}}} - end - - @tag :capture_log - test "skips malformed frames and stays connected" do - {_server, port} = Hunter.StreamingServer.start(self()) - {:ok, pid} = Hunter.Streaming.connect(client(), url: "ws://localhost:#{port}") - assert_receive {:ws_connected, ws} - - send(ws, {:push_text, "not json"}) - send(ws, {:push_text, Poison.encode!(%{"stream" => ["user"], "event" => "update", "payload" => File.read!(Path.expand(Path.join([__DIR__, "..", "fixtures", "status.json"])))})}) - - assert_receive {:hunter_stream, ^pid, %Hunter.Streaming.Event{type: "update"}} - assert Process.alive?(pid) - end - - test "returns an error when the endpoint refuses the upgrade" do - # Nothing is listening on this port. - assert {:error, _reason} = Hunter.Streaming.connect(client(), url: "ws://localhost:9") - end - end - - defp client, do: Hunter.new(base_url: "https://mastodon.example", access_token: "123456") -``` - -- [ ] **Step 4: Run the tests to verify they fail** - -Run: `mix test test/hunter/streaming_test.exs` -Expected: the `health?/2` tests still PASS; the four `connect/2` tests FAIL with `UndefinedFunctionError` for `Hunter.Streaming.connect/2` (and `Hunter.StreamingServer` compiles from test/support). - -- [ ] **Step 5: Create the Connection GenServer** - -Create `lib/hunter/streaming/connection.ex`: - -```elixir -defmodule Hunter.Streaming.Connection do - # Owns the Mint conn + WebSocket state for one streaming connection. - # Started via Hunter.Streaming.connect/2; not part of the public API. - @moduledoc false - - use GenServer - - require Logger - - alias Hunter.Streaming.Event - - @handshake_timeout 15_000 - - defstruct [:conn, :websocket, :ref, :subscriber] - - def start_link(opts), do: GenServer.start_link(__MODULE__, opts) - - @impl GenServer - def init(opts) do - uri = Keyword.fetch!(opts, :uri) - subscriber = Keyword.fetch!(opts, :subscriber) - streams = Keyword.get(opts, :streams, []) - transport_opts = Keyword.get(opts, :transport_opts, []) - - {http_scheme, ws_scheme} = schemes(uri.scheme) - path = uri.path <> "?" <> uri.query - - with {:ok, conn} <- - Mint.HTTP.connect(http_scheme, uri.host, uri.port, - protocols: [:http1], - transport_opts: transport_opts - ), - {:ok, conn, ref} <- Mint.WebSocket.upgrade(ws_scheme, conn, path, []), - {:ok, conn, status, headers} <- await_upgrade(conn, ref), - {:ok, conn, websocket} <- Mint.WebSocket.new(conn, ref, status, headers) do - state = %__MODULE__{conn: conn, websocket: websocket, ref: ref, subscriber: subscriber} - - case subscribe_initial(state, streams) do - {:ok, state} -> {:ok, state} - {:error, reason} -> {:stop, reason} - end - else - {:error, reason} -> {:stop, reason} - {:error, _conn, reason} -> {:stop, reason} - end - end - - @impl GenServer - def handle_call({:control, type, stream, params}, _from, state) do - frame = - params - |> Map.new(fn {key, value} -> {to_string(key), to_string(value)} end) - |> Map.merge(%{"type" => type, "stream" => stream}) - - case send_frame(state, {:text, Poison.encode!(frame)}) do - {:ok, state} -> {:reply, :ok, state} - {:error, state, reason} -> stop_with(state, {:error, reason}, {:reply, :ok}) - end - end - - def handle_call(:close, _from, state) do - case send_frame(state, {:close, 1_000, ""}) do - {:ok, state} -> stop_with(state, :local, {:reply, :ok}) - {:error, state, _reason} -> stop_with(state, :local, {:reply, :ok}) - end - end - - @impl GenServer - def handle_info(message, state) do - case Mint.WebSocket.stream(state.conn, message) do - {:ok, conn, entries} -> - handle_entries(entries, %{state | conn: conn}) - - {:error, conn, reason, _responses} -> - stop_with(%{state | conn: conn}, {:error, reason}, :noreply) - - :unknown -> - {:noreply, state} - end - end - - defp handle_entries(entries, state) do - frames = - for {:data, ref, data} <- entries, ref == state.ref do - data - end - - Enum.reduce_while(frames, {:noreply, state}, fn data, {:noreply, state} -> - case decode_frames(state, data) do - {:noreply, state} -> {:cont, {:noreply, state}} - stop -> {:halt, stop} - end - end) - end - - defp decode_frames(state, data) do - case Mint.WebSocket.decode(state.websocket, data) do - {:ok, websocket, frames} -> - dispatch_frames(frames, %{state | websocket: websocket}) - - {:error, websocket, reason} -> - Logger.warning("Hunter.Streaming: undecodable data: #{inspect(reason)}") - {:noreply, %{state | websocket: websocket}} - end - end - - defp dispatch_frames([], state), do: {:noreply, state} - - defp dispatch_frames([{:text, text} | rest], state) do - case Event.parse(text) do - {:ok, event} -> - send(state.subscriber, {:hunter_stream, self(), event}) - - {:error, reason} -> - Logger.warning("Hunter.Streaming: skipping malformed frame: #{inspect(reason)}") - end - - dispatch_frames(rest, state) - end - - defp dispatch_frames([{:ping, data} | rest], state) do - case send_frame(state, {:pong, data}) do - {:ok, state} -> dispatch_frames(rest, state) - {:error, state, reason} -> stop_with(state, {:error, reason}, :noreply) - end - end - - defp dispatch_frames([{:close, code, _reason} | _rest], state) do - stop_with(state, {:remote, code}, :noreply) - end - - defp dispatch_frames([_other | rest], state), do: dispatch_frames(rest, state) - - defp subscribe_initial(state, streams) do - Enum.reduce_while(streams, {:ok, state}, fn spec, {:ok, state} -> - {stream, params} = - case spec do - {stream, params} -> {stream, params} - stream when is_binary(stream) -> {stream, []} - end - - frame = - params - |> Map.new(fn {key, value} -> {to_string(key), to_string(value)} end) - |> Map.merge(%{"type" => "subscribe", "stream" => stream}) - - case send_frame(state, {:text, Poison.encode!(frame)}) do - {:ok, state} -> {:cont, {:ok, state}} - {:error, _state, reason} -> {:halt, {:error, reason}} - end - end) - end - - defp send_frame(state, frame) do - with {:ok, websocket, data} <- Mint.WebSocket.encode(state.websocket, frame), - state = %{state | websocket: websocket}, - {:ok, conn} <- Mint.WebSocket.stream_request_body(state.conn, state.ref, data) do - {:ok, %{state | conn: conn}} - else - {:error, %Mint.WebSocket{} = websocket, reason} -> - {:error, %{state | websocket: websocket}, reason} - - {:error, conn, reason} -> - {:error, %{state | conn: conn}, reason} - end - end - - defp stop_with(state, reason, reply_or_noreply) do - send(state.subscriber, {:hunter_stream, self(), {:closed, reason}}) - Mint.HTTP.close(state.conn) - - case reply_or_noreply do - {:reply, value} -> {:stop, :normal, value, state} - :noreply -> {:stop, :normal, state} - end - end - - defp await_upgrade(conn, ref, status \\ nil, headers \\ nil) do - receive do - message -> - case Mint.WebSocket.stream(conn, message) do - {:ok, conn, entries} -> - status = List.keyfind(entries, :status, 0, {nil, nil, status}) |> elem(2) - headers = List.keyfind(entries, :headers, 0, {nil, nil, headers}) |> elem(2) - - if List.keymember?(entries, :done, 0) do - {:ok, conn, status, headers} - else - await_upgrade(conn, ref, status, headers) - end - - {:error, _conn, reason, _responses} -> - {:error, reason} - - :unknown -> - await_upgrade(conn, ref, status, headers) - end - after - @handshake_timeout -> {:error, :handshake_timeout} - end - end - - defp schemes("wss"), do: {:https, :wss} - defp schemes("ws"), do: {:http, :ws} -end -``` - -- [ ] **Step 6: Add `connect/2` to `Hunter.Streaming`** - -In `lib/hunter/streaming.ex`, add `alias Hunter.Streaming.Connection` next to the existing `alias Hunter.Config`, then add above `health?/2`: - -```elixir -@doc """ -Opens a streaming WebSocket connection linked to the caller. - -## Parameters - - * `conn` - connection credentials - * `opts` - option list - -## Options - - * `streams` - initial subscriptions: stream names or `{name, params}` - tuples, e.g. `["user", {"hashtag", tag: "elixir"}]` - * `subscriber` - pid that receives events, default: the caller - * `url` - streaming base URL override, e.g. `"wss://streaming.example"` - (see the module docs for discovery) - * `transport_opts` - Mint transport options, e.g. - `[verify: :verify_none]` for self-signed certificates - -""" -@spec connect(Hunter.Client.t(), Keyword.t()) :: {:ok, pid} | {:error, term} -def connect(%Hunter.Client{} = conn, opts \\ []) do - case Connection.start_link( - uri: ws_uri(conn, opts), - subscriber: Keyword.get(opts, :subscriber, self()), - streams: Keyword.get(opts, :streams, []), - transport_opts: Keyword.get(opts, :transport_opts, []) - ) do - {:ok, pid} -> {:ok, pid} - {:error, reason} -> {:error, reason} - end -end - -defp ws_uri(%Hunter.Client{base_url: base_url, access_token: token}, opts) do - base = - case Keyword.fetch(opts, :url) do - {:ok, url} -> - String.trim_trailing(url, "/") - - :error -> - base_url - |> String.trim_trailing("/") - |> String.replace_prefix("https://", "wss://") - |> String.replace_prefix("http://", "ws://") - end - - uri = URI.parse(base <> "/api/v1/streaming") - %{uri | query: URI.encode_query(access_token: token), port: uri.port} -end -``` - -Note: Elixir's `URI` knows the default ports for `ws` (80) and `wss` (443), so `uri.port` is always set. - -- [ ] **Step 7: Run the tests to verify they pass** - -Run: `mix test test/hunter/streaming_test.exs` -Expected: PASS (7 tests, no Bandit startup noise, no stray logs outside the `:capture_log` test). - -- [ ] **Step 8: Format, run the full suite, commit** - -```bash -mix format -mix test -git add mix.exs mix.lock lib/hunter/streaming.ex lib/hunter/streaming/connection.ex test/support/streaming_server.ex test/support/streaming_server/handler.ex test/hunter/streaming_test.exs -git commit -m "feat: streaming WebSocket connection with event delivery" -``` - ---- - -### Task 4: `subscribe/3`, `unsubscribe/3`, `close/1`, ping/pong, close paths - -**Files:** -- Modify: `lib/hunter/streaming.ex` -- Test: `test/hunter/streaming_test.exs` - -**Interfaces:** -- Consumes: the GenServer calls `{:control, type, stream, params}` and `:close` implemented in Task 3's `Hunter.Streaming.Connection`, and the `Hunter.StreamingServer` scripting messages (`:ping_client`, `{:close, code}`). -- Produces: `Hunter.Streaming.subscribe(pid, String.t(), Keyword.t()) :: :ok`, `unsubscribe/3` same shape, `close(pid) :: :ok`. Task 6 uses `close/1`. - -- [ ] **Step 1: Write the failing tests** - -Append to `test/hunter/streaming_test.exs` (a new describe block after `connect/2`; uses the existing `client/0` helper): - -```elixir - describe "runtime control and close paths" do - setup do - {_server, port} = Hunter.StreamingServer.start(self()) - {:ok, pid} = Hunter.Streaming.connect(client(), url: "ws://localhost:#{port}") - assert_receive {:ws_connected, ws} - %{pid: pid, ws: ws} - end - - test "subscribe/3 and unsubscribe/3 send control frames", %{pid: pid} do - assert :ok = Hunter.Streaming.subscribe(pid, "list", list: "12") - assert_receive {:ws_frame, %{"type" => "subscribe", "stream" => "list", "list" => "12"}} - - assert :ok = Hunter.Streaming.unsubscribe(pid, "list", list: "12") - assert_receive {:ws_frame, %{"type" => "unsubscribe", "stream" => "list", "list" => "12"}} - end - - test "answers server pings with pong, never surfacing them", %{pid: pid, ws: ws} do - send(ws, :ping_client) - - assert_receive {:ws_pong, "hb"} - refute_receive {:hunter_stream, ^pid, _}, 100 - assert Process.alive?(pid) - end - - test "server close delivers {:closed, {:remote, code}} and exits normally", %{pid: pid, ws: ws} do - ref = Process.monitor(pid) - send(ws, {:close, 4_000}) - - assert_receive {:hunter_stream, ^pid, {:closed, {:remote, 4_000}}} - assert_receive {:DOWN, ^ref, :process, ^pid, :normal} - end - - test "server going away delivers {:closed, {:error, reason}}", %{pid: pid, ws: ws} do - ref = Process.monitor(pid) - Process.exit(ws, :kill) - - assert_receive {:hunter_stream, ^pid, {:closed, _reason}} - assert_receive {:DOWN, ^ref, :process, ^pid, :normal} - end - - test "close/1 sends a close frame and delivers {:closed, :local}", %{pid: pid} do - ref = Process.monitor(pid) - - assert :ok = Hunter.Streaming.close(pid) - assert_receive {:hunter_stream, ^pid, {:closed, :local}} - assert_receive {:DOWN, ^ref, :process, ^pid, :normal} - end - end -``` - -Note on the kill test: depending on timing the transport error may surface as `{:error, %Mint.TransportError{reason: :closed}}` or as a remote close — the assertion only pins that a `{:closed, _}` message arrives and the exit is `:normal`. - -- [ ] **Step 2: Run the tests to verify they fail** - -Run: `mix test test/hunter/streaming_test.exs` -Expected: the new describe block FAILS with `UndefinedFunctionError` for `Hunter.Streaming.subscribe/3` (the first test) — the ping/close-path tests may already pass since Task 3 shipped the Connection internals; that is expected and fine (they lock the behavior in). - -- [ ] **Step 3: Add the public wrappers** - -In `lib/hunter/streaming.ex`, add after `connect/2`: - -```elixir -@doc """ -Subscribes the connection to a stream at runtime. - -## Parameters - - * `pid` - the connection from `connect/2` - * `stream` - stream name, passed through verbatim (e.g. `"user"`, - `"public:local"`, `"hashtag"`, `"list"`) - * `params` - stream parameters, e.g. `tag: "elixir"` or `list: "12"` - -""" -@spec subscribe(pid, String.t(), Keyword.t()) :: :ok -def subscribe(pid, stream, params \\ []) do - GenServer.call(pid, {:control, "subscribe", stream, params}) -end - -@doc """ -Unsubscribes the connection from a stream at runtime; same arguments as -`subscribe/3`. -""" -@spec unsubscribe(pid, String.t(), Keyword.t()) :: :ok -def unsubscribe(pid, stream, params \\ []) do - GenServer.call(pid, {:control, "unsubscribe", stream, params}) -end - -@doc """ -Closes the connection gracefully: sends a close frame, delivers -`{:hunter_stream, pid, {:closed, :local}}` to the subscriber, and the -process exits `:normal`. -""" -@spec close(pid) :: :ok -def close(pid) do - GenServer.call(pid, :close) -end -``` - -- [ ] **Step 4: Run the tests to verify they pass** - -Run: `mix test test/hunter/streaming_test.exs` -Expected: PASS (12 tests). - -- [ ] **Step 5: Format, run the full suite, commit** - -```bash -mix format -mix test -git add lib/hunter/streaming.ex test/hunter/streaming_test.exs -git commit -m "feat: streaming subscribe/unsubscribe/close and close-path handling" -``` - ---- - -### Task 5: Delete `Hunter.EventStream`, CHANGELOG, gates - -**Files:** -- Delete: `lib/hunter/event_stream.ex` -- Modify: `CHANGELOG.md` - -**Interfaces:** -- Consumes: everything from Tasks 1–4. -- Produces: release notes; a gate-clean branch for the integration task. - -- [ ] **Step 1: Delete the dead module** - -```bash -git rm lib/hunter/event_stream.ex -grep -rn "EventStream" lib test README.md -``` - -Expected: `grep` finds nothing (the module was never referenced; if a reference turns up, remove it and note it in your report). - -- [ ] **Step 2: Add the CHANGELOG entries** - -In `CHANGELOG.md`, the `## Unreleased` section currently contains only a `* Features` list. Make it: - -```markdown -## Unreleased - - * Breaking changes - - Removed `Hunter.EventStream` ([#3]): the SSE frame struct added in - 2017 was never wired to a connection; the streaming API ships as - WebSocket-only (`Hunter.Streaming`) - - * Features - - Streaming API ([#3]): `Hunter.Streaming.connect/2` opens Mastodon's - multiplexed streaming WebSocket (new `mint_web_socket` dependency) - with runtime `subscribe/3`/`unsubscribe/3`, graceful `close/1`, and - `health?/2`; parsed events (`Hunter.Streaming.Event` — payloads - decode to `Status`, `Notification`, `Conversation`, `Announcement`, - `Announcement.Reaction`, or id strings for deletes) are delivered to - a subscriber pid as `{:hunter_stream, pid, event}` messages. No - automatic reconnection: the process notifies the subscriber and - exits, so callers supervise it - - Account extras ([#124]): `lookup_account/2`, `accounts_by_ids/2`, -``` - -(The Account extras bullet and everything after it stay unchanged.) - -Then add the link reference next to the existing ones at the bottom of the file, after the `[#122]` line: - -```markdown -[#3]: https://github.com/milmazz/hunter/issues/3 -``` - -- [ ] **Step 3: Run the gates** - -```bash -mix test -mix format --check-formatted && mix credo -mix dialyzer -``` - -Expected: 0 test failures; no formatting diffs; no new credo issues; no new dialyzer warnings (first run may rebuild the PLT for the new deps — that is slow but normal). Report any warning verbatim instead of hacking around it. - -- [ ] **Step 4: Commit** - -```bash -git add -A -git commit -m "feat!: remove Hunter.EventStream; changelog for streaming (#3)" -``` - ---- - -### Task 6: CI streaming service + integration test - -**Files:** -- Modify: `docker-compose.ci.yml` -- Modify: `scripts/ci/nginx.conf` -- Modify: `scripts/ci/setup_mastodon.sh` (one line) -- Test: `test/integration/mastodon_test.exs` - -**Interfaces:** -- Consumes: `Hunter.Streaming.connect/2` (`transport_opts:` option), `close/1`, `health?/2`, `Hunter.Streaming.Event` struct; `Hunter.IntegrationCase` context (`conn`), `eventually/2`; `Hunter.create_status/3` and `Hunter.destroy_status/2` on the facade. -- Produces: a CI stack whose nginx proxies `/api/v1/streaming` (WebSocket upgrade) to the Mastodon streaming server, and one integration test proving the full path. - -- [ ] **Step 1: Add the streaming service to the compose file** - -In `docker-compose.ci.yml`, add after the `sidekiq` service (Mastodon ships streaming as its own image since 4.2 — same tag as `web`): - -```yaml - streaming: - image: ghcr.io/mastodon/mastodon-streaming:v4.3.8 - env_file: scripts/ci/.env.mastodon - command: node ./streaming - depends_on: - db: - condition: service_healthy - redis: - condition: service_healthy - healthcheck: - test: - [ - "CMD-SHELL", - "wget -q --spider --proxy=off localhost:4000/api/v1/streaming/health || exit 1" - ] - interval: 5s - timeout: 5s - retries: 30 -``` - -And make `nginx` wait for it — in the `nginx` service's `depends_on`, add: - -```yaml - streaming: - condition: service_healthy -``` - -- [ ] **Step 2: Proxy the streaming path through nginx** - -In `scripts/ci/nginx.conf`, add a second `location` block inside the existing `server` block, before `location /`: - -```nginx - location /api/v1/streaming { - proxy_pass http://streaming:4000; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; - proxy_set_header Host localhost; - proxy_set_header X-Forwarded-Proto https; - proxy_read_timeout 120s; - } -``` - -- [ ] **Step 3: Boot the new service in the setup script** - -In `scripts/ci/setup_mastodon.sh`, the stack boot line currently reads: - -```bash -$COMPOSE up -d web sidekiq nginx -``` - -Change it to: - -```bash -$COMPOSE up -d web sidekiq streaming nginx -``` - -- [ ] **Step 4: Write the integration test** - -Append to `test/integration/mastodon_test.exs`, before the file's final `end`: - -```elixir - describe "streaming" do - test "health check and live user-stream update", %{conn: conn} do - assert Hunter.Streaming.health?(conn) - - {:ok, pid} = - Hunter.Streaming.connect(conn, - streams: ["user"], - transport_opts: [verify: :verify_none] - ) - - status = Hunter.create_status(conn, "streaming test #{System.unique_integer([:positive])}") - - try do - status_id = status.id - - assert_receive {:hunter_stream, ^pid, - %Hunter.Streaming.Event{ - type: "update", - payload: %Hunter.Status{id: ^status_id} - }}, - 30_000 - - Hunter.Streaming.close(pid) - assert_receive {:hunter_stream, ^pid, {:closed, :local}} - after - Hunter.destroy_status(conn, status.id) - end - end - end -``` - -- [ ] **Step 5: Run the integration test against a local stack** - -```bash -./scripts/ci/setup_mastodon.sh -source scripts/ci/.env.hunter -mix test --only integration -``` - -Expected: the whole integration suite PASSES including the new streaming test. If the stack is already running from a previous session, tear it down first (`docker compose -f docker-compose.ci.yml down -v`) so the new streaming service and nginx config load. If Docker is unavailable in your environment, report that verbatim as a concern instead of skipping silently — the controller will run it in CI. - -- [ ] **Step 6: Run the unit gates and commit** - -```bash -mix test -mix format --check-formatted && mix credo -git add docker-compose.ci.yml scripts/ci/nginx.conf scripts/ci/setup_mastodon.sh test/integration/mastodon_test.exs -git commit -m "feat: streaming integration test and CI streaming service" -``` - ---- - -## Self-Review Notes - -Spec coverage: `connect/subscribe/unsubscribe/close` (Tasks 3–4), `health?` (Task 2), `Event` parsing table incl. `:announcement_reaction` clause and unknown-type passthrough (Task 1), subscriber message contract incl. the single `{:closed, reason}` path with `:local`/`{:remote, code}`/`{:error, term}` (Tasks 3–4), handshake-failure `{:error, reason}` with no leftover process (Task 3, GenServer `init` `{:stop, reason}`), malformed-frame skip with `Logger.warning` (Tasks 1+3), `EventStream` deletion + CHANGELOG breaking entry (Task 5), scripted-WS-server unit tests + `Req.Test` health tests + real-Mastodon integration incl. the CI streaming container (Tasks 3, 2, 6). Out-of-scope items (SSE, reconnect, URL auto-discovery) have no tasks, as intended. Type consistency: `{:control, type, stream, params}`/`:close` GenServer messages match between Task 3 (implementation) and Task 4 (wrappers); `Hunter.StreamingServer.start/1` → `{server, port}` used identically in Tasks 3 and 4; `transport_opts:` flows `connect/2` → `Connection.start_link/1` → `Mint.HTTP.connect/4` and is exercised in Task 6. One deliberate addition over the spec: the `transport_opts:` connect option — required for the CI stack's self-signed certificate; it is the Mint-level twin of the `req_options` override the integration case already applies to REST calls. diff --git a/docs/superpowers/specs/2026-07-06-ci-and-test-suite-design.md b/docs/superpowers/specs/2026-07-06-ci-and-test-suite-design.md deleted file mode 100644 index 1ab43d0..0000000 --- a/docs/superpowers/specs/2026-07-06-ci-and-test-suite-design.md +++ /dev/null @@ -1,141 +0,0 @@ -# CI Workflow and Test Suite Design - -**Date:** 2026-07-06 -**Status:** Approved -**Related:** [issue #13](https://github.com/milmazz/hunter/issues/13) - -## Goal - -Establish a modern GitHub Actions CI process for hunter and build a robust test -suite: deeper unit tests plus integration tests that run against a real -Mastodon server, both locally and in CI. - -## Background - -- The existing workflow (`.github/workflows/elixir.yml`) targets the `master` - branch, but the repository's default branch is `main`, so CI never runs. It - also pins Elixir 1.14/OTP 25 and uses deprecated action versions. -- `.tool-versions` was recently bumped to Erlang 28 / Elixir 1.19-otp-28, and - deps were bumped (`httpoison ~> 3.0`, `poison ~> 6.0`). These changes are - uncommitted and this work builds on them. -- Tests exist for only 6 of ~20 modules. They are Mox-based delegation tests - that assert the mock returns what it was told to return. The HTTP layer - (`Hunter.Api.HTTPClient`, `Hunter.Api.Request`), the JSON-to-struct - transformation, and error handling are untested. -- The codebase already has the right test seam: the `Hunter.Api` behaviour - with a Mox mock (`Hunter.ApiMock`) configured in `test/test_helper.exs`. - -## Part 1: CI workflow - -Replace `.github/workflows/elixir.yml` with `.github/workflows/ci.yml`, -triggered on pushes and pull requests to `main`. Four jobs: - -### test (matrix) - -- Matrix pairs: Elixir 1.15 / OTP 25 (floor) and Elixir 1.19 / OTP 28 - (matching `.tool-versions`). -- `erlef/setup-beam@v1` for toolchain, `actions/checkout@v4`, - `actions/cache@v4` for `deps/` and `_build/` keyed on - `os + otp + elixir + hashFiles('mix.lock')`. -- Steps: `mix deps.get`, `mix deps.unlock --check-unused`, - `mix compile --warnings-as-errors`, `mix test`. -- Consequence: bump `elixir: "~> 1.8"` to `elixir: "~> 1.15"` in `mix.exs`. - The 1.8 claim is untestable and almost certainly broken by the dep bumps. - If the floor pair fails against the updated deps in CI, raise the floor to - the oldest pair that passes and note it in the changelog. - -### lint - -- Latest pair only: `mix format --check-formatted`, `mix credo --strict`. - -### dialyzer - -- Latest pair only. Cache the PLT (`actions/cache` keyed on - `os + otp + elixir + hashFiles('mix.lock')`) so runs are fast after the - first. Fix stale `:race_conditions`-era flags in the `mix.exs` dialyzer - config if current dialyxir rejects them. - -### integration - -- Boots a real Mastodon server via `docker-compose.ci.yml` (postgres, redis, - mastodon web, streaming, sidekiq) with a **pinned** Mastodon image version, - bumped deliberately. -- A setup script (`scripts/ci/setup_mastodon.sh`) waits for health, creates a - confirmed/approved user via `tootctl accounts create`, and provisions an - OAuth application + access token with full scopes via `rails runner` - (Doorkeeper), emitting `HUNTER_BASE_URL` and `HUNTER_TOKEN`. -- Runs `mix test --only integration` against that server. - -## Part 2: Unit test suite - -Three layers, ordered by value: - -### Entity parsing tests (biggest gap) - -- Real Mastodon API JSON fixtures under `test/fixtures/` for each entity: - Status, Account, Card, Attachment, Relationship, Notification, Context, - Instance, Report, Result, Emoji, Tag, Mention. -- Assert that decoding (the `transform/2` path in `Hunter.Api.HTTPClient`, - i.e. `Poison.decode!(body, as: %Entity{})`) produces correct nested structs - (e.g. a Status containing an Account, Attachments, Tags). -- These test actual behavior rather than mock echo. - -### Mox delegation tests for the full API surface - -- Extend the existing pattern from 6 modules to all public API functions: - Status, Relationship, Report, Result, Context, Attachment, and the - timeline/favourites/notifications/search functions on `Hunter` itself. -- Include error paths: expectations that raise `Hunter.Error` and assertions - on the error struct. - -### Pure-function tests for the HTTP plumbing - -- `Hunter.Api.Request`: request body building (empty, map, multipart, - binary), header merging, and status-code handling (2xx ok, non-2xx error, - transport error) for the parts testable without a network. -- `Hunter.Client.new/1` and `Hunter.Config` resolution (env var and - application-env fallbacks). - -## Part 3: Integration suite - -- Lives in `test/integration/`, tagged `@moduletag :integration`. -- Excluded by default in `test_helper.exs` (`ExUnit.configure(exclude: - [:integration])`) so `mix test` stays fast and offline. -- Activated when `HUNTER_BASE_URL` and `HUNTER_TOKEN` are set — the same - mechanism works locally against any test instance and in the CI job. -- End-to-end flow against the real server: verify credentials → post status → - favourite/reblog → search → follow/notifications → upload media → delete - status → instance metadata. Tests clean up what they create where the API - allows it. - -## Sequencing - -1. Commit the pending dep bumps (`mix.exs`, `mix.lock`, `.tool-versions`) as - the first commit of the branch. -2. CI workflow for unit/lint/dialyzer jobs (immediate value, no test changes - needed). -3. Unit test layers (entity parsing → delegation coverage → HTTP plumbing). -4. Integration suite + Mastodon-in-Docker CI job. - -## Out of scope - -- Replacing Poison/HTTPoison with newer stacks (Req/Jason) — separate effort. -- Coverage reporting (declined during design). -- Streaming API (`Hunter.EventStream`) integration tests — websocket/SSE - testing is a follow-up. - -## Errata (post-implementation) - -Recorded after the stack merged; the sections above are the point-in-time -design and are intentionally left as written. - -- The matrix floor shipped as **Elixir 1.15 / OTP 26**, not OTP 25: the - updated dependency lock (`quic 1.7.0`, via hackney) does not compile on - OTP 25. Exercised the spec's own escape hatch; recorded in CHANGELOG and - PR #102. -- The integration env contract grew beyond `HUNTER_BASE_URL`/`HUNTER_TOKEN`: - the suite also requires `HUNTER_TOKEN2` (second account, follow/notification - tests), and later `HUNTER_PASSWORD2` (password-grant auth-flow test, #100) - and `HUNTER_OAUTH_CLIENT_ID`/`HUNTER_OAUTH_CLIENT_SECRET`/`HUNTER_OAUTH_CODE` - (OAuth flow test, #112). `scripts/ci/setup_mastodon.sh` provisions all of - them; see CONTRIBUTING.md for the current list. diff --git a/docs/superpowers/specs/2026-07-07-auth-fixes-design.md b/docs/superpowers/specs/2026-07-07-auth-fixes-design.md deleted file mode 100644 index 1337858..0000000 --- a/docs/superpowers/specs/2026-07-07-auth-fixes-design.md +++ /dev/null @@ -1,97 +0,0 @@ -# Auth Fixes Design: Token Scopes (#100) + access_token Rename (#101) - -**Date:** 2026-07-07 -**Status:** Approved -**Related:** [issue #100](https://github.com/milmazz/hunter/issues/100), -[issue #101](https://github.com/milmazz/hunter/issues/101) - -## Goal - -Make `log_in/4` request the scopes the app was registered with, so tokens can -actually write (#100), and rename `Hunter.Client.bearer_token` to -`access_token` for ecosystem consistency (#101). One branch (`auth-fixes` off -`main`), one PR, shipping in the 0.6.0 breaking window. - -## Root cause of #100 (diagnosed live, 2026-07-07) - -`Hunter.Api.HTTPClient.log_in/4` omits `scope` from the `/oauth/token` -password-grant payload. Doorkeeper then grants Mastodon's **default scope, -`read`**, regardless of the app's registered scopes. Reproduced against a -local Mastodon v4.3.8: token request without `scope` → `granted scope: read` -→ `POST /api/v1/statuses` returns exactly the issue's error ("This action is -outside the authorized scopes"). Same request with -`scope: "read write follow"` → write succeeds. - -Constraint discovered: `Hunter.Application` does not store the scopes it was -created with, so `log_in` has nothing to re-request today. - -## Decisions (user-approved 2026-07-07) - -1. **#100 fix shape:** store scopes on `Hunter.Application` (new `scopes` - field, persisted by `save?: true`); `log_in` sends them. Not an explicit - `log_in` argument; not hardcoded scopes. -2. **#101 shape:** hard rename, no compatibility shim. Compile-time breakage - is loud and documented. -3. Single PR; rename lands first so the #100 code is written against the new - field name. -4. `log_in_oauth`'s suspected missing `redirect_uri` is out of scope — file a - separate issue instead of widening this PR (verification requires a - browser authorization dance). - -## Part 0: restore the lost CHANGELOG entry - -The rebase-merge of PR #109 dropped its final commit (`cb80130`). First -commit of this branch restores the Unreleased "Bug fixes" bullet for #74 -(GET/DELETE options as query params) with its `[#74]` link reference. - -## Part 1: #101 hard rename - -- `Hunter.Client`: `defstruct [:base_url, :access_token]`, `@type t`, and - moduledoc/`new/1` docs updated. -- `Hunter.Api.HTTPClient`: `get_headers/1` matches - `%Hunter.Client{access_token: token}`; `log_in/4` and `log_in_oauth/3` - build `%Hunter.Client{base_url: base_url, access_token: …}`. -- All test files constructing `Hunter.Client.new(bearer_token: …)` switch to - `access_token:` (≈12 unit test files plus - `test/support/integration_case.ex`). -- README examples updated (`grep -rn bearer_token` must come up empty - repo-wide except CHANGELOG history). -- CHANGELOG breaking entry: rename, with the one-line migration - (`bearer_token:` → `access_token:`). - -## Part 2: #100 fix - -- `Hunter.Application` gains `scopes` (list of `String.t()`, default `nil`): - struct, `@type`, field docs. -- `HTTPClient.create_app/5` puts the **requested** scopes list into the - returned struct after decoding (the v1 apps response cannot be trusted to - echo scopes across server versions). Persisted credentials - (`save?: true` → Poison-encoded file) therefore include scopes; old files - load with `scopes: nil`. -- `HTTPClient.log_in/4` payload gains `scope: Enum.join(scopes, " ")` when - the app's scopes is a non-empty list; the parameter is omitted for `nil` - or `[]` (byte-compatible with old saved credentials — current behavior). -- CHANGELOG bug-fix entry referencing #100. - -## Testing - -- **Integration (the #100 regression test):** new test doing the README - flow — `Hunter.create_app/5` → `Hunter.log_in/4` → `create_status` → - `destroy_status` — which fails with the exact #100 error before the fix. - Requires a known password: `scripts/ci/setup_mastodon.sh` gains a - `tootctl accounts modify kadaba --reset-password` step and exports - `HUNTER_PASSWORD2` (env file + `$GITHUB_ENV`). The test skips nothing: - password comes from the same env contract as the tokens. -- **Unit:** application persistence tests extended for the scopes round-trip - (create → save → load); a Mox test pinning that `log_in` receives the app - with scopes intact; rename covered by the entire existing suite compiling - and passing (`--warnings-as-errors` catches stragglers). -- Full gate per commit: compile --warnings-as-errors, test, format - --check-formatted, credo --strict; dialyzer once at the end (struct/type - changes). - -## Out of scope - -- `log_in_oauth` redirect_uri verification/fix — new issue to file. -- Any other 0.6.0 items (#107 follow-ups, #110 domain-block auth, #103 - Finch). diff --git a/docs/superpowers/specs/2026-07-07-query-params-and-api-drift-design.md b/docs/superpowers/specs/2026-07-07-query-params-and-api-drift-design.md deleted file mode 100644 index 5f9e79d..0000000 --- a/docs/superpowers/specs/2026-07-07-query-params-and-api-drift-design.md +++ /dev/null @@ -1,128 +0,0 @@ -# Query Parameters (#74) and API-Drift Cleanup (#106) Design - -**Date:** 2026-07-07 -**Status:** Approved -**Related:** [issue #74](https://github.com/milmazz/hunter/issues/74), -[issue #106](https://github.com/milmazz/hunter/issues/106) - -## Goal - -Make GET/DELETE request options travel as query-string parameters instead of -JSON request bodies (#74), then remove or correct the API surface that modern -Mastodon no longer supports (#106). Ships as two stacked PRs; the breaking -changes land in the already-open 0.6.0 window. - -## Background - -- `Hunter.Api.Request.request/5` JSON-encodes `data` into the request body - for every verb. Mastodon (Rails) parses JSON bodies even on GET/DELETE, so - the library *appears* to work — but proxies and CDNs routinely drop bodies - on those verbs, and the behavior contradicts the documented API. Issue #74 - reports pagination/filtering (`max_id`, `since_id`, `limit`, `local`) - silently broken across ~14 GET endpoints. -- `relationships/2` hand-builds its own query string - (`"/api/v1/accounts/relationships?#{ids_array}"`) — evidence the general - mechanism is missing. -- Issue #106 as filed is partially stale: `search/3` already targets - `/api/v2/search`. The real remaining drift: `q` currently travels in a GET - body (the #74 bug), `Result.hashtags` decodes v1-style strings while v2 - returns objects, `follow_by_uri` targets `POST /api/v1/follows` (removed in - Mastodon 4.0), and `reports/1` targets `GET /api/v1/reports` (removed). - Correct the issue with a comment when PR B closes it. - -## Decisions (user-approved 2026-07-07) - -1. `Result.hashtags` decodes to `[%Hunter.Tag{}]` — v2-faithful, consistent - with the rest of the library; breaking, lands in 0.6.0. -2. `follow_by_uri` is **removed** (not reimplemented via resolve). -3. `reports/1` (GET) is removed; `report/4` (POST) stays. -4. The query-param fix covers **GET and DELETE**. -5. Mechanism: request-layer routing (approach A below), not per-call-site URL - building. - -## PR A: query parameters (#74) - -Branch `query-params` off `main`. - -### Mechanism - -New pure function in `Hunter.Api.Request`: - -```elixir -split_payload(method, data) :: {body, params} -``` - -- `:get` / `:delete` → `{"", params}` where `params` is a list of - `{key, value}` tuples derived from `data` (keyword list or map). -- every other verb → `{process_request_body(data), []}` — byte-identical to - today's body behavior. -- List values encode Rails-style repeated keys: `%{id: [1, 2]}` → - `[{"id[]", 1}, {"id[]", 2}]`. -- Empty data → `{"", []}`; HTTPoison must not append a bare `?`. - -`request/5` passes `params` via HTTPoison's `params:` request option (merged -into the caller-supplied `Config.http_options()` without clobbering other -options). Public signatures of `request/5` and `request!/5` are unchanged. - -### Call-site cleanup - -`HTTPClient.relationships/2` stops hand-building its query string: endpoint -becomes `"/api/v1/accounts/relationships"` and the ids travel as -`%{id: ids}` through `split_payload`'s array encoding. - -No other call sites change — that is the point of the mechanism. - -### Tests - -- Unit (`test/hunter/api/request_test.exs`): `split_payload/2` routing table - (GET with options, DELETE with options, POST untouched, array encoding, - empty data), and that `params` reach the HTTPoison options. -- Integration (`test/integration/mastodon_test.exs`): new assertions that - prove params take effect server-side — `limit: 1` returns at most one - result, `max_id` pagination excludes the anchor status. These fail against - the current body-quirk behavior only if Rails ignores GET bodies — they - exist to pin the *correct* transport, not to detect the old one, so they - must pass both locally and in CI after the fix. -- Existing integration tests (`search_account`, `relationships` via the - follow test) re-verify the migrated paths. - -## PR B: API-drift cleanup (#106) - -Branch `api-drift` off `query-params` (stacked: its integration test needs -`q` as a real query param). - -### Changes - -1. **v2 search result shape**: `Transformer.transform(body, :result)` decodes - `hashtags: [%Hunter.Tag{}]`; `test/fixtures/result.json` corrected to the - v2 object shape (`[{"name": "elixir", "url": …}]`); transformer test - updated to assert Tag structs. `Hunter.Result` typespec/docs updated. -2. **Remove `follow_by_uri`**: from `Hunter`, `Hunter.Account`, the - `Hunter.Api` behaviour callback, `Hunter.Api.HTTPClient`, and its Mox test. -3. **Remove `reports/1`**: from `Hunter`, `Hunter.Report`, the behaviour, - `HTTPClient`, its Mox test, and the `:reports` transform clause plus its - transformer test (`report/4` POST and the `:report` clause stay). -4. **CHANGELOG**: three breaking-change entries under Unreleased (0.6.0). -5. **Issue hygiene**: comment on #106 correcting the stale v1-search claim; - PR closes #106. - -### Tests - -- Transformer test asserts `%Hunter.Tag{name: "elixir"}` in result hashtags. -- New integration test: post a `#hunterci` status, `Result.search` for it, - assert the status appears and hashtags decode as Tag structs. -- Compile with `--warnings-as-errors` guarantees no dangling references to - the removed functions. - -## Out of scope - -- Finch migration (issue #103) — explicitly deferred by the user. -- `bearer_token` → `access_token` rename (issue #101) — separate 0.6.0 item. -- Test-suite follow-ups (issue #107). -- Any change to POST/PATCH body encoding. - -## Sequencing note - -Both PRs are stacked; after PR A squash-merges, PR B needs the same -merge-main sync applied to `unit-test-suite`/`integration-tests` last time -(or merge PR A with a merge commit to avoid it). diff --git a/docs/superpowers/specs/2026-07-08-endpoint-fixes-design.md b/docs/superpowers/specs/2026-07-08-endpoint-fixes-design.md deleted file mode 100644 index 5e3f235..0000000 --- a/docs/superpowers/specs/2026-07-08-endpoint-fixes-design.md +++ /dev/null @@ -1,113 +0,0 @@ -# Modern-Mastodon Endpoint Fixes Design (#118) - -**Date:** 2026-07-08 -**Status:** Approved -**Related:** [issue #118](https://github.com/milmazz/hunter/issues/118), part -of the Mastodon 4.6 API parity effort (#118–#128) - -## Goal - -Fix the endpoints that are broken or deprecated on modern Mastodon servers -before the 0.6.0 release, so the release doesn't ship API calls that can only -fail. One branch (`fix-api-drift-4x` off `main`), one PR. The password-grant -deprecation stays with #126. - -## Decisions (user-approved 2026-07-08) - -1. **Media v2 returns as-is**: no library-side polling; `url` may be `nil` - while the server processes asynchronously, documented on `upload_media`. -2. **Instance v2 reshape is top-level only**: nested objects decode as plain - maps; full nested-struct modeling stays with #119. -3. PR is left unmerged for the user's manual review (standing instruction). - -## Part 1: preview cards (breaking) - -`GET /api/v1/statuses/:id/card` was removed in Mastodon 3.0; the card is -embedded in the Status entity. - -- `Hunter.Status` gains a `card` field (`Hunter.Card.t() | nil`): struct, - `@type`, fields doc. -- `Transformer.status_nested_struct/0` decodes `card: %Hunter.Card{}`. -- `test/fixtures/status.json` gains a `card` object; transformer test asserts - the nested `%Hunter.Card{}`. -- **Removed** everywhere (function + doc + spec/callback): `Hunter.card_by_status/2` - delegate, `Hunter.Card.card_by_status/2`, the `Hunter.Api` callback, the - HTTPClient implementation, the Mox test in `test/hunter/card_test.exs`, and - the now-orphaned `:card` transform clause plus its transformer test. The - `Hunter.Card` struct itself stays (embedded entity). - -## Part 2: follow requests (breaking return type) - -The implemented `POST /api/v1/follow_requests/#{action}` with an `id` body -never matched the documented API. - -- Paths become `POST /api/v1/follow_requests/:account_id/authorize` and - `.../reject`; empty payload; decode `:relationship` (the endpoint returns a - `Relationship` since 3.0). -- `follow_request_action` callback and the `accept_follow_request/2` / - `reject_follow_request/2` wrappers change spec from `boolean` to - `Hunter.Relationship.t()`. -- Mox tests updated to the new return shape. -- **Integration test** (the old path was broken and the suite never noticed — - this coverage is the point): `conn2` locks its own account via - `Account.update_credentials(conn2, %{locked: true})` (with an `on_exit` - unlock net); `conn` follows `id2` and asserts `requested: true`; `conn2` - sees the request in `Account.follow_requests/1` (asserting the requester's - account appears) and calls `accept_follow_request(conn2, id1)`, asserting - the returned `%Hunter.Relationship{followed_by: true}`; cleanup unfollows - and unlocks via `on_exit` nets so the pre-existing follow test is unaffected - regardless of intra-module test order. - -## Part 3: notification dismiss (path fix) - -- `clear_notification/2` path becomes - `POST /api/v1/notifications/#{id}/dismiss` (the implemented - `/notifications/dismiss/#{id}` matches no server version). -- The existing cross-account notification integration test extends: capture - the mention notification's id, dismiss it, assert it no longer appears in - `Notification.notifications/1`. - -## Part 4: media v2 (deprecated migration) - -- `upload_media/3` posts to `POST /api/v2/media` (multipart handling - unchanged). -- Response returned as-is; the function docs (`Hunter.Attachment.upload_media/3` - and the `Hunter` delegate doc) document that `url` may be `nil` until the - server finishes processing (v2 answers 202 for large files) and that the - attachment `id` is immediately usable with `create_status` once processing - completes. -- Existing live media test covers the endpoint swap (it already retries - `create_status` via `eventually`). - -## Part 5: instance v2 (breaking reshape) - -- `instance_info/1` targets `GET /api/v2/instance`. -- `Hunter.Instance` reshapes to the v2 top-level fields: - `defstruct [:domain, :title, :version, :source_url, :description, :usage, - :thumbnail, :languages, :configuration, :registrations, :contact, :rules]` - — nested objects as plain maps; `@type` and fields doc updated. -- `test/fixtures/instance.json` rewritten to the v2 shape; transformer test - asserts `domain`/`version` and a nested plain-map field. -- Live instance test asserts `domain`/`version` instead of `uri`. - -## CHANGELOG - -- Breaking: card_by_status removal (+ `Status.card` addition), follow-request - return type + endpoint fix, Instance v2 reshape. -- Bug fixes: notification dismiss path, follow-request path (called out as - previously non-functional), media v2 migration note. -- All referencing [#118]. - -## Testing - -Full unit gate per commit (compile --warnings-as-errors, test, format, credo ---strict); dialyzer once before the PR (struct/callback changes); full live -integration run (13 tests expected: 12 existing + the follow-request test, -with the dismiss step folded into an existing test) via -`./scripts/ci/setup_mastodon.sh` + `mix test --only integration`. - -## Out of scope - -- Password-grant OAuth deprecation (#126), entity modernization beyond - Instance top-level (#119), everything else in #120–#128. -- Merging the PR (user reviews manually). diff --git a/docs/superpowers/specs/2026-07-08-flatten-facade-design.md b/docs/superpowers/specs/2026-07-08-flatten-facade-design.md deleted file mode 100644 index ef7a296..0000000 --- a/docs/superpowers/specs/2026-07-08-flatten-facade-design.md +++ /dev/null @@ -1,150 +0,0 @@ -# Flatten the facade: one public module, one transport module - -**Date:** 2026-07-08 -**Status:** Approved - -## Problem - -Every endpoint is implemented three times across three layers: - -1. `Hunter.followers/3` — full docs + `@spec` + `defdelegate` (`lib/hunter.ex`) -2. `Hunter.Account.followers/3` — the same docs + `@spec` again, plus a - one-line call into the client (`lib/hunter/account.ex`) -3. `Hunter.Api.HTTPClient.followers/3` — the actual three-line - implementation (`lib/hunter/api/http_client.ex`) - -Consequences: two levels of indirection before the real implementation, -duplicated documentation that has already started to drift between the -facade and entity copies, and duplicated unit-test surface. The entity -functions are textbook shallow pass-through methods (Ousterhout, *A -Philosophy of Software Design*): same signature, same semantics, no added -abstraction. - -## Decision - -Remove the middle layer entirely and merge the bottom two. - -- **`Hunter`** becomes the single deep public module: docs, specs, and the - real implementation bodies live only here. Single file (`lib/hunter.ex`, - ~1700 lines of mostly docs); each body is a short pipe into the transport. -- **Entity modules** become pure data definitions: `defstruct`, `@type t`, - and field documentation. No HTTP functions, no `HTTPClient` alias. -- **`Hunter.Api.Request`** is the single transport module. It absorbs - `HTTPClient`'s private helpers (`request!/5`, `get_headers/1`, - `process_url/2`) and exposes a conn-aware entry point. **The `Request` - module name and test file survive; `Hunter.Api.HTTPClient` is deleted.** -- **`Hunter.Api.Transformer`** is unchanged. - -This is a breaking change (removal of every `Hunter../n` -function). Accepted: the library is mid-revamp for Mastodon 4.6 parity and -has shipped several breaking releases already. No `@deprecated` shims — -hard removal with a CHANGELOG migration table and a bump to **0.7.0**. - -## Design - -### Transport: `Hunter.Api.Request.request!/5` - -```elixir -@spec request!( - Hunter.Client.t() | String.t(), - atom, - String.t(), - atom | nil, - Keyword.t() | map | {:form_multipart, list} - ) :: term -def request!(conn_or_base_url, method, path, to, payload \\ []) -``` - -Responsibilities, in order: - -1. Join `path` onto the base URL (from `%Hunter.Client{base_url: _}` or a - bare base-URL string) — replaces `process_url/2`. -2. Build headers: `Bearer` auth token when given a `%Hunter.Client{}`, - none for a bare base URL — replaces `get_headers/1`. -3. Call the existing low-level `Req` plumbing (params encoding, multipart, - JSON body, response handling) already in `Request`. -4. On success, run `Hunter.Api.Transformer.transform(body, to)`. -5. On failure, `raise Hunter.Error, reason: reason`. - -The existing `Request.request/5` / `Request.request!/5` (method-first, -URL-string) low-level functions are subsumed: their plumbing becomes -private helpers of the new `request!/5`. The public surface of the module -is the conn-aware `request!/5` only. - -### Facade: `Hunter` - -Typical endpoint after the change: - -```elixir -@doc """ -Get a list of followers -... -""" -@spec followers(Hunter.Client.t(), String.t() | non_neg_integer, Keyword.t()) :: - [Hunter.Account.t()] -def followers(conn, id, options \\ []) do - Request.request!(conn, :get, "/api/v1/accounts/#{id}/followers", :accounts, options) -end -``` - -Functions with real logic move their full bodies (and private helpers) -into `Hunter`: - -- `create_app/5` — payload build, `save?: true` handling, and the private - `save_credentials/2`; `load_credentials/1` moves too. -- `log_in/4` and `log_in_oauth/3` — payload build + `%Hunter.Client{}` - construction (currently split between `Hunter.Client` and `HTTPClient`). -- `search_account/2` — required-`:q` opts building. -- `upload_media/3` — multipart parts construction. -- `new/1` and `user_agent/0` — move from `Hunter.Client`, which becomes a - pure struct like every other entity. - -### Entity modules - -Keep: `@moduledoc` (entity + field docs), `@type t`, `@derive`, -`defstruct`. Remove: every endpoint function, `alias Hunter.Api.HTTPClient`. -Affected modules (the ones that alias `HTTPClient` today): `Account`, -`Application`, `Attachment`, `Client`, `Context`, `Domain`, `Instance`, -`List`, `Notification`, `Poll`, `Relationship`, `Report`, `Result`, -`Status`, `WebPushSubscription`. - -### Tests - -- Per-domain test files (`test/hunter/account_test.exs`, …) stay in place; - the module under test changes from the entity to `Hunter`. Req.Test - stubs and fixtures are unchanged. -- `test/hunter/api/request_test.exs` gains coverage for the conn-aware - `request!/5`: URL join, auth header from conn vs bare base URL, - transformer dispatch, `Hunter.Error` on failure. -- `test/integration/mastodon_test.exs` updated to call `Hunter.*` where it - doesn't already. -- Net effect: one call path, one place to test it. - -### Docs & release - -- CHANGELOG: breaking-changes section with a migration table - (`Hunter.Account.followers/3` → `Hunter.followers/3`, one row per - removed module). -- README examples audited for entity-module calls. -- Version bump to 0.7.0. - -## Delivery: two stacked PRs - -1. **PR 1 — transport merge (non-breaking).** `Request` absorbs - `HTTPClient`'s helpers and grows the conn-aware `request!/5`; - `HTTPClient`'s endpoint functions keep their signatures but their - bodies become single calls to `Request.request!/5`; suite stays green - with no public API change. -2. **PR 2 — the breaking flatten.** Endpoint bodies move into `Hunter`, - `HTTPClient` is deleted, entity modules stripped to structs, tests - retargeted, CHANGELOG + 0.7.0. - -## Alternatives considered - -- **Option 1 — promote `HTTPClient` bodies into entity modules.** Removes - one hop but keeps the facade/entity doc duplication and the doubled test - surface. Rejected: solves half the problem. -- **`@deprecated` shims for one release.** Rejected: the revamp is already - breaking, and shims would preserve the exact duplication being removed. -- **Per-domain internal modules under the facade.** Rejected: reintroduces - one-hop delegation and doc-drift risk to save file length. diff --git a/docs/superpowers/specs/2026-07-09-account-extras-design.md b/docs/superpowers/specs/2026-07-09-account-extras-design.md deleted file mode 100644 index e1bb906..0000000 --- a/docs/superpowers/specs/2026-07-09-account-extras-design.md +++ /dev/null @@ -1,111 +0,0 @@ -# Account extras: lookup, familiar followers, notes, endorsements, registration - -Issue: #124. Part of the Mastodon 4.6 API parity effort. Depends on #119 -(closed) which added `Account.fields`, the `Relationship` note/endorsed -fields, and the `FeaturedTag` entity. - -## Goal - -Expose the account-level endpoints Mastodon added since 2.4 that Hunter -does not yet surface. Eleven endpoints across four groups: lookup/fetch, -registration, relationship extras, and endorsements. All ship in a single -PR. - -## Approach - -Each endpoint is a thin function on the `Hunter` module delegating to -`Hunter.Api.Request.request!/4-6`, exactly like the existing account -functions (`account/2`, `followers/3`, `relationships/2`, `follow/2`). -The response is decoded by a `Hunter.Api.Transformer` target atom into an -existing entity struct. - -Ten of the eleven endpoints reuse existing entities (`Account`, -`Relationship`, `FeaturedTag`). Only `familiar_followers` needs a new -entity because its response is a distinct shape. - -## New entity - -`Hunter.FamiliarFollowers` (`lib/hunter/familiar_followers.ex`): - -- `id` — `String.t` — the account id these familiar followers relate to -- `accounts` — `[Hunter.Account.t]` — accounts you follow that also - follow that account - -Standard entity module: `@type t`, `@derive [Poison.Encoder]`, -`defstruct`, moduledoc with a `## Fields` list, matching the style of -`Hunter.FeaturedTag`. - -New transformer clause in `Hunter.Api.Transformer`: - -```elixir -def transform(body, :familiar_followers), - do: Poison.decode!(body, as: [%Hunter.FamiliarFollowers{accounts: [account_nested_struct()]}]) -``` - -## Endpoints (functions on `Hunter`) - -| Function | HTTP / Path | Transformer | Returns | -|----------|-------------|-------------|---------| -| `lookup_account(conn, acct)` | GET `/api/v1/accounts/lookup?acct=` | `:account` | `Account.t` | -| `accounts_by_ids(conn, ids)` | GET `/api/v1/accounts?id[]=` | `:accounts` | `[Account.t]` | -| `familiar_followers(conn, ids)` | GET `/api/v1/accounts/familiar_followers?id[]=` | `:familiar_followers` | `[FamiliarFollowers.t]` | -| `account_featured_tags(conn, id)` | GET `/api/v1/accounts/:id/featured_tags` | `:featured_tags` | `[FeaturedTag.t]` | -| `register_account(conn, params)` | POST `/api/v1/accounts` | `nil` (raw) | `Hunter.Client.t` | -| `set_account_note(conn, id, comment)` | POST `/api/v1/accounts/:id/note` `{comment}` | `:relationship` | `Relationship.t` | -| `remove_from_followers(conn, id)` | POST `/api/v1/accounts/:id/remove_from_followers` | `:relationship` | `Relationship.t` | -| `endorsements(conn, opts \\ [])` | GET `/api/v1/endorsements` | `:accounts` | `[Account.t]` | -| `endorse(conn, id)` | POST `/api/v1/accounts/:id/endorse` | `:relationship` | `Relationship.t` | -| `unendorse(conn, id)` | POST `/api/v1/accounts/:id/unendorse` | `:relationship` | `Relationship.t` | -| `account_endorsements(conn, id, opts \\ [])` | GET `/api/v1/accounts/:id/endorsements` | `:accounts` | `[Account.t]` | - -### Naming rationale - -- `accounts_by_ids` parallels the existing `statuses_by_ids/2`. -- `endorse`/`unendorse` parallel `follow`/`unfollow`. The issue notes the - older `pin`/`unpin` account variants are deprecated (4.4) — not - implemented. The existing `pin`/`unpin` functions on `Hunter` act on - *statuses*, so there is no collision. -- `endorsements/2` (your featured accounts, 2.5) and - `account_endorsements/3` (a given account's featured accounts, 4.4) are - distinct endpoints and get distinct names. - -### List params - -`ids`-taking functions (`accounts_by_ids`, `familiar_followers`) pass -`%{id: ids}` — `Hunter.Api.Request.encode_params/1` already expands a list -value into repeated `id[]=` params (see `relationships/2`). - -Pagination functions (`endorsements`, `account_endorsements`) take an -options keyword forwarded verbatim (`max_id`, `since_id`, `limit`). - -### Registration detail - -`register_account/2` mirrors `log_in/*`: the caller passes a -`Hunter.Client` carrying the *app-level* access token. We POST the -registration params (`username`, `email`, `password`, `agreement`, -`locale`, and optional `reason`) with the transformer set to `nil` so we -get the raw decoded Token map, then return -`%Hunter.Client{base_url: conn.base_url, access_token: response["access_token"]}`. -No `Token` entity is introduced. - -## Testing - -Add one test per endpoint in `test/hunter/account_test.exs` using the -existing `Hunter.ReqCase` harness (`stub_request/1`, -`respond_with_fixture/2-3`, `read_json_body!/1`), asserting method, path, -query string / body, and the decoded struct. - -- Reuse `account.json`, `relationship.json`, `featured_tag.json`. -- Add `test/fixtures/familiar_followers.json` (array of one - `{id, accounts: [account]}`). -- `register_account` test stubs POST `/api/v1/accounts` returning a Token - JSON (`access_token`, `token_type`, `scope`, `created_at`) and asserts a - `%Hunter.Client{}` with the token comes back, and that the request - carried the app bearer token. -- Add a `:familiar_followers` case to `test/hunter/api/transformer_test.exs`. - -## Out of scope - -- A `Hunter.Token` entity (registration returns a `Hunter.Client`). -- The deprecated account `pin`/`unpin` endorsement endpoints. -- Any other parity issue (#118, #120–#128). diff --git a/docs/superpowers/specs/2026-07-10-oauth-modernization-design.md b/docs/superpowers/specs/2026-07-10-oauth-modernization-design.md deleted file mode 100644 index 7ebcf09..0000000 --- a/docs/superpowers/specs/2026-07-10-oauth-modernization-design.md +++ /dev/null @@ -1,185 +0,0 @@ -# OAuth modernization: PKCE, revocation, app credentials, discovery - -Issue: #126. Part of the Mastodon 4.6 API parity effort. Closes the last -auth item left open by #118 (password grant). - -## Goal - -Bring Hunter's OAuth surface from its 2017 snapshot up to Mastodon -4.3/4.4: token revocation, PKCE on the authorization-code flow, an -app-level (client-credentials) login, app credential verification, honest -`CredentialApplication` handling, and the discovery/OIDC endpoints. -Remove the undocumented password grant. All ship in a single PR. - -## Approach - -Everything stays on the `Hunter` facade (per #138); each endpoint is a -thin function over `Hunter.Api.Request.request!/4-6`. No new modules and -no new entity structs: `verify_app_credentials` reuses -`Hunter.Application` (which already carries the 4.3/4.4 fields), while -the RFC 8414 metadata and OIDC userinfo responses are returned as plain -maps — they are open-ended standard formats, not Mastodon entities, and -a struct would go stale as servers add fields. - -## Token lifecycle - -### `revoke_token(app, token, base_url \\ "https://mastodon.social")` - -`POST /oauth/revoke` with `client_id`, `client_secret`, `token`. -Transformer `:empty`; returns `true`. Raises `Hunter.Error` on failure -(Mastodon returns 403 `unauthorized_client` when the token does not -belong to the client). - -### PKCE on the code exchange - -`log_in_oauth/3` gains a trailing options list: - -```elixir -@spec log_in_oauth(Hunter.Application.t(), String.t(), String.t(), Keyword.t()) :: - Hunter.Client.t() -def log_in_oauth(app, code, base_url \\ "https://mastodon.social", opts \\ []) -``` - -When `opts[:code_verifier]` is present it is forwarded in the -`POST /oauth/token` payload. Existing 2- and 3-arity calls are unchanged. - -### `log_in_app(app, base_url \\ "https://mastodon.social")` - -`POST /oauth/token` with `grant_type=client_credentials` (plus -`client_id`, `client_secret`, `scope` from `app.scopes` when set). -Returns a `Hunter.Client` holding the app-level token. This is one step -beyond the issue checklist, but without it `verify_app_credentials/1` -and the existing `register_account/2` cannot be driven through Hunter -at all. - -### Remove `log_in/4` - -The password grant is no longer a documented Mastodon flow. The function, -its spec, its unit tests, and the README section that demonstrates it are -deleted. Breaking change, recorded in the CHANGELOG. - -## PKCE helpers (pure, no HTTP) - -### `generate_pkce/0` - -Returns `%{code_verifier: verifier, code_challenge: challenge, -code_challenge_method: "S256"}` where `verifier` is 32 bytes from -`:crypto.strong_rand_bytes/1` encoded `Base.url_encode64(padding: false)` -(43 chars) and `challenge` is the unpadded base64url SHA-256 of the -verifier, per RFC 7636. - -### `authorization_url(app, base_url \\ "https://mastodon.social", opts \\ [])` - -Builds the `GET /oauth/authorize` URL the caller sends the user to: -`response_type=code`, `client_id` from the app, `redirect_uri` from -`opts` or the app's first registered URI (`redirect_uris` list head, -then `redirect_uri`, then the oob URN), `scope` from `opts` or -`app.scopes` joined with spaces. Optional passthrough params: -`code_challenge`, `code_challenge_method`, `state`, `force_login`, -`lang`. Returns the URL string; performs no request. - -## App credentials - -### `verify_app_credentials(conn)` - -`GET /api/v1/apps/verify_credentials` with the app-level bearer token. -Transformer `:application`; returns `Hunter.Application` (the server -omits `client_secret`; since 4.3 it includes `scopes` and -`redirect_uris`). - -### `create_app` handles `CredentialApplication` honestly - -- `redirect_uris` accepts a `String.t` **or** `[String.t]` and is - forwarded as given (Mastodon accepts an array since 4.3). -- The server response is no longer clobbered: today `create_app` - overwrites `scopes` with its input and `redirect_uri` with its input. - Instead, server-returned `scopes`/`redirect_uris`/`redirect_uri` win, - and the requested values are only backfilled when the server returned - none (pre-4.3 servers). The `redirect_uri` backfill uses the first - requested URI so persisted credentials keep working with - `log_in_oauth`. -- `Hunter.Application` already models `client_secret_expires_at` and - `redirect_uris`, and already omits the deprecated `vapid_key` — no - struct changes. - -## Discovery / OIDC - -### `oauth_server_metadata(base_url \\ "https://mastodon.social")` - -`GET /.well-known/oauth-authorization-server` (RFC 8414, Mastodon 4.3). -Unauthenticated; transformer `nil`; returns the decoded map -(`issuer`, `authorization_endpoint`, `token_endpoint`, -`scopes_supported`, `code_challenge_methods_supported`, ...). - -### `userinfo(conn)` - -`GET /oauth/userinfo` (Mastodon 4.4) with a user-level token carrying -the `profile` (or `read`) scope. Transformer `nil`; returns the decoded -OIDC claims map (`iss`, `sub`, `name`, `preferred_username`, ...). - -## Testing - -### Unit (`Hunter.ReqCase` / `Req.Test` stubs) - -New `test/hunter/oauth_test.exs`: - -- `revoke_token/3` — POST `/oauth/revoke`, body carries - `client_id`/`client_secret`/`token`, returns `true`; 403 raises. -- `log_in_oauth/4` — payload includes `code_verifier` when given and - omits it otherwise. -- `log_in_app/2` — `grant_type=client_credentials` payload; returns a - `Hunter.Client` with the token. -- `generate_pkce/0` — verifier is 43 chars of the base64url alphabet, - unique across calls; challenge equals the recomputed unpadded - base64url SHA-256 of the returned verifier. -- `authorization_url/3` — asserts the exact query string incl. - challenge params; defaulting of scope/redirect_uri from the app. -- `oauth_server_metadata/1` and `userinfo/1` — path, auth header - presence/absence, map passthrough. - -`test/hunter/application_test.exs` gains: - -- `verify_app_credentials/1` — GET path, bearer header, decoded - `Hunter.Application`. -- a 4.3-shaped `CredentialApplication` response for `create_app` - (list `redirect_uris`, `client_secret_expires_at`) asserting the - server values survive and nothing is clobbered. -- a pre-4.3-shaped response asserting the backfill. - -`log_in/4` tests in `test/hunter/client_test.exs` are removed. - -### Integration (`test/integration/mastodon_test.exs`) - -`scripts/ci/setup_mastodon.sh` additionally mints a PKCE-bound grant: -a fixed `code_verifier` is generated in the script, its S256 challenge -stored on a second `Doorkeeper::AccessGrant` -(`code_challenge`/`code_challenge_method` columns), and -`HUNTER_OAUTH_PKCE_CODE`/`HUNTER_OAUTH_PKCE_VERIFIER` are exported. -New/updated tests: - -- PKCE exchange: `log_in_oauth(app, code, base_url, code_verifier: v)` - yields a working client. -- Revoke: revoke the token from the plain oauth flow, then assert an - authenticated call raises `Hunter.Error`. -- App flow: `log_in_app` + `verify_app_credentials` round-trip. -- `oauth_server_metadata/1` returns a map with the instance `issuer`. -- `userinfo/1` returns claims for the token's user. -- The "README auth flow" test drops `log_in` and instead exercises - `create_app` → `log_in_app` → `verify_app_credentials`. - -## Docs - -- README auth section rewritten around authorization-code + PKCE - (`create_app` → `generate_pkce` → `authorization_url` → user pastes - code → `log_in_oauth(..., code_verifier: ...)`), with `log_in_app` - for app-level calls; the password-grant example is removed. -- CHANGELOG: additions listed; `log_in/4` removal called out as - breaking. - -## Out of scope - -- A `Hunter.Token` entity (login functions keep returning - `Hunter.Client`). -- Refresh-token support (Mastodon does not issue refresh tokens). -- `Hunter.ServerMetadata`/`Hunter.UserInfo` structs. -- Any other parity issue (#120–#128). diff --git a/docs/superpowers/specs/2026-07-10-streaming-design.md b/docs/superpowers/specs/2026-07-10-streaming-design.md deleted file mode 100644 index fafd5b6..0000000 --- a/docs/superpowers/specs/2026-07-10-streaming-design.md +++ /dev/null @@ -1,182 +0,0 @@ -# Streaming API: multiplexed WebSocket connection - -Issue: #3. Part of the Mastodon 4.6 API parity effort. Builds on the Req -migration (#103, done) — mint is already in the dependency tree via -Req/Finch. - -## Goal - -Receive timeline/notification updates in real time over Mastodon's -multiplexed WebSocket endpoint (`WSS /api/v1/streaming`, Mastodon 3.3+), -with runtime subscribe/unsubscribe, parsed entity payloads, and a health -check. WebSocket only: the legacy per-stream SSE endpoints are out of -scope (follow-up issue if ever wanted). - -## Decisions (settled during brainstorming) - -- **Transport:** WebSocket only, via a new `mint_web_socket ~> 1.0` - dependency. -- **Consumer API:** a supervised-by-the-caller connection process that - sends parsed events to a subscriber pid as tagged tuples. -- **Reconnection:** none in v1. The process notifies the subscriber and - exits `:normal`; consumers restart it under their own supervision. - Auto-reconnect can be added later without breaking the API. -- **Structure:** one GenServer plus a pure event-parsing module. Hunter - stays a pure library — no OTP application or supervision tree. -- **Testing:** scripted local WebSocket server (test-only `bandit` + - `websock_adapter` deps) for unit tests, plus a happy-path test against - the real Mastodon streaming service in CI. - -## Files - -- `lib/hunter/streaming.ex` — `Hunter.Streaming`, the consumer-facing API -- `lib/hunter/streaming/connection.ex` — `Hunter.Streaming.Connection`, - the GenServer owning the Mint conn and WebSocket state -- `lib/hunter/streaming/event.ex` — `Hunter.Streaming.Event`, the event - struct and pure frame parser -- Delete `lib/hunter/event_stream.ex` (`Hunter.EventStream`): SSE-specific, - added 2017, never wired to anything. Breaking change, documented in the - CHANGELOG under `## Unreleased`. - -## Public API (`Hunter.Streaming`) - -```elixir -@spec connect(Hunter.Client.t(), Keyword.t()) :: {:ok, pid} | {:error, term} -def connect(conn, opts \\ []) -``` - -Opens `wss:///api/v1/streaming?access_token=` -and links the connection process to the caller. Options: - -- `streams:` — initial subscriptions, list of `stream` or `{stream, params}` - (e.g. `["user", {"hashtag", tag: "elixir"}]`), subscribed immediately - after the handshake -- `subscriber:` — pid receiving events, default `self()` -- `url:` — full streaming base URL override (e.g. - `"wss://streaming.example.com"`) for instances whose streaming host - differs from the REST host. Callers can discover it via - `Hunter.instance_info/1` → `configuration["urls"]["streaming"]`; Hunter - does **not** auto-fetch it. - -```elixir -@spec subscribe(pid, String.t(), Keyword.t()) :: :ok -@spec unsubscribe(pid, String.t(), Keyword.t()) :: :ok -``` - -Send the JSON control frame -`{"type": "subscribe" | "unsubscribe", "stream": ..., ...params}`. -Examples: `subscribe(pid, "user")`, `subscribe(pid, "hashtag", tag: "elixir")`, -`subscribe(pid, "list", list: "12")`. Stream names are passed through -verbatim (`"user"`, `"user:notification"`, `"public"`, `"public:media"`, -`"public:local"`, `"public:local:media"`, `"public:remote"`, -`"public:remote:media"`, `"hashtag"`, `"hashtag:local"`, `"list"`, -`"direct"`) — no client-side validation, so new server-side streams work -without a Hunter release. - -```elixir -@spec close(pid) :: :ok -``` - -Graceful shutdown: sends a close frame, delivers -`{:closed, :local}` to the subscriber (same single code path as every -other close), then the process exits `:normal`. - -```elixir -@spec health?(Hunter.Client.t(), Keyword.t()) :: boolean -``` - -`GET /api/v1/streaming/health` (Mastodon 2.5) on the streaming host -(same `url:` override). The endpoint returns plain-text `"OK"`, not JSON, -so this bypasses `Hunter.Api.Transformer` and issues the request directly -through `Req` with `Hunter.Config.req_options/0` (so tests can stub it). -Returns `true` on a 200 `"OK"` body; `false` on anything else, including -transport errors. - -## Messages to the subscriber - -- `{:hunter_stream, connection_pid, %Hunter.Streaming.Event{}}` — one per - parsed event frame -- `{:hunter_stream, connection_pid, {:closed, reason}}` — sent once when - the socket closes for any reason (`reason`: `{:remote, code}` for a - server close frame, `{:error, term}` for a transport drop, `:local` - after `close/1`), after which the process exits `:normal` - -Server ping frames are answered with pong by the connection process and -never surfaced. - -## Event parsing (`Hunter.Streaming.Event`) - -Mastodon WebSocket frames are JSON: -`{"stream": ["user"], "event": "update", "payload": ""}` -— note the payload is a JSON *string* (double-encoded), except for -payload-less events. `parse/1` is pure: frame binary in, struct out. - -```elixir -@type t :: %__MODULE__{ - streams: [String.t()], - type: String.t(), - payload: term - } -``` - -Payload mapping (inner payload decoded via existing -`Hunter.Api.Transformer` targets): - -| `event` | payload decodes to | -|---|---| -| `update`, `status.update` | `Hunter.Status.t` (`:status`) | -| `notification` | `Hunter.Notification.t` (`:notification`) | -| `conversation` | `Hunter.Conversation.t` (`:conversation`) | -| `announcement` | `Hunter.Announcement.t` (`:announcement`) | -| `announcement.reaction` | `Hunter.Announcement.Reaction.t` | -| `delete`, `announcement.delete` | the id, as `String.t` | -| `filters_changed`, `notifications_merged` | `nil` | -| anything else | raw payload passed through undecoded | - -Unknown event types are **delivered**, not dropped — forward -compatibility with new Mastodon releases. - -`announcement.reaction` needs a small new transformer clause -(`:announcement_reaction` → `%Hunter.Announcement.Reaction{}`); all other -targets already exist. - -## Error handling - -- Handshake failure (non-101 response, TLS/TCP error): `connect/2` - returns `{:error, reason}`; no process is left running. -- Malformed/unparseable frame: `Logger.warning` and skip; the connection - stays up. -- Server close frame or transport drop: `{:closed, reason}` message, then - `:normal` exit (linked callers are not killed). - -## Testing - -- **`Hunter.Streaming.Event` unit tests** (pure): one test per event type - from the table above, reusing existing entity fixtures - (`status.json`, `notification.json`, `conversation.json`, - `announcement.json`) wrapped in WS frame JSON; plus unknown-type - passthrough and malformed-frame cases. -- **Connection tests** against a real in-process WebSocket server: - test-only deps `bandit` + `websock_adapter`, a scripted WebSock handler - driven per-test. Covers: handshake carries the `access_token` query - param; `streams:` option and `subscribe/3` emit correct control frames; - event frames arrive as `{:hunter_stream, pid, %Event{}}`; server ping → - client pong; server close → `{:closed, {:remote, code}}` + normal exit; - `close/1` sends a close frame. Real handshake through mint_web_socket — - no transport mocking. -- **`health?/2` unit test** via the existing `Req.Test` stub harness - (plain-text `"OK"` response → `true`; 404 → `false`). -- **Integration** (real Mastodon in CI): add the `streaming` service - (Mastodon's separate Node process; same image, `bundle`-less - `node ./streaming` entrypoint, port 4000) to `docker-compose.ci.yml` - with a healthcheck on `/api/v1/streaming/health`. One happy-path test: - `health?/1` is true; connect to the `user` stream, post a status over - REST, assert the `update` event arrives as `%Hunter.Status{}`. - -## Out of scope - -- SSE transport (the per-stream `GET /api/v1/streaming/*` HTTP endpoints) -- Auto-reconnect/backoff -- Streaming-URL auto-discovery inside `connect/2` -- Admin/direct-message-conversation streams beyond the pass-through - stream names listed above