Commit Graph
7 Commits
Author SHA1 Message Date
awkawbandClaude Sonnet 5 fb670cbb08 Rename the crate from invoiceninjars to invoiceninja-sdk
CI / test (push) Canceled after 0s
CI / Build on the declared MSRV (push) Canceled after 0s
CI / Latest stable (advisory) (push) Canceled after 0s
The package name has never matched the repository it lives in
(gitea.awkawb.cloud/awkawb/invoiceninja-sdk, mirrored to
github.com/awkawb1/invoiceninja-sdk) — both pointed at `invoiceninja-sdk`
while `Cargo.toml` declared `invoiceninjars`. Lines everything up on the
name the repo has always used.

Cargo derives the crate's Rust identifier from the package name by
lowercasing hyphens to underscores, so every `use invoiceninjars::...`
becomes `use invoiceninja_sdk::...` — in doctests across `src/`, the four
examples, all four integration test files, and `README.md`. `Cargo.lock`'s
own self-entry picked up the new name on the next `cargo check`, which is
also when the rename gets its real check: every doctest and every test file
has to resolve the new path to compile at all, not just the ones a grep
happens to catch.

`flake.nix`'s `pname` and description follow, so `nix build` produces
`invoiceninja-sdk-0.1.0.drv` rather than a name that no longer matches
anything. `GOALS.md`'s two mentions — the hypothetical CLI's name and the
`cargo install` example — follow too, for the same reason.

Verified clean on both the dev toolchain and the 1.88 MSRV shell: full test
suite (209 tests), clippy, fmt, and `nix build`/`nix flake check`.

Not yet published to crates.io (checked: the name 404s there), so this
lands before the old name was ever claimed — no one's `Cargo.toml` breaks
today, but it's recorded as a Changed entry since `GOALS.md` asks for
breaking changes to be legible in the changelog rather than discovered at
compile time, and the crate is reachable as a git dependency regardless of
crates.io status.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-25 09:43:05 -04:00
awkawbandClaude Opus 5 e6e41fe5c6 Wrap the eight invitation-key downloads
CI / test (push) Canceled after 0s
CI / Build on the declared MSRV (push) Canceled after 0s
CI / Latest stable (advisory) (push) Canceled after 0s
`GOALS.md` listed these as "eight routes that take no auth token, so they do
not fit the authenticated request path and need a different entry point."
That was wrong on every count, and wrong in the way goal 6 exists to catch:
the premise came from the route names rather than the route file.

All eight sit inside `routes/api.php`'s `api/v1` group, under its
`token_auth` middleware, and the controllers' own OpenAPI blocks declare
`X-API-TOKEN`. So they needed no new entry point — just a macro, alongside
`entity_action_methods!` and for the same four entities. What the item had
actually described is the client-portal family in `routes/client.php`, which
is genuinely token-less, is not under `/api/v1`, and stays unwrapped for
that reason; it is now the gap entry in `COVERAGE.md`.

`download_{invoice,quote,credit,purchase_order}_by_invitation_key` returns
the PDF and `download_e_*_by_invitation_key` the XML e-document. These take
a contact's invitation key rather than a record id, so the server renders in
that contact's locale — a client with contacts in two languages gets a
different document per key, which the id-based `download_*` cannot express.

Two things here are easy to get wrong, so both are pinned by tests. The
entity segment is singular (`invoice/{key}/download`), the only route family
in the API that departs from the plural CRUD path. And an unknown key is a
400 carrying `no record found`: the controllers return that directly instead
of raising `ModelNotFoundException`, so the status matches the rule for a
missing record but the message does not.

The smoke test's read tier now downloads an invoice by invitation key,
taking the key from the `invitations` array the list route already returns.
It is there to re-check the token claim against a real server rather than
against a second reading of the source. The `download_e_*` four are left
out: they render from e-invoice settings a test instance need not have.

Also in here, unrelated: a rustfmt fix to a `match` arm in
`examples/smoke_test.rs` that was already off-format on main, so
`cargo fmt --check` — and with it CI's `test` job — was failing before this
change. It could not be split out cleanly, being in a file this change also
touches.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-24 23:00:54 -04:00
awkawbandClaude Opus 5 e7c9663621 Expect a 400, not a 404, for a record that is gone
Running the password tier against a disposable 5.13.50 instance for the
first time failed its last check. The purge itself was fine — the row really
was gone — but the assertion looked for a 404 and the server answers 400.

`Handler.php` maps Laravel's `ModelNotFoundException` to a 400 carrying
`No query results for model [App\Models\X].` and reserves 404 for a route
that does not exist, so a not-found check written against 404 never fires.
The assertion now matches the status together with the message, so a 400
raised for some other reason still fails, and `Error::Api` says so for
callers, who would otherwise write the same dead branch.

Also adds jq to the devshell, which the MSRV recheck in CLAUDE.md pipes
through but which was only ever resolving from the host.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-24 21:57:12 -04:00
awkawbandClaude Opus 5 a7d9a2d9d6 Extend the smoke test with write and password tiers
The read tier now also fetches each row raw and fails if a typed row lost a
key, which is what catches a field the models drop.

The write tier creates a purchase order, location, webhook and recurring
invoice from bare `Default` models, since those are exactly what broke
against a real server, and exercises the uploads and the client extras. The
password tier merges two throwaway clients and purges the survivor.

Writes stay behind the existing guard: `IN_SMOKE_WRITE_HOST` must equal the
host in `IN_BASE_URL`, so a write only ever reaches a server that was named
explicitly. It prints PASS/FAIL per check and never the contents of a
record. `smoke.yml` runs it on demand only (`workflow_dispatch`), with the
write tier gated on the `write_host` input naming the instance.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-24 21:56:31 -04:00
awkawbandClaude Opus 5 46a6ea1322 Pin the response shapes captured from a real server
Twice now a mock written from the upstream source passed while a real server
disagreed — the system-log timestamps and the `/subscriptions/steps` shape —
and `frequency_id` and several empty-string fields were wrong the same way.
Reading the source harder was not going to find the next one.

`tests/fixtures/*.json` hold the *shapes* of real responses: every key, with
its JSON type, and no values at all. `real_shapes.rs` decodes each one and
checks that a decode plus re-serialise loses no key, so a field the models
silently drop now fails a test. It also refuses a fixture holding any real
value, and refuses a fixture that no test reads.

`seed_sample_data.rs` fills a disposable instance with one row of each kind
and `capture_shapes.rs` captures the shapes back out with GET requests only,
so the set can be refreshed when a divergence turns up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-24 21:56:17 -04:00
awkawbandClaude Opus 5 9e7d81307d Add a smoke test for real servers, and show why transport errors happen
The suite runs against mock servers written from the upstream source, so
it cannot catch a place where a real server disagrees with that reading.
`examples/smoke_test.rs` can: it lists every resource, checks the
pagination walk against a single oversized request, probes the per_page
cap, and exercises statics, search and health_check. The upload wire
format (POST plus `_method=PUT`) was read from the framework source
rather than observed, so its write tier is the real verification of it.

It prints PASS/FAIL per check and never the contents of a record.

Writes are guarded. `IN_SMOKE_WRITE_HOST` must equal the host in
`IN_BASE_URL`, so a write can only reach a server that was named
explicitly. `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. The write tier leaves a gap in the client number
sequence and a soft-deleted row even after cleanup, so it is documented
as disposable-instance only.

Running it against a dead port exposed a diagnosability gap:
`Error::Http` printed "error sending request for url (...)" and nothing
else. reqwest's Display stops at the outermost layer, and the reason
("Connection refused", a DNS failure, a TLS error) lives in `source()`.
The error now renders its whole cause chain.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-24 14:35:46 -04:00
awkawbandClaude Opus 5 ff9a859d0b Initial commit: async Rust client for the Invoice Ninja v5 API
CI / test (push) Canceled after 0s
Covers Clients, Invoices, Quotes, Credits, Payments, Products, Expenses,
Projects, Tasks, and Vendors, each with list, list_all, get, create,
update, and delete, generated by the resource_methods! macro.

Every model carries a #[serde(flatten)] extra map so fields the API
returns but the crate does not model survive a get/update round-trip
instead of being silently dropped.

Includes a Nix dev shell (Rust via rust-overlay), a GitHub Actions CI
workflow running build/test/fmt/clippy, and round-trip tests for the
extra-preservation invariant.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-23 14:30:01 -04:00