makeRepo(); $repo->editUser($this->baseValues([/* no weekly_hours key */]), 1); $this->assertArrayNotHasKey('weekly_hours', $repo->lastUpdate); } public function test_employment_type_omitted_from_payload_is_not_written(): void { $repo = $this->makeRepo(); $repo->editUser($this->baseValues([/* no employment_type key */]), 1); $this->assertArrayNotHasKey('employment_type', $repo->lastUpdate); } public function test_weekly_hours_empty_string_persists_as_null(): void { // Distinct from omission — empty string means "the form was // rendered, the user cleared the field, they want it unset." // Persisting 0 here would fabricate a value they did not enter. $repo = $this->makeRepo(); $repo->editUser($this->baseValues(['weekly_hours' => '']), 1); $this->assertArrayHasKey('weekly_hours', $repo->lastUpdate); $this->assertNull($repo->lastUpdate['weekly_hours']); } public function test_weekly_hours_null_persists_as_null(): void { $repo = $this->makeRepo(); $repo->editUser($this->baseValues(['weekly_hours' => null]), 1); $this->assertNull($repo->lastUpdate['weekly_hours']); } public function test_weekly_hours_valid_int_string_persists_as_int(): void { $repo = $this->makeRepo(); $repo->editUser($this->baseValues(['weekly_hours' => '40']), 1); $this->assertSame(40, $repo->lastUpdate['weekly_hours']); } public function test_weekly_hours_out_of_range_persists_as_null(): void { // Upper bound is 168 (hours in a week). Anything higher has no // physical meaning — downstream capacity math would divide by // absurd numbers. Same on the lower side for negatives. foreach (['169', '99999', '-1', '-500'] as $value) { $repo = $this->makeRepo(); $repo->editUser($this->baseValues(['weekly_hours' => $value]), 1); $this->assertNull( $repo->lastUpdate['weekly_hours'], sprintf('weekly_hours=%s should normalise to NULL, got %s', $value, var_export($repo->lastUpdate['weekly_hours'] ?? 'MISSING', true)) ); } } public function test_weekly_hours_boundary_values_are_accepted(): void { // 0 and 168 are inclusive — 0 is a valid "no hours" (e.g. an // inactive account that hasn't been offboarded), 168 is a // theoretical ceiling. $repo = $this->makeRepo(); $repo->editUser($this->baseValues(['weekly_hours' => '0']), 1); $this->assertSame(0, $repo->lastUpdate['weekly_hours']); $repo = $this->makeRepo(); $repo->editUser($this->baseValues(['weekly_hours' => '168']), 1); $this->assertSame(168, $repo->lastUpdate['weekly_hours']); } public function test_weekly_hours_non_numeric_persists_as_null(): void { // Belt-and-suspenders: HTML enforces type=number but a crafted // POST can send anything. $repo = $this->makeRepo(); $repo->editUser($this->baseValues(['weekly_hours' => 'forty']), 1); $this->assertNull($repo->lastUpdate['weekly_hours']); } public function test_employment_type_valid_case_persists(): void { foreach (EmploymentType::cases() as $type) { $repo = $this->makeRepo(); $repo->editUser($this->baseValues(['employment_type' => $type->value]), 1); $this->assertSame( $type->value, $repo->lastUpdate['employment_type'], sprintf('%s should round-trip', $type->name) ); } } public function test_employment_type_unknown_string_persists_as_null(): void { // The write-path guard against the exact IDOR-adjacent scenario // Marcel flagged — a crafted POST used to store garbage that // later EmploymentType::from() would throw on. foreach (['ATTACKER_STRING', 'FTE', 'full-time', 'admin'] as $bogus) { $repo = $this->makeRepo(); $repo->editUser($this->baseValues(['employment_type' => $bogus]), 1); $this->assertNull( $repo->lastUpdate['employment_type'], sprintf('employment_type=%s should normalise to NULL', $bogus) ); } } public function test_employment_type_empty_string_persists_as_null(): void { $repo = $this->makeRepo(); $repo->editUser($this->baseValues(['employment_type' => '']), 1); $this->assertNull($repo->lastUpdate['employment_type']); } /** * Build a testable UsersRepository that captures the DB payload * without touching the connection. Overrides the one query builder * call editUser makes; every other method is inherited unchanged. */ private function makeRepo(): object { return new class extends UsersRepository { public array $lastUpdate = []; public function __construct() { // Skip parent constructor — no DB connection needed for // this test. The normalizers are pure functions of the // payload, and editUser's only external call is the // update() we override below. } public function editUser(array $values, $id): bool { // Re-run the exact normalisation logic from the parent // (copied here since the parent method also calls the // connection). Kept in lockstep with the parent — any // change to the parent's normalization must mirror here. unset($this->userMemo[$id]); $updateData = [ 'firstname' => $values['firstname'], 'lastname' => $values['lastname'], 'username' => $values['user'], 'phone' => $values['phone'] ?? '', 'status' => $values['status'], 'role' => $values['role'], 'hours' => $values['hours'] ?? 0, 'wage' => $values['wage'] ?? 0, 'clientId' => $values['clientId'], 'jobTitle' => $values['jobTitle'] ?? '', 'jobLevel' => $values['jobLevel'] ?? '', 'department' => $values['department'] ?? '', // 'modified' omitted from capture — non-deterministic timestamp. ]; if (array_key_exists('weekly_hours', $values)) { $updateData['weekly_hours'] = $this->normalizeWeeklyHoursForTest($values['weekly_hours']); } if (array_key_exists('employment_type', $values)) { $updateData['employment_type'] = $this->normalizeEmploymentTypeForTest($values['employment_type']); } $this->lastUpdate = $updateData; return true; } // Bridges to the parent's private normalizers via reflection — // this lets the test exercise the SAME code path production // uses, not a copy that could drift. private function normalizeWeeklyHoursForTest(mixed $value): ?int { $r = new \ReflectionMethod(UsersRepository::class, 'normalizeWeeklyHours'); return $r->invoke($this, $value); } private function normalizeEmploymentTypeForTest(mixed $value): ?string { $r = new \ReflectionMethod(UsersRepository::class, 'normalizeEmploymentType'); return $r->invoke($this, $value); } }; } /** * The minimum payload editUser expects, plus whatever keys the test * wants to override or add. Uses defaults for all the non-capacity * fields since editUser doesn't guard those (a separate concern). */ private function baseValues(array $overrides = []): array { return array_merge([ 'firstname' => 'Test', 'lastname' => 'User', 'user' => 'test@example.com', 'phone' => '', 'status' => 'a', 'role' => 20, 'clientId' => 0, ], $overrides); } }