diff --git a/CLAUDE.md b/CLAUDE.md index f0e6ca8..0827b6b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,7 +16,8 @@ editing Nix expressions. This project uses `just` (see `justfile`) and Nix directly. -- `just build-vm` — cleans previous artifacts and builds the test VM (`nixosConfigurations.test`) via `nom build`. +- `just build-vm [proxy]` — cleans previous artifacts and builds the test VM (`nixosConfigurations.test-`) + via `nom build`. `proxy` is `nginx` (default) or `caddy`. - `just boot-vm` — boots the built VM (`result/bin/run-nixos-vm`) in the current terminal. Serves Invoice Ninja on `localhost:8080` (see `QEMU_NET_OPTS` in the `justfile`) with root login `toor`. - `just clean` — removes `result`/`nixos.qcow2` and kills any running `qemu` process. @@ -28,10 +29,12 @@ This project uses `just` (see `justfile`) and Nix directly. ### `tests/` - `common.nix` — the shared `services.invoiceninja` config used by both the interactive VM and the automated test: - imports the module, enables nginx, and points at the checked-in test fixtures (`invoice_ninja_test_password`, - `test.env`). -- `default.nix` — the interactive VM config (`nixosConfigurations.test` in `flake.nix`), used for manual, - click-around verification via `just build-vm` / `just boot-vm`. Adds the qemu-guest profile, a root login, and + imports the module and points at the checked-in test fixtures (`invoice_ninja_test_password`, `test.env`). It's a + function taking `{ proxyServer ? "nginx" }` so callers can pick which proxy backend to test. +- `default.nix` — the interactive VM config (`nixosConfigurations.test-nginx`/`test-caddy` in `flake.nix`), used + for manual, click-around verification via `just build-vm [proxy]` / `just boot-vm`. It's also a function taking + `{ proxyServer ? "nginx" }`, forwarded straight through to `common.nix`, so `flake.nix` can instantiate one + `nixosConfigurations` entry per proxy backend. Adds the qemu-guest profile, a root login, and `nixpkgs.config.allowUnfree = true` (required to build `pkgs.chromium`). It also sets a dummy `fileSystems."/"` and disables `boot.loader.grub` purely to satisfy NixOS's built-in assertions when `nix flake check` evaluates `config.system.build.toplevel` directly — the qemu-vm module overrides both with a tmpfs root and direct kernel @@ -39,12 +42,15 @@ This project uses `just` (see `justfile`) and Nix directly. unused. - `nixos-test.nix` — the automated test (`pkgs.testers.nixosTest`, exposed as `checks..invoiceninja` in `flake.nix`). Builds its own `pkgs` (with `allowUnfree` baked in — `nixosTest` forbids setting `nixpkgs.config` - on a node once `pkgs` is externally supplied) and boots a VM with `common.nix`, then: waits for - mysql/redis/phpfpm/queue-worker/nginx to be active, checks the `invoiceninja-data-setup` marker files - (`.initial-migration`, `.db-seeded`, `.admin-created`) to confirm the one-time setup actually completed (not just - that the oneshot unit exited), curls `/` for the app shell, exercises the `invoiceninja-manage` wrapper, and logs - in via `/api/v1/login` using the test admin credentials as an end-to-end check that migrations/seeding/account - creation all worked. This is the only automated test in the repo — there is no separate unit-test suite. + on a node once `pkgs` is externally supplied) and boots two nodes from `common.nix` — one with + `proxyServer = "nginx"`, one with `proxyServer = "caddy"` — so both proxy backends get exercised. The shared + assertion suite (factored into a `check_invoiceninja(machine, proxy_unit)` function in the test script) runs + against each node: waits for mysql/redis/phpfpm/queue-worker/proxy to be active, checks the + `invoiceninja-data-setup` marker files (`.initial-migration`, `.db-seeded`, `.admin-created`) to confirm the + one-time setup actually completed (not just that the oneshot unit exited), curls `/` for the app shell, exercises + the `invoiceninja-manage` wrapper, and logs in via `/api/v1/login` using the test admin credentials as an + end-to-end check that migrations/seeding/account creation all worked. This is the only automated test in the + repo — there is no separate unit-test suite. ## Architecture @@ -108,8 +114,9 @@ expect Nix to report the correct hash on a failed build (or use `nix-prefetch`/` ### `flake.nix` Standard flake shape: `overlays.default` registers the package, `packages..invoiceninja` exposes it, -`nixosModules` re-exports `nixos-module/default.nix`, and `nixosConfigurations.test` builds the VM defined in -`tests/`. Only `x86_64-linux` is currently supported. +`nixosModules` re-exports `nixos-module/default.nix`, and `nixosConfigurations.test-nginx`/`test-caddy` each build +the VM defined in `tests/default.nix` with the corresponding `proxyServer`. Only `x86_64-linux` is currently +supported. ## Conventions diff --git a/flake.nix b/flake.nix index a5352f4..e639cfd 100644 --- a/flake.nix +++ b/flake.nix @@ -20,9 +20,15 @@ ); in rec { - nixosConfigurations.test = nixpkgs.lib.nixosSystem { - system = "x86_64-linux"; - modules = [ ./tests ]; + nixosConfigurations = { + test-nginx = nixpkgs.lib.nixosSystem { + system = "x86_64-linux"; + modules = [ (import ./tests/default.nix { proxyServer = "nginx"; }) ]; + }; + test-caddy = nixpkgs.lib.nixosSystem { + system = "x86_64-linux"; + modules = [ (import ./tests/default.nix { proxyServer = "caddy"; }) ]; + }; }; overlays.default = final: prev: { invoiceninja = final.callPackage ./package.nix { }; }; diff --git a/justfile b/justfile index 470011f..b52cf94 100644 --- a/justfile +++ b/justfile @@ -18,10 +18,10 @@ clean: pkill qemu exit 0 -[doc('Build virtual machine for testing')] +[doc('Build virtual machine for testing (proxy: nginx or caddy)')] [group('main')] -build-vm: clean - nom build ".#nixosConfigurations.test.config.system.build.vm" +build-vm proxy="nginx": clean + nom build ".#nixosConfigurations.test-{{proxy}}.config.system.build.vm" [doc('Run virtual machine in current terminal')] [group('main')] diff --git a/tests/common.nix b/tests/common.nix index 39a089a..5f9c84f 100644 --- a/tests/common.nix +++ b/tests/common.nix @@ -1,3 +1,4 @@ +{ proxyServer ? "nginx" }: { ... }: { @@ -5,7 +6,7 @@ services.invoiceninja = { enable = true; - proxy.server = "nginx"; + proxy.server = proxyServer; adminAccount.passwordFile = ./invoice_ninja_test_password; secretFile = ./test.env; settings.APP_DEBUG = true; diff --git a/tests/default.nix b/tests/default.nix index 8f0c6f5..5c270d0 100644 --- a/tests/default.nix +++ b/tests/default.nix @@ -1,9 +1,10 @@ +{ proxyServer ? "nginx" }: { pkgs, modulesPath, ... }: { imports = [ (modulesPath + "/profiles/qemu-guest.nix") - ./common.nix + (import ./common.nix { inherit proxyServer; }) ]; # Required to build pkgs.chromium, which the module pulls in for PDF generation. diff --git a/tests/nixos-test.nix b/tests/nixos-test.nix index d06d1e8..1590f3f 100644 --- a/tests/nixos-test.nix +++ b/tests/nixos-test.nix @@ -15,54 +15,61 @@ in pkgs.testers.nixosTest { name = "invoiceninja"; - nodes.machine = { - imports = [ ./common.nix ]; + nodes = { + nginx.imports = [ (import ./common.nix { proxyServer = "nginx"; }) ]; + caddy.imports = [ (import ./common.nix { proxyServer = "caddy"; }) ]; }; testScript = '' - machine.start() + def check_invoiceninja(machine, proxy_unit): + 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(proxy_unit) + machine.wait_for_open_port(80) - 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) + # phpfpm-invoiceninja/invoiceninja-queue-worker both Require= invoiceninja-data-setup, + # so systemd already waited for it to finish above. Confirm the one-time setup steps + # it guards actually completed, rather than just that the unit exited. + with subtest("one-time setup (migrate, seed, admin account) completed"): + machine.succeed("test -f /var/lib/invoiceninja/.initial-migration") + machine.succeed("test -f /var/lib/invoiceninja/.db-seeded") + machine.succeed("test -f /var/lib/invoiceninja/.admin-created") - # phpfpm-invoiceninja/invoiceninja-queue-worker both Require= invoiceninja-data-setup, - # so systemd already waited for it to finish above. Confirm the one-time setup steps - # it guards actually completed, rather than just that the unit exited. - with subtest("one-time setup (migrate, seed, admin account) completed"): - machine.succeed("test -f /var/lib/invoiceninja/.initial-migration") - machine.succeed("test -f /var/lib/invoiceninja/.db-seeded") - machine.succeed("test -f /var/lib/invoiceninja/.admin-created") + with subtest("application shell is served over HTTP"): + response = machine.succeed("curl -sf http://localhost/") + assert "Invoice Ninja" in response, f"unexpected response from /: {response}" - with subtest("application shell is served over HTTP"): - response = machine.succeed("curl -sf http://localhost/") - assert "Invoice Ninja" in response, f"unexpected response from /: {response}" + with subtest("web UI login page is served"): + # The UI is a client-side-routed React SPA, so curl (no JS) only sees the same + # index.html shell as "/" - this confirms the proxy/Laravel resolve the /login route + # to that shell instead of 404ing, which is what a browser needs before it can + # render the actual login form. + login_page = machine.succeed("curl -sf http://localhost/login") + assert "Invoice Ninja" in login_page, f"unexpected response from /login: {login_page}" - with subtest("web UI login page is served"): - # The UI is a client-side-routed React SPA, so curl (no JS) only sees the same - # index.html shell as "/" - this confirms nginx/Laravel resolve the /login route - # to that shell instead of 404ing, which is what a browser needs before it can - # render the actual login form. - login_page = machine.succeed("curl -sf http://localhost/login") - assert "Invoice Ninja" in login_page, f"unexpected response from /login: {login_page}" + with subtest("invoiceninja-manage wrapper can run artisan commands"): + machine.succeed("invoiceninja-manage migrate:status") - with subtest("invoiceninja-manage wrapper can run artisan commands"): - machine.succeed("invoiceninja-manage migrate:status") + with subtest("scheduler timer is enabled and schedule:run succeeds"): + # Without this, the app's dashboard reports "cron jobs are not enabled". + machine.succeed("systemctl is-active invoiceninja-scheduler.timer") + machine.succeed("systemctl start --wait invoiceninja-scheduler.service") - with subtest("scheduler timer is enabled and schedule:run succeeds"): - # Without this, the app's dashboard reports "cron jobs are not enabled". - machine.succeed("systemctl is-active invoiceninja-scheduler.timer") - machine.succeed("systemctl start --wait invoiceninja-scheduler.service") + with subtest("admin account can log in via the API"): + login_response = machine.succeed( + "curl -sf -X POST http://localhost/api/v1/login " + + "-H 'Content-Type: application/json' " + + "-d '{\"email\":\"${adminEmail}\",\"password\":\"${adminPassword}\"}'" + ) + assert "${adminEmail}" in login_response, f"login did not succeed: {login_response}" - with subtest("admin account can log in via the API"): - login_response = machine.succeed( - "curl -sf -X POST http://localhost/api/v1/login " - + "-H 'Content-Type: application/json' " - + "-d '{\"email\":\"${adminEmail}\",\"password\":\"${adminPassword}\"}'" - ) - assert "${adminEmail}" in login_response, f"login did not succeed: {login_response}" + + nginx.start() + caddy.start() + + check_invoiceninja(nginx, "nginx.service") + check_invoiceninja(caddy, "caddy.service") ''; }