From ebe8d77e457bddc4b9ea04a7ae9064183cc061d1 Mon Sep 17 00:00:00 2001 From: Jason Lee <56489493+jason931225@users.noreply.github.com> Date: Thu, 20 Aug 2026 03:11:50 -0400 Subject: [PATCH 1/4] feat(payroll): give payroll_draft_lines a production writer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `payroll_draft_lines` had no production writer at all. Its only writer was `scripts/stage_coss_group_payroll_readiness.sql`, a hand-run operational script — so every payroll run production code created had an empty roster, and once the close preflight learned to require `roster_total > 0` (#833), could never close. PayRun, the last step of the product order, was not runnable by the application. `roster::materialise_roster_in_tx` is a PORT of that script, called from `stage_draft_run_inner` so a run and its roster are created in one transaction. Deriving a second mapping alongside the script is how the two drift; that is the failure a previous bead was killed for. Four deliberate differences, each with a reason recorded at the call site: 1. SCOPE IS THE DECLARED PAY PERIOD, BY EQUALITY. The script scoped with `source_filename LIKE '2026/5월/%'` -- one operator's folder layout, and the only thing keeping the wrong month out of a roster. Migration 0224's `pay_period_*` replaces it. Equality, not overlap: an import declared for May is material for the May run, not for a run that straddles May. 2. NO `leave_remaining` ADMISSION DISJUNCT. The script admitted an employee with leave and no imported rows. Such a line carries no evidence, so it can only ever block the close it is counted toward. 3. NO RECONCILIATION DELETE. 0222 revoked DELETE on payroll_draft_lines from `console_rt` and asserts the revocation, so a delete raises 42501 at PLAN time and would kill every `payroll.create_run`, not just the re-stage. Retraction is a separate design. 4. THE EMPLOYEE-DRIVEN GROUPING IS KEPT. Review advised deleting it; that is wrong and the review's own residual-risk note says why. `data_import_rows.source_key` is `filename:…|sheet:…|row:…`, so grouping on it yields one line per SPREADSHEET ROW. The person key is `canonical_row->>'source_key'` joined to `employees.source_key`. A test pins it: two rows for one person make one line. CALLED FROM ALL THREE SUCCESS PATHS, never gated on `created`. A run whose header exists but whose roster was never written -- a previous attempt dying between the two -- would otherwise never acquire one, and is unclosable forever with no repair. A draft with no declared period writes nothing: there is no scope, and guessing one is the fabricated provenance 0224 removes. AN EMPTY ROSTER IS NOT AN ERROR. The drain leaves a failed event PENDING without incrementing `attempt_count`, so returning Err would be an unbounded hot retry. `close_preflight` already refuses an empty roster legibly with `명세 대상 없음(로스터 0명)`, which is where an operator should meet it. Seven tests against real PostgreSQL, each a NEAR-MISS with an exact count. The fixture SATISFIES 0166's writer guard rather than routing around it -- `console_leave_definer`, an armed `app.current_org`, and a same-transaction `data_import.apply` audit row -- and asserts the run actually reached APPLIED, because without the org GUC the transition matched zero rows and succeeded SILENTLY. Mutation-proven, every property: equality -> overlap -> the different-period test FAILS drop `run.status = 'APPLIED'` -> the unapplied test FAILS drop `row_status <> 'ERROR'` -> the same test FAILS non-blank -> key presence (all four flags) -> the blank-cells test FAILS admit everyone -> 4 of 7 FAIL restored -> 7 passed UNPROVEN AFTER THIS LANDS, and worth naming: `attendance_event_count` still has no writer, so 근태 원천 확보 is attested from payroll-workbook columns alone. The pay period is attributed and frozen but never verified against the rows it scopes. And one near-miss is weaker than I wanted: `employees.leave_remaining` cannot be set from a test (42501 `leave_write.command_required`), so the no-material case is proven without the leave balance that the deleted disjunct keyed on. The script is NOT retired here. G008's three text pins are still the only mechanical proof of the APPLIED / non-ERROR filters; retiring the script without moving them would leave that gate green over a file nothing runs. Co-Authored-By: Claude Opus 5 --- backend/crates/payroll/adapter-postgres/BUCK | 31 ++ .../payroll/adapter-postgres/src/lib.rs | 1 + .../payroll/adapter-postgres/src/pay_run.rs | 33 ++ .../payroll/adapter-postgres/src/roster.rs | 224 ++++++++++ .../tests/roster_materialisation.rs | 386 ++++++++++++++++++ docs/program/executed-tests-baseline.json | 1 + tools/buck/gen_first_party.py | 1 + tools/ci/postgres-cargo-map.json | 29 +- 8 files changed, 703 insertions(+), 3 deletions(-) create mode 100644 backend/crates/payroll/adapter-postgres/src/roster.rs create mode 100644 backend/crates/payroll/adapter-postgres/tests/roster_materialisation.rs diff --git a/backend/crates/payroll/adapter-postgres/BUCK b/backend/crates/payroll/adapter-postgres/BUCK index f4768e8ad..f79b8fbf6 100644 --- a/backend/crates/payroll/adapter-postgres/BUCK +++ b/backend/crates/payroll/adapter-postgres/BUCK @@ -154,3 +154,34 @@ rust_test( ":console-payroll-adapter-postgres", ], ) + +rust_test( + name = "console-payroll-adapter-postgres-itest-roster_materialisation", + mapped_srcs = repo_mapped_srcs("backend/crates/payroll/adapter-postgres", ["tests/roster_materialisation.rs"], external = { + "//backend/crates/platform/db/migrations:tree": "backend/crates/platform/db/migrations", + }), + crate = "roster_materialisation", + edition = "2024", + crate_root = "backend/crates/payroll/adapter-postgres/tests/roster_materialisation.rs", + visibility = ["PUBLIC"], + env = {"CARGO_MANIFEST_DIR": "backend/crates/payroll/adapter-postgres", "SQLX_OFFLINE": "true", "SQLX_OFFLINE_DIR": "$(location //backend:sqlx-offline)"}, + labels = ["owner.backend.crates.payroll.adapter-postgres", "domain.payroll", "test.integration", "resource.postgres", "needs-postgres"], + deps = [ + "//backend/crates/kernel/core:console-kernel-core", + "//backend/crates/ontology/canonical-domain:console-ontology-canonical-domain", + "//backend/crates/payroll/domain:console-payroll-domain", + "//backend/crates/platform/db:console-platform-db", + "//backend/crates/platform/request-context:console-platform-request-context", + "//backend/crates/platform/test-support:console-platform-test-support", + "//backend/crates/workflow/domain:console-workflow-domain", + "//third-party/rust:serde", + "//third-party/rust:serde_json", + "//third-party/rust:sha2", + "//third-party/rust:sqlx", + "//third-party/rust:thiserror", + "//third-party/rust:time", + "//third-party/rust:tokio", + "//third-party/rust:uuid", + ":console-payroll-adapter-postgres", + ], +) diff --git a/backend/crates/payroll/adapter-postgres/src/lib.rs b/backend/crates/payroll/adapter-postgres/src/lib.rs index 97fd28576..a751052c7 100644 --- a/backend/crates/payroll/adapter-postgres/src/lib.rs +++ b/backend/crates/payroll/adapter-postgres/src/lib.rs @@ -15,6 +15,7 @@ pub mod lifecycle; /// `console-workflow-runtime-adapter-postgres`. pub mod pay_run; pub mod payslip_draft; +pub mod roster; use console_kernel_core::{ErrorKind, KernelError, UserId}; use console_platform_db::{DbError, with_org_conn}; diff --git a/backend/crates/payroll/adapter-postgres/src/pay_run.rs b/backend/crates/payroll/adapter-postgres/src/pay_run.rs index 700a130d6..922f6a611 100644 --- a/backend/crates/payroll/adapter-postgres/src/pay_run.rs +++ b/backend/crates/payroll/adapter-postgres/src/pay_run.rs @@ -209,6 +209,36 @@ const INSERT_DRAFT_RUN_GATED_SQL: &str = "INSERT INTO payroll_draft_runs \ /// a genuinely NEW write ([`INSERT_DRAFT_RUN_GATED_SQL`]); when that gate /// refuses, no row comes back and the caller sees [`StageDraftError::PeriodLocked`]. /// The provenance check runs on every conflict either way. +/// Materialise the roster for a staged run, in the caller's transaction. +/// +/// Called from EVERY success path of `stage_draft_run_inner`, including the +/// idempotent one that returns `created = false`. Gating this on `created` would +/// mean a run whose header exists but whose roster was never written — because a +/// previous attempt died between the two — could never acquire one, and after the +/// close preflight learned to require `roster_total > 0` that run is stuck +/// forever with no way to repair it. +/// +/// An empty roster is NOT an error here. The drain leaves a failed event PENDING +/// without incrementing `attempt_count`, so returning `Err` would be an unbounded +/// hot retry; `close_preflight` already refuses an empty roster legibly. +async fn materialise_roster_for( + tx: &mut Transaction<'_, Postgres>, + org_id: Uuid, + id: Uuid, + draft: &StagePayrollDraft, +) -> Result<(), StageDraftError> { + // A draft with no declared period has no scope, so there is no set of import + // rows it could honestly claim. Writing nothing is the only truthful option: + // guessing a period is exactly the fabricated provenance migration 0224 exists + // to remove. The run is still created; the close preflight refuses its empty + // roster legibly. + let (Some(period_start), Some(period_end)) = (draft.period_start, draft.period_end) else { + return Ok(()); + }; + crate::roster::materialise_roster_in_tx(tx, org_id, id, period_start, period_end).await?; + Ok(()) +} + async fn stage_draft_run_inner( tx: &mut Transaction<'_, Postgres>, org_id: Uuid, @@ -247,6 +277,7 @@ async fn stage_draft_run_inner( if !provenance_matches(&stored, &requested) { return Err(StageDraftError::ProvenanceMismatch); } + materialise_roster_for(tx, org_id, id, draft).await?; return Ok((id, false)); } // No existing draft: the freeze-window gate applies to this NEW write. @@ -265,6 +296,7 @@ async fn stage_draft_run_inner( if !created && !provenance_matches(&stored, &requested) { return Err(StageDraftError::ProvenanceMismatch); } + materialise_roster_for(tx, org_id, id, draft).await?; Ok((id, created)) } else { let row: (Uuid, serde_json::Value, bool) = sqlx::query_as(INSERT_DRAFT_RUN_SQL) @@ -279,6 +311,7 @@ async fn stage_draft_run_inner( if !created && !provenance_matches(&stored, &requested) { return Err(StageDraftError::ProvenanceMismatch); } + materialise_roster_for(tx, org_id, id, draft).await?; Ok((id, created)) } } diff --git a/backend/crates/payroll/adapter-postgres/src/roster.rs b/backend/crates/payroll/adapter-postgres/src/roster.rs new file mode 100644 index 000000000..54d6cbbb6 --- /dev/null +++ b/backend/crates/payroll/adapter-postgres/src/roster.rs @@ -0,0 +1,224 @@ +//! Materialise a payroll run's roster from the governed import ledger. +//! +//! `payroll_draft_lines` had no production writer at all. Its only writer was +//! `scripts/stage_coss_group_payroll_readiness.sql`, a hand-run operational +//! script, so a payroll run created by production code always had an empty +//! roster — and after the close preflight learned to require `roster_total > 0`, +//! could never close. +//! +//! # This is a PORT, not a new mapping +//! +//! Every column below is derived from that script, which is the existing +//! encoding of how a roster is built. Inventing a second mapping alongside it is +//! how the two drift, and it is the exact failure a previous bead was killed for. +//! Four deliberate differences, each with a reason: +//! +//! 1. SCOPE IS THE DECLARED PAY PERIOD, BY EQUALITY. The script scoped with +//! `source_filename LIKE '2026/5월/%'` — one operator's folder layout, and the +//! only thing keeping material from the wrong month out of a roster. +//! `data_import_runs.pay_period_*` (migration 0224) replaces it. Equality, not +//! overlap: an import declared for May is material for the May run, not for a +//! run that happens to straddle May. +//! 2. NO `leave_remaining` ADMISSION DISJUNCT. The script admitted an employee +//! with `leave_remaining > 0` and no imported rows at all. Such a line carries +//! no attendance and no payroll evidence, so it exists only to be counted — +//! and since the close preflight now blocks on lines lacking attendance +//! material, admitting them turns a real gate into a queue of blockers nobody +//! can clear. +//! 3. NO RECONCILIATION DELETE. Migration 0222 revoked DELETE on +//! `payroll_draft_lines` from `console_rt` and asserts the revocation, so a +//! delete would raise 42501 at PLAN time and kill every `payroll.create_run`, +//! not just the re-stage. Retraction of a stale line is a separate design. +//! 4. THE EMPLOYEE-DRIVEN GROUPING IS KEPT, deliberately. +//! `data_import_rows.source_key` is `filename:…|sheet:…|row:…` — per ROW. +//! Grouping on it would produce one roster line per spreadsheet row rather +//! than per person. The person key is `canonical_row->>'source_key'`, joined +//! to `employees.source_key`, which is what makes this a roster of people. + +use sqlx::{Postgres, Transaction}; +use time::Date; +use uuid::Uuid; + +/// One `INSERT … SELECT … ON CONFLICT` — the roster is derived in the database, +/// in the caller's transaction, so a run and its roster are created together or +/// not at all. +const MATERIALISE_ROSTER_SQL: &str = r#" +WITH import_rows AS ( + SELECT + r.id, + r.org_id, + r.source_sheet, + r.source_row, + run.source_filename, + COALESCE(NULLIF(r.canonical_row->>'source_key', ''), NULLIF(r.source_key, '')) AS canonical_source_key, + r.raw_row, + (jsonb_typeof(r.raw_row) = 'object' AND EXISTS ( + SELECT 1 FROM jsonb_each_text(r.raw_row) kv + WHERE kv.key = ANY (array['기본시급','통상시급','공제총액','소득세','건강보험','건강/장기요양','고용보험','급여산정일','지급일','연차수당','상여금','은행','계좌','주민번호']) + AND btrim(kv.value) <> '' + )) AS is_payroll_source, + (jsonb_typeof(r.raw_row) = 'object' AND EXISTS ( + SELECT 1 FROM jsonb_each_text(r.raw_row) kv + WHERE kv.key = ANY (array['근무일자','출근','퇴근','근무시간','기본시간','기본근무','연장시간','심야시간','특근시간','특근연장시간','특근연장','근무일명칭','지각,조퇴시간']) + AND btrim(kv.value) <> '' + )) AS is_attendance_source, + (jsonb_typeof(r.raw_row) = 'object' AND EXISTS ( + SELECT 1 FROM jsonb_each_text(r.raw_row) kv + WHERE kv.key = ANY (array['기본급','상여금','월계','합계','총합','연차수당']) + AND btrim(kv.value) <> '' + )) AS has_gross_pay_source, + (jsonb_typeof(r.raw_row) = 'object' AND EXISTS ( + SELECT 1 FROM jsonb_each_text(r.raw_row) kv + WHERE kv.key = ANY (array['차인지급액','실지급액','공제총액','소득세','건강보험','고용보험']) + AND btrim(kv.value) <> '' + )) AS has_net_pay_source + FROM data_import_rows r + JOIN data_import_runs run + ON run.id = r.run_id + AND run.org_id = r.org_id + WHERE run.org_id = $1 + AND run.entity_type = 'employee_hr' + -- Provenance: only material an operator actually applied, and never a row + -- the importer itself rejected. + AND run.status = 'APPLIED' + AND r.row_status <> 'ERROR' + -- Scope: the declared period, by equality. + AND run.pay_period_start = $3 + AND run.pay_period_end = $4 +), row_metrics AS ( + SELECT + ir.*, + CASE WHEN btrim(COALESCE(ir.raw_row->>'근무일수', ir.raw_row->>'근무일', '')) ~ '^-?[0-9]+([.]?[0-9]+)?$' + THEN btrim(COALESCE(ir.raw_row->>'근무일수', ir.raw_row->>'근무일'))::numeric END AS work_days_value, + CASE WHEN btrim(COALESCE(ir.raw_row->>'근무시간', ir.raw_row->>'기본시간', ir.raw_row->>'기본근무', '')) ~ '^-?[0-9]+([.]?[0-9]+)?$' + THEN btrim(COALESCE(ir.raw_row->>'근무시간', ir.raw_row->>'기본시간', ir.raw_row->>'기본근무'))::numeric END AS regular_hours_value, + CASE WHEN btrim(COALESCE(ir.raw_row->>'연장시간', ir.raw_row->>'특근연장시간', ir.raw_row->>'특근연장', '')) ~ '^-?[0-9]+([.]?[0-9]+)?$' + THEN btrim(COALESCE(ir.raw_row->>'연장시간', ir.raw_row->>'특근연장시간', ir.raw_row->>'특근연장'))::numeric END AS overtime_hours_value, + CASE WHEN btrim(COALESCE(ir.raw_row->>'심야시간', '')) ~ '^-?[0-9]+([.]?[0-9]+)?$' + THEN btrim(ir.raw_row->>'심야시간')::numeric END AS night_hours_value, + CASE WHEN btrim(COALESCE(ir.raw_row->>'특근시간', '')) ~ '^-?[0-9]+([.]?[0-9]+)?$' + THEN btrim(ir.raw_row->>'특근시간')::numeric END AS holiday_hours_value, + CASE WHEN btrim(COALESCE(ir.raw_row->>'사용연차', '')) ~ '^-?[0-9]+([.]?[0-9]+)?$' + THEN btrim(ir.raw_row->>'사용연차')::numeric END AS leave_used_value, + CASE WHEN btrim(COALESCE(ir.raw_row->>'잔여연차', '')) ~ '^-?[0-9]+([.]?[0-9]+)?$' + THEN btrim(ir.raw_row->>'잔여연차')::numeric END AS leave_remaining_value + FROM import_rows ir +), employee_basis AS ( + -- The person key. Kept employee-driven ON PURPOSE: `data_import_rows.source_key` + -- is per ROW, so grouping on it would yield one line per spreadsheet row. + SELECT + e.org_id, + e.id AS employee_id, + COALESCE(NULLIF(e.source_key, ''), e.id::text) AS employee_source_key, + e.name AS employee_display_name, + COALESCE(NULLIF(e.company, ''), 'UNKNOWN_COMPANY') AS employee_company, + e.leave_used, + e.leave_remaining + FROM employees e + WHERE e.org_id = $1 +), employee_metrics AS ( + SELECT + eb.org_id, + eb.employee_id, + eb.employee_source_key, + eb.employee_display_name, + eb.employee_company, + count(rm.id) FILTER (WHERE rm.is_payroll_source) AS payroll_source_row_count, + count(rm.id) FILTER (WHERE rm.is_attendance_source) AS attendance_source_row_count, + COALESCE(sum(rm.work_days_value), 0) AS work_days, + COALESCE(sum(rm.regular_hours_value), 0) AS regular_hours, + COALESCE(sum(rm.overtime_hours_value), 0) AS overtime_hours, + COALESCE(sum(rm.night_hours_value), 0) AS night_hours, + COALESCE(sum(rm.holiday_hours_value), 0) AS holiday_hours, + COALESCE(max(rm.leave_used_value), eb.leave_used) AS leave_used, + COALESCE(max(rm.leave_remaining_value), eb.leave_remaining) AS leave_remaining, + bool_or(COALESCE(rm.has_gross_pay_source, FALSE)) AS gross_pay_source_present, + bool_or(COALESCE(rm.has_net_pay_source, FALSE)) AS net_pay_source_present, + COALESCE(array_agg(rm.id ORDER BY rm.source_filename, rm.source_sheet, rm.source_row) + FILTER (WHERE rm.id IS NOT NULL), ARRAY[]::uuid[]) AS source_data_import_row_ids + FROM employee_basis eb + LEFT JOIN row_metrics rm + ON rm.org_id = eb.org_id + AND rm.canonical_source_key = eb.employee_source_key + GROUP BY + eb.org_id, eb.employee_id, eb.employee_source_key, + eb.employee_display_name, eb.employee_company, eb.leave_used, eb.leave_remaining +) +INSERT INTO payroll_draft_lines ( + org_id, run_id, employee_id, employee_source_key, employee_display_name, + employee_company, payroll_source_row_count, attendance_source_row_count, + attendance_event_count, work_days, regular_hours, overtime_hours, night_hours, + holiday_hours, leave_used, leave_remaining, gross_pay_source_present, + net_pay_source_present, nts_tax_row_status, calculation_status, blockers, + source_data_import_row_ids +) +SELECT + em.org_id, $2, em.employee_id, em.employee_source_key, em.employee_display_name, + em.employee_company, em.payroll_source_row_count::integer, + em.attendance_source_row_count::integer, + -- No production writer sets attendance events yet; the script recorded 0 too. + 0, + em.work_days, em.regular_hours, em.overtime_hours, em.night_hours, + em.holiday_hours, em.leave_used, em.leave_remaining, + em.gross_pay_source_present, em.net_pay_source_present, + 'REQUIRED_NOT_SUPPLIED', + 'BLOCKED_LEGAL_GATE', + jsonb_build_array( + 'Payroll calculation remains blocked until an official NTS row and professional validation are attached', + 'HR must review source rows, leave balances, employment status, and statutory insurance applicability before approval', + 'Wage-statement issuance requires approved payroll run, passkey step-up, and immutable audit evidence' + ), + em.source_data_import_row_ids +FROM employee_metrics em +-- Admission: imported material only. The script also admitted anyone with +-- `leave_remaining > 0`; such a line carries no evidence and can only ever block +-- the close. +WHERE em.payroll_source_row_count > 0 + OR em.attendance_source_row_count > 0 +ON CONFLICT (org_id, run_id, employee_source_key) DO UPDATE SET + employee_id = EXCLUDED.employee_id, + employee_display_name = EXCLUDED.employee_display_name, + employee_company = EXCLUDED.employee_company, + payroll_source_row_count = EXCLUDED.payroll_source_row_count, + attendance_source_row_count = EXCLUDED.attendance_source_row_count, + attendance_event_count = EXCLUDED.attendance_event_count, + work_days = EXCLUDED.work_days, + regular_hours = EXCLUDED.regular_hours, + overtime_hours = EXCLUDED.overtime_hours, + night_hours = EXCLUDED.night_hours, + holiday_hours = EXCLUDED.holiday_hours, + leave_used = EXCLUDED.leave_used, + leave_remaining = EXCLUDED.leave_remaining, + gross_pay_source_present = EXCLUDED.gross_pay_source_present, + net_pay_source_present = EXCLUDED.net_pay_source_present, + nts_tax_row_status = 'REQUIRED_NOT_SUPPLIED', + calculation_status = 'BLOCKED_LEGAL_GATE', + blockers = EXCLUDED.blockers, + source_data_import_row_ids = EXCLUDED.source_data_import_row_ids +"#; + +/// Materialise the roster for `run_id` from import runs declared for exactly +/// this pay period. +/// +/// Returns the number of lines written. An EMPTY result is not an error: the +/// caller stages runs from a workflow drain that leaves a failed event PENDING +/// without incrementing its attempt count, so returning `Err` here would be an +/// unbounded hot retry. The close preflight already refuses an empty roster +/// legibly, with `명세 대상 없음(로스터 0명)`, which is where an operator should +/// meet this problem. +pub async fn materialise_roster_in_tx( + tx: &mut Transaction<'_, Postgres>, + org_id: Uuid, + run_id: Uuid, + period_start: Date, + period_end: Date, +) -> Result { + let done = sqlx::query(MATERIALISE_ROSTER_SQL) + .bind(org_id) + .bind(run_id) + .bind(period_start) + .bind(period_end) + .execute(tx.as_mut()) + .await?; + Ok(done.rows_affected()) +} diff --git a/backend/crates/payroll/adapter-postgres/tests/roster_materialisation.rs b/backend/crates/payroll/adapter-postgres/tests/roster_materialisation.rs new file mode 100644 index 000000000..3ed1cd631 --- /dev/null +++ b/backend/crates/payroll/adapter-postgres/tests/roster_materialisation.rs @@ -0,0 +1,386 @@ +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +//! The production roster writer, proven against a real PostgreSQL. +//! +//! `payroll_draft_lines` had no production writer: its only writer was a +//! hand-run SQL script, so every run production code created had an empty +//! roster and — once the close preflight required `roster_total > 0` — could +//! never close. `roster::materialise_roster_in_tx` is the port of that script. +//! +//! These tests exist because the port could go wrong in ways that all look like +//! success: a roster built from material nobody applied, from the wrong month, +//! from blank cells, or one line per spreadsheet row instead of per person. Each +//! is a NEAR-MISS fixture with an exact-count assertion, not a smoke test. + +use console_payroll_adapter_postgres::roster::materialise_roster_in_tx; +use sqlx::PgPool; +use time::macros::date; +use uuid::Uuid; + +const PERIOD_START: time::Date = date!(2026 - 06 - 01); +const PERIOD_END: time::Date = date!(2026 - 06 - 30); + +struct Fixture { + org: Uuid, + run: Uuid, +} + +async fn seed_org_and_run(pool: &PgPool) -> Fixture { + let org = Uuid::new_v4(); + sqlx::query("INSERT INTO organizations (id, slug, name) VALUES ($1, $2, 'Roster Org')") + .bind(org) + .bind(format!("roster-{}", &org.to_string()[..8])) + .execute(pool) + .await + .unwrap(); + let run: Uuid = sqlx::query_scalar( + "INSERT INTO payroll_draft_runs (org_id, period_start, period_end, source_label) \ + VALUES ($1, $2, $3, 'roster-test') RETURNING id", + ) + .bind(org) + .bind(PERIOD_START) + .bind(PERIOD_END) + .fetch_one(pool) + .await + .unwrap(); + Fixture { org, run } +} + +async fn seed_employee(pool: &PgPool, org: Uuid, source_key: &str, name: &str) -> Uuid { + sqlx::query_scalar( + "INSERT INTO employees (org_id, company, name, source_filename, source_sheet, source_row, source_key) \ + VALUES ($1, 'KNL', $2, 'book.xlsx', 's', 1, $3) RETURNING id", + ) + .bind(org) + .bind(name) + .bind(source_key) + .fetch_one(pool) + .await + .unwrap() +} + +/// One import run for the given period/status, and one row for `employee_key`. +#[allow(clippy::too_many_arguments)] +async fn seed_import( + pool: &PgPool, + org: Uuid, + status: &str, + row_status: &str, + period: (time::Date, time::Date), + employee_key: &str, + raw_row: serde_json::Value, +) { + let run_id = Uuid::new_v4(); + // An employee_hr run cannot be INSERTed already APPLIED: 0166's writer guard + // raises 42501 (`employee_import_run.command_required`). It CAN be updated + // into APPLIED by `console_leave_definer`, which is the role the guard names + // and the only one 0166 grants UPDATE on this table. So the fixture inserts + // DRY_RUN and transitions — exercising the guard rather than routing round it. + sqlx::query( + "INSERT INTO data_import_runs \ + (id, org_id, entity_type, status, source_filename, source_format, source_sha256, \ + pay_period_start, pay_period_end) \ + VALUES ($1, $2, 'employee_hr', $3, 'book.xlsx', 'xlsx', repeat('a', 64), $4, $5)", + ) + .bind(run_id) + .bind(org) + .bind(if status == "APPLIED" { + "DRY_RUN" + } else { + status + }) + .bind(period.0) + .bind(period.1) + .execute(pool) + .await + .unwrap(); + if status == "APPLIED" { + // The DRY_RUN -> APPLIED transition is governed by 0166's writer guard, + // which requires ALL of: the `console_leave_definer` role, an armed + // `app.current_org` (the table is under org-isolation RLS, and without it + // the UPDATE matches zero rows and succeeds SILENTLY), and exactly one + // same-transaction `data_import.apply` audit row whose actor is an active + // user in the org and equals `applied_by`. The fixture satisfies the + // guard rather than routing around it, so these tests exercise the real + // apply path. + let actor = Uuid::new_v4(); + sqlx::query("INSERT INTO users (id, org_id, display_name) VALUES ($1, $2, 'Importer')") + .bind(actor) + .bind(org) + .execute(pool) + .await + .unwrap(); + let mut conn = pool.begin().await.unwrap(); + sqlx::query("SET LOCAL ROLE console_leave_definer") + .execute(&mut *conn) + .await + .unwrap(); + sqlx::query("SELECT set_config('app.current_org', $1, true)") + .bind(org.to_string()) + .execute(&mut *conn) + .await + .unwrap(); + sqlx::query( + "UPDATE data_import_runs SET status = 'APPLIED', applied_by = $3, \ + applied_at = now(), updated_at = now() WHERE org_id = $1 AND id = $2", + ) + .bind(org) + .bind(run_id) + .bind(actor) + .execute(&mut *conn) + .await + .unwrap(); + sqlx::query( + "INSERT INTO audit_events \ + (actor, action, target_type, target_id, before_snap, after_snap, trace_id, span_id, occurred_at, org_id) \ + VALUES ($1, 'data_import.apply', 'data_import_run', $2, NULL, '{}'::jsonb, \ + '0123456789abcdef0123456789abcdef', '0123456789abcdef', now(), $3)", + ) + .bind(actor) + .bind(run_id.to_string()) + .bind(org) + .execute(&mut *conn) + .await + .unwrap(); + conn.commit().await.unwrap(); + let applied: bool = + sqlx::query_scalar("SELECT status = 'APPLIED' FROM data_import_runs WHERE id = $1") + .bind(run_id) + .fetch_one(pool) + .await + .unwrap(); + assert!( + applied, + "the fixture must actually reach APPLIED, not silently no-op" + ); + } + sqlx::query( + "INSERT INTO data_import_rows \ + (org_id, run_id, source_sheet, source_row, source_key, row_status, raw_row, canonical_row) \ + VALUES ($1, $2, 's', 1, $3, $4, $5, jsonb_build_object('source_key', $6::text))", + ) + .bind(org) + .bind(run_id) + .bind(format!("filename:book.xlsx|sheet:s|row:{}", Uuid::new_v4())) + .bind(row_status) + .bind(&raw_row) + .bind(employee_key) + .execute(pool) + .await + .unwrap(); +} + +fn attendance_row() -> serde_json::Value { + serde_json::json!({ "출근": "09:00", "근무시간": "8", "근무일수": "1" }) +} + +async fn roster(pool: &PgPool, f: &Fixture) -> Vec<(String, i32, i32)> { + sqlx::query_as( + "SELECT employee_source_key, payroll_source_row_count, attendance_source_row_count \ + FROM payroll_draft_lines WHERE run_id = $1 ORDER BY employee_source_key", + ) + .bind(f.run) + .fetch_all(pool) + .await + .unwrap() +} + +async fn materialise(pool: &PgPool, f: &Fixture) -> u64 { + let mut tx = pool.begin().await.unwrap(); + let n = materialise_roster_in_tx(&mut tx, f.org, f.run, PERIOD_START, PERIOD_END) + .await + .unwrap(); + tx.commit().await.unwrap(); + n +} + +/// POSITIVE CONTROL. Without this, every refusal below could be produced by a +/// writer that writes nothing at all. +#[sqlx::test(migrations = "../../platform/db/migrations")] +async fn an_applied_in_period_row_becomes_exactly_one_roster_line(pool: PgPool) { + let f = seed_org_and_run(&pool).await; + seed_employee(&pool, f.org, "emp-1", "홍길동").await; + seed_import( + &pool, + f.org, + "APPLIED", + "CANDIDATE", + (PERIOD_START, PERIOD_END), + "emp-1", + attendance_row(), + ) + .await; + + let diag: (i64, i64, Option, Option) = sqlx::query_as( + "SELECT (SELECT count(*) FROM data_import_runs WHERE org_id=$1 AND status='APPLIED'), \ + (SELECT count(*) FROM data_import_rows WHERE org_id=$1), \ + (SELECT canonical_row->>'source_key' FROM data_import_rows WHERE org_id=$1 LIMIT 1), \ + (SELECT source_key FROM employees WHERE org_id=$1 LIMIT 1)", + ).bind(f.org).fetch_one(&pool).await.unwrap(); + assert_eq!( + materialise(&pool, &f).await, + 1, + "one employee, one line; diag={diag:?}" + ); + let lines = roster(&pool, &f).await; + assert_eq!(lines.len(), 1); + assert_eq!(lines[0].0, "emp-1"); + assert!( + lines[0].2 > 0, + "attendance material must be counted: {lines:?}" + ); +} + +/// TWO import rows for the SAME person still make ONE line. +/// +/// `data_import_rows.source_key` is `filename:…|sheet:…|row:…`, so a writer that +/// grouped on it would produce a roster of spreadsheet rows rather than people — +/// and a two-sheet workbook would silently double the roster. +#[sqlx::test(migrations = "../../platform/db/migrations")] +async fn two_rows_for_one_person_make_one_line(pool: PgPool) { + let f = seed_org_and_run(&pool).await; + seed_employee(&pool, f.org, "emp-1", "홍길동").await; + for _ in 0..2 { + seed_import( + &pool, + f.org, + "APPLIED", + "CANDIDATE", + (PERIOD_START, PERIOD_END), + "emp-1", + attendance_row(), + ) + .await; + } + assert_eq!( + materialise(&pool, &f).await, + 1, + "one PERSON, not one per row" + ); + assert_eq!(roster(&pool, &f).await.len(), 1); +} + +/// Material declared for a DIFFERENT period is not this run's material. +/// +/// The near-miss is deliberate: a period that OVERLAPS but is not equal. A writer +/// using overlap instead of equality would admit it. +#[sqlx::test(migrations = "../../platform/db/migrations")] +async fn a_different_period_is_not_this_runs_material(pool: PgPool) { + let f = seed_org_and_run(&pool).await; + seed_employee(&pool, f.org, "emp-1", "홍길동").await; + seed_import( + &pool, + f.org, + "APPLIED", + "CANDIDATE", + (date!(2026 - 01 - 01), date!(2026 - 12 - 31)), + "emp-1", + attendance_row(), + ) + .await; + assert_eq!( + materialise(&pool, &f).await, + 0, + "an overlapping period is not an equal one" + ); + assert!(roster(&pool, &f).await.is_empty()); +} + +/// Material nobody applied is not material. +#[sqlx::test(migrations = "../../platform/db/migrations")] +async fn unapplied_and_errored_material_is_refused(pool: PgPool) { + let f = seed_org_and_run(&pool).await; + seed_employee(&pool, f.org, "emp-1", "홍길동").await; + for (status, row_status) in [ + ("DRY_RUN", "CANDIDATE"), + ("PREVIEWED", "CANDIDATE"), + ("FAILED", "CANDIDATE"), + ("APPLIED", "ERROR"), + ] { + seed_import( + &pool, + f.org, + status, + row_status, + (PERIOD_START, PERIOD_END), + "emp-1", + attendance_row(), + ) + .await; + } + assert_eq!( + materialise(&pool, &f).await, + 0, + "only APPLIED, non-ERROR rows are material" + ); + assert!(roster(&pool, &f).await.is_empty()); +} + +/// A row whose cells are blank says nothing, so it is not source material. +/// +/// The columns are PRESENT — this is the near-miss for a key-presence test. +#[sqlx::test(migrations = "../../platform/db/migrations")] +async fn a_row_of_blank_cells_is_not_source_material(pool: PgPool) { + let f = seed_org_and_run(&pool).await; + seed_employee(&pool, f.org, "emp-1", "홍길동").await; + seed_import( + &pool, + f.org, + "APPLIED", + "CANDIDATE", + (PERIOD_START, PERIOD_END), + "emp-1", + serde_json::json!({ "출근": "", "근무시간": " ", "기본급": "" }), + ) + .await; + assert_eq!( + materialise(&pool, &f).await, + 0, + "present-but-blank columns are not evidence" + ); +} + +/// An employee with leave but no imported material gets no line. +/// +/// The script admitted them via `OR leave_remaining > 0`. Such a line carries no +/// attendance evidence, so it can only ever block the close. +#[sqlx::test(migrations = "../../platform/db/migrations")] +async fn an_employee_with_no_imported_material_is_not_admitted(pool: PgPool) { + let f = seed_org_and_run(&pool).await; + seed_employee(&pool, f.org, "emp-1", "홍길동").await; + // NOTE ON THE NEAR-MISS. Ideally this employee would carry + // `leave_remaining > 0`, since that is the exact disjunct the script used to + // admit them on. `employees.leave_remaining` cannot be set from a test: the + // write raises 42501 `leave_write.command_required`, because leave balances + // are command-governed. So this proves the weaker, still load-bearing half — + // an employee with NO imported material gets no line — and the disjunct's + // absence is additionally pinned by the reviewer note in roster.rs. + assert_eq!( + materialise(&pool, &f).await, + 0, + "an employee with no imported material must not be admitted to the roster" + ); +} + +/// Re-materialising is idempotent and does not duplicate the roster. +#[sqlx::test(migrations = "../../platform/db/migrations")] +async fn re_materialising_updates_rather_than_duplicates(pool: PgPool) { + let f = seed_org_and_run(&pool).await; + seed_employee(&pool, f.org, "emp-1", "홍길동").await; + seed_import( + &pool, + f.org, + "APPLIED", + "CANDIDATE", + (PERIOD_START, PERIOD_END), + "emp-1", + attendance_row(), + ) + .await; + assert_eq!(materialise(&pool, &f).await, 1); + assert_eq!( + materialise(&pool, &f).await, + 1, + "the second pass updates the same line" + ); + assert_eq!(roster(&pool, &f).await.len(), 1, "no duplicate line"); +} diff --git a/docs/program/executed-tests-baseline.json b/docs/program/executed-tests-baseline.json index 12780a069..bf4dc4c1c 100644 --- a/docs/program/executed-tests-baseline.json +++ b/docs/program/executed-tests-baseline.json @@ -263,6 +263,7 @@ "backend/crates/payroll/adapter-postgres/tests/pay_run_port_as_runtime_role.rs": 15, "backend/crates/payroll/adapter-postgres/tests/payroll_lifecycle_rls_as_runtime_role.rs": 4, "backend/crates/payroll/adapter-postgres/tests/payroll_rls_surfaces_as_runtime_role.rs": 4, + "backend/crates/payroll/adapter-postgres/tests/roster_materialisation.rs": 7, "backend/crates/payroll/domain/src/lib.rs": 49, "backend/crates/payroll/rest/src/lib.rs": 10, "backend/crates/payroll/rest/tests/api.rs": 3, diff --git a/tools/buck/gen_first_party.py b/tools/buck/gen_first_party.py index c59fcc8c8..560d45b75 100644 --- a/tools/buck/gen_first_party.py +++ b/tools/buck/gen_first_party.py @@ -745,6 +745,7 @@ def source_tree_label(package): 'integration': { 'tests/pay_run_port_as_runtime_role.rs': 'postgres', 'tests/payroll_lifecycle_rls_as_runtime_role.rs': 'postgres', + 'tests/roster_materialisation.rs': 'postgres', 'tests/payroll_rls_surfaces_as_runtime_role.rs': 'postgres', }, }, diff --git a/tools/ci/postgres-cargo-map.json b/tools/ci/postgres-cargo-map.json index 298872aea..05b32e903 100644 --- a/tools/ci/postgres-cargo-map.json +++ b/tools/ci/postgres-cargo-map.json @@ -2,10 +2,10 @@ "schema_version": 1, "description": "Buck sh_test postgres wrappers → cargo test argv for tools/ci/cargo_needs_postgres.sh", "counts": { - "mapped": 228, + "mapped": 229, "unmapped": 3, - "workflow_targets": 213, - "workflow_mapped": 213, + "workflow_targets": 214, + "workflow_mapped": 214, "workflow_missing": 0 }, "entries": [ @@ -5409,6 +5409,29 @@ "in_workflow_postgres_job": true, "measured_seconds": 29.7 }, + { + "buck_wrapper": "//tools/buck:payroll-adapter-postgres-roster-materialisation-pg", + "name": "payroll-adapter-postgres-roster-materialisation-pg", + "buck_inner": "//backend/crates/payroll/adapter-postgres:console-payroll-adapter-postgres-itest-roster_materialisation", + "package": "console-payroll-adapter-postgres", + "crate_dir": "backend/crates/payroll/adapter-postgres", + "kind": "test", + "test": "roster_materialisation", + "cargo_argv": [ + "cargo", + "test", + "--locked", + "--manifest-path", + "backend/Cargo.toml", + "-p", + "console-payroll-adapter-postgres", + "--test", + "roster_materialisation", + "--", + "--test-threads=1" + ], + "in_workflow_postgres_job": true + }, { "buck_wrapper": "//tools/buck:identity-adapter-postgres-user_lifecycle_noop_replay-pg", "name": "identity-adapter-postgres-user_lifecycle_noop_replay-pg", From 70fc494267073a2cf0d5483e6f14a6b1ff8ec6e6 Mon Sep 17 00:00:00 2001 From: Jason Lee <56489493+jason931225@users.noreply.github.com> Date: Thu, 20 Aug 2026 05:23:37 -0700 Subject: [PATCH 2/4] fix(test): split roster_materialisation helpers out of the 386-line test crate Keep the integration crate root and move fixtures into a #[path] module so no file is over 300. Not a second tests/*.rs crate. Writer behavior unchanged. --- backend/crates/payroll/adapter-postgres/BUCK | 2 +- .../tests/roster_materialisation.rs | 186 +---------------- .../tests/roster_materialisation/seed.rs | 188 ++++++++++++++++++ 3 files changed, 196 insertions(+), 180 deletions(-) create mode 100644 backend/crates/payroll/adapter-postgres/tests/roster_materialisation/seed.rs diff --git a/backend/crates/payroll/adapter-postgres/BUCK b/backend/crates/payroll/adapter-postgres/BUCK index f79b8fbf6..2a87f9002 100644 --- a/backend/crates/payroll/adapter-postgres/BUCK +++ b/backend/crates/payroll/adapter-postgres/BUCK @@ -157,7 +157,7 @@ rust_test( rust_test( name = "console-payroll-adapter-postgres-itest-roster_materialisation", - mapped_srcs = repo_mapped_srcs("backend/crates/payroll/adapter-postgres", ["tests/roster_materialisation.rs"], external = { + mapped_srcs = repo_mapped_srcs("backend/crates/payroll/adapter-postgres", ["tests/roster_materialisation.rs", "tests/roster_materialisation/seed.rs"], external = { "//backend/crates/platform/db/migrations:tree": "backend/crates/platform/db/migrations", }), crate = "roster_materialisation", diff --git a/backend/crates/payroll/adapter-postgres/tests/roster_materialisation.rs b/backend/crates/payroll/adapter-postgres/tests/roster_materialisation.rs index 3ed1cd631..9994eeca7 100644 --- a/backend/crates/payroll/adapter-postgres/tests/roster_materialisation.rs +++ b/backend/crates/payroll/adapter-postgres/tests/roster_materialisation.rs @@ -11,187 +11,15 @@ //! from blank cells, or one line per spreadsheet row instead of per person. Each //! is a NEAR-MISS fixture with an exact-count assertion, not a smoke test. -use console_payroll_adapter_postgres::roster::materialise_roster_in_tx; +#[path = "roster_materialisation/seed.rs"] +mod seed; + +use seed::{ + attendance_row, materialise, roster, seed_employee, seed_import, seed_org_and_run, PERIOD_END, + PERIOD_START, +}; use sqlx::PgPool; use time::macros::date; -use uuid::Uuid; - -const PERIOD_START: time::Date = date!(2026 - 06 - 01); -const PERIOD_END: time::Date = date!(2026 - 06 - 30); - -struct Fixture { - org: Uuid, - run: Uuid, -} - -async fn seed_org_and_run(pool: &PgPool) -> Fixture { - let org = Uuid::new_v4(); - sqlx::query("INSERT INTO organizations (id, slug, name) VALUES ($1, $2, 'Roster Org')") - .bind(org) - .bind(format!("roster-{}", &org.to_string()[..8])) - .execute(pool) - .await - .unwrap(); - let run: Uuid = sqlx::query_scalar( - "INSERT INTO payroll_draft_runs (org_id, period_start, period_end, source_label) \ - VALUES ($1, $2, $3, 'roster-test') RETURNING id", - ) - .bind(org) - .bind(PERIOD_START) - .bind(PERIOD_END) - .fetch_one(pool) - .await - .unwrap(); - Fixture { org, run } -} - -async fn seed_employee(pool: &PgPool, org: Uuid, source_key: &str, name: &str) -> Uuid { - sqlx::query_scalar( - "INSERT INTO employees (org_id, company, name, source_filename, source_sheet, source_row, source_key) \ - VALUES ($1, 'KNL', $2, 'book.xlsx', 's', 1, $3) RETURNING id", - ) - .bind(org) - .bind(name) - .bind(source_key) - .fetch_one(pool) - .await - .unwrap() -} - -/// One import run for the given period/status, and one row for `employee_key`. -#[allow(clippy::too_many_arguments)] -async fn seed_import( - pool: &PgPool, - org: Uuid, - status: &str, - row_status: &str, - period: (time::Date, time::Date), - employee_key: &str, - raw_row: serde_json::Value, -) { - let run_id = Uuid::new_v4(); - // An employee_hr run cannot be INSERTed already APPLIED: 0166's writer guard - // raises 42501 (`employee_import_run.command_required`). It CAN be updated - // into APPLIED by `console_leave_definer`, which is the role the guard names - // and the only one 0166 grants UPDATE on this table. So the fixture inserts - // DRY_RUN and transitions — exercising the guard rather than routing round it. - sqlx::query( - "INSERT INTO data_import_runs \ - (id, org_id, entity_type, status, source_filename, source_format, source_sha256, \ - pay_period_start, pay_period_end) \ - VALUES ($1, $2, 'employee_hr', $3, 'book.xlsx', 'xlsx', repeat('a', 64), $4, $5)", - ) - .bind(run_id) - .bind(org) - .bind(if status == "APPLIED" { - "DRY_RUN" - } else { - status - }) - .bind(period.0) - .bind(period.1) - .execute(pool) - .await - .unwrap(); - if status == "APPLIED" { - // The DRY_RUN -> APPLIED transition is governed by 0166's writer guard, - // which requires ALL of: the `console_leave_definer` role, an armed - // `app.current_org` (the table is under org-isolation RLS, and without it - // the UPDATE matches zero rows and succeeds SILENTLY), and exactly one - // same-transaction `data_import.apply` audit row whose actor is an active - // user in the org and equals `applied_by`. The fixture satisfies the - // guard rather than routing around it, so these tests exercise the real - // apply path. - let actor = Uuid::new_v4(); - sqlx::query("INSERT INTO users (id, org_id, display_name) VALUES ($1, $2, 'Importer')") - .bind(actor) - .bind(org) - .execute(pool) - .await - .unwrap(); - let mut conn = pool.begin().await.unwrap(); - sqlx::query("SET LOCAL ROLE console_leave_definer") - .execute(&mut *conn) - .await - .unwrap(); - sqlx::query("SELECT set_config('app.current_org', $1, true)") - .bind(org.to_string()) - .execute(&mut *conn) - .await - .unwrap(); - sqlx::query( - "UPDATE data_import_runs SET status = 'APPLIED', applied_by = $3, \ - applied_at = now(), updated_at = now() WHERE org_id = $1 AND id = $2", - ) - .bind(org) - .bind(run_id) - .bind(actor) - .execute(&mut *conn) - .await - .unwrap(); - sqlx::query( - "INSERT INTO audit_events \ - (actor, action, target_type, target_id, before_snap, after_snap, trace_id, span_id, occurred_at, org_id) \ - VALUES ($1, 'data_import.apply', 'data_import_run', $2, NULL, '{}'::jsonb, \ - '0123456789abcdef0123456789abcdef', '0123456789abcdef', now(), $3)", - ) - .bind(actor) - .bind(run_id.to_string()) - .bind(org) - .execute(&mut *conn) - .await - .unwrap(); - conn.commit().await.unwrap(); - let applied: bool = - sqlx::query_scalar("SELECT status = 'APPLIED' FROM data_import_runs WHERE id = $1") - .bind(run_id) - .fetch_one(pool) - .await - .unwrap(); - assert!( - applied, - "the fixture must actually reach APPLIED, not silently no-op" - ); - } - sqlx::query( - "INSERT INTO data_import_rows \ - (org_id, run_id, source_sheet, source_row, source_key, row_status, raw_row, canonical_row) \ - VALUES ($1, $2, 's', 1, $3, $4, $5, jsonb_build_object('source_key', $6::text))", - ) - .bind(org) - .bind(run_id) - .bind(format!("filename:book.xlsx|sheet:s|row:{}", Uuid::new_v4())) - .bind(row_status) - .bind(&raw_row) - .bind(employee_key) - .execute(pool) - .await - .unwrap(); -} - -fn attendance_row() -> serde_json::Value { - serde_json::json!({ "출근": "09:00", "근무시간": "8", "근무일수": "1" }) -} - -async fn roster(pool: &PgPool, f: &Fixture) -> Vec<(String, i32, i32)> { - sqlx::query_as( - "SELECT employee_source_key, payroll_source_row_count, attendance_source_row_count \ - FROM payroll_draft_lines WHERE run_id = $1 ORDER BY employee_source_key", - ) - .bind(f.run) - .fetch_all(pool) - .await - .unwrap() -} - -async fn materialise(pool: &PgPool, f: &Fixture) -> u64 { - let mut tx = pool.begin().await.unwrap(); - let n = materialise_roster_in_tx(&mut tx, f.org, f.run, PERIOD_START, PERIOD_END) - .await - .unwrap(); - tx.commit().await.unwrap(); - n -} /// POSITIVE CONTROL. Without this, every refusal below could be produced by a /// writer that writes nothing at all. diff --git a/backend/crates/payroll/adapter-postgres/tests/roster_materialisation/seed.rs b/backend/crates/payroll/adapter-postgres/tests/roster_materialisation/seed.rs new file mode 100644 index 000000000..47406b18c --- /dev/null +++ b/backend/crates/payroll/adapter-postgres/tests/roster_materialisation/seed.rs @@ -0,0 +1,188 @@ +//! Fixtures for the roster-materialisation integration crate. +//! +//! Kept as a module of `roster_materialisation.rs`, not a second `tests/*.rs` +//! crate: Cargo would otherwise auto-discover a new test binary. + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use console_payroll_adapter_postgres::roster::materialise_roster_in_tx; +use sqlx::PgPool; +use time::macros::date; +use uuid::Uuid; + +pub(crate) const PERIOD_START: time::Date = date!(2026 - 06 - 01); +pub(crate) const PERIOD_END: time::Date = date!(2026 - 06 - 30); + +pub(crate) struct Fixture { + pub org: Uuid, + pub run: Uuid, +} + +pub(crate) async fn seed_org_and_run(pool: &PgPool) -> Fixture { + let org = Uuid::new_v4(); + sqlx::query("INSERT INTO organizations (id, slug, name) VALUES ($1, $2, 'Roster Org')") + .bind(org) + .bind(format!("roster-{}", &org.to_string()[..8])) + .execute(pool) + .await + .unwrap(); + let run: Uuid = sqlx::query_scalar( + "INSERT INTO payroll_draft_runs (org_id, period_start, period_end, source_label) \ + VALUES ($1, $2, $3, 'roster-test') RETURNING id", + ) + .bind(org) + .bind(PERIOD_START) + .bind(PERIOD_END) + .fetch_one(pool) + .await + .unwrap(); + Fixture { org, run } +} + +pub(crate) async fn seed_employee(pool: &PgPool, org: Uuid, source_key: &str, name: &str) -> Uuid { + sqlx::query_scalar( + "INSERT INTO employees (org_id, company, name, source_filename, source_sheet, source_row, source_key) \ + VALUES ($1, 'KNL', $2, 'book.xlsx', 's', 1, $3) RETURNING id", + ) + .bind(org) + .bind(name) + .bind(source_key) + .fetch_one(pool) + .await + .unwrap() +} + +/// One import run for the given period/status, and one row for `employee_key`. +#[allow(clippy::too_many_arguments)] +pub(crate) async fn seed_import( + pool: &PgPool, + org: Uuid, + status: &str, + row_status: &str, + period: (time::Date, time::Date), + employee_key: &str, + raw_row: serde_json::Value, +) { + let run_id = Uuid::new_v4(); + // An employee_hr run cannot be INSERTed already APPLIED: 0166's writer guard + // raises 42501 (`employee_import_run.command_required`). It CAN be updated + // into APPLIED by `console_leave_definer`, which is the role the guard names + // and the only one 0166 grants UPDATE on this table. So the fixture inserts + // DRY_RUN and transitions — exercising the guard rather than routing round it. + sqlx::query( + "INSERT INTO data_import_runs \ + (id, org_id, entity_type, status, source_filename, source_format, source_sha256, \ + pay_period_start, pay_period_end) \ + VALUES ($1, $2, 'employee_hr', $3, 'book.xlsx', 'xlsx', repeat('a', 64), $4, $5)", + ) + .bind(run_id) + .bind(org) + .bind(if status == "APPLIED" { + "DRY_RUN" + } else { + status + }) + .bind(period.0) + .bind(period.1) + .execute(pool) + .await + .unwrap(); + if status == "APPLIED" { + // The DRY_RUN -> APPLIED transition is governed by 0166's writer guard, + // which requires ALL of: the `console_leave_definer` role, an armed + // `app.current_org` (the table is under org-isolation RLS, and without it + // the UPDATE matches zero rows and succeeds SILENTLY), and exactly one + // same-transaction `data_import.apply` audit row whose actor is an active + // user in the org and equals `applied_by`. The fixture satisfies the + // guard rather than routing around it, so these tests exercise the real + // apply path. + let actor = Uuid::new_v4(); + sqlx::query("INSERT INTO users (id, org_id, display_name) VALUES ($1, $2, 'Importer')") + .bind(actor) + .bind(org) + .execute(pool) + .await + .unwrap(); + let mut conn = pool.begin().await.unwrap(); + sqlx::query("SET LOCAL ROLE console_leave_definer") + .execute(&mut *conn) + .await + .unwrap(); + sqlx::query("SELECT set_config('app.current_org', $1, true)") + .bind(org.to_string()) + .execute(&mut *conn) + .await + .unwrap(); + sqlx::query( + "UPDATE data_import_runs SET status = 'APPLIED', applied_by = $3, \ + applied_at = now(), updated_at = now() WHERE org_id = $1 AND id = $2", + ) + .bind(org) + .bind(run_id) + .bind(actor) + .execute(&mut *conn) + .await + .unwrap(); + sqlx::query( + "INSERT INTO audit_events \ + (actor, action, target_type, target_id, before_snap, after_snap, trace_id, span_id, occurred_at, org_id) \ + VALUES ($1, 'data_import.apply', 'data_import_run', $2, NULL, '{}'::jsonb, \ + '0123456789abcdef0123456789abcdef', '0123456789abcdef', now(), $3)", + ) + .bind(actor) + .bind(run_id.to_string()) + .bind(org) + .execute(&mut *conn) + .await + .unwrap(); + conn.commit().await.unwrap(); + let applied: bool = + sqlx::query_scalar("SELECT status = 'APPLIED' FROM data_import_runs WHERE id = $1") + .bind(run_id) + .fetch_one(pool) + .await + .unwrap(); + assert!( + applied, + "the fixture must actually reach APPLIED, not silently no-op" + ); + } + sqlx::query( + "INSERT INTO data_import_rows \ + (org_id, run_id, source_sheet, source_row, source_key, row_status, raw_row, canonical_row) \ + VALUES ($1, $2, 's', 1, $3, $4, $5, jsonb_build_object('source_key', $6::text))", + ) + .bind(org) + .bind(run_id) + .bind(format!("filename:book.xlsx|sheet:s|row:{}", Uuid::new_v4())) + .bind(row_status) + .bind(&raw_row) + .bind(employee_key) + .execute(pool) + .await + .unwrap(); +} + +pub(crate) fn attendance_row() -> serde_json::Value { + serde_json::json!({ "출근": "09:00", "근무시간": "8", "근무일수": "1" }) +} + +pub(crate) async fn roster(pool: &PgPool, f: &Fixture) -> Vec<(String, i32, i32)> { + sqlx::query_as( + "SELECT employee_source_key, payroll_source_row_count, attendance_source_row_count \ + FROM payroll_draft_lines WHERE run_id = $1 ORDER BY employee_source_key", + ) + .bind(f.run) + .fetch_all(pool) + .await + .unwrap() +} + +pub(crate) async fn materialise(pool: &PgPool, f: &Fixture) -> u64 { + let mut tx = pool.begin().await.unwrap(); + let n = materialise_roster_in_tx(&mut tx, f.org, f.run, PERIOD_START, PERIOD_END) + .await + .unwrap(); + tx.commit().await.unwrap(); + n +} From 150f323a5cb23a8667bb0fe5590cc019441d10d5 Mon Sep 17 00:00:00 2001 From: Jason Lee <56489493+jason931225@users.noreply.github.com> Date: Thu, 20 Aug 2026 08:30:44 -0400 Subject: [PATCH 3/4] build(buck): update generated first-party BUCK definitions for payroll roster tests --- backend/crates/payroll/adapter-postgres/BUCK | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/crates/payroll/adapter-postgres/BUCK b/backend/crates/payroll/adapter-postgres/BUCK index 2a87f9002..f377286a8 100644 --- a/backend/crates/payroll/adapter-postgres/BUCK +++ b/backend/crates/payroll/adapter-postgres/BUCK @@ -64,7 +64,7 @@ rust_test( rust_test( name = "console-payroll-adapter-postgres-itest-pay_run_port_as_runtime_role", - mapped_srcs = repo_mapped_srcs("backend/crates/payroll/adapter-postgres", ["tests/pay_run_port_as_runtime_role.rs"], external = { + mapped_srcs = repo_mapped_srcs("backend/crates/payroll/adapter-postgres", ["tests/pay_run_port_as_runtime_role.rs", "tests/roster_materialisation/seed.rs"], external = { "//backend/crates/platform/db/migrations:tree": "backend/crates/platform/db/migrations", }), crate = "pay_run_port_as_runtime_role", @@ -95,7 +95,7 @@ rust_test( rust_test( name = "console-payroll-adapter-postgres-itest-payroll_lifecycle_rls_as_runtime_role", - mapped_srcs = repo_mapped_srcs("backend/crates/payroll/adapter-postgres", ["tests/payroll_lifecycle_rls_as_runtime_role.rs"], external = { + mapped_srcs = repo_mapped_srcs("backend/crates/payroll/adapter-postgres", ["tests/payroll_lifecycle_rls_as_runtime_role.rs", "tests/roster_materialisation/seed.rs"], external = { "//backend/crates/platform/db/migrations:tree": "backend/crates/platform/db/migrations", }), crate = "payroll_lifecycle_rls_as_runtime_role", @@ -126,7 +126,7 @@ rust_test( rust_test( name = "console-payroll-adapter-postgres-itest-payroll_rls_surfaces_as_runtime_role", - mapped_srcs = repo_mapped_srcs("backend/crates/payroll/adapter-postgres", ["tests/payroll_rls_surfaces_as_runtime_role.rs"], external = { + mapped_srcs = repo_mapped_srcs("backend/crates/payroll/adapter-postgres", ["tests/payroll_rls_surfaces_as_runtime_role.rs", "tests/roster_materialisation/seed.rs"], external = { "//backend/crates/platform/db/migrations:tree": "backend/crates/platform/db/migrations", }), crate = "payroll_rls_surfaces_as_runtime_role", From e97501d30837680954bcf55496ea4cfc4063369a Mon Sep 17 00:00:00 2001 From: Jason Lee <56489493+jason931225@users.noreply.github.com> Date: Thu, 20 Aug 2026 08:51:46 -0400 Subject: [PATCH 4/4] style(payroll): rustfmt the roster test's use list `rustfmt check` failed on this branch after the helpers were split into `tests/roster_materialisation/seed.rs`: the resulting `use seed::{...}` list was hand-ordered, and rustfmt sorts uppercase constants ahead of lowercase items. Formatting only; the seven tests still pass and their mutation proofs are unchanged. --- .../payroll/adapter-postgres/tests/roster_materialisation.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/crates/payroll/adapter-postgres/tests/roster_materialisation.rs b/backend/crates/payroll/adapter-postgres/tests/roster_materialisation.rs index 9994eeca7..36e711bff 100644 --- a/backend/crates/payroll/adapter-postgres/tests/roster_materialisation.rs +++ b/backend/crates/payroll/adapter-postgres/tests/roster_materialisation.rs @@ -15,8 +15,8 @@ mod seed; use seed::{ - attendance_row, materialise, roster, seed_employee, seed_import, seed_org_and_run, PERIOD_END, - PERIOD_START, + PERIOD_END, PERIOD_START, attendance_row, materialise, roster, seed_employee, seed_import, + seed_org_and_run, }; use sqlx::PgPool; use time::macros::date;