Add an automated VM test against a real Invoice Ninja instance
CI / test (push) Canceled after 0s
CI / Build on the declared MSRV (push) Canceled after 0s
CI / Latest stable (advisory) (push) Canceled after 0s

Adds an invoiceninja-nixos flake input and two things built from it:

- nixosConfigurations.invoiceninja-test-nginx/-caddy: manual test
  VMs, re-exported as-is from that repo rather than redefined here —
  same principle as this crate being a client of the SDK, not a
  second implementation of it.
- checks.invoiceninja-cli: an automated nixosTest (tests/, adapted
  from invoiceninja-nixos's own test scaffolding) that boots a real
  Invoice Ninja instance and runs the actual invoiceninja-cli binary
  against it - create/list/get/update/invoice/payment/delete across
  clients, invoices, and payments - rather than raw curl.

tests/common.nix's module import had to change from a relative
../nixos-module/invoiceninja.nix (which doesn't exist here) to a
parameter threaded from the flake input's nixosModules.invoiceninja.

Building the VM pulls from the invoiceninja-nixos cachix cache via a
nixConfig block, mirroring that repo's own.

Also fixes a wrong assumption the first version of this test's final
assertion made, caught by actually running it against a real server:
`clients list` with no `--status` does not exclude soft-deleted
records - confirmed empirically (Invoice Ninja returns everything
regardless of is_deleted unless status=active is passed explicitly).
Corrected the test to check is_deleted via `clients get` instead, and
fixed the misleadingly-incomplete --status doc comment in cli.rs that
had the same wrong assumption baked in.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 16:57:46 -04:00
co-authored by Claude Sonnet 5
parent 96fba9a303
commit c8a67b1208
10 changed files with 466 additions and 4 deletions
+23
View File
@@ -0,0 +1,23 @@
{ proxyServer ? "nginx", invoiceninjaModule }:
{ ... }:
{
# Unlike the invoiceninja-nixos repo this was copied from, there's no
# sibling `nixos-module/` directory here — the module comes in through
# this parameter instead, threaded from flake.nix as
# `invoiceninja-nixos.nixosModules.invoiceninja` (the flake input). The
# module's own `../package.nix` relative import inside that file still
# resolves correctly regardless of who imports it, since it's relative to
# the module file's own location in the Nix store, not to this directory.
imports = [ invoiceninjaModule ];
services.invoiceninja = {
enable = true;
proxy.server = proxyServer;
adminAccount.passwordFile = ./invoice_ninja_test_password;
secretFile = ./test.env;
settings.APP_DEBUG = true;
};
networking.firewall.enable = false;
}
+52
View File
@@ -0,0 +1,52 @@
{ proxyServer ? "nginx", invoiceninjaModule }:
{ pkgs, modulesPath, ... }:
{
imports = [
(modulesPath + "/profiles/qemu-guest.nix")
# Not imported by default for a plain nixosSystem in this nixpkgs (only nixosTest nodes get it
# automatically) - needed explicitly so virtualisation.cores/memorySize below are real options.
(modulesPath + "/virtualisation/qemu-vm.nix")
(import ./common.nix { inherit proxyServer invoiceninjaModule; })
];
# `nix flake check` evaluates config.system.build.toplevel directly, which asserts a real root
# filesystem and bootloader are configured. `just build-vm` builds config.system.build.vm
# instead, where the qemu-vm module overrides both of these with a tmpfs root and direct
# kernel boot (virtualisation.useBootLoader defaults to false) - so these values just need to
# satisfy the assertions and are otherwise unused.
fileSystems."/" = {
device = "/dev/disk/by-label/nixos";
fsType = "ext4";
};
boot.loader.grub.enable = false;
users.users.root = {
initialPassword = "toor";
};
services.resolved.enable = true;
# `just boot-vm` forwards the guest's port 80 to host port 8080 (QEMU_NET_OPTS in the justfile),
# but the backend builds absolute URLs (e.g. the stored company logo path) from APP_URL, which
# defaults to plain "http://localhost" with no port. The SPA's own relative URLs are fine (they
# inherit whatever port the browser is already on), but any backend-generated absolute URL then
# points at host port 80 - where nothing is listening - instead of the forwarded 8080, giving a
# silent net::ERR_CONNECTION_REFUSED for things like the logo image. Override to match the actual
# forwarded port.
services.invoiceninja.settings.APP_URL = "http://localhost:8080";
# The qemu-vm module's defaults (1 core, 1024MB) are too tight for real interactive use: MySQL +
# Redis + PHP-FPM + chromium (PDF generation, spawned per request - each is a full multi-process
# browser, not a lightweight renderer) running concurrently can exhaust 1GB fast, and this VM
# boots with panic_on_oom - so it's not a graceful slowdown, the whole VM goes down. Confirmed by
# reproducing a "logo upload hangs" report this way: at the 1024MB default, even a couple of
# concurrent PDF-generation requests triggered `redis-server invoked oom-killer` and a kernel
# panic. 2 cores / 2560MB comfortably handles 2 concurrent PDF generations alongside normal
# traffic (verified: ~1.4GB still free afterward); a deliberate flood of many concurrent PDF
# requests can still exhaust even this, since each is a full chromium instance - if that's a
# concern for your deployment, consider capping `phpfpm.settings."pm.max_children"` below its
# default of 8, or provisioning more RAM.
virtualisation.cores = 2;
virtualisation.memorySize = 2560;
}
+1
View File
@@ -0,0 +1 @@
password
+103
View File
@@ -0,0 +1,103 @@
{ 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 and see it in a payment"):
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}"
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}"
'';
}
+2
View File
@@ -0,0 +1,2 @@
APP_KEY=base64:gJnNI8pOx4c0/Z6kl9mIjn0X9XkbJUWjtqnDFl9prvo=
API_KEY=test-api-token