db = $this->createMock(PDO::class); $this->repository = new PasswordResetRepository($this->db); } // ── Helper ───────────────────────────────────────────────────── private function stmtOk(array|false $fetchReturn = false): PDOStatement&MockObject { $stmt = $this->createMock(PDOStatement::class); $stmt->method('execute')->willReturn(true); $stmt->method('fetch')->willReturn($fetchReturn); return $stmt; } // ── create ───────────────────────────────────────────────────── /** * create() doit préparer un INSERT sur 'password_resets' * avec le user_id, le token_hash et la date d'expiration fournis. */ public function testCreateCallsInsertWithCorrectData(): void { $userId = 1; $tokenHash = hash('sha256', 'montokenbrut'); $expiresAt = date('Y-m-d H:i:s', time() + 3600); $stmt = $this->stmtOk(); $this->db->expects($this->once())->method('prepare') ->with($this->stringContains('INSERT INTO password_resets')) ->willReturn($stmt); $stmt->expects($this->once()) ->method('execute') ->with($this->callback(function (array $data) use ($userId, $tokenHash, $expiresAt): bool { return $data[':user_id'] === $userId && $data[':token_hash'] === $tokenHash && $data[':expires_at'] === $expiresAt && isset($data[':created_at']); })); $this->repository->create($userId, $tokenHash, $expiresAt); } /** * create() doit renseigner :created_at au format 'Y-m-d H:i:s'. */ public function testCreateSetsCreatedAt(): void { $stmt = $this->stmtOk(); $this->db->expects($this->once())->method('prepare')->willReturn($stmt); $stmt->expects($this->once()) ->method('execute') ->with($this->callback(function (array $data): bool { return isset($data[':created_at']) && (bool) \DateTime::createFromFormat('Y-m-d H:i:s', $data[':created_at']); })); $this->repository->create(1, 'hash', date('Y-m-d H:i:s', time() + 3600)); } // ── findActiveByHash ─────────────────────────────────────────── /** * findActiveByHash() doit retourner null si aucun token actif ne correspond au hash. */ public function testFindActiveByHashReturnsNullWhenMissing(): void { $stmt = $this->stmtOk(false); $this->db->expects($this->once())->method('prepare')->willReturn($stmt); $this->assertNull($this->repository->findActiveByHash('hashquinaexistepas')); } /** * findActiveByHash() doit retourner la ligne si un token actif correspond au hash. */ public function testFindActiveByHashReturnsRowWhenFound(): void { $tokenHash = hash('sha256', 'montokenbrut'); $row = [ 'id' => 1, 'user_id' => 42, 'token_hash' => $tokenHash, 'expires_at' => date('Y-m-d H:i:s', time() + 3600), 'used_at' => null, 'created_at' => date('Y-m-d H:i:s'), ]; $stmt = $this->stmtOk($row); $this->db->method('prepare')->willReturn($stmt); $this->assertSame($row, $this->repository->findActiveByHash($tokenHash)); } /** * findActiveByHash() doit inclure AND used_at IS NULL dans le SQL * pour n'obtenir que les tokens non consommés. */ public function testFindActiveByHashFiltersOnNullUsedAt(): void { $tokenHash = hash('sha256', 'montokenbrut'); $stmt = $this->stmtOk(false); $this->db->expects($this->once()) ->method('prepare') ->with($this->stringContains('used_at IS NULL')) ->willReturn($stmt); $stmt->expects($this->once()) ->method('execute') ->with([':token_hash' => $tokenHash]); $this->repository->findActiveByHash($tokenHash); } // ── invalidateByUserId ───────────────────────────────────────── /** * invalidateByUserId() doit préparer un UPDATE renseignant :used_at * pour tous les tokens non consommés de l'utilisateur. */ public function testInvalidateByUserIdCallsUpdateWithUsedAt(): void { $userId = 42; $stmt = $this->stmtOk(); $this->db->expects($this->once())->method('prepare') ->with($this->stringContains('UPDATE password_resets')) ->willReturn($stmt); $stmt->expects($this->once()) ->method('execute') ->with($this->callback(function (array $data) use ($userId): bool { return $data[':user_id'] === $userId && isset($data[':used_at']) && (bool) \DateTime::createFromFormat('Y-m-d H:i:s', $data[':used_at']); })); $this->repository->invalidateByUserId($userId); } /** * invalidateByUserId() doit inclure AND used_at IS NULL dans le SQL * pour ne cibler que les tokens encore actifs. */ public function testInvalidateByUserIdFiltersOnActiveTokens(): void { $stmt = $this->stmtOk(); $this->db->expects($this->once()) ->method('prepare') ->with($this->stringContains('used_at IS NULL')) ->willReturn($stmt); $this->repository->invalidateByUserId(1); } /** * invalidateByUserId() ne doit pas utiliser DELETE — les tokens * sont invalidés via used_at pour conserver la traçabilité. */ public function testInvalidateByUserIdNeverCallsDelete(): void { $stmt = $this->stmtOk(); $this->db->expects($this->once())->method('prepare') ->with($this->stringContains('UPDATE')) ->willReturn($stmt); $this->db->expects($this->never())->method('exec'); $this->repository->invalidateByUserId(1); } // ── consumeActiveToken ──────────────────────────────────────── /** * consumeActiveToken() doit utiliser UPDATE ... RETURNING pour consommer * et retourner le token en une seule opération atomique. */ public function testConsumeActiveTokenUsesAtomicUpdateReturning(): void { $row = ['id' => 1, 'user_id' => 42, 'token_hash' => 'hash', 'used_at' => null]; $stmt = $this->stmtOk($row); $this->db->expects($this->once()) ->method('prepare') ->with($this->callback(fn (string $sql): bool => str_contains($sql, 'UPDATE password_resets') && str_contains($sql, 'used_at IS NULL') && str_contains($sql, 'RETURNING *') )) ->willReturn($stmt); $result = $this->repository->consumeActiveToken('hash', '2024-01-01 00:00:00'); $this->assertSame($row, $result); } /** * consumeActiveToken() retourne null si aucun token actif ne correspond. */ public function testConsumeActiveTokenReturnsNullWhenNoRowMatches(): void { $stmt = $this->stmtOk(false); $this->db->method('prepare')->willReturn($stmt); $this->assertNull($this->repository->consumeActiveToken('missing', '2024-01-01 00:00:00')); } }