These four resources are the only ones with the SDK's entity_action_methods! route. Adds: - `action <id> <action>` - mark_sent, mark_paid, archive, restore, clone_to_invoice, clone_to_quote, auto_bill, delete (routed), returning the updated record. - `download <id>...` - the rendered PDF, to stdout or `--out <path>`; more than one id streams a single merged PDF (Invoice Ninja's bulk download action). New EntityResourceArgs/EntityAction in cli.rs rather than extending the existing ResourceArgs/Action: Rust enums don't compose, and every resource's `run` match on Action needs to stay exhaustive without a catch-all arm for variants it doesn't support. Found and fixed along the way: the Download variant's --output <path> field collided by name with the global --output table|json flag, causing clap to panic at runtime trying to downcast the wrong type. Renamed to --out. Extended tests/nixos-test.nix to exercise action/download for real against the VM. That caught a test-ordering bug of its own - asserting mark_sent on an invoice *after* paying it off, which correctly doesn't revert status_id from paid back to sent - fixed by reordering the subtests. nix flake check passes clean. Also backfills two CHANGELOG.md gaps from earlier sessions: the VM test and CI additions had never actually been logged there. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
121 lines
5.6 KiB
Nix
121 lines
5.6 KiB
Nix
{ nixpkgsPath, system, invoiceninjaModule, invoiceninjaCliPkg }:
|
|
|
|
let
|
|
pkgs = import nixpkgsPath { inherit system; };
|
|
|
|
adminEmail = "admin@email.com";
|
|
adminPassword = pkgs.lib.strings.trim (builtins.readFile ./invoice_ninja_test_password);
|
|
in
|
|
pkgs.testers.nixosTest {
|
|
name = "invoiceninja-cli";
|
|
|
|
nodes = {
|
|
# Only one node/proxy backend: which proxy Invoice Ninja sits behind is
|
|
# invoiceninja-nixos's own concern (its checks exercise both nginx and
|
|
# caddy already). This test is about whether invoiceninja-cli works
|
|
# against a real server, which doesn't depend on which proxy fronts it.
|
|
machine = {
|
|
imports = [ (import ./common.nix { proxyServer = "nginx"; inherit invoiceninjaModule; }) ];
|
|
environment.systemPackages = [ invoiceninjaCliPkg ];
|
|
};
|
|
};
|
|
|
|
testScript = ''
|
|
import json
|
|
|
|
def api_post(machine, path, payload, token=None):
|
|
auth_header = f"-H 'X-API-TOKEN: {token}' " if token else ""
|
|
return json.loads(machine.succeed(
|
|
f"curl -sf -X POST http://localhost{path} "
|
|
+ auth_header
|
|
+ "-H 'Content-Type: application/json' "
|
|
+ f"-d '{json.dumps(payload)}'"
|
|
))
|
|
|
|
def cli(machine, token, *args):
|
|
quoted_args = " ".join(f"'{a}'" for a in args)
|
|
return machine.succeed(
|
|
f"IN_API_TOKEN={token} IN_BASE_URL=http://localhost/api/v1 "
|
|
f"invoiceninja-cli --output json {quoted_args}"
|
|
)
|
|
|
|
machine.start()
|
|
|
|
# phpfpm-invoiceninja/invoiceninja-queue-worker both Require= invoiceninja-data-setup,
|
|
# so systemd already waits for it to finish before either starts.
|
|
machine.wait_for_unit("mysql.service")
|
|
machine.wait_for_unit("redis-invoiceninja.service")
|
|
machine.wait_for_unit("phpfpm-invoiceninja.service")
|
|
machine.wait_for_unit("invoiceninja-queue-worker.service")
|
|
machine.wait_for_unit("nginx.service")
|
|
machine.wait_for_open_port(80)
|
|
|
|
with subtest("admin account can log in via the API"):
|
|
login_response = api_post(machine, "/api/v1/login", {"email": "${adminEmail}", "password": "${adminPassword}"})
|
|
assert "${adminEmail}" in json.dumps(login_response), f"login did not succeed: {login_response}"
|
|
token = login_response["data"][0]["token"]["token"]
|
|
|
|
with subtest("invoiceninja-cli can create and list a client"):
|
|
cli(machine, token, "clients", "create", "--data", '{"name": "CLI Test Client"}')
|
|
listing = cli(machine, token, "clients", "list")
|
|
clients = json.loads(listing)
|
|
assert any(c["name"] == "CLI Test Client" for c in clients), f"client not in list output: {listing}"
|
|
client_id = next(c["id"] for c in clients if c["name"] == "CLI Test Client")
|
|
|
|
with subtest("invoiceninja-cli update preserves fields it wasn't told about"):
|
|
cli(machine, token, "clients", "update", client_id, "--data", '{"city": "Springfield"}')
|
|
updated = json.loads(cli(machine, token, "clients", "get", client_id))[0]
|
|
assert updated["city"] == "Springfield", f"city not updated: {updated}"
|
|
assert updated["name"] == "CLI Test Client", f"update blanked a field it wasn't told about: {updated}"
|
|
|
|
with subtest("invoiceninja-cli can create an invoice against that client"):
|
|
invoice_out = cli(
|
|
machine, token, "invoices", "create", "--data",
|
|
json.dumps({
|
|
"client_id": client_id,
|
|
"line_items": [{"quantity": 1, "cost": 100, "product_key": "test-product"}],
|
|
}),
|
|
)
|
|
invoice = json.loads(invoice_out)[0]
|
|
assert invoice["amount"] == 100, f"unexpected invoice amount: {invoice}"
|
|
|
|
with subtest("invoiceninja-cli action marks the invoice sent, and download fetches a PDF"):
|
|
# Must run before the invoice is paid below: mark_sent on an
|
|
# already-paid invoice does not revert its status (status_id stays
|
|
# 4/paid, not 2/sent) - confirmed by this test failing that way
|
|
# the first time it was written in this order.
|
|
action_out = cli(machine, token, "invoices", "action", invoice["id"], "mark_sent")
|
|
acted = json.loads(action_out)[0]
|
|
assert acted["status_id"] == "2", f"mark_sent did not update status_id: {acted}"
|
|
|
|
machine.succeed(
|
|
f"IN_API_TOKEN={token} IN_BASE_URL=http://localhost/api/v1 "
|
|
f"invoiceninja-cli invoices download {invoice['id']} --out /tmp/invoice.pdf"
|
|
)
|
|
magic_bytes = machine.succeed("head -c 4 /tmp/invoice.pdf")
|
|
assert magic_bytes == "%PDF", f"expected a PDF, got: {magic_bytes!r}"
|
|
|
|
with subtest("invoiceninja-cli can pay the invoice in full"):
|
|
payment_out = cli(
|
|
machine, token, "payments", "create", "--data",
|
|
json.dumps({
|
|
"client_id": client_id,
|
|
"amount": invoice["amount"],
|
|
"invoices": [{"invoice_id": invoice["id"], "amount": invoice["amount"]}],
|
|
}),
|
|
)
|
|
payment = json.loads(payment_out)[0]
|
|
assert payment["applied"] == invoice["amount"], f"payment not fully applied: {payment}"
|
|
|
|
with subtest("invoiceninja-cli delete soft-deletes the client"):
|
|
cli(machine, token, "clients", "delete", client_id)
|
|
# Not `clients list`: Invoice Ninja's default /clients (no `status`
|
|
# param) returns every record regardless of is_deleted — confirmed
|
|
# against a real server, contrary to what CLAUDE.md's `--status`
|
|
# doc comment implies. Check the flag directly instead of relying
|
|
# on list-exclusion.
|
|
deleted = json.loads(cli(machine, token, "clients", "get", client_id))[0]
|
|
assert deleted["is_deleted"], f"delete did not set is_deleted: {deleted}"
|
|
'';
|
|
}
|