Compare commits

..

1 commit

Author SHA1 Message Date
Cursor Origin
23720997fc Pre-computed merge for change #3 1970-01-01 00:00:00 +00:00
6 changed files with 6 additions and 507 deletions

View file

@ -1,283 +0,0 @@
import { assert, assertEquals, assertExists } from "jsr:@std/assert@1";
import type { PgDb } from "./pg.ts";
import { callCoreFn } from "./rpc.ts";
import { withTestDb, closeTestPool } from "./test_helpers.ts";
import { createPool, PgDb as PgDbClass } from "./pg.ts";
const TENANT_ID = 999002;
type Fixture = { companyId: number; projectId: number; budgetItemId: number };
async function seedFixture(db: PgDb): Promise<Fixture> {
await db.prepare(
`INSERT INTO companies (code, name, kind, tenant_id) VALUES ('COSTCO', 'Cost Test Co', 'principal', ?)`,
).run(TENANT_ID);
const companyId = await db.lastInsertId();
await db.prepare(
`INSERT INTO projects (code, name, status, theme_id, company_id, tenant_id)
VALUES ('COST-0001', 'Obra Costos', 'activo', 'arctec-dos-logos-fold', ?, ?)`,
).run(companyId, TENANT_ID);
const projectId = await db.lastInsertId();
await db.prepare(
`INSERT INTO budget_chapters (project_id, code, name, sort_order)
VALUES (?, 'CAP01', 'Capítulo prueba', 1)`,
).run(projectId);
const chapterId = await db.lastInsertId();
await db.prepare(
`INSERT INTO budget_items (project_id, chapter_id, code, description, unit, quantity, unit_price, amount)
VALUES (?, ?, 'ITM01', 'Concepto prueba', 'PZA', 100, 50, 5000)`,
).run(projectId, chapterId);
const budgetItemId = await db.lastInsertId();
return { companyId, projectId, budgetItemId };
}
function dataId(env: { ok: boolean; data?: Record<string, unknown> }, key = "id"): number {
assert(env.ok, `RPC falló: ${JSON.stringify(env)}`);
const data = env.data as Record<string, unknown>;
const nested = data.expense ?? data.warehouse ?? data;
const id = (nested as Record<string, unknown>)[key] ?? data[key];
assertExists(id);
return Number(id);
}
Deno.test("RPC gastos: CRUD completo", async () => {
await withTestDb(async (db) => {
const fx = await seedFixture(db);
const base = { tenant_id: TENANT_ID };
const created = await callCoreFn(db, "core.fn_expense_create", {
...base,
company_id: fx.companyId,
project_id: fx.projectId,
budget_item_id: fx.budgetItemId,
description: "Gasto prueba RPC",
quantity: 2,
unit: "PZA",
unit_price: 100,
subtotal: 200,
total: 232,
tax_included: false,
is_deductible: true,
counts_toward_budget: true,
created_by_id: 1,
created_by_name: "Tester",
});
assertEquals(created.ok, true);
assertEquals(created.code, "CREATED");
const expenseId = dataId(created as { ok: boolean; data: Record<string, unknown> });
const listed = await callCoreFn(db, "core.fn_expense_list", { ...base, project_id: fx.projectId });
assertEquals(listed.ok, true);
const expenses = (listed.data as { expenses: unknown[] }).expenses;
assert(expenses.length >= 1);
const got = await callCoreFn(db, "core.fn_expense_get", { id: expenseId });
assertEquals(got.ok, true);
const updated = await callCoreFn(db, "core.fn_expense_update", {
id: expenseId,
tenant_id: TENANT_ID,
description: "Gasto editado",
notes: "nota",
});
assertEquals(updated.ok, true);
const iva = await callCoreFn(db, "core.fn_iva_period_summary", {
...base,
company_id: fx.companyId,
year: new Date().getFullYear(),
month: new Date().getMonth() + 1,
});
assertEquals(iva.ok, true);
const settings = await callCoreFn(db, "core.fn_cost_settings_get", base);
assertEquals(settings.ok, true);
const saved = await callCoreFn(db, "core.fn_cost_settings_save", {
...base,
budget_warn_pct: 80,
budget_critical_pct: 95,
default_iva_rate: 0.16,
});
assertEquals(saved.ok, true);
const att = await callCoreFn(db, "core.fn_expense_attachment_add", {
expense_id: expenseId,
original_name: "factura.pdf",
mime: "application/pdf",
size_bytes: 100,
sha256: "",
iv: "00",
storage_name: "test.pdf",
uploaded_by_id: 1,
uploaded_by_name: "Tester",
});
assertEquals(att.ok, true);
const voided = await callCoreFn(db, "core.fn_expense_void", { id: expenseId });
assertEquals(voided.ok, true);
});
});
Deno.test("RPC almacén: central, obra, entrada, transferencia, salida", async () => {
await withTestDb(async (db) => {
const fx = await seedFixture(db);
const base = { tenant_id: TENANT_ID };
const central = await callCoreFn(db, "core.fn_warehouse_ensure_central", base);
assertEquals(central.ok, true);
const centralId = dataId(central as { ok: boolean; data: Record<string, unknown> });
const projectWh = await callCoreFn(db, "core.fn_warehouse_open_project", {
...base,
project_id: fx.projectId,
});
assertEquals(projectWh.ok, true);
const projectWhId = dataId(projectWh as { ok: boolean; data: Record<string, unknown> });
const listed = await callCoreFn(db, "core.fn_warehouse_list", base);
assertEquals(listed.ok, true);
const mat = await callCoreFn(db, "core.fn_warehouse_material_upsert", {
...base,
code: "CEM001",
name: "Cemento",
unit: "BTO",
});
assertEquals(mat.ok, true);
const materialId = dataId(mat as { ok: boolean; data: Record<string, unknown> });
const entry = await callCoreFn(db, "core.fn_warehouse_entry_create", {
...base,
warehouse_id: centralId,
material_id: materialId,
quantity: 50,
unit_cost: 120,
created_by_id: 1,
created_by_name: "Tester",
});
assertEquals(entry.ok, true);
assertEquals(entry.code, "CREATED");
const stockCentral = await callCoreFn(db, "core.fn_warehouse_stock_list", {
...base,
warehouse_id: centralId,
global: false,
});
assertEquals(stockCentral.ok, true);
const transfer = await callCoreFn(db, "core.fn_warehouse_transfer_create", {
...base,
from_warehouse_id: centralId,
to_warehouse_id: projectWhId,
material_id: materialId,
quantity: 20,
created_by_name: "Tester",
});
assertEquals(transfer.ok, true);
const exit = await callCoreFn(db, "core.fn_warehouse_exit_create", {
...base,
warehouse_id: projectWhId,
material_id: materialId,
quantity: 5,
budget_item_id: fx.budgetItemId,
destination_note: "Uso en obra",
created_by_id: 1,
created_by_name: "Tester",
});
assertEquals(exit.ok, true);
const movements = await callCoreFn(db, "core.fn_warehouse_movement_list", {
warehouse_id: projectWhId,
});
assertEquals(movements.ok, true);
const globalStock = await callCoreFn(db, "core.fn_warehouse_stock_list", {
...base,
global: true,
});
assertEquals(globalStock.ok, true);
});
});
Deno.test("RPC control presupuestal: summary, items, chapters, deviations", async () => {
await withTestDb(async (db) => {
const fx = await seedFixture(db);
const base = { tenant_id: TENANT_ID, project_id: fx.projectId };
await callCoreFn(db, "core.fn_expense_create", {
...base,
company_id: fx.companyId,
description: "Gasto para control",
subtotal: 500,
total: 580,
counts_toward_budget: true,
created_by_id: 1,
created_by_name: "Tester",
});
for (const fn of [
"core.fn_cost_control_project_summary",
"core.fn_cost_control_by_item",
"core.fn_cost_control_by_chapter",
"core.fn_cost_control_top_deviations",
]) {
const env = await callCoreFn(db, fn, base);
assertEquals(env.ok, true, `${fn} debe responder ok`);
}
});
});
Deno.test("RPC IAM: usuarios y permisos", async () => {
const url = Deno.env.get("DATABASE_URL_IAM");
if (!url) throw new Error("DATABASE_URL_IAM requerido");
const pool = createPool(url, { max: 2 });
const ROLLBACK = Symbol("test-rollback");
try {
await pool.begin(async (tx) => {
const db = new PgDbClass(tx as never);
await tx`SELECT set_config('app.tenant_id', ${String(TENANT_ID)}, true)`;
const users = await db.prepare(`SELECT iam.fn_user_list($1::jsonb) AS result`).get({
tenant_id: TENANT_ID,
}) as { result: { ok: boolean } };
assertEquals(users.result.ok, true);
const perms = await db.prepare(`SELECT iam.fn_permission_list($1::jsonb) AS result`).get({}) as {
result: { ok: boolean; data: { permissions: unknown[] } };
};
assertEquals(perms.result.ok, true);
assert(perms.result.data.permissions.length > 0);
const roleGet = await db.prepare(`SELECT iam.fn_role_permissions_get($1::jsonb) AS result`).get({
role_code: "user",
}) as { result: { ok: boolean } };
assertEquals(roleGet.result.ok, true);
const roleSave = await db.prepare(`SELECT iam.fn_role_permissions_save($1::jsonb) AS result`).get({
role_code: "user",
permissions: ["view_expenses", "view_warehouse", "manage_workers", "manage_documents", "view_reports"],
}) as { result: { ok: boolean } };
assertEquals(roleSave.result.ok, true);
throw ROLLBACK;
});
} catch (e) {
if (e !== ROLLBACK) throw e;
} finally {
await pool.end({ timeout: 1 });
}
});
Deno.test({
name: "cleanup test pool",
sanitizeResources: false,
sanitizeOps: false,
}, async () => {
await closeTestPool();
});

View file

@ -33,7 +33,5 @@
<include file="changesets/024-rpc-cost-control.sql" relativeToChangelogFile="true"/> <include file="changesets/024-rpc-cost-control.sql" relativeToChangelogFile="true"/>
<include file="changesets/025-rpc-payroll-cost-sync.sql" relativeToChangelogFile="true"/> <include file="changesets/025-rpc-payroll-cost-sync.sql" relativeToChangelogFile="true"/>
<include file="changesets/026-rpc-project-update-warehouse.sql" relativeToChangelogFile="true"/> <include file="changesets/026-rpc-project-update-warehouse.sql" relativeToChangelogFile="true"/>
<include file="changesets/027-iam-rpc-cross-grants.sql" relativeToChangelogFile="true"/>
<include file="changesets/028-fix-cost-settings-ambiguous.sql" relativeToChangelogFile="true"/>
</databaseChangeLog> </databaseChangeLog>

View file

@ -1,9 +0,0 @@
--liquibase formatted sql
-- PANELS · core · permisos cruzados para RPC iam que usan core.rpc_*
-- (debe ejecutarse como panels_core_owner; iam_owner no puede GRANT en core)
--changeset panel:core-027a-iam-rpc-cross-grants endDelimiter:; splitStatements:true
GRANT USAGE ON SCHEMA core TO panels_iam_app;
GRANT EXECUTE ON FUNCTION core.rpc_ok(jsonb, text, jsonb) TO panels_iam_app;
GRANT EXECUTE ON FUNCTION core.rpc_err(text, text, jsonb, jsonb) TO panels_iam_app;
GRANT EXECUTE ON FUNCTION core.rpc_from_exception(text, text, text, text) TO panels_iam_app;

View file

@ -1,88 +0,0 @@
--liquibase formatted sql
-- PANELS · core · fix ambigüedad de columnas en _cost_settings (RETURNS TABLE vs tenant_cost_settings)
--changeset panel:core-028a-fix-cost-settings splitStatements:false
CREATE OR REPLACE FUNCTION core._cost_settings(p_tenant_id integer)
RETURNS TABLE (
budget_warn_pct numeric,
budget_critical_pct numeric,
default_iva_rate numeric
)
LANGUAGE plpgsql
STABLE
SET search_path = core
AS $$
BEGIN
RETURN QUERY
SELECT
COALESCE(s.budget_warn_pct, 85::numeric),
COALESCE(s.budget_critical_pct, 100::numeric),
COALESCE(s.default_iva_rate, 0.16::numeric)
FROM (
SELECT t.budget_warn_pct, t.budget_critical_pct, t.default_iva_rate
FROM tenant_cost_settings t
WHERE t.tenant_id = p_tenant_id
) s
UNION ALL
SELECT 85::numeric, 100::numeric, 0.16::numeric
WHERE NOT EXISTS (SELECT 1 FROM tenant_cost_settings WHERE tenant_id = p_tenant_id)
LIMIT 1;
END;
$$;
--changeset panel:core-028b-fix-cost-budget-warning splitStatements:false
CREATE OR REPLACE FUNCTION core._cost_budget_warning(
p_tenant_id integer,
p_budget_item_id bigint,
p_add_subtotal numeric DEFAULT 0,
p_add_qty numeric DEFAULT 0
)
RETURNS jsonb
LANGUAGE plpgsql
STABLE
SET search_path = core
AS $$
DECLARE
v_item record;
v_warn numeric;
v_critical numeric;
v_exec_amt numeric;
v_exec_qty numeric;
v_pct numeric;
v_level text;
BEGIN
IF p_budget_item_id IS NULL THEN
RETURN NULL;
END IF;
SELECT id, description, amount, quantity INTO v_item
FROM budget_items WHERE id = p_budget_item_id;
IF NOT FOUND OR COALESCE(v_item.amount, 0) <= 0 THEN
RETURN NULL;
END IF;
SELECT cs.budget_warn_pct, cs.budget_critical_pct INTO v_warn, v_critical
FROM core._cost_settings(p_tenant_id) cs;
v_exec_amt := core._cost_executed_amount(p_budget_item_id) + COALESCE(p_add_subtotal, 0);
v_exec_qty := core._cost_executed_qty(p_budget_item_id) + COALESCE(p_add_qty, 0);
v_pct := round(v_exec_amt / v_item.amount * 100, 1);
IF v_pct > v_critical THEN
v_level := 'critical';
ELSIF v_pct > v_warn THEN
v_level := 'warn';
ELSE
RETURN NULL;
END IF;
RETURN jsonb_build_object(
'level', v_level,
'budget_item_id', p_budget_item_id,
'pct_after', v_pct,
'executed_amount', v_exec_amt,
'budget_amount', v_item.amount,
'executed_qty', v_exec_qty,
'budget_qty', v_item.quantity,
'message', format(
'Esta operación dejará el concepto "%s" al %s%% del presupuesto (importe)',
left(v_item.description, 80), v_pct
)
);
END;
$$;

View file

@ -108,6 +108,10 @@ END;
$$; $$;
--changeset panel:iam-004e-iam-rpc-grants endDelimiter:; splitStatements:true --changeset panel:iam-004e-iam-rpc-grants endDelimiter:; splitStatements:true
GRANT USAGE ON SCHEMA core TO panels_iam_app;
GRANT EXECUTE ON FUNCTION core.rpc_ok(jsonb, text, jsonb) TO panels_iam_app;
GRANT EXECUTE ON FUNCTION core.rpc_err(text, text, jsonb, jsonb) TO panels_iam_app;
GRANT EXECUTE ON FUNCTION core.rpc_from_exception(text, text, text, text) TO panels_iam_app;
GRANT EXECUTE ON FUNCTION iam.fn_user_list(jsonb) TO panels_iam_app; GRANT EXECUTE ON FUNCTION iam.fn_user_list(jsonb) TO panels_iam_app;
GRANT EXECUTE ON FUNCTION iam.fn_permission_list(jsonb) TO panels_iam_app; GRANT EXECUTE ON FUNCTION iam.fn_permission_list(jsonb) TO panels_iam_app;
GRANT EXECUTE ON FUNCTION iam.fn_role_permissions_get(jsonb) TO panels_iam_app; GRANT EXECUTE ON FUNCTION iam.fn_role_permissions_get(jsonb) TO panels_iam_app;

View file

@ -170,138 +170,15 @@ BODY=$(echo "$R" | head -n -1); CODE=$(echo "$R" | tail -1)
check_status "expenses list" "200" "$CODE" "$BODY" check_status "expenses list" "200" "$CODE" "$BODY"
echo "$BODY" | grep -q '"ok":true' || fail "expenses list envelope" echo "$BODY" | grep -q '"ok":true' || fail "expenses list envelope"
echo "=== Expenses CREATE ==="
R=$(curl -s -w "\n%{http_code}" -b "$JAR" -X POST "$BASE/v1/expenses" \
-H "Content-Type: application/json" \
-d "{
\"company_id\": 1,
\"project_id\": ${PROJECT_ID},
\"description\": \"Gasto smoke test ${SUFFIX}\",
\"subtotal\": 1000,
\"total\": 1160,
\"tax_included\": false,
\"is_deductible\": true,
\"counts_toward_budget\": true
}")
BODY=$(echo "$R" | head -n -1); CODE=$(echo "$R" | tail -1)
check_status "expense create" "201" "$CODE" "$BODY"
EXPENSE_ID=$(echo "$BODY" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
[[ -n "$EXPENSE_ID" ]] || fail "no expense id"
echo "=== Expenses GET ==="
R=$(curl -s -w "\n%{http_code}" -b "$JAR" "$BASE/v1/expenses/$EXPENSE_ID")
BODY=$(echo "$R" | head -n -1); CODE=$(echo "$R" | tail -1)
check_status "expense get" "200" "$CODE" "$BODY"
echo "=== Expenses PATCH ==="
R=$(curl -s -w "\n%{http_code}" -b "$JAR" -X PATCH "$BASE/v1/expenses/$EXPENSE_ID" \
-H "Content-Type: application/json" \
-d '{"description":"Gasto smoke editado","notes":"ok"}')
BODY=$(echo "$R" | head -n -1); CODE=$(echo "$R" | tail -1)
check_status "expense patch" "200" "$CODE" "$BODY"
echo "=== IVA summary ==="
R=$(curl -s -w "\n%{http_code}" -b "$JAR" "$BASE/v1/iva/summary?company_id=1&year=2026&month=1")
BODY=$(echo "$R" | head -n -1); CODE=$(echo "$R" | tail -1)
check_status "iva summary" "200" "$CODE" "$BODY"
echo "=== Cost settings GET/PUT ==="
R=$(curl -s -w "\n%{http_code}" -b "$JAR" "$BASE/v1/cost-settings")
BODY=$(echo "$R" | head -n -1); CODE=$(echo "$R" | tail -1)
check_status "cost settings get" "200" "$CODE" "$BODY"
R=$(curl -s -w "\n%{http_code}" -b "$JAR" -X PUT "$BASE/v1/cost-settings" \
-H "Content-Type: application/json" \
-d '{"budget_warn_pct":85,"budget_critical_pct":100,"default_iva_rate":0.16}')
BODY=$(echo "$R" | head -n -1); CODE=$(echo "$R" | tail -1)
check_status "cost settings put" "200" "$CODE" "$BODY"
echo "=== Warehouses LIST ===" echo "=== Warehouses LIST ==="
R=$(curl -s -w "\n%{http_code}" -b "$JAR" "$BASE/v1/warehouses") R=$(curl -s -w "\n%{http_code}" -b "$JAR" "$BASE/v1/warehouses")
BODY=$(echo "$R" | head -n -1); CODE=$(echo "$R" | tail -1) BODY=$(echo "$R" | head -n -1); CODE=$(echo "$R" | tail -1)
check_status "warehouses list" "200" "$CODE" "$BODY" check_status "warehouses list" "200" "$CODE" "$BODY"
echo "=== Warehouse central ensure ===" echo "=== Cost control (project 1) ==="
R=$(curl -s -w "\n%{http_code}" -b "$JAR" -X POST "$BASE/v1/warehouses/central/ensure") R=$(curl -s -w "\n%{http_code}" -b "$JAR" "$BASE/v1/projects/1/cost-control/summary")
BODY=$(echo "$R" | head -n -1); CODE=$(echo "$R" | tail -1)
check_status "warehouse central ensure" "200" "$CODE" "$BODY"
CENTRAL_ID=$(echo "$BODY" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
[[ -n "$CENTRAL_ID" ]] || fail "no central warehouse id"
echo "=== Project warehouse open ==="
R=$(curl -s -w "\n%{http_code}" -b "$JAR" -X POST "$BASE/v1/projects/$PROJECT_ID/warehouse/open")
BODY=$(echo "$R" | head -n -1); CODE=$(echo "$R" | tail -1)
check_status "project warehouse open" "200" "$CODE" "$BODY"
PROJECT_WH_ID=$(echo "$BODY" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
[[ -n "$PROJECT_WH_ID" ]] || fail "no project warehouse id"
echo "=== Warehouse material ==="
R=$(curl -s -w "\n%{http_code}" -b "$JAR" -X POST "$BASE/v1/warehouse-materials" \
-H "Content-Type: application/json" \
-d "{\"code\":\"MAT${SUFFIX}\",\"name\":\"Material smoke\",\"unit\":\"PZA\"}")
BODY=$(echo "$R" | head -n -1); CODE=$(echo "$R" | tail -1)
check_status "warehouse material upsert" "200" "$CODE" "$BODY"
MATERIAL_ID=$(echo "$BODY" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
[[ -n "$MATERIAL_ID" ]] || fail "no material id"
echo "=== Warehouse entry ==="
R=$(curl -s -w "\n%{http_code}" -b "$JAR" -X POST "$BASE/v1/warehouses/$CENTRAL_ID/entries" \
-H "Content-Type: application/json" \
-d "{\"material_id\":${MATERIAL_ID},\"quantity\":10,\"unit_cost\":50}")
BODY=$(echo "$R" | head -n -1); CODE=$(echo "$R" | tail -1)
check_status "warehouse entry" "201" "$CODE" "$BODY"
echo "=== Warehouse transfer ==="
R=$(curl -s -w "\n%{http_code}" -b "$JAR" -X POST "$BASE/v1/warehouse-transfers" \
-H "Content-Type: application/json" \
-d "{\"from_warehouse_id\":${CENTRAL_ID},\"to_warehouse_id\":${PROJECT_WH_ID},\"material_id\":${MATERIAL_ID},\"quantity\":3}")
BODY=$(echo "$R" | head -n -1); CODE=$(echo "$R" | tail -1)
check_status "warehouse transfer" "201" "$CODE" "$BODY"
echo "=== Warehouse stock ==="
R=$(curl -s -w "\n%{http_code}" -b "$JAR" "$BASE/v1/warehouses/$PROJECT_WH_ID/stock")
BODY=$(echo "$R" | head -n -1); CODE=$(echo "$R" | tail -1)
check_status "warehouse stock" "200" "$CODE" "$BODY"
R=$(curl -s -w "\n%{http_code}" -b "$JAR" "$BASE/v1/warehouses/stock/global")
BODY=$(echo "$R" | head -n -1); CODE=$(echo "$R" | tail -1)
check_status "warehouse global stock" "200" "$CODE" "$BODY"
echo "=== Warehouse movements ==="
R=$(curl -s -w "\n%{http_code}" -b "$JAR" "$BASE/v1/warehouses/$PROJECT_WH_ID/movements")
BODY=$(echo "$R" | head -n -1); CODE=$(echo "$R" | tail -1)
check_status "warehouse movements" "200" "$CODE" "$BODY"
echo "=== Cost control (project) ==="
R=$(curl -s -w "\n%{http_code}" -b "$JAR" "$BASE/v1/projects/$PROJECT_ID/cost-control/summary")
BODY=$(echo "$R" | head -n -1); CODE=$(echo "$R" | tail -1) BODY=$(echo "$R" | head -n -1); CODE=$(echo "$R" | tail -1)
check_status "cost control summary" "200" "$CODE" "$BODY" check_status "cost control summary" "200" "$CODE" "$BODY"
R=$(curl -s -w "\n%{http_code}" -b "$JAR" "$BASE/v1/projects/$PROJECT_ID/cost-control/items")
BODY=$(echo "$R" | head -n -1); CODE=$(echo "$R" | tail -1)
check_status "cost control items" "200" "$CODE" "$BODY"
R=$(curl -s -w "\n%{http_code}" -b "$JAR" "$BASE/v1/projects/$PROJECT_ID/cost-control/chapters")
BODY=$(echo "$R" | head -n -1); CODE=$(echo "$R" | tail -1)
check_status "cost control chapters" "200" "$CODE" "$BODY"
R=$(curl -s -w "\n%{http_code}" -b "$JAR" "$BASE/v1/projects/$PROJECT_ID/cost-control/deviations")
BODY=$(echo "$R" | head -n -1); CODE=$(echo "$R" | tail -1)
check_status "cost control deviations" "200" "$CODE" "$BODY"
echo "=== IAM endpoints ==="
R=$(curl -s -w "\n%{http_code}" -b "$JAR" "$BASE/v1/me/permissions")
BODY=$(echo "$R" | head -n -1); CODE=$(echo "$R" | tail -1)
check_status "me permissions" "200" "$CODE" "$BODY"
R=$(curl -s -w "\n%{http_code}" -b "$JAR" "$BASE/v1/iam/permissions")
BODY=$(echo "$R" | head -n -1); CODE=$(echo "$R" | tail -1)
check_status "iam permissions" "200" "$CODE" "$BODY"
R=$(curl -s -w "\n%{http_code}" -b "$JAR" "$BASE/v1/iam/users")
BODY=$(echo "$R" | head -n -1); CODE=$(echo "$R" | tail -1)
check_status "iam users" "200" "$CODE" "$BODY"
R=$(curl -s -w "\n%{http_code}" -b "$JAR" "$BASE/v1/iam/roles/user/permissions")
BODY=$(echo "$R" | head -n -1); CODE=$(echo "$R" | tail -1)
check_status "iam role permissions get" "200" "$CODE" "$BODY"
echo "=== Expenses DELETE (void) ==="
R=$(curl -s -w "\n%{http_code}" -b "$JAR" -X DELETE "$BASE/v1/expenses/$EXPENSE_ID")
BODY=$(echo "$R" | head -n -1); CODE=$(echo "$R" | tail -1)
check_status "expense void" "200" "$CODE" "$BODY"
echo "" echo ""
echo "All CRUD smoke tests passed." echo "All CRUD smoke tests passed."