Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2532b83a32 | ||
|
|
e7c9663621 | ||
|
|
0485272080 | ||
|
|
a7d9a2d9d6 | ||
|
|
46a6ea1322 | ||
|
|
83270e8262 | ||
|
|
f616da08db | ||
|
|
bd2d34cae9 | ||
|
|
0002d32197 | ||
|
|
9e7d81307d | ||
|
|
e4f2373b53 |
@@ -0,0 +1,47 @@
|
||||
name: Smoke test
|
||||
|
||||
# Runs `examples/smoke_test.rs` against a real Invoice Ninja instance. Manual
|
||||
# only: it needs an instance the runner can reach and credentials for it, and
|
||||
# the offline suite in `ci.yml` is what gates merges. This is for catching the
|
||||
# places where the real server disagrees with the upstream source the mocks
|
||||
# were written from.
|
||||
#
|
||||
# Repository secrets required: IN_BASE_URL (the API root, ending /api/v1) and
|
||||
# IN_API_TOKEN. Point them at a disposable instance, never at production.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
write_host:
|
||||
description: >-
|
||||
Leave empty for the read-only tier (GET requests only). To also run
|
||||
the write tier, type the host of IN_BASE_URL (e.g. `localhost`). It
|
||||
creates and deletes records, so the instance must be disposable.
|
||||
required: false
|
||||
default: ''
|
||||
type: string
|
||||
runner:
|
||||
description: Runner label; use a self-hosted one if the instance is not public.
|
||||
required: false
|
||||
default: ubuntu-latest
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
smoke:
|
||||
runs-on: ${{ inputs.runner }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# Same pin as `ci.yml` and `devVersion` in flake.nix.
|
||||
- uses: dtolnay/rust-toolchain@1.99.1
|
||||
- run: cargo run --example smoke_test
|
||||
env:
|
||||
IN_BASE_URL: ${{ secrets.IN_BASE_URL }}
|
||||
IN_API_TOKEN: ${{ secrets.IN_API_TOKEN }}
|
||||
# The program refuses to write unless this equals the host in
|
||||
# IN_BASE_URL, so a mistyped host skips the write tier.
|
||||
IN_SMOKE_WRITE_HOST: ${{ inputs.write_host }}
|
||||
# Optional. Only used inside the write tier, where it enables the
|
||||
# password tier (merge two throwaway clients, purge the survivor).
|
||||
IN_API_PASSWORD: ${{ secrets.IN_API_PASSWORD }}
|
||||
+59
-1
@@ -9,6 +9,40 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Added
|
||||
|
||||
- Document uploads: `upload_client`, `upload_invoice`, `upload_expense`, and
|
||||
thirteen more, one for each resource with an `/{id}/upload` route. They
|
||||
take a `Vec<FileUpload>` and an optional `is_public`, and return the
|
||||
refreshed record. Uploads are never retried, since a replay could attach
|
||||
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.
|
||||
- A declared minimum supported Rust version of **1.88**, verified by running
|
||||
the test suite against it. The floor comes from the dependency graph
|
||||
(`reqwest` → `url` → `idna` → `icu_*`), not from this crate's own code.
|
||||
@@ -17,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` — 265 methods. 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
|
||||
@@ -58,6 +92,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
from a proxy or a login redirect from a wrong base URL.
|
||||
- API errors with an empty or message-less body now report the status reason
|
||||
("Not Found") instead of rendering blank.
|
||||
- `Error::Http` now renders its whole cause chain. `reqwest`'s own message
|
||||
stops at "error sending request for url (...)", leaving out the part that
|
||||
says why — "Connection refused", a DNS failure, a TLS handshake error — so
|
||||
anyone printing the error had no way to tell which it was.
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -69,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 `#`
|
||||
|
||||
@@ -84,10 +84,90 @@ 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
|
||||
|
||||
Everything above is checked against mock servers written from the upstream
|
||||
source, so it cannot catch a place where the real server disagrees with that
|
||||
reading. `examples/smoke_test.rs` can:
|
||||
|
||||
```sh
|
||||
# Read-only (GET requests only). IN_BASE_URL has no default, on purpose.
|
||||
IN_BASE_URL=http://localhost:8080/api/v1 IN_API_TOKEN=... \
|
||||
cargo run --example smoke_test
|
||||
|
||||
# Adds a write tier: create a throwaway client, upload files to it, delete it.
|
||||
IN_SMOKE_WRITE_HOST=localhost IN_BASE_URL=... IN_API_TOKEN=... \
|
||||
cargo run --example smoke_test
|
||||
```
|
||||
|
||||
**Never point the write tier at a production server.** The guard is that
|
||||
`IN_SMOKE_WRITE_HOST` must equal the host in `IN_BASE_URL`, so a write only
|
||||
reaches a server that was named explicitly. Even after its delete the write
|
||||
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: <resource>`, 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 was run
|
||||
against a disposable 5.13.50 instance on 2026-10-24 and all four checks
|
||||
pass. `.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,
|
||||
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
|
||||
|
||||
@@ -161,6 +241,18 @@ changes, doc tweaks) does not need an entry.
|
||||
elsewhere without checking `routes/api.php`. In particular the official PHP
|
||||
SDK puts bulk `download` on *every* resource, which is wrong; only
|
||||
PDF-rendering entities support it.
|
||||
- `upload_methods!` generates `upload_{x}` for the 16 resources with a
|
||||
`/{id}/upload` route; documents can only be created this way, because
|
||||
`DocumentController::store` is an empty stub upstream (hence no
|
||||
`create_document`). Three things are easy to get wrong, all verified
|
||||
against the upstream source: the request is a **`POST` with
|
||||
`_method=PUT`** (PHP does not parse multipart on a real `PUT`, and the
|
||||
spec's `_method=POST` would 405); `is_public` goes out as **`1`/`0`**
|
||||
(Laravel's strict `boolean` rule rejects the string `"true"`); and uploads
|
||||
are **never retried**, because reqwest's multipart body is not cloneable
|
||||
and `execute` gives an un-cloneable request exactly one attempt — a test
|
||||
pins this, since nothing in the types would notice if it changed. Don't
|
||||
"fix" any of the three.
|
||||
- Non-CRUD endpoints (documents, reports, charts, search, statics, emails,
|
||||
refunds, ping/health) are hand-written in the last `impl` block rather than
|
||||
macro-generated, because their request and response shapes are all
|
||||
@@ -220,22 +312,28 @@ changes, doc tweaks) does not need an entry.
|
||||
decode, API error responses (with per-field validation `errors` map), and
|
||||
client construction errors. `ApiErrorBody` mirrors Invoice Ninja's error
|
||||
JSON shape.
|
||||
- **A missing record is a `400`, not a `404`.** `Handler.php` maps Laravel's
|
||||
`ModelNotFoundException` to a 400 carrying `No query results for model
|
||||
[App\Models\X].`, and keeps 404 for a route that does not exist or a method
|
||||
it does not support. So don't write a not-found check against 404 — the
|
||||
smoke test's post-purge assertion did, and passed only because it had
|
||||
never been run. Match the status together with the message.
|
||||
|
||||
## Coverage
|
||||
|
||||
**All 34 of the API's `Route::resource` groups are wrapped**, plus `users`
|
||||
(which isn't one). 265 methods: 30 resources with CRUD + bulk, 3 with CRUD
|
||||
only, 4 with entity actions, and 25 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 multipart
|
||||
uploads (needs a reqwest feature), 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
|
||||
|
||||
+156
-36
@@ -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). 265 methods in total.
|
||||
not a resource group). 325 methods in total.
|
||||
|
||||
## Resources with full CRUD + bulk
|
||||
|
||||
@@ -58,6 +58,10 @@ not a resource group). 265 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.
|
||||
@@ -123,6 +127,54 @@ same reason: the response isn't JSON.
|
||||
|
||||
Convenience wrappers: `mark_invoice_sent` and `mark_invoice_paid`.
|
||||
|
||||
## Document uploads
|
||||
|
||||
`PUT /{resource}/{id}/upload` exists for 16 resources, each getting
|
||||
`upload_{singular}(id, files, is_public)` that returns the refreshed record:
|
||||
|
||||
| Resource | Method |
|
||||
| --- | --- |
|
||||
| Clients | `upload_client` |
|
||||
| Companies | `upload_company` |
|
||||
| Credits | `upload_credit` |
|
||||
| Expenses | `upload_expense` |
|
||||
| Group Settings | `upload_group_setting` |
|
||||
| Invoices | `upload_invoice` |
|
||||
| Payments | `upload_payment` |
|
||||
| Products | `upload_product` |
|
||||
| Projects | `upload_project` |
|
||||
| Purchase Orders | `upload_purchase_order` |
|
||||
| Quotes | `upload_quote` |
|
||||
| Recurring Expenses | `upload_recurring_expense` |
|
||||
| Recurring Invoices | `upload_recurring_invoice` |
|
||||
| Recurring Quotes | `upload_recurring_quote` |
|
||||
| Tasks | `upload_task` |
|
||||
| Vendors | `upload_vendor` |
|
||||
|
||||
This is the *only* way the API creates a document:
|
||||
`DocumentController::store` is an empty stub upstream, so `Document` has
|
||||
`list`, `get`, `update`, `delete`, `download`, and `bulk`, but no
|
||||
`create_document`, and there will not be one.
|
||||
|
||||
Three details that are easy to get wrong, all verified against the upstream
|
||||
source rather than the published spec:
|
||||
|
||||
- **The request is a `POST` carrying `_method=PUT`.** The route is
|
||||
registered as `PUT`, but PHP does not parse a multipart body on a `PUT`, so
|
||||
the files would never arrive. Laravel's kernel turns the spoofed method back
|
||||
into a `PUT` before routing. The OpenAPI spec documents `POST` with
|
||||
`_method=POST`, which would 405 against these routes.
|
||||
- **`is_public` is sent as `1`/`0`.** Laravel's `boolean` rule accepts only
|
||||
`true`, `false`, `0`, `1`, `'0'`, and `'1'` under strict comparison, so the
|
||||
string `"true"` fails validation.
|
||||
- **Uploads are never retried**, even on a 429. An upload adds a document, so
|
||||
it is not idempotent whatever its verb, and a replay after a lost response
|
||||
would attach the file twice.
|
||||
|
||||
The server enforces file type and size limits; a refusal comes back as a 422
|
||||
`Error::Api` with the per-field `errors` map. Files are held in memory while
|
||||
uploading.
|
||||
|
||||
## Other wrapped endpoints
|
||||
|
||||
| Endpoint | Method |
|
||||
@@ -164,16 +216,112 @@ the whole tail — `chart("project_burnup/<id>", &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<Activity>` (*observed*) |
|
||||
| `GET /activities?reactv2=1` | `activity_feed(rows)` | `Vec<ActivityFeedEntry>` (*observed*) |
|
||||
| `POST /activities/entity` | `entity_activity(entity, id, rows)` | `Vec<ActivityFeedEntry>` (*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<serde_json::Value>` |
|
||||
| `POST /emails/entityHistory` | `entity_email_history` | `Vec<serde_json::Value>` |
|
||||
| `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. The smoke test's
|
||||
optional password tier (merge two throwaway clients, purge the survivor) was
|
||||
run against a disposable 5.13.50 instance on 2026-10-24 and passes; it also
|
||||
established that a purged record reads back as a **400**, not a 404. 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<SubscriptionStep>` (*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<BankTransaction>` |
|
||||
| `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<Document>` (*observed*; one page) |
|
||||
|
||||
`updateTaxData` and `check_vat` call out to external tax services, so the
|
||||
smoke test does not exercise them.
|
||||
|
||||
## Not covered
|
||||
|
||||
### Blocked on a dependency change
|
||||
### Nothing is blocked on a dependency
|
||||
|
||||
**Multipart uploads** — `PUT /{resource}/{id}/upload` exists for clients,
|
||||
companies, credits, expenses, group_settings, invoices, payments, products,
|
||||
projects, purchase_orders, quotes, recurring_expenses, recurring_invoices,
|
||||
recurring_quotes, tasks, and vendors. These need reqwest's `multipart`
|
||||
feature, which isn't currently enabled. This is the main reason `Document`
|
||||
has no `create_document`.
|
||||
Multipart uploads used to be, and are now wrapped — see [Document
|
||||
uploads](#document-uploads). One upload route is deliberately left out:
|
||||
`PUT /edocument/upload` is not a document attachment. It imports an expense
|
||||
from an e-document (`ExpenseController::edocument`), and is better treated as
|
||||
an import endpoint alongside the others under *Import/export* below.
|
||||
|
||||
### Resource groups not wrapped
|
||||
|
||||
@@ -184,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`,
|
||||
|
||||
Generated
+24
@@ -600,6 +600,22 @@ version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "mime"
|
||||
version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "mime_guess"
|
||||
version = "2.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
|
||||
dependencies = [
|
||||
"mime",
|
||||
"unicase",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.2.4"
|
||||
@@ -776,6 +792,7 @@ dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
@@ -784,6 +801,7 @@ dependencies = [
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"mime_guess",
|
||||
"native-tls",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
@@ -1163,6 +1181,12 @@ version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||
|
||||
[[package]]
|
||||
name = "unicase"
|
||||
version = "2.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.27"
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ license = "MIT"
|
||||
repository = "https://github.com/awkawb1/invoiceninja-sdk"
|
||||
|
||||
[dependencies]
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "native-tls"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "native-tls", "multipart"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
thiserror = "2"
|
||||
|
||||
@@ -121,7 +121,7 @@ releases. `CHANGELOG.md` records them, and breaking changes should be
|
||||
legible there rather than discovered at compile time.
|
||||
|
||||
**Gitea is the source of truth; GitHub is a one-way mirror.** The canonical
|
||||
repository is `gitea.awkawb.cloud/awkawb/invoiceninja`. A Gitea push mirror
|
||||
repository is `gitea.awkawb.cloud/awkawb/invoiceninja-sdk`. A Gitea push mirror
|
||||
copies every commit to <https://github.com/awkawb1/invoiceninja-sdk>, which
|
||||
is what `Cargo.toml` points at. Nothing should be written to the GitHub
|
||||
copy: the next sync overwrites it, so a merged pull request there would
|
||||
@@ -141,27 +141,44 @@ All 34 `Route::resource` groups are wrapped. Full coverage is the target, so
|
||||
`COVERAGE.md`'s gap list is a to-do list, not a permanent boundary. Roughly
|
||||
in order of value over effort:
|
||||
|
||||
1. **Multipart uploads** — 16 `PUT /{resource}/{id}/upload` routes plus
|
||||
`edocument/upload`. Blocked only on enabling reqwest's `multipart`
|
||||
feature. This is also what makes `create_document` possible, so it
|
||||
unblocks the one obviously missing piece of an otherwise complete
|
||||
resource.
|
||||
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.
|
||||
**Done:** multipart document uploads, for the 16 resources that have the
|
||||
route (`upload_client`, `upload_invoice`, …). This earlier claimed it would
|
||||
also make `create_document` possible, which was wrong: upstream
|
||||
`DocumentController::store` is an empty stub, and an upload is the *only*
|
||||
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:
|
||||
|
||||
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.
|
||||
6. **Import/export and the integrations** — Stripe, QuickBooks, einvoice
|
||||
(17 routes), Yodlee, Nordigen, calendar. The largest block and the least
|
||||
SDK-shaped; worth doing last and possibly in pieces.
|
||||
6. **Import/export and the integrations** — `/import`, `/export`,
|
||||
`edocument/upload`, Stripe, QuickBooks, einvoice (17 routes), Yodlee,
|
||||
Nordigen, calendar. The largest block and the least SDK-shaped; worth
|
||||
doing last and possibly in pieces.
|
||||
|
||||
Keep `COVERAGE.md` current as these land — it names exact counts, so it goes
|
||||
stale quietly.
|
||||
@@ -178,9 +195,12 @@ These are genuinely undecided. Deciding one is a change to this file.
|
||||
eleven chart endpoints share no response shape. Per-chart structs would
|
||||
be eleven small models of unstable payloads; unclear whether that earns
|
||||
its keep.
|
||||
- **Large responses are buffered entirely into memory.** PDF downloads and
|
||||
CSV report exports go through `Vec<u8>`. Streaming would be better for
|
||||
big exports but changes those signatures.
|
||||
- **Large bodies are buffered entirely into memory.** PDF downloads and CSV
|
||||
report exports come back as `Vec<u8>`, and an uploaded `FileUpload` holds
|
||||
its whole contents until the request finishes. Streaming would be better
|
||||
for big files in both directions but changes those signatures — and for
|
||||
uploads it would also change the retry story, which currently depends on
|
||||
the multipart body being un-cloneable.
|
||||
- **`Error::PaginationLimit` has no test**, because reaching it needs
|
||||
10,001 mock round trips. Making `MAX_PAGES` injectable purely for a test
|
||||
may be worse than the gap.
|
||||
|
||||
@@ -9,7 +9,7 @@ Requires Rust 1.88 or newer.
|
||||
> [`GOALS.md`](GOALS.md) covers what the crate is for and what it is not.
|
||||
|
||||
> **Development happens on
|
||||
> [Gitea](https://gitea.awkawb.cloud/awkawb/invoiceninja); the
|
||||
> [Gitea](https://gitea.awkawb.cloud/awkawb/invoiceninja-sdk); the
|
||||
> [GitHub copy](https://github.com/awkawb1/invoiceninja-sdk) is a read-only
|
||||
> mirror.** Anything pushed to GitHub is overwritten by the next sync, so
|
||||
> please open issues and pull requests on Gitea.
|
||||
@@ -100,7 +100,7 @@ IN_API_TOKEN=... cargo run --example list_clients
|
||||
|
||||
## Coverage
|
||||
|
||||
Every one of the API's 34 resource groups, plus users — 265 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`:
|
||||
|
||||
@@ -158,15 +158,33 @@ let pdf: Vec<u8> = client.download_invoice("abc123").await?;
|
||||
let merged: Vec<u8> = client.download_invoices(&["abc123", "def456"]).await?;
|
||||
```
|
||||
|
||||
Reports return their raw export body (CSV for most of them), and the
|
||||
schemaless endpoints decode into any type you choose:
|
||||
Documents are attached through the record they belong to, since that is the
|
||||
only way the API creates one. Sixteen resources support it, as
|
||||
`upload_client`, `upload_invoice`, `upload_expense`, and so on:
|
||||
|
||||
```rust
|
||||
use invoiceninjars::FileUpload;
|
||||
|
||||
let receipt = FileUpload::new("receipt.pdf", std::fs::read("receipt.pdf")?)
|
||||
.mime("application/pdf");
|
||||
|
||||
// `Some(true)` makes the document visible to the client in the portal.
|
||||
let expense = client.upload_expense("abc123", vec![receipt], Some(false)).await?;
|
||||
```
|
||||
|
||||
Files are held in memory while uploading, and an upload is never retried,
|
||||
since a replay after a lost response would attach the file twice.
|
||||
|
||||
Reports return their raw export body (CSV for most of them). `statics`,
|
||||
`search`, and `health_check` return typed results; `chart` has no common
|
||||
response shape, so it decodes into any type you choose:
|
||||
|
||||
```rust
|
||||
use serde_json::{json, Value};
|
||||
|
||||
let csv: Vec<u8> = client.report("invoices", &json!({"date_range": "this_year"})).await?;
|
||||
let totals: Value = client.chart("totals_v2", &json!({})).await?;
|
||||
let statics: Value = client.statics().await?;
|
||||
let statics = client.statics().await?;
|
||||
```
|
||||
|
||||
Add more resources by adding a model under `src/models/` and a
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
//! Records the *shape* of real Invoice Ninja responses as test fixtures.
|
||||
//!
|
||||
//! Mock servers written from the upstream source have been wrong about the
|
||||
//! real thing before (system-log timestamps, `/subscriptions/steps`), so the
|
||||
//! offline suite benefits from fixtures that came from a real server. This
|
||||
//! writes them without writing any data: every string becomes `""`, every
|
||||
//! number `0`, every bool `false`, and an array keeps only its first element.
|
||||
//! What survives is the key names and the JSON types, which is what decoding
|
||||
//! depends on. `tests/real_shapes.rs` refuses to pass if a fixture holds
|
||||
//! anything else.
|
||||
//!
|
||||
//! GET requests only, so it is safe against any instance, but it still reads
|
||||
//! real records in memory. Review the diff before committing.
|
||||
//!
|
||||
//! ```sh
|
||||
//! IN_BASE_URL=http://localhost:8080/api/v1 IN_API_TOKEN=... \
|
||||
//! cargo run --example capture_shapes
|
||||
//! ```
|
||||
//!
|
||||
//! A resource with no rows on the instance writes nothing and is reported as
|
||||
//! skipped, since an empty list says nothing about a row's shape. Populate a
|
||||
//! disposable instance and re-run to capture more.
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
/// Resources captured as a one-row list. The name doubles as the fixture
|
||||
/// filename.
|
||||
const LISTS: &[&str] = &[
|
||||
"activities",
|
||||
"bank_integrations",
|
||||
"bank_transaction_rules",
|
||||
"bank_transactions",
|
||||
"client_gateway_tokens",
|
||||
"clients",
|
||||
"companies",
|
||||
"company_gateways",
|
||||
"credits",
|
||||
"designs",
|
||||
"documents",
|
||||
"expense_categories",
|
||||
"expenses",
|
||||
"group_settings",
|
||||
"invoices",
|
||||
"locations",
|
||||
"payment_terms",
|
||||
"payments",
|
||||
"products",
|
||||
"projects",
|
||||
"purchase_orders",
|
||||
"quotes",
|
||||
"recurring_expenses",
|
||||
"recurring_invoices",
|
||||
"recurring_quotes",
|
||||
"subscriptions",
|
||||
"system_logs",
|
||||
"tags",
|
||||
"task_schedulers",
|
||||
"task_statuses",
|
||||
"tasks",
|
||||
"tax_rates",
|
||||
"tokens",
|
||||
"users",
|
||||
"vendors",
|
||||
"webhooks",
|
||||
];
|
||||
|
||||
/// Endpoints with no `data` envelope, captured whole.
|
||||
const BARE: &[(&str, &str)] = &[
|
||||
("statics", "statics"),
|
||||
("health_check", "health_check"),
|
||||
("subscription_steps", "subscriptions/steps"),
|
||||
("activity_feed", "activities?rows=1&reactv2=1"),
|
||||
];
|
||||
|
||||
/// Replaces every value with a placeholder of the same JSON type.
|
||||
fn shape(value: &Value) -> Value {
|
||||
match value {
|
||||
Value::Null => Value::Null,
|
||||
Value::Bool(_) => json!(false),
|
||||
Value::Number(n) if n.is_f64() => json!(0.0),
|
||||
Value::Number(_) => json!(0),
|
||||
Value::String(_) => json!(""),
|
||||
// Only the first element: a list's rows share a shape, and keeping
|
||||
// all of them would just be size.
|
||||
Value::Array(items) => Value::Array(items.first().map(shape).into_iter().collect()),
|
||||
Value::Object(map) => {
|
||||
Value::Object(map.iter().map(|(k, v)| (k.clone(), shape(v))).collect())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let base = std::env::var("IN_BASE_URL").expect("set IN_BASE_URL");
|
||||
let token = std::env::var("IN_API_TOKEN").expect("set IN_API_TOKEN");
|
||||
let base = base.trim_end_matches('/');
|
||||
let out = Path::new("tests/fixtures");
|
||||
fs::create_dir_all(out).expect("create tests/fixtures");
|
||||
|
||||
let http = reqwest::Client::new();
|
||||
let get = |path: String| {
|
||||
let http = http.clone();
|
||||
let token = token.clone();
|
||||
let url = format!("{base}/{path}");
|
||||
async move {
|
||||
let response = http
|
||||
.get(url)
|
||||
.header("X-Api-Token", token)
|
||||
.header("Accept", "application/json")
|
||||
.header("X-Requested-With", "XMLHttpRequest")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let status = response.status();
|
||||
let body = response.text().await.map_err(|e| e.to_string())?;
|
||||
if !status.is_success() {
|
||||
return Err(format!("HTTP {status}"));
|
||||
}
|
||||
serde_json::from_str::<Value>(&body).map_err(|e| e.to_string())
|
||||
}
|
||||
};
|
||||
|
||||
for name in LISTS {
|
||||
// Deleted and archived rows included: on a disposable instance they
|
||||
// are often all there is.
|
||||
// `system_logs` answers the status filter with a 500, so fall back to
|
||||
// the plain list when the filtered one fails.
|
||||
let filtered = format!("{name}?per_page=1&status=active,archived,deleted");
|
||||
let result = match get(filtered).await {
|
||||
Ok(body) => Ok(body),
|
||||
Err(_) => get(format!("{name}?per_page=1")).await,
|
||||
};
|
||||
match result {
|
||||
Ok(body) => match body
|
||||
.get("data")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|d| d.first())
|
||||
{
|
||||
Some(row) => {
|
||||
write(out, name, &json!({ "data": [shape(row)] }));
|
||||
println!(" captured {name}");
|
||||
}
|
||||
None => println!(" skipped {name}: no rows on this instance"),
|
||||
},
|
||||
Err(e) => println!(" FAILED {name}: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
for (name, path) in BARE {
|
||||
match get((*path).to_string()).await {
|
||||
Ok(body) => {
|
||||
write(out, name, &shape(&body));
|
||||
println!(" captured {name}");
|
||||
}
|
||||
Err(e) => println!(" FAILED {name}: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn write(dir: &Path, name: &str, value: &Value) {
|
||||
let mut text = serde_json::to_string_pretty(value).expect("serialise fixture");
|
||||
text.push('\n');
|
||||
fs::write(dir.join(format!("{name}.json")), text).expect("write fixture");
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
//! Fills a **disposable** Invoice Ninja instance with one record of each
|
||||
//! common kind, so `capture_shapes` has real rows to take shapes from.
|
||||
//!
|
||||
//! ```sh
|
||||
//! IN_SMOKE_WRITE_HOST=localhost IN_BASE_URL=http://localhost:8080/api/v1 \
|
||||
//! IN_API_TOKEN=... cargo run --example seed_sample_data
|
||||
//! ```
|
||||
//!
|
||||
//! **Never run this against production.** It creates records, advances
|
||||
//! number sequences, and may fire webhooks. It uses the smoke test's guard:
|
||||
//! `IN_SMOKE_WRITE_HOST` must equal the host in `IN_BASE_URL`. Nothing is
|
||||
//! deleted afterwards; the instance is meant to be thrown away.
|
||||
//!
|
||||
//! Each step is independent: one that fails (a validation rule this script
|
||||
//! did not anticipate) is reported and the rest carry on, with anything that
|
||||
//! needed it skipped.
|
||||
|
||||
use std::future::Future;
|
||||
use std::process::ExitCode;
|
||||
|
||||
use invoiceninjars::{Error, InvoiceNinjaClient};
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
fn model<T: DeserializeOwned>(value: Value) -> T {
|
||||
serde_json::from_value(value).expect("seed body matches the model")
|
||||
}
|
||||
|
||||
fn describe(error: &Error) -> String {
|
||||
match error {
|
||||
Error::Api { errors, .. } if !errors.is_empty() => format!("{error} {errors:?}"),
|
||||
_ => error.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs one creation, reporting the outcome and returning the new id.
|
||||
async fn step<T>(name: &str, call: impl Future<Output = invoiceninjars::Result<T>>) -> Option<T> {
|
||||
match call.await {
|
||||
Ok(created) => {
|
||||
println!(" created {name}");
|
||||
Some(created)
|
||||
}
|
||||
Err(e) => {
|
||||
println!(" FAILED {name}: {}", describe(&e));
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> ExitCode {
|
||||
let base_url = std::env::var("IN_BASE_URL").expect("set IN_BASE_URL");
|
||||
let token = std::env::var("IN_API_TOKEN").expect("set IN_API_TOKEN");
|
||||
let host = reqwest::Url::parse(&base_url)
|
||||
.ok()
|
||||
.and_then(|u| u.host_str().map(str::to_string))
|
||||
.unwrap_or_default();
|
||||
if std::env::var("IN_SMOKE_WRITE_HOST").as_deref() != Ok(host.as_str()) {
|
||||
eprintln!("refusing to write: set IN_SMOKE_WRITE_HOST={host} to confirm this instance is disposable");
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
let c = InvoiceNinjaClient::new(&base_url, token).expect("client");
|
||||
println!("seeding {host}");
|
||||
|
||||
let line =
|
||||
json!([{ "product_key": "seed-product", "quantity": 1, "cost": 10.0, "notes": "seed" }]);
|
||||
|
||||
let client_id = step(
|
||||
"client",
|
||||
c.create_client(&model(json!({
|
||||
"name": "seed-client",
|
||||
"contacts": [{ "first_name": "Seed", "last_name": "Contact", "email": "seed@example.com" }],
|
||||
}))),
|
||||
)
|
||||
.await
|
||||
.and_then(|x| x.id);
|
||||
let vendor_id = step(
|
||||
"vendor",
|
||||
c.create_vendor(&model(json!({ "name": "seed-vendor" }))),
|
||||
)
|
||||
.await
|
||||
.and_then(|x| x.id);
|
||||
|
||||
step(
|
||||
"product",
|
||||
c.create_product(&model(
|
||||
json!({ "product_key": "seed-product", "price": 10.0 }),
|
||||
)),
|
||||
)
|
||||
.await;
|
||||
step(
|
||||
"tax_rate",
|
||||
c.create_tax_rate(&model(json!({ "name": "seed-tax", "rate": 5.0 }))),
|
||||
)
|
||||
.await;
|
||||
step(
|
||||
"payment_term",
|
||||
c.create_payment_term(&model(json!({ "name": "seed-term", "num_days": 15 }))),
|
||||
)
|
||||
.await;
|
||||
step(
|
||||
"expense_category",
|
||||
c.create_expense_category(&model(json!({ "name": "seed-category" }))),
|
||||
)
|
||||
.await;
|
||||
step(
|
||||
"tag",
|
||||
c.create_tag(&model(
|
||||
json!({ "name": "seed-tag", "entity_type": "client" }),
|
||||
)),
|
||||
)
|
||||
.await;
|
||||
step(
|
||||
"group_setting",
|
||||
c.create_group_setting(&model(json!({ "name": "seed-group" }))),
|
||||
)
|
||||
.await;
|
||||
step(
|
||||
"webhook",
|
||||
c.create_webhook(&model(
|
||||
json!({ "event_id": "1", "target_url": "https://example.com/seed-hook" }),
|
||||
)),
|
||||
)
|
||||
.await;
|
||||
step(
|
||||
"expense",
|
||||
c.create_expense(&model(
|
||||
json!({ "amount": 5.0, "public_notes": "seed", "vendor_id": vendor_id }),
|
||||
)),
|
||||
)
|
||||
.await;
|
||||
step(
|
||||
"recurring_expense",
|
||||
c.create_recurring_expense(&model(
|
||||
json!({ "amount": 5.0, "frequency_id": 5, "vendor_id": vendor_id }),
|
||||
)),
|
||||
)
|
||||
.await;
|
||||
|
||||
if let Some(vendor_id) = &vendor_id {
|
||||
step(
|
||||
"purchase_order",
|
||||
c.create_purchase_order(&model(
|
||||
json!({ "vendor_id": vendor_id, "line_items": line }),
|
||||
)),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
let Some(client_id) = client_id else {
|
||||
println!("no client, so the client-dependent records are skipped");
|
||||
return ExitCode::FAILURE;
|
||||
};
|
||||
|
||||
step(
|
||||
"location",
|
||||
c.create_location(&model(
|
||||
json!({ "name": "seed-location", "client_id": client_id, "address1": "1 Seed St" }),
|
||||
)),
|
||||
)
|
||||
.await;
|
||||
step(
|
||||
"project",
|
||||
c.create_project(&model(
|
||||
json!({ "name": "seed-project", "client_id": client_id }),
|
||||
)),
|
||||
)
|
||||
.await;
|
||||
step(
|
||||
"task",
|
||||
c.create_task(&model(
|
||||
json!({ "description": "seed task", "client_id": client_id }),
|
||||
)),
|
||||
)
|
||||
.await;
|
||||
step(
|
||||
"quote",
|
||||
c.create_quote(&model(
|
||||
json!({ "client_id": client_id, "line_items": line }),
|
||||
)),
|
||||
)
|
||||
.await;
|
||||
step(
|
||||
"credit",
|
||||
c.create_credit(&model(
|
||||
json!({ "client_id": client_id, "line_items": line }),
|
||||
)),
|
||||
)
|
||||
.await;
|
||||
step(
|
||||
"recurring_invoice",
|
||||
c.create_recurring_invoice(&model(
|
||||
json!({ "client_id": client_id, "frequency_id": 5, "line_items": line }),
|
||||
)),
|
||||
)
|
||||
.await;
|
||||
// Expected to 500 on 5.13.50: the `recurring_quotes` table has no
|
||||
// `design_id` column, so the server's own insert fails. Not an SDK bug.
|
||||
step(
|
||||
"recurring_quote",
|
||||
c.create_recurring_quote(&model(
|
||||
json!({ "client_id": client_id, "frequency_id": 5, "line_items": line }),
|
||||
)),
|
||||
)
|
||||
.await;
|
||||
|
||||
let invoice = step(
|
||||
"invoice",
|
||||
c.create_invoice(&model(
|
||||
json!({ "client_id": client_id, "line_items": line }),
|
||||
)),
|
||||
)
|
||||
.await;
|
||||
if let Some(id) = invoice.and_then(|i| i.id) {
|
||||
// A payment can only be applied to an invoice that has been sent.
|
||||
if step("invoice mark_sent", c.mark_invoice_sent(&id))
|
||||
.await
|
||||
.is_some()
|
||||
{
|
||||
step(
|
||||
"payment",
|
||||
c.create_payment(&model(json!({
|
||||
"client_id": client_id,
|
||||
"amount": 5.0,
|
||||
"invoices": [{ "invoice_id": id, "amount": 5.0 }],
|
||||
}))),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
@@ -0,0 +1,861 @@
|
||||
//! Smoke test against a real Invoice Ninja instance.
|
||||
//!
|
||||
//! The rest of the suite runs against mock servers written from the upstream
|
||||
//! source, so it cannot catch a place where the real server disagrees with
|
||||
//! that reading. This can. It prints PASS/FAIL per check, never the contents
|
||||
//! of any record, and exits non-zero if anything failed.
|
||||
//!
|
||||
//! ```sh
|
||||
//! # Read-only: GET requests only.
|
||||
//! IN_BASE_URL=http://localhost:8080/api/v1 IN_API_TOKEN=... \
|
||||
//! cargo run --example smoke_test
|
||||
//!
|
||||
//! # Adds the write tier. Naming the host is the guard: it must equal the
|
||||
//! # host in IN_BASE_URL, so a write cannot reach a server you did not name.
|
||||
//! IN_SMOKE_WRITE_HOST=localhost IN_BASE_URL=... IN_API_TOKEN=... \
|
||||
//! cargo run --example smoke_test
|
||||
//! ```
|
||||
//!
|
||||
//! The read tier also fetches the same rows raw and fails if a typed row lost
|
||||
//! a key. The write tier creates a client, attaches files to it, creates a
|
||||
//! purchase order, location, webhook, and recurring invoice from default
|
||||
//! models, and deletes what it made.
|
||||
//!
|
||||
//! With `IN_API_PASSWORD` also set, a password tier runs inside the write
|
||||
//! tier: it merges two throwaway clients and purges the survivor, which
|
||||
//! really removes the row. Take the password from the environment, never from
|
||||
//! a command line or chat.
|
||||
//!
|
||||
//! **Do not point the write tier at a production server.** Even after the delete that
|
||||
//! 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.
|
||||
//!
|
||||
//! `IN_BASE_URL` has no default, unlike the other example, because defaulting
|
||||
//! to the hosted app is the wrong failure for a tool that can write.
|
||||
|
||||
use std::fmt::Display;
|
||||
use std::process::ExitCode;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use invoiceninjars::models::{
|
||||
Client, ListParams, Location, PurchaseOrder, RecurringInvoice, Vendor, Webhook,
|
||||
};
|
||||
use invoiceninjars::{Error, FileUpload, InvoiceNinjaClient};
|
||||
use serde::Serialize;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
#[derive(Default)]
|
||||
struct Report {
|
||||
passed: u32,
|
||||
failed: u32,
|
||||
}
|
||||
|
||||
impl Report {
|
||||
fn pass(&mut self, name: &str, detail: impl Display) {
|
||||
println!(" PASS {name:<34} {detail}");
|
||||
self.passed += 1;
|
||||
}
|
||||
|
||||
fn fail(&mut self, name: &str, error: impl Display) {
|
||||
println!(" FAIL {name:<34} {error}");
|
||||
self.failed += 1;
|
||||
}
|
||||
|
||||
fn check(&mut self, name: &str, ok: bool, detail: impl Display) {
|
||||
if ok {
|
||||
self.pass(name, detail);
|
||||
} else {
|
||||
self.fail(name, detail);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders an error with its per-field validation map, which is usually what
|
||||
/// explains a 422.
|
||||
fn describe(error: &Error) -> String {
|
||||
match error {
|
||||
Error::Api { errors, .. } if !errors.is_empty() => format!("{error} {errors:?}"),
|
||||
_ => error.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Plain GETs for the raw JSON, so a typed result can be compared with what
|
||||
/// the server actually sent. Decoding successfully proves little: a model
|
||||
/// that quietly dropped a field would still decode.
|
||||
struct Raw {
|
||||
http: reqwest::Client,
|
||||
base: String,
|
||||
token: String,
|
||||
}
|
||||
|
||||
impl Raw {
|
||||
async fn rows(&self, path: &str) -> Option<Vec<Value>> {
|
||||
let body: Value = self
|
||||
.http
|
||||
.get(format!("{}/{path}?per_page=2", self.base))
|
||||
.header("X-Api-Token", &self.token)
|
||||
.header("Accept", "application/json")
|
||||
.header("X-Requested-With", "XMLHttpRequest")
|
||||
.send()
|
||||
.await
|
||||
.ok()?
|
||||
.json()
|
||||
.await
|
||||
.ok()?;
|
||||
body.get("data")?.as_array().cloned()
|
||||
}
|
||||
}
|
||||
|
||||
/// Keys that are left out of a request when empty on purpose (the server
|
||||
/// rejects the empty value), so an empty one is not a lost key.
|
||||
const OMITTED_WHEN_EMPTY: &[&str] = &["country_id", "status_id", "format"];
|
||||
|
||||
/// Key paths in `raw` that `typed` no longer has.
|
||||
fn lost_keys(raw: &Value, typed: &Value, prefix: &str, into: &mut Vec<String>) {
|
||||
match (raw, typed) {
|
||||
(Value::Object(raw), Value::Object(typed)) => {
|
||||
for (key, raw_value) in raw {
|
||||
match typed.get(key) {
|
||||
Some(typed_value) => {
|
||||
lost_keys(raw_value, typed_value, &format!("{prefix}.{key}"), into)
|
||||
}
|
||||
None if raw_value == "" && OMITTED_WHEN_EMPTY.contains(&key.as_str()) => {}
|
||||
None => into.push(format!("{prefix}.{key}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
(Value::Array(raw), Value::Array(typed)) => {
|
||||
if let (Some(raw), Some(typed)) = (raw.first(), typed.first()) {
|
||||
lost_keys(raw, typed, &format!("{prefix}[]"), into);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetches the same rows raw and requires the typed ones to keep every key.
|
||||
/// Only key names are printed, never values.
|
||||
async fn key_loss(report: &mut Report, raw: &Raw, path: &str, typed: &[impl Serialize]) {
|
||||
let Some(raw_rows) = raw.rows(path).await else {
|
||||
report.fail(
|
||||
&format!("keys kept: {path}"),
|
||||
"could not fetch the raw rows",
|
||||
);
|
||||
return;
|
||||
};
|
||||
let mut lost = Vec::new();
|
||||
for (raw_row, typed_row) in raw_rows.iter().zip(typed) {
|
||||
let typed_row = serde_json::to_value(typed_row).unwrap_or(Value::Null);
|
||||
lost_keys(raw_row, &typed_row, path, &mut lost);
|
||||
}
|
||||
lost.sort();
|
||||
lost.dedup();
|
||||
report.check(
|
||||
&format!("keys kept: {path}"),
|
||||
lost.is_empty(),
|
||||
if lost.is_empty() {
|
||||
format!("{} row(s), no key lost", raw_rows.len().min(typed.len()))
|
||||
} else {
|
||||
format!("lost {}", lost.join(", "))
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> ExitCode {
|
||||
let base_url = std::env::var("IN_BASE_URL").expect("set IN_BASE_URL (no default, on purpose)");
|
||||
let token = std::env::var("IN_API_TOKEN").expect("set IN_API_TOKEN");
|
||||
|
||||
let host = host_of(&base_url);
|
||||
let raw = Raw {
|
||||
http: reqwest::Client::new(),
|
||||
base: base_url.trim_end_matches('/').to_string(),
|
||||
token: token.clone(),
|
||||
};
|
||||
let client = match InvoiceNinjaClient::new(&base_url, token) {
|
||||
Ok(client) => client,
|
||||
Err(e) => {
|
||||
eprintln!("could not build client: {e}");
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
};
|
||||
|
||||
println!("target: {host}");
|
||||
let mut report = Report::default();
|
||||
|
||||
println!("\nread-only checks");
|
||||
read_checks(&client, &raw, &mut report).await;
|
||||
|
||||
// Empty counts as unset: CI passes the variable through even when blank.
|
||||
match std::env::var("IN_SMOKE_WRITE_HOST")
|
||||
.ok()
|
||||
.filter(|v| !v.is_empty())
|
||||
{
|
||||
Some(allowed) if allowed == host => {
|
||||
println!("\nwrite checks (IN_SMOKE_WRITE_HOST matches {host})");
|
||||
write_checks(&client, &mut report).await;
|
||||
|
||||
match std::env::var("IN_API_PASSWORD")
|
||||
.ok()
|
||||
.filter(|v| !v.is_empty())
|
||||
{
|
||||
Some(password) => {
|
||||
println!("\npassword checks (IN_API_PASSWORD is set)");
|
||||
password_checks(&client, &mut report, password).await;
|
||||
}
|
||||
None => println!("\npassword checks skipped (set IN_API_PASSWORD to enable)"),
|
||||
}
|
||||
}
|
||||
Some(allowed) => {
|
||||
println!(
|
||||
"\nwrite checks SKIPPED: IN_SMOKE_WRITE_HOST is `{allowed}` but the target is `{host}`"
|
||||
);
|
||||
}
|
||||
None => println!("\nwrite checks skipped (set IN_SMOKE_WRITE_HOST to enable)"),
|
||||
}
|
||||
|
||||
println!("\n{} passed, {} failed", report.passed, report.failed);
|
||||
if report.failed == 0 {
|
||||
ExitCode::SUCCESS
|
||||
} else {
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
|
||||
fn host_of(base_url: &str) -> String {
|
||||
reqwest::Url::parse(base_url)
|
||||
.ok()
|
||||
.and_then(|url| url.host_str().map(str::to_string))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
async fn read_checks(client: &InvoiceNinjaClient, raw: &Raw, report: &mut Report) {
|
||||
let small = ListParams::new().per_page(2);
|
||||
|
||||
// Decodes one page, then (if there are rows) checks none of their keys
|
||||
// were lost. The REST path is the method name without `list_`.
|
||||
macro_rules! list {
|
||||
($report:expr, $name:literal, $call:expr) => {
|
||||
match $call.await {
|
||||
Ok(rows) => {
|
||||
$report.pass($name, format!("{} row(s) decoded", rows.len()));
|
||||
if !rows.is_empty() {
|
||||
key_loss($report, raw, $name.trim_start_matches("list_"), &rows).await;
|
||||
}
|
||||
}
|
||||
Err(e) => $report.fail($name, describe(&e)),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
match client.ping().await {
|
||||
Ok(()) => report.pass("ping", "authenticated"),
|
||||
Err(e) => report.fail("ping", describe(&e)),
|
||||
}
|
||||
|
||||
// Hosted returns a bare message instead of a report; both must decode.
|
||||
match client.health_check().await {
|
||||
Ok(health) => report.pass(
|
||||
"health_check",
|
||||
match health.system_health {
|
||||
Some(ok) => format!("self-hosted report, system_health={ok}"),
|
||||
None => "bare message (no report on this deployment)".to_string(),
|
||||
},
|
||||
),
|
||||
Err(e) => report.fail("health_check", describe(&e)),
|
||||
}
|
||||
|
||||
match client.statics().await {
|
||||
Ok(s) => report.check(
|
||||
"statics",
|
||||
!s.currencies.is_empty() && !s.countries.is_empty(),
|
||||
format!(
|
||||
"{} currencies, {} countries, {} unmodeled key(s) kept in extra",
|
||||
s.currencies.len(),
|
||||
s.countries.len(),
|
||||
s.extra.len()
|
||||
),
|
||||
),
|
||||
Err(e) => report.fail("statics", describe(&e)),
|
||||
}
|
||||
|
||||
match client.search(&json!({ "search": "a" })).await {
|
||||
Ok(results) => report.pass(
|
||||
"search",
|
||||
format!(
|
||||
"{} client hit(s), {} unmodeled bucket(s)",
|
||||
results.clients.len(),
|
||||
results.extra.len()
|
||||
),
|
||||
),
|
||||
Err(e) => report.fail("search", describe(&e)),
|
||||
}
|
||||
|
||||
match client.subscription_steps().await {
|
||||
Ok(steps) => report.check(
|
||||
"subscription_steps",
|
||||
!steps.is_empty() && steps.iter().all(|s| !s.id.is_empty()),
|
||||
format!("{} step(s) decoded", steps.len()),
|
||||
),
|
||||
Err(e) => report.fail("subscription_steps", describe(&e)),
|
||||
}
|
||||
|
||||
match client.activity_feed(Some(5)).await {
|
||||
Ok(feed) => report.check(
|
||||
"activity_feed",
|
||||
feed.iter().all(|e| e.activity_type_id > 0),
|
||||
format!("{} entr(ies), numeric type ids", feed.len()),
|
||||
),
|
||||
Err(e) => report.fail("activity_feed", describe(&e)),
|
||||
}
|
||||
|
||||
// A POST, but it only renders: nothing is stored. Needs an invoice to
|
||||
// render, so it is skipped on an empty instance.
|
||||
match client.list_invoices(&small).await {
|
||||
Ok(rows) => match rows
|
||||
.first()
|
||||
.and_then(|i| i.id.clone().map(|id| (id, i.client_id.clone())))
|
||||
{
|
||||
Some((id, client_id)) => {
|
||||
let body = json!({ "entity": "invoice", "entity_id": id, "client_id": client_id });
|
||||
match client.live_preview(&body).await {
|
||||
Ok(pdf) => report.check(
|
||||
"live_preview",
|
||||
pdf.starts_with(b"%PDF"),
|
||||
format!("{} byte(s), starts with the PDF magic", pdf.len()),
|
||||
),
|
||||
Err(e) => report.fail("live_preview", describe(&e)),
|
||||
}
|
||||
}
|
||||
None => report.pass("live_preview", "skipped: no invoices on this instance"),
|
||||
},
|
||||
Err(e) => report.fail("live_preview", describe(&e)),
|
||||
}
|
||||
|
||||
list!(report, "list_clients", client.list_clients(&small));
|
||||
list!(report, "list_invoices", client.list_invoices(&small));
|
||||
list!(report, "list_quotes", client.list_quotes(&small));
|
||||
list!(report, "list_credits", client.list_credits(&small));
|
||||
list!(report, "list_payments", client.list_payments(&small));
|
||||
list!(report, "list_products", client.list_products(&small));
|
||||
list!(report, "list_expenses", client.list_expenses(&small));
|
||||
list!(report, "list_projects", client.list_projects(&small));
|
||||
list!(report, "list_tasks", client.list_tasks(&small));
|
||||
list!(report, "list_vendors", client.list_vendors(&small));
|
||||
list!(
|
||||
report,
|
||||
"list_recurring_invoices",
|
||||
client.list_recurring_invoices(&small)
|
||||
);
|
||||
list!(
|
||||
report,
|
||||
"list_recurring_quotes",
|
||||
client.list_recurring_quotes(&small)
|
||||
);
|
||||
list!(
|
||||
report,
|
||||
"list_recurring_expenses",
|
||||
client.list_recurring_expenses(&small)
|
||||
);
|
||||
list!(
|
||||
report,
|
||||
"list_purchase_orders",
|
||||
client.list_purchase_orders(&small)
|
||||
);
|
||||
list!(report, "list_tax_rates", client.list_tax_rates(&small));
|
||||
list!(
|
||||
report,
|
||||
"list_expense_categories",
|
||||
client.list_expense_categories(&small)
|
||||
);
|
||||
list!(
|
||||
report,
|
||||
"list_task_statuses",
|
||||
client.list_task_statuses(&small)
|
||||
);
|
||||
list!(
|
||||
report,
|
||||
"list_payment_terms",
|
||||
client.list_payment_terms(&small)
|
||||
);
|
||||
list!(
|
||||
report,
|
||||
"list_subscriptions",
|
||||
client.list_subscriptions(&small)
|
||||
);
|
||||
list!(
|
||||
report,
|
||||
"list_bank_transactions",
|
||||
client.list_bank_transactions(&small)
|
||||
);
|
||||
list!(report, "list_webhooks", client.list_webhooks(&small));
|
||||
list!(
|
||||
report,
|
||||
"list_bank_integrations",
|
||||
client.list_bank_integrations(&small)
|
||||
);
|
||||
list!(
|
||||
report,
|
||||
"list_bank_transaction_rules",
|
||||
client.list_bank_transaction_rules(&small)
|
||||
);
|
||||
list!(
|
||||
report,
|
||||
"list_company_gateways",
|
||||
client.list_company_gateways(&small)
|
||||
);
|
||||
list!(report, "list_designs", client.list_designs(&small));
|
||||
list!(
|
||||
report,
|
||||
"list_group_settings",
|
||||
client.list_group_settings(&small)
|
||||
);
|
||||
list!(report, "list_locations", client.list_locations(&small));
|
||||
list!(report, "list_tags", client.list_tags(&small));
|
||||
list!(
|
||||
report,
|
||||
"list_task_schedulers",
|
||||
client.list_task_schedulers(&small)
|
||||
);
|
||||
list!(report, "list_tokens", client.list_tokens(&small));
|
||||
list!(
|
||||
report,
|
||||
"list_client_gateway_tokens",
|
||||
client.list_client_gateway_tokens(&small)
|
||||
);
|
||||
list!(report, "list_companies", client.list_companies(&small));
|
||||
list!(report, "list_system_logs", client.list_system_logs(&small));
|
||||
list!(report, "list_documents", client.list_documents(&small));
|
||||
list!(report, "list_users", client.list_users(&small));
|
||||
list!(report, "list_activities", client.list_activities(&small));
|
||||
|
||||
// The walk follows `meta.pagination.total_pages`, which only a real server
|
||||
// can show we are reading correctly. Compare it to one oversized request.
|
||||
let everything = ListParams::new().per_page(5000);
|
||||
match (
|
||||
client
|
||||
.list_all_clients(&ListParams::new().per_page(2))
|
||||
.await,
|
||||
client.list_clients(&everything).await,
|
||||
) {
|
||||
(Ok(walked), Ok(single)) if single.len() < 5000 => report.check(
|
||||
"pagination walk == single request",
|
||||
walked.len() == single.len(),
|
||||
format!(
|
||||
"walked {} at per_page=2, single request returned {}",
|
||||
walked.len(),
|
||||
single.len()
|
||||
),
|
||||
),
|
||||
(Ok(_), Ok(_)) => report.pass("pagination walk", "skipped: 5000+ clients"),
|
||||
(Err(e), _) | (_, Err(e)) => report.fail("pagination walk", describe(&e)),
|
||||
}
|
||||
|
||||
// The server caps per_page. Ask for far more than the cap and see how many
|
||||
// rows come back, which is the fact the clamp in `get_all_pages` rests on.
|
||||
match client
|
||||
.list_clients(&ListParams::new().per_page(100_000))
|
||||
.await
|
||||
{
|
||||
Ok(rows) => report.pass(
|
||||
"per_page cap",
|
||||
format!("asked for 100000, got {} row(s) (cap is 5000)", rows.len()),
|
||||
),
|
||||
Err(e) => report.fail("per_page cap", describe(&e)),
|
||||
}
|
||||
|
||||
// A single fetch by id, to exercise `get_one` and `seg`.
|
||||
match client.list_clients(&small).await {
|
||||
Ok(rows) => match rows.first().and_then(|c| c.id.clone()) {
|
||||
Some(id) => match client.get_client(&id).await {
|
||||
Ok(c) => report.pass(
|
||||
"get_client",
|
||||
format!("fetched one, {} unmodeled field(s) in extra", c.extra.len()),
|
||||
),
|
||||
Err(e) => report.fail("get_client", describe(&e)),
|
||||
},
|
||||
None => report.pass("get_client", "skipped: no clients on this instance"),
|
||||
},
|
||||
Err(e) => report.fail("get_client", describe(&e)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn write_checks(client: &InvoiceNinjaClient, report: &mut Report) {
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0);
|
||||
let name = format!("sdk-smoke-{nanos}");
|
||||
|
||||
let created = match client
|
||||
.create_client(&Client {
|
||||
name: name.clone(),
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(created) => created,
|
||||
Err(e) => {
|
||||
report.fail("create_client", describe(&e));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let Some(id) = created.id.clone() else {
|
||||
report.fail("create_client", "response carried no id");
|
||||
return;
|
||||
};
|
||||
report.pass("create_client", format!("created `{name}`"));
|
||||
|
||||
upload_checks(client, report, &id).await;
|
||||
round_trip_checks(client, report, &id, created.extra.len()).await;
|
||||
extras_checks(client, report, &id).await;
|
||||
creation_checks(client, report, &id).await;
|
||||
activity_checks(client, report, &id).await;
|
||||
|
||||
// Always runs, including after a failed check above.
|
||||
match client.delete_client(&id).await {
|
||||
Ok(()) => report.pass(
|
||||
"delete_client",
|
||||
"soft-deleted (a purge needs the account password)",
|
||||
),
|
||||
Err(e) => report.fail(
|
||||
"delete_client",
|
||||
format!("{} -- remove `{name}` by hand", describe(&e)),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
async fn upload_checks(client: &InvoiceNinjaClient, report: &mut Report, id: &str) {
|
||||
let text = |label: &str| {
|
||||
FileUpload::new(
|
||||
format!("{label}.txt"),
|
||||
format!("smoke {label}").into_bytes(),
|
||||
)
|
||||
.mime("text/plain")
|
||||
};
|
||||
let count_documents = |c: &Client| {
|
||||
c.extra
|
||||
.get("documents")
|
||||
.and_then(|d| d.as_array())
|
||||
.map(Vec::len)
|
||||
};
|
||||
|
||||
// POST + `_method=PUT`: read from the framework source, then observed
|
||||
// working on 5.13.50.
|
||||
match client
|
||||
.upload_client(id, vec![text("first")], Some(false))
|
||||
.await
|
||||
{
|
||||
Ok(c) => report.check(
|
||||
"upload_client (1 file, private)",
|
||||
count_documents(&c) == Some(1),
|
||||
format!("documents on record: {:?}", count_documents(&c)),
|
||||
),
|
||||
Err(e) => report.fail("upload_client (1 file, private)", describe(&e)),
|
||||
}
|
||||
|
||||
match client
|
||||
.upload_client(id, vec![text("second"), text("third")], Some(true))
|
||||
.await
|
||||
{
|
||||
Ok(c) => report.check(
|
||||
"upload_client (2 files, public)",
|
||||
count_documents(&c) == Some(3),
|
||||
format!(
|
||||
"documents on record: {:?} (expected 3)",
|
||||
count_documents(&c)
|
||||
),
|
||||
),
|
||||
Err(e) => report.fail("upload_client (2 files, public)", describe(&e)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn round_trip_checks(
|
||||
client: &InvoiceNinjaClient,
|
||||
report: &mut Report,
|
||||
id: &str,
|
||||
extra_keys_at_create: usize,
|
||||
) {
|
||||
// `get` then `update` must not drop fields the crate does not model.
|
||||
let fetched = match client.get_client(id).await {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
report.fail("get_client (round trip)", describe(&e));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let before = fetched.extra.len();
|
||||
|
||||
let mut edited = fetched;
|
||||
edited.private_notes = "edited by smoke test".into();
|
||||
let updated = match client.update_client(id, &edited).await {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
report.fail("update_client", describe(&e));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
report.check(
|
||||
"update_client preserves extra",
|
||||
updated.extra.len() >= before.min(extra_keys_at_create),
|
||||
format!(
|
||||
"extra keys: {extra_keys_at_create} at create, {before} on get, {} after update",
|
||||
updated.extra.len()
|
||||
),
|
||||
);
|
||||
report.check(
|
||||
"update_client applied the edit",
|
||||
updated.private_notes == "edited by smoke test",
|
||||
"private_notes round-tripped",
|
||||
);
|
||||
}
|
||||
|
||||
/// Per-client extras that only read or reshape data already on the record.
|
||||
/// `updateTaxData` and `check_vat` are left out: they call external services.
|
||||
async fn extras_checks(client: &InvoiceNinjaClient, report: &mut Report, id: &str) {
|
||||
match client.client_show_settings(id).await {
|
||||
Ok(settings) => report.check(
|
||||
"client_show_settings",
|
||||
settings.is_object(),
|
||||
format!("{} setting(s)", settings.as_object().map_or(0, |m| m.len())),
|
||||
),
|
||||
Err(e) => report.fail("client_show_settings", describe(&e)),
|
||||
}
|
||||
|
||||
match client.client_vat_status(id).await {
|
||||
Ok(status) => report.pass(
|
||||
"client_vat_status",
|
||||
format!("message of {} char(s)", status.message.len()),
|
||||
),
|
||||
Err(e) => report.fail("client_vat_status", describe(&e)),
|
||||
}
|
||||
|
||||
// The two uploads in `upload_checks` should be visible here.
|
||||
match client.client_documents(id, &json!({})).await {
|
||||
Ok(docs) => report.check(
|
||||
"client_documents",
|
||||
docs.len() == 3,
|
||||
format!("{} document(s) (expected 3)", docs.len()),
|
||||
),
|
||||
Err(e) => report.fail("client_documents", describe(&e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates from bare `Default` models the records that broke against a real
|
||||
/// server: an empty `status_id`, `country_id` or `format` was rejected, and a
|
||||
/// string `frequency_id` failed to decode after the record existed. Each is
|
||||
/// deleted again straight away.
|
||||
async fn creation_checks(client: &InvoiceNinjaClient, report: &mut Report, client_id: &str) {
|
||||
let stamp = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0);
|
||||
|
||||
let vendor = match client
|
||||
.create_vendor(&Vendor {
|
||||
name: format!("sdk-smoke-vendor-{stamp}"),
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(vendor) => vendor,
|
||||
Err(e) => {
|
||||
report.fail("create_vendor", describe(&e));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let vendor_id = vendor.id.clone().unwrap_or_default();
|
||||
|
||||
match client
|
||||
.create_purchase_order(&PurchaseOrder {
|
||||
vendor_id: vendor_id.clone(),
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(po) => {
|
||||
report.pass("create_purchase_order (default)", "empty status_id omitted");
|
||||
if let Some(id) = po.id {
|
||||
let _ = client.delete_purchase_order(&id).await;
|
||||
}
|
||||
}
|
||||
Err(e) => report.fail("create_purchase_order (default)", describe(&e)),
|
||||
}
|
||||
|
||||
match client
|
||||
.create_location(&Location {
|
||||
name: format!("sdk-smoke-location-{stamp}"),
|
||||
client_id: client_id.to_string(),
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(location) => {
|
||||
report.pass("create_location (default)", "empty country_id omitted");
|
||||
if let Some(id) = location.id {
|
||||
let _ = client.delete_location(&id).await;
|
||||
}
|
||||
}
|
||||
Err(e) => report.fail("create_location (default)", describe(&e)),
|
||||
}
|
||||
|
||||
match client
|
||||
.create_webhook(&Webhook {
|
||||
event_id: "1".into(),
|
||||
target_url: "https://example.com/sdk-smoke".into(),
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(hook) => {
|
||||
report.pass("create_webhook (default)", "empty format omitted");
|
||||
if let Some(id) = hook.id {
|
||||
let _ = client.delete_webhook(&id).await;
|
||||
}
|
||||
}
|
||||
Err(e) => report.fail("create_webhook (default)", describe(&e)),
|
||||
}
|
||||
|
||||
// The create succeeds either way; decoding the reply is what failed.
|
||||
match client
|
||||
.create_recurring_invoice(&RecurringInvoice {
|
||||
client_id: client_id.to_string(),
|
||||
frequency_id: 5,
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(recurring) => {
|
||||
report.check(
|
||||
"create_recurring_invoice",
|
||||
recurring.frequency_id == 5,
|
||||
format!("frequency_id decoded as {}", recurring.frequency_id),
|
||||
);
|
||||
if let Some(id) = recurring.id {
|
||||
let _ = client.delete_recurring_invoice(&id).await;
|
||||
}
|
||||
}
|
||||
Err(e) => report.fail("create_recurring_invoice", describe(&e)),
|
||||
}
|
||||
|
||||
if let Err(e) = client.delete_vendor(&vendor_id).await {
|
||||
report.fail(
|
||||
"delete_vendor",
|
||||
format!("{} -- remove the vendor by hand", describe(&e)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Password tier: needs `IN_API_PASSWORD`, and only runs inside the write
|
||||
/// tier's host guard. Merges two throwaway clients, then purges the survivor,
|
||||
/// which unlike `delete_client` really removes the row. Users, companies and
|
||||
/// vendors are left alone: inviting a user sends mail, and a company purge
|
||||
/// takes data that was not created here.
|
||||
async fn password_checks(client: &InvoiceNinjaClient, report: &mut Report, password: String) {
|
||||
let client = match client.clone().with_api_password(password) {
|
||||
Ok(client) => client,
|
||||
Err(e) => {
|
||||
report.fail("with_api_password", describe(&e));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
match client.new_user_template().await {
|
||||
Ok(user) => report.check(
|
||||
"new_user_template",
|
||||
user.id.as_deref().unwrap_or("").is_empty(),
|
||||
"blank user, nothing stored",
|
||||
),
|
||||
Err(e) => report.fail("new_user_template", describe(&e)),
|
||||
}
|
||||
|
||||
let stamp = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0);
|
||||
let mut ids = Vec::new();
|
||||
for label in ["keep", "drop"] {
|
||||
match client
|
||||
.create_client(&Client {
|
||||
name: format!("sdk-smoke-merge-{label}-{stamp}"),
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(created) => ids.extend(created.id),
|
||||
Err(e) => {
|
||||
report.fail("create_client (merge)", describe(&e));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
let [keep, drop] = [ids[0].clone(), ids[1].clone()];
|
||||
|
||||
match client.merge_clients(&keep, &drop).await {
|
||||
Ok(merged) => report.check(
|
||||
"merge_clients",
|
||||
merged.id.as_deref() == Some(keep.as_str()),
|
||||
"the first id is the survivor",
|
||||
),
|
||||
Err(e) => report.fail("merge_clients", describe(&e)),
|
||||
}
|
||||
|
||||
match client.purge_client(&keep).await {
|
||||
Ok(message) => report.pass("purge_client", format!("`{}`", message.message)),
|
||||
Err(e) => report.fail(
|
||||
"purge_client",
|
||||
format!(
|
||||
"{} -- remove the sdk-smoke-merge clients by hand",
|
||||
describe(&e)
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
// A purge is permanent, so the record must now be gone, not just flagged.
|
||||
// A missing record answers 400, not 404: the server maps Laravel's
|
||||
// ModelNotFoundException to a 400 and keeps 404 for a missing route. The
|
||||
// message is checked too, so a 400 raised for some other reason fails.
|
||||
match client.get_client(&keep).await {
|
||||
Err(Error::Api { status, message, .. })
|
||||
if status.as_u16() == 400 && message.contains("No query results") =>
|
||||
{
|
||||
report.pass("get_client after purge", "400 no query results, the row is gone")
|
||||
}
|
||||
Err(e) => report.fail("get_client after purge", describe(&e)),
|
||||
Ok(_) => report.fail("get_client after purge", "still readable after a purge"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Notes, the per-record feed and email history, against the throwaway
|
||||
/// client. Nothing here sends mail: the history is read, and is expected to
|
||||
/// be empty because no mail has gone out for this client.
|
||||
async fn activity_checks(client: &InvoiceNinjaClient, report: &mut Report, id: &str) {
|
||||
let note = "sdk smoke note";
|
||||
match client.add_activity_note("clients", id, note).await {
|
||||
Ok(activity) => report.check(
|
||||
"add_activity_note",
|
||||
activity.notes == note && activity.client_id == id,
|
||||
"note stored against the client",
|
||||
),
|
||||
Err(e) => report.fail("add_activity_note", describe(&e)),
|
||||
}
|
||||
|
||||
match client.entity_activity("client", id, Some(10)).await {
|
||||
Ok(feed) => report.check(
|
||||
"entity_activity",
|
||||
feed.iter().any(|e| e.notes == note),
|
||||
format!("{} entr(ies), including the note", feed.len()),
|
||||
),
|
||||
Err(e) => report.fail("entity_activity", describe(&e)),
|
||||
}
|
||||
|
||||
match client.client_email_history(id).await {
|
||||
Ok(history) => report.check(
|
||||
"client_email_history",
|
||||
history.is_empty(),
|
||||
"no mail sent, so no history",
|
||||
),
|
||||
Err(e) => report.fail("client_email_history", describe(&e)),
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,10 @@
|
||||
msrvToolchain = toolchainFor msrvVersion [ ];
|
||||
|
||||
shellFor = toolchain: pkgs.mkShell {
|
||||
packages = [ toolchain pkgs.pkg-config pkgs.openssl ];
|
||||
# jq is for the MSRV recheck in CLAUDE.md, which pipes `cargo
|
||||
# metadata` through it; the rest is the toolchain plus what
|
||||
# openssl-sys needs to link.
|
||||
packages = [ toolchain pkgs.pkg-config pkgs.openssl pkgs.jq ];
|
||||
RUST_SRC_PATH = "${toolchain}/lib/rustlib/src/rust/library";
|
||||
};
|
||||
in
|
||||
|
||||
+607
-17
@@ -1,19 +1,21 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use percent_encoding::{utf8_percent_encode, AsciiSet, NON_ALPHANUMERIC};
|
||||
use reqwest::{header, Method, StatusCode};
|
||||
use reqwest::{header, multipart, Method, StatusCode};
|
||||
use serde::{de::DeserializeOwned, Serialize};
|
||||
|
||||
use crate::error::{ApiErrorBody, Error, Result};
|
||||
use crate::models::{
|
||||
BankIntegration, BankTransaction, BankTransactionRule, BulkRequest, Client as ClientModel,
|
||||
ClientGatewayToken, Company, CompanyGateway, CompanyToken, Credit, Design, Document, Expense,
|
||||
ExpenseCategory, GroupSetting, HealthCheck, Invoice, ItemResponse, ListParams, ListResponse,
|
||||
Location, Payment, PaymentTerm, Product, Project, PurchaseOrder, Quote, RecurringExpense,
|
||||
RecurringInvoice, RecurringQuote, SearchResults, Statics, Subscription, SystemLog, Tag, Task,
|
||||
TaskScheduler, TaskStatus, TaxRate, User, Vendor, Webhook,
|
||||
Activity, ActivityFeedEntry, BankIntegration, BankTransaction, BankTransactionRule,
|
||||
BulkRequest, Client as ClientModel, ClientGatewayToken, Company, CompanyGateway, CompanyToken,
|
||||
Credit, Design, Document, Expense, ExpenseCategory, GroupSetting, HealthCheck, Invoice,
|
||||
ItemResponse, ListParams, ListResponse, Location, MessageResponse, Payment, PaymentTerm,
|
||||
Product, Project, PurchaseOrder, Quote, RecurringExpense, RecurringInvoice, RecurringQuote,
|
||||
SearchResults, Statics, Subscription, SubscriptionStep, SystemLog, Tag, Task, TaskScheduler,
|
||||
TaskStatus, TaxRate, User, Vendor, Webhook,
|
||||
};
|
||||
use crate::retry::{self, RetryPolicy};
|
||||
use crate::upload::FileUpload;
|
||||
|
||||
/// API root of the hosted Invoice Ninja app.
|
||||
pub const HOSTED_BASE_URL: &str = "https://invoicing.co/api/v1";
|
||||
@@ -60,6 +62,28 @@ fn seg(value: &str) -> Result<impl std::fmt::Display + '_> {
|
||||
Ok(utf8_percent_encode(value, PATH_SEGMENT))
|
||||
}
|
||||
|
||||
/// Standard base64 with padding. Only used for the one header that needs it,
|
||||
/// which is not worth a dependency.
|
||||
fn base64(bytes: &[u8]) -> String {
|
||||
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
|
||||
let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
|
||||
for chunk in bytes.chunks(3) {
|
||||
let n = chunk
|
||||
.iter()
|
||||
.enumerate()
|
||||
.fold(0u32, |n, (i, b)| n | (*b as u32) << (16 - 8 * i));
|
||||
for i in 0..4 {
|
||||
if i <= chunk.len() {
|
||||
out.push(ALPHABET[(n >> (18 - 6 * i) & 63) as usize] as char);
|
||||
} else {
|
||||
out.push('=');
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Renders the start of a response body for an error message.
|
||||
///
|
||||
/// Collapsed to one line and truncated, so a stray HTML error page or a
|
||||
@@ -101,9 +125,9 @@ fn excerpt(bytes: &[u8]) -> String {
|
||||
pub struct InvoiceNinjaClient {
|
||||
http: reqwest::Client,
|
||||
base_url: String,
|
||||
/// Marked sensitive, so it renders as `Sensitive` rather than the value
|
||||
/// in the derived `Debug`.
|
||||
api_password: Option<header::HeaderValue>,
|
||||
/// The header to send it under, and its value. Marked sensitive, so it
|
||||
/// renders as `Sensitive` rather than the value in the derived `Debug`.
|
||||
api_password: Option<(&'static str, header::HeaderValue)>,
|
||||
timeout: Duration,
|
||||
retry: RetryPolicy,
|
||||
}
|
||||
@@ -184,7 +208,14 @@ impl InvoiceNinjaClient {
|
||||
/// middleware and reject requests without the account password, even
|
||||
/// when the token is valid: reading or writing an individual user,
|
||||
/// `/users/bulk`, purging a client or company, and merging clients.
|
||||
/// Without this, those calls fail rather than returning data.
|
||||
/// Without this, those calls fail rather than returning data. (The
|
||||
/// server also lets a recent password-authenticated session through
|
||||
/// without the header, so an omitted password is not always refused.)
|
||||
///
|
||||
/// A password that fits a header value is sent as `X-API-PASSWORD`. One
|
||||
/// that does not (it contains a control character such as a newline) is
|
||||
/// sent as `X-API-PASSWORD-BASE64`, which the server's middleware decodes.
|
||||
/// It used to be refused locally with a misleading `InvalidToken`.
|
||||
///
|
||||
/// ```no_run
|
||||
/// # fn main() -> invoiceninjars::Result<()> {
|
||||
@@ -196,10 +227,17 @@ impl InvoiceNinjaClient {
|
||||
/// # }
|
||||
/// ```
|
||||
pub fn with_api_password(mut self, password: impl Into<String>) -> Result<Self> {
|
||||
let mut value = header::HeaderValue::from_str(&password.into())
|
||||
.map_err(|e| Error::InvalidToken(e.to_string()))?;
|
||||
let password = password.into();
|
||||
let (name, mut value) = match header::HeaderValue::from_str(&password) {
|
||||
Ok(value) => ("X-API-PASSWORD", value),
|
||||
Err(_) => (
|
||||
"X-API-PASSWORD-BASE64",
|
||||
header::HeaderValue::from_str(&base64(password.as_bytes()))
|
||||
.expect("base64 output is always a valid header value"),
|
||||
),
|
||||
};
|
||||
value.set_sensitive(true);
|
||||
self.api_password = Some(value);
|
||||
self.api_password = Some((name, value));
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
@@ -211,7 +249,7 @@ impl InvoiceNinjaClient {
|
||||
.request(method, self.url(path))
|
||||
.timeout(self.timeout);
|
||||
match &self.api_password {
|
||||
Some(password) => builder.header("X-API-PASSWORD", password.clone()),
|
||||
Some((name, password)) => builder.header(*name, password.clone()),
|
||||
None => builder,
|
||||
}
|
||||
}
|
||||
@@ -220,8 +258,16 @@ impl InvoiceNinjaClient {
|
||||
/// configured [`RetryPolicy`].
|
||||
///
|
||||
/// Every helper goes through here, so retrying is uniform. The request is
|
||||
/// cloned before each attempt; a body that cannot be cloned (none of ours
|
||||
/// today, since all payloads are in-memory JSON) simply gets one attempt.
|
||||
/// cloned before each attempt; a body that cannot be cloned simply gets
|
||||
/// one attempt.
|
||||
///
|
||||
/// Multipart uploads rely on that. `reqwest` builds their body as a
|
||||
/// stream, so `try_clone` returns `None` and they are never replayed —
|
||||
/// not even on a 429. That is wanted: an upload *adds* a document, so it
|
||||
/// is not idempotent whatever its HTTP verb, and a replay after a lost
|
||||
/// response would attach the file twice. A test pins this, because
|
||||
/// nothing in the types would stop a future change from making the body
|
||||
/// cloneable and silently breaking it.
|
||||
///
|
||||
/// Note what is *not* retried: a `POST` that fails with a 5xx or times
|
||||
/// out is surfaced, because the server may already have applied it and a
|
||||
@@ -545,10 +591,52 @@ impl InvoiceNinjaClient {
|
||||
Self::decode(response).await
|
||||
}
|
||||
|
||||
/// Attaches files to a record through its `/upload` route.
|
||||
///
|
||||
/// The route is registered as `PUT`, but PHP does not parse a multipart
|
||||
/// body on a `PUT`, so the files would never arrive. The working form is
|
||||
/// a `POST` carrying `_method=PUT`, which Laravel's kernel turns back
|
||||
/// into a `PUT` before routing. The published OpenAPI spec documents
|
||||
/// `POST` with `_method=POST`, which would 405 against these routes.
|
||||
///
|
||||
/// `is_public` goes out as `1`/`0`: Laravel's `boolean` rule accepts only
|
||||
/// `true`, `false`, `0`, `1`, `'0'` and `'1'`, so the string `"true"`
|
||||
/// fails validation.
|
||||
async fn upload_files<T: DeserializeOwned>(
|
||||
&self,
|
||||
path: &str,
|
||||
files: Vec<FileUpload>,
|
||||
is_public: Option<bool>,
|
||||
) -> Result<T> {
|
||||
// The server answers an upload with no files with a success that
|
||||
// changed nothing, which is exactly the quiet failure to avoid.
|
||||
if files.is_empty() {
|
||||
return Err(Error::InvalidUpload("no files to upload".into()));
|
||||
}
|
||||
|
||||
let mut form = multipart::Form::new().text("_method", "PUT");
|
||||
if let Some(is_public) = is_public {
|
||||
form = form.text("is_public", if is_public { "1" } else { "0" });
|
||||
}
|
||||
for file in files {
|
||||
form = form.part("documents[]", file.into_part()?);
|
||||
}
|
||||
|
||||
let response = self
|
||||
.execute(self.req(Method::POST, path).multipart(form))
|
||||
.await?;
|
||||
let body: ItemResponse<T> = Self::decode(response).await?;
|
||||
Ok(body.data)
|
||||
}
|
||||
|
||||
/// Issues a request whose body is discarded, failing on non-2xx.
|
||||
async fn get_discard(&self, path: &str) -> Result<()> {
|
||||
self.get_raw(path).await.map(|_| ())
|
||||
}
|
||||
|
||||
async fn post_discard<B: Serialize>(&self, path: &str, payload: &B) -> Result<()> {
|
||||
self.post_raw(path, payload).await.map(|_| ())
|
||||
}
|
||||
}
|
||||
|
||||
/// Generates typed list/list_all/get/create/update/delete methods for a
|
||||
@@ -609,6 +697,34 @@ macro_rules! resource_methods {
|
||||
};
|
||||
}
|
||||
|
||||
/// Generates `upload_{x}`, which attaches files through
|
||||
/// `PUT /{resource}/{id}/upload`. Sixteen resources expose this route; check
|
||||
/// `routes/api.php` before adding one.
|
||||
///
|
||||
/// The upload route is how a document is created at all —
|
||||
/// `DocumentController::store` is an empty stub upstream, which is why there
|
||||
/// is no `create_document`.
|
||||
macro_rules! upload_methods {
|
||||
($upload:ident, $path:literal, $model:ty) => {
|
||||
/// Attaches files to the record and returns it as refreshed by the
|
||||
/// server. `is_public` sets whether the documents are visible to the
|
||||
/// client in the portal; `None` leaves the server default.
|
||||
///
|
||||
/// The server enforces file type and size limits, which surface as a
|
||||
/// 422 [`Error::Api`](crate::Error::Api). Uploads are never retried,
|
||||
/// since a replay could attach the file twice.
|
||||
pub async fn $upload(
|
||||
&self,
|
||||
id: &str,
|
||||
files: Vec<FileUpload>,
|
||||
is_public: Option<bool>,
|
||||
) -> Result<$model> {
|
||||
self.upload_files(&format!("{}/{}/upload", $path, seg(id)?), files, is_public)
|
||||
.await
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// Generates the `GET /{resource}/{id}/{action}` helpers. Only invoices,
|
||||
/// quotes, credits, and purchase orders expose this route.
|
||||
macro_rules! entity_action_methods {
|
||||
@@ -977,6 +1093,33 @@ impl InvoiceNinjaClient {
|
||||
.await
|
||||
}
|
||||
|
||||
// --- document uploads -----------------------------------------------
|
||||
|
||||
upload_methods!(upload_client, "clients", ClientModel);
|
||||
upload_methods!(upload_company, "companies", Company);
|
||||
upload_methods!(upload_credit, "credits", Credit);
|
||||
upload_methods!(upload_expense, "expenses", Expense);
|
||||
upload_methods!(upload_group_setting, "group_settings", GroupSetting);
|
||||
upload_methods!(upload_invoice, "invoices", Invoice);
|
||||
upload_methods!(upload_payment, "payments", Payment);
|
||||
upload_methods!(upload_product, "products", Product);
|
||||
upload_methods!(upload_project, "projects", Project);
|
||||
upload_methods!(upload_purchase_order, "purchase_orders", PurchaseOrder);
|
||||
upload_methods!(upload_quote, "quotes", Quote);
|
||||
upload_methods!(
|
||||
upload_recurring_expense,
|
||||
"recurring_expenses",
|
||||
RecurringExpense
|
||||
);
|
||||
upload_methods!(
|
||||
upload_recurring_invoice,
|
||||
"recurring_invoices",
|
||||
RecurringInvoice
|
||||
);
|
||||
upload_methods!(upload_recurring_quote, "recurring_quotes", RecurringQuote);
|
||||
upload_methods!(upload_task, "tasks", Task);
|
||||
upload_methods!(upload_vendor, "vendors", Vendor);
|
||||
|
||||
// --- entity actions with non-entity responses -----------------------
|
||||
|
||||
/// Converts an invoice into a purchase order. Separate from
|
||||
@@ -1317,3 +1460,450 @@ impl InvoiceNinjaClient {
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-resource routes that sit beside the CRUD set. Each answers with its
|
||||
/// own shape (checked against the upstream controllers), so none of them is
|
||||
/// macro-generated.
|
||||
///
|
||||
/// All are `POST` unless noted, so none is retried on a 5xx or a timeout.
|
||||
/// Several are no-body triggers; they send `{}` rather than an empty body.
|
||||
impl InvoiceNinjaClient {
|
||||
// --- webhooks, tasks, designs ---------------------------------------
|
||||
|
||||
/// Re-sends a webhook for one entity. `body` takes `entity` (e.g.
|
||||
/// `invoice`) and `entity_id`. Answers with the webhook itself, not the
|
||||
/// delivery result.
|
||||
pub async fn retry_webhook<B: Serialize>(&self, id: &str, body: &B) -> Result<Webhook> {
|
||||
self.post_one(&format!("webhooks/{}/retry", seg(id)?), body)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Reorders task-board columns and the tasks in them. `body` takes
|
||||
/// `status_ids` (task status ids, in order) and `task_ids` (a map from
|
||||
/// task status id to the ordered task ids in that column).
|
||||
pub async fn sort_tasks<B: Serialize>(&self, body: &B) -> Result<MessageResponse> {
|
||||
self.post_unwrapped("tasks/sort", body).await
|
||||
}
|
||||
|
||||
/// Makes a design the default for an entity. `body` takes `design_id`
|
||||
/// and `entity`, and optionally `settings_level` (`company`,
|
||||
/// `group_settings`, or `client`) with the matching `group_settings_id`
|
||||
/// or `client_id`.
|
||||
pub async fn set_default_design<B: Serialize>(&self, body: &B) -> Result<MessageResponse> {
|
||||
self.post_unwrapped("designs/set/default", body).await
|
||||
}
|
||||
|
||||
// --- client gateway tokens ------------------------------------------
|
||||
|
||||
/// Makes a stored payment method the client's default.
|
||||
pub async fn set_default_client_gateway_token(&self, id: &str) -> Result<ClientGatewayToken> {
|
||||
self.post_one(
|
||||
&format!("client_gateway_tokens/{}/setAsDefault", seg(id)?),
|
||||
&serde_json::json!({}),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
// --- subscriptions --------------------------------------------------
|
||||
|
||||
/// The steps of the purchase flow and what each depends on.
|
||||
///
|
||||
/// There is no `data` wrapper. The server sends a JSON object keyed by
|
||||
/// the PHP class behind each step (the controller's `collect()` keeps
|
||||
/// those keys), though the upstream source reads as if it were a list.
|
||||
/// Both shapes decode here; the keys are dropped, since each step's `id`
|
||||
/// says the same thing, and the order of the result is not meaningful.
|
||||
pub async fn subscription_steps(&self) -> Result<Vec<SubscriptionStep>> {
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum Steps {
|
||||
List(Vec<SubscriptionStep>),
|
||||
Keyed(std::collections::BTreeMap<String, SubscriptionStep>),
|
||||
}
|
||||
|
||||
Ok(match self.get_unwrapped("subscriptions/steps").await? {
|
||||
Steps::List(steps) => steps,
|
||||
Steps::Keyed(steps) => steps.into_values().collect(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Validates a proposed `steps` list against the dependency rules.
|
||||
/// Succeeds on a valid list; an invalid one is a 422
|
||||
/// ([`Error::Api`]) carrying the reason.
|
||||
pub async fn check_subscription_steps<B: Serialize>(&self, body: &B) -> Result<()> {
|
||||
self.post_discard("subscriptions/steps/check", body).await
|
||||
}
|
||||
|
||||
// --- company gateways -----------------------------------------------
|
||||
|
||||
/// Asks the gateway to verify its credentials. The outcome is in the
|
||||
/// returned message; a failed check is still an HTTP 200.
|
||||
pub async fn test_company_gateway(&self, id: &str) -> Result<MessageResponse> {
|
||||
self.post_unwrapped(
|
||||
&format!("company_gateways/{}/test", seg(id)?),
|
||||
&serde_json::json!({}),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Duplicates a company gateway, returning the copy.
|
||||
pub async fn clone_company_gateway(&self, id: &str) -> Result<CompanyGateway> {
|
||||
self.post_one(
|
||||
&format!("company_gateways/{}/clone", seg(id)?),
|
||||
&serde_json::json!({}),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Starts importing the gateway's customers as clients. The import runs
|
||||
/// in the background; the message only confirms it was queued.
|
||||
pub async fn import_company_gateway_customers(&self, id: &str) -> Result<MessageResponse> {
|
||||
self.post_unwrapped(
|
||||
&format!("company_gateways/{}/import_customers", seg(id)?),
|
||||
&serde_json::json!({}),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
// --- bank integrations and transactions -----------------------------
|
||||
|
||||
/// Asks the server to refresh every linked bank account. The response
|
||||
/// body is a serialised query builder with no usable content, so it is
|
||||
/// discarded; list the integrations afterwards to see the result.
|
||||
/// Throttled to 30 calls a minute.
|
||||
pub async fn refresh_bank_accounts(&self) -> Result<()> {
|
||||
self.post_discard("bank_integrations/refresh_accounts", &serde_json::json!({}))
|
||||
.await
|
||||
}
|
||||
|
||||
/// Disconnects a bank account, returning the updated integration.
|
||||
/// `account_id` is the provider's account id, not the integration's.
|
||||
pub async fn remove_bank_account(&self, account_id: &str) -> Result<BankIntegration> {
|
||||
self.post_one(
|
||||
&format!("bank_integrations/remove_account/{}", seg(account_id)?),
|
||||
&serde_json::json!({}),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Starts fetching transactions for every auto-sync integration. Despite
|
||||
/// the `account_id` in the route, the server ignores it. Throttled to one
|
||||
/// call a minute.
|
||||
pub async fn fetch_bank_transactions(&self, account_id: &str) -> Result<MessageResponse> {
|
||||
self.post_unwrapped(
|
||||
&format!("bank_integrations/get_transactions/{}", seg(account_id)?),
|
||||
&serde_json::json!({}),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Matches bank transactions to invoices, payments, or expenses. `body`
|
||||
/// takes a `transactions` array; the matched transactions come back.
|
||||
pub async fn match_bank_transactions<B: Serialize>(
|
||||
&self,
|
||||
body: &B,
|
||||
) -> Result<Vec<BankTransaction>> {
|
||||
self.post_list("bank_transactions/match", body).await
|
||||
}
|
||||
|
||||
// --- client extras --------------------------------------------------
|
||||
|
||||
/// Re-fetches the client's tax data from the tax provider. Throttled to
|
||||
/// 10 calls a minute.
|
||||
pub async fn update_client_tax_data(&self, id: &str) -> Result<ClientModel> {
|
||||
self.post_one(
|
||||
&format!("clients/{}/updateTaxData", seg(id)?),
|
||||
&serde_json::json!({}),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// The client's effective settings, each tagged with the level it came
|
||||
/// from. Left as raw JSON: the map's keys are the settings themselves.
|
||||
pub async fn client_show_settings(&self, id: &str) -> Result<serde_json::Value> {
|
||||
self.post_unwrapped(
|
||||
&format!("clients/{}/show_settings", seg(id)?),
|
||||
&serde_json::json!({}),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Looks the client up on the Peppol network. Left as raw JSON: the
|
||||
/// controller returns whatever the discovery service produced.
|
||||
pub async fn client_peppol_discovery(&self, id: &str) -> Result<serde_json::Value> {
|
||||
self.post_unwrapped(
|
||||
&format!("clients/{}/peppol_discovery", seg(id)?),
|
||||
&serde_json::json!({}),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Validates the client's VAT number. Updates the client's VAT status as
|
||||
/// a side effect.
|
||||
pub async fn check_client_vat(&self, id: &str) -> Result<MessageResponse> {
|
||||
self.post_unwrapped(
|
||||
&format!("clients/{}/check_vat", seg(id)?),
|
||||
&serde_json::json!({}),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// The client's VAT validation status. A `GET`, and the only read-only
|
||||
/// method in this block.
|
||||
pub async fn client_vat_status(&self, id: &str) -> Result<MessageResponse> {
|
||||
self.get_unwrapped(&format!("clients/{}/vat_status", seg(id)?))
|
||||
.await
|
||||
}
|
||||
|
||||
/// Every document attached to the client or to its invoices, quotes,
|
||||
/// credits, expenses, payments, tasks, recurring invoices, recurring
|
||||
/// expenses, and projects. `body` takes `filter` (a name substring) and
|
||||
/// `sort` (`column|asc` or `column|desc`). Returns one page.
|
||||
pub async fn client_documents<B: Serialize>(
|
||||
&self,
|
||||
id: &str,
|
||||
body: &B,
|
||||
) -> Result<Vec<Document>> {
|
||||
self.post_list(&format!("clients/{}/documents", seg(id)?), body)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
/// Destructive actions behind the API's `password_protected` middleware.
|
||||
///
|
||||
/// Every method here needs
|
||||
/// [`with_api_password`](InvoiceNinjaClient::with_api_password); without it
|
||||
/// the server normally refuses the request. Most are **irreversible**: a purge
|
||||
/// force-deletes rows rather than archiving them, and a merge removes one of
|
||||
/// the two records.
|
||||
///
|
||||
/// All are `POST`s, so none is retried on a 5xx or a timeout. That matters
|
||||
/// most here: replaying a merge after a lost response could act on a record
|
||||
/// that no longer exists.
|
||||
impl InvoiceNinjaClient {
|
||||
/// Permanently deletes a client and its documents. Unlike
|
||||
/// [`delete_client`](Self::delete_client), which soft-deletes, this cannot
|
||||
/// be undone.
|
||||
pub async fn purge_client(&self, id: &str) -> Result<MessageResponse> {
|
||||
self.post_unwrapped(
|
||||
&format!("clients/{}/purge", seg(id)?),
|
||||
&serde_json::json!({}),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Merges `mergeable_id` into `id`, returning the client that is kept.
|
||||
///
|
||||
/// The route is `clients/{id}/{mergeable_id}/merge` and the controller
|
||||
/// calls `merge` on the first, so `id` is the survivor. Merging a client
|
||||
/// into itself, or naming one that does not exist, is a 400.
|
||||
pub async fn merge_clients(&self, id: &str, mergeable_id: &str) -> Result<ClientModel> {
|
||||
self.post_one(
|
||||
&format!("clients/{}/{}/merge", seg(id)?, seg(mergeable_id)?),
|
||||
&serde_json::json!({}),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Merges `mergeable_id` into `id`, returning the vendor that is kept.
|
||||
/// Same semantics as [`merge_clients`](Self::merge_clients).
|
||||
pub async fn merge_vendors(&self, id: &str, mergeable_id: &str) -> Result<Vendor> {
|
||||
self.post_one(
|
||||
&format!("vendors/{}/{}/merge", seg(id)?, seg(mergeable_id)?),
|
||||
&serde_json::json!({}),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Deletes a company. The hosted app refuses to purge its default
|
||||
/// company with a 400.
|
||||
pub async fn purge_company(&self, id: &str) -> Result<MessageResponse> {
|
||||
self.post_unwrapped(
|
||||
&format!("companies/purge/{}", seg(id)?),
|
||||
&serde_json::json!({}),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Force-deletes a company's clients, products, projects, tasks, vendors,
|
||||
/// expenses, purchase orders, bank transactions and activity, and resets
|
||||
/// its number counters. The company, its settings, and its invoices
|
||||
/// remain as the server leaves them.
|
||||
pub async fn purge_company_save_settings(&self, id: &str) -> Result<MessageResponse> {
|
||||
self.post_unwrapped(
|
||||
&format!("companies/purge_save_settings/{}", seg(id)?),
|
||||
&serde_json::json!({}),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Re-sends a user's invitation email.
|
||||
pub async fn invite_user(&self, id: &str) -> Result<MessageResponse> {
|
||||
self.post_unwrapped(
|
||||
&format!("users/{}/invite", seg(id)?),
|
||||
&serde_json::json!({}),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Permanently deletes a user. Only the account owner may, and not
|
||||
/// themselves. The server answers 204 with no body.
|
||||
pub async fn purge_user(&self, id: &str) -> Result<()> {
|
||||
self.post_discard(&format!("users/{}/purge", seg(id)?), &serde_json::json!({}))
|
||||
.await
|
||||
}
|
||||
|
||||
/// A blank user as the server would create it, for filling in and
|
||||
/// passing to [`create_user`](Self::create_user). Nothing is stored.
|
||||
pub async fn new_user_template(&self) -> Result<User> {
|
||||
self.get_one("users/create").await
|
||||
}
|
||||
}
|
||||
|
||||
/// Activity log, email history, and document previews. Each has a response
|
||||
/// shape of its own (read from the upstream controllers, and for the activity
|
||||
/// routes confirmed against a 5.13.50 server), so none is macro-generated.
|
||||
impl InvoiceNinjaClient {
|
||||
// --- activities -----------------------------------------------------
|
||||
|
||||
/// One page of the activity log, newest first.
|
||||
///
|
||||
/// The server takes its row limit from a `rows` parameter (default 75)
|
||||
/// rather than `per_page`, though it paginates the result as well; use
|
||||
/// [`ListParams::param`] to pass `rows`.
|
||||
pub async fn list_activities(&self, params: &ListParams) -> Result<Vec<Activity>> {
|
||||
self.get_list("activities", params).await
|
||||
}
|
||||
|
||||
pub async fn list_all_activities(&self, params: &ListParams) -> Result<Vec<Activity>> {
|
||||
self.get_all_pages("activities", params).await
|
||||
}
|
||||
|
||||
/// The activity log as the web app shows it: each entry's records are
|
||||
/// resolved into labelled references, newest first. Non-admins see only
|
||||
/// their own activity. `rows` defaults to 75 on the server.
|
||||
pub async fn activity_feed(&self, rows: Option<u32>) -> Result<Vec<ActivityFeedEntry>> {
|
||||
let mut params = ListParams::new().param("reactv2", "1");
|
||||
if let Some(rows) = rows {
|
||||
params = params.param("rows", rows.to_string());
|
||||
}
|
||||
self.get_list("activities", ¶ms).await
|
||||
}
|
||||
|
||||
/// The feed for one record. `entity` is the **singular** name (`invoice`,
|
||||
/// `quote`, `credit`, `purchase_order`, `payment`, `client`, `vendor`,
|
||||
/// `expense`, `task`, `project`, `subscription`, `recurring_invoice`).
|
||||
/// Contrast [`add_activity_note`](Self::add_activity_note), which wants
|
||||
/// the plural.
|
||||
pub async fn entity_activity(
|
||||
&self,
|
||||
entity: &str,
|
||||
entity_id: &str,
|
||||
rows: Option<u32>,
|
||||
) -> Result<Vec<ActivityFeedEntry>> {
|
||||
let mut body = serde_json::json!({ "entity": entity, "entity_id": entity_id });
|
||||
// Left out rather than sent as null: the server tests for the key's
|
||||
// presence, so a null `rows` would count as given and remove the cap.
|
||||
if let Some(rows) = rows {
|
||||
body["rows"] = rows.into();
|
||||
}
|
||||
self.post_list("activities/entity", &body).await
|
||||
}
|
||||
|
||||
/// Adds a note to a record's history. `entity` is the **plural** table
|
||||
/// name (`invoices`, `quotes`, `credits`, `recurring_invoices`,
|
||||
/// `clients`, `vendors`, `payments`, `projects`, `tasks`, `expenses`,
|
||||
/// `recurring_expenses`, `bank_transactions`, `purchase_orders`), unlike
|
||||
/// [`entity_activity`](Self::entity_activity).
|
||||
///
|
||||
/// Not idempotent: each call adds another note, so it is not retried on a
|
||||
/// 5xx.
|
||||
pub async fn add_activity_note(
|
||||
&self,
|
||||
entity: &str,
|
||||
entity_id: &str,
|
||||
notes: &str,
|
||||
) -> Result<Activity> {
|
||||
self.post_one(
|
||||
"activities/notes",
|
||||
&serde_json::json!({ "entity": entity, "entity_id": entity_id, "notes": notes }),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// The PDF of an invoice, quote or credit as it was when the activity was
|
||||
/// recorded. A 404 means that activity kept no backup.
|
||||
pub async fn download_activity_entity(&self, activity_id: &str) -> Result<Vec<u8>> {
|
||||
self.get_raw(&format!("activities/download_entity/{}", seg(activity_id)?))
|
||||
.await
|
||||
}
|
||||
|
||||
// --- email history --------------------------------------------------
|
||||
|
||||
/// The client's email delivery history, newest first: one object per sent
|
||||
/// message that has delivery events. Left as raw JSON, since the objects
|
||||
/// mirror whatever the mail provider reported and no instance available
|
||||
/// here had sent mail to take a shape from.
|
||||
pub async fn client_email_history(&self, client_id: &str) -> Result<Vec<serde_json::Value>> {
|
||||
self.post_unwrapped(
|
||||
&format!("emails/clientHistory/{}", seg(client_id)?),
|
||||
&serde_json::json!({}),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Email delivery history for one record. `entity` is `invoice`, `quote`,
|
||||
/// `credit`, `recurring_invoice`, or `purchase_order`. Raw JSON, as for
|
||||
/// [`client_email_history`](Self::client_email_history).
|
||||
pub async fn entity_email_history(
|
||||
&self,
|
||||
entity: &str,
|
||||
entity_id: &str,
|
||||
) -> Result<Vec<serde_json::Value>> {
|
||||
self.post_unwrapped(
|
||||
"emails/entityHistory",
|
||||
&serde_json::json!({ "entity": entity, "entity_id": entity_id }),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
// --- previews -------------------------------------------------------
|
||||
//
|
||||
// These render a document without saving it and answer with a PDF, so
|
||||
// they return the bytes. `body` is the entity to render plus, for the
|
||||
// design routes, the `design` object.
|
||||
|
||||
/// Renders a preview of an existing record or of an unsaved design.
|
||||
/// Takes `entity`, `entity_id` (`-1` for a mock record) and `design`.
|
||||
pub async fn preview<B: Serialize>(&self, body: &B) -> Result<Vec<u8>> {
|
||||
self.post_raw("preview", body).await
|
||||
}
|
||||
|
||||
/// [`preview`](Self::preview) as the HTML the PDF is made from.
|
||||
pub async fn preview_html<B: Serialize>(&self, body: &B) -> Result<String> {
|
||||
let bytes = self.post_raw("preview?html=true", body).await?;
|
||||
Ok(String::from_utf8_lossy(&bytes).into_owned())
|
||||
}
|
||||
|
||||
/// Renders an unsaved invoice, quote, credit or recurring invoice as a
|
||||
/// PDF: the body is the entity's own fields, plus `entity` and
|
||||
/// `entity_id`.
|
||||
pub async fn live_preview<B: Serialize>(&self, body: &B) -> Result<Vec<u8>> {
|
||||
self.post_raw("live_preview", body).await
|
||||
}
|
||||
|
||||
/// Renders a design against mock data, for editing it.
|
||||
pub async fn live_design<B: Serialize>(&self, body: &B) -> Result<Vec<u8>> {
|
||||
self.post_raw("live_design", body).await
|
||||
}
|
||||
|
||||
/// [`preview`](Self::preview) for a purchase order.
|
||||
pub async fn preview_purchase_order<B: Serialize>(&self, body: &B) -> Result<Vec<u8>> {
|
||||
self.post_raw("preview/purchase_order", body).await
|
||||
}
|
||||
|
||||
/// [`live_preview`](Self::live_preview) for a purchase order.
|
||||
pub async fn live_preview_purchase_order<B: Serialize>(&self, body: &B) -> Result<Vec<u8>> {
|
||||
self.post_raw("live_preview/purchase_order", body).await
|
||||
}
|
||||
}
|
||||
|
||||
+42
-1
@@ -15,7 +15,11 @@ pub struct ApiErrorBody {
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[non_exhaustive]
|
||||
pub enum Error {
|
||||
#[error("http transport error: {0}")]
|
||||
/// Rendered with its whole cause chain, because `reqwest`'s own message
|
||||
/// stops at "error sending request for url (...)" and leaves out the part
|
||||
/// that says why: "Connection refused", a DNS failure, a TLS handshake
|
||||
/// error, a timeout.
|
||||
#[error("http transport error: {}", cause_chain(.0))]
|
||||
Http(#[from] reqwest::Error),
|
||||
|
||||
/// The response was not the JSON this endpoint is documented to return.
|
||||
@@ -31,6 +35,15 @@ pub enum Error {
|
||||
excerpt: String,
|
||||
},
|
||||
|
||||
/// A non-2xx response: the server's message, plus any per-field
|
||||
/// validation errors.
|
||||
///
|
||||
/// **A missing record is `400`, not `404`.** Invoice Ninja's exception
|
||||
/// handler maps Laravel's `ModelNotFoundException` to a 400 whose message
|
||||
/// is `No query results for model [App\Models\Client].`, and reserves 404
|
||||
/// for a route that does not exist or a method that route does not
|
||||
/// support. Matching on 404 to mean "not found" will never fire — match
|
||||
/// the status together with the message.
|
||||
#[error("api returned {status}: {message}")]
|
||||
Api {
|
||||
status: reqwest::StatusCode,
|
||||
@@ -54,6 +67,14 @@ pub enum Error {
|
||||
#[error("`{0}` cannot be used as a url path segment")]
|
||||
InvalidPathSegment(String),
|
||||
|
||||
/// An upload was refused before any request was sent: no files, a file
|
||||
/// with no name, or a MIME type that does not parse.
|
||||
///
|
||||
/// Raised locally because the server would otherwise answer an empty
|
||||
/// upload with a success that changed nothing.
|
||||
#[error("invalid upload: {0}")]
|
||||
InvalidUpload(String),
|
||||
|
||||
/// A `list_all_*` call kept receiving full pages past the point where any
|
||||
/// plausible result set would have ended.
|
||||
///
|
||||
@@ -66,3 +87,23 @@ pub enum Error {
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
/// Joins an error and every error beneath it with `: `.
|
||||
///
|
||||
/// `reqwest::Error`'s `Display` prints only the outermost layer, so the
|
||||
/// reason a request failed lives in `source()` and is otherwise invisible to
|
||||
/// anyone who just prints the error.
|
||||
fn cause_chain(error: &(dyn std::error::Error + 'static)) -> String {
|
||||
let mut message = error.to_string();
|
||||
let mut source = error.source();
|
||||
while let Some(cause) = source {
|
||||
let text = cause.to_string();
|
||||
// Some layers repeat their cause's text; don't print it twice.
|
||||
if !message.contains(&text) {
|
||||
message.push_str(": ");
|
||||
message.push_str(&text);
|
||||
}
|
||||
source = cause.source();
|
||||
}
|
||||
message
|
||||
}
|
||||
|
||||
@@ -18,7 +18,9 @@ mod client;
|
||||
mod error;
|
||||
pub mod models;
|
||||
mod retry;
|
||||
mod upload;
|
||||
|
||||
pub use client::{InvoiceNinjaClient, DEFAULT_TIMEOUT, HOSTED_BASE_URL};
|
||||
pub use error::{ApiErrorBody, Error, Result};
|
||||
pub use retry::RetryPolicy;
|
||||
pub use upload::FileUpload;
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
/// One row of the activity log, as `GET /activities` returns it.
|
||||
///
|
||||
/// Every reference is a hashed id, or an empty string when the activity does
|
||||
/// not concern that kind of record. `activity_type_id` arrives as a string
|
||||
/// here but as a number in the [feed](ActivityFeedEntry).
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct Activity {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<String>,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub activity_type_id: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub user_id: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub company_id: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub client_id: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub contact_id: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub vendor_id: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub vendor_contact_id: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub invoice_id: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub quote_id: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub credit_id: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub payment_id: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub recurring_invoice_id: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub recurring_expense_id: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub purchase_order_id: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub expense_id: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub task_id: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub token_id: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub is_system: bool,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub notes: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub ip: String,
|
||||
#[serde(default)]
|
||||
pub created_at: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub updated_at: Option<i64>,
|
||||
|
||||
#[serde(flatten)]
|
||||
pub extra: Map<String, Value>,
|
||||
}
|
||||
|
||||
/// A named record an activity refers to: the display label and its hashed id.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct ActivityRef {
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub label: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub hashed_id: String,
|
||||
|
||||
#[serde(flatten)]
|
||||
pub extra: Map<String, Value>,
|
||||
}
|
||||
|
||||
/// One entry of the activity feed (`activity_feed`, `entity_activity`).
|
||||
///
|
||||
/// A different shape from [`Activity`]: the server resolves the records an
|
||||
/// activity mentions into labelled references. Which references are present
|
||||
/// depends on the activity type, so each is optional, and any the server adds
|
||||
/// that this does not name lands in `extra`.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct ActivityFeedEntry {
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub id: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub hashed_id: String,
|
||||
#[serde(default, deserialize_with = "super::common::int_or_numeric_string")]
|
||||
pub activity_type_id: i64,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub notes: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub ip: String,
|
||||
#[serde(default, deserialize_with = "super::common::int_or_numeric_string")]
|
||||
pub created_at: i64,
|
||||
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub user: Option<ActivityRef>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub client: Option<ActivityRef>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub vendor: Option<ActivityRef>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub invoice: Option<ActivityRef>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub quote: Option<ActivityRef>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub credit: Option<ActivityRef>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub payment: Option<ActivityRef>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub task: Option<ActivityRef>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub expense: Option<ActivityRef>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub purchase_order: Option<ActivityRef>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub recurring_invoice: Option<ActivityRef>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub recurring_expense: Option<ActivityRef>,
|
||||
|
||||
#[serde(flatten)]
|
||||
pub extra: Map<String, Value>,
|
||||
}
|
||||
@@ -154,6 +154,32 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
/// Deserializes an integer that may arrive as a JSON number or as a numeric
|
||||
/// string.
|
||||
///
|
||||
/// The OpenAPI spec calls `frequency_id` a number on recurring invoices and
|
||||
/// expenses, but a real 5.13.50 server returns the string `"5"`. A strict
|
||||
/// `i64` made that a hard decode error *after* the record had already been
|
||||
/// created. A string that is not a whole number is still an error.
|
||||
pub(crate) fn int_or_numeric_string<'de, D>(deserializer: D) -> Result<i64, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
match Option::<Value>::deserialize(deserializer)? {
|
||||
None | Some(Value::Null) => Ok(0),
|
||||
Some(Value::Number(n)) => n
|
||||
.as_i64()
|
||||
.ok_or_else(|| serde::de::Error::custom(format!("expected a whole number, got {n}"))),
|
||||
Some(Value::String(s)) if s.is_empty() => Ok(0),
|
||||
Some(Value::String(s)) => s.trim().parse().map_err(|_| {
|
||||
serde::de::Error::custom(format!("expected a whole number, got string `{s}`"))
|
||||
}),
|
||||
Some(other) => Err(serde::de::Error::custom(format!(
|
||||
"expected a whole number or numeric string, got {other}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Body for a bulk action (`POST /{resource}/bulk`).
|
||||
///
|
||||
/// The action set varies by resource; `archive`, `restore`, and `delete` work
|
||||
@@ -174,3 +200,15 @@ impl BulkRequest {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The `{"message": "..."}` body that action endpoints answer with.
|
||||
///
|
||||
/// `message` is defaulted so a reply that carries none still decodes.
|
||||
/// Anything else the server adds is kept in `extra`.
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
pub struct MessageResponse {
|
||||
#[serde(default, deserialize_with = "null_to_default")]
|
||||
pub message: String,
|
||||
#[serde(flatten)]
|
||||
pub extra: serde_json::Map<String, Value>,
|
||||
}
|
||||
|
||||
@@ -18,7 +18,14 @@ pub struct Location {
|
||||
pub state: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub postal_code: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
/// Omitted when empty: the server validates it as an integer, so an unset
|
||||
/// `""` is rejected with a 422 and a default `Location` could not be
|
||||
/// created.
|
||||
#[serde(
|
||||
default,
|
||||
deserialize_with = "super::common::null_to_default",
|
||||
skip_serializing_if = "String::is_empty"
|
||||
)]
|
||||
pub country_id: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub user_id: String,
|
||||
|
||||
+6
-2
@@ -1,3 +1,4 @@
|
||||
mod activity;
|
||||
mod bank_integration;
|
||||
mod bank_transaction;
|
||||
mod bank_transaction_rule;
|
||||
@@ -35,12 +36,15 @@ mod user;
|
||||
mod vendor;
|
||||
mod webhook;
|
||||
|
||||
pub use activity::{Activity, ActivityFeedEntry, ActivityRef};
|
||||
pub use bank_integration::BankIntegration;
|
||||
pub use bank_transaction::BankTransaction;
|
||||
pub use bank_transaction_rule::BankTransactionRule;
|
||||
pub use client::{Client, ClientContact};
|
||||
pub use client_gateway_token::ClientGatewayToken;
|
||||
pub use common::{BulkRequest, ItemResponse, ListParams, ListResponse, Meta, Pagination};
|
||||
pub use common::{
|
||||
BulkRequest, ItemResponse, ListParams, ListResponse, MessageResponse, Meta, Pagination,
|
||||
};
|
||||
pub use company::Company;
|
||||
pub use company_gateway::CompanyGateway;
|
||||
pub use company_token::CompanyToken;
|
||||
@@ -61,7 +65,7 @@ pub use recurring_expense::RecurringExpense;
|
||||
pub use recurring_invoice::{RecurringInvoice, RecurringQuote};
|
||||
pub use search::{SearchHit, SearchResults};
|
||||
pub use statics::{Currency, DateFormat, StaticItem, Statics};
|
||||
pub use subscription::Subscription;
|
||||
pub use subscription::{Subscription, SubscriptionStep};
|
||||
pub use system_log::SystemLog;
|
||||
pub use tag::Tag;
|
||||
pub use task::Task;
|
||||
|
||||
@@ -38,7 +38,9 @@ pub struct Payment {
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct PaymentInvoice {
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub invoice_id: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub amount: f64,
|
||||
|
||||
#[serde(flatten)]
|
||||
|
||||
@@ -23,7 +23,14 @@ pub struct PurchaseOrder {
|
||||
pub po_number: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub quote_number: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
/// Omitted when empty: the server turns `""` into `NULL` and then fails
|
||||
/// the insert on a non-null column with a 500, so a new purchase order
|
||||
/// must leave it out and take the server's default.
|
||||
#[serde(
|
||||
default,
|
||||
deserialize_with = "super::common::null_to_default",
|
||||
skip_serializing_if = "String::is_empty"
|
||||
)]
|
||||
pub status_id: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub date: String,
|
||||
|
||||
@@ -28,7 +28,7 @@ pub struct RecurringExpense {
|
||||
/// Integer enum matching [`RecurringInvoice::frequency_id`].
|
||||
///
|
||||
/// [`RecurringInvoice::frequency_id`]: super::RecurringInvoice::frequency_id
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
#[serde(default, deserialize_with = "super::common::int_or_numeric_string")]
|
||||
pub frequency_id: i64,
|
||||
/// Number of cycles left; `-1` means it recurs indefinitely.
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
|
||||
@@ -25,8 +25,9 @@ pub struct RecurringInvoice {
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub status_id: String,
|
||||
/// How often the invoice recurs. Invoice Ninja returns this as an integer
|
||||
/// enum (1 = daily, 2 = weekly, … ), not a human-readable string.
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
/// enum (1 = daily, 2 = weekly, … ), not a human-readable string. A real
|
||||
/// server sends it as a numeric string; both decode.
|
||||
#[serde(default, deserialize_with = "super::common::int_or_numeric_string")]
|
||||
pub frequency_id: i64,
|
||||
/// Number of cycles left to send; `-1` means it recurs indefinitely.
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
|
||||
@@ -55,3 +55,16 @@ pub struct Subscription {
|
||||
#[serde(flatten)]
|
||||
pub extra: Map<String, Value>,
|
||||
}
|
||||
|
||||
/// One step of the subscription purchase flow and the steps it depends on,
|
||||
/// as returned by `GET /subscriptions/steps`.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct SubscriptionStep {
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub id: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub dependencies: Vec<String>,
|
||||
|
||||
#[serde(flatten)]
|
||||
pub extra: Map<String, Value>,
|
||||
}
|
||||
|
||||
@@ -3,8 +3,9 @@ use serde_json::{Map, Value};
|
||||
|
||||
/// A server-side log entry (gateway responses, mail failures, and so on).
|
||||
///
|
||||
/// Note that unlike every other resource, this one's `created_at` and
|
||||
/// `updated_at` are returned as date **strings**, not integer timestamps.
|
||||
/// The OpenAPI spec declares `created_at` and `updated_at` as date strings,
|
||||
/// but a real server (5.13.50) sends integer timestamps like every other
|
||||
/// resource.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct SystemLog {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -23,9 +24,9 @@ pub struct SystemLog {
|
||||
#[serde(default)]
|
||||
pub log: Value,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub created_at: String,
|
||||
pub created_at: i64,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub updated_at: String,
|
||||
pub updated_at: i64,
|
||||
|
||||
#[serde(flatten)]
|
||||
pub extra: Map<String, Value>,
|
||||
|
||||
@@ -13,8 +13,13 @@ pub struct Webhook {
|
||||
/// The URL Invoice Ninja posts to.
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub target_url: String,
|
||||
/// Payload format: `JSON` or `UBL`.
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
/// Payload format: `JSON` or `UBL`. Omitted when empty: the server turns
|
||||
/// `""` into `NULL` and fails the insert on a non-null column (a 500).
|
||||
#[serde(
|
||||
default,
|
||||
deserialize_with = "super::common::null_to_default",
|
||||
skip_serializing_if = "String::is_empty"
|
||||
)]
|
||||
pub format: String,
|
||||
|
||||
#[serde(flatten)]
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
use std::fmt;
|
||||
|
||||
use reqwest::multipart::Part;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
/// A file to attach to a record with the `upload_*` methods.
|
||||
///
|
||||
/// The contents are held in memory, so a large file costs its full size in
|
||||
/// RAM for as long as the upload takes. Streaming uploads are an open
|
||||
/// question in `GOALS.md`.
|
||||
///
|
||||
/// ```
|
||||
/// use invoiceninjars::FileUpload;
|
||||
///
|
||||
/// let file = FileUpload::new("receipt.pdf", b"%PDF-1.4 ...".to_vec())
|
||||
/// .mime("application/pdf");
|
||||
/// ```
|
||||
#[derive(Clone)]
|
||||
pub struct FileUpload {
|
||||
file_name: String,
|
||||
bytes: Vec<u8>,
|
||||
mime: Option<String>,
|
||||
}
|
||||
|
||||
impl FileUpload {
|
||||
/// `file_name` is what the server records and shows against the
|
||||
/// document, so it should carry the right extension.
|
||||
pub fn new(file_name: impl Into<String>, bytes: impl Into<Vec<u8>>) -> Self {
|
||||
Self {
|
||||
file_name: file_name.into(),
|
||||
bytes: bytes.into(),
|
||||
mime: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the part's `Content-Type`. Optional: without it the part carries
|
||||
/// none, and the server decides from the file's contents.
|
||||
pub fn mime(mut self, mime: impl Into<String>) -> Self {
|
||||
self.mime = Some(mime.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn into_part(self) -> Result<Part> {
|
||||
if self.file_name.trim().is_empty() {
|
||||
return Err(Error::InvalidUpload(
|
||||
"a file needs a non-empty file name".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let part = Part::bytes(self.bytes).file_name(self.file_name);
|
||||
match self.mime {
|
||||
Some(mime) => part
|
||||
.mime_str(&mime)
|
||||
.map_err(|_| Error::InvalidUpload(format!("`{mime}` is not a valid MIME type"))),
|
||||
None => Ok(part),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Elides the contents: the derived impl would print the entire file.
|
||||
impl fmt::Debug for FileUpload {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("FileUpload")
|
||||
.field("file_name", &self.file_name)
|
||||
.field("len", &self.bytes.len())
|
||||
.field("mime", &self.mime)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,127 @@
|
||||
//! Goal 2, checked mechanically: every model must absorb a field that is
|
||||
//! `null` or absent.
|
||||
//!
|
||||
//! Invoice Ninja returns `null` for many nominally-string fields and omits
|
||||
//! others, so a model that fails on either takes the whole response down with
|
||||
//! it. Rather than trusting each field to carry the right serde attribute,
|
||||
//! this serialises `Default` (which emits every non-optional field), then for
|
||||
//! each field in turn nulls it and, separately, removes it, and requires the
|
||||
//! result to still decode.
|
||||
//!
|
||||
//! A wrong *type* is deliberately not covered: that is meant to be a hard
|
||||
//! error, so it is not something to absorb.
|
||||
|
||||
use invoiceninjars::models::*;
|
||||
use serde::{de::DeserializeOwned, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
/// Returns one line per mutation that failed to decode.
|
||||
fn failures<T: Serialize + DeserializeOwned + Default>(name: &str) -> Vec<String> {
|
||||
let base = serde_json::to_value(T::default()).expect("default serialises");
|
||||
let object = base
|
||||
.as_object()
|
||||
.unwrap_or_else(|| panic!("{name}: default did not serialise to an object"));
|
||||
|
||||
let mut out = Vec::new();
|
||||
for key in object.keys() {
|
||||
let mut nulled = base.clone();
|
||||
nulled[key] = Value::Null;
|
||||
if let Err(e) = serde_json::from_value::<T>(nulled) {
|
||||
out.push(format!("{name}.{key} = null: {e}"));
|
||||
}
|
||||
|
||||
let mut removed = base.clone();
|
||||
removed.as_object_mut().unwrap().remove(key);
|
||||
if let Err(e) = serde_json::from_value::<T>(removed) {
|
||||
out.push(format!("{name}.{key} absent: {e}"));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
macro_rules! tolerant {
|
||||
($($ty:ty),+ $(,)?) => {{
|
||||
let mut all = Vec::new();
|
||||
$( all.extend(failures::<$ty>(stringify!($ty))); )+
|
||||
all
|
||||
}};
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_model_absorbs_a_null_or_missing_field() {
|
||||
let all = tolerant![
|
||||
Activity,
|
||||
ActivityFeedEntry,
|
||||
ActivityRef,
|
||||
BankIntegration,
|
||||
BankTransaction,
|
||||
BankTransactionRule,
|
||||
Client,
|
||||
ClientContact,
|
||||
ClientGatewayToken,
|
||||
Company,
|
||||
CompanyGateway,
|
||||
CompanyToken,
|
||||
Design,
|
||||
Document,
|
||||
Expense,
|
||||
ExpenseCategory,
|
||||
GroupSetting,
|
||||
Invoice,
|
||||
InvoiceItem,
|
||||
Location,
|
||||
Payment,
|
||||
PaymentInvoice,
|
||||
PaymentTerm,
|
||||
Product,
|
||||
Project,
|
||||
PurchaseOrder,
|
||||
RecurringExpense,
|
||||
RecurringInvoice,
|
||||
Subscription,
|
||||
SubscriptionStep,
|
||||
SystemLog,
|
||||
Tag,
|
||||
Task,
|
||||
TaskScheduler,
|
||||
TaskStatus,
|
||||
TaxRate,
|
||||
User,
|
||||
Vendor,
|
||||
VendorContact,
|
||||
Webhook,
|
||||
];
|
||||
|
||||
assert!(
|
||||
all.is_empty(),
|
||||
"{} field(s) break decoding on null/absent:\n{}",
|
||||
all.len(),
|
||||
all.join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
/// A guard on the test itself: if `Default` stopped emitting fields, the loop
|
||||
/// above would pass vacuously.
|
||||
#[test]
|
||||
fn the_tolerance_test_actually_exercises_fields() {
|
||||
let client = serde_json::to_value(Client::default()).unwrap();
|
||||
assert!(
|
||||
client.as_object().unwrap().len() > 10,
|
||||
"Client::default() should serialise many fields"
|
||||
);
|
||||
}
|
||||
|
||||
/// The mutation really does bite: a field without `null_to_default` fails.
|
||||
#[test]
|
||||
fn a_bare_non_option_field_would_be_caught() {
|
||||
#[derive(serde::Serialize, serde::Deserialize, Default)]
|
||||
struct Naive {
|
||||
#[serde(default)]
|
||||
name: String,
|
||||
}
|
||||
assert_eq!(
|
||||
failures::<Naive>("Naive").len(),
|
||||
1,
|
||||
"only the null case fails"
|
||||
);
|
||||
}
|
||||
Vendored
+29
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"activity_type_id": "",
|
||||
"client_id": "",
|
||||
"company_id": "",
|
||||
"contact_id": "",
|
||||
"created_at": 0,
|
||||
"credit_id": "",
|
||||
"expense_id": "",
|
||||
"id": "",
|
||||
"invoice_id": "",
|
||||
"ip": "",
|
||||
"is_system": false,
|
||||
"notes": "",
|
||||
"payment_id": "",
|
||||
"purchase_order_id": "",
|
||||
"quote_id": "",
|
||||
"recurring_expense_id": "",
|
||||
"recurring_invoice_id": "",
|
||||
"task_id": "",
|
||||
"token_id": "",
|
||||
"updated_at": 0,
|
||||
"user_id": "",
|
||||
"vendor_contact_id": "",
|
||||
"vendor_id": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+24
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"activity_type_id": 0,
|
||||
"created_at": 0,
|
||||
"hashed_id": "",
|
||||
"id": "",
|
||||
"ip": "",
|
||||
"notes": "",
|
||||
"purchase_order": {
|
||||
"hashed_id": "",
|
||||
"label": ""
|
||||
},
|
||||
"user": {
|
||||
"hashed_id": "",
|
||||
"label": ""
|
||||
},
|
||||
"vendor": {
|
||||
"hashed_id": "",
|
||||
"label": ""
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+114
@@ -0,0 +1,114 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"address1": "",
|
||||
"address2": "",
|
||||
"archived_at": 0,
|
||||
"assigned_user_id": "",
|
||||
"balance": 0,
|
||||
"city": "",
|
||||
"classification": "",
|
||||
"client_hash": "",
|
||||
"contacts": [
|
||||
{
|
||||
"archived_at": 0,
|
||||
"can_sign": false,
|
||||
"cc_only": false,
|
||||
"contact_key": "",
|
||||
"created_at": 0,
|
||||
"custom_value1": "",
|
||||
"custom_value2": "",
|
||||
"custom_value3": "",
|
||||
"custom_value4": "",
|
||||
"email": "",
|
||||
"first_name": "",
|
||||
"id": "",
|
||||
"is_locked": false,
|
||||
"is_primary": false,
|
||||
"last_login": 0,
|
||||
"last_name": "",
|
||||
"link": "",
|
||||
"password": "",
|
||||
"phone": "",
|
||||
"send_email": false,
|
||||
"updated_at": 0
|
||||
}
|
||||
],
|
||||
"country_id": "",
|
||||
"created_at": 0,
|
||||
"credit_balance": 0,
|
||||
"custom_value1": "",
|
||||
"custom_value2": "",
|
||||
"custom_value3": "",
|
||||
"custom_value4": "",
|
||||
"display_name": "",
|
||||
"documents": [
|
||||
{
|
||||
"archived_at": 0,
|
||||
"assigned_user_id": "",
|
||||
"created_at": 0,
|
||||
"disk": "",
|
||||
"documentable_id": "",
|
||||
"documentable_type": "",
|
||||
"hash": "",
|
||||
"height": 0,
|
||||
"id": "",
|
||||
"is_default": false,
|
||||
"is_deleted": false,
|
||||
"is_public": false,
|
||||
"link": "",
|
||||
"name": "",
|
||||
"preview": "",
|
||||
"project_id": "",
|
||||
"size": 0,
|
||||
"type": "",
|
||||
"updated_at": 0,
|
||||
"url": "",
|
||||
"user_id": "",
|
||||
"vendor_id": "",
|
||||
"width": 0
|
||||
}
|
||||
],
|
||||
"e_invoice": {},
|
||||
"gateway_tokens": [],
|
||||
"group_settings_id": "",
|
||||
"has_valid_vat_number": false,
|
||||
"id": "",
|
||||
"id_number": "",
|
||||
"industry_id": "",
|
||||
"is_deleted": false,
|
||||
"is_tax_exempt": false,
|
||||
"last_login": 0,
|
||||
"locations": [],
|
||||
"name": "",
|
||||
"number": "",
|
||||
"paid_to_date": 0,
|
||||
"payment_balance": 0,
|
||||
"phone": "",
|
||||
"postal_code": "",
|
||||
"private_notes": "",
|
||||
"public_notes": "",
|
||||
"routing_id": "",
|
||||
"settings": {
|
||||
"currency_id": "",
|
||||
"entity": "",
|
||||
"industry_id": null,
|
||||
"size_id": null
|
||||
},
|
||||
"shipping_address1": "",
|
||||
"shipping_address2": "",
|
||||
"shipping_city": "",
|
||||
"shipping_country_id": "",
|
||||
"shipping_postal_code": "",
|
||||
"shipping_state": "",
|
||||
"size_id": "",
|
||||
"state": "",
|
||||
"tags": [],
|
||||
"tax_info": {},
|
||||
"updated_at": 0,
|
||||
"user_id": "",
|
||||
"vat_number": "",
|
||||
"website": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+1059
File diff suppressed because it is too large
Load Diff
Vendored
+139
@@ -0,0 +1,139 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"amount": 0,
|
||||
"archived_at": 0,
|
||||
"assigned_user_id": "",
|
||||
"backup": {
|
||||
"adjustable_amount": 0,
|
||||
"cancellation": {
|
||||
"adjustment": 0,
|
||||
"status_id": 0
|
||||
},
|
||||
"child_invoice_ids": [],
|
||||
"document_type": null,
|
||||
"e_invoice_cleared_at": null,
|
||||
"e_invoice_status": null,
|
||||
"guid": "",
|
||||
"notes": null,
|
||||
"parent_invoice_id": null,
|
||||
"parent_invoice_number": null,
|
||||
"redirect": null
|
||||
},
|
||||
"balance": 0,
|
||||
"client_id": "",
|
||||
"created_at": 0,
|
||||
"custom_surcharge1": 0,
|
||||
"custom_surcharge2": 0,
|
||||
"custom_surcharge3": 0,
|
||||
"custom_surcharge4": 0,
|
||||
"custom_surcharge_tax1": false,
|
||||
"custom_surcharge_tax2": false,
|
||||
"custom_surcharge_tax3": false,
|
||||
"custom_surcharge_tax4": false,
|
||||
"custom_value1": "",
|
||||
"custom_value2": "",
|
||||
"custom_value3": "",
|
||||
"custom_value4": "",
|
||||
"date": "",
|
||||
"design_id": "",
|
||||
"discount": 0,
|
||||
"documents": [],
|
||||
"due_date": "",
|
||||
"e_invoice": {},
|
||||
"entity_type": "",
|
||||
"exchange_rate": 0,
|
||||
"footer": "",
|
||||
"has_expenses": false,
|
||||
"has_tasks": false,
|
||||
"id": "",
|
||||
"invitations": [
|
||||
{
|
||||
"archived_at": 0,
|
||||
"can_sign": false,
|
||||
"client_contact_id": "",
|
||||
"created_at": 0,
|
||||
"email_error": "",
|
||||
"email_status": "",
|
||||
"id": "",
|
||||
"key": "",
|
||||
"link": "",
|
||||
"message_id": "",
|
||||
"opened_date": "",
|
||||
"sent_date": "",
|
||||
"updated_at": 0,
|
||||
"viewed_date": ""
|
||||
}
|
||||
],
|
||||
"invoice_id": "",
|
||||
"is_amount_discount": false,
|
||||
"is_deleted": false,
|
||||
"last_sent_date": "",
|
||||
"line_items": [
|
||||
{
|
||||
"cost": 0,
|
||||
"custom_value1": "",
|
||||
"custom_value2": "",
|
||||
"custom_value3": "",
|
||||
"custom_value4": "",
|
||||
"date": "",
|
||||
"discount": 0,
|
||||
"expense_id": "",
|
||||
"gross_line_total": 0,
|
||||
"income_account_id": "",
|
||||
"is_amount_discount": false,
|
||||
"line_total": 0,
|
||||
"net_cost": 0,
|
||||
"notes": "",
|
||||
"product_cost": 0,
|
||||
"product_key": "",
|
||||
"quantity": 0,
|
||||
"sort_id": "",
|
||||
"tags": "",
|
||||
"task_id": "",
|
||||
"tax_amount": 0,
|
||||
"tax_id": "",
|
||||
"tax_name1": "",
|
||||
"tax_name2": "",
|
||||
"tax_name3": "",
|
||||
"tax_rate1": 0,
|
||||
"tax_rate2": 0,
|
||||
"tax_rate3": 0,
|
||||
"type_id": "",
|
||||
"unit_code": ""
|
||||
}
|
||||
],
|
||||
"location_id": "",
|
||||
"next_send_date": "",
|
||||
"number": "",
|
||||
"paid_to_date": 0,
|
||||
"partial": 0,
|
||||
"partial_due_date": "",
|
||||
"po_number": "",
|
||||
"private_notes": "",
|
||||
"project_id": "",
|
||||
"public_notes": "",
|
||||
"reminder1_sent": "",
|
||||
"reminder2_sent": "",
|
||||
"reminder3_sent": "",
|
||||
"reminder_last_sent": "",
|
||||
"status_id": "",
|
||||
"subscription_id": "",
|
||||
"sync": null,
|
||||
"tags": [],
|
||||
"tax_info": {},
|
||||
"tax_name1": "",
|
||||
"tax_name2": "",
|
||||
"tax_name3": "",
|
||||
"tax_rate1": 0,
|
||||
"tax_rate2": 0,
|
||||
"tax_rate3": 0,
|
||||
"terms": "",
|
||||
"total_taxes": 0,
|
||||
"updated_at": 0,
|
||||
"user_id": "",
|
||||
"uses_inclusive_taxes": false,
|
||||
"vendor_id": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+25
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"archived_at": 0,
|
||||
"created_at": 0,
|
||||
"design": {
|
||||
"body": "",
|
||||
"footer": "",
|
||||
"header": "",
|
||||
"includes": "",
|
||||
"product": "",
|
||||
"task": ""
|
||||
},
|
||||
"entities": "",
|
||||
"id": "",
|
||||
"is_active": false,
|
||||
"is_custom": false,
|
||||
"is_deleted": false,
|
||||
"is_free": false,
|
||||
"is_template": false,
|
||||
"name": "",
|
||||
"updated_at": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+29
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"archived_at": 0,
|
||||
"assigned_user_id": "",
|
||||
"created_at": 0,
|
||||
"disk": "",
|
||||
"documentable_id": "",
|
||||
"documentable_type": "",
|
||||
"hash": "",
|
||||
"height": 0,
|
||||
"id": "",
|
||||
"is_default": false,
|
||||
"is_deleted": false,
|
||||
"is_public": false,
|
||||
"link": "",
|
||||
"name": "",
|
||||
"preview": "",
|
||||
"project_id": "",
|
||||
"size": 0,
|
||||
"type": "",
|
||||
"updated_at": 0,
|
||||
"url": "",
|
||||
"user_id": "",
|
||||
"vendor_id": "",
|
||||
"width": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"archived_at": 0,
|
||||
"color": "",
|
||||
"created_at": 0,
|
||||
"id": "",
|
||||
"is_deleted": false,
|
||||
"name": "",
|
||||
"updated_at": 0,
|
||||
"user_id": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+55
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"amount": 0,
|
||||
"archived_at": 0,
|
||||
"assigned_user_id": "",
|
||||
"bank_id": "",
|
||||
"calculate_tax_by_amount": false,
|
||||
"category_id": "",
|
||||
"client_id": "",
|
||||
"created_at": 0,
|
||||
"currency_id": "",
|
||||
"custom_value1": "",
|
||||
"custom_value2": "",
|
||||
"custom_value3": "",
|
||||
"custom_value4": "",
|
||||
"date": "",
|
||||
"documents": [],
|
||||
"e_invoice": {},
|
||||
"entity_type": "",
|
||||
"exchange_rate": 0,
|
||||
"expense_currency_id": "",
|
||||
"foreign_amount": 0,
|
||||
"id": "",
|
||||
"invoice_currency_id": "",
|
||||
"invoice_documents": false,
|
||||
"invoice_id": "",
|
||||
"is_deleted": false,
|
||||
"number": "",
|
||||
"payment_date": "",
|
||||
"payment_type_id": "",
|
||||
"private_notes": "",
|
||||
"project_id": "",
|
||||
"public_notes": "",
|
||||
"recurring_expense_id": "",
|
||||
"should_be_invoiced": false,
|
||||
"tags": [],
|
||||
"tax_amount1": 0,
|
||||
"tax_amount2": 0,
|
||||
"tax_amount3": 0,
|
||||
"tax_name1": "",
|
||||
"tax_name2": "",
|
||||
"tax_name3": "",
|
||||
"tax_rate1": 0,
|
||||
"tax_rate2": 0,
|
||||
"tax_rate3": 0,
|
||||
"transaction_id": "",
|
||||
"transaction_reference": "",
|
||||
"updated_at": 0,
|
||||
"user_id": "",
|
||||
"uses_inclusive_taxes": false,
|
||||
"vendor_id": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"archived_at": 0,
|
||||
"created_at": 0,
|
||||
"documents": [],
|
||||
"id": "",
|
||||
"is_deleted": false,
|
||||
"name": "",
|
||||
"settings": {
|
||||
"entity": ""
|
||||
},
|
||||
"updated_at": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+37
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"api_version": "",
|
||||
"cache_enabled": false,
|
||||
"env_writable": false,
|
||||
"exchange_rate_api_not_configured": false,
|
||||
"exec": false,
|
||||
"extensions": [
|
||||
{
|
||||
"gd": false
|
||||
}
|
||||
],
|
||||
"file_permissions": "",
|
||||
"flutter_renderer": "",
|
||||
"is_docker": false,
|
||||
"jobs_pending": 0,
|
||||
"mail_mailer": "",
|
||||
"open_basedir": false,
|
||||
"pdf_engine": "",
|
||||
"pending_migrations": false,
|
||||
"phantom_enabled": false,
|
||||
"php_version": {
|
||||
"current_php_cli_version": "",
|
||||
"current_php_version": "",
|
||||
"is_okay": false,
|
||||
"memory_limit": "",
|
||||
"minimum_php_version": ""
|
||||
},
|
||||
"queue": "",
|
||||
"queue_data": {
|
||||
"failed": 0,
|
||||
"last_error": "",
|
||||
"pending": 0
|
||||
},
|
||||
"simple_db_check": false,
|
||||
"system_health": false,
|
||||
"trailing_slash": false
|
||||
}
|
||||
Vendored
+140
@@ -0,0 +1,140 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"amount": 0,
|
||||
"archived_at": 0,
|
||||
"assigned_user_id": "",
|
||||
"auto_bill_enabled": false,
|
||||
"backup": {
|
||||
"adjustable_amount": 0,
|
||||
"cancellation": {
|
||||
"adjustment": 0,
|
||||
"status_id": 0
|
||||
},
|
||||
"child_invoice_ids": [],
|
||||
"document_type": null,
|
||||
"e_invoice_cleared_at": null,
|
||||
"e_invoice_status": null,
|
||||
"guid": "",
|
||||
"notes": null,
|
||||
"parent_invoice_id": null,
|
||||
"parent_invoice_number": null,
|
||||
"redirect": null
|
||||
},
|
||||
"balance": 0,
|
||||
"client_id": "",
|
||||
"created_at": 0,
|
||||
"custom_surcharge1": 0,
|
||||
"custom_surcharge2": 0,
|
||||
"custom_surcharge3": 0,
|
||||
"custom_surcharge4": 0,
|
||||
"custom_surcharge_tax1": false,
|
||||
"custom_surcharge_tax2": false,
|
||||
"custom_surcharge_tax3": false,
|
||||
"custom_surcharge_tax4": false,
|
||||
"custom_value1": "",
|
||||
"custom_value2": "",
|
||||
"custom_value3": "",
|
||||
"custom_value4": "",
|
||||
"date": "",
|
||||
"design_id": "",
|
||||
"discount": 0,
|
||||
"documents": [],
|
||||
"due_date": "",
|
||||
"e_invoice": {},
|
||||
"entity_type": "",
|
||||
"exchange_rate": 0,
|
||||
"footer": "",
|
||||
"has_expenses": false,
|
||||
"has_tasks": false,
|
||||
"id": "",
|
||||
"invitations": [
|
||||
{
|
||||
"archived_at": 0,
|
||||
"can_sign": false,
|
||||
"client_contact_id": "",
|
||||
"created_at": 0,
|
||||
"email_error": "",
|
||||
"email_status": "",
|
||||
"id": "",
|
||||
"key": "",
|
||||
"link": "",
|
||||
"message_id": "",
|
||||
"opened_date": "",
|
||||
"sent_date": "",
|
||||
"updated_at": 0,
|
||||
"viewed_date": ""
|
||||
}
|
||||
],
|
||||
"is_amount_discount": false,
|
||||
"is_deleted": false,
|
||||
"last_sent_date": "",
|
||||
"line_items": [
|
||||
{
|
||||
"cost": 0,
|
||||
"custom_value1": "",
|
||||
"custom_value2": "",
|
||||
"custom_value3": "",
|
||||
"custom_value4": "",
|
||||
"date": "",
|
||||
"discount": 0,
|
||||
"expense_id": "",
|
||||
"gross_line_total": 0,
|
||||
"income_account_id": "",
|
||||
"is_amount_discount": false,
|
||||
"line_total": 0,
|
||||
"net_cost": 0,
|
||||
"notes": "",
|
||||
"product_cost": 0,
|
||||
"product_key": "",
|
||||
"quantity": 0,
|
||||
"sort_id": "",
|
||||
"tags": "",
|
||||
"task_id": "",
|
||||
"tax_amount": 0,
|
||||
"tax_id": "",
|
||||
"tax_name1": "",
|
||||
"tax_name2": "",
|
||||
"tax_name3": "",
|
||||
"tax_rate1": 0,
|
||||
"tax_rate2": 0,
|
||||
"tax_rate3": 0,
|
||||
"type_id": "",
|
||||
"unit_code": ""
|
||||
}
|
||||
],
|
||||
"location_id": "",
|
||||
"next_send_date": "",
|
||||
"number": "",
|
||||
"paid_to_date": 0,
|
||||
"partial": 0,
|
||||
"partial_due_date": "",
|
||||
"po_number": "",
|
||||
"private_notes": "",
|
||||
"project_id": "",
|
||||
"public_notes": "",
|
||||
"recurring_id": "",
|
||||
"reminder1_sent": "",
|
||||
"reminder2_sent": "",
|
||||
"reminder3_sent": "",
|
||||
"reminder_last_sent": "",
|
||||
"status_id": "",
|
||||
"subscription_id": "",
|
||||
"sync": null,
|
||||
"tags": [],
|
||||
"tax_info": {},
|
||||
"tax_name1": "",
|
||||
"tax_name2": "",
|
||||
"tax_name3": "",
|
||||
"tax_rate1": 0,
|
||||
"tax_rate2": 0,
|
||||
"tax_rate3": 0,
|
||||
"terms": "",
|
||||
"total_taxes": 0,
|
||||
"updated_at": 0,
|
||||
"user_id": "",
|
||||
"uses_inclusive_taxes": false,
|
||||
"vendor_id": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+27
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"address1": "",
|
||||
"address2": "",
|
||||
"archived_at": 0,
|
||||
"city": "",
|
||||
"client_id": "",
|
||||
"country_id": "",
|
||||
"created_at": 0,
|
||||
"custom_value1": "",
|
||||
"custom_value2": "",
|
||||
"custom_value3": "",
|
||||
"custom_value4": "",
|
||||
"id": "",
|
||||
"is_deleted": false,
|
||||
"is_shipping_location": false,
|
||||
"name": "",
|
||||
"postal_code": "",
|
||||
"state": "",
|
||||
"tax_data": {},
|
||||
"updated_at": 0,
|
||||
"user_id": "",
|
||||
"vendor_id": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+13
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"archived_at": 0,
|
||||
"created_at": 0,
|
||||
"id": "",
|
||||
"is_deleted": false,
|
||||
"name": "",
|
||||
"num_days": 0,
|
||||
"updated_at": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+52
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"amount": 0,
|
||||
"applied": 0,
|
||||
"archived_at": 0,
|
||||
"assigned_user_id": "",
|
||||
"client_contact_id": "",
|
||||
"client_id": "",
|
||||
"company_gateway_id": "",
|
||||
"created_at": 0,
|
||||
"currency_id": "",
|
||||
"custom_value1": "",
|
||||
"custom_value2": "",
|
||||
"custom_value3": "",
|
||||
"custom_value4": "",
|
||||
"date": "",
|
||||
"documents": [],
|
||||
"exchange_currency_id": "",
|
||||
"exchange_rate": 0,
|
||||
"gateway_type_id": "",
|
||||
"id": "",
|
||||
"invitation_id": "",
|
||||
"is_deleted": false,
|
||||
"is_manual": false,
|
||||
"number": "",
|
||||
"paymentables": [
|
||||
{
|
||||
"amount": 0,
|
||||
"archived_at": 0,
|
||||
"created_at": 0,
|
||||
"id": "",
|
||||
"invoice_id": "",
|
||||
"payment_id": "",
|
||||
"refunded": 0,
|
||||
"updated_at": 0
|
||||
}
|
||||
],
|
||||
"private_notes": "",
|
||||
"project_id": "",
|
||||
"refunded": 0,
|
||||
"status_id": "",
|
||||
"tags": [],
|
||||
"transaction_id": "",
|
||||
"transaction_reference": "",
|
||||
"type_id": "",
|
||||
"updated_at": 0,
|
||||
"user_id": "",
|
||||
"vendor_id": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+37
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"archived_at": 0,
|
||||
"assigned_user_id": "",
|
||||
"cost": 0,
|
||||
"created_at": 0,
|
||||
"custom_value1": "",
|
||||
"custom_value2": "",
|
||||
"custom_value3": "",
|
||||
"custom_value4": "",
|
||||
"documents": [],
|
||||
"id": "",
|
||||
"in_stock_quantity": 0,
|
||||
"income_account_id": "",
|
||||
"is_deleted": false,
|
||||
"max_quantity": 0,
|
||||
"notes": "",
|
||||
"price": 0,
|
||||
"product_image": "",
|
||||
"product_key": "",
|
||||
"quantity": 0,
|
||||
"stock_notification": false,
|
||||
"stock_notification_threshold": 0,
|
||||
"tags": [],
|
||||
"tax_id": "",
|
||||
"tax_name1": "",
|
||||
"tax_name2": "",
|
||||
"tax_name3": "",
|
||||
"tax_rate1": 0,
|
||||
"tax_rate2": 0,
|
||||
"tax_rate3": 0,
|
||||
"updated_at": 0,
|
||||
"user_id": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+30
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"archived_at": 0,
|
||||
"assigned_user_id": "",
|
||||
"budgeted_amount": 0,
|
||||
"budgeted_hours": 0,
|
||||
"client_id": "",
|
||||
"color": "",
|
||||
"created_at": 0,
|
||||
"current_hours": 0,
|
||||
"custom_value1": "",
|
||||
"custom_value2": "",
|
||||
"custom_value3": "",
|
||||
"custom_value4": "",
|
||||
"documents": [],
|
||||
"due_date": "",
|
||||
"id": "",
|
||||
"is_deleted": false,
|
||||
"name": "",
|
||||
"number": "",
|
||||
"private_notes": "",
|
||||
"public_notes": "",
|
||||
"tags": [],
|
||||
"task_rate": 0,
|
||||
"updated_at": 0,
|
||||
"user_id": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+93
@@ -0,0 +1,93 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"amount": 0,
|
||||
"archived_at": 0,
|
||||
"assigned_user_id": "",
|
||||
"balance": 0,
|
||||
"client_id": "",
|
||||
"created_at": 0,
|
||||
"currency_id": "",
|
||||
"custom_surcharge1": 0,
|
||||
"custom_surcharge2": 0,
|
||||
"custom_surcharge3": 0,
|
||||
"custom_surcharge4": 0,
|
||||
"custom_surcharge_tax1": false,
|
||||
"custom_surcharge_tax2": false,
|
||||
"custom_surcharge_tax3": false,
|
||||
"custom_surcharge_tax4": false,
|
||||
"custom_value1": "",
|
||||
"custom_value2": "",
|
||||
"custom_value3": "",
|
||||
"custom_value4": "",
|
||||
"date": "",
|
||||
"design_id": "",
|
||||
"discount": 0,
|
||||
"documents": [],
|
||||
"due_date": "",
|
||||
"e_invoice": {},
|
||||
"entity_type": "",
|
||||
"exchange_rate": 0,
|
||||
"expense_id": "",
|
||||
"footer": "",
|
||||
"has_expenses": false,
|
||||
"has_tasks": false,
|
||||
"id": "",
|
||||
"invitations": [
|
||||
{
|
||||
"archived_at": 0,
|
||||
"can_sign": false,
|
||||
"created_at": 0,
|
||||
"email_error": "",
|
||||
"email_status": "",
|
||||
"id": "",
|
||||
"key": "",
|
||||
"link": "",
|
||||
"message_id": "",
|
||||
"opened_date": "",
|
||||
"sent_date": "",
|
||||
"updated_at": 0,
|
||||
"vendor_contact_id": "",
|
||||
"viewed_date": ""
|
||||
}
|
||||
],
|
||||
"invoice_id": "",
|
||||
"is_amount_discount": false,
|
||||
"is_deleted": false,
|
||||
"last_sent_date": "",
|
||||
"line_items": [],
|
||||
"location_id": "",
|
||||
"next_send_date": "",
|
||||
"number": "",
|
||||
"paid_to_date": 0,
|
||||
"partial": 0,
|
||||
"partial_due_date": "",
|
||||
"po_number": "",
|
||||
"private_notes": "",
|
||||
"project_id": "",
|
||||
"public_notes": "",
|
||||
"quote_id": "",
|
||||
"reminder1_sent": "",
|
||||
"reminder2_sent": "",
|
||||
"reminder3_sent": "",
|
||||
"reminder_last_sent": "",
|
||||
"status_id": "",
|
||||
"subscription_id": "",
|
||||
"sync": null,
|
||||
"tags": [],
|
||||
"tax_info": {},
|
||||
"tax_name1": "",
|
||||
"tax_name2": "",
|
||||
"tax_name3": "",
|
||||
"tax_rate1": 0,
|
||||
"tax_rate2": 0,
|
||||
"tax_rate3": 0,
|
||||
"terms": "",
|
||||
"total_taxes": 0,
|
||||
"updated_at": 0,
|
||||
"user_id": "",
|
||||
"uses_inclusive_taxes": false,
|
||||
"vendor_id": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+123
@@ -0,0 +1,123 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"amount": 0,
|
||||
"archived_at": 0,
|
||||
"assigned_user_id": "",
|
||||
"balance": 0,
|
||||
"client_id": "",
|
||||
"created_at": 0,
|
||||
"custom_surcharge1": 0,
|
||||
"custom_surcharge2": 0,
|
||||
"custom_surcharge3": 0,
|
||||
"custom_surcharge4": 0,
|
||||
"custom_surcharge_tax1": false,
|
||||
"custom_surcharge_tax2": false,
|
||||
"custom_surcharge_tax3": false,
|
||||
"custom_surcharge_tax4": false,
|
||||
"custom_value1": "",
|
||||
"custom_value2": "",
|
||||
"custom_value3": "",
|
||||
"custom_value4": "",
|
||||
"date": "",
|
||||
"design_id": "",
|
||||
"discount": 0,
|
||||
"documents": [],
|
||||
"due_date": "",
|
||||
"e_invoice": {},
|
||||
"entity_type": "",
|
||||
"exchange_rate": 0,
|
||||
"footer": "",
|
||||
"has_expenses": false,
|
||||
"has_tasks": false,
|
||||
"id": "",
|
||||
"invitations": [
|
||||
{
|
||||
"archived_at": 0,
|
||||
"can_sign": false,
|
||||
"client_contact_id": "",
|
||||
"created_at": 0,
|
||||
"email_error": "",
|
||||
"email_status": "",
|
||||
"id": "",
|
||||
"key": "",
|
||||
"link": "",
|
||||
"message_id": "",
|
||||
"opened_date": "",
|
||||
"sent_date": "",
|
||||
"updated_at": 0,
|
||||
"viewed_date": ""
|
||||
}
|
||||
],
|
||||
"invoice_id": "",
|
||||
"is_amount_discount": false,
|
||||
"is_deleted": false,
|
||||
"last_sent_date": "",
|
||||
"line_items": [
|
||||
{
|
||||
"cost": 0,
|
||||
"custom_value1": "",
|
||||
"custom_value2": "",
|
||||
"custom_value3": "",
|
||||
"custom_value4": "",
|
||||
"date": "",
|
||||
"discount": 0,
|
||||
"expense_id": "",
|
||||
"gross_line_total": 0,
|
||||
"income_account_id": "",
|
||||
"is_amount_discount": false,
|
||||
"line_total": 0,
|
||||
"net_cost": 0,
|
||||
"notes": "",
|
||||
"product_cost": 0,
|
||||
"product_key": "",
|
||||
"quantity": 0,
|
||||
"sort_id": "",
|
||||
"tags": "",
|
||||
"task_id": "",
|
||||
"tax_amount": 0,
|
||||
"tax_id": "",
|
||||
"tax_name1": "",
|
||||
"tax_name2": "",
|
||||
"tax_name3": "",
|
||||
"tax_rate1": 0,
|
||||
"tax_rate2": 0,
|
||||
"tax_rate3": 0,
|
||||
"type_id": "",
|
||||
"unit_code": ""
|
||||
}
|
||||
],
|
||||
"location_id": "",
|
||||
"next_send_date": "",
|
||||
"number": "",
|
||||
"paid_to_date": 0,
|
||||
"partial": 0,
|
||||
"partial_due_date": "",
|
||||
"po_number": "",
|
||||
"private_notes": "",
|
||||
"project_id": "",
|
||||
"public_notes": "",
|
||||
"reminder1_sent": "",
|
||||
"reminder2_sent": "",
|
||||
"reminder3_sent": "",
|
||||
"reminder_last_sent": "",
|
||||
"status_id": "",
|
||||
"subscription_id": "",
|
||||
"sync": null,
|
||||
"tags": [],
|
||||
"tax_info": {},
|
||||
"tax_name1": "",
|
||||
"tax_name2": "",
|
||||
"tax_name3": "",
|
||||
"tax_rate1": 0,
|
||||
"tax_rate2": 0,
|
||||
"tax_rate3": 0,
|
||||
"terms": "",
|
||||
"total_taxes": 0,
|
||||
"updated_at": 0,
|
||||
"user_id": "",
|
||||
"uses_inclusive_taxes": false,
|
||||
"vendor_id": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"amount": 0,
|
||||
"archived_at": 0,
|
||||
"assigned_user_id": "",
|
||||
"bank_id": "",
|
||||
"calculate_tax_by_amount": false,
|
||||
"category_id": "",
|
||||
"client_id": "",
|
||||
"created_at": 0,
|
||||
"currency_id": "",
|
||||
"custom_value1": "",
|
||||
"custom_value2": "",
|
||||
"custom_value3": "",
|
||||
"custom_value4": "",
|
||||
"date": "",
|
||||
"documents": [],
|
||||
"entity_type": "",
|
||||
"exchange_rate": 0,
|
||||
"foreign_amount": 0,
|
||||
"frequency_id": "",
|
||||
"id": "",
|
||||
"invoice_currency_id": "",
|
||||
"invoice_documents": false,
|
||||
"invoice_id": "",
|
||||
"is_deleted": false,
|
||||
"last_sent_date": "",
|
||||
"next_send_date": "",
|
||||
"number": "",
|
||||
"payment_date": "",
|
||||
"payment_type_id": "",
|
||||
"private_notes": "",
|
||||
"project_id": "",
|
||||
"public_notes": "",
|
||||
"recurring_dates": [],
|
||||
"recurring_expense_currency_id": "",
|
||||
"remaining_cycles": 0,
|
||||
"should_be_invoiced": false,
|
||||
"status_id": "",
|
||||
"tags": [],
|
||||
"tax_amount1": 0,
|
||||
"tax_amount2": 0,
|
||||
"tax_amount3": 0,
|
||||
"tax_name1": "",
|
||||
"tax_name2": "",
|
||||
"tax_name3": "",
|
||||
"tax_rate1": 0,
|
||||
"tax_rate2": 0,
|
||||
"tax_rate3": 0,
|
||||
"transaction_id": "",
|
||||
"transaction_reference": "",
|
||||
"updated_at": 0,
|
||||
"user_id": "",
|
||||
"uses_inclusive_taxes": false,
|
||||
"vendor_id": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"amount": 0,
|
||||
"archived_at": 0,
|
||||
"assigned_user_id": "",
|
||||
"auto_bill": "",
|
||||
"auto_bill_enabled": false,
|
||||
"balance": 0,
|
||||
"client_id": "",
|
||||
"created_at": 0,
|
||||
"custom_surcharge1": 0,
|
||||
"custom_surcharge2": 0,
|
||||
"custom_surcharge3": 0,
|
||||
"custom_surcharge4": 0,
|
||||
"custom_surcharge_tax1": false,
|
||||
"custom_surcharge_tax2": false,
|
||||
"custom_surcharge_tax3": false,
|
||||
"custom_surcharge_tax4": false,
|
||||
"custom_value1": "",
|
||||
"custom_value2": "",
|
||||
"custom_value3": "",
|
||||
"custom_value4": "",
|
||||
"date": "",
|
||||
"design_id": "",
|
||||
"discount": 0,
|
||||
"documents": [],
|
||||
"due_date": "",
|
||||
"due_date_days": "",
|
||||
"e_invoice": {},
|
||||
"entity_type": "",
|
||||
"exchange_rate": 0,
|
||||
"footer": "",
|
||||
"frequency_id": "",
|
||||
"has_expenses": false,
|
||||
"has_tasks": false,
|
||||
"id": "",
|
||||
"invitations": [
|
||||
{
|
||||
"archived_at": 0,
|
||||
"can_sign": false,
|
||||
"client_contact_id": "",
|
||||
"created_at": 0,
|
||||
"email_error": "",
|
||||
"email_status": "",
|
||||
"id": "",
|
||||
"key": "",
|
||||
"link": "",
|
||||
"message_id": "",
|
||||
"opened_date": "",
|
||||
"sent_date": "",
|
||||
"updated_at": 0,
|
||||
"viewed_date": ""
|
||||
}
|
||||
],
|
||||
"is_amount_discount": false,
|
||||
"is_deleted": false,
|
||||
"last_sent_date": "",
|
||||
"line_items": [],
|
||||
"location_id": "",
|
||||
"next_send_date": "",
|
||||
"next_send_datetime": "",
|
||||
"number": "",
|
||||
"paid_to_date": 0,
|
||||
"partial": 0,
|
||||
"partial_due_date": "",
|
||||
"po_number": "",
|
||||
"private_notes": "",
|
||||
"project_id": "",
|
||||
"public_notes": "",
|
||||
"recurring_dates": [],
|
||||
"remaining_cycles": 0,
|
||||
"status_id": "",
|
||||
"subscription_id": "",
|
||||
"tags": [],
|
||||
"tax_name1": "",
|
||||
"tax_name2": "",
|
||||
"tax_name3": "",
|
||||
"tax_rate1": 0,
|
||||
"tax_rate2": 0,
|
||||
"tax_rate3": 0,
|
||||
"terms": "",
|
||||
"total_taxes": 0,
|
||||
"updated_at": 0,
|
||||
"user_id": "",
|
||||
"uses_inclusive_taxes": false,
|
||||
"vendor_id": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+185
@@ -0,0 +1,185 @@
|
||||
{
|
||||
"bulk_updates": {
|
||||
"client": [
|
||||
""
|
||||
],
|
||||
"expense": [
|
||||
""
|
||||
],
|
||||
"recurring_invoice": [
|
||||
""
|
||||
],
|
||||
"task": [
|
||||
""
|
||||
]
|
||||
},
|
||||
"countries": [
|
||||
{
|
||||
"capital": "",
|
||||
"citizenship": "",
|
||||
"country_code": "",
|
||||
"currency": "",
|
||||
"currency_code": "",
|
||||
"currency_sub_unit": "",
|
||||
"decimal_separator": "",
|
||||
"eea": false,
|
||||
"full_name": "",
|
||||
"id": "",
|
||||
"iso_3166_2": "",
|
||||
"iso_3166_3": "",
|
||||
"name": "",
|
||||
"region_code": "",
|
||||
"sub_region_code": "",
|
||||
"swap_currency_symbol": false,
|
||||
"swap_postal_code": false,
|
||||
"thousand_separator": ""
|
||||
}
|
||||
],
|
||||
"currencies": [
|
||||
{
|
||||
"code": "",
|
||||
"decimal_separator": "",
|
||||
"exchange_rate": 0,
|
||||
"id": "",
|
||||
"name": "",
|
||||
"precision": 0,
|
||||
"swap_currency_symbol": false,
|
||||
"symbol": "",
|
||||
"thousand_separator": ""
|
||||
}
|
||||
],
|
||||
"date_formats": [
|
||||
{
|
||||
"format": "",
|
||||
"format_dart": "",
|
||||
"format_moment": "",
|
||||
"id": ""
|
||||
}
|
||||
],
|
||||
"datetime_formats": [
|
||||
{
|
||||
"format": "",
|
||||
"format_dart": "",
|
||||
"format_moment": "",
|
||||
"id": ""
|
||||
}
|
||||
],
|
||||
"gateways": [
|
||||
{
|
||||
"created_at": 0,
|
||||
"default_gateway_type_id": "",
|
||||
"fields": "",
|
||||
"id": "",
|
||||
"is_offsite": false,
|
||||
"is_secure": false,
|
||||
"key": "",
|
||||
"name": "",
|
||||
"options": {
|
||||
"1": {
|
||||
"refund": false,
|
||||
"token_billing": false,
|
||||
"webhooks": [
|
||||
""
|
||||
]
|
||||
},
|
||||
"2": {
|
||||
"refund": false,
|
||||
"token_billing": false,
|
||||
"webhooks": [
|
||||
""
|
||||
]
|
||||
}
|
||||
},
|
||||
"provider": "",
|
||||
"site_url": "",
|
||||
"sort_order": 0,
|
||||
"updated_at": 0,
|
||||
"visible": false
|
||||
}
|
||||
],
|
||||
"industries": [
|
||||
{
|
||||
"id": "",
|
||||
"name": ""
|
||||
}
|
||||
],
|
||||
"languages": [
|
||||
{
|
||||
"id": "",
|
||||
"locale": "",
|
||||
"name": ""
|
||||
}
|
||||
],
|
||||
"license_key": false,
|
||||
"payment_types": [
|
||||
{
|
||||
"gateway_type_id": 0,
|
||||
"id": "",
|
||||
"name": ""
|
||||
}
|
||||
],
|
||||
"sizes": [
|
||||
{
|
||||
"id": "",
|
||||
"name": ""
|
||||
}
|
||||
],
|
||||
"templates": {
|
||||
"invoice": {
|
||||
"body": "",
|
||||
"subject": ""
|
||||
},
|
||||
"partial_payment": {
|
||||
"body": "",
|
||||
"subject": ""
|
||||
},
|
||||
"payment": {
|
||||
"body": "",
|
||||
"subject": ""
|
||||
},
|
||||
"payment_failed": {
|
||||
"body": "",
|
||||
"subject": ""
|
||||
},
|
||||
"purchase_order": {
|
||||
"body": "",
|
||||
"subject": ""
|
||||
},
|
||||
"quote": {
|
||||
"body": "",
|
||||
"subject": ""
|
||||
},
|
||||
"quote_reminder1": {
|
||||
"body": "",
|
||||
"subject": ""
|
||||
},
|
||||
"reminder1": {
|
||||
"body": "",
|
||||
"subject": ""
|
||||
},
|
||||
"reminder2": {
|
||||
"body": "",
|
||||
"subject": ""
|
||||
},
|
||||
"reminder3": {
|
||||
"body": "",
|
||||
"subject": ""
|
||||
},
|
||||
"reminder_endless": {
|
||||
"body": "",
|
||||
"subject": ""
|
||||
},
|
||||
"statement": {
|
||||
"body": "",
|
||||
"subject": ""
|
||||
}
|
||||
},
|
||||
"timezones": [
|
||||
{
|
||||
"id": "",
|
||||
"location": "",
|
||||
"name": "",
|
||||
"utc_offset": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"App\\Livewire\\BillingPortal\\Authentication\\Login": {
|
||||
"dependencies": [],
|
||||
"id": ""
|
||||
},
|
||||
"App\\Livewire\\BillingPortal\\Authentication\\Register": {
|
||||
"dependencies": [],
|
||||
"id": ""
|
||||
},
|
||||
"App\\Livewire\\BillingPortal\\Authentication\\RegisterOrLogin": {
|
||||
"dependencies": [],
|
||||
"id": ""
|
||||
},
|
||||
"App\\Livewire\\BillingPortal\\Cart\\Cart": {
|
||||
"dependencies": [],
|
||||
"id": ""
|
||||
}
|
||||
}
|
||||
Vendored
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"category_id": 0,
|
||||
"client_id": "",
|
||||
"company_id": "",
|
||||
"created_at": 0,
|
||||
"event_id": 0,
|
||||
"id": "",
|
||||
"log": "",
|
||||
"type_id": 0,
|
||||
"updated_at": 0,
|
||||
"user_id": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"archived_at": 0,
|
||||
"color": null,
|
||||
"created_at": 0,
|
||||
"entity_type": "",
|
||||
"id": "",
|
||||
"is_deleted": false,
|
||||
"name": "",
|
||||
"updated_at": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"archived_at": 0,
|
||||
"color": "",
|
||||
"created_at": 0,
|
||||
"id": "",
|
||||
"is_deleted": false,
|
||||
"name": "",
|
||||
"status_order": 0,
|
||||
"updated_at": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+36
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"archived_at": 0,
|
||||
"assigned_user_id": "",
|
||||
"client_id": "",
|
||||
"created_at": 0,
|
||||
"custom_value1": "",
|
||||
"custom_value2": "",
|
||||
"custom_value3": "",
|
||||
"custom_value4": "",
|
||||
"date": "",
|
||||
"description": "",
|
||||
"documents": [],
|
||||
"due_date": "",
|
||||
"duration": 0,
|
||||
"estimated_duration": null,
|
||||
"id": "",
|
||||
"invoice_id": "",
|
||||
"is_date_based": false,
|
||||
"is_deleted": false,
|
||||
"is_running": false,
|
||||
"meta": {},
|
||||
"number": "",
|
||||
"project_id": "",
|
||||
"rate": 0,
|
||||
"status_id": "",
|
||||
"status_order": null,
|
||||
"status_sort_order": 0,
|
||||
"tags": [],
|
||||
"time_log": "",
|
||||
"updated_at": 0,
|
||||
"user_id": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+13
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"archived_at": 0,
|
||||
"created_at": 0,
|
||||
"id": "",
|
||||
"is_deleted": false,
|
||||
"name": "",
|
||||
"rate": 0,
|
||||
"updated_at": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"archived_at": 0,
|
||||
"created_at": 0,
|
||||
"id": "",
|
||||
"is_deleted": false,
|
||||
"is_system": false,
|
||||
"name": "",
|
||||
"token": "",
|
||||
"updated_at": 0,
|
||||
"user_id": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+44
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"archived_at": 0,
|
||||
"created_at": 0,
|
||||
"custom_value1": "",
|
||||
"custom_value2": "",
|
||||
"custom_value3": "",
|
||||
"custom_value4": "",
|
||||
"email": "",
|
||||
"email_verified_at": 0,
|
||||
"first_name": "",
|
||||
"google_2fa_secret": false,
|
||||
"has_password": false,
|
||||
"id": "",
|
||||
"is_deleted": false,
|
||||
"language_id": "",
|
||||
"last_confirmed_email_address": "",
|
||||
"last_login": 0,
|
||||
"last_name": "",
|
||||
"oauth_provider_id": "",
|
||||
"oauth_user_token": "",
|
||||
"passkey_count": 0,
|
||||
"passkey_enabled": false,
|
||||
"phone": "",
|
||||
"referral_code": "",
|
||||
"referral_meta": {
|
||||
"enterprise": 0,
|
||||
"free": 0,
|
||||
"pro": 0
|
||||
},
|
||||
"settings": {
|
||||
"calendar_connection": {
|
||||
"email": "",
|
||||
"status": ""
|
||||
}
|
||||
},
|
||||
"signature": "",
|
||||
"updated_at": 0,
|
||||
"user_logged_in_notification": false,
|
||||
"verified_phone_number": false
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+63
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"address1": "",
|
||||
"address2": "",
|
||||
"archived_at": 0,
|
||||
"assigned_user_id": "",
|
||||
"city": "",
|
||||
"classification": "",
|
||||
"contacts": [
|
||||
{
|
||||
"archived_at": 0,
|
||||
"can_sign": false,
|
||||
"cc_only": false,
|
||||
"created_at": 0,
|
||||
"custom_value1": "",
|
||||
"custom_value2": "",
|
||||
"custom_value3": "",
|
||||
"custom_value4": "",
|
||||
"email": "",
|
||||
"first_name": "",
|
||||
"id": "",
|
||||
"is_primary": false,
|
||||
"last_login": 0,
|
||||
"last_name": "",
|
||||
"link": "",
|
||||
"password": "",
|
||||
"phone": "",
|
||||
"send_email": false,
|
||||
"updated_at": 0
|
||||
}
|
||||
],
|
||||
"country_id": "",
|
||||
"created_at": 0,
|
||||
"currency_id": "",
|
||||
"custom_value1": "",
|
||||
"custom_value2": "",
|
||||
"custom_value3": "",
|
||||
"custom_value4": "",
|
||||
"display_name": "",
|
||||
"documents": [],
|
||||
"id": "",
|
||||
"id_number": "",
|
||||
"is_deleted": false,
|
||||
"is_tax_exempt": false,
|
||||
"language_id": "",
|
||||
"last_login": 0,
|
||||
"name": "",
|
||||
"number": "",
|
||||
"phone": "",
|
||||
"postal_code": "",
|
||||
"private_notes": "",
|
||||
"public_notes": "",
|
||||
"routing_id": "",
|
||||
"state": "",
|
||||
"tags": [],
|
||||
"updated_at": 0,
|
||||
"user_id": "",
|
||||
"vat_number": "",
|
||||
"website": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+18
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"archived_at": 0,
|
||||
"company_id": "",
|
||||
"created_at": 0,
|
||||
"event_id": "",
|
||||
"format": "",
|
||||
"headers": [],
|
||||
"id": "",
|
||||
"is_deleted": false,
|
||||
"rest_method": "",
|
||||
"target_url": "",
|
||||
"updated_at": 0,
|
||||
"user_id": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -584,23 +584,24 @@ fn company_keeps_settings_untyped_and_preserves_toggles() {
|
||||
assert_eq!(round_tripped["settings"]["timezone_id"], "15");
|
||||
}
|
||||
|
||||
/// System logs are the one resource whose timestamps are date strings
|
||||
/// rather than integers.
|
||||
/// The spec says system-log timestamps are date strings; a real server sends
|
||||
/// integers, as it does everywhere else.
|
||||
#[test]
|
||||
fn system_log_timestamps_are_strings() {
|
||||
fn system_log_timestamps_are_integers() {
|
||||
let log: SystemLog = serde_json::from_value(json!({
|
||||
"id": "sl1",
|
||||
"event_id": 30,
|
||||
"category_id": 1,
|
||||
"type_id": 300,
|
||||
"log": {"message": "gateway declined"},
|
||||
"created_at": "2026-10-01 12:00:00",
|
||||
"updated_at": "2026-10-01 12:00:00",
|
||||
"created_at": 1792869488,
|
||||
"updated_at": 1792869489,
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(log.event_id, 30);
|
||||
assert_eq!(log.created_at, "2026-10-01 12:00:00");
|
||||
assert_eq!(log.created_at, 1792869488);
|
||||
assert_eq!(log.updated_at, 1792869489);
|
||||
assert_eq!(log.log["message"], "gateway declined");
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
//! Decodes fixtures captured from a real server.
|
||||
//!
|
||||
//! `tests/fixtures/*.json` are written by `examples/capture_shapes.rs` and
|
||||
//! hold only key names and JSON types (strings are `""`, numbers `0`, bools
|
||||
//! `false`). Mock responses written from the upstream source have been wrong
|
||||
//! about the real thing before, so these pin the shapes a server actually
|
||||
//! sent. To refresh them, seed a disposable instance with
|
||||
//! `examples/seed_sample_data.rs` and re-run the capture.
|
||||
//!
|
||||
//! Three properties are checked: every fixture decodes into its model
|
||||
//! (goal 2), a decode followed by a re-serialise loses no key (goal 1), and no
|
||||
//! fixture contains a real value, so a careless capture cannot leak data.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use invoiceninjars::models::*;
|
||||
use serde::{de::DeserializeOwned, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
fn dir() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures")
|
||||
}
|
||||
|
||||
fn load(name: &str) -> Value {
|
||||
let path = dir().join(format!("{name}.json"));
|
||||
let text = fs::read_to_string(&path).unwrap_or_else(|e| panic!("{}: {e}", path.display()));
|
||||
serde_json::from_str(&text).unwrap_or_else(|e| panic!("{}: {e}", path.display()))
|
||||
}
|
||||
|
||||
/// The captured row of a list fixture.
|
||||
fn row(name: &str) -> Value {
|
||||
load(name)["data"][0].clone()
|
||||
}
|
||||
|
||||
/// Key paths present in `raw` but absent from `out`. Arrays are compared
|
||||
/// through their first element, which is all a fixture keeps.
|
||||
fn lost_keys(raw: &Value, out: &Value, prefix: &str, into: &mut Vec<String>) {
|
||||
match (raw, out) {
|
||||
(Value::Object(raw), Value::Object(out)) => {
|
||||
for (key, raw_value) in raw {
|
||||
match out.get(key) {
|
||||
Some(out_value) => {
|
||||
lost_keys(raw_value, out_value, &format!("{prefix}.{key}"), into)
|
||||
}
|
||||
None => into.push(format!("{prefix}.{key}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
(Value::Array(raw), Value::Array(out)) => {
|
||||
if let (Some(raw), Some(out)) = (raw.first(), out.first()) {
|
||||
lost_keys(raw, out, &format!("{prefix}[]"), into);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Decodes `raw` as `T`, re-serialises it, and returns problems.
|
||||
fn check<T: DeserializeOwned + Serialize>(name: &str, raw: &Value) -> Vec<String> {
|
||||
let typed: T = match serde_json::from_value(raw.clone()) {
|
||||
Ok(typed) => typed,
|
||||
Err(e) => return vec![format!("{name}: does not decode: {e}")],
|
||||
};
|
||||
let out = serde_json::to_value(&typed).expect("serialises");
|
||||
let mut lost = Vec::new();
|
||||
lost_keys(raw, &out, name, &mut lost);
|
||||
lost.into_iter()
|
||||
.filter(|path| !OMITTED_WHEN_EMPTY.contains(&path.as_str()))
|
||||
.map(|path| format!("{path}: key lost on a decode/re-serialise"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Fields that are deliberately left out of a request when empty, because the
|
||||
/// server rejects the empty value (`""` becomes `NULL` on a non-null column,
|
||||
/// or fails an `integer` rule). A fixture holds `""` placeholders, so these
|
||||
/// show up as absent after a re-serialise. Adding to this list is a decision.
|
||||
const OMITTED_WHEN_EMPTY: &[&str] = &[
|
||||
"locations.country_id",
|
||||
"purchase_orders.status_id",
|
||||
"webhooks.format",
|
||||
];
|
||||
|
||||
/// One fixture per `(file, type)`; the macro also records which files it
|
||||
/// covered so a new capture cannot sit untested.
|
||||
macro_rules! list_fixtures {
|
||||
($($file:literal => $ty:ty),+ $(,)?) => {
|
||||
const COVERED_LISTS: &[&str] = &[$($file),+];
|
||||
|
||||
fn check_lists() -> Vec<String> {
|
||||
let mut problems = Vec::new();
|
||||
$( problems.extend(check::<$ty>($file, &row($file))); )+
|
||||
problems
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
list_fixtures! {
|
||||
"activities" => Activity,
|
||||
"clients" => Client,
|
||||
"companies" => Company,
|
||||
"credits" => Credit,
|
||||
"designs" => Design,
|
||||
"documents" => Document,
|
||||
"expense_categories" => ExpenseCategory,
|
||||
"expenses" => Expense,
|
||||
"group_settings" => GroupSetting,
|
||||
"invoices" => Invoice,
|
||||
"locations" => Location,
|
||||
"payment_terms" => PaymentTerm,
|
||||
"payments" => Payment,
|
||||
"products" => Product,
|
||||
"projects" => Project,
|
||||
"purchase_orders" => PurchaseOrder,
|
||||
"quotes" => Quote,
|
||||
"recurring_expenses" => RecurringExpense,
|
||||
"recurring_invoices" => RecurringInvoice,
|
||||
"system_logs" => SystemLog,
|
||||
"tags" => Tag,
|
||||
"task_statuses" => TaskStatus,
|
||||
"tasks" => Task,
|
||||
"tax_rates" => TaxRate,
|
||||
"tokens" => CompanyToken,
|
||||
"users" => User,
|
||||
"vendors" => Vendor,
|
||||
"webhooks" => Webhook,
|
||||
}
|
||||
|
||||
/// Fixtures that are not a one-row list.
|
||||
const COVERED_OTHER: &[&str] = &[
|
||||
"statics",
|
||||
"health_check",
|
||||
"subscription_steps",
|
||||
"activity_feed",
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn every_real_row_decodes_and_loses_no_key() {
|
||||
let problems = check_lists();
|
||||
assert!(
|
||||
problems.is_empty(),
|
||||
"{} problem(s):\n{}",
|
||||
problems.len(),
|
||||
problems.join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_endpoint_fixtures_decode() {
|
||||
let mut problems = Vec::new();
|
||||
problems.extend(check::<Statics>("statics", &load("statics")));
|
||||
problems.extend(check::<HealthCheck>("health_check", &load("health_check")));
|
||||
problems.extend(check::<ActivityFeedEntry>(
|
||||
"activity_feed",
|
||||
&load("activity_feed")["data"][0],
|
||||
));
|
||||
|
||||
// Keyed by class name on a real server; see `subscription_steps`.
|
||||
let steps: Result<BTreeMap<String, SubscriptionStep>, _> =
|
||||
serde_json::from_value(load("subscription_steps"));
|
||||
match steps {
|
||||
Ok(steps) => assert!(!steps.is_empty(), "the captured steps object is empty"),
|
||||
Err(e) => problems.push(format!("subscription_steps: {e}")),
|
||||
}
|
||||
|
||||
assert!(problems.is_empty(), "{}", problems.join("\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_fixture_file_is_covered_by_a_test() {
|
||||
let mut on_disk: Vec<String> = fs::read_dir(dir())
|
||||
.expect("tests/fixtures exists")
|
||||
.filter_map(|entry| {
|
||||
let path = entry.ok()?.path();
|
||||
(path.extension()? == "json").then(|| path.file_stem()?.to_str().map(String::from))?
|
||||
})
|
||||
.collect();
|
||||
on_disk.sort();
|
||||
|
||||
let mut covered: Vec<String> = COVERED_LISTS
|
||||
.iter()
|
||||
.chain(COVERED_OTHER)
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
covered.sort();
|
||||
|
||||
assert_eq!(
|
||||
on_disk, covered,
|
||||
"a captured fixture has no test (or a test has no fixture)"
|
||||
);
|
||||
}
|
||||
|
||||
/// The capture tool replaces every value; this refuses to pass if any
|
||||
/// survived, so real data cannot be committed by accident.
|
||||
#[test]
|
||||
fn fixtures_hold_shapes_and_never_values() {
|
||||
fn walk(value: &Value, at: &str, bad: &mut Vec<String>) {
|
||||
match value {
|
||||
Value::String(s) if !s.is_empty() => bad.push(format!("{at}: string value")),
|
||||
Value::Number(n) if n.as_f64() != Some(0.0) => bad.push(format!("{at}: number value")),
|
||||
Value::Bool(true) => bad.push(format!("{at}: true")),
|
||||
Value::Array(items) => {
|
||||
assert!(items.len() <= 1, "{at}: a fixture keeps one array element");
|
||||
items.iter().for_each(|v| walk(v, &format!("{at}[]"), bad));
|
||||
}
|
||||
Value::Object(map) => map
|
||||
.iter()
|
||||
.for_each(|(k, v)| walk(v, &format!("{at}.{k}"), bad)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let mut bad = Vec::new();
|
||||
for entry in fs::read_dir(dir()).expect("tests/fixtures exists") {
|
||||
let path = entry.unwrap().path();
|
||||
if path.extension().is_some_and(|e| e == "json") {
|
||||
let name = path.file_stem().unwrap().to_string_lossy().to_string();
|
||||
walk(&load(&name), &name, &mut bad);
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
bad.is_empty(),
|
||||
"fixtures contain values:\n{}",
|
||||
bad.join("\n")
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user