//! Milestone-5 filtering integration tests (spec S9.1): real compiled files,
//! real HTTP over the loopback, real snapshot swaps, and the local-DNS path
//! end to end.
//!
//! This lives in its own file because it needs `@import("build_options")`, which
//! only exists when the compilation is driven by `build.zig`. The body compiles
//! on every `zig build test` run, so it cannot rot, and every test skips at run
//! time unless `-Dintegration` is passed.
//!
//! Hermetic: every file is written inside one `std.testing.tmpDir`, every socket
//! is on 127.0.0.1, and every database is `:memory:`. Nothing leaves the
//! machine and nothing outlives its test.
//!
//! Two `std.Io` instances are in play, as in the milestone-3 and milestone-4
//! suites. `std.testing.tmpDir` creates and removes its directory against
//! `std.testing.io`; everything under test runs on a `std.Io.Threaded` the test
//! owns, because the manager and the forward client both race tasks against a
//! budget. A `std.Io.Dir` is a handle, so the two agree on what they address.

const std = @import("std");
const build_options = @import("build_options");
const net = std.Io.net;

const model = @import("../config/model.zig");
const reconcile = @import("../config/reconcile.zig");
const db = @import("../storage/db.zig");
const events_fixture = @import("../storage/events_fixture.zig");
const migrations = @import("../storage/migrations.zig");
const context = @import("../storage/repositories/context.zig");
const groups_repo = @import("../storage/repositories/groups_repo.zig");
const local_repo = @import("../storage/repositories/local_repo.zig");
const rules_repo = @import("../storage/repositories/rules_repo.zig");
const sources_repo = @import("../storage/repositories/sources_repo.zig");

const compiler = @import("compiler.zig");
const fetcher = @import("fetcher.zig");
const parsers = @import("parsers.zig");
const manager = @import("manager.zig");
const matcher = @import("matcher.zig");
const response = @import("response.zig");

const lookup = @import("../web/handlers/lookup.zig");

const forward_client = @import("../local/forward_client.zig");
const forward_zones = @import("../local/forward_zones.zig");
const records = @import("../local/records.zig");

const edns = @import("../dns/edns.zig");
const header = @import("../dns/header.zig");
const name = @import("../dns/name.zig");
const packet = @import("../dns/packet.zig");
const question = @import("../dns/question.zig");
const record = @import("../dns/record.zig");
const types = @import("../dns/types.zig");
const transport = @import("../upstream/transport.zig");

const testing = std.testing;
const Sha256 = std.crypto.hash.sha2.Sha256;

/// Long enough that a loopback exchange cannot lose to scheduling, short enough
/// that a wedged server fails the run instead of hanging it.
const budget: std.Io.Clock.Duration = .{ .raw = .fromSeconds(10), .clock = .awake };

/// The forward-zone read timeout. Case 15 asserts a silent resolver gives up
/// inside twice this, so it has to be short enough to keep the run quick and
/// long enough that a loopback answer always beats it.
/// `/api/lookup` reports the local tables beside the filter decision; this
/// suite's cases are about the filter half, so both are empty here.
const empty_records: records.Records = .empty;
const empty_zones: forward_zones.Zones = .empty;

const read_timeout: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(200), .clock = .awake };

const file_limit: std.Io.Limit = .limited(8 * 1024 * 1024);

const source_url = "http://127.0.0.1/list.txt";
const source_name = "test list";

// ---------------------------------------------------------------------------
// fixtures: compiled bodies
// ---------------------------------------------------------------------------

/// Names the compiler must drop, whatever list they arrive in: a single-label
/// name in a blocklist black-holes the loopback names of every client on the
/// LAN (spec S2.3f).
const single_label_names = [_][]const u8{ "localhost", "broadcasthost", "ip6-localhost" };

const fixture_domains = 5_000;

/// The sampling stride over the fixture's domains. Coprime with
/// `fixture_domains` so twenty samples never repeat.
const sample_stride = 263;

/// A hosts-format list with sink addresses, comments, wildcard entries, the
/// single-label names every real hosts file carries, and a regex line.
fn hostsFixture(gpa: std.mem.Allocator) ![]u8 {
    var out: std.Io.Writer.Allocating = .init(gpa);
    errdefer out.deinit();
    const w = &out.writer;

    try w.writeAll("# nxdns test fixture\n");
    for (single_label_names) |bare| try w.print("127.0.0.1 {s}\n", .{bare});
    for (0..fixture_domains) |i| {
        try w.print("0.0.0.0 ad{d}.example.com # tracker\n", .{i});
        if (i % 500 == 0) try w.print("0.0.0.0 *.wild{d}.example.net\n", .{i});
    }
    try w.writeAll("/ads[0-9]+/\n");
    return out.toOwnedSlice();
}

/// A small list whose every counter is a known number: two domains, one
/// wildcard, one regex line and one single-label name.
const http_body =
    "# small hosts list\n" ++
    "0.0.0.0 ads.example.com # advertising\n" ++
    "0.0.0.0 tracker.example.net\n" ++
    "0.0.0.0 *.wild.example.org\n" ++
    "127.0.0.1 localhost\n" ++
    "/regex[0-9]+/\n";

const http_domains: i64 = 2;
const http_wildcards: i64 = 1;
const http_exceptions: i64 = 0;
const http_regex: i64 = 1;
/// Zero, and asserted rather than assumed: a hosts list carries no line a DNS
/// sinkhole cannot translate, which is what makes the abp fixture below a
/// separate source instead of two more lines in this one.
const http_unsupported: i64 = 0;

/// An ABP-format list: one element-hiding rule and one `$`-modifier rule that
/// nxdns counts and skips, beside two names it blocks. `detectFormat` assigns
/// one format to a whole source, so these lines cannot join `http_body` — a
/// single `##` there would re-parse every hosts line as ABP.
const abp_body =
    "! small abp list\n" ++
    "##.ad-banner\n" ++
    "||ads.example^$third-party\n" ++
    "||blocked.example^\n" ++
    "tracker.example\n";

/// `||blocked.example^` covers its own apex, so it writes one `.list` entry
/// beside its `.wild` one; `tracker.example` writes the second.
const abp_domains: i64 = 2;
const abp_wildcards: i64 = 1;
const abp_unsupported: i64 = 2;

/// Compiles `text` into `<base>.list`, `<base>.wild` and `<base>.allow` under
/// `dir`, exactly as the manager's compile stage does, and returns the
/// compiler's own result.
fn compileToFiles(
    gpa: std.mem.Allocator,
    io: std.Io,
    dir: std.Io.Dir,
    base: []const u8,
    text: []const u8,
    format: parsers.Format,
) !compiler.Result {
    var list_name_buf: [64]u8 = undefined;
    var wild_name_buf: [64]u8 = undefined;
    var allow_name_buf: [64]u8 = undefined;
    const list_name = try std.fmt.bufPrint(&list_name_buf, "{s}.list", .{base});
    const wild_name = try std.fmt.bufPrint(&wild_name_buf, "{s}.wild", .{base});
    const allow_name = try std.fmt.bufPrint(&allow_name_buf, "{s}.allow", .{base});

    const list_file = try dir.createFile(io, list_name, .{ .permissions = .fromMode(0o600) });
    defer list_file.close(io);
    const wild_file = try dir.createFile(io, wild_name, .{ .permissions = .fromMode(0o600) });
    defer wild_file.close(io);
    const allow_file = try dir.createFile(io, allow_name, .{ .permissions = .fromMode(0o600) });
    defer allow_file.close(io);

    const buffers = try gpa.alloc(u8, 3 * 16 * 1024);
    defer gpa.free(buffers);

    var r: std.Io.Reader = .fixed(text);
    var list_w = list_file.writer(io, buffers[0 .. 16 * 1024]);
    var wild_w = wild_file.writer(io, buffers[16 * 1024 .. 32 * 1024]);
    var allow_w = allow_file.writer(io, buffers[32 * 1024 ..]);

    const result = try compiler.compile(
        gpa,
        &r,
        format,
        &list_w.interface,
        &wild_w.interface,
        &allow_w.interface,
    );
    try list_w.interface.flush();
    try wild_w.interface.flush();
    try allow_w.interface.flush();
    return result;
}

/// The three compiled bodies of one source, read back from disk with their
/// headers stripped, exactly as `Manager.reload` reads them.
const Bodies = struct {
    list: []u8,
    wild: []u8,
    allow: []u8,

    fn read(gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir, base: []const u8) !Bodies {
        var list_name_buf: [64]u8 = undefined;
        var wild_name_buf: [64]u8 = undefined;
        var allow_name_buf: [64]u8 = undefined;
        const list = try dir.readFileAlloc(
            io,
            try std.fmt.bufPrint(&list_name_buf, "{s}.list", .{base}),
            gpa,
            file_limit,
        );
        errdefer gpa.free(list);
        const wild = try dir.readFileAlloc(
            io,
            try std.fmt.bufPrint(&wild_name_buf, "{s}.wild", .{base}),
            gpa,
            file_limit,
        );
        errdefer gpa.free(wild);
        const allow = try dir.readFileAlloc(
            io,
            try std.fmt.bufPrint(&allow_name_buf, "{s}.allow", .{base}),
            gpa,
            file_limit,
        );
        return .{ .list = list, .wild = wild, .allow = allow };
    }

    fn deinit(self: *Bodies, gpa: std.mem.Allocator) void {
        gpa.free(self.list);
        gpa.free(self.wild);
        gpa.free(self.allow);
        self.* = undefined;
    }
};

/// A one-group, one-source snapshot over the three compiled bodies.
fn snapshotOver(
    gpa: std.mem.Allocator,
    list_body: []const u8,
    wild_body: []const u8,
    allow_body: []const u8,
) !matcher.Snapshot {
    const sources = [_]model.BlocklistSource{.{ .url = source_url, .name = source_name }};
    const compiled = [_]?matcher.Snapshot.Compiled{
        .{ .list_body = list_body, .wild_body = wild_body, .allow_body = allow_body },
    };
    return matcher.Snapshot.build(gpa, .{
        .groups = &.{.{ .name = "default" }},
        .group_ids = &.{1},
        .group_sources = &.{.{ .group = "default", .source_url = source_url }},
        .sources = &sources,
        .source_ids = &.{7},
        .rules = &.{},
        .clients = &.{},
        .prefixes = &.{},
        .compiled = &compiled,
        .seed = 0x5eed,
        .generation = 1,
    });
}

/// The third producer of this digest, beside `compiler.compile` and
/// `Manager.bodyChecksum`. All three must frame the bodies the same way or the
/// manager reads its own files as damaged.
fn bodyChecksum(list_body: []const u8, wild_body: []const u8, allow_body: []const u8) [64]u8 {
    var hasher = Sha256.init(.{});
    hasher.update(list_body);
    hasher.update(compiler.body_separator);
    hasher.update(wild_body);
    hasher.update(compiler.body_separator);
    hasher.update(allow_body);
    hasher.update(compiler.body_separator);
    var digest: [Sha256.digest_length]u8 = undefined;
    hasher.final(&digest);
    return std.fmt.bytesToHex(digest, .lower);
}

// ---------------------------------------------------------------------------
// fixtures: database
// ---------------------------------------------------------------------------

fn openMigrated() !db.Db {
    var database = try db.Db.open(":memory:", .{ .mode = .memory });
    errdefer database.close();
    try db.applyPragmas(&database, .{});
    _ = try migrations.migrate(&database);
    return database;
}

/// One enabled source linked to the `default` group, which migration step 1
/// already seeded. Returns the source's row id, which is what the compiled
/// files are named after.
fn seedSource(database: *db.Db, url: []const u8) !i64 {
    const group_id = (try groups_repo.groupId(database, "default")) orelse
        return error.TestGroupMissing;

    try sources_repo.insertBlocklistSource(database, .{ .url = url, .name = source_name }, .{});
    var rows = try listRows(database);
    defer rows.deinit();
    const source_id = (try rows.byUrl(url)).id;

    var group_ids: context.IdMap = .empty;
    defer group_ids.deinit(testing.allocator);
    try group_ids.put(testing.allocator, "default", group_id);
    var source_ids: context.IdMap = .empty;
    defer source_ids.deinit(testing.allocator);
    try source_ids.put(testing.allocator, url, source_id);

    try groups_repo.insertGroupSource(
        database,
        .{ .group = "default", .source_url = url },
        .{ .group_ids = &group_ids, .source_ids = &source_ids },
    );
    return source_id;
}

const Rows = struct {
    list: std.ArrayList(sources_repo.SourceRow),

    fn deinit(self: *Rows) void {
        sources_repo.freeSourceRows(testing.allocator, self.list.items);
        self.list.deinit(testing.allocator);
    }

    fn byUrl(self: *const Rows, url: []const u8) !sources_repo.SourceRow {
        for (self.list.items) |row| {
            if (std.mem.eql(u8, row.url, url)) return row;
        }
        return error.TestSourceMissing;
    }
};

fn listRows(database: *db.Db) !Rows {
    return .{ .list = try sources_repo.listSourceRows(database, testing.allocator) };
}

// ---------------------------------------------------------------------------
// fixtures: the manager and everything it needs
// ---------------------------------------------------------------------------

/// Heap-allocated because the fetcher points at the client and the manager
/// points at the fetcher: moving this struct would dangle both.
const Env = struct {
    gpa: std.mem.Allocator,
    threaded: std.Io.Threaded,
    tmp: testing.TmpDir,
    database: db.Db,
    http: std.http.Client,
    transfer_buf: [fetcher.min_transfer_buf]u8,
    redirect_buf: [fetcher.redirect_buffer_len]u8,
    f: fetcher.Fetcher,
    mgr: manager.Manager,

    fn create(gpa: std.mem.Allocator) !*Env {
        const self = try gpa.create(Env);
        errdefer gpa.destroy(self);

        self.gpa = gpa;
        self.threaded = .init(gpa, .{});
        self.tmp = testing.tmpDir(.{ .iterate = true });
        self.database = try openMigrated();
        self.http = .{ .allocator = gpa, .io = self.threaded.io() };
        self.f = .{
            .http = &self.http,
            .transfer_buf = &self.transfer_buf,
            .redirect_buf = &self.redirect_buf,
        };
        self.mgr = try manager.Manager.init(
            gpa,
            &self.database,
            .{ .dir = self.tmp.dir },
            &self.f,
            .{},
            budget,
        );
        return self;
    }

    fn destroy(self: *Env) void {
        const gpa = self.gpa;
        self.mgr.deinit(self.io());
        self.http.deinit();
        self.database.close();
        self.threaded.deinit();
        self.tmp.cleanup();
        gpa.destroy(self);
    }

    fn io(self: *Env) std.Io {
        return self.threaded.io();
    }

    /// The blocklist directory the manager writes into, created if the manager
    /// has not created it yet.
    fn blocklistDir(self: *Env) !std.Io.Dir {
        _ = try self.tmp.dir.createDirPathStatus(self.io(), "blocklists", .fromMode(0o700));
        return self.tmp.dir.openDir(self.io(), "blocklists", .{ .iterate = true });
    }

    fn status(self: *Env, id: i64) !manager.SourceStatus {
        var out: [8]manager.SourceStatus = undefined;
        const kept = self.mgr.statusSnapshot(self.io(), &out);
        for (out[0..kept]) |entry| {
            if (entry.id == id) return entry;
        }
        return error.TestStatusMissing;
    }

    /// The decision the published snapshot makes for `domain` in the default
    /// group, and the generation that made it.
    fn evaluate(self: *Env, domain: []const u8) !struct { matcher.Decision, u64 } {
        const handle = self.mgr.acquire(self.io()) orelse return error.TestNoSnapshot;
        defer handle.release(self.io());
        const group = handle.snapshot.groupIndexByName("default") orelse
            return error.TestGroupMissing;
        return .{ handle.snapshot.evaluate(group, domain), handle.snapshot.generation };
    }
};

// ---------------------------------------------------------------------------
// fixtures: the loopback http server
// ---------------------------------------------------------------------------

const Route = enum(u8) { body, redirect, not_found, oversize, chunked, stall, changed };

/// How long the `stall` route holds a reply open when nothing releases it.
///
/// The cases that use it prove a writer ran *beside* a parked download rather
/// than behind it, so nothing depends on this number being large. It exists so
/// that a regression fails the run in a second or two instead of hanging it,
/// which is what an unbounded stall against a single lock would do.
const stall_budget: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(1_500), .clock = .awake };

const redirect_path = "/redirected.txt";

const chunked_domains = 1_500;

/// The body the `chunked` route serves (milestone-15 ruling 7). Two thresholds
/// matter here and they are different: a body over `fetcher.min_transfer_buf`
/// (16 KiB) makes the fetcher's `pumpBody` loop iterate instead of finishing in
/// one read, and a body over the fixture connection's 8192-byte write buffer
/// makes the reply reach the wire in parts. 1500 lines of 31 bytes clears both
/// with room to spare.
fn chunkedFixtureBody(gpa: std.mem.Allocator) ![]u8 {
    var out: std.Io.Writer.Allocating = .init(gpa);
    errdefer out.deinit();
    for (0..chunked_domains) |i| {
        try out.writer.print("0.0.0.0 chunk{d:0>5}.example.com\n", .{i});
    }
    return out.toOwnedSlice();
}

/// `body` cut into three equal parts, deliberately not at line boundaries: a
/// domain name then straddles every part boundary, so a byte the transfer loses
/// or repeats there corrupts a name and moves the compiled count, instead of
/// being absorbed by a spare newline.
fn thirds(body: []const u8) [3][]const u8 {
    const first = body.len / 3;
    const second = 2 * (body.len / 3);
    return .{ body[0..first], body[first..second], body[second..] };
}

/// Past `fetcher.max_body_bytes`. The cap is a constant, so the only way to
/// reach it in a test is a declared length: the fetcher refuses on the response
/// head, before a byte of body streams.
const oversize_length = "104857600";

const HttpFixture = struct {
    server: net.Server,
    body: []const u8,
    /// What the `changed` route serves: the same list after its author edited
    /// it. A test sets this before the serving task starts and reaches it by
    /// switching the route, so the two bodies are read through the atomic that
    /// selects them and never written beside a request in flight.
    changed_body: []const u8,
    route: std.atomic.Value(u8),
    /// Connections accepted, whatever came over them. A test that claims a pass
    /// downloaded nothing reads this rather than the route counters: a refetch
    /// that failed on the wire is still a refetch, and this counts it.
    accepted: std.atomic.Value(u32),
    /// How many parts the `chunked` route has flushed. The test reads it to
    /// prove the reply really left this server in pieces, because a `Writer`
    /// reports a buffered part as written and would otherwise hide a fixture
    /// that sent everything in one go.
    flushed_parts: std.atomic.Value(u32),
    /// Set by the `stall` route once the reply is open and the body has
    /// stopped. A test that waits for this knows the refresh is inside its
    /// download and not on its way there.
    stall_reached: std.Io.Event,
    /// Set by the test to let the `stall` route finish its reply.
    stall_release: std.Io.Event,
    /// Whether the `stall` route has stopped waiting — by release or by
    /// `stall_budget` expiring. A writer that returns while this is still false
    /// returned with the download unfinished, which is the whole claim.
    stall_resumed: std.atomic.Value(bool),

    fn init(io: std.Io, body: []const u8) !HttpFixture {
        const local: net.IpAddress = try .parse("127.0.0.1", 0);
        return .{
            .server = try local.listen(io, .{ .reuse_address = true }),
            .body = body,
            .changed_body = "",
            .route = .init(@intFromEnum(Route.body)),
            .accepted = .init(0),
            .flushed_parts = .init(0),
            .stall_reached = .unset,
            .stall_release = .unset,
            .stall_resumed = .init(false),
        };
    }

    fn deinit(self: *HttpFixture, io: std.Io) void {
        self.server.deinit(io);
    }

    fn url(self: *const HttpFixture, buf: []u8) ![]const u8 {
        return std.fmt.bufPrint(buf, "http://127.0.0.1:{d}/list.txt", .{
            self.server.socket.address.getPort(),
        });
    }

    fn setRoute(self: *HttpFixture, route: Route) void {
        self.route.store(@intFromEnum(route), .release);
    }

    /// One request per connection, for as many connections as arrive. Returns
    /// when the task is canceled, which is what closes the accept.
    fn serve(self: *HttpFixture, io: std.Io) void {
        while (true) {
            var stream = self.server.accept(io) catch return;
            defer stream.close(io);
            _ = self.accepted.fetchAdd(1, .monotonic);

            var read_buf: [8192]u8 = undefined;
            var write_buf: [8192]u8 = undefined;
            var reader = stream.reader(io, &read_buf);
            var writer = stream.writer(io, &write_buf);
            var http: std.http.Server = .init(&reader.interface, &writer.interface);

            var request = http.receiveHead() catch continue;
            self.respond(io, &request) catch continue;
        }
    }

    /// Every reply closes the connection. A keep-alive reply would leave the
    /// fetcher holding the connection open while this server waits to accept a
    /// second one that never comes (milestone-5 spec, S9 note from S8).
    fn respond(self: *HttpFixture, io: std.Io, request: *std.http.Server.Request) !void {
        switch (@as(Route, @enumFromInt(self.route.load(.acquire)))) {
            .body => try request.respond(self.body, .{ .keep_alive = false }),
            .changed => try request.respond(self.changed_body, .{ .keep_alive = false }),
            .redirect => if (std.mem.eql(u8, request.head.target, redirect_path))
                try request.respond(self.body, .{ .keep_alive = false })
            else
                try request.respond("", .{
                    .keep_alive = false,
                    .status = .found,
                    .extra_headers = &.{.{ .name = "location", .value = redirect_path }},
                }),
            .not_found => try request.respond("no such list", .{
                .keep_alive = false,
                .status = .not_found,
            }),
            .oversize => try request.respond("", .{
                .keep_alive = false,
                .transfer_encoding = .none,
                .extra_headers = &.{.{ .name = "content-length", .value = oversize_length }},
            }),
            .chunked => try self.respondChunked(request),
            .stall => try self.respondStalled(io, request),
        }
    }

    /// Opens the reply, sends the first third of the body and then stops until
    /// `stall_release` is set or `stall_budget` runs out. The fetcher is inside
    /// its body pump for the whole of that, which is what a slow upstream looks
    /// like to a refresh pass — and what the refresh used to hold the writer
    /// lock across.
    fn respondStalled(self: *HttpFixture, io: std.Io, request: *std.http.Server.Request) !void {
        var send_buf: [4096]u8 = undefined;
        var stream = try request.respondStreaming(&send_buf, .{
            .respond_options = .{ .keep_alive = false },
        });
        const parts = thirds(self.body);
        try stream.writer.writeAll(parts[0]);
        try stream.flush();

        self.stall_reached.set(io);
        // A timeout and a cancellation are both "stop waiting": the writes
        // below then either finish the reply or fail into `serve`'s `continue`.
        self.stall_release.waitTimeout(io, .{ .duration = stall_budget }) catch {};
        self.stall_resumed.store(true, .release);

        try stream.writer.writeAll(parts[1]);
        try stream.writer.writeAll(parts[2]);
        try stream.end();
    }

    /// Streams the body in three flushed parts instead of one `respond`
    /// (milestone-15 ruling 7). Every other arm sends ~130 bytes in a single
    /// write, which the fetcher consumes in one read: the loop that 35f2324
    /// killed the process in was never driven end to end by this suite.
    fn respondChunked(self: *HttpFixture, request: *std.http.Server.Request) !void {
        var send_buf: [4096]u8 = undefined;
        var stream = try request.respondStreaming(&send_buf, .{
            .respond_options = .{ .keep_alive = false },
        });
        for (thirds(self.body)) |part| {
            try stream.writer.writeAll(part);
            // Without this the parts sit in `send_buf` and leave as one write.
            try stream.flush();
            _ = self.flushed_parts.fetchAdd(1, .release);
        }
        try stream.end();
    }
};

// ---------------------------------------------------------------------------
// fixtures: the loopback dns responder
// ---------------------------------------------------------------------------

const DnsMode = enum { answer, truncate, silent, wrong_id };

/// A query for nas.lan.home A: id 0x2222, RD set, one question, no OPT.
const forward_query =
    "\x22\x22\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00" ++
    "\x03nas\x03lan\x04home\x00\x00\x01\x00\x01";

const forward_rdata = [4]u8{ 192, 168, 1, 10 };
const forward_ttl: u32 = 120;

const DnsFixture = struct {
    socket: net.Socket,
    listener: ?net.Server = null,
    mode: DnsMode,

    fn port(self: *const DnsFixture) u16 {
        return self.socket.address.getPort();
    }

    fn deinit(self: *DnsFixture, io: std.Io) void {
        if (self.listener) |*listener| listener.deinit(io);
        self.socket.close(io);
    }

    fn serveUdp(self: *DnsFixture, io: std.Io) void {
        var in: [2048]u8 = undefined;
        var out: [2048]u8 = undefined;
        while (true) {
            const msg = self.socket.receive(io, &in) catch return;
            if (self.mode == .silent) continue;
            const reply = answerFor(msg.data, &out, self.mode) catch continue;
            self.socket.send(io, &msg.from, reply) catch return;
        }
    }

    /// The TCP side always answers in full: it exists only because the UDP
    /// answer was truncated.
    fn serveTcp(self: *DnsFixture, io: std.Io) void {
        const listener = &(self.listener.?);
        var out: [2048]u8 = undefined;
        while (true) {
            var stream = listener.accept(io) catch return;
            defer stream.close(io);

            var read_buf: [2048]u8 = undefined;
            var write_buf: [2048]u8 = undefined;
            var reader = stream.reader(io, &read_buf);
            var writer = stream.writer(io, &write_buf);

            const framed = reader.interface.takeArray(transport.prefix_len) catch continue;
            const len = transport.parsePrefix(framed.*);
            const query = reader.interface.take(len) catch continue;
            const reply = answerFor(query, &out, .answer) catch continue;

            writer.interface.writeAll(&transport.framePrefix(@intCast(reply.len))) catch continue;
            writer.interface.writeAll(reply) catch continue;
            writer.interface.flush() catch continue;
        }
    }
};

fn answerFor(query: []const u8, out: []u8, mode: DnsMode) ![]u8 {
    const request = try packet.parse(query);
    const q = packet.firstQuestion(request) orelse return error.TestMissingQuestion;

    var b = try packet.ResponseBuilder.init(out, request.header, q);
    if (mode != .truncate) try b.addAnswer(q.name, .a, .in, forward_ttl, &forward_rdata);
    const bytes = b.finish();

    switch (mode) {
        // RFC 1035 §4.1.1: TC is bit 0x02 of the second flags byte. The builder
        // has no truncation switch because nothing in nxdns truncates.
        .truncate => bytes[2] |= 0x02,
        .wrong_id => packet.setId(bytes, request.header.id +% 1),
        else => {},
    }
    return bytes;
}

/// A UDP socket and a TCP listener on the same loopback port, which is what a
/// resolver URL names. The two port spaces are independent, so an ephemeral UDP
/// port is nearly always free for TCP; a collision is retried rather than
/// failed, because it is not a defect in anything under test.
fn bindPair(io: std.Io) !struct { net.Socket, net.Server } {
    var attempts: usize = 0;
    while (attempts < 32) : (attempts += 1) {
        const any: net.IpAddress = try .parse("127.0.0.1", 0);
        const socket = try any.bind(io, .{ .mode = .dgram });
        const paired: net.IpAddress = try .parse("127.0.0.1", socket.address.getPort());
        const listener = paired.listen(io, .{ .reuse_address = true }) catch |err| switch (err) {
            error.AddressInUse => {
                socket.close(io);
                continue;
            },
            else => {
                socket.close(io);
                return err;
            },
        };
        return .{ socket, listener };
    }
    return error.TestNoFreePortPair;
}

fn expectForwardAnswer(reply: []const u8) !void {
    const p = try packet.parse(reply);
    try testing.expectEqual(@as(u16, 0x2222), p.header.id);
    try testing.expect(p.header.flags.qr);
    try testing.expectEqual(@as(u16, 1), p.header.ancount);

    var it = packet.answers(p);
    const rec = (try it.next()) orelse return error.TestMissingAnswer;
    try testing.expectEqual(types.Type.a, rec.rtype);
    try testing.expectEqual(forward_ttl, rec.ttl);
    try testing.expectEqual(forward_rdata, try record.rdataA(reply, rec));
}

// ---------------------------------------------------------------------------
// 1–3: compile, load and match through real files
// ---------------------------------------------------------------------------

test "1: a compiled hosts fixture loads into a snapshot that blocks its domains" {
    if (!build_options.integration) return error.SkipZigTest;

    const gpa = testing.allocator;
    var threaded: std.Io.Threaded = .init(gpa, .{});
    defer threaded.deinit();
    const io = threaded.io();

    var tmp = testing.tmpDir(.{ .iterate = true });
    defer tmp.cleanup();

    const text = try hostsFixture(gpa);
    defer gpa.free(text);

    const result = try compileToFiles(gpa, io, tmp.dir, "1", text, .hosts);
    try testing.expectEqual(@as(u32, fixture_domains), result.counts.domains);
    try testing.expectEqual(@as(u32, 1), result.counts.skipped_regex);
    // The three single-label names are the only invalid candidates here.
    try testing.expectEqual(@as(u32, single_label_names.len), result.counts.invalid);

    var bodies = try Bodies.read(gpa, io, tmp.dir, "1");
    defer bodies.deinit(gpa);

    for (single_label_names) |bare| {
        try testing.expect(std.mem.find(u8, bodies.list, bare) == null);
    }

    var snapshot = try snapshotOver(gpa, bodies.list, bodies.wild, bodies.allow);
    defer snapshot.deinit();
    const group = snapshot.groupIndexByName("default").?;

    var buf: [64]u8 = undefined;
    for (0..20) |j| {
        const member = try std.fmt.bufPrint(&buf, "ad{d}.example.com", .{j * sample_stride});
        const decision = snapshot.evaluate(group, member);
        try testing.expect(decision.blocked);
        try testing.expectEqual(matcher.Reason.blocklist_domain, decision.reason);
    }
    for (0..20) |j| {
        const stranger = try std.fmt.bufPrint(&buf, "nomatch{d}.example.org", .{j});
        try testing.expect(!snapshot.evaluate(group, stranger).blocked);
    }

    // A `.wild` entry covers proper subdomains and not the apex.
    try testing.expect(snapshot.evaluate(group, "a.wild0.example.net").blocked);
    try testing.expect(!snapshot.evaluate(group, "wild0.example.net").blocked);
}

test "2: recompiling the same fixture produces byte-identical files and checksums" {
    if (!build_options.integration) return error.SkipZigTest;

    const gpa = testing.allocator;
    var threaded: std.Io.Threaded = .init(gpa, .{});
    defer threaded.deinit();
    const io = threaded.io();

    var tmp = testing.tmpDir(.{ .iterate = true });
    defer tmp.cleanup();

    const text = try hostsFixture(gpa);
    defer gpa.free(text);

    var first_dir = try tmp.dir.createDirPathOpen(io, "first", .{});
    defer first_dir.close(io);
    var second_dir = try tmp.dir.createDirPathOpen(io, "second", .{});
    defer second_dir.close(io);

    const first = try compileToFiles(gpa, io, first_dir, "1", text, .hosts);
    const second = try compileToFiles(gpa, io, second_dir, "1", text, .hosts);

    try testing.expectEqualStrings(&first.checksum, &second.checksum);
    try testing.expectEqual(first.counts, second.counts);

    var first_bodies = try Bodies.read(gpa, io, first_dir, "1");
    defer first_bodies.deinit(gpa);
    var second_bodies = try Bodies.read(gpa, io, second_dir, "1");
    defer second_bodies.deinit(gpa);

    try testing.expectEqualSlices(u8, first_bodies.list, second_bodies.list);
    try testing.expectEqualSlices(u8, first_bodies.wild, second_bodies.wild);
    try testing.expectEqualSlices(u8, first_bodies.allow, second_bodies.allow);

    // The checksum the compiler reported is the one over the three bodies it
    // wrote, which is what the manager stores and compares against.
    try testing.expectEqualStrings(
        &bodyChecksum(first_bodies.list, first_bodies.wild, first_bodies.allow),
        &first.checksum,
    );
}

test "3: a damaged compiled file never replaces a serving snapshot with a worse one" {
    if (!build_options.integration) return error.SkipZigTest;

    const gpa = testing.allocator;
    const env = try Env.create(gpa);
    defer env.destroy();
    const io = env.io();

    const id = try seedSource(&env.database, source_url);

    const good_list = "aaa.example.com\nbbb.example.com\n";
    const good_wild = "ccc.example.com\n";

    var dir = try env.blocklistDir();
    defer dir.close(io);

    var name_buf: [64]u8 = undefined;
    const list_name = try std.fmt.bufPrint(&name_buf, "{d}.list", .{id});
    try dir.writeFile(io, .{ .sub_path = list_name, .data = good_list });
    var wild_name_buf: [64]u8 = undefined;
    const wild_name = try std.fmt.bufPrint(&wild_name_buf, "{d}.wild", .{id});
    try dir.writeFile(io, .{ .sub_path = wild_name, .data = good_wild });

    try sources_repo.updateSourceStats(&env.database, id, .{
        .last_updated = 1_700_000_000,
        .domain_count = 2,
        .wildcard_count = 1,
        .exception_count = 0,
        .skipped_regex_count = 0,
        .skipped_unsupported_count = 0,
        .checksum = &bodyChecksum(good_list, good_wild, ""),
    });

    try env.mgr.reload(io);
    {
        const decision, const generation = try env.evaluate("aaa.example.com");
        try testing.expect(decision.blocked);
        try testing.expectEqual(@as(u64, 1), generation);
    }

    // Truncated mid-line: the checksum no longer matches, so the source is
    // marked `.load_failed` and left out. The reload still succeeds, because
    // one damaged list must not cost the operator every other one.
    try dir.writeFile(io, .{ .sub_path = list_name, .data = "aaa.example.com\nbbb.exa" });
    try env.mgr.reload(io);
    {
        const decision, const generation = try env.evaluate("aaa.example.com");
        try testing.expect(!decision.blocked);
        try testing.expectEqual(@as(u64, 2), generation);
        try testing.expectEqual(manager.State.load_failed, (try env.status(id)).state);
    }

    // Present, checksum-clean and malformed: the build fails and nothing is
    // swapped, so the snapshot published above keeps serving.
    const unsorted_list = "bbb.example.com\naaa.example.com\n";
    try dir.writeFile(io, .{ .sub_path = list_name, .data = unsorted_list });
    try sources_repo.updateSourceStats(&env.database, id, .{
        .last_updated = 1_700_000_000,
        .domain_count = 2,
        .wildcard_count = 1,
        .exception_count = 0,
        .skipped_regex_count = 0,
        .skipped_unsupported_count = 0,
        .checksum = &bodyChecksum(unsorted_list, good_wild, ""),
    });

    try testing.expectError(error.NotSorted, env.mgr.reload(io));
    {
        _, const generation = try env.evaluate("aaa.example.com");
        try testing.expectEqual(@as(u64, 2), generation);
    }
}

// ---------------------------------------------------------------------------
// 4–8: the fetcher against a loopback http server
// ---------------------------------------------------------------------------

/// The whole refresh path for one source served by `fixture`, with the status
/// table synced first so `refreshSource` has somewhere to record its outcome.
fn refreshOnce(env: *Env, url: []const u8) !bool {
    try env.mgr.reload(env.io());
    var rows = try listRows(&env.database);
    defer rows.deinit();
    return env.mgr.refreshSource(env.io(), try rows.byUrl(url));
}

test "4: a 200 response is fetched, compiled, recorded and served" {
    if (!build_options.integration) return error.SkipZigTest;

    const gpa = testing.allocator;
    const env = try Env.create(gpa);
    defer env.destroy();
    const io = env.io();

    var fixture = try HttpFixture.init(io, http_body);
    defer fixture.deinit(io);
    var group: std.Io.Group = .init;
    defer group.cancel(io);
    try group.concurrent(io, HttpFixture.serve, .{ &fixture, io });

    var url_buf: [64]u8 = undefined;
    const url = try fixture.url(&url_buf);
    const id = try seedSource(&env.database, url);

    try testing.expect(try refreshOnce(env, url));
    try env.mgr.reload(io);

    var dir = try env.blocklistDir();
    defer dir.close(io);
    var bodies = try Bodies.read(gpa, io, dir, "1");
    defer bodies.deinit(gpa);
    try testing.expect(std.mem.find(u8, bodies.list, "ads.example.com") != null);

    var rows = try listRows(&env.database);
    defer rows.deinit();
    const row = try rows.byUrl(url);
    try testing.expectEqual(id, row.id);
    try testing.expectEqual(http_domains, row.domain_count);
    try testing.expectEqual(http_wildcards, row.wildcard_count);
    try testing.expectEqual(http_regex, row.skipped_regex_count);
    try testing.expectEqual(http_unsupported, row.skipped_unsupported_count);
    try testing.expectEqual(@as(usize, 64), (row.checksum orelse return error.TestNoChecksum).len);
    try testing.expect(row.last_updated != null);

    try testing.expectEqual(manager.State.ok, (try env.status(id)).state);

    {
        const decision, _ = try env.evaluate("ads.example.com");
        try testing.expect(decision.blocked);
        try testing.expectEqual(matcher.Reason.blocklist_domain, decision.reason);
    }
    {
        const decision, _ = try env.evaluate("a.wild.example.org");
        try testing.expect(decision.blocked);
        try testing.expectEqual(matcher.Reason.blocklist_wildcard, decision.reason);
    }
    {
        const decision, _ = try env.evaluate("safe.example.com");
        try testing.expect(!decision.blocked);
    }
}

test "5: a redirect is followed to the same result" {
    if (!build_options.integration) return error.SkipZigTest;

    const gpa = testing.allocator;
    const env = try Env.create(gpa);
    defer env.destroy();
    const io = env.io();

    var fixture = try HttpFixture.init(io, http_body);
    defer fixture.deinit(io);
    fixture.setRoute(.redirect);
    var group: std.Io.Group = .init;
    defer group.cancel(io);
    try group.concurrent(io, HttpFixture.serve, .{ &fixture, io });

    var url_buf: [64]u8 = undefined;
    const url = try fixture.url(&url_buf);
    const id = try seedSource(&env.database, url);

    try testing.expect(try refreshOnce(env, url));
    try env.mgr.reload(io);

    try testing.expectEqual(manager.State.ok, (try env.status(id)).state);
    var rows = try listRows(&env.database);
    defer rows.deinit();
    try testing.expectEqual(http_domains, (try rows.byUrl(url)).domain_count);

    const decision, _ = try env.evaluate("tracker.example.net");
    try testing.expect(decision.blocked);
}

test "6: a 404 leaves the compiled files and the snapshot untouched" {
    if (!build_options.integration) return error.SkipZigTest;

    const gpa = testing.allocator;
    const env = try Env.create(gpa);
    defer env.destroy();
    const io = env.io();

    var fixture = try HttpFixture.init(io, http_body);
    defer fixture.deinit(io);
    var group: std.Io.Group = .init;
    defer group.cancel(io);
    try group.concurrent(io, HttpFixture.serve, .{ &fixture, io });

    var url_buf: [64]u8 = undefined;
    const url = try fixture.url(&url_buf);
    const id = try seedSource(&env.database, url);

    try testing.expect(try refreshOnce(env, url));
    try env.mgr.reload(io);

    var dir = try env.blocklistDir();
    defer dir.close(io);
    var before = try Bodies.read(gpa, io, dir, "1");
    defer before.deinit(gpa);

    fixture.setRoute(.not_found);
    try testing.expect(!try refreshOnce(env, url));

    const failed = try env.status(id);
    try testing.expectEqual(manager.State.fetch_failed, failed.state);
    try testing.expectEqualStrings("HttpStatus", failed.errorText());

    var after = try Bodies.read(gpa, io, dir, "1");
    defer after.deinit(gpa);
    try testing.expectEqualSlices(u8, before.list, after.list);
    try testing.expectEqualSlices(u8, before.wild, after.wild);

    try env.mgr.reload(io);
    const decision, _ = try env.evaluate("ads.example.com");
    try testing.expect(decision.blocked);
}

test "7: a body over the cap fails the refresh and leaves no temporary file" {
    if (!build_options.integration) return error.SkipZigTest;

    const gpa = testing.allocator;
    const env = try Env.create(gpa);
    defer env.destroy();
    const io = env.io();

    var fixture = try HttpFixture.init(io, http_body);
    defer fixture.deinit(io);
    fixture.setRoute(.oversize);
    var group: std.Io.Group = .init;
    defer group.cancel(io);
    try group.concurrent(io, HttpFixture.serve, .{ &fixture, io });

    var url_buf: [64]u8 = undefined;
    const url = try fixture.url(&url_buf);
    const id = try seedSource(&env.database, url);

    try testing.expect(!try refreshOnce(env, url));

    const failed = try env.status(id);
    try testing.expectEqual(manager.State.fetch_failed, failed.state);
    try testing.expectEqualStrings("BodyTooLarge", failed.errorText());

    var dir = try env.blocklistDir();
    defer dir.close(io);
    var it = dir.iterate();
    while (try it.next(io)) |entry| {
        try testing.expect(!std.mem.endsWith(u8, entry.name, ".tmp"));
    }
}

test "8: refetching identical content skips the rewrite and still moves last_updated" {
    if (!build_options.integration) return error.SkipZigTest;

    const gpa = testing.allocator;
    const env = try Env.create(gpa);
    defer env.destroy();
    const io = env.io();

    var fixture = try HttpFixture.init(io, http_body);
    defer fixture.deinit(io);
    var group: std.Io.Group = .init;
    defer group.cancel(io);
    try group.concurrent(io, HttpFixture.serve, .{ &fixture, io });

    var url_buf: [64]u8 = undefined;
    const url = try fixture.url(&url_buf);
    const id = try seedSource(&env.database, url);

    try testing.expect(try refreshOnce(env, url));

    var dir = try env.blocklistDir();
    defer dir.close(io);
    const before = try dir.statFile(io, "1.list", .{});

    // A refresh inside the same wall-clock second would write back the same
    // number, so the stored value is moved out of the way first.
    try sources_repo.updateSourceStats(&env.database, id, .{
        .last_updated = 1_000,
        .domain_count = http_domains,
        .wildcard_count = http_wildcards,
        .exception_count = http_exceptions,
        .skipped_regex_count = http_regex,
        .skipped_unsupported_count = http_unsupported,
        .checksum = blk: {
            var rows = try listRows(&env.database);
            defer rows.deinit();
            var stored: [64]u8 = undefined;
            @memcpy(&stored, (try rows.byUrl(url)).checksum orelse return error.TestNoChecksum);
            break :blk &stored;
        },
    });

    try testing.expect(!try refreshOnce(env, url));

    // `replace` renames a new file over the old one, so an untouched file keeps
    // both its inode and its modification time.
    const after = try dir.statFile(io, "1.list", .{});
    try testing.expectEqual(before.inode, after.inode);
    try testing.expectEqual(before.mtime, after.mtime);

    var rows = try listRows(&env.database);
    defer rows.deinit();
    const row = try rows.byUrl(url);
    try testing.expect((row.last_updated orelse return error.TestNoTimestamp) > 1_000);
    try testing.expectEqual(manager.State.ok, (try env.status(id)).state);
}

// ---------------------------------------------------------------------------
// 9–10: the swap and the orphan sweep
// ---------------------------------------------------------------------------

const ReloadTask = struct {
    mgr: *manager.Manager,
    result: manager.Manager.Error!void = {},

    fn run(self: *ReloadTask, io: std.Io) void {
        self.result = self.mgr.reload(io);
    }
};

/// Writes one source's compiled files and records their checksum, without any
/// network: the swap and the orphan sweep care about files and rows, not about
/// where the bytes came from.
///
/// All three files, including an empty `.allow`, because that is what a publish
/// leaves: `publishOne` runs once per body and never skips the empty one.
fn publishFixtureFiles(env: *Env, id: i64, list_body: []const u8, wild_body: []const u8) !void {
    const io = env.io();
    var dir = try env.blocklistDir();
    defer dir.close(io);

    var list_name_buf: [64]u8 = undefined;
    var wild_name_buf: [64]u8 = undefined;
    var allow_name_buf: [64]u8 = undefined;
    try dir.writeFile(io, .{
        .sub_path = try std.fmt.bufPrint(&list_name_buf, "{d}.list", .{id}),
        .data = list_body,
    });
    try dir.writeFile(io, .{
        .sub_path = try std.fmt.bufPrint(&wild_name_buf, "{d}.wild", .{id}),
        .data = wild_body,
    });
    try dir.writeFile(io, .{
        .sub_path = try std.fmt.bufPrint(&allow_name_buf, "{d}.allow", .{id}),
        .data = "",
    });

    try sources_repo.updateSourceStats(&env.database, id, .{
        .last_updated = 1_700_000_000,
        .domain_count = 1,
        .wildcard_count = 0,
        .exception_count = 0,
        .skipped_regex_count = 0,
        .skipped_unsupported_count = 0,
        .checksum = &bodyChecksum(list_body, wild_body, ""),
    });
}

test "9: a reload swaps under a held handle and the new generation follows the release" {
    if (!build_options.integration) return error.SkipZigTest;

    const gpa = testing.allocator;
    const env = try Env.create(gpa);
    defer env.destroy();
    const io = env.io();

    const id = try seedSource(&env.database, source_url);
    try publishFixtureFiles(env, id, "aaa.example.com\n", "");
    try env.mgr.reload(io);

    const handle = env.mgr.acquire(io) orelse return error.TestNoSnapshot;
    const group = handle.snapshot.groupIndexByName("default") orelse return error.TestGroupMissing;
    try testing.expectEqual(@as(u64, 1), handle.snapshot.generation);

    var task: ReloadTask = .{ .mgr = &env.mgr };
    var tasks: std.Io.Group = .init;
    defer tasks.cancel(io);
    try tasks.concurrent(io, ReloadTask.run, .{ &task, io });

    // The reload takes the exclusive lock and therefore waits here. The
    // snapshot this handle holds cannot change under it, and cannot be freed
    // while it is read.
    for (0..1000) |_| {
        try testing.expect(handle.snapshot.evaluate(group, "aaa.example.com").blocked);
    }
    try testing.expectEqual(@as(u64, 1), handle.snapshot.generation);

    handle.release(io);
    try tasks.await(io);
    try task.result;

    const after = env.mgr.acquire(io) orelse return error.TestNoSnapshot;
    defer after.release(io);
    try testing.expectEqual(@as(u64, 2), after.snapshot.generation);
    try testing.expect(after.snapshot.evaluate(group, "aaa.example.com").blocked);
}

test "10: pruneOrphans deletes the files of a deleted source and leaves live ones" {
    if (!build_options.integration) return error.SkipZigTest;

    const gpa = testing.allocator;
    const env = try Env.create(gpa);
    defer env.destroy();
    const io = env.io();

    const id = try seedSource(&env.database, source_url);
    try publishFixtureFiles(env, id, "aaa.example.com\n", "");

    var dir = try env.blocklistDir();
    defer dir.close(io);
    // Every name a source can own, spelled out: this fixture is what decides
    // whether the sweep covers the whole set, so it enumerates
    // `manager.source_file_suffixes` by hand rather than sharing it. A suffix
    // added to the manager and not added here is swept by nothing and asserted
    // by nothing.
    try dir.writeFile(io, .{ .sub_path = "9999.list", .data = "gone.example.com\n" });
    try dir.writeFile(io, .{ .sub_path = "9999.wild", .data = "" });
    try dir.writeFile(io, .{ .sub_path = "9999.allow", .data = "lifted.example.com\n" });
    // Id 9999 has no `blocklist_sources` row, so no refresh can be writing for
    // it: these temporaries are what a refresh that died mid-write leaves
    // behind, and the sweep is the only thing that will ever remove them.
    try dir.writeFile(io, .{ .sub_path = "9999.raw.tmp", .data = "" });
    try dir.writeFile(io, .{ .sub_path = "9999.list.tmp", .data = "" });
    try dir.writeFile(io, .{ .sub_path = "9999.wild.tmp", .data = "" });
    try dir.writeFile(io, .{ .sub_path = "9999.allow.tmp", .data = "" });

    // The live source does have a row, so its temporary is a refresh in
    // progress and must survive a sweep that runs beside it.
    var live_tmp_buf: [64]u8 = undefined;
    const live_tmp = try std.fmt.bufPrint(&live_tmp_buf, "{d}.raw.tmp", .{id});
    try dir.writeFile(io, .{ .sub_path = live_tmp, .data = "" });

    try env.mgr.pruneOrphans(io);

    var live_buf: [64]u8 = undefined;
    try dir.access(io, try std.fmt.bufPrint(&live_buf, "{d}.list", .{id}), .{});
    try dir.access(io, try std.fmt.bufPrint(&live_buf, "{d}.allow", .{id}), .{});
    try dir.access(io, live_tmp, .{});
    try testing.expectError(error.FileNotFound, dir.access(io, "9999.list", .{}));
    try testing.expectError(error.FileNotFound, dir.access(io, "9999.wild", .{}));
    try testing.expectError(error.FileNotFound, dir.access(io, "9999.allow", .{}));
    try testing.expectError(error.FileNotFound, dir.access(io, "9999.raw.tmp", .{}));
    try testing.expectError(error.FileNotFound, dir.access(io, "9999.list.tmp", .{}));
    try testing.expectError(error.FileNotFound, dir.access(io, "9999.wild.tmp", .{}));
    try testing.expectError(error.FileNotFound, dir.access(io, "9999.allow.tmp", .{}));
}

test "10b: the scheduler sweeps orphans on its own, with no operator call" {
    if (!build_options.integration) return error.SkipZigTest;

    const gpa = testing.allocator;
    const env = try Env.create(gpa);
    defer env.destroy();
    const io = env.io();

    const list_body = "aaa.example.com\n";
    const id = try seedSource(&env.database, source_url);
    try publishFixtureFiles(env, id, list_body, "");
    // Dated now, so the startup pass finds nothing to download: this case
    // asserts the sweep, and `source_url` points at a port nobody is serving.
    try sources_repo.updateSourceStats(&env.database, id, .{
        .last_updated = std.Io.Clock.real.now(io).toSeconds(),
        .domain_count = 1,
        .wildcard_count = 0,
        .exception_count = 0,
        .skipped_regex_count = 0,
        .skipped_unsupported_count = 0,
        .checksum = &bodyChecksum(list_body, "", ""),
    });

    var dir = try env.blocklistDir();
    defer dir.close(io);
    // What a source deleted while the server was down leaves, and what a
    // process killed mid-refresh leaves. Nothing else in the tree removes
    // either.
    try dir.writeFile(io, .{ .sub_path = "9999.list", .data = "gone.example.com\n" });
    try dir.writeFile(io, .{ .sub_path = "9999.wild", .data = "" });
    try dir.writeFile(io, .{ .sub_path = "9999.allow", .data = "" });
    try dir.writeFile(io, .{ .sub_path = "9999.raw.tmp", .data = "" });
    try dir.writeFile(io, .{ .sub_path = "9999.allow.tmp", .data = "" });

    // `runScheduler` is the entry point `app.zig` hands to `Io.Group`, and the
    // only one the server ever calls. A disabled update parks it after the
    // startup pass, and the seam turns that park into the shutdown the task
    // only otherwise exits on, so the production path runs to completion here
    // with no interval to wait out.
    env.mgr.update.enabled = false;
    env.mgr.schedule_clock = .shutdown_at_first_park;
    try testing.expectError(error.Canceled, env.mgr.runScheduler(io));

    try testing.expectError(error.FileNotFound, dir.access(io, "9999.list", .{}));
    try testing.expectError(error.FileNotFound, dir.access(io, "9999.wild", .{}));
    try testing.expectError(error.FileNotFound, dir.access(io, "9999.allow", .{}));
    try testing.expectError(error.FileNotFound, dir.access(io, "9999.raw.tmp", .{}));
    try testing.expectError(error.FileNotFound, dir.access(io, "9999.allow.tmp", .{}));

    // The live source kept its files and is still filtering: the sweep did not
    // take the snapshot the same pass had just published.
    var live_buf: [64]u8 = undefined;
    try dir.access(io, try std.fmt.bufPrint(&live_buf, "{d}.list", .{id}), .{});
    const decision, _ = try env.evaluate("aaa.example.com");
    try testing.expect(decision.blocked);
}

// ---------------------------------------------------------------------------
// 10c–10d: a refresh in flight against the writers beside it
// ---------------------------------------------------------------------------

const RefreshAllTask = struct {
    mgr: *manager.Manager,
    /// Read by the test to tell "the pass is still downloading" from "the pass
    /// has finished". Atomic because the two run on different tasks.
    done: std.atomic.Value(bool) = .init(false),
    result: manager.Manager.Error!void = {},

    fn run(self: *RefreshAllTask, io: std.Io) void {
        self.result = self.mgr.refreshAll(io);
        self.done.store(true, .release);
    }
};

const RefreshSourceTask = struct {
    mgr: *manager.Manager,
    row: sources_repo.SourceRow,
    result: manager.Manager.Error!bool = false,

    fn run(self: *RefreshSourceTask, io: std.Io) void {
        self.result = self.mgr.refreshSource(io, self.row);
    }
};

/// One block rule in the `default` group, written the way the web handler
/// writes it.
fn insertBlockRule(database: *db.Db, pattern: []const u8) !void {
    const group_id = (try groups_repo.groupId(database, "default")) orelse
        return error.TestGroupMissing;
    var group_ids: context.IdMap = .empty;
    defer group_ids.deinit(testing.allocator);
    try group_ids.put(testing.allocator, "default", group_id);

    try rules_repo.insertRule(database, .{
        .group = "default",
        .pattern = pattern,
        .kind = .exact,
        .action = .block,
    }, .{ .group_ids = &group_ids });
}

test "10c: a rule save completes while a refresh is parked in its download" {
    if (!build_options.integration) return error.SkipZigTest;

    const gpa = testing.allocator;
    const env = try Env.create(gpa);
    defer env.destroy();
    const io = env.io();

    var fixture = try HttpFixture.init(io, http_body);
    defer fixture.deinit(io);
    fixture.setRoute(.stall);
    var group: std.Io.Group = .init;
    defer group.cancel(io);
    try group.concurrent(io, HttpFixture.serve, .{ &fixture, io });

    var url_buf: [64]u8 = undefined;
    const url = try fixture.url(&url_buf);
    const id = try seedSource(&env.database, url);
    try env.mgr.reload(io);

    var task: RefreshAllTask = .{ .mgr = &env.mgr };
    var tasks: std.Io.Group = .init;
    defer tasks.cancel(io);
    try tasks.concurrent(io, RefreshAllTask.run, .{ &task, io });

    // The pass is now inside the download, holding the refresh lock and
    // nothing else.
    try fixture.stall_reached.wait(io);

    // What the web does on every rule save: write the row, then reload. Behind
    // one lock this waits out the download — on a request path with no timeout,
    // occupying one of 64 web slots.
    try insertBlockRule(&env.database, "blocked.example.com");
    try env.mgr.reload(io);

    // Not a duration: the reply is still open, so the download this reload
    // returned across has not finished and cannot have been waited out. Behind
    // one lock the reload would have returned only after `stall_budget` gave
    // the reply up.
    const ran_beside = !fixture.stall_resumed.load(.acquire) and !task.done.load(.acquire);
    fixture.stall_release.set(io);

    try tasks.await(io);
    try task.result;
    try testing.expect(ran_beside);

    // Both writers landed: the rule is enforced and the refresh finished the
    // download the reload ran across.
    const decision, _ = try env.evaluate("blocked.example.com");
    try testing.expect(decision.blocked);
    try testing.expectEqual(matcher.Reason.rule_block_exact, decision.reason);
    try testing.expectEqual(manager.State.ok, (try env.status(id)).state);
}

test "10d: a source deleted mid-refresh does not take the refresh's temporary files" {
    if (!build_options.integration) return error.SkipZigTest;

    const gpa = testing.allocator;
    const env = try Env.create(gpa);
    defer env.destroy();
    const io = env.io();

    var fixture = try HttpFixture.init(io, http_body);
    defer fixture.deinit(io);
    fixture.setRoute(.stall);
    var group: std.Io.Group = .init;
    defer group.cancel(io);
    try group.concurrent(io, HttpFixture.serve, .{ &fixture, io });

    var url_buf: [64]u8 = undefined;
    const url = try fixture.url(&url_buf);
    const id = try seedSource(&env.database, url);
    try env.mgr.reload(io);

    // A `SourceRow` borrows the strings this list owns, and the refresh task
    // reads them for as long as it runs. The list outlives the task.
    var rows = try listRows(&env.database);
    defer rows.deinit();
    var task: RefreshSourceTask = .{ .mgr = &env.mgr, .row = try rows.byUrl(url) };

    var tasks: std.Io.Group = .init;
    defer tasks.cancel(io);
    try tasks.concurrent(io, RefreshSourceTask.run, .{ &task, io });

    try fixture.stall_reached.wait(io);

    var dir = try env.blocklistDir();
    defer dir.close(io);
    var raw_buf: [64]u8 = undefined;
    const raw_name = try std.fmt.bufPrint(&raw_buf, "{d}.raw.tmp", .{id});
    try dir.access(io, raw_name, .{});

    // The `DELETE /api/blocklists/{id}` sequence: drop the row, then sweep the
    // directory. The row is gone, so nothing in the sweep itself would spare
    // this source's raw download — only the refresh lock does, by making the
    // sweep wait for the pass instead of running across it.
    try sources_repo.deleteSource(&env.database, id);
    try env.mgr.pruneOrphans(io);

    try tasks.await(io);
    // True only if the compile found the raw file the download had written.
    try testing.expect(try task.result);

    // The sweep did run, once the refresh was out of the way: the compiled
    // files of a source with no row are gone, and so is every temporary.
    var it = dir.iterate();
    while (try it.next(io)) |entry| {
        try testing.expect(!std.mem.endsWith(u8, entry.name, ".tmp"));
        try testing.expect(!std.mem.endsWith(u8, entry.name, ".list"));
        try testing.expect(!std.mem.endsWith(u8, entry.name, ".wild"));
        try testing.expect(!std.mem.endsWith(u8, entry.name, ".allow"));
    }
}

// ---------------------------------------------------------------------------
// 10e: the restart invariant, across the config engine and the filter layer
// ---------------------------------------------------------------------------

test "10e: a reconcile then a restart reuses the compiled files and downloads nothing" {
    if (!build_options.integration) return error.SkipZigTest;

    const gpa = testing.allocator;
    const env = try Env.create(gpa);
    defer env.destroy();
    const io = env.io();

    var fixture = try HttpFixture.init(io, http_body);
    defer fixture.deinit(io);
    var group: std.Io.Group = .init;
    defer group.cancel(io);
    try group.concurrent(io, HttpFixture.serve, .{ &fixture, io });

    var url_buf: [64]u8 = undefined;
    const url = try fixture.url(&url_buf);
    const id = try seedSource(&env.database, url);

    // One real download, so the compiled artifacts exist and are named after
    // the row id the rest of this test is about.
    try testing.expect(try refreshOnce(env, url));
    try testing.expectEqual(@as(u32, 1), fixture.accepted.load(.monotonic));

    var dir = try env.blocklistDir();
    defer dir.close(io);
    var list_buf: [64]u8 = undefined;
    var wild_buf: [64]u8 = undefined;
    var allow_buf: [64]u8 = undefined;
    const list_name = try std.fmt.bufPrint(&list_buf, "{d}.list", .{id});
    const wild_name = try std.fmt.bufPrint(&wild_buf, "{d}.wild", .{id});
    const allow_name = try std.fmt.bufPrint(&allow_buf, "{d}.allow", .{id});
    const list_before = try dir.statFile(io, list_name, .{});
    const wild_before = try dir.statFile(io, wild_name, .{});
    const allow_before = try dir.statFile(io, allow_name, .{});

    // File mode, declaring exactly what the database already holds. The engine
    // has to recognise the source by its url and leave the row where it is:
    // the compiled files are named after that id, and the manager looks for
    // them under the same number.
    const cfg: model.Config = .{
        .groups = &.{.{ .name = "default" }},
        .blocklist_sources = &.{.{ .url = url, .name = source_name }},
        .group_sources = &.{.{ .group = "default", .source_url = url }},
        .upstreams = &.{.{ .url = "https://dns.example/dns-query" }},
    };
    var pass = try reconcile.begin(
        io,
        gpa,
        &env.database,
        cfg,
        std.Io.Clock.real.now(io).toSeconds(),
        .{},
    );
    errdefer pass.rollback();
    // Committed before the manager comes up, which is the ordering the invariant
    // rests on: a manager that read the table mid-transaction could see either
    // half of a source it is about to look for on disk.
    try pass.commit();

    // The restart. The old manager is gone and a new one comes up over the same
    // directory and the same database with nothing carried across in memory.
    // `runScheduler` is the boot sequence the server runs — the orphan sweep,
    // then the startup pass — and a disabled update parks it rather than
    // waiting out an interval; the seam turns that park into shutdown.
    env.mgr.deinit(io);
    env.mgr = try manager.Manager.init(
        gpa,
        &env.database,
        .{ .dir = env.tmp.dir },
        &env.f,
        .{ .enabled = false },
        budget,
    );
    env.mgr.schedule_clock = .shutdown_at_first_park;
    try testing.expectError(error.Canceled, env.mgr.runScheduler(io));

    // Nothing was downloaded. The server is still listening, so this is a
    // decision the pass made rather than a connection it could not have opened.
    try testing.expectEqual(@as(u32, 1), fixture.accepted.load(.monotonic));

    // The same three files: not recompiled, and not swept as orphans and
    // written back. `.allow` is asserted with the other two because a restart
    // that rewrote only the exception body would otherwise leave this test
    // green while changing what the snapshot lets through.
    const list_after = try dir.statFile(io, list_name, .{});
    const wild_after = try dir.statFile(io, wild_name, .{});
    const allow_after = try dir.statFile(io, allow_name, .{});
    try testing.expectEqual(list_before.inode, list_after.inode);
    try testing.expectEqual(list_before.mtime, list_after.mtime);
    try testing.expectEqual(wild_before.inode, wild_after.inode);
    try testing.expectEqual(wild_before.mtime, wild_after.mtime);
    try testing.expectEqual(allow_before.inode, allow_after.inode);
    try testing.expectEqual(allow_before.mtime, allow_after.mtime);

    // The row kept the id those files are named after, and the snapshot the
    // restart published is the one compiled from them.
    var rows = try listRows(&env.database);
    defer rows.deinit();
    try testing.expectEqual(id, (try rows.byUrl(url)).id);
    try testing.expectEqual(manager.State.ok, (try env.status(id)).state);

    // Asserted last, after the behaviour it explains: the engine wrote no row
    // at all, which is why the id above survived and why the restart above had
    // files to find.
    try testing.expectEqual(@as(u32, 0), pass.summary.sources.total());

    const decision, _ = try env.evaluate("ads.example.com");
    try testing.expect(decision.blocked);
    try testing.expectEqual(matcher.Reason.blocklist_domain, decision.reason);
}

// ---------------------------------------------------------------------------
// 11–12: local records, from the database to the wire
// ---------------------------------------------------------------------------

/// A query for example.com A with an EDNS(0) OPT record advertising 4096
/// bytes: id 0x1234, RD set, one question, one additional. Only its header and
/// its OPT record are used; each test supplies its own question.
const opt_query =
    "\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x01" ++
    "\x07example\x03com\x00\x00\x01\x00\x01" ++
    "\x00\x00\x29\x10\x00\x00\x00\x00\x00\x00\x00";

fn requestHeader() header.Header {
    return (packet.parse(opt_query) catch unreachable).header;
}

/// Answers `qname`/`qtype` from `table`, exactly as `server/handler.zig` does:
/// look the name up, then write the run it returns into a response.
fn answerLocal(
    table: *const records.Records,
    buf: []u8,
    qname: []const u8,
    qtype: types.Type,
) ![]u8 {
    const owner = try name.fromText(qname);
    const q: question.Question = .{ .name = owner, .qtype = qtype, .qclass = .in };
    var b = try packet.ResponseBuilder.init(buf, requestHeader(), q);
    try records.writeAnswers(&b, owner, table.lookup(qname, qtype));
    return b.finish();
}

fn localTable(gpa: std.mem.Allocator, database: *db.Db) !records.Records {
    var rows = try local_repo.listLocalRecords(database, gpa);
    defer rows.deinit(gpa);
    defer local_repo.freeLocalRecords(gpa, rows.items);
    return records.Records.build(gpa, rows.items);
}

test "11: a local A record read from the database answers an A query" {
    if (!build_options.integration) return error.SkipZigTest;

    const gpa = testing.allocator;
    var database = try openMigrated();
    defer database.close();

    try local_repo.insertLocalRecord(&database, .{
        .name = "nas.lan",
        .rtype = .a,
        .value = "192.168.1.10",
        .ttl = 600,
    }, .{});

    var table = try localTable(gpa, &database);
    defer table.deinit(gpa);

    var buf: [512]u8 = undefined;
    const bytes = try answerLocal(&table, &buf, "nas.lan", .a);

    const p = try packet.parse(bytes);
    try testing.expectEqual(@as(u16, 1), p.header.ancount);
    var it = packet.answers(p);
    const rec = (try it.next()) orelse return error.TestMissingAnswer;
    try testing.expectEqual(types.Type.a, rec.rtype);
    try testing.expectEqual(@as(u32, 600), rec.ttl);
    try testing.expectEqual([4]u8{ 192, 168, 1, 10 }, try record.rdataA(bytes, rec));
    try testing.expect((try it.next()) == null);

    // A type the name has no record of is NODATA, not a wrong answer.
    var mx_buf: [512]u8 = undefined;
    const mx = try answerLocal(&table, &mx_buf, "nas.lan", .mx);
    try testing.expectEqual(@as(u16, 0), (try packet.parse(mx)).header.ancount);
    try testing.expect(table.hasName("nas.lan"));
}

test "12: a local CNAME answers an A query with the CNAME alone" {
    if (!build_options.integration) return error.SkipZigTest;

    const gpa = testing.allocator;
    var database = try openMigrated();
    defer database.close();

    try local_repo.insertLocalRecord(&database, .{
        .name = "www.lan",
        .rtype = .cname,
        .value = "nas.lan",
        .ttl = 300,
    }, .{});

    var table = try localTable(gpa, &database);
    defer table.deinit(gpa);

    var buf: [512]u8 = undefined;
    const bytes = try answerLocal(&table, &buf, "www.lan", .a);

    const p = try packet.parse(bytes);
    try testing.expectEqual(@as(u16, 1), p.header.ancount);
    var it = packet.answers(p);
    const rec = (try it.next()) orelse return error.TestMissingAnswer;
    try testing.expectEqual(types.Type.cname, rec.rtype);
    try testing.expectEqual(@as(u32, 300), rec.ttl);
    try testing.expectEqualSlices(
        u8,
        (try name.fromText("nas.lan")).wire(),
        (try record.rdataCname(bytes, rec)).wire(),
    );
    try testing.expect((try it.next()) == null);
}

// ---------------------------------------------------------------------------
// 13–16: forward zones over the loopback
// ---------------------------------------------------------------------------

/// Seeds `lan.home` pointing at a loopback resolver and returns the zone table
/// the caller matches against.
fn seedZone(
    gpa: std.mem.Allocator,
    database: *db.Db,
    scheme: []const u8,
    port: u16,
) !forward_zones.Zones {
    var url_buf: [64]u8 = undefined;
    const resolver = try std.fmt.bufPrint(&url_buf, "{s}://127.0.0.1:{d}", .{ scheme, port });
    try local_repo.insertForwardZone(database, .{ .zone = "lan.home", .resolver = resolver }, .{});

    var rows = try local_repo.listForwardZones(database, gpa);
    defer rows.deinit(gpa);
    defer local_repo.freeForwardZones(gpa, rows.items);
    return forward_zones.Zones.build(gpa, rows.items);
}

test "13: a forward zone reaches its own resolver over udp" {
    if (!build_options.integration) return error.SkipZigTest;

    const gpa = testing.allocator;
    var threaded: std.Io.Threaded = .init(gpa, .{});
    defer threaded.deinit();
    const io = threaded.io();

    var database = try openMigrated();
    defer database.close();

    const local: net.IpAddress = try .parse("127.0.0.1", 0);
    var fixture: DnsFixture = .{
        .socket = try local.bind(io, .{ .mode = .dgram }),
        .mode = .answer,
    };
    defer fixture.deinit(io);

    var group: std.Io.Group = .init;
    defer group.cancel(io);
    try group.concurrent(io, DnsFixture.serveUdp, .{ &fixture, io });

    var zones = try seedZone(gpa, &database, "udp", fixture.port());
    defer zones.deinit(gpa);

    const zone = zones.match("nas.lan.home") orelse return error.TestNoZone;
    try testing.expectEqualStrings("lan.home", zone.zone);
    try testing.expectEqual(fixture.port(), zone.resolver.port);
    // A name outside the zone is not this resolver's business.
    try testing.expect(zones.match("notlan.home") == null);

    var frame_buf: [forward_client.min_frame_buf]u8 = undefined;
    var client: forward_client.ForwardClient = .init(zone.resolver, &frame_buf, read_timeout);

    var reply_buf: [2048]u8 = undefined;
    try expectForwardAnswer(try client.exchange(io, forward_query, &reply_buf));

    try testing.expectEqual(@as(u64, 1), client.stats.queries);
    try testing.expectEqual(@as(u64, 0), client.stats.udp_truncated);
    try testing.expectEqual(@as(u64, 0), client.stats.failures);
}

test "14: a truncated udp answer is retried over tcp" {
    if (!build_options.integration) return error.SkipZigTest;

    const gpa = testing.allocator;
    var threaded: std.Io.Threaded = .init(gpa, .{});
    defer threaded.deinit();
    const io = threaded.io();

    var database = try openMigrated();
    defer database.close();

    const socket, const listener = try bindPair(io);
    var fixture: DnsFixture = .{ .socket = socket, .listener = listener, .mode = .truncate };
    defer fixture.deinit(io);

    var group: std.Io.Group = .init;
    defer group.cancel(io);
    try group.concurrent(io, DnsFixture.serveUdp, .{ &fixture, io });
    try group.concurrent(io, DnsFixture.serveTcp, .{ &fixture, io });

    var zones = try seedZone(gpa, &database, "udp", fixture.port());
    defer zones.deinit(gpa);
    const zone = zones.match("a.b.lan.home") orelse return error.TestNoZone;

    var frame_buf: [forward_client.min_frame_buf]u8 = undefined;
    var client: forward_client.ForwardClient = .init(zone.resolver, &frame_buf, read_timeout);

    var reply_buf: [2048]u8 = undefined;
    try expectForwardAnswer(try client.exchange(io, forward_query, &reply_buf));

    try testing.expectEqual(@as(u64, 1), client.stats.udp_truncated);
    try testing.expectEqual(@as(u64, 0), client.stats.failures);
}

test "15: a silent resolver times out inside the configured budget" {
    if (!build_options.integration) return error.SkipZigTest;

    const gpa = testing.allocator;
    var threaded: std.Io.Threaded = .init(gpa, .{});
    defer threaded.deinit();
    const io = threaded.io();

    var database = try openMigrated();
    defer database.close();

    const local: net.IpAddress = try .parse("127.0.0.1", 0);
    var fixture: DnsFixture = .{
        .socket = try local.bind(io, .{ .mode = .dgram }),
        .mode = .silent,
    };
    defer fixture.deinit(io);

    var group: std.Io.Group = .init;
    defer group.cancel(io);
    try group.concurrent(io, DnsFixture.serveUdp, .{ &fixture, io });

    var zones = try seedZone(gpa, &database, "udp", fixture.port());
    defer zones.deinit(gpa);
    const zone = zones.match("nas.lan.home") orelse return error.TestNoZone;

    var frame_buf: [forward_client.min_frame_buf]u8 = undefined;
    var client: forward_client.ForwardClient = .init(zone.resolver, &frame_buf, read_timeout);

    var reply_buf: [2048]u8 = undefined;
    const started: std.Io.Timestamp = .now(io, .awake);
    try testing.expectError(error.Timeout, client.exchange(io, forward_query, &reply_buf));
    const elapsed = started.durationTo(.now(io, .awake));

    // The deadline is not asserted to the millisecond: the receive returns a
    // fraction of a millisecond early on this backend, and pinning the floor to
    // the exact budget would fail on clock resolution rather than on behaviour.
    const limit = read_timeout.raw.toMilliseconds();
    try testing.expect(elapsed.toMilliseconds() >= @divTrunc(limit, 2));
    try testing.expect(elapsed.toMilliseconds() < 2 * limit);
    try testing.expectEqual(@as(u64, 1), client.stats.failures);
}

test "16: a resolver answering with the wrong id is rejected" {
    if (!build_options.integration) return error.SkipZigTest;

    const gpa = testing.allocator;
    var threaded: std.Io.Threaded = .init(gpa, .{});
    defer threaded.deinit();
    const io = threaded.io();

    var database = try openMigrated();
    defer database.close();

    const local: net.IpAddress = try .parse("127.0.0.1", 0);
    var fixture: DnsFixture = .{
        .socket = try local.bind(io, .{ .mode = .dgram }),
        .mode = .wrong_id,
    };
    defer fixture.deinit(io);

    var group: std.Io.Group = .init;
    defer group.cancel(io);
    try group.concurrent(io, DnsFixture.serveUdp, .{ &fixture, io });

    var zones = try seedZone(gpa, &database, "udp", fixture.port());
    defer zones.deinit(gpa);
    const zone = zones.match("nas.lan.home") orelse return error.TestNoZone;

    var frame_buf: [forward_client.min_frame_buf]u8 = undefined;
    var client: forward_client.ForwardClient = .init(zone.resolver, &frame_buf, read_timeout);

    var reply_buf: [2048]u8 = undefined;
    try testing.expectError(
        error.ResponseMismatch,
        client.exchange(io, forward_query, &reply_buf),
    );
    try testing.expectEqual(@as(u64, 1), client.stats.failures);
}

// ---------------------------------------------------------------------------
// 17: the blocked reply each mode synthesizes
// ---------------------------------------------------------------------------

test "17: each blocking mode synthesizes the documented blocked reply" {
    if (!build_options.integration) return error.SkipZigTest;

    const gpa = testing.allocator;
    var threaded: std.Io.Threaded = .init(gpa, .{});
    defer threaded.deinit();
    const io = threaded.io();

    var tmp = testing.tmpDir(.{ .iterate = true });
    defer tmp.cleanup();

    // The reply is synthesized for a name the compiled files actually block, so
    // this case covers the decision and the response together.
    const result = try compileToFiles(gpa, io, tmp.dir, "1", "0.0.0.0 ads.example.com\n", .hosts);
    try testing.expectEqual(@as(u32, 1), result.counts.domains);

    var bodies = try Bodies.read(gpa, io, tmp.dir, "1");
    defer bodies.deinit(gpa);
    var snapshot = try snapshotOver(gpa, bodies.list, bodies.wild, bodies.allow);
    defer snapshot.deinit();

    const group = snapshot.groupIndexByName("default").?;
    try testing.expect(snapshot.evaluate(group, "ads.example.com").blocked);

    const ttl: u32 = 5;
    const blocked = try name.fromText("ads.example.com");

    for ([_]types.Type{ .a, .aaaa }) |qtype| {
        var buf: [512]u8 = undefined;
        const zero = try response.writeBlocked(
            &buf,
            requestHeader(),
            .{ .name = blocked, .qtype = qtype, .qclass = .in },
            null,
            false,
            .{ .mode = .zero, .ttl = ttl },
        );

        const p = try packet.parse(zero);
        try testing.expectEqual(types.Rcode.no_error, p.header.flags.rcode);
        try testing.expectEqual(@as(u16, 1), p.header.ancount);

        var it = packet.answers(p);
        const rec = (try it.next()) orelse return error.TestMissingAnswer;
        try testing.expectEqual(qtype, rec.rtype);
        try testing.expectEqual(ttl, rec.ttl);
        switch (qtype) {
            .a => try testing.expectEqual([4]u8{ 0, 0, 0, 0 }, try record.rdataA(zero, rec)),
            .aaaa => try testing.expectEqual(
                [16]u8{ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 },
                try record.rdataAaaa(zero, rec),
            ),
            else => unreachable,
        }

        var nx_buf: [512]u8 = undefined;
        const nx = try response.writeBlocked(
            &nx_buf,
            requestHeader(),
            .{ .name = blocked, .qtype = qtype, .qclass = .in },
            edns.parseOpt(opt_query, packet.findOptRecord(try packet.parse(opt_query)).?) catch
                unreachable,
            false,
            .{ .mode = .nxdomain, .ttl = ttl },
        );

        const nx_packet = try packet.parse(nx);
        try testing.expectEqual(types.Rcode.nx_domain, nx_packet.header.flags.rcode);
        try testing.expectEqual(@as(u16, 0), nx_packet.header.ancount);
        // The EDNS echo survives the NXDOMAIN path.
        try testing.expect(packet.findOptRecord(nx_packet) != null);
    }
}

// ---------------------------------------------------------------------------
// 18: the multi-read download path
// ---------------------------------------------------------------------------

test "18: a body streamed in flushed parts survives the fetcher's multi-read pump" {
    if (!build_options.integration) return error.SkipZigTest;

    const gpa = testing.allocator;
    const env = try Env.create(gpa);
    defer env.destroy();
    const io = env.io();

    const body = try chunkedFixtureBody(gpa);
    defer gpa.free(body);
    try testing.expect(body.len >= 24 * 1024);
    try testing.expect(body.len > fetcher.min_transfer_buf);

    var fixture = try HttpFixture.init(io, body);
    defer fixture.deinit(io);
    fixture.setRoute(.chunked);
    var group: std.Io.Group = .init;
    defer group.cancel(io);
    try group.concurrent(io, HttpFixture.serve, .{ &fixture, io });

    var url_buf: [64]u8 = undefined;
    const url = try fixture.url(&url_buf);
    const id = try seedSource(&env.database, url);

    try testing.expect(try refreshOnce(env, url));
    try env.mgr.reload(io);

    try testing.expect(fixture.flushed_parts.load(.acquire) >= 3);

    var rows = try listRows(&env.database);
    defer rows.deinit();
    const row = try rows.byUrl(url);
    try testing.expectEqual(@as(i64, chunked_domains), row.domain_count);
    try testing.expectEqual(@as(i64, 0), row.wildcard_count);
    try testing.expectEqual(manager.State.ok, (try env.status(id)).state);

    // Every name arrived intact, including the two that straddled a flush
    // boundary: a byte lost or repeated at a boundary corrupts a name, which
    // moves the compiled count away from the number of lines sent.
    var dir = try env.blocklistDir();
    defer dir.close(io);
    var bodies = try Bodies.read(gpa, io, dir, "1");
    defer bodies.deinit(gpa);
    try testing.expectEqual(
        @as(usize, chunked_domains),
        std.mem.count(u8, manager.stripHeader(bodies.list), "\n"),
    );

    var buf: [64]u8 = undefined;
    for ([_]usize{ 0, chunked_domains / 3, 2 * chunked_domains / 3, chunked_domains - 1 }) |i| {
        const domain = try std.fmt.bufPrint(&buf, "chunk{d:0>5}.example.com", .{i});
        const decision, _ = try env.evaluate(domain);
        try testing.expect(decision.blocked);
        try testing.expectEqual(matcher.Reason.blocklist_domain, decision.reason);
    }
}

// ---------------------------------------------------------------------------
// 19–20: list exceptions (milestone 21)
// ---------------------------------------------------------------------------

/// A real ABP list: one domain anchor, the exception that lifts one subtree out
/// of it in each of the two accepted spellings, and two `@@` forms nxdns does
/// not honour.
const abp_exception_body =
    "[Adblock Plus 2.0]\n" ++
    "! Title: exceptions\n" ++
    "||ads.example^\n" ++
    "||tracker.example^\n" ++
    "@@||good.ads.example^\n" ++
    "@@||fine.tracker.example$important\n" ++
    "@@||paid.ads.example^$third-party\n" ++
    "@@partial.ads.example\n";

const abp_exception_domains: i64 = 2;
const abp_exception_wildcards: i64 = 2;
const abp_exception_exceptions: i64 = 2;

test "19: a downloaded list's exceptions lift its own blocks and nothing else" {
    if (!build_options.integration) return error.SkipZigTest;

    const gpa = testing.allocator;
    const env = try Env.create(gpa);
    defer env.destroy();
    const io = env.io();

    var fixture = try HttpFixture.init(io, abp_exception_body);
    defer fixture.deinit(io);
    var group: std.Io.Group = .init;
    defer group.cancel(io);
    try group.concurrent(io, HttpFixture.serve, .{ &fixture, io });

    var url_buf: [64]u8 = undefined;
    const url = try fixture.url(&url_buf);
    const id = try seedSource(&env.database, url);

    try testing.expect(try refreshOnce(env, url));
    try env.mgr.reload(io);

    // The `.allow` body is a third file beside the two, and the row and the
    // status entry both carry its count — which is what a
    // `POST /api/blocklists/update` response row reports as `exceptions`.
    var dir = try env.blocklistDir();
    defer dir.close(io);
    var bodies = try Bodies.read(gpa, io, dir, "1");
    defer bodies.deinit(gpa);
    try testing.expectEqualStrings(
        "fine.tracker.example\ngood.ads.example\n",
        manager.stripHeader(bodies.allow),
    );

    var rows = try listRows(&env.database);
    defer rows.deinit();
    const row = try rows.byUrl(url);
    try testing.expectEqual(abp_exception_domains, row.domain_count);
    try testing.expectEqual(abp_exception_wildcards, row.wildcard_count);
    try testing.expectEqual(abp_exception_exceptions, row.exception_count);

    const status = try env.status(id);
    try testing.expectEqual(manager.State.ok, status.state);
    try testing.expect(status.loaded);
    try testing.expectEqual(@as(u32, @intCast(abp_exception_exceptions)), status.counts.exceptions);

    // The blocks the list makes still land, apex and subdomain alike.
    for ([_][]const u8{ "ads.example", "x.ads.example", "paid.ads.example", "partial.ads.example" }) |blocked| {
        const decision, _ = try env.evaluate(blocked);
        try testing.expect(decision.blocked);
    }

    // The two exceptions lift the excepted name and everything under it.
    for ([_][]const u8{
        "good.ads.example",
        "y.good.ads.example",
        "fine.tracker.example",
        "z.fine.tracker.example",
    }) |lifted| {
        const decision, _ = try env.evaluate(lifted);
        try testing.expect(!decision.blocked);
        try testing.expectEqual(matcher.Reason.blocklist_exception, decision.reason);
        try testing.expectEqual(@as(?u32, 0), decision.source);
    }

    // What `/api/lookup` answers, through the same function the handler calls:
    // the reason names the exception and the source id names the list.
    {
        const handle = env.mgr.acquire(io) orelse return error.TestNoSnapshot;
        defer handle.release(io);
        const group_index = handle.snapshot.groupIndexByName("default") orelse
            return error.TestGroupMissing;

        const result = lookup.evaluate(
            handle.snapshot,
            group_index,
            "y.good.ads.example",
            &empty_records,
            &empty_zones,
        );
        try testing.expect(!result.blocked);
        try testing.expectEqual(matcher.Reason.blocklist_exception, result.reason);
        try testing.expectEqualStrings("good.ads.example", result.matched);
        try testing.expectEqual(@as(?i64, id), result.source_id);

        const rendered = lookup.body("y.good.ads.example", result, url);
        try testing.expectEqualStrings("blocklist_exception", rendered.reason);
        try testing.expectEqualStrings(url, rendered.source_url.?);
    }

    // An operator block rule outranks the list's exception: a downloaded list
    // may cancel what a list decided and never what the operator decided.
    const group_id = (try groups_repo.groupId(&env.database, "default")) orelse
        return error.TestGroupMissing;
    _ = try rules_repo.insertRuleRow(&env.database, .{
        .group_id = group_id,
        .pattern = "good.ads.example",
        .kind = .exact,
        .action = .block,
    }, 1_700_000_000);
    try env.mgr.reload(io);
    {
        const decision, _ = try env.evaluate("good.ads.example");
        try testing.expect(decision.blocked);
        try testing.expectEqual(matcher.Reason.rule_block_exact, decision.reason);
    }
}

test "20: a data directory with no .allow file loads, and an unframed checksum does not" {
    if (!build_options.integration) return error.SkipZigTest;

    const gpa = testing.allocator;
    const env = try Env.create(gpa);
    defer env.destroy();
    const io = env.io();

    const id = try seedSource(&env.database, source_url);

    // Two compiled files and no `.allow` file, which is what a source that
    // published before exceptions existed left on disk.
    const list_body = "aaa.example.com\nbbb.example.com\n";
    const wild_body = "ccc.example.com\n";

    var dir = try env.blocklistDir();
    defer dir.close(io);
    var list_name_buf: [64]u8 = undefined;
    var wild_name_buf: [64]u8 = undefined;
    try dir.writeFile(io, .{
        .sub_path = try std.fmt.bufPrint(&list_name_buf, "{d}.list", .{id}),
        .data = list_body,
    });
    try dir.writeFile(io, .{
        .sub_path = try std.fmt.bufPrint(&wild_name_buf, "{d}.wild", .{id}),
        .data = wild_body,
    });

    var stats: sources_repo.SourceStats = .{
        .last_updated = 1_700_000_000,
        .domain_count = 2,
        .wildcard_count = 1,
        .exception_count = 0,
        .skipped_regex_count = 0,
        .skipped_unsupported_count = 0,
        .checksum = undefined,
    };

    // The digest those two files carried before the bodies were framed. It is
    // not the digest of any body layout the compiler produces now, so the load
    // must report the mismatch instead of serving the files.
    {
        var hasher = Sha256.init(.{});
        hasher.update(list_body);
        hasher.update(wild_body);
        var digest: [Sha256.digest_length]u8 = undefined;
        hasher.final(&digest);
        const unframed = std.fmt.bytesToHex(digest, .lower);

        stats.checksum = &unframed;
        try sources_repo.updateSourceStats(&env.database, id, stats);
        try env.mgr.reload(io);

        const status = try env.status(id);
        try testing.expectEqual(manager.State.load_failed, status.state);
        try testing.expect(!status.loaded);
        try testing.expectEqualStrings("ChecksumMismatch", status.errorText());
    }

    // The same two files under the framed digest of three bodies, the third of
    // them empty. The absent `.allow` file is that empty body, so the source
    // loads and both entries filter.
    {
        const framed = bodyChecksum(list_body, wild_body, "");
        stats.checksum = &framed;
        try sources_repo.updateSourceStats(&env.database, id, stats);
        try env.mgr.reload(io);

        const status = try env.status(id);
        try testing.expectEqual(manager.State.ok, status.state);
        try testing.expect(status.loaded);
        try testing.expectEqualStrings("", status.errorText());

        const decision, _ = try env.evaluate("aaa.example.com");
        try testing.expect(decision.blocked);
        try testing.expect((try env.evaluate("x.ccc.example.com"))[0].blocked);
    }
}

// ---------------------------------------------------------------------------
// 21–22: the unsupported count, from the compile to the database and back
// ---------------------------------------------------------------------------

test "21: an abp list's unsupported lines are counted, persisted and read back" {
    if (!build_options.integration) return error.SkipZigTest;

    const gpa = testing.allocator;
    const env = try Env.create(gpa);
    defer env.destroy();
    const io = env.io();

    var fixture = try HttpFixture.init(io, abp_body);
    defer fixture.deinit(io);
    var group: std.Io.Group = .init;
    defer group.cancel(io);
    try group.concurrent(io, HttpFixture.serve, .{ &fixture, io });

    var url_buf: [64]u8 = undefined;
    const url = try fixture.url(&url_buf);
    const id = try seedSource(&env.database, url);

    try testing.expect(try refreshOnce(env, url));
    try env.mgr.reload(io);

    var rows = try listRows(&env.database);
    defer rows.deinit();
    const row = try rows.byUrl(url);
    try testing.expectEqual(abp_domains, row.domain_count);
    try testing.expectEqual(abp_wildcards, row.wildcard_count);
    try testing.expectEqual(@as(i64, 0), row.skipped_regex_count);
    try testing.expectEqual(abp_unsupported, row.skipped_unsupported_count);

    const status = try env.status(id);
    try testing.expectEqual(manager.State.ok, status.state);
    try testing.expectEqual(@as(u32, @intCast(abp_unsupported)), status.counts.skipped_unsupported);

    // What the number costs the operator: the `$`-modifier rule named a domain
    // and blocked nothing, while the two lines nxdns could translate did block.
    try testing.expect(!(try env.evaluate("ads.example"))[0].blocked);
    try testing.expect((try env.evaluate("blocked.example"))[0].blocked);
    try testing.expect((try env.evaluate("tracker.example"))[0].blocked);
}

/// The same list before and after its author edited only lines nxdns skips.
/// The written entries are identical in both, so the two compiles produce one
/// checksum and the refresh takes the unchanged-checksum path.
const churn_before =
    "! churn fixture\n" ++
    "##.ad-one\n" ++
    "||blocked.example^\n" ++
    "tracker.example\n";

const churn_after =
    "! churn fixture\n" ++
    "##.ad-one\n" ++
    "##.ad-two\n" ++
    "/ads[0-9]+/\n" ++
    "||blocked.example^\n" ++
    "tracker.example\n";

const boundary_before = "a.example\n";
const boundary_after = "*.a.example\n";

test "23: a name moving from the list body to the wild body forces a republish" {
    if (!build_options.integration) return error.SkipZigTest;

    const gpa = testing.allocator;
    const env = try Env.create(gpa);
    defer env.destroy();
    const io = env.io();

    var fixture = try HttpFixture.init(io, boundary_before);
    fixture.changed_body = boundary_after;
    defer fixture.deinit(io);
    var group: std.Io.Group = .init;
    defer group.cancel(io);
    try group.concurrent(io, HttpFixture.serve, .{ &fixture, io });

    var url_buf: [64]u8 = undefined;
    const url = try fixture.url(&url_buf);
    _ = try seedSource(&env.database, url);

    try testing.expect(try refreshOnce(env, url));

    var first_checksum: [64]u8 = undefined;
    {
        var rows = try listRows(&env.database);
        defer rows.deinit();
        const row = try rows.byUrl(url);
        try testing.expectEqual(@as(i64, 1), row.domain_count);
        try testing.expectEqual(@as(i64, 0), row.wildcard_count);
        @memcpy(&first_checksum, row.checksum orelse return error.TestNoChecksum);
    }
}

test "22: a list that changed only its skipped lines still updates both skip counters" {
    if (!build_options.integration) return error.SkipZigTest;

    const gpa = testing.allocator;
    const env = try Env.create(gpa);
    defer env.destroy();
    const io = env.io();

    var fixture = try HttpFixture.init(io, churn_before);
    fixture.changed_body = churn_after;
    defer fixture.deinit(io);
    var group: std.Io.Group = .init;
    defer group.cancel(io);
    try group.concurrent(io, HttpFixture.serve, .{ &fixture, io });

    var url_buf: [64]u8 = undefined;
    const url = try fixture.url(&url_buf);
    const id = try seedSource(&env.database, url);

    try testing.expect(try refreshOnce(env, url));

    var first_checksum: [64]u8 = undefined;
    {
        var rows = try listRows(&env.database);
        defer rows.deinit();
        const row = try rows.byUrl(url);
        try testing.expectEqual(@as(i64, 0), row.skipped_regex_count);
        try testing.expectEqual(@as(i64, 1), row.skipped_unsupported_count);
        @memcpy(&first_checksum, row.checksum orelse return error.TestNoChecksum);
    }

    // The edited list. Two more skipped lines and not one written entry moved,
    // so the refresh finds its stored checksum and rewrites nothing on disk.
    fixture.setRoute(.changed);
    try testing.expect(!try refreshOnce(env, url));

    {
        var rows = try listRows(&env.database);
        defer rows.deinit();
        const row = try rows.byUrl(url);
        try testing.expectEqualStrings(&first_checksum, row.checksum orelse return error.TestNoChecksum);
        // The three written counts are the ones an unchanged checksum vouches
        // for; the two skip counts are the ones it says nothing about.
        try testing.expectEqual(abp_domains, row.domain_count);
        try testing.expectEqual(abp_wildcards, row.wildcard_count);
        try testing.expectEqual(@as(i64, 1), row.skipped_regex_count);
        try testing.expectEqual(@as(i64, 2), row.skipped_unsupported_count);
    }

    const live = try env.status(id);
    try testing.expectEqual(@as(u32, 1), live.counts.skipped_regex);
    try testing.expectEqual(@as(u32, 2), live.counts.skipped_unsupported);

    // The restart. A new manager over the same database and the same files
    // carries nothing across in memory, so the status it publishes is what
    // rehydration read out of the row — which is the only reason writing the
    // fresh counts above matters.
    env.mgr.deinit(io);
    env.mgr = try manager.Manager.init(
        gpa,
        &env.database,
        .{ .dir = env.tmp.dir },
        &env.f,
        .{ .enabled = false },
        budget,
    );
    try env.mgr.reload(io);

    const restored = try env.status(id);
    try testing.expectEqual(manager.State.ok, restored.state);
    try testing.expect(restored.loaded);
    try testing.expectEqual(@as(u32, 1), restored.counts.skipped_regex);
    try testing.expectEqual(@as(u32, 2), restored.counts.skipped_unsupported);
}

// ---------------------------------------------------------------------------
// diagnostics: the events the manager records (milestone 27)
// ---------------------------------------------------------------------------

test "27: a failing refresh opens a blocklist.refresh episode a good one closes" {
    if (!build_options.integration) return error.SkipZigTest;

    const gpa = testing.allocator;
    const env = try Env.create(gpa);
    defer env.destroy();
    const io = env.io();

    var fx: events_fixture.Fixture = .{};
    try fx.init(io, 1000);
    defer fx.deinit();
    env.mgr.diagnostics = &fx.store;

    var fixture = try HttpFixture.init(io, http_body);
    defer fixture.deinit(io);
    fixture.setRoute(.oversize);
    var group: std.Io.Group = .init;
    defer group.cancel(io);
    try group.concurrent(io, HttpFixture.serve, .{ &fixture, io });

    var url_buf: [64]u8 = undefined;
    const url = try fixture.url(&url_buf);
    _ = try seedSource(&env.database, url);

    try testing.expect(!try refreshOnce(env, url));

    try testing.expectEqualStrings("blocklist.refresh", try fx.text(
        "SELECT code FROM operational_events WHERE resolved_at IS NULL",
    ));
    // The whole url, not the display copy: `subject_key` is the identity.
    try testing.expectEqualStrings(url, try fx.text(
        "SELECT subject_key FROM operational_events WHERE resolved_at IS NULL",
    ));
    try testing.expectEqualStrings("warning", try fx.text(
        "SELECT severity FROM operational_events WHERE resolved_at IS NULL",
    ));

    fixture.setRoute(.body);
    try testing.expect(try refreshOnce(env, url));

    try testing.expectEqual(
        @as(i64, 0),
        try fx.count("SELECT count(*) FROM operational_events WHERE code = 'blocklist.refresh' AND resolved_at IS NULL"),
    );
}

/// Records the deadline of the first park and then reports the shutdown the
/// scheduler loop only otherwise exits on. Its clock never moves, so the
/// deadline it captures is exactly `anchor + interval`.
const FirstParkRecorder = struct {
    deadline_s: ?i64 = null,
    parked: bool = false,

    fn clock(self: *FirstParkRecorder) manager.ScheduleClock {
        return .{ .ctx = self, .nowFn = now, .waitFn = wait };
    }

    fn now(_: ?*anyopaque, _: std.Io) i64 {
        return 0;
    }

    fn wait(ctx: ?*anyopaque, _: std.Io, _: *std.Io.Event, deadline_s: ?i64) std.Io.Cancelable!void {
        const self: *FirstParkRecorder = @ptrCast(@alignCast(ctx.?));
        self.deadline_s = deadline_s;
        self.parked = true;
        return error.Canceled;
    }
};

test "34: a pass whose refresh failed still advances the schedule anchor" {
    if (!build_options.integration) return error.SkipZigTest;

    const gpa = testing.allocator;
    const env = try Env.create(gpa);
    defer env.destroy();
    const io = env.io();

    var fixture = try HttpFixture.init(io, http_body);
    defer fixture.deinit(io);
    // Every download this pass makes fails.
    fixture.setRoute(.oversize);
    var group: std.Io.Group = .init;
    defer group.cancel(io);
    try group.concurrent(io, HttpFixture.serve, .{ &fixture, io });

    var url_buf: [64]u8 = undefined;
    const url = try fixture.url(&url_buf);
    _ = try seedSource(&env.database, url);

    var recorder: FirstParkRecorder = .{};
    env.mgr.schedule_clock = recorder.clock();
    env.mgr.setSchedule(io, true, 2);

    try testing.expectError(error.Canceled, env.mgr.runScheduler(io));

    // The startup pass tried the source and failed. The anchor still moved to
    // that pass's completion, so the next refresh is a full interval away
    // rather than immediate: a failing source must not become a download loop.
    try testing.expect(recorder.parked);
    try testing.expectEqual(@as(?i64, 2 * 3_600), recorder.deadline_s);
}

test "27: one refreshAll pass records one occurrence of a failing source" {
    if (!build_options.integration) return error.SkipZigTest;

    const gpa = testing.allocator;
    const env = try Env.create(gpa);
    defer env.destroy();
    const io = env.io();

    var fx: events_fixture.Fixture = .{};
    try fx.init(io, 1000);
    defer fx.deinit();
    env.mgr.diagnostics = &fx.store;

    var fixture = try HttpFixture.init(io, http_body);
    defer fixture.deinit(io);
    fixture.setRoute(.oversize);
    var group: std.Io.Group = .init;
    defer group.cancel(io);
    try group.concurrent(io, HttpFixture.serve, .{ &fixture, io });

    var url_buf: [64]u8 = undefined;
    const url = try fixture.url(&url_buf);
    _ = try seedSource(&env.database, url);

    // `scheduledPass` is what an elapsed interval runs, and it ends in
    // `refreshAll`, which ends in a reload. One pass is one flush: a reload
    // that flushed on its own, or a scheduler that flushed after a pass that
    // already had, would report this failure twice.
    try env.mgr.scheduledPass(io);

    try testing.expectEqual(@as(i64, 1), try fx.count(
        "SELECT occurrences FROM operational_events WHERE code = 'blocklist.refresh'",
    ));

    // A second pass on a still-failing source is one more occurrence: the
    // failures one pass held ride the detail, and the flushes a pass makes are
    // not what `occurrences` counts.
    try env.mgr.scheduledPass(io);
    try testing.expectEqual(@as(i64, 2), try fx.count(
        "SELECT occurrences FROM operational_events WHERE code = 'blocklist.refresh'",
    ));
    const detail = try fx.text("SELECT detail FROM operational_events WHERE code = 'blocklist.refresh'");
    try testing.expect(std.mem.indexOf(u8, detail, "this pass") != null);
}

test "27: a clean reload resolves blocklist.snapshot and records no storage failure" {
    if (!build_options.integration) return error.SkipZigTest;

    const gpa = testing.allocator;
    const env = try Env.create(gpa);
    defer env.destroy();
    const io = env.io();

    var fx: events_fixture.Fixture = .{};
    try fx.init(io, 1000);
    defer fx.deinit();

    // A stale episode from a previous boot, which a published snapshot closes.
    fx.store.report(io, 900, .blocklist_snapshot, "snapshot", "blocklist snapshot", .@"error", "stale");
    env.mgr.diagnostics = &fx.store;

    try env.mgr.reload(io);

    try testing.expectEqual(
        @as(i64, 0),
        try fx.count("SELECT count(*) FROM operational_events WHERE resolved_at IS NULL"),
    );
    try testing.expectEqual(@as(i64, 900), try fx.count("SELECT first_seen FROM operational_events"));
}

test "27: an unreadable blocklist directory opens a blocklist.storage episode" {
    if (!build_options.integration) return error.SkipZigTest;
    // Mode bits do not apply to root, so the denial the test needs cannot happen.
    if (std.c.geteuid() == 0) return error.SkipZigTest;

    const gpa = testing.allocator;
    const env = try Env.create(gpa);
    defer env.destroy();
    const io = env.io();

    var fx: events_fixture.Fixture = .{};
    try fx.init(io, 1000);
    defer fx.deinit();
    env.mgr.diagnostics = &fx.store;

    var dir = try env.blocklistDir();
    dir.close(io);
    try env.tmp.dir.setPermissions(io, .fromMode(0o600));
    const failed = env.mgr.pruneOrphans(io);
    try env.tmp.dir.setPermissions(io, .fromMode(0o700));
    try testing.expectError(error.FileSystem, failed);

    try testing.expectEqualStrings("blocklist.storage", try fx.text(
        "SELECT code FROM operational_events WHERE resolved_at IS NULL",
    ));
    // `createDirPathStatus` is the first call to touch the unreadable parent,
    // so it is the operation that fails.
    try testing.expectEqualStrings("create_dir", try fx.text(
        "SELECT subject_key FROM operational_events WHERE resolved_at IS NULL",
    ));

    // A pass that can read the directory again closes it.
    try env.mgr.pruneOrphans(io);
    try testing.expectEqual(
        @as(i64, 0),
        try fx.count("SELECT count(*) FROM operational_events WHERE resolved_at IS NULL"),
    );
}
