diff --git a/CHANGELOG.md b/CHANGELOG.md index 39bf780..a895370 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 the file twice. This is the only way the API creates a document, so there is no `create_document`. Enables reqwest's `multipart` feature, which adds `mime`, `mime_guess`, and `unicase` and leaves the MSRV at 1.88. +- Per-resource extras, nineteen methods: `retry_webhook`, `sort_tasks`, + `set_default_design`, `set_default_client_gateway_token`, + `subscription_steps`, `check_subscription_steps`, the company-gateway + `test`/`clone`/`import_customers` trio, four bank-integration and + bank-transaction routes, and six client routes (`update_client_tax_data`, + `client_show_settings`, `client_peppol_discovery`, `check_client_vat`, + `client_vat_status`, `client_documents`). Each is a `POST` unless noted in + `COVERAGE.md`, so none is retried on a 5xx. Adds `MessageResponse` for the + `{"message": ...}` replies and `SubscriptionStep`. +- Password-protected actions, eight methods: `purge_client`, `merge_clients`, + `merge_vendors`, `purge_company`, `purge_company_save_settings`, + `invite_user`, `purge_user`, and `new_user_template`. They need + `with_api_password`, most cannot be undone, and none is retried on a 5xx. + A merge keeps the first id and folds the second into it. Checked against + the upstream controllers and mock servers; not yet against a real instance. +- Activity log, email history and previews, fourteen methods: + `list_activities`, `list_all_activities`, `activity_feed`, + `entity_activity`, `add_activity_note`, `download_activity_entity`, + `client_email_history`, `entity_email_history`, and the previews `preview`, + `preview_html`, `live_preview`, `live_design`, `preview_purchase_order` and + `live_preview_purchase_order`. Adds `Activity`, `ActivityFeedEntry` and + `ActivityRef`. Email history is returned as raw JSON. See `COVERAGE.md` for + which were confirmed against a real server and the easy mistakes (a + singular entity name in one route, a plural in the next). - `FileUpload` and `Error::InvalidUpload`. An upload with no files, a nameless file, or an unparseable MIME type is refused locally, because the server answers an empty upload with a success that changed nothing. @@ -27,7 +51,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 release. The advisory job fails visibly rather than silently; it is kept non-blocking through branch protection rather than `continue-on-error`. - Wrappers for every one of the API's 34 `Route::resource` groups, plus - `users` — 284 methods (including document uploads). See `COVERAGE.md` for the per-endpoint inventory. + `users` — 325 methods (including document uploads, per-resource extras, password-protected actions, and activity, email-history and preview routes). See `COVERAGE.md` for the per-endpoint inventory. - `COVERAGE.md`, a detailed record of what is wrapped, what is not, and why, checked against `routes/api.php` on the `v5-stable` branch. - Retry with full-jitter exponential backoff, configurable through @@ -83,6 +107,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `list_all_*` with `per_page(0)` never terminated: the check `got < 0` has no solution. It now falls back to the default page size, and a `MAX_PAGES` backstop raises `Error::PaginationLimit` rather than quietly truncating. +- Creating a recurring invoice or recurring expense failed after the record + had been created: a real server returns `frequency_id` as the string `"5"` + and the field was a strict integer. It now accepts a number or a numeric + string. +- Creating a purchase order, location, or webhook from a default model was + rejected by a real server. An unset `PurchaseOrder.status_id`, + `Location.country_id`, or `Webhook.format` was sent as `""`, which the + server turns into `NULL` and then refuses (a 500 for the purchase order and + webhook, a 422 for the location). They are now left out of the request when + empty. +- `with_api_password` refused a password containing a control character (a + newline, say) with a misleading `InvalidToken` error. Such a password is + now sent base64-encoded as `X-API-PASSWORD-BASE64`, the form the server's + middleware accepts for exactly this. +- A payment whose `invoices` entry had a `null` or missing `invoice_id` or + `amount` failed to decode the whole payment. Both now default. +- `list_system_logs` failed to decode against a real server. `SystemLog`'s + `created_at` and `updated_at` were typed as strings, following the OpenAPI + spec, but the server sends integer timestamps. They are now `i64`, like + every other model. - Requests could hang indefinitely. `reqwest` applies no timeout of its own and none was configured. - Ids were interpolated into request paths unescaped, so a `/`, `?`, or `#` diff --git a/CLAUDE.md b/CLAUDE.md index 0563c65..c0200af 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,10 +84,51 @@ IN_API_TOKEN=... cargo run --example list_clients ``` Tests live in `tests/` (integration-style, no network access required); -`src/` has no `#[test]` modules. Two files: `model_roundtrip.rs` covers the -serde layer, and `client_requests.rs` covers the request plumbing -(pagination, path escaping, headers, timeouts, error mapping) against a -`wiremock` server on loopback — no credentials or internet needed. +`src/` has no `#[test]` modules. Four files: + +- `model_roundtrip.rs` — the serde layer, from hand-written payloads. +- `client_requests.rs` — the request plumbing (pagination, path escaping, + headers, timeouts, retry, error mapping, one test per endpoint extra) + against a `wiremock` server on loopback. +- `decode_tolerance.rs` — for every model, serialises `Default`, then nulls + and removes each field in turn and requires the result to still decode. + A new model belongs in its list; a new non-`Option` field without + `null_to_default` fails it. +- `real_shapes.rs` — decodes `tests/fixtures/*.json`, which are the *shapes* + (keys and JSON types, no values) of responses captured from a real server, + and checks a decode + re-serialise loses no key. It also refuses fixtures + that hold any real value, and fixtures with no test. + +No credentials or internet needed for any of them. + +**Why the fixtures matter:** twice, mocks written from the upstream source +passed while a real server disagreed (system-log timestamps; the +`/subscriptions/steps` shape), and `frequency_id` and several empty-string +fields were wrong the same way. When the smoke test finds a divergence, +refresh the fixtures so it stays caught: + +```sh +# 1. Fill a DISPOSABLE instance with one row of each kind (writes!). +IN_SMOKE_WRITE_HOST=localhost IN_BASE_URL=... IN_API_TOKEN=... \ + cargo run --example seed_sample_data +# 2. Capture shapes (GET only). Review `git diff tests/fixtures` before committing. +IN_BASE_URL=... IN_API_TOKEN=... cargo run --example capture_shapes +``` + +A resource with no rows is skipped, so the fixture set only covers what the +instance held. Bank integrations, company gateways, client gateway tokens, +subscriptions, task schedulers, and recurring quotes are not captured: they +need external accounts, or (recurring quotes on 5.13.50) the server's own +insert fails. + +**Fields omitted when empty.** The server runs `ConvertEmptyStringsToNull`, +so an unset `String` serialised as `""` becomes `NULL`, which a non-null +column or an `integer` rule then rejects. `Location.country_id`, +`PurchaseOrder.status_id` and `Webhook.format` therefore carry +`skip_serializing_if = "String::is_empty"`. If a create from a `Default` +model 500s or 422s on an empty field, do the same — and add the path to +`OMITTED_WHEN_EMPTY` in `tests/real_shapes.rs` and `examples/smoke_test.rs`, +which is deliberately a list of decisions rather than a blanket exemption. ### Testing against a real server @@ -112,10 +153,20 @@ tier leaves a gap in the client number sequence and a soft-deleted row (a real purge needs the account password), and it may fire webhooks. Use a disposable instance — a local VM or container — for it. +Beyond "does it decode", the read tier fetches the same rows raw and fails +if a typed row lost a key (`keys kept: `, names only, never +values), and the write tier creates a purchase order, location, webhook and +recurring invoice from bare `Default` models, since those are what broke. +With `IN_API_PASSWORD` set as well, a password tier merges two throwaway +clients and purges the survivor (a real, permanent delete); it has not yet +been run against an instance. `.github/workflows/smoke.yml` runs it on demand (`workflow_dispatch` only, +needing `IN_BASE_URL` and `IN_API_TOKEN` secrets); the write tier runs only if +the `write_host` input names the instance's host. + It prints PASS/FAIL per check and never the contents of a record. The upload -wire format (`POST` plus `_method=PUT`) was read from the framework source -rather than observed, so the first run of the write tier against a real -instance is the real verification of it. +wire format (`POST` plus `_method=PUT`) was read from the framework source, +then confirmed by running the write tier against a disposable Invoice Ninja +5.13.50 instance (2026-10-24): single and multi-file uploads both attached. ## Changelog @@ -264,18 +315,18 @@ changes, doc tweaks) does not need an entry. ## Coverage **All 34 of the API's `Route::resource` groups are wrapped**, plus `users` -(which isn't one). 284 methods: 30 resources with CRUD + bulk, 3 with CRUD -only, 4 with entity actions, 16 with document uploads, and 28 hand-written. +(which isn't one). 325 methods: 30 resources with CRUD + bulk, 3 with CRUD +only, 4 with entity actions, 16 with document uploads, and 69 hand-written. Plus the non-CRUD surface: documents, entity actions and PDF downloads for the four action-capable resources, `report`, `chart`, `search`, `statics`, `send_email`, `refund_payment`, `ping`, and `health_check`. **`COVERAGE.md` is the detailed inventory** — every wrapped endpoint, every -gap, and the reason for each gap. The main remaining gaps are a handful of -per-resource extras -(`clients/{id}/purge`, `company_gateways/{id}/test`, …), and the -integration/auth routes. +gap, and the reason for each gap. The main remaining gaps are the +integration and auth routes, import/export, the public invitation-key +downloads (which take no auth token, so they don't fit the client's +request path), and a few company and user extras. Keep `COVERAGE.md` current when adding or removing an endpoint: it names exact counts and route groups, so it goes stale quietly. The three coverage diff --git a/COVERAGE.md b/COVERAGE.md index 3726fac..b36751d 100644 --- a/COVERAGE.md +++ b/COVERAGE.md @@ -18,7 +18,7 @@ declares 34 `Route::resource` groups and 274 explicit route declarations > exists or what it returns. **All 34 `Route::resource` groups are now wrapped**, plus `users` (which is -not a resource group). 284 methods in total. +not a resource group). 325 methods in total. ## Resources with full CRUD + bulk @@ -58,6 +58,10 @@ not a resource group). 284 methods in total. | Task Schedulers | `/task_schedulers` | `TaskScheduler` | | Tokens | `/tokens` | `CompanyToken` | +`create_recurring_quote` currently 500s against Invoice Ninja 5.13.50: the +server's own insert names a `design_id` column its `recurring_quotes` table +does not have. The request is well-formed; nothing here can work around it. + `bulk_*` accepts any action the endpoint supports; `archive`, `restore`, and `delete` work on all of them. An `ids` list matching nothing returns an empty `Vec`, not an error. @@ -212,6 +216,103 @@ the whole tail — `chart("project_burnup/", &body)`. Large report exports are queued and emailed rather than returned inline; in that case the body is a short JSON message instead of CSV. +## Activities, email history and previews + +Each has a response shape of its own. Marked *observed* where confirmed +against a 5.13.50 server. + +| Endpoint | Method | Returns | +| --- | --- | --- | +| `GET /activities` | `list_activities`, `list_all_activities` | `Vec` (*observed*) | +| `GET /activities?reactv2=1` | `activity_feed(rows)` | `Vec` (*observed*) | +| `POST /activities/entity` | `entity_activity(entity, id, rows)` | `Vec` (*observed*) | +| `POST /activities/notes` | `add_activity_note(entity, id, notes)` | `Activity` (*observed*) | +| `GET /activities/download_entity/{id}` | `download_activity_entity` | PDF bytes | +| `POST /emails/clientHistory/{client}` | `client_email_history` | `Vec` | +| `POST /emails/entityHistory` | `entity_email_history` | `Vec` | +| `POST /preview` | `preview`, `preview_html` | PDF bytes / HTML | +| `POST /live_preview` | `live_preview` | PDF bytes (*observed*) | +| `POST /live_design` | `live_design` | PDF bytes | +| `POST /preview/purchase_order` | `preview_purchase_order` | PDF bytes | +| `POST /live_preview/purchase_order` | `live_preview_purchase_order` | PDF bytes | + +Things that are easy to get wrong: + +- **The entity name is singular in one route and plural in the next.** + `entity_activity` takes `invoice`; `add_activity_note` takes `invoices`. +- **`rows`, not `per_page`, caps the activity log** (default 75). Pass it + through `ListParams::param`. `entity_activity` leaves `rows` out when not + given rather than sending `null`, because the server tests for the key and + a null would count as given and remove the cap. +- **The two activity shapes differ.** `Activity` carries hashed ids and a + string `activity_type_id`; the feed's `ActivityFeedEntry` carries a numeric + one and labelled references (`invoice`, `client`, … each `{label, + hashed_id}`). +- **Email history is raw JSON.** The objects mirror what the mail provider + reported, and the instance available had never sent mail, so there was no + shape to take. +- **Not yet run against a real server:** `preview`, `preview_html`, and both + purchase-order previews (they need a design object or a purchase order and a + PDF backend), and `live_design`, which answered a bare minimal request with + a 500 from the server on 5.13.50. + +## Password-protected actions + +Behind the `password_protected` middleware, so each needs +`with_api_password` (the server also lets a recent password-authenticated +session through without the header). Most are **irreversible**, and all are +`POST`s except one, so none is retried on a 5xx. + +| Endpoint | Method | Returns | +| --- | --- | --- | +| `POST /clients/{id}/purge` | `purge_client` | `MessageResponse` | +| `POST /clients/{id}/{other}/merge` | `merge_clients(id, other)` | the surviving `Client` | +| `POST /vendors/{id}/{other}/merge` | `merge_vendors(id, other)` | the surviving `Vendor` | +| `POST /companies/purge/{id}` | `purge_company` | `MessageResponse` | +| `POST /companies/purge_save_settings/{id}` | `purge_company_save_settings` | `MessageResponse` | +| `POST /users/{id}/invite` | `invite_user` | `MessageResponse` | +| `POST /users/{id}/purge` | `purge_user` | `()` (the server answers 204) | +| `GET /users/create` | `new_user_template` | a blank `User`, nothing stored | + +A merge keeps the first id and folds the second into it. These are verified +against the upstream controllers and mock servers only: running them needs the +account password, so the smoke test's optional password tier (merge two +throwaway clients, purge the survivor) has not yet been run against a real +instance. A password containing a control character cannot be a header value +and goes out as `X-API-PASSWORD-BASE64` instead. + +## Per-resource extras + +Routes that sit beside a resource's CRUD set, each with its own response +shape (read from the upstream controllers; the ones marked *observed* were +also confirmed against a 5.13.50 server). All are `POST` unless noted, so none +is retried on a 5xx or a timeout. + +| Endpoint | Method | Returns | +| --- | --- | --- | +| `POST /webhooks/{id}/retry` | `retry_webhook` | `Webhook` | +| `POST /tasks/sort` | `sort_tasks` | `MessageResponse` | +| `POST /designs/set/default` | `set_default_design` | `MessageResponse` | +| `POST /client_gateway_tokens/{id}/setAsDefault` | `set_default_client_gateway_token` | `ClientGatewayToken` | +| `GET /subscriptions/steps` | `subscription_steps` | `Vec` (*observed*: an object keyed by class name, not the array the source suggests) | +| `POST /subscriptions/steps/check` | `check_subscription_steps` | `()` | +| `POST /company_gateways/{id}/test` | `test_company_gateway` | `MessageResponse` | +| `POST /company_gateways/{id}/clone` | `clone_company_gateway` | `CompanyGateway` | +| `POST /company_gateways/{id}/import_customers` | `import_company_gateway_customers` | `MessageResponse` | +| `POST /bank_integrations/refresh_accounts` | `refresh_bank_accounts` | `()` (the body is a serialised query builder) | +| `POST /bank_integrations/remove_account/{id}` | `remove_bank_account` | `BankIntegration` | +| `POST /bank_integrations/get_transactions/{id}` | `fetch_bank_transactions` | `MessageResponse` (the server ignores the id) | +| `POST /bank_transactions/match` | `match_bank_transactions` | `Vec` | +| `POST /clients/{id}/updateTaxData` | `update_client_tax_data` | `Client` | +| `POST /clients/{id}/show_settings` | `client_show_settings` | `serde_json::Value` (*observed*) | +| `POST /clients/{id}/peppol_discovery` | `client_peppol_discovery` | `serde_json::Value` | +| `POST /clients/{id}/check_vat` | `check_client_vat` | `MessageResponse` | +| `GET /clients/{id}/vat_status` | `client_vat_status` | `MessageResponse` (*observed*) | +| `POST /clients/{id}/documents` | `client_documents` | `Vec` (*observed*; one page) | + +`updateTaxData` and `check_vat` call out to external tax services, so the +smoke test does not exercise them. + ## Not covered ### Nothing is blocked on a dependency @@ -231,42 +332,14 @@ from `src/models/mod.rs`, and add a `resource_methods!` invocation in `src/client.rs` (or `crud_methods!` if the resource has no `/bulk` route — check `routes/api.php`). -### Password-protected endpoints still unwrapped - -`with_api_password` makes these reachable, but the methods themselves aren't -written yet: - -- `POST /clients/{id}/purge` -- `POST /clients/{id}/{other}/merge` -- `POST /vendors/{id}/{other}/merge` -- `POST /companies/purge/{company}`, - `POST /companies/purge_save_settings/{company}` -- `POST /users/{id}/invite`, `POST /users/{id}/purge`, `GET /users/create` - ### Individual endpoints not wrapped -- **Client extras** — `updateTaxData`, `show_settings`, `peppol_discovery`, - `check_vat`, `vat_status`, `documents` -- **Bank integration extras** — `refresh_accounts`, `remove_account/{id}`, - `get_transactions/{id}`, and `POST /bank_transactions/match` -- **Company gateway extras** — `test`, `clone`, `import_customers` - **Company extras** — `GET /companies/{id}/logo`, `POST /companies/{id}/default`, `updateOriginTaxData`, `POST /companies/current` -- **Design extras** — `POST /designs/set/default` -- **Client gateway token extras** — `setAsDefault` -- **Task extras** — `POST /tasks/sort` -- **Webhook extras** — `POST /webhooks/{id}/retry` -- **Subscription extras** — `GET /subscriptions/steps`, - `POST /subscriptions/steps/check` - **User extras** (not password-protected, unlike the rest of `/users`) — `POST /users/{id}/disconnect_mailer`, `POST /users/{id}/disconnect_oauth`, `POST /user/{id}/reconfirm` -- **Preview** — `POST /preview`, `POST /preview/purchase_order`, live preview -- **Activities** — `GET /activities`, `POST /activities/entity`, - `POST /activities/notes`, `download_entity/{activity}` -- **Email history** — `POST /emails/clientHistory/{client}`, - `POST /emails/entityHistory` - **Auth** — `/login`, `/logout`, `/refresh`, `/connected_account`, passkey routes - **Import/export** — `/import`, `/preimport`, `/import_json`, `/export`, diff --git a/GOALS.md b/GOALS.md index 2a31ecc..958dcab 100644 --- a/GOALS.md +++ b/GOALS.md @@ -149,18 +149,29 @@ way a document is created. There is no `create_document` and there will not be one. `edocument/upload` was left out because it imports an expense from an e-document rather than attaching a file, so it belongs with item 6. +**Done:** the per-resource extras — webhook retry, task sort, default +design, default gateway token, subscription steps, and the client, +bank-integration, and company-gateway routes (19 methods). The numbering +below keeps its original item numbers. Running the smoke test against a real +server overturned one reading of the source here: `/subscriptions/steps` +answers with an object keyed by class name, not the array the controller +suggests. That is the case for keeping the real-server tier. + +**Done:** the password-protected actions — client purge and merge, vendor +merge, company purge, user invite and purge, and the blank-user template (8 +methods). They are verified against the upstream controllers and mocks only; +the smoke test has a password tier for the first real-server run +(`IN_API_PASSWORD`), which has not happened yet. The claim here that +`with_api_password` already made them reachable was not quite true: a +password with a control character was refused locally. + +**Done:** activities, email history and previews (14 methods, two new +models). The email history stays raw JSON, since no instance here had sent +mail to take a shape from; promoting it to a model needs a sample. Several +previews are unverified against a real server (see `COVERAGE.md`). + Still to do: -2. **Per-resource extras** — mechanical, and they reuse existing patterns: - `webhooks/{id}/retry`, `tasks/sort`, `designs/set/default`, - `client_gateway_tokens/{id}/setAsDefault`, `subscriptions/steps`, the - client extras (`updateTaxData`, `show_settings`, `check_vat`, …), the - bank-integration and company-gateway extras. -3. **Password-protected actions** — `with_api_password` already makes these - reachable; only the methods are missing. Client purge and merge, vendor - merge, company purge, user invite and purge. -4. **Preview, activities, and email history** — distinct response shapes, so - each needs its own hand-written method and probably its own model. 5. **Public invitation-key downloads** — eight routes that take no auth token, so they do not fit the authenticated request path and need a different entry point. diff --git a/README.md b/README.md index 11b1f03..ebddd66 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,7 @@ IN_API_TOKEN=... cargo run --example list_clients ## Coverage -Every one of the API's 34 resource groups, plus users — 284 methods. Each +Every one of the API's 34 resource groups, plus users — 325 methods. Each resource has `list`, `list_all`, `get`, `create`, `update`, `delete`, and (where the API offers one) `bulk`: