253 lines
9.9 KiB
PHP
253 lines
9.9 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Unit\app\Domain\Users\Repositories;
|
|
|
|
use Leantime\Domain\Users\Enums\EmploymentType;
|
|
use Leantime\Domain\Users\Repositories\Users as UsersRepository;
|
|
use Unit\TestCase;
|
|
|
|
/**
|
|
* Behaviors under test — the persistence contract for the two new
|
|
* capacity attributes on zp_user (weekly_hours + employment_type,
|
|
* added in migration 30523):
|
|
*
|
|
* 1. Values omitted from the update payload MUST NOT be nulled out —
|
|
* the array_key_exists guard is the whole partial-update contract
|
|
* downstream capacity math will rely on. If a caller sends only
|
|
* {name: 'x'}, weekly_hours must remain untouched.
|
|
* 2. Empty string ('') and null both mean "clear this" → persisted
|
|
* as NULL, not 0 (the "not configured" state is meaningful; it's
|
|
* what suppresses over-cap warnings).
|
|
* 3. weekly_hours accepts int-ish strings and clamps to 0..168.
|
|
* Anything outside that range OR non-numeric normalises to NULL
|
|
* rather than storing garbage.
|
|
* 4. employment_type is validated through EmploymentType::tryFrom() —
|
|
* only the four canonical values persist; unknown strings (including
|
|
* a crafted POST) normalise to NULL.
|
|
*
|
|
* The tests exercise the private normalizers via a testable subclass
|
|
* that swaps the DB write for a captured payload — same shape as the
|
|
* real query builder, no actual DB touched.
|
|
*/
|
|
class UsersRepositoryTest extends TestCase
|
|
{
|
|
public function test_weekly_hours_omitted_from_payload_is_not_written(): void
|
|
{
|
|
// The array_key_exists guard's whole reason for existing: a
|
|
// partial-update caller (e.g. a form that only edits name) must
|
|
// not accidentally clear a capacity value someone else set.
|
|
$repo = $this->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);
|
|
}
|
|
}
|