From 130decd48cec7efd3cb4c6bc13900ae1889977f9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 4 Sep 2026 04:17:47 +0000 Subject: [PATCH] =?UTF-8?q?test:=20validar=20endpoints=20y=20RPCs=20de=20g?= =?UTF-8?q?astos,=20almac=C3=A9n=20e=20IAM?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Añade cost_modules_test.ts con CRUD de gastos, almacén, control presupuestal e IAM - Amplía crud-smoke-test.sh para cubrir todos los endpoints HTTP nuevos - Corrige migración IAM: grants de core.rpc_* movidos a changeset core-027 - Corrige ambigüedad PL/pgSQL en _cost_settings que rompía fn_expense_create Co-authored-by: alberto.martinez --- api/cost_modules_test.ts | 283 ++++++++++++++++++ db/core/changelog-master.xml | 2 + .../changesets/027-iam-rpc-cross-grants.sql | 9 + .../028-fix-cost-settings-ambiguous.sql | 88 ++++++ .../changesets/004-rpc-users-permissions.sql | 4 - scripts/crud-smoke-test.sh | 127 +++++++- 6 files changed, 507 insertions(+), 6 deletions(-) create mode 100644 api/cost_modules_test.ts create mode 100644 db/core/changesets/027-iam-rpc-cross-grants.sql create mode 100644 db/core/changesets/028-fix-cost-settings-ambiguous.sql diff --git a/api/cost_modules_test.ts b/api/cost_modules_test.ts new file mode 100644 index 0000000..831b3b5 --- /dev/null +++ b/api/cost_modules_test.ts @@ -0,0 +1,283 @@ +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 { + 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 }, key = "id"): number { + assert(env.ok, `RPC falló: ${JSON.stringify(env)}`); + const data = env.data as Record; + const nested = data.expense ?? data.warehouse ?? data; + const id = (nested as Record)[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 }); + + 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 }); + + 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 }); + + 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 }); + + 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(); +}); diff --git a/db/core/changelog-master.xml b/db/core/changelog-master.xml index 45c7c9d..11c82e7 100644 --- a/db/core/changelog-master.xml +++ b/db/core/changelog-master.xml @@ -33,5 +33,7 @@ + + diff --git a/db/core/changesets/027-iam-rpc-cross-grants.sql b/db/core/changesets/027-iam-rpc-cross-grants.sql new file mode 100644 index 0000000..1d6cd8c --- /dev/null +++ b/db/core/changesets/027-iam-rpc-cross-grants.sql @@ -0,0 +1,9 @@ +--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; diff --git a/db/core/changesets/028-fix-cost-settings-ambiguous.sql b/db/core/changesets/028-fix-cost-settings-ambiguous.sql new file mode 100644 index 0000000..48c7b05 --- /dev/null +++ b/db/core/changesets/028-fix-cost-settings-ambiguous.sql @@ -0,0 +1,88 @@ +--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; +$$; diff --git a/db/iam/changesets/004-rpc-users-permissions.sql b/db/iam/changesets/004-rpc-users-permissions.sql index 9e10e51..f0c49f0 100644 --- a/db/iam/changesets/004-rpc-users-permissions.sql +++ b/db/iam/changesets/004-rpc-users-permissions.sql @@ -108,10 +108,6 @@ END; $$; --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_permission_list(jsonb) TO panels_iam_app; GRANT EXECUTE ON FUNCTION iam.fn_role_permissions_get(jsonb) TO panels_iam_app; diff --git a/scripts/crud-smoke-test.sh b/scripts/crud-smoke-test.sh index 3652872..2ca1f2b 100755 --- a/scripts/crud-smoke-test.sh +++ b/scripts/crud-smoke-test.sh @@ -170,15 +170,138 @@ BODY=$(echo "$R" | head -n -1); CODE=$(echo "$R" | tail -1) check_status "expenses list" "200" "$CODE" "$BODY" 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 ===" R=$(curl -s -w "\n%{http_code}" -b "$JAR" "$BASE/v1/warehouses") BODY=$(echo "$R" | head -n -1); CODE=$(echo "$R" | tail -1) check_status "warehouses list" "200" "$CODE" "$BODY" -echo "=== Cost control (project 1) ===" -R=$(curl -s -w "\n%{http_code}" -b "$JAR" "$BASE/v1/projects/1/cost-control/summary") +echo "=== Warehouse central ensure ===" +R=$(curl -s -w "\n%{http_code}" -b "$JAR" -X POST "$BASE/v1/warehouses/central/ensure") +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) 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 "All CRUD smoke tests passed."