zynagent-first application framework

Testing#

Tests are ordinary Zig test declarations in files imported from src/app.zig. Run them from the project root with:

zyn test

Use std.testing directly for validation, codecs, resources, and other code which does not need a running application. Keep those tests beside the code they cover so importing the application also discovers them.

Keep cross-module and HTTP integration tests under src/tests. Import them from a test block in src/app.zig, directly or through src/tests/root.zig:

test {
    _ = @import("tests/root.zig");
}

The test root imports each test module. Files are not discovered by filename or directory. zyn init creates src/tests/.keep when the directory is empty.

Database and HTTP tests#

Start a managed application when a test needs PostgreSQL or the real route and middleware stack:

const std = @import("std");
const zyn = @import("zyn");

test "GET /posts returns stored posts" {
    var app = try zyn.testing.start(
        std.testing.io,
        std.testing.allocator,
        .{},
    );
    defer app.deinit();

    try app.db.exec(
        "INSERT INTO posts (title) VALUES ($1)",
        .{"Hello"},
    );

    var response = try app.request(.GET, "/posts", .{});
    defer response.deinit();

    try std.testing.expectEqual(.ok, response.status);
    try std.testing.expect(std.mem.indexOf(u8, response.body, "Hello") != null);
    try std.testing.expect(response.header("content-type") != null);
}

app.db is the initialized direct database pool for that application, app.cache is a direct cache capability backed by a second isolated fixture, app.storage is its isolated blob capability, app.notifications delivers through the testing capture transport, and app.email_capture_path names that fixture's private capture directory. Buffered blob and notification results use the test application's allocator and remain valid until app.deinit. The HTTP server and synchronous jobs share the same storage root and blob metadata. The database defaults to two connections. The HTTP server uses a separate two-connection pool connected to the same fixture database through the same restricted role, so committed changes are shared without sharing PostgreSQL sessions or pool capacity. app.request sends a request to its ephemeral loopback server without following redirects. The selected app.port is also available for clients which need direct access. Pass request headers and a body when needed:

var response = try app.request(.POST, "/posts", .{
    .headers = &.{
        .{
            .name = "Content-Type",
            .value = "application/json",
        },
        .{
            .name = "Sec-Fetch-Site",
            .value = "same-origin",
        },
    },
    .body = "{\"title\":\"Hello\"}",
});
defer response.deinit();

The response owns its status, headers, and body. Use response.header(NAME) for an ASCII-case-insensitive lookup, and call deinit when finished. Unsafe requests to generated routes also need the Sec-Fetch-Site field shown above because fresh route modules enable CSRF protection by default. app.deinit stops the server, closes the fixture database pools, removes its storage and email-capture roots, and drops its application and cache databases; call it only after every app.request, app.jobs, app.cache, app.storage, app.notifications, and direct app.port operation has finished.

The method may be a std.http.Method, an enum literal, or an uppercase ASCII method-name string:

var properties = try app.request(.PROPFIND, "/files", .{});
defer properties.deinit();

var report = try app.request("REPORT", "/calendars", .{
    .body = "<sync-collection />",
});
defer report.deinit();

Omitted POST, PUT, and PATCH bodies are sent as zero-length bodies. A body on a method which does not accept one returns error.InvalidTestRequestBody. Extension methods are sent without a body when .body is omitted and with the provided body otherwise. Empty names or names containing anything other than A through Z return error.InvalidTestRequestMethod. HEAD, informational, 204, and 304 responses always expose an empty owned body.

Notification tests#

Managed applications expose app.notifications with recorded immediate and deferred delivery outcomes for assertions. Request roles and transactions use the same execution contracts while returning void. The testing profile must select capture transport. Each managed application receives an isolated directory at app.email_capture_path; captured MIME uses the stable delivery identifier as the .eml filename. Deferred notifications are executed synchronously through app.jobs.perform_one or perform_ready, including encrypted payload checkpoints and retries. See framework/notifications for a complete delivery and captured-message example.

Database transactions begun through app.db expose tx.notifications.deliver_later; cancellation removes both the notification occurrence and its framework-owned worker job.

Background job tests#

Managed applications expose their fixture queue through app.jobs. Enqueueing uses the same compile-time registered argument-type checks, JSON validation, queue defaults, priority, immediate or scheduled eligibility, payload limit, and PostgreSQL storage as application code. Invalid required fields, unexpected fields, and incompatible field types fail compilation before the fixture starts:

try app.jobs.enqueue("send_receipt",
    .{ .order_id = "order-123" },
    .{},
);

const attempt = (try app.jobs.perform_one(.{})).?;
try std.testing.expectEqualStrings("send_receipt", attempt.job);
try std.testing.expectEqual(.completed, attempt.outcome);
try std.testing.expectEqual(@as(u16, 1), attempt.attempt);

perform_one executes at most one eligible attempt and returns null when no registered job is eligible. Handler failures are returned as attempt outcomes rather than method errors. error_name contains the handler or lease-transition error name when present. Claim, database, setup, and non-lease persistence failures are returned by the method.

Execution is synchronous and uses the fixture application's normal database and queue pools, registered handlers, attempt arenas, leases, heartbeats, retries, nested enqueueing, and terminal transitions. It does not start a job worker process. Application writes and nested enqueues committed by a handler are not rolled back when that handler later fails.

Scheduled jobs and delayed retries are skipped by default. Set include_scheduled to execute the earliest future attempt when no ready work remains, without sleeping or replacing another worker's active lease:

try app.jobs.enqueue("send_receipt", .{
    .order_id = "order-124",
}, .{ .after = .fromSeconds(60) });
try std.testing.expect((try app.jobs.perform_one(.{})) == null);

const scheduled = (try app.jobs.perform_one(.{
    .include_scheduled = true,
})).?;

Recurring schedules are synchronized and materialized separately from handler execution. Supply the test's PostgreSQL time as Unix microseconds:

const enqueued = try app.jobs.enqueue_recurring(.{ .at = unix_microseconds });
try std.testing.expectEqual(@as(u32, 1), enqueued);

const summary = try app.jobs.perform_ready(.{});

enqueue_recurring initializes new definitions at their next future occurrence, resets changed definitions to their next future occurrence, and coalesces missed occurrences for unchanged definitions. It is idempotent for a supplied time and returns the number of jobs inserted. It does not execute handlers; use perform_one or perform_ready separately. Concurrent calls use the same atomic cursor advancement as production workers.

Use perform_ready to execute multiple attempts:

const summary = try app.jobs.perform_ready(.{
    .limit = 100,
    .include_scheduled = true,
});

try std.testing.expectEqual(@as(u32, 3), summary.attempts);
try std.testing.expectEqual(@as(u32, 1), summary.completed);
try std.testing.expect(!summary.has_more);

The limit counts attempts, including retries and lease losses. A limit of zero executes nothing. has_more reports whether another job was eligible under the same scheduled-selection option when the limit was reached. Earlier attempts remain committed if a later attempt returns an infrastructure error. Claims respect job concurrency limits; a ready job whose key is at capacity does not contribute to has_more until capacity is released or its lease expires.

app.jobs.status() returns pending, running, completed, failed, and discarded row counts. Pending includes scheduled jobs, delayed retries, and rows with expired leases. Completed includes retained successful jobs. Status includes stored unknown job names, although execution only claims registered names.

All job operations must finish before app.deinit.

Isolation#

The first managed application lazily starts a project-local PostgreSQL test cluster and restores db/zyn_schema.sql followed by db/schema.sql into a fresh template. Prepared state is reused only while its schema, PostgreSQL version, platform, and zyn test contract match. Tests which never call zyn.testing.start do not start PostgreSQL.

Each application handle receives its own cloned database, including overlapping handles in one test. Every fixture connects through a private restricted role which owns only its database; a separate internal administrator creates and drops fixture databases and roles through PostgreSQL connections. Parent cleanup removes a test's databases after it exits, including after a panic or early exit. Seeds, migrations, generated-type updates, development databases, and development servers are not used. Test PostgreSQL disables durable flushes because every fixture database is disposable.

zyn db verify is a separate, explicit development command. It replays migration files in a disposable database and compares the result to committed schema and type artifacts; ordinary application tests continue to restore the snapshot instead.

Managed applications use the normal application module graph, zyn.zon, and the encrypted test credential profile without process-environment overrides. zyn test itself does not start PostgreSQL merely because a suite runs; the first call to zyn.testing.start starts the managed test cluster lazily. zyn.testing.available() can be used when the same test is also run by another test runner.