//! `groups` and `group_sources`.
//!
//! Both lists yield model values holding **names**, never row ids: ids are not
//! stable across an import, so an export carrying them would not re-import into
//! the same shape.
//!
//! The import path is list / insert / deleteAll / count. The REST surface
//! is the second half of this file: it speaks row ids, because that is what a
//! `/api/groups/{id}` request names.

const std = @import("std");
const Allocator = std.mem.Allocator;

const db = @import("../db.zig");
const migrations = @import("../migrations.zig");
const model = @import("../../config/model.zig");
const context = @import("context.zig");
const crud = @import("crud.zig");

const IdMap = context.IdMap;
const InsertContext = context.InsertContext;

// ---------------------------------------------------------------------------
// groups
// ---------------------------------------------------------------------------

/// Every string in the result is a heap copy owned by `gpa`; free the whole
/// list with `freeGroups` and then `deinit` the list itself.
pub fn listGroups(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.Group) {
    return crud.listRows(
        model.Group,
        database,
        gpa,
        "SELECT name, safe_search FROM groups ORDER BY name",
        readGroup,
    );
}

fn readGroup(stmt: *db.Stmt, gpa: Allocator) db.Error!model.Group {
    const name = try stmt.columnTextAlloc(gpa, 0);
    errdefer gpa.free(name);
    return .{ .name = name, .safe_search = stmt.columnBool(1) };
}

pub fn freeGroups(gpa: Allocator, items: []const model.Group) void {
    crud.freeRows(model.Group, gpa, items);
}

pub fn insertGroup(database: *db.Db, item: model.Group, ctx: InsertContext) db.Error!void {
    _ = ctx;
    var stmt = try database.prepare("INSERT INTO groups (name, safe_search) VALUES (?1, ?2)");
    defer stmt.deinit();
    try stmt.bindText(1, item.name);
    try stmt.bindBool(2, item.safe_search);
    try stmt.exec();
}

pub fn deleteAllGroups(database: *db.Db) db.Error!void {
    return database.exec("DELETE FROM groups;");
}

pub fn countGroups(database: *db.Db) db.Error!i64 {
    return database.queryInt("SELECT count(*) FROM groups");
}

/// The row id of one group by name, or null. `listGroups` returns model values
/// without row ids by design (ids are not stable across an import); the filter
/// snapshot needs them to map a decision back to a row.
pub fn groupId(database: *db.Db, group_name: []const u8) db.Error!?i64 {
    var stmt = try database.prepare("SELECT id FROM groups WHERE name = ?1");
    defer stmt.deinit();
    try stmt.bindText(1, group_name);
    if (!try stmt.step()) return null;
    return stmt.columnInt(0);
}

// ---------------------------------------------------------------------------
// group_sources
// ---------------------------------------------------------------------------

const list_group_sources_sql =
    \\SELECT g.name, s.url FROM group_sources gs
    \\  JOIN groups g ON g.id = gs.group_id
    \\  JOIN blocklist_sources s ON s.id = gs.source_id
    \\ ORDER BY g.name, s.url
;

/// The two foreign keys are `NOT NULL` and enforced, so the join is total: a
/// `group_sources` row can never be dropped by it.
pub fn listGroupSources(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.GroupSource) {
    return crud.listRows(model.GroupSource, database, gpa, list_group_sources_sql, readGroupSource);
}

fn readGroupSource(stmt: *db.Stmt, gpa: Allocator) db.Error!model.GroupSource {
    const group = try stmt.columnTextAlloc(gpa, 0);
    errdefer gpa.free(group);
    const source_url = try stmt.columnTextAlloc(gpa, 1);
    errdefer gpa.free(source_url);
    return .{ .group = group, .source_url = source_url };
}

pub fn freeGroupSources(gpa: Allocator, items: []const model.GroupSource) void {
    crud.freeRows(model.GroupSource, gpa, items);
}

pub fn insertGroupSource(database: *db.Db, item: model.GroupSource, ctx: InsertContext) db.Error!void {
    const group_id = try ctx.groupId(item.group);
    const source_id = try ctx.sourceId(item.source_url);

    var stmt = try database.prepare("INSERT INTO group_sources (group_id, source_id) VALUES (?1, ?2)");
    defer stmt.deinit();
    try stmt.bindInt(1, group_id);
    try stmt.bindInt(2, source_id);
    try stmt.exec();
}

pub fn deleteAllGroupSources(database: *db.Db) db.Error!void {
    return database.exec("DELETE FROM group_sources;");
}

pub fn countGroupSources(database: *db.Db) db.Error!i64 {
    return database.queryInt("SELECT count(*) FROM group_sources");
}

// ---------------------------------------------------------------------------
// REST surface (milestone 8)
// ---------------------------------------------------------------------------
//
// The write shape is `model.Group`: its two fields are exactly the columns an
// operator may set, so the REST layer needs no third shape for them.

pub const GroupRow = struct { id: i64, name: []const u8, safe_search: bool };

/// Same order as `listGroups`; every string is a heap copy owned by `gpa`.
pub fn listGroupRows(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(GroupRow) {
    return crud.listRows(
        GroupRow,
        database,
        gpa,
        "SELECT id, name, safe_search FROM groups ORDER BY name",
        readGroupRow,
    );
}

fn readGroupRow(stmt: *db.Stmt, gpa: Allocator) db.Error!GroupRow {
    const name = try stmt.columnTextAlloc(gpa, 1);
    errdefer gpa.free(name);
    return .{ .id = stmt.columnInt(0), .name = name, .safe_search = stmt.columnBool(2) };
}

pub fn freeGroupRow(gpa: Allocator, row: GroupRow) void {
    crud.freeRow(GroupRow, gpa, row);
}

pub fn freeGroupRows(gpa: Allocator, items: []const GroupRow) void {
    crud.freeRows(GroupRow, gpa, items);
}

pub fn getGroup(database: *db.Db, gpa: Allocator, id: i64) db.Error!?GroupRow {
    var stmt = try database.prepare("SELECT id, name, safe_search FROM groups WHERE id = ?1");
    defer stmt.deinit();
    try stmt.bindInt(1, id);
    if (!try stmt.step()) return null;
    return try readGroupRow(&stmt, gpa);
}

/// `error.Constraint`: `groups.name` is UNIQUE.
pub fn insertGroupRow(database: *db.Db, item: model.Group) db.Error!i64 {
    var stmt = try database.prepare("INSERT INTO groups (name, safe_search) VALUES (?1, ?2)");
    defer stmt.deinit();
    try stmt.bindText(1, item.name);
    try stmt.bindBool(2, item.safe_search);
    try stmt.exec();
    return database.lastInsertRowid();
}

/// `error.NotFound`: no group holds `id`. `error.Constraint`: `groups.name` is
/// UNIQUE.
pub fn updateGroup(database: *db.Db, id: i64, item: model.Group) db.Error!void {
    var stmt = try database.prepare("UPDATE groups SET name = ?2, safe_search = ?3 WHERE id = ?1");
    defer stmt.deinit();
    try stmt.bindInt(1, id);
    try stmt.bindText(2, item.name);
    try stmt.bindBool(3, item.safe_search);
    return crud.execStrict(database, &stmt);
}

/// `error.NotFound`: no group holds `id`. `error.Constraint`: `clients.group_id`
/// references it and carries no `ON DELETE` action, so a group any client sits
/// in cannot go. `client_prefixes`, `group_sources` and `rules` cascade and
/// disappear with it.
pub fn deleteGroup(database: *db.Db, id: i64) db.Error!void {
    var stmt = try database.prepare("DELETE FROM groups WHERE id = ?1");
    defer stmt.deinit();
    try stmt.bindInt(1, id);
    return crud.execStrict(database, &stmt);
}

/// The blocklist sources assigned to one group, ascending. An unknown
/// `group_id` yields an empty list, not an error: the caller that needs the
/// distinction reads the group itself.
pub fn listGroupSourceIds(database: *db.Db, gpa: Allocator, group_id: i64) db.Error!std.ArrayList(i64) {
    return crud.listRowsBound(
        i64,
        database,
        gpa,
        "SELECT source_id FROM group_sources WHERE group_id = ?1 ORDER BY source_id",
        readSourceId,
        .{group_id},
    );
}

fn readSourceId(stmt: *db.Stmt, gpa: Allocator) db.Error!i64 {
    _ = gpa;
    return stmt.columnInt(0);
}

/// Replaces one group's whole source assignment inside a transaction, so a
/// caller never observes the group with half a set.
///
/// The assignment is a set: an id repeated in `source_ids` is written once.
/// Order does not survive, and re-running the call with the same ids is a no-op
/// as far as any reader can tell.
///
/// `error.NotFound`: no group holds `group_id` — checked explicitly, because an
/// empty `source_ids` writes nothing and would otherwise report success for a
/// group that does not exist. `error.Constraint`: an id in `source_ids` names no
/// `blocklist_sources` row.
pub fn setGroupSources(database: *db.Db, group_id: i64, source_ids: []const i64) db.Error!void {
    var tx = try db.Tx.begin(database);
    errdefer tx.rollback();

    if (!try groupExists(database, group_id)) return error.NotFound;

    {
        var delete = try database.prepare("DELETE FROM group_sources WHERE group_id = ?1");
        defer delete.deinit();
        try delete.bindInt(1, group_id);
        try delete.exec();
    }

    var insert = try database.prepare("INSERT INTO group_sources (group_id, source_id) VALUES (?1, ?2)");
    defer insert.deinit();
    for (source_ids, 0..) |source_id, i| {
        if (std.mem.indexOfScalar(i64, source_ids[0..i], source_id) != null) continue;
        // `reset` clears the bindings too, so both parameters are bound again
        // on every pass.
        try insert.reset();
        try insert.bindInt(1, group_id);
        try insert.bindInt(2, source_id);
        try insert.exec();
    }

    try tx.commit();
}

// ---------------------------------------------------------------------------
// reconcile surface (milestone 20)
// ---------------------------------------------------------------------------
//
// `group_sources` has no row id — the pair *is* the identity — so the reconcile
// engine matches on the pair and needs the ids the name-keyed list above
// resolves away.

pub const GroupSourcePair = struct { group_id: i64, source_id: i64 };

/// Every assignment as the pair of ids it is. Nothing to free.
pub fn listGroupSourcePairs(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(GroupSourcePair) {
    return crud.listRows(
        GroupSourcePair,
        database,
        gpa,
        "SELECT group_id, source_id FROM group_sources ORDER BY group_id, source_id",
        readGroupSourcePair,
    );
}

fn readGroupSourcePair(stmt: *db.Stmt, gpa: Allocator) db.Error!GroupSourcePair {
    _ = gpa;
    return .{ .group_id = stmt.columnInt(0), .source_id = stmt.columnInt(1) };
}

/// Removes one assignment. `error.NotFound`: no row holds the pair.
pub fn deleteGroupSourcePair(database: *db.Db, pair: GroupSourcePair) db.Error!void {
    var stmt = try database.prepare("DELETE FROM group_sources WHERE group_id = ?1 AND source_id = ?2");
    defer stmt.deinit();
    try stmt.bindInt(1, pair.group_id);
    try stmt.bindInt(2, pair.source_id);
    return crud.execStrict(database, &stmt);
}

fn groupExists(database: *db.Db, id: i64) db.Error!bool {
    var stmt = try database.prepare("SELECT 1 FROM groups WHERE id = ?1");
    defer stmt.deinit();
    try stmt.bindInt(1, id);
    return stmt.step();
}

// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------

const testing = std.testing;

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;
}

/// Migration step 1 seeds `(1, 'default')`, so a migrated database already holds
/// one group and one known id.
fn defaultGroupIds() !IdMap {
    var ids: IdMap = .empty;
    errdefer ids.deinit(testing.allocator);
    try ids.put(testing.allocator, "default", 1);
    return ids;
}

fn seedGroups(database: *db.Db) !void {
    const ctx: InsertContext = .{};
    try insertGroup(database, .{ .name = "kids", .safe_search = true }, ctx);
    try insertGroup(database, .{ .name = "zeta" }, ctx);
    try insertGroup(database, .{ .name = "alpha", .safe_search = true }, ctx);
}

fn seedGroupSources(database: *db.Db, ids: *const IdMap) !void {
    try database.exec(
        \\INSERT INTO blocklist_sources (id, url, name) VALUES
        \\  (1, 'https://b.example/list.txt', 'B'),
        \\  (2, 'https://a.example/list.txt', 'A');
    );
    var sources: IdMap = .empty;
    defer sources.deinit(testing.allocator);
    try sources.put(testing.allocator, "https://b.example/list.txt", 1);
    try sources.put(testing.allocator, "https://a.example/list.txt", 2);

    const ctx: InsertContext = .{ .group_ids = ids, .source_ids = &sources };
    try insertGroupSource(database, .{ .group = "default", .source_url = "https://b.example/list.txt" }, ctx);
    try insertGroupSource(database, .{ .group = "default", .source_url = "https://a.example/list.txt" }, ctx);
}

test "groups round-trip in name order" {
    var database = try openMigrated();
    defer database.close();
    try seedGroups(&database);

    var items = try listGroups(&database, testing.allocator);
    defer items.deinit(testing.allocator);
    defer freeGroups(testing.allocator, items.items);

    // The seeded `default` group sorts between `alpha` and `kids`.
    try testing.expectEqual(@as(usize, 4), items.items.len);
    try testing.expectEqualStrings("alpha", items.items[0].name);
    try testing.expect(items.items[0].safe_search);
    try testing.expectEqualStrings("default", items.items[1].name);
    try testing.expect(!items.items[1].safe_search);
    try testing.expectEqualStrings("kids", items.items[2].name);
    try testing.expect(items.items[2].safe_search);
    try testing.expectEqualStrings("zeta", items.items[3].name);
    try testing.expect(!items.items[3].safe_search);
}

test "deleteAllGroups empties the table and countGroups reflects it" {
    var database = try openMigrated();
    defer database.close();
    try seedGroups(&database);

    try testing.expectEqual(@as(i64, 4), try countGroups(&database));
    try deleteAllGroups(&database);
    try testing.expectEqual(@as(i64, 0), try countGroups(&database));

    var items = try listGroups(&database, testing.allocator);
    defer items.deinit(testing.allocator);
    defer freeGroups(testing.allocator, items.items);
    try testing.expectEqual(@as(usize, 0), items.items.len);
}

fn listGroupsUnderFailure(gpa: Allocator) !void {
    var database = try openMigrated();
    defer database.close();
    try seedGroups(&database);

    var items = try listGroups(&database, gpa);
    defer items.deinit(gpa);
    defer freeGroups(gpa, items.items);
}

test "listGroups is leak-safe under allocation failure" {
    try testing.checkAllAllocationFailures(testing.allocator, listGroupsUnderFailure, .{});
}

test "listGroupSources yields names, not ids" {
    var database = try openMigrated();
    defer database.close();
    var ids = try defaultGroupIds();
    defer ids.deinit(testing.allocator);
    try seedGroupSources(&database, &ids);

    var items = try listGroupSources(&database, testing.allocator);
    defer items.deinit(testing.allocator);
    defer freeGroupSources(testing.allocator, items.items);

    try testing.expectEqual(@as(usize, 2), items.items.len);
    try testing.expectEqualStrings("default", items.items[0].group);
    try testing.expectEqualStrings("https://a.example/list.txt", items.items[0].source_url);
    try testing.expectEqualStrings("default", items.items[1].group);
    try testing.expectEqualStrings("https://b.example/list.txt", items.items[1].source_url);
}

test "deleteAllGroupSources empties the table and countGroupSources reflects it" {
    var database = try openMigrated();
    defer database.close();
    var ids = try defaultGroupIds();
    defer ids.deinit(testing.allocator);
    try seedGroupSources(&database, &ids);

    try testing.expectEqual(@as(i64, 2), try countGroupSources(&database));
    try deleteAllGroupSources(&database);
    try testing.expectEqual(@as(i64, 0), try countGroupSources(&database));
}

test "insertGroupSource reports an id the caller's map does not hold" {
    var database = try openMigrated();
    defer database.close();
    const ctx: InsertContext = .{};
    try testing.expectError(
        error.NotFound,
        insertGroupSource(&database, .{ .group = "kids", .source_url = "https://a.example/list.txt" }, ctx),
    );
}

fn listGroupSourcesUnderFailure(gpa: Allocator, ids: *const IdMap) !void {
    var database = try openMigrated();
    defer database.close();
    try seedGroupSources(&database, ids);

    var items = try listGroupSources(&database, gpa);
    defer items.deinit(gpa);
    defer freeGroupSources(gpa, items.items);
}

test "listGroupSources is leak-safe under allocation failure" {
    var ids = try defaultGroupIds();
    defer ids.deinit(testing.allocator);
    try testing.checkAllAllocationFailures(testing.allocator, listGroupSourcesUnderFailure, .{&ids});
}

// --- REST surface ----------------------------------------------------------

/// Two sources with known ids, for the group-source assignment tests.
fn seedSourceRows(database: *db.Db) !void {
    try database.exec(
        \\INSERT INTO blocklist_sources (id, url, name) VALUES
        \\  (10, 'https://a.example/list.txt', 'A'),
        \\  (20, 'https://b.example/list.txt', 'B'),
        \\  (30, 'https://c.example/list.txt', 'C');
    );
}

test "a group round-trips through insert, get, list, update and delete" {
    var database = try openMigrated();
    defer database.close();

    const id = try insertGroupRow(&database, .{ .name = "kids", .safe_search = true });
    try testing.expect(id > 1);

    const fetched = (try getGroup(&database, testing.allocator, id)).?;
    defer freeGroupRow(testing.allocator, fetched);
    try testing.expectEqual(id, fetched.id);
    try testing.expectEqualStrings("kids", fetched.name);
    try testing.expect(fetched.safe_search);

    var rows = try listGroupRows(&database, testing.allocator);
    defer rows.deinit(testing.allocator);
    defer freeGroupRows(testing.allocator, rows.items);
    try testing.expectEqual(@as(usize, 2), rows.items.len);
    try testing.expectEqualStrings("default", rows.items[0].name);
    try testing.expectEqual(@as(i64, 1), rows.items[0].id);
    try testing.expectEqualStrings("kids", rows.items[1].name);
    try testing.expectEqual(id, rows.items[1].id);

    try updateGroup(&database, id, .{ .name = "teens", .safe_search = false });
    const updated = (try getGroup(&database, testing.allocator, id)).?;
    defer freeGroupRow(testing.allocator, updated);
    try testing.expectEqualStrings("teens", updated.name);
    try testing.expect(!updated.safe_search);

    try deleteGroup(&database, id);
    try testing.expectEqual(@as(?GroupRow, null), try getGroup(&database, testing.allocator, id));
    try testing.expectEqual(@as(i64, 1), try countGroups(&database));
}

test "update and delete report NotFound for an id no group holds" {
    var database = try openMigrated();
    defer database.close();

    try testing.expectError(error.NotFound, updateGroup(&database, 404, .{ .name = "ghost" }));
    try testing.expectError(error.NotFound, deleteGroup(&database, 404));
    try testing.expectEqual(@as(?GroupRow, null), try getGroup(&database, testing.allocator, 404));
}

test "a duplicate group name surfaces as error.Constraint on insert and on update" {
    var database = try openMigrated();
    defer database.close();

    const id = try insertGroupRow(&database, .{ .name = "kids" });
    try testing.expectError(error.Constraint, insertGroupRow(&database, .{ .name = "kids" }));
    try testing.expectError(error.Constraint, updateGroup(&database, id, .{ .name = "default" }));
}

test "deleting a group a client sits in surfaces as error.Constraint" {
    var database = try openMigrated();
    defer database.close();

    const id = try insertGroupRow(&database, .{ .name = "kids" });
    try database.exec(
        \\INSERT INTO clients (ip, group_id, first_seen, last_seen)
        \\VALUES ('192.168.1.9', 2, 1, 1);
    );
    try testing.expectError(error.Constraint, deleteGroup(&database, id));
    try testing.expectEqual(@as(i64, 2), try countGroups(&database));
}

test "deleting a group takes its rules, prefixes and source assignment with it" {
    var database = try openMigrated();
    defer database.close();
    try seedSourceRows(&database);

    const id = try insertGroupRow(&database, .{ .name = "kids" });
    try setGroupSources(&database, id, &.{ 10, 20 });
    try database.exec("INSERT INTO rules (group_id, pattern, kind, action, created_at) VALUES (2, 'x.example', 'exact', 'block', 1);");
    try database.exec("INSERT INTO client_prefixes (prefix, group_id) VALUES ('10.0.0.0/8', 2);");

    try deleteGroup(&database, id);
    try testing.expectEqual(@as(i64, 0), try countGroupSources(&database));
    try testing.expectEqual(@as(i64, 0), try database.queryInt("SELECT count(*) FROM rules"));
    try testing.expectEqual(@as(i64, 0), try database.queryInt("SELECT count(*) FROM client_prefixes"));
}

fn sourceIds(database: *db.Db, group_id: i64) ![]i64 {
    var list = try listGroupSourceIds(database, testing.allocator, group_id);
    return list.toOwnedSlice(testing.allocator);
}

test "setGroupSources replaces the whole set and repeats without effect" {
    var database = try openMigrated();
    defer database.close();
    try seedSourceRows(&database);

    try setGroupSources(&database, 1, &.{ 20, 10 });
    {
        const ids = try sourceIds(&database, 1);
        defer testing.allocator.free(ids);
        try testing.expectEqualSlices(i64, &.{ 10, 20 }, ids);
    }

    // Same set again: the rows are rewritten, the observable state is not.
    try setGroupSources(&database, 1, &.{ 10, 20 });
    {
        const ids = try sourceIds(&database, 1);
        defer testing.allocator.free(ids);
        try testing.expectEqualSlices(i64, &.{ 10, 20 }, ids);
    }
    try testing.expectEqual(@as(i64, 2), try countGroupSources(&database));

    // A different set replaces, it does not merge.
    try setGroupSources(&database, 1, &.{30});
    {
        const ids = try sourceIds(&database, 1);
        defer testing.allocator.free(ids);
        try testing.expectEqualSlices(i64, &.{30}, ids);
    }

    // The empty set clears it.
    try setGroupSources(&database, 1, &.{});
    try testing.expectEqual(@as(i64, 0), try countGroupSources(&database));
}

test "setGroupSources writes a repeated id once" {
    var database = try openMigrated();
    defer database.close();
    try seedSourceRows(&database);

    try setGroupSources(&database, 1, &.{ 10, 10, 20, 10 });
    const ids = try sourceIds(&database, 1);
    defer testing.allocator.free(ids);
    try testing.expectEqualSlices(i64, &.{ 10, 20 }, ids);
}

test "setGroupSources leaves other groups alone" {
    var database = try openMigrated();
    defer database.close();
    try seedSourceRows(&database);
    const kids = try insertGroupRow(&database, .{ .name = "kids" });

    try setGroupSources(&database, 1, &.{10});
    try setGroupSources(&database, kids, &.{ 20, 30 });
    try setGroupSources(&database, kids, &.{20});

    const default_ids = try sourceIds(&database, 1);
    defer testing.allocator.free(default_ids);
    try testing.expectEqualSlices(i64, &.{10}, default_ids);
    const kids_ids = try sourceIds(&database, kids);
    defer testing.allocator.free(kids_ids);
    try testing.expectEqualSlices(i64, &.{20}, kids_ids);
}

test "setGroupSources reports NotFound for a group that does not exist" {
    var database = try openMigrated();
    defer database.close();
    try seedSourceRows(&database);

    try testing.expectError(error.NotFound, setGroupSources(&database, 404, &.{10}));
    // Including the case where the empty set writes nothing at all.
    try testing.expectError(error.NotFound, setGroupSources(&database, 404, &.{}));
}

test "setGroupSources rolls back and reports Constraint for an unknown source id" {
    var database = try openMigrated();
    defer database.close();
    try seedSourceRows(&database);
    try setGroupSources(&database, 1, &.{10});

    try testing.expectError(error.Constraint, setGroupSources(&database, 1, &.{ 20, 999 }));

    // The prior assignment survived: the failed call wrote nothing.
    const ids = try sourceIds(&database, 1);
    defer testing.allocator.free(ids);
    try testing.expectEqualSlices(i64, &.{10}, ids);
}

fn groupRowsUnderFailure(gpa: Allocator) !void {
    var database = try openMigrated();
    defer database.close();
    try seedGroups(&database);

    var rows = try listGroupRows(&database, gpa);
    defer rows.deinit(gpa);
    defer freeGroupRows(gpa, rows.items);

    const one = (try getGroup(&database, gpa, 1)).?;
    defer freeGroupRow(gpa, one);

    var ids = try listGroupSourceIds(&database, gpa, 1);
    defer ids.deinit(gpa);
}

test "the group read surface is leak-safe under allocation failure" {
    try testing.checkAllAllocationFailures(testing.allocator, groupRowsUnderFailure, .{});
}
