createMock(UserLifecycleAuditRepository::class); $repo->expects($this->once()) ->method('create') ->with($this->callback(static function (array $row): bool { return $row['action'] === 'deactivate' && $row['trigger_type'] === 'manual' && $row['status'] === 'success' && $row['run_uuid'] === 'run-1' && $row['target_user_id'] === 5 && $row['target_user_uuid'] === 'user-uuid-5' && $row['target_user_email'] === 'test@example.com' && $row['actor_user_id'] === 99 && $row['policy_deactivate_days'] === 30 && $row['policy_delete_days'] === 60 && $row['snapshot_enc'] === null && $row['snapshot_version'] === 1; })) ->willReturn(1); $service = new UserLifecycleAuditService($repo); $result = $service->logDeactivate( 'run-1', 'manual', ['deactivate_days' => 30, 'delete_days' => 60], 99, ['id' => 5, 'uuid' => 'user-uuid-5', 'email' => 'test@example.com'], 'success', null ); $this->assertTrue($result); } public function testLogDeactivateReturnsFalseOnRepoException(): void { $repo = $this->createMock(UserLifecycleAuditRepository::class); $repo->method('create')->willThrowException(new \RuntimeException('db error')); $service = new UserLifecycleAuditService($repo); $result = $service->logDeactivate('run-1', 'manual', [], null, []); $this->assertFalse($result); } public function testLogDeactivateReturnsFalseWhenRepoReturnsFalse(): void { $repo = $this->createMock(UserLifecycleAuditRepository::class); $repo->method('create')->willReturn(false); $service = new UserLifecycleAuditService($repo); $result = $service->logDeactivate('run-1', 'manual', [], null, []); $this->assertFalse($result); } // ── logDeleteWithSnapshot ─────────────────────────────────────────── public function testLogDeleteWithSnapshotCreatesEncryptedSnapshot(): void { if (!self::$cryptoAvailable) { $this->markTestSkipped('Crypto not configured'); } $targetUser = []; foreach (self::SNAPSHOT_FIELDS as $field) { $targetUser[$field] = 'val_' . $field; } $targetUser['id'] = 5; $targetUser['extra_field'] = 'should_be_excluded'; $repo = $this->createMock(UserLifecycleAuditRepository::class); $repo->expects($this->once()) ->method('create') ->with($this->callback(static function (array $row): bool { return $row['action'] === 'delete' && $row['status'] === 'success' && $row['snapshot_version'] === 1 && is_string($row['snapshot_enc']) && $row['snapshot_enc'] !== ''; })) ->willReturn(7); $service = new UserLifecycleAuditService($repo); $result = $service->logDeleteWithSnapshot('run-2', 'cron', ['deactivate_days' => 30, 'delete_days' => 90], null, $targetUser); $this->assertSame(7, $result); } public function testLogDeleteWithSnapshotContainsOnlySnapshotFields(): void { if (!self::$cryptoAvailable) { $this->markTestSkipped('Crypto not configured'); } $targetUser = ['id' => 5, 'uuid' => 'u5', 'email' => 'e@example.com', 'extra' => 'no']; $capturedRow = null; $repo = $this->createMock(UserLifecycleAuditRepository::class); $repo->expects($this->once()) ->method('create') ->with($this->callback(static function (array $row) use (&$capturedRow): bool { $capturedRow = $row; return true; })) ->willReturn(1); $service = new UserLifecycleAuditService($repo); $service->logDeleteWithSnapshot('run-3', 'manual', [], null, $targetUser); $this->assertNotNull($capturedRow); $decryptedJson = Crypto::decryptString($capturedRow['snapshot_enc']); $snapshot = json_decode($decryptedJson, true); $this->assertIsArray($snapshot); $this->assertSame(self::SNAPSHOT_FIELDS, array_keys($snapshot)); $this->assertArrayNotHasKey('extra', $snapshot); $this->assertArrayNotHasKey('id', $snapshot); } public function testLogDeleteWithSnapshotReturnsFalseOnCryptoFailure(): void { // Simulate a scenario where Crypto throws by passing a target user that will // cause the try/catch to fire. We cannot easily override static Crypto, but we // know that if APP_CRYPTO_KEY were missing, encryptString would throw. // Since we defined a valid key, let's test the repo exception path instead. $repo = $this->createMock(UserLifecycleAuditRepository::class); $repo->method('create')->willThrowException(new \RuntimeException('db error')); $service = new UserLifecycleAuditService($repo); $result = $service->logDeleteWithSnapshot('run-4', 'manual', [], null, ['id' => 1, 'uuid' => 'u']); $this->assertFalse($result); } // ── logRestore ────────────────────────────────────────────────────── public function testLogRestoreCallsRepoWithRestoreAction(): void { $repo = $this->createMock(UserLifecycleAuditRepository::class); $repo->expects($this->once()) ->method('create') ->with($this->callback(static function (array $row): bool { return $row['action'] === 'restore' && $row['trigger_type'] === 'manual' && $row['status'] === 'success'; })) ->willReturn(10); $service = new UserLifecycleAuditService($repo); $result = $service->logRestore('run-5', 'manual', [], 99, ['id' => 5, 'uuid' => 'u5', 'email' => 'e@x.com']); $this->assertTrue($result); } public function testLogRestoreReturnsFalseOnException(): void { $repo = $this->createMock(UserLifecycleAuditRepository::class); $repo->method('create')->willThrowException(new \RuntimeException('fail')); $service = new UserLifecycleAuditService($repo); $result = $service->logRestore('run-5', 'manual', [], null, []); $this->assertFalse($result); } // ── decryptSnapshot ───────────────────────────────────────────────── public function testDecryptSnapshotWithValidPayload(): void { if (!self::$cryptoAvailable) { $this->markTestSkipped('Crypto not configured'); } $original = ['uuid' => 'test-uuid', 'email' => 'test@example.com']; $encrypted = Crypto::encryptString(json_encode($original)); $repo = $this->createMock(UserLifecycleAuditRepository::class); $service = new UserLifecycleAuditService($repo); $result = $service->decryptSnapshot(['snapshot_enc' => $encrypted]); $this->assertSame($original, $result); } public function testDecryptSnapshotReturnsNullForEmptyPayload(): void { $repo = $this->createMock(UserLifecycleAuditRepository::class); $service = new UserLifecycleAuditService($repo); $this->assertNull($service->decryptSnapshot([])); $this->assertNull($service->decryptSnapshot(['snapshot_enc' => ''])); $this->assertNull($service->decryptSnapshot(['snapshot_enc' => ' '])); } public function testDecryptSnapshotReturnsNullForInvalidPayload(): void { $repo = $this->createMock(UserLifecycleAuditRepository::class); $service = new UserLifecycleAuditService($repo); $result = $service->decryptSnapshot(['snapshot_enc' => 'not-valid-encrypted-data']); $this->assertNull($result); } // ── markDeleteEventRestored ───────────────────────────────────────── public function testMarkDeleteEventRestoredDelegatesToRepo(): void { $repo = $this->createMock(UserLifecycleAuditRepository::class); $repo->expects($this->once()) ->method('markRestored') ->with(1, 99, 200) ->willReturn(true); $service = new UserLifecycleAuditService($repo); $this->assertTrue($service->markDeleteEventRestored(1, 99, 200)); } public function testMarkDeleteEventRestoredReturnsFalseOnException(): void { $repo = $this->createMock(UserLifecycleAuditRepository::class); $repo->method('markRestored')->willThrowException(new \RuntimeException('fail')); $service = new UserLifecycleAuditService($repo); $this->assertFalse($service->markDeleteEventRestored(1, 99, 200)); } // ── purgeExpired ──────────────────────────────────────────────────── public function testPurgeExpiredDelegatesToRepoWith365Days(): void { $repo = $this->createMock(UserLifecycleAuditRepository::class); $repo->expects($this->once()) ->method('purgeOlderThanDays') ->with(365) ->willReturn(5); $service = new UserLifecycleAuditService($repo); $this->assertSame(5, $service->purgeExpired()); } // ── buildBaseRow enum normalization ────────────────────────────────── public function testBuildBaseRowNormalizesInvalidTriggerTypeToDefault(): void { $repo = $this->createMock(UserLifecycleAuditRepository::class); $repo->expects($this->once()) ->method('create') ->with($this->callback(static function (array $row): bool { return $row['trigger_type'] === 'system'; // fallback for invalid trigger })) ->willReturn(1); $service = new UserLifecycleAuditService($repo); $service->logDeactivate('run-x', 'INVALID_TRIGGER', [], null, []); } public function testBuildBaseRowNormalizesInvalidStatusToFailed(): void { $repo = $this->createMock(UserLifecycleAuditRepository::class); $repo->expects($this->once()) ->method('create') ->with($this->callback(static function (array $row): bool { return $row['status'] === 'failed'; // fallback for invalid status })) ->willReturn(1); $service = new UserLifecycleAuditService($repo); $service->logDeactivate('run-x', 'manual', [], null, [], 'INVALID_STATUS'); } public function testBuildBaseRowNormalizesNullActorToNull(): void { $repo = $this->createMock(UserLifecycleAuditRepository::class); $repo->expects($this->once()) ->method('create') ->with($this->callback(static function (array $row): bool { return $row['actor_user_id'] === null; })) ->willReturn(1); $service = new UserLifecycleAuditService($repo); $service->logDeactivate('run-x', 'manual', [], null, []); } public function testBuildBaseRowNormalizesZeroActorToNull(): void { $repo = $this->createMock(UserLifecycleAuditRepository::class); $repo->expects($this->once()) ->method('create') ->with($this->callback(static function (array $row): bool { return $row['actor_user_id'] === null; })) ->willReturn(1); $service = new UserLifecycleAuditService($repo); $service->logDeactivate('run-x', 'manual', [], 0, []); } public function testBuildBaseRowSetsNegativePolicyDaysToZero(): void { $repo = $this->createMock(UserLifecycleAuditRepository::class); $repo->expects($this->once()) ->method('create') ->with($this->callback(static function (array $row): bool { return $row['policy_deactivate_days'] === 0 && $row['policy_delete_days'] === 0; })) ->willReturn(1); $service = new UserLifecycleAuditService($repo); $service->logDeactivate('run-x', 'manual', ['deactivate_days' => -10, 'delete_days' => -5], null, []); } }