diff --git a/appinfo/routes.php b/appinfo/routes.php index 5b3895ce9b..24e202e067 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -2144,6 +2144,9 @@ // organisation-scoped like `active`. Same ordering rule: it MUST stay above // the `{uuid}` route or `show('completed')` answers it with a 404. ['name' => 'flowRun#completedForSubject', 'url' => '/api/flow-runs/completed', 'verb' => 'GET'], + // Per flow: newest run and runs since a moment (flow-run-summary). Same + // ordering rule: above `{uuid}`, or `show('summary')` answers with a 404. + ['name' => 'flowRun#summary', 'url' => '/api/flow-runs/summary', 'verb' => 'GET'], ['name' => 'flowRun#show', 'url' => '/api/flow-runs/{uuid}', 'verb' => 'GET', 'requirements' => ['uuid' => '[^/]+']], ['name' => 'flowRun#objects', 'url' => '/api/flow-runs/{uuid}/objects', 'verb' => 'GET', 'requirements' => ['uuid' => '[^/]+']], ['name' => 'flowRun#retry', 'url' => '/api/flow-runs/{uuid}/retry', 'verb' => 'POST', 'requirements' => ['uuid' => '[^/]+']], diff --git a/lib/Controller/FlowRunController.php b/lib/Controller/FlowRunController.php index 3d9d0d96f4..cdee5f46cf 100644 --- a/lib/Controller/FlowRunController.php +++ b/lib/Controller/FlowRunController.php @@ -28,6 +28,7 @@ namespace OCA\OpenRegister\Controller; +use DateTime; use OCA\OpenRegister\Db\AuditFlowAttribution; use OCA\OpenRegister\Db\FlowRun; use OCA\OpenRegister\Db\FlowRunMapper; @@ -142,12 +143,17 @@ public function __construct( /** * List runs, newest first, optionally filtered by flow and status. * + * `flowIds` (comma separated) narrows to any of several flows and `since` + * (ISO 8601) to runs created at or after that moment; an unreadable + * `since` is a 400 rather than an unfiltered list. + * * @return JSONResponse The runs plus the paging window. * * @NoAdminRequired * @NoCSRFRequired * * @spec openspec/changes/or-flow-tooling/specs/flow-tooling/spec.md + * @spec openspec/changes/flow-run-summary/specs/flow-run-summary/spec.md */ #[NoAdminRequired] #[NoCSRFRequired] @@ -196,13 +202,20 @@ public function index(): JSONResponse { } } + $since = $this->sinceParam(); + if ($since === false) { + return $this->badSince(); + } + $runs = $this->mapper->findAllRuns( flowId: $flowFilter, status: $statusFilter, limit: $limit, offset: $offset, requesterUid: $requesterUid, - ownedFlowIds: $ownedFlowIds + ownedFlowIds: $ownedFlowIds, + since: $since, + flowIds: $this->flowIdsParam() ); return new JSONResponse( @@ -215,6 +228,127 @@ public function index(): JSONResponse { }//end index() + /** + * Per flow: the newest run and the number of runs since a moment. + * + * The read behind a flow list's "last run" and "runs this week" columns, so + * a list of flows does not page through every run to draw two cells. + * `flowIds` (comma separated, at most 200) names the flows; `since` + * (ISO 8601) defaults to seven days ago. Scoped exactly like `index()`: a + * non-admin counts the runs they triggered plus the runs of flows they own, + * so a summary never counts a run the history would hide. + * + * @return JSONResponse `{results: {flowId: {lastRun, runsSince}}, since}`. + * + * @NoAdminRequired + * @NoCSRFRequired + * + * @spec openspec/changes/flow-run-summary/specs/flow-run-summary/spec.md + */ + #[NoAdminRequired] + #[NoCSRFRequired] + public function summary(): JSONResponse { + $since = $this->sinceParam(); + if ($since === false) { + return $this->badSince(); + } + + if ($since === null) { + $since = new DateTime('-7 days'); + } + + $flowIds = array_slice($this->flowIdsParam(), 0, 200); + $sinceText = $since->format(DateTime::ATOM); + + $requesterUid = null; + $ownedFlowIds = []; + if ($this->isAdmin() === false) { + $requesterUid = $this->callerUid(); + if ($requesterUid === null || $requesterUid === '') { + return new JSONResponse(['results' => (object) [], 'since' => $sinceText]); + } + + $ownedFlowIds = $this->flowIdsOwnedByCaller(); + } + + $summary = $this->mapper->summarizeByFlow( + flowIds: $flowIds, + since: $since, + requesterUid: $requesterUid, + ownedFlowIds: $ownedFlowIds + ); + + return new JSONResponse(['results' => (object) $summary, 'since' => $sinceText]); + }//end summary() + + /** + * The `flowIds` request parameter as a list of non-empty ids. + * + * Accepts a comma-separated string or an array, so both `?flowIds=a,b` and + * `?flowIds[]=a&flowIds[]=b` work. + * + * @return array The flow ids, empty when none were given. + * + * @spec openspec/changes/flow-run-summary/specs/flow-run-summary/spec.md + */ + private function flowIdsParam(): array { + $raw = $this->request->getParam('flowIds'); + if (is_string($raw) === true) { + $raw = explode(',', $raw); + } + + if (is_array($raw) === false) { + return []; + } + + $ids = []; + foreach ($raw as $id) { + if (is_string($id) === true && trim($id) !== '') { + $ids[] = trim($id); + } + } + + return array_values(array_unique($ids)); + }//end flowIdsParam() + + /** + * The `since` request parameter as a moment. + * + * @return DateTime|false|null The moment, null when absent, false when unreadable. + * + * @spec openspec/changes/flow-run-summary/specs/flow-run-summary/spec.md + */ + private function sinceParam(): DateTime|false|null { + $raw = $this->request->getParam('since'); + if ($raw === null || (is_string($raw) === true && trim($raw) === '')) { + return null; + } + + if (is_string($raw) === false) { + return false; + } + + try { + return new DateTime(trim($raw)); + } catch (Throwable) { + return false; + } + }//end sinceParam() + + /** + * The 400 for a `since` that is not a date. + * + * @return JSONResponse The refusal naming the parameter. + * + * @spec openspec/changes/flow-run-summary/specs/flow-run-summary/spec.md + */ + private function badSince(): JSONResponse { + return new JSONResponse( + ['error' => 'The since parameter must be an ISO 8601 date or date-time.'], + Http::STATUS_BAD_REQUEST + ); + }//end badSince() + /** * The runs that are still going, for the caller's organisation. * diff --git a/lib/Db/FlowRunMapper.php b/lib/Db/FlowRunMapper.php index 85e4d4f6a0..2a7432d5ba 100644 --- a/lib/Db/FlowRunMapper.php +++ b/lib/Db/FlowRunMapper.php @@ -217,11 +217,14 @@ public function lockByUuid(string $uuid): FlowRun { * (administrators and system reads only). * @param array $ownedFlowIds Flow ids the caller owns; runs of these * are visible regardless of who triggered them. + * @param DateTime|null $since Only runs created at or after this moment. + * @param array $flowIds Restrict to any of these flows; empty means no restriction. * * @return array The runs. * * @spec openspec/changes/or-flow-runs/specs/flow-runs/spec.md * @spec openspec/changes/flow-engine-unification/specs/flow-storage/spec.md + * @spec openspec/changes/flow-run-summary/specs/flow-run-summary/spec.md */ public function findAllRuns( ?string $flowId = null, @@ -230,6 +233,8 @@ public function findAllRuns( int $offset = 0, ?string $requesterUid = null, array $ownedFlowIds = [], + ?DateTime $since = null, + array $flowIds = [], ): array { $qb = $this->db->getQueryBuilder(); $qb->select('*') @@ -242,6 +247,14 @@ public function findAllRuns( $qb->andWhere($qb->expr()->eq('flow_id', $qb->createNamedParameter($flowId))); } + if (empty($flowIds) === false) { + $qb->andWhere($qb->expr()->in('flow_id', $qb->createNamedParameter(array_values($flowIds), IQueryBuilder::PARAM_STR_ARRAY))); + } + + if ($since !== null) { + $qb->andWhere($qb->expr()->gte('created', $qb->createNamedParameter($since, IQueryBuilder::PARAM_DATETIME_MUTABLE))); + } + if ($status !== null && $status !== '') { $qb->andWhere($qb->expr()->eq('status', $qb->createNamedParameter($status))); } @@ -251,6 +264,147 @@ public function findAllRuns( return $this->findEntities(query: $qb); }//end findAllRuns() + /** + * Per flow: its most recent run and how many runs started since a moment. + * + * The read behind a flow list's "last run" and "runs this week" columns. + * Both halves apply the same visibility rule as {@see findAllRuns}, so the + * numbers a list shows never count a run its history would hide. Every + * requested flow gets an entry, a flow with no visible run included, so a + * caller can tell "never ran" from "not asked". + * + * @param array $flowIds The flows to summarise. + * @param DateTime $since Count runs created at or after this. + * @param string|null $requesterUid The caller, or null for no scoping. + * @param array $ownedFlowIds Flow ids the caller owns. + * + * @return array|null, runsSince: int}> Keyed by flow id. + * + * @spec openspec/changes/flow-run-summary/specs/flow-run-summary/spec.md + */ + public function summarizeByFlow( + array $flowIds, + DateTime $since, + ?string $requesterUid = null, + array $ownedFlowIds = [], + ): array { + $summary = []; + foreach ($flowIds as $flowId) { + $flowId = trim((string)$flowId); + if ($flowId !== '') { + $summary[$flowId] = ['lastRun' => null, 'runsSince' => 0]; + } + } + + if ($summary === []) { + return []; + } + + $ids = array_keys($summary); + $ids = array_map(static fn ($id): string => (string)$id, $ids); + + foreach ($this->countSinceByFlow(flowIds: $ids, since: $since, requesterUid: $requesterUid, ownedFlowIds: $ownedFlowIds) as $flowId => $count) { + if (isset($summary[$flowId]) === true) { + $summary[$flowId]['runsSince'] = $count; + } + } + + foreach ($this->latestRunsByFlow(flowIds: $ids, requesterUid: $requesterUid, ownedFlowIds: $ownedFlowIds) as $run) { + $flowId = (string)$run->getFlowId(); + if (isset($summary[$flowId]) === false) { + continue; + } + + $summary[$flowId]['lastRun'] = [ + 'uuid' => $run->getUuid(), + 'status' => $run->getStatus(), + 'error' => $run->getError(), + 'created' => $run->getCreated()?->format(DateTime::ATOM), + 'updated' => $run->getUpdated()?->format(DateTime::ATOM), + ]; + } + + return $summary; + }//end summarizeByFlow() + + /** + * How many visible runs each flow started since a moment. + * + * @param array $flowIds The flows to count. + * @param DateTime $since Count runs created at or after this. + * @param string|null $requesterUid The caller, or null for no scoping. + * @param array $ownedFlowIds Flow ids the caller owns. + * + * @return array Run count keyed by flow id; flows without runs are absent. + * + * @spec openspec/changes/flow-run-summary/specs/flow-run-summary/spec.md + */ + public function countSinceByFlow(array $flowIds, DateTime $since, ?string $requesterUid, array $ownedFlowIds): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('flow_id') + ->selectAlias($qb->func()->count('id'), 'total') + ->from($this->getTableName()) + ->where($qb->expr()->in('flow_id', $qb->createNamedParameter($flowIds, IQueryBuilder::PARAM_STR_ARRAY))) + ->andWhere($qb->expr()->gte('created', $qb->createNamedParameter($since, IQueryBuilder::PARAM_DATETIME_MUTABLE))) + ->groupBy('flow_id'); + + $this->scopeToVisible(qb: $qb, requesterUid: $requesterUid, ownedFlowIds: $ownedFlowIds); + + $counts = []; + $result = $qb->executeQuery(); + while (($row = $result->fetch()) !== false) { + $counts[(string)$row['flow_id']] = (int)$row['total']; + } + + $result->closeCursor(); + + return $counts; + }//end countSinceByFlow() + + /** + * The newest visible run of each flow, without its log or marking. + * + * Two queries rather than one per flow: the newest id per flow, then those + * rows. Only the columns a summary shows are read, so a list of a hundred + * flows never loads a hundred run logs. + * + * @param array $flowIds The flows to read. + * @param string|null $requesterUid The caller, or null for no scoping. + * @param array $ownedFlowIds Flow ids the caller owns. + * + * @return array At most one run per flow. + * + * @spec openspec/changes/flow-run-summary/specs/flow-run-summary/spec.md + */ + public function latestRunsByFlow(array $flowIds, ?string $requesterUid, array $ownedFlowIds): array { + $qb = $this->db->getQueryBuilder(); + $qb->selectAlias($qb->func()->max('id'), 'latest') + ->from($this->getTableName()) + ->where($qb->expr()->in('flow_id', $qb->createNamedParameter($flowIds, IQueryBuilder::PARAM_STR_ARRAY))) + ->groupBy('flow_id'); + + $this->scopeToVisible(qb: $qb, requesterUid: $requesterUid, ownedFlowIds: $ownedFlowIds); + + $latest = []; + $result = $qb->executeQuery(); + while (($row = $result->fetch()) !== false) { + $latest[] = (int)$row['latest']; + } + + $result->closeCursor(); + + if ($latest === []) { + return []; + } + + $rows = $this->db->getQueryBuilder(); + $rows->select('id', 'uuid', 'flow_id', 'status', 'error', 'created', 'updated') + ->from($this->getTableName()) + ->where($rows->expr()->in('id', $rows->createNamedParameter($latest, IQueryBuilder::PARAM_INT_ARRAY))); + + return $this->findEntities(query: $rows); + }//end latestRunsByFlow() + /** * Narrow a run query to what one caller may see. * diff --git a/openspec/changes/flow-run-summary/proposal.md b/openspec/changes/flow-run-summary/proposal.md new file mode 100644 index 0000000000..093edf96ee --- /dev/null +++ b/openspec/changes/flow-run-summary/proposal.md @@ -0,0 +1,31 @@ +--- +kind: code +--- + +# Proposal: flow-run-summary + +## Summary + +A flow list shows, per flow, how its last run ended and how many runs it had +this week. Today nothing answers that without paging through every run. This +change adds `GET /api/flow-runs/summary?flowIds=a,b&since=`, which +returns per flow the newest visible run (uuid, status, error, created, updated) +and the number of visible runs created since the moment, and lets the run +history read narrow to several flows (`flowIds`) and a start moment (`since`) +so a "runs this week" page can list exactly the runs the summary counted. + +## Why + +The shared flows index in `@conduction/nextcloud-vue` (board PqFlows, used by +every app's Flows page) draws "Laatste uitvoering" and "Uitvoeringen deze +week" columns, a "Met fouten" filter and a "Uitvoeringen deze week" tab. The +run history read returns at most 200 rows with no time filter, so a client +deriving those numbers either undercounts or loads every run log. + +## Scope + +- `FlowRunMapper::summarizeByFlow()`, `countSinceByFlow()`, + `latestRunsByFlow()`; `findAllRuns()` gains `since` and `flowIds`. +- `FlowRunController::summary()` and the `since`/`flowIds` parameters on + `index()`, scoped exactly like `index()`. +- Route `flowRun#summary`, declared above the `{uuid}` route. diff --git a/openspec/changes/flow-run-summary/specs/flow-run-summary/spec.md b/openspec/changes/flow-run-summary/specs/flow-run-summary/spec.md new file mode 100644 index 0000000000..5e3720a4a2 --- /dev/null +++ b/openspec/changes/flow-run-summary/specs/flow-run-summary/spec.md @@ -0,0 +1,43 @@ +## Purpose + +Per-flow run figures for a flow list, read in the datastore under the same +visibility rule as the run history. + +## ADDED Requirements + +### Requirement: The summary returns the newest run and a count per requested flow + +`GET /api/flow-runs/summary` SHALL accept `flowIds` (comma separated or an +array, at most 200 honoured) and an optional `since` (ISO 8601, default seven +days ago). It SHALL return `{results: {flowId: {lastRun, runsSince}}, since}` +with an entry for EVERY requested flow, `lastRun: null` and `runsSince: 0` +for a flow without visible runs. `lastRun` SHALL carry only `uuid`, `status`, +`error`, `created` and `updated`, never the log or marking. + +#### Scenario: a flow that never ran is still answered +- GIVEN flows A and B, where only A has runs +- WHEN the summary is read for A and B +- THEN B is present with `lastRun: null` and `runsSince: 0` + +### Requirement: The summary is scoped like the run history + +For a non-admin caller the summary SHALL count and return only runs the caller +triggered or runs of flows the caller owns, the same predicate the run history +applies. A caller without a session SHALL receive an empty result and no query +SHALL be issued. + +#### Scenario: no session reads nothing +- GIVEN a request without a user session +- WHEN the summary is read +- THEN the results are empty and the mapper is not called + +### Requirement: The run history narrows to several flows and a start moment + +`GET /api/flow-runs` SHALL accept `flowIds` (any of these flows) and `since` +(created at or after). An unreadable `since` SHALL be refused with 400 on both +reads rather than ignored. + +#### Scenario: an unreadable since is refused +- GIVEN `since=not-a-date` +- WHEN the summary or the history is read +- THEN the response is 400 naming the parameter diff --git a/openspec/changes/flow-run-summary/tasks.md b/openspec/changes/flow-run-summary/tasks.md new file mode 100644 index 0000000000..0a1198414e --- /dev/null +++ b/openspec/changes/flow-run-summary/tasks.md @@ -0,0 +1,7 @@ +# Tasks: flow-run-summary + +- [x] Mapper: `summarizeByFlow`, `countSinceByFlow`, `latestRunsByFlow`; `findAllRuns` takes `since` and `flowIds` +- [x] Controller: `summary()`, `since`/`flowIds` on `index()`, 400 on an unreadable `since` +- [x] Route `flowRun#summary` above `{uuid}` +- [x] Unit tests for the controller and the mapper's assembly +- [ ] nc-vue: the flows index source reads the summary (separate PR in nextcloud-vue) diff --git a/tests/Unit/Controller/FlowRunControllerTest.php b/tests/Unit/Controller/FlowRunControllerTest.php index a4bcfd4cbf..8cb9c35ad0 100644 --- a/tests/Unit/Controller/FlowRunControllerTest.php +++ b/tests/Unit/Controller/FlowRunControllerTest.php @@ -499,6 +499,72 @@ public function testTheHistoryReadReturnsNothingWithoutASession(): void { $this->assertSame([], $controller->index()->getData()['results']); }//end testTheHistoryReadReturnsNothingWithoutASession() + public function testTheSummaryIsScopedToTheCallerAndDefaultsToSevenDays(): void { + $this->params(['flowIds' => 'flow-a, flow-b,,flow-a']); + $this->flowOwnership->method('idsOwnedByCaller')->willReturn(['flow-a']); + + $this->mapper->expects($this->once()) + ->method('summarizeByFlow') + ->willReturnCallback(function (array $flowIds, \DateTime $since, ?string $requesterUid, array $ownedFlowIds): array { + $this->assertSame(['flow-a', 'flow-b'], $flowIds); + $this->assertSame('alice', $requesterUid); + $this->assertSame(['flow-a'], $ownedFlowIds); + $this->assertEqualsWithDelta((new \DateTime('-7 days'))->getTimestamp(), $since->getTimestamp(), 60); + return ['flow-a' => ['lastRun' => null, 'runsSince' => 3]]; + }); + + $data = $this->controller->summary()->getData(); + + $this->assertSame(3, $data['results']->{'flow-a'}['runsSince']); + } + + public function testTheSummaryReadsNothingWithoutASession(): void { + $this->params(['flowIds' => 'flow-a']); + + $session = $this->createMock(IUserSession::class); + $session->method('getUser')->willReturn(null); + + $controller = new FlowRunController( + appName: 'openregister', + request: $this->request, + mapper: $this->mapper, + runner: $this->runner, + resolvers: $this->resolvers, + userSession: $session, + organisationService: $this->organisations, + guard: new FlowRunnableGuard(flows: $this->flows, access: $this->access), + flowOwnership: $this->flowOwnership + ); + + $this->mapper->expects($this->never())->method('summarizeByFlow'); + + $this->assertSame([], (array) $controller->summary()->getData()['results']); + } + + public function testAnUnreadableSinceIsRefusedOnTheSummaryAndTheHistory(): void { + $this->params(['since' => 'not-a-date', 'flowIds' => 'flow-a']); + + $this->mapper->expects($this->never())->method('summarizeByFlow'); + $this->mapper->expects($this->never())->method('findAllRuns'); + + $this->assertSame(Http::STATUS_BAD_REQUEST, $this->controller->summary()->getStatus()); + $this->assertSame(Http::STATUS_BAD_REQUEST, $this->controller->index()->getStatus()); + } + + public function testTheHistoryNarrowsToSeveralFlowsAndASinceMoment(): void { + $this->params(['since' => '2026-10-12T00:00:00+00:00', 'flowIds' => ['flow-a', 'flow-b']]); + + $this->mapper->expects($this->once()) + ->method('findAllRuns') + ->willReturnCallback(function (...$args): array { + $this->assertSame('2026-10-12', $args[6]->format('Y-m-d')); + $this->assertSame(['flow-a', 'flow-b'], $args[7]); + return []; + }); + + $this->controller->index(); + } + public function testResumeSignalsTheSuspendedRunWithTheRequestBody(): void { $run = new FlowRun(); $run->setUuid('run-1'); diff --git a/tests/Unit/Db/FlowRunMapperSummaryTest.php b/tests/Unit/Db/FlowRunMapperSummaryTest.php new file mode 100644 index 0000000000..d559d685f4 --- /dev/null +++ b/tests/Unit/Db/FlowRunMapperSummaryTest.php @@ -0,0 +1,105 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Test + * @package OCA\OpenRegister\Tests\Unit\Db + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + */ + +declare(strict_types=1); + +// phpcs:disable PEAR.Commenting.FunctionComment.Missing -- arrange/act/assert PHPUnit conventions. +// phpcs:disable CustomSniffs.Functions.NamedParameters.RequireNamedParameters -- PHPUnit assertion helpers use positional args. + +namespace OCA\OpenRegister\Tests\Unit\Db; + +use DateTime; +use OCA\OpenRegister\Db\FlowRun; +use OCA\OpenRegister\Db\FlowRunMapper; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; + +class FlowRunMapperSummaryTest extends TestCase { + + /** + * A mapper whose two summary seams answer from fixed data. + * + * @param array $counts Runs since, per flow. + * @param array $latest Newest run per flow. + * + * @return FlowRunMapper&MockObject The mapper. + */ + private function mapper(array $counts, array $latest): FlowRunMapper&MockObject { + $mapper = $this->getMockBuilder(FlowRunMapper::class) + ->disableOriginalConstructor() + ->onlyMethods(['countSinceByFlow', 'latestRunsByFlow']) + ->getMock(); + $mapper->method('countSinceByFlow')->willReturn($counts); + $mapper->method('latestRunsByFlow')->willReturn($latest); + + return $mapper; + } + + private function latestRun(string $flowId, string $status): FlowRun { + $run = new FlowRun(); + $run->setUuid($flowId . '-latest'); + $run->setFlowId($flowId); + $run->setStatus($status); + $error = null; + if ($status === FlowRun::STATUS_FAILED) { + $error = 'boom'; + } + + $run->setError($error); + $run->setCreated(new DateTime('2026-10-15T08:41:00+00:00')); + $run->setUpdated(new DateTime('2026-10-15T08:41:01+00:00')); + + return $run; + } + + public function testEveryRequestedFlowIsAnsweredAndNeverRunReadsAsNull(): void { + $mapper = $this->mapper(['flow-a' => 31], [$this->latestRun('flow-a', FlowRun::STATUS_FAILED)]); + + $summary = $mapper->summarizeByFlow(['flow-a', 'flow-b', ' '], new DateTime('-7 days')); + + $this->assertSame(['flow-a', 'flow-b'], array_keys($summary)); + $this->assertSame(31, $summary['flow-a']['runsSince']); + $this->assertSame('failed', $summary['flow-a']['lastRun']['status']); + $this->assertSame('boom', $summary['flow-a']['lastRun']['error']); + $this->assertSame('2026-10-15T08:41:00+00:00', $summary['flow-a']['lastRun']['created']); + $this->assertSame(['lastRun' => null, 'runsSince' => 0], $summary['flow-b']); + } + + public function testRowsForFlowsNobodyAskedAboutAreIgnored(): void { + $mapper = $this->mapper(['flow-x' => 9], [$this->latestRun('flow-x', FlowRun::STATUS_COMPLETED)]); + + $summary = $mapper->summarizeByFlow(['flow-a'], new DateTime('-7 days')); + + $this->assertSame(['flow-a' => ['lastRun' => null, 'runsSince' => 0]], $summary); + } + + public function testNoFlowsMeansNoQuery(): void { + $mapper = $this->getMockBuilder(FlowRunMapper::class) + ->disableOriginalConstructor() + ->onlyMethods(['countSinceByFlow', 'latestRunsByFlow']) + ->getMock(); + $mapper->expects($this->never())->method('countSinceByFlow'); + $mapper->expects($this->never())->method('latestRunsByFlow'); + + $this->assertSame([], $mapper->summarizeByFlow([], new DateTime())); + } +}