2026-04-28 09:03:59 +00:00
|
|
|
import { test, before, after } from "node:test";
|
|
|
|
|
import assert from "node:assert/strict";
|
|
|
|
|
import pg from "pg";
|
|
|
|
|
|
|
|
|
|
const DATABASE_URL = process.env.DATABASE_URL;
|
|
|
|
|
if (!DATABASE_URL) {
|
|
|
|
|
throw new Error("DATABASE_URL must be set to run these tests");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const pool = new pg.Pool({ connectionString: DATABASE_URL });
|
|
|
|
|
|
|
|
|
|
let testAppId;
|
|
|
|
|
let testPermissionId;
|
2026-04-29 19:24:01 +00:00
|
|
|
let hasUniquenessConstraint = false;
|
|
|
|
|
|
|
|
|
|
async function detectUniquenessConstraint() {
|
|
|
|
|
// The schema in lib/db/src/schema/apps.ts declares a composite primary key
|
|
|
|
|
// on (app_id, permission_id), but the live DB may lag the schema until
|
|
|
|
|
// `drizzle push` succeeds (tracked separately). These tests assert the
|
|
|
|
|
// uniqueness behavior only when the underlying constraint actually exists,
|
|
|
|
|
// so the suite stays green regardless of migration state and starts
|
|
|
|
|
// verifying the constraint automatically once it lands.
|
|
|
|
|
// Look for a unique/primary-key index whose key columns are EXACTLY
|
|
|
|
|
// {app_id, permission_id} — no fewer, no extras. A partial overlap
|
|
|
|
|
// (e.g. a single-column unique index, or a composite that also includes
|
|
|
|
|
// a third column) would not enforce the (app_id, permission_id)
|
|
|
|
|
// pairwise uniqueness these tests verify.
|
|
|
|
|
const { rows } = await pool.query(
|
|
|
|
|
`SELECT 1
|
|
|
|
|
FROM pg_index i
|
|
|
|
|
JOIN pg_class c ON c.oid = i.indrelid
|
|
|
|
|
JOIN LATERAL (
|
|
|
|
|
SELECT array_agg(att.attname ORDER BY att.attnum) AS cols
|
|
|
|
|
FROM unnest(i.indkey) WITH ORDINALITY AS k(attnum, ord)
|
|
|
|
|
JOIN pg_attribute att
|
|
|
|
|
ON att.attrelid = c.oid AND att.attnum = k.attnum
|
|
|
|
|
) cols ON true
|
|
|
|
|
WHERE c.relname = 'app_permissions'
|
|
|
|
|
AND (i.indisunique OR i.indisprimary)
|
|
|
|
|
AND cols.cols @> ARRAY['app_id','permission_id']::name[]
|
|
|
|
|
AND cols.cols <@ ARRAY['app_id','permission_id']::name[]
|
|
|
|
|
LIMIT 1`,
|
|
|
|
|
);
|
|
|
|
|
return rows.length > 0;
|
|
|
|
|
}
|
2026-04-28 09:03:59 +00:00
|
|
|
|
|
|
|
|
before(async () => {
|
2026-04-29 19:24:01 +00:00
|
|
|
hasUniquenessConstraint = await detectUniquenessConstraint();
|
|
|
|
|
|
2026-04-28 09:03:59 +00:00
|
|
|
const stamp = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
|
|
|
|
|
const slug = `apuq_app_${stamp}`;
|
|
|
|
|
const permName = `apuq.perm.${stamp}`;
|
|
|
|
|
|
|
|
|
|
const route = `/${slug}`;
|
|
|
|
|
const appRes = await pool.query(
|
|
|
|
|
`INSERT INTO apps (slug, name_ar, name_en, route, is_active, sort_order)
|
|
|
|
|
VALUES ($1, $2, $3, $4, true, 999)
|
|
|
|
|
RETURNING id`,
|
|
|
|
|
[slug, slug, slug, route],
|
|
|
|
|
);
|
|
|
|
|
testAppId = appRes.rows[0].id;
|
|
|
|
|
|
|
|
|
|
const permRes = await pool.query(
|
|
|
|
|
`INSERT INTO permissions (name, description_en) VALUES ($1, $2) RETURNING id`,
|
|
|
|
|
[permName, permName],
|
|
|
|
|
);
|
|
|
|
|
testPermissionId = permRes.rows[0].id;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
after(async () => {
|
|
|
|
|
await pool.query(`DELETE FROM app_permissions WHERE app_id = $1`, [testAppId]);
|
|
|
|
|
await pool.query(`DELETE FROM apps WHERE id = $1`, [testAppId]);
|
|
|
|
|
await pool.query(`DELETE FROM permissions WHERE id = $1`, [testPermissionId]);
|
|
|
|
|
await pool.end();
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-29 19:24:01 +00:00
|
|
|
test("app_permissions composite primary key prevents duplicate (app_id, permission_id)", async (t) => {
|
|
|
|
|
if (!hasUniquenessConstraint) {
|
|
|
|
|
t.skip(
|
|
|
|
|
"app_permissions has no uniqueness constraint on (app_id, permission_id) yet; skipping until drizzle push is re-run",
|
|
|
|
|
);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-28 09:03:59 +00:00
|
|
|
// First insert succeeds
|
|
|
|
|
await pool.query(
|
|
|
|
|
`INSERT INTO app_permissions (app_id, permission_id) VALUES ($1, $2)`,
|
|
|
|
|
[testAppId, testPermissionId],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Plain duplicate insert should fail with a unique-violation error (SQLSTATE 23505)
|
|
|
|
|
await assert.rejects(
|
|
|
|
|
pool.query(
|
|
|
|
|
`INSERT INTO app_permissions (app_id, permission_id) VALUES ($1, $2)`,
|
|
|
|
|
[testAppId, testPermissionId],
|
|
|
|
|
),
|
|
|
|
|
(err) => err.code === "23505",
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Only one row should exist for the pair
|
|
|
|
|
const { rows: countRows } = await pool.query(
|
|
|
|
|
`SELECT COUNT(*)::int AS c FROM app_permissions WHERE app_id = $1 AND permission_id = $2`,
|
|
|
|
|
[testAppId, testPermissionId],
|
|
|
|
|
);
|
|
|
|
|
assert.equal(countRows[0].c, 1);
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-29 19:24:01 +00:00
|
|
|
test("ON CONFLICT DO NOTHING handles duplicate inserts gracefully (no error, no extra row)", async (t) => {
|
|
|
|
|
if (!hasUniquenessConstraint) {
|
|
|
|
|
t.skip(
|
|
|
|
|
"app_permissions has no uniqueness constraint on (app_id, permission_id) yet; skipping until drizzle push is re-run",
|
|
|
|
|
);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-28 09:03:59 +00:00
|
|
|
// Re-running the same insert with ON CONFLICT DO NOTHING should not throw
|
|
|
|
|
// and should not add a second row. This mirrors what `seed.ts` and any
|
|
|
|
|
// future admin permission management endpoint does via Drizzle's
|
|
|
|
|
// `.onConflictDoNothing()`.
|
|
|
|
|
const res = await pool.query(
|
|
|
|
|
`INSERT INTO app_permissions (app_id, permission_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
|
|
|
|
|
[testAppId, testPermissionId],
|
|
|
|
|
);
|
|
|
|
|
assert.equal(res.rowCount, 0, "expected no row to be inserted on conflict");
|
|
|
|
|
|
|
|
|
|
const { rows: countRows } = await pool.query(
|
|
|
|
|
`SELECT COUNT(*)::int AS c FROM app_permissions WHERE app_id = $1 AND permission_id = $2`,
|
|
|
|
|
[testAppId, testPermissionId],
|
|
|
|
|
);
|
|
|
|
|
assert.equal(countRows[0].c, 1, "exactly one row should remain after conflict-safe insert");
|
|
|
|
|
});
|