Skip to content

Commit eba21b6

Browse files
nilmergclaude
andcommitted
ScheduleRepositoryTest: Add tests covering find/create/update/delete/duplicate
Verify the statements `ScheduleRepository` issues against a mocked database connection, serving the ORM queries it builds internally by returning SQLite-backed PDOStatements the ORM hydrates into models. Rotation deletion/duplication is delegated to and covered by `RotationRepositoryTest`. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent f4de32a commit eba21b6

1 file changed

Lines changed: 362 additions & 0 deletions

File tree

Lines changed: 362 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,362 @@
1+
<?php
2+
3+
// SPDX-FileCopyrightText: 2026 Icinga GmbH <https://icinga.com>
4+
// SPDX-License-Identifier: GPL-3.0-or-later
5+
6+
namespace Tests\Icinga\Module\Notifications\Repository;
7+
8+
use ArrayIterator;
9+
use DateTime;
10+
use Icinga\Module\Notifications\Model\Schedule;
11+
use Icinga\Module\Notifications\Repository\ScheduleRepository;
12+
use ipl\Sql\Connection;
13+
use ipl\Sql\Select;
14+
use PDO;
15+
use PDOStatement;
16+
use PHPUnit\Framework\TestCase;
17+
18+
/**
19+
* Tests for {@see ScheduleRepository}.
20+
*
21+
* Like {@see RotationRepositoryTest}, these tests don't talk to a real database. A {@see Connection} mock is used and
22+
* the statements the repository is expected to issue are anticipated and verified. The ORM queries the repository
23+
* builds internally are served by returning real {@see PDOStatement}s (backed by an in-memory SQLite database) whose
24+
* rows the ORM hydrates into models.
25+
*
26+
* What these tests do not cover:
27+
* - The actual interaction with a production database, which is mocked.
28+
* - The rendered SQL of the issued statements (the connection is mocked before rendering happens).
29+
* - The deletion/duplication of the schedule's rotations, which is delegated to (and covered by)
30+
* {@see RotationRepositoryTest}.
31+
*/
32+
class ScheduleRepositoryTest extends TestCase
33+
{
34+
/**
35+
* Build a real PDOStatement yielding the given rows
36+
*
37+
* See {@see RotationRepositoryTest::selectResult()} for the rationale.
38+
*
39+
* @param list<array<string, mixed>> $rows All rows must share the same keys, which become the result's columns
40+
*
41+
* @return PDOStatement
42+
*/
43+
private function selectResult(array $rows): PDOStatement
44+
{
45+
$columns = empty($rows) ? ['id'] : array_keys($rows[0]);
46+
47+
$pdo = new PDO('sqlite::memory:');
48+
$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_OBJ);
49+
$pdo->exec('CREATE TABLE result (' . implode(', ', array_map(fn ($c) => '"' . $c . '"', $columns)) . ')');
50+
51+
if (! empty($rows)) {
52+
$insert = $pdo->prepare(
53+
'INSERT INTO result VALUES (' . implode(', ', array_fill(0, count($columns), '?')) . ')'
54+
);
55+
56+
foreach ($rows as $row) {
57+
$insert->execute(array_values($row));
58+
}
59+
}
60+
61+
return $pdo->query('SELECT * FROM result');
62+
}
63+
64+
/**
65+
* Covers fetching a schedule, anticipating the `select` it issues and providing a row the ORM hydrates into the
66+
* returned model.
67+
*
68+
* @return void
69+
*/
70+
public function testFindHydratesTheSchedule(): void
71+
{
72+
$databaseMock = $this->createMock(Connection::class);
73+
$databaseMock->method('quoteIdentifier')
74+
->willReturnArgument(0);
75+
76+
$databaseMock->expects($this->once())
77+
->method('select')
78+
->with($this->isInstanceOf(Select::class))
79+
->willReturn($this->selectResult([
80+
[
81+
'id' => 5,
82+
'name' => 'My Schedule',
83+
'timezone' => 'Europe/Berlin',
84+
'deleted' => 'n'
85+
]
86+
]));
87+
88+
$schedule = (new ScheduleRepository($databaseMock))->find(5);
89+
90+
$this->assertNotNull($schedule, 'find() did not return the schedule');
91+
$this->assertEquals(5, $schedule->id);
92+
$this->assertSame('My Schedule', $schedule->name);
93+
$this->assertSame('Europe/Berlin', $schedule->timezone);
94+
$this->assertFalse($schedule->deleted, 'The deleted flag should be cast to a bool');
95+
}
96+
97+
/**
98+
* Covers fetching a non-existent schedule, which is expected to yield null.
99+
*
100+
* @return void
101+
*/
102+
public function testFindReturnsNullIfTheScheduleDoesNotExist(): void
103+
{
104+
$databaseMock = $this->createMock(Connection::class);
105+
$databaseMock->method('quoteIdentifier')
106+
->willReturnArgument(0);
107+
108+
$databaseMock->expects($this->once())
109+
->method('select')
110+
->willReturn($this->selectResult([]));
111+
112+
$this->assertNull((new ScheduleRepository($databaseMock))->find(404));
113+
}
114+
115+
/**
116+
* Covers creating a schedule: it's inserted and its generated id is set on the model.
117+
*
118+
* @return void
119+
*/
120+
public function testCreateInsertsScheduleAndAssignsTheId(): void
121+
{
122+
$start = (int) (new DateTime())->format('Uv');
123+
124+
$schedule = (new Schedule())->setProperties([
125+
'name' => 'New Schedule',
126+
'timezone' => 'Europe/Vienna'
127+
]);
128+
129+
$databaseMock = $this->createMock(Connection::class);
130+
$databaseMock->method('quoteIdentifier')
131+
->willReturnArgument(0);
132+
133+
$databaseMock->expects($this->never())
134+
->method('update');
135+
136+
$databaseMock->expects($this->once())
137+
->method('insert')
138+
->willReturnCallback(function ($table, $data) use ($start) {
139+
$this->assertSame('schedule', $table);
140+
$this->assertArrayHasKey('changed_at', $data);
141+
$this->assertGreaterThanOrEqual($start, $data['changed_at']);
142+
unset($data['changed_at']);
143+
$this->assertSame(['name' => 'New Schedule', 'timezone' => 'Europe/Vienna'], $data);
144+
145+
return $this->createStub(PDOStatement::class);
146+
});
147+
148+
$databaseMock->expects($this->once())
149+
->method('lastInsertId')
150+
->willReturn('77');
151+
152+
(new ScheduleRepository($databaseMock))->create($schedule);
153+
154+
$this->assertSame('77', $schedule->id, 'The generated id was not assigned to the schedule');
155+
}
156+
157+
/**
158+
* Covers updating a schedule.
159+
*
160+
* @return void
161+
*/
162+
public function testUpdateUpdatesTheSchedule(): void
163+
{
164+
$start = (int) (new DateTime())->format('Uv');
165+
166+
$schedule = (new Schedule())->setProperties([
167+
'id' => 5,
168+
'name' => 'Renamed Schedule',
169+
'timezone' => 'Europe/Vienna'
170+
]);
171+
172+
$databaseMock = $this->createMock(Connection::class);
173+
$databaseMock->method('quoteIdentifier')
174+
->willReturnArgument(0);
175+
176+
$databaseMock->expects($this->never())
177+
->method('insert');
178+
179+
$databaseMock->expects($this->once())
180+
->method('update')
181+
->willReturnCallback(function ($table, $data, $where) use ($start) {
182+
$this->assertSame('schedule', $table);
183+
$this->assertSame(['id = ?' => 5], $where);
184+
$this->assertArrayHasKey('changed_at', $data);
185+
$this->assertGreaterThanOrEqual($start, $data['changed_at']);
186+
unset($data['changed_at']);
187+
$this->assertSame(['name' => 'Renamed Schedule', 'timezone' => 'Europe/Vienna'], $data);
188+
189+
return $this->createStub(PDOStatement::class);
190+
});
191+
192+
(new ScheduleRepository($databaseMock))->update($schedule);
193+
}
194+
195+
/**
196+
* Covers deleting a schedule that has no rotations and whose recipient references aren't shared by other
197+
* recipients, so the schedule and its recipient references are simply marked as deleted.
198+
*
199+
* @return void
200+
*/
201+
public function testDeleteMarksScheduleAndRecipientReferencesDeleted(): void
202+
{
203+
$start = (int) (new DateTime())->format('Uv');
204+
205+
$schedule = (new Schedule())->setProperties(['id' => 5]);
206+
207+
$databaseMock = $this->createMock(Connection::class);
208+
$databaseMock->method('quoteIdentifier')
209+
->willReturnArgument(0);
210+
211+
// The rotations query, here without any results
212+
$databaseMock->expects($this->once())
213+
->method('select')
214+
->willReturn($this->selectResult([]));
215+
216+
$databaseMock->expects($this->never())
217+
->method('insert');
218+
219+
// The single fetchCol determines no escalation references this schedule, so no escalations are touched
220+
$databaseMock->expects($this->once())
221+
->method('fetchCol')
222+
->with($this->isInstanceOf(Select::class))
223+
->willReturn([]);
224+
225+
$tables = [];
226+
$databaseMock->expects($this->exactly(2))
227+
->method('update')
228+
->willReturnCallback(function ($table, $data, $where) use ($start, &$tables) {
229+
$tables[] = $table;
230+
231+
$this->assertArrayHasKey('changed_at', $data, sprintf('Update of %s has no changed_at', $table));
232+
$this->assertGreaterThanOrEqual($start, $data['changed_at']);
233+
unset($data['changed_at']);
234+
$this->assertSame(['deleted' => 'y'], $data);
235+
236+
if ($table === 'rule_escalation_recipient') {
237+
$this->assertSame(['schedule_id = ?' => 5], $where);
238+
} elseif ($table === 'schedule') {
239+
$this->assertSame(['id = ?' => 5], $where);
240+
} else {
241+
$this->fail(sprintf('Unexpected update of %s', $table));
242+
}
243+
244+
return $this->createStub(PDOStatement::class);
245+
});
246+
247+
(new ScheduleRepository($databaseMock))->delete($schedule);
248+
249+
$this->assertSame(['rule_escalation_recipient', 'schedule'], $tables);
250+
}
251+
252+
/**
253+
* Covers deleting a schedule whose recipient references include escalations not referenced by any other
254+
* recipient. Those escalations are expected to be marked as deleted as well.
255+
*
256+
* @return void
257+
*/
258+
public function testDeleteAlsoRemovesEscalationsLeftWithoutRecipients(): void
259+
{
260+
$start = (int) (new DateTime())->format('Uv');
261+
262+
$schedule = (new Schedule())->setProperties(['id' => 5]);
263+
264+
$databaseMock = $this->createMock(Connection::class);
265+
$databaseMock->method('quoteIdentifier')
266+
->willReturnArgument(0);
267+
268+
$databaseMock->expects($this->once())
269+
->method('select')
270+
->willReturn($this->selectResult([]));
271+
272+
$databaseMock->expects($this->never())
273+
->method('insert');
274+
275+
// First the escalations referenced by the schedule's recipients (5 and 6), then those among them that still
276+
// have other recipients (only 6). Escalation 5 is thus left without recipients and must be removed.
277+
$databaseMock->expects($this->exactly(2))
278+
->method('fetchCol')
279+
->willReturnOnConsecutiveCalls([5, 6], [6]);
280+
281+
$updates = [];
282+
$databaseMock->expects($this->exactly(3))
283+
->method('update')
284+
->willReturnCallback(function ($table, $data, $where) use ($start, &$updates) {
285+
$updates[$table] = ['data' => $data, 'where' => $where];
286+
287+
$this->assertArrayHasKey('changed_at', $data, sprintf('Update of %s has no changed_at', $table));
288+
$this->assertGreaterThanOrEqual($start, $data['changed_at']);
289+
unset($data['changed_at']);
290+
291+
if ($table === 'rule_escalation_recipient') {
292+
$this->assertSame(['deleted' => 'y'], $data);
293+
$this->assertSame(['schedule_id = ?' => 5], $where);
294+
} elseif ($table === 'rule_escalation') {
295+
$this->assertSame(['deleted' => 'y', 'position' => null], $data);
296+
$this->assertSame(['id IN (?)' => [5]], $where);
297+
} elseif ($table === 'schedule') {
298+
$this->assertSame(['deleted' => 'y'], $data);
299+
$this->assertSame(['id = ?' => 5], $where);
300+
} else {
301+
$this->fail(sprintf('Unexpected update of %s', $table));
302+
}
303+
304+
return $this->createStub(PDOStatement::class);
305+
});
306+
307+
(new ScheduleRepository($databaseMock))->delete($schedule);
308+
309+
$this->assertSame(
310+
['rule_escalation_recipient', 'rule_escalation', 'schedule'],
311+
array_keys($updates),
312+
'The escalation left without recipients was not removed in the expected order'
313+
);
314+
}
315+
316+
/**
317+
* Covers duplicating a schedule (without rotations): a new schedule is inserted with the given name and timezone
318+
* and its id is returned.
319+
*
320+
* @return void
321+
*/
322+
public function testDuplicateInsertsScheduleAndReturnsTheNewId(): void
323+
{
324+
$start = (int) (new DateTime())->format('Uv');
325+
326+
$original = (new Schedule())->setProperties([
327+
'id' => 5,
328+
'name' => 'Original',
329+
'timezone' => 'Europe/Berlin',
330+
// No rotations, so RotationRepository::duplicate() isn't involved
331+
'rotation' => new ArrayIterator([])
332+
]);
333+
334+
$databaseMock = $this->createMock(Connection::class);
335+
$databaseMock->method('quoteIdentifier')
336+
->willReturnArgument(0);
337+
338+
$databaseMock->expects($this->never())
339+
->method('update');
340+
341+
$databaseMock->expects($this->once())
342+
->method('insert')
343+
->willReturnCallback(function ($table, $data) use ($start) {
344+
$this->assertSame('schedule', $table);
345+
$this->assertArrayHasKey('changed_at', $data);
346+
$this->assertGreaterThanOrEqual($start, $data['changed_at']);
347+
unset($data['changed_at']);
348+
$this->assertSame(['name' => 'Copy', 'timezone' => 'America/New_York'], $data);
349+
350+
return $this->createStub(PDOStatement::class);
351+
});
352+
353+
$databaseMock->expects($this->once())
354+
->method('lastInsertId')
355+
->willReturn('88');
356+
357+
// duplicate() is declared to return an int, so the string id is coerced
358+
$newId = (new ScheduleRepository($databaseMock))->duplicate($original, 'Copy', 'America/New_York');
359+
360+
$this->assertSame(88, $newId);
361+
}
362+
}

0 commit comments

Comments
 (0)