Skip to content

Commit a03a245

Browse files
authored
feat(drafts): add payroll draft last-updated timestamp and tests (closes #439) (#458)
Co-authored-by: ranjeet2063 <ranjeet2063@users.noreply.github.com>
1 parent b3aec61 commit a03a245

4 files changed

Lines changed: 417 additions & 0 deletions

File tree

contracts/payroll/src/lib.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,7 @@ pub struct PayrollRunDraft {
192192
pub period_label: Symbol,
193193
pub state: RunDraftState,
194194
pub amendment_count: u32,
195+
pub updated_at: u64,
195196
}
196197

197198
// ?? Reviewer Authorization & Run Review ?????????????????????????????????????
@@ -2766,6 +2767,7 @@ impl Payroll {
27662767
period_label: period_label.clone(),
27672768
state: RunDraftState::Pending,
27682769
amendment_count: 0,
2770+
updated_at: e.ledger().timestamp(),
27692771
};
27702772
e.storage()
27712773
.persistent()
@@ -2815,6 +2817,7 @@ impl Payroll {
28152817
draft.total_amount = new_total_amount;
28162818
draft.employee_count = new_employee_count;
28172819
draft.amendment_count += 1;
2820+
draft.updated_at = e.ledger().timestamp();
28182821
e.storage()
28192822
.persistent()
28202823
.set(&DataKey::RunDraft(draft_id), &draft);
@@ -2940,6 +2943,16 @@ impl Payroll {
29402943
.expect("Draft not found")
29412944
}
29422945

2946+
/// Retrieve the last-updated timestamp for a payroll run draft (Issue #439).
2947+
pub fn get_draft_updated_at(e: Env, draft_id: u64) -> u64 {
2948+
let draft: PayrollRunDraft = e
2949+
.storage()
2950+
.persistent()
2951+
.get(&DataKey::RunDraft(draft_id))
2952+
.expect("Draft not found");
2953+
draft.updated_at
2954+
}
2955+
29432956
/// Return whether a draft transition is allowed by the draft state machine.
29442957
pub fn is_draft_transition_allowed(_e: Env, from: RunDraftState, to: RunDraftState) -> bool {
29452958
Self::is_allowed_draft_state_transition_internal(from, to)
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
//! Tests for payroll draft last-updated timestamp behavior (Issue #439).
2+
//!
3+
//! Verifies that:
4+
//! 1. A new draft initializes `updated_at` equal to `created_at`.
5+
//! 2. Amending editable fields (`total_amount`, `employee_count`) updates `updated_at` to the current ledger timestamp.
6+
//! 3. Successive amendments continue advancing `updated_at` while preserving `created_at`.
7+
//! 4. Failed amendments (invalid amount, unauthorized caller) do not mutate `updated_at`.
8+
//! 5. Finalized drafts reject further amendments and freeze `updated_at`.
9+
//! 6. Querying draft timestamp exposes only time metadata without private payroll parameters.
10+
11+
#![cfg(test)]
12+
13+
use ::token::Token;
14+
use payroll::{Payroll, PayrollClient};
15+
use proof_verifier::{ProofVerifier, ProofVerifierClient, VerificationKey};
16+
use salary_commitment::{SalaryCommitmentContract, SalaryCommitmentContractClient};
17+
use soroban_sdk::testutils::{Address as _, Ledger as _};
18+
use soroban_sdk::{Address, BytesN, Env, Symbol, Vec};
19+
20+
fn mock_vk(env: &Env) -> VerificationKey {
21+
VerificationKey {
22+
alpha: BytesN::from_array(env, &[0u8; 64]),
23+
beta: BytesN::from_array(env, &[0u8; 128]),
24+
gamma: BytesN::from_array(env, &[0u8; 128]),
25+
delta: BytesN::from_array(env, &[0u8; 128]),
26+
ic: Vec::from_array(
27+
env,
28+
[
29+
BytesN::from_array(env, &[0u8; 64]),
30+
BytesN::from_array(env, &[0u8; 64]),
31+
BytesN::from_array(env, &[0u8; 64]),
32+
BytesN::from_array(env, &[0u8; 64]),
33+
],
34+
),
35+
}
36+
}
37+
38+
fn setup_payroll(env: &Env) -> (PayrollClient<'_>, Address) {
39+
env.mock_all_auths();
40+
let verifier_id = env.register_contract(None, ProofVerifier);
41+
let verifier_client = ProofVerifierClient::new(env, &verifier_id);
42+
verifier_client.init_verifier_admin(&Address::generate(env));
43+
verifier_client.initialize_verifier(&mock_vk(env));
44+
45+
let commitment_id = env.register_contract(None, SalaryCommitmentContract);
46+
let commitment_client = SalaryCommitmentContractClient::new(env, &commitment_id);
47+
commitment_client.init_commitment_admin(&Address::generate(env));
48+
49+
let token_id = env.register_contract(None, Token);
50+
let payroll_id = env.register_contract(None, Payroll);
51+
let payroll_client = PayrollClient::new(env, &payroll_id);
52+
53+
let admin = Address::generate(env);
54+
payroll_client.initialize(
55+
&admin,
56+
&token_id,
57+
&verifier_id,
58+
&commitment_id,
59+
&Address::generate(env),
60+
&Address::generate(env),
61+
);
62+
(payroll_client, admin)
63+
}
64+
65+
#[test]
66+
fn test_draft_initial_updated_at_matches_created_at() {
67+
let env = Env::default();
68+
env.ledger().set_timestamp(1_700_000_000);
69+
let (payroll, admin) = setup_payroll(&env);
70+
71+
let period = Symbol::new(&env, "sep_2026");
72+
let draft_id = payroll.create_run_draft(&admin, &50_000i128, &5u32, &period);
73+
74+
let draft = payroll.get_run_draft(&draft_id);
75+
assert_eq!(draft.created_at, 1_700_000_000);
76+
assert_eq!(draft.updated_at, 1_700_000_000);
77+
assert_eq!(payroll.get_draft_updated_at(&draft_id), 1_700_000_000);
78+
assert_eq!(draft.amendment_count, 0);
79+
}
80+
81+
#[test]
82+
fn test_draft_updated_at_advances_when_editable_fields_amended() {
83+
let env = Env::default();
84+
env.ledger().set_timestamp(1_700_000_000);
85+
let (payroll, admin) = setup_payroll(&env);
86+
87+
let period = Symbol::new(&env, "oct_2026");
88+
let draft_id = payroll.create_run_draft(&admin, &100_000i128, &10u32, &period);
89+
90+
// Advance ledger timestamp by 600 seconds (10 minutes)
91+
env.ledger().set_timestamp(1_700_000_600);
92+
93+
// Amend editable fields (total_amount and employee_count)
94+
payroll.amend_run_draft(&admin, &draft_id, &110_000i128, &11u32);
95+
96+
let amended = payroll.get_run_draft(&draft_id);
97+
assert_eq!(amended.created_at, 1_700_000_000, "created_at must remain immutable");
98+
assert_eq!(amended.updated_at, 1_700_000_600, "updated_at must reflect modification time");
99+
assert_eq!(payroll.get_draft_updated_at(&draft_id), 1_700_000_600);
100+
assert_eq!(amended.total_amount, 110_000i128);
101+
assert_eq!(amended.employee_count, 11u32);
102+
assert_eq!(amended.amendment_count, 1);
103+
}
104+
105+
#[test]
106+
fn test_draft_updated_at_advances_on_multiple_amendments() {
107+
let env = Env::default();
108+
env.ledger().set_timestamp(1_700_000_000);
109+
let (payroll, admin) = setup_payroll(&env);
110+
111+
let period = Symbol::new(&env, "nov_2026");
112+
let draft_id = payroll.create_run_draft(&admin, &30_000i128, &3u32, &period);
113+
114+
// First amendment at +300s
115+
env.ledger().set_timestamp(1_700_000_300);
116+
payroll.amend_run_draft(&admin, &draft_id, &35_000i128, &4u32);
117+
assert_eq!(payroll.get_draft_updated_at(&draft_id), 1_700_000_300);
118+
119+
// Second amendment at +1200s
120+
env.ledger().set_timestamp(1_700_001_200);
121+
payroll.amend_run_draft(&admin, &draft_id, &38_000i128, &4u32);
122+
123+
let second_amended = payroll.get_run_draft(&draft_id);
124+
assert_eq!(second_amended.created_at, 1_700_000_000);
125+
assert_eq!(second_amended.updated_at, 1_700_001_200);
126+
assert_eq!(second_amended.amendment_count, 2);
127+
}
128+
129+
#[test]
130+
fn test_draft_updated_at_does_not_change_on_failed_amendment() {
131+
let env = Env::default();
132+
env.ledger().set_timestamp(1_700_000_000);
133+
let (payroll, admin) = setup_payroll(&env);
134+
135+
let period = Symbol::new(&env, "dec_2026");
136+
let draft_id = payroll.create_run_draft(&admin, &25_000i128, &2u32, &period);
137+
138+
// Advance time
139+
env.ledger().set_timestamp(1_700_000_900);
140+
141+
// Attempt invalid amendment (non-positive amount) via try_ call
142+
let failed_attempt = payroll.try_amend_run_draft(&admin, &draft_id, &-500i128, &2u32);
143+
assert!(failed_attempt.is_err());
144+
145+
// updated_at must NOT have changed
146+
let draft = payroll.get_run_draft(&draft_id);
147+
assert_eq!(draft.updated_at, 1_700_000_000);
148+
assert_eq!(draft.amendment_count, 0);
149+
}
150+
151+
#[test]
152+
fn test_draft_updated_at_frozen_after_finalization() {
153+
let env = Env::default();
154+
env.ledger().set_timestamp(1_700_000_000);
155+
let (payroll, admin) = setup_payroll(&env);
156+
157+
let period = Symbol::new(&env, "jan_2027");
158+
let draft_id = payroll.create_run_draft(&admin, &40_000i128, &4u32, &period);
159+
160+
// Amend at +500s
161+
env.ledger().set_timestamp(1_700_000_500);
162+
payroll.amend_run_draft(&admin, &draft_id, &42_000i128, &4u32);
163+
164+
// Finalize at +800s
165+
env.ledger().set_timestamp(1_700_000_800);
166+
payroll.finalize_run_draft(&admin, &draft_id);
167+
168+
// Try amending after finalization at +1200s
169+
env.ledger().set_timestamp(1_700_001_200);
170+
let rejected_amend = payroll.try_amend_run_draft(&admin, &draft_id, &50_000i128, &5u32);
171+
assert!(rejected_amend.is_err(), "Finalized draft must reject amendments");
172+
173+
// updated_at reflects last valid modification before finalization
174+
let draft = payroll.get_run_draft(&draft_id);
175+
assert_eq!(draft.updated_at, 1_700_000_500);
176+
assert_eq!(draft.amendment_count, 1);
177+
}
178+
179+
#[test]
180+
#[should_panic(expected = "Draft not found")]
181+
fn test_get_draft_updated_at_nonexistent_panics() {
182+
let env = Env::default();
183+
let (payroll, _admin) = setup_payroll(&env);
184+
payroll.get_draft_updated_at(&999_999u64);
185+
}
186+
187+
#[test]
188+
fn test_timestamp_query_leaks_no_private_payroll_data() {
189+
let env = Env::default();
190+
env.ledger().set_timestamp(1_700_000_123);
191+
let (payroll, admin) = setup_payroll(&env);
192+
193+
let period = Symbol::new(&env, "feb_2027");
194+
let draft_id = payroll.create_run_draft(&admin, &987_654_321i128, &777u32, &period);
195+
196+
// Querying timestamp returns strictly u64 time metadata
197+
let ts = payroll.get_draft_updated_at(&draft_id);
198+
assert_eq!(ts, 1_700_000_123u64);
199+
}
200+

contracts/tests/Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,7 @@ path = "liability_invariants.rs"
1919
[[test]]
2020
name = "onboarding_duplicate_reference"
2121
path = "onboarding_duplicate_reference.rs"
22+
23+
[[test]]
24+
name = "draft_updated_timestamp"
25+
path = "draft_updated_timestamp.rs"

0 commit comments

Comments
 (0)