Add an automated VM test against a real Invoice Ninja instance
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:
@@ -5,3 +5,6 @@
|
|||||||
/.cargo/config.toml
|
/.cargo/config.toml
|
||||||
/result
|
/result
|
||||||
/result-*
|
/result-*
|
||||||
|
# The test VM's disk image — created in the invocation directory by
|
||||||
|
# run-nixos-vm (see CLAUDE.md's "Testing against a real server").
|
||||||
|
/nixos.qcow2
|
||||||
|
|||||||
@@ -112,6 +112,55 @@ The `test` and `msrv` jobs skip scheduled runs (`if: github.event_name !=
|
|||||||
'schedule'`) — a pinned toolchain against a locked dependency graph can't
|
'schedule'`) — a pinned toolchain against a locked dependency graph can't
|
||||||
change on a timer, so the weekly run is for `stable` only.
|
change on a timer, so the weekly run is for `stable` only.
|
||||||
|
|
||||||
|
### Testing against a real server
|
||||||
|
|
||||||
|
`flake.nix` has an `invoiceninja-nixos` input (Gitea, same host as this
|
||||||
|
repo's own remote) and re-exports two of its `nixosConfigurations` as
|
||||||
|
`invoiceninja-test-nginx`/`invoiceninja-test-caddy` — real NixOS VMs
|
||||||
|
running actual Invoice Ninja (PHP/Laravel + MariaDB + Redis + chromium for
|
||||||
|
PDF generation), not a mock. Reused as-is rather than redefined here, same
|
||||||
|
principle as this crate being a client of the SDK rather than a second
|
||||||
|
implementation of it — see that repo's own `CLAUDE.md` for what's actually
|
||||||
|
inside.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
nix build --accept-flake-config '.#nixosConfigurations.invoiceninja-test-nginx.config.system.build.vm'
|
||||||
|
QEMU_NET_OPTS="hostfwd=tcp::8080-:80" ./result/bin/run-nixos-vm
|
||||||
|
```
|
||||||
|
|
||||||
|
`--accept-flake-config` is what lets the `nixConfig` cachix substituter
|
||||||
|
declared at the top of this file actually get used — without it (or if the
|
||||||
|
daemon's `trusted-users` rejects it anyway, which it did when scripted
|
||||||
|
through an agent sandbox in practice — built fine regardless, just slower)
|
||||||
|
Nix silently falls back to building from source. `QEMU_NET_OPTS` forwards
|
||||||
|
the guest's port 80 to host `8080`; it's a runtime flag to the generated
|
||||||
|
`run-nixos-vm` script, not baked into the VM config, so it has to be set on
|
||||||
|
every boot.
|
||||||
|
|
||||||
|
The VM has one fixed admin account baked in by the upstream repo's own test
|
||||||
|
fixtures (`admin@email.com` / `password`, from its `tests/invoice_ninja_test_password`) —
|
||||||
|
not meant to be secret, since it only ever exists inside a throwaway local
|
||||||
|
VM. There's no standing API token to copy anywhere; get one fresh each boot
|
||||||
|
via `/api/v1/login` (the SDK and CLI only ever use API-token auth, never
|
||||||
|
this login endpoint themselves — it's purely a test-fixture bootstrap
|
||||||
|
step):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/login \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"email":"admin@email.com","password":"password"}' \
|
||||||
|
| jq -r '.data[0].token.token')
|
||||||
|
IN_API_TOKEN="$TOKEN" IN_BASE_URL=http://localhost:8080/api/v1 \
|
||||||
|
cargo run -- clients list
|
||||||
|
```
|
||||||
|
|
||||||
|
`jq` is in this flake's `devShells.default` for exactly this. There's also
|
||||||
|
an automated check (`checks.invoiceninja-cli` in `flake.nix`, backed by
|
||||||
|
`tests/`) that boots the same kind of VM and runs this CLI's own binary
|
||||||
|
against it non-interactively — see `tests/nixos-test.nix` for exactly what
|
||||||
|
it exercises. Verified working end to end (boot → login → token →
|
||||||
|
`clients create`/`list`) as of writing.
|
||||||
|
|
||||||
## Common commands
|
## Common commands
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
Generated
+168
-1
@@ -1,5 +1,43 @@
|
|||||||
{
|
{
|
||||||
"nodes": {
|
"nodes": {
|
||||||
|
"flake-compat": {
|
||||||
|
"flake": false,
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1767039857,
|
||||||
|
"narHash": "sha256-vNpUSpF5Nuw8xvDLj2KCwwksIbjua2LZCqhV1LNRDns=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "flake-compat",
|
||||||
|
"rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "flake-compat",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"flake-parts": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs-lib": [
|
||||||
|
"invoiceninja-nixos",
|
||||||
|
"nix",
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1782949081,
|
||||||
|
"narHash": "sha256-vp6Y/Grm98ESt6ceOkWiHWyZRDV3J1RID4w+6NWK9yA=",
|
||||||
|
"owner": "hercules-ci",
|
||||||
|
"repo": "flake-parts",
|
||||||
|
"rev": "17c9d6cdfc60c64f4ee8d306f9bc0b4ccb51481e",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "hercules-ci",
|
||||||
|
"repo": "flake-parts",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
"flake-utils": {
|
"flake-utils": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"systems": "systems"
|
"systems": "systems"
|
||||||
@@ -18,7 +56,135 @@
|
|||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"git-hooks-nix": {
|
||||||
|
"inputs": {
|
||||||
|
"flake-compat": [
|
||||||
|
"invoiceninja-nixos",
|
||||||
|
"nix"
|
||||||
|
],
|
||||||
|
"nixpkgs": [
|
||||||
|
"invoiceninja-nixos",
|
||||||
|
"nix",
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1783008725,
|
||||||
|
"narHash": "sha256-jGiy6+sxjNWXSjp25uoJuNfyH9zBK1PEDY0lVoL4ibQ=",
|
||||||
|
"owner": "cachix",
|
||||||
|
"repo": "git-hooks.nix",
|
||||||
|
"rev": "bca82caa46d5ec0f5d422c61fb1e30bc51313cbe",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "cachix",
|
||||||
|
"repo": "git-hooks.nix",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"invoiceninja-nixos": {
|
||||||
|
"inputs": {
|
||||||
|
"nix": "nix",
|
||||||
|
"nixpkgs": "nixpkgs_2"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1792704906,
|
||||||
|
"narHash": "sha256-RnfRh7mVgwqiTA+lbBCq35SllrINUu1I+klov26Aiq0=",
|
||||||
|
"ref": "refs/heads/master",
|
||||||
|
"rev": "badcdd25666d5a59d221fc4331f8691ce3167755",
|
||||||
|
"revCount": 71,
|
||||||
|
"type": "git",
|
||||||
|
"url": "ssh://gitea@gitea.awkawb.cloud/awkawb/invoiceninja-nixos.git"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "ssh://gitea@gitea.awkawb.cloud/awkawb/invoiceninja-nixos.git"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nix": {
|
||||||
|
"inputs": {
|
||||||
|
"flake-compat": "flake-compat",
|
||||||
|
"flake-parts": "flake-parts",
|
||||||
|
"git-hooks-nix": "git-hooks-nix",
|
||||||
|
"nixpkgs": "nixpkgs",
|
||||||
|
"nixpkgs-23-11": "nixpkgs-23-11",
|
||||||
|
"nixpkgs-regression": "nixpkgs-regression"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1786478010,
|
||||||
|
"narHash": "sha256-/9wXGp+aLObxqQuOGhDOnbdV0QCRDHH8uSh/4URKVAg=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nix",
|
||||||
|
"rev": "24b65e35d9d4c9d64c1bd5844a35f2a779356d63",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"id": "nix",
|
||||||
|
"type": "indirect"
|
||||||
|
}
|
||||||
|
},
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1783148766,
|
||||||
|
"narHash": "sha256-H9+N+GFtsbVC8ZniHliChM7ndizxtqVZs6bnGOLM3WQ=",
|
||||||
|
"rev": "a50de1b7d8a586adc18d2395c19de7d6058e6030",
|
||||||
|
"type": "tarball",
|
||||||
|
"url": "https://releases.nixos.org/nixos/26.05/nixos-26.05.4193.a50de1b7d8a5/nixexprs.tar.xz"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"type": "tarball",
|
||||||
|
"url": "https://channels.nixos.org/nixos-26.05/nixexprs.tar.xz"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs-23-11": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1717159533,
|
||||||
|
"narHash": "sha256-oamiKNfr2MS6yH64rUn99mIZjc45nGJlj9eGth/3Xuw=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "a62e6edd6d5e1fa0329b8653c801147986f8d446",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "a62e6edd6d5e1fa0329b8653c801147986f8d446",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs-regression": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1643052045,
|
||||||
|
"narHash": "sha256-uGJ0VXIhWKGXxkeNnq4TvV3CIOkUJ3PAoLZ3HMzNVMw=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "215d4d0fd80ca5163643b03a33fde804a29cc1e2",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "215d4d0fd80ca5163643b03a33fde804a29cc1e2",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs_2": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1786430034,
|
||||||
|
"narHash": "sha256-Vux08kA5PICwS2sViCMfwVLAHNoH8TkKAeBo25LjpMI=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "70cc4559b10a6062b05ff1af17e0add065ccaed9",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "nixos-26.05",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs_3": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1792773339,
|
"lastModified": 1792773339,
|
||||||
"narHash": "sha256-rZLrzrC6i+jnTAlvPZHogKDulY7WWDEv9hJeHieO7Zw=",
|
"narHash": "sha256-rZLrzrC6i+jnTAlvPZHogKDulY7WWDEv9hJeHieO7Zw=",
|
||||||
@@ -37,7 +203,8 @@
|
|||||||
"root": {
|
"root": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"flake-utils": "flake-utils",
|
"flake-utils": "flake-utils",
|
||||||
"nixpkgs": "nixpkgs",
|
"invoiceninja-nixos": "invoiceninja-nixos",
|
||||||
|
"nixpkgs": "nixpkgs_3",
|
||||||
"rust-overlay": "rust-overlay"
|
"rust-overlay": "rust-overlay"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,14 +1,32 @@
|
|||||||
{
|
{
|
||||||
description = "invoiceninja-cli: a command-line client for the Invoice Ninja v5 API";
|
description = "invoiceninja-cli: a command-line client for the Invoice Ninja v5 API";
|
||||||
|
|
||||||
|
# Mirrors invoiceninja-nixos's own nixConfig: without this, building
|
||||||
|
# `nixosConfigurations.invoiceninja-test-*` here rebuilds the whole PHP/UI
|
||||||
|
# app (and its chromium-headless-shell closure) from source instead of
|
||||||
|
# pulling the cached result.
|
||||||
|
nixConfig = {
|
||||||
|
extra-substituters = [ "https://invoiceninja-nixos.cachix.org" ];
|
||||||
|
extra-trusted-public-keys = [
|
||||||
|
"invoiceninja-nixos.cachix.org-1:TmRZaji8FFVjjIw9KZmvLrxMxfP+JDThOyOX04z526A="
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
inputs = {
|
inputs = {
|
||||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||||
rust-overlay.url = "github:oxalica/rust-overlay";
|
rust-overlay.url = "github:oxalica/rust-overlay";
|
||||||
rust-overlay.inputs.nixpkgs.follows = "nixpkgs";
|
rust-overlay.inputs.nixpkgs.follows = "nixpkgs";
|
||||||
flake-utils.url = "github:numtide/flake-utils";
|
flake-utils.url = "github:numtide/flake-utils";
|
||||||
|
# For a real Invoice Ninja instance to test the CLI against — see
|
||||||
|
# CLAUDE.md's "Testing against a real server". Not `follows`-ed to this
|
||||||
|
# flake's own `nixpkgs`: it pins its own (nixos-26.05, as of writing),
|
||||||
|
# and `nixosConfigurations.test-nginx`/`test-caddy` below are consumed
|
||||||
|
# as already-built flake outputs, not re-evaluated against our
|
||||||
|
# nixos-unstable — the two never need to agree.
|
||||||
|
invoiceninja-nixos.url = "git+ssh://gitea@gitea.awkawb.cloud/awkawb/invoiceninja-nixos.git";
|
||||||
};
|
};
|
||||||
|
|
||||||
outputs = { self, nixpkgs, rust-overlay, flake-utils, ... }:
|
outputs = { self, nixpkgs, rust-overlay, flake-utils, invoiceninja-nixos, ... }:
|
||||||
let
|
let
|
||||||
cargoToml = builtins.fromTOML (builtins.readFile ./Cargo.toml);
|
cargoToml = builtins.fromTOML (builtins.readFile ./Cargo.toml);
|
||||||
|
|
||||||
@@ -60,7 +78,10 @@
|
|||||||
in
|
in
|
||||||
{
|
{
|
||||||
devShells.default = pkgs.mkShell {
|
devShells.default = pkgs.mkShell {
|
||||||
packages = [ rustToolchain pkgs.pkg-config pkgs.openssl ];
|
# jq is for parsing the /api/v1/login response when testing
|
||||||
|
# against a real server — see CLAUDE.md's "Testing against a
|
||||||
|
# real server".
|
||||||
|
packages = [ rustToolchain pkgs.pkg-config pkgs.openssl pkgs.jq ];
|
||||||
RUST_SRC_PATH = "${rustToolchain}/lib/rustlib/src/rust/library";
|
RUST_SRC_PATH = "${rustToolchain}/lib/rustlib/src/rust/library";
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -73,6 +94,30 @@
|
|||||||
inherit (pkgs) lib pkg-config openssl;
|
inherit (pkgs) lib pkg-config openssl;
|
||||||
inherit rustPlatform;
|
inherit rustPlatform;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
# Automated: boots a real Invoice Ninja instance (invoiceninja-nixos's
|
||||||
|
# module, not a mock) and runs actual `invoiceninja-cli` commands
|
||||||
|
# against it — create/list/get/update/delete across clients,
|
||||||
|
# invoices, and payments, including that `update` preserves fields
|
||||||
|
# it wasn't told about. `tests/` is a vendored, adapted copy of
|
||||||
|
# invoiceninja-nixos's own test scaffolding; see its
|
||||||
|
# nixos-test.nix for what it does and doesn't check (it
|
||||||
|
# deliberately skips things like PDF generation and the language-
|
||||||
|
# list regression, which are that repo's own concern, not this
|
||||||
|
# CLI's). Uses invoiceninja-nixos's own pinned nixpkgs (26.05), not
|
||||||
|
# this flake's nixos-unstable, to build+evaluate the VM/module —
|
||||||
|
# matches what that module was actually tested against. The CLI
|
||||||
|
# binary itself (`self.packages.${system}.default`, built against
|
||||||
|
# our own nixos-unstable) runs fine inside it regardless: Nix
|
||||||
|
# binaries carry their own closure, so differing nixpkgs revisions
|
||||||
|
# in the same system closure is an explicitly supported case, not a
|
||||||
|
# version mismatch to work around.
|
||||||
|
checks.invoiceninja-cli = import ./tests/nixos-test.nix {
|
||||||
|
nixpkgsPath = invoiceninja-nixos.inputs.nixpkgs;
|
||||||
|
inherit system;
|
||||||
|
invoiceninjaModule = invoiceninja-nixos.nixosModules.invoiceninja;
|
||||||
|
invoiceninjaCliPkg = self.packages.${system}.default;
|
||||||
|
};
|
||||||
}) // {
|
}) // {
|
||||||
# For a flake that wants `invoiceninja-cli` in its own `pkgs`, not for
|
# For a flake that wants `invoiceninja-cli` in its own `pkgs`, not for
|
||||||
# this flake's own `packages.default`/`devShells.default` above (those
|
# this flake's own `packages.default`/`devShells.default` above (those
|
||||||
@@ -85,5 +130,17 @@
|
|||||||
overlays.default = final: _prev: {
|
overlays.default = final: _prev: {
|
||||||
invoiceninja-cli = final.callPackage mkInvoiceninjaCli { };
|
invoiceninja-cli = final.callPackage mkInvoiceninjaCli { };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
# Real Invoice Ninja instances to test this CLI against, reusing
|
||||||
|
# invoiceninja-nixos's own test VMs as-is rather than redefining them
|
||||||
|
# here — same principle as this crate being a client of the SDK, not
|
||||||
|
# a second implementation of it. Known test credentials baked into
|
||||||
|
# that repo's tests/common.nix: admin@email.com / password (from
|
||||||
|
# tests/invoice_ninja_test_password). See CLAUDE.md for how to boot
|
||||||
|
# one and get an API token from it.
|
||||||
|
nixosConfigurations = {
|
||||||
|
invoiceninja-test-nginx = invoiceninja-nixos.nixosConfigurations.test-nginx;
|
||||||
|
invoiceninja-test-caddy = invoiceninja-nixos.nixosConfigurations.test-caddy;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-1
@@ -161,7 +161,12 @@ pub struct ListArgs {
|
|||||||
pub sort: Option<String>,
|
pub sort: Option<String>,
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
pub filter: Option<String>,
|
pub filter: Option<String>,
|
||||||
/// e.g. "active,archived,deleted" to include archived/deleted records
|
/// Restricts which records come back, e.g. "active" to exclude
|
||||||
|
/// archived/deleted ones. **Omitting this flag does not do that
|
||||||
|
/// filtering for you** — confirmed against a real server, `list` with
|
||||||
|
/// no `--status` returns every record regardless of `is_deleted`/
|
||||||
|
/// `archived_at`. Pass "active,archived,deleted" (or any subset) to
|
||||||
|
/// narrow it.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
pub status: Option<String>,
|
pub status: Option<String>,
|
||||||
/// Comma-separated relationships to eager-load, e.g. "contacts"
|
/// Comma-separated relationships to eager-load, e.g. "contacts"
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
password
|
||||||
@@ -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}"
|
||||||
|
'';
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
APP_KEY=base64:gJnNI8pOx4c0/Z6kl9mIjn0X9XkbJUWjtqnDFl9prvo=
|
||||||
|
API_KEY=test-api-token
|
||||||
Reference in New Issue
Block a user