Add unit tests for department weather API: cache helpers, fallback behavior, timeline range handling, and schema conformance. Enhance route logic with custom date ranges, cache support, and fallback handling. Update OpenAPI spec and schema mappings.

This commit is contained in:
Jeppe Bundgaard
2026-03-24 13:59:57 +01:00
parent 228efb74fb
commit 4a5dd3a83f
6 changed files with 512 additions and 40 deletions
+12 -6
View File
@@ -7798,7 +7798,7 @@ paths:
tags:
- Departments
summary: Get department weather timeline
description: Returns 48 hourly entries with weather, washes, Workfeed employee-hours, and productivity status aggregated across selected departments (server local time). Default range is start of yesterday (`00:00`) to end of today (`23:00`). Use `date` (`YYYY-MM-DD`) to override the anchor day and get start of previous day to end of selected day.
description: Returns hourly weather, washes, Workfeed employee-hours, and productivity status aggregated across selected departments (server local time). Default range is start of yesterday (`00:00`) to end of today (`23:00`). Use `date_from` and `date_to` (`YYYY-MM-DD`) together to override the range. If department coordinates are missing/invalid or WeatherAPI cannot resolve the location, weather data falls back silently and timeline slots default to `mostly_clear`.
operationId: getDepartmentWeatherTimeline
parameters:
- name: id
@@ -7821,14 +7821,22 @@ paths:
type: string
example: '1,2,3'
description: Optional CSV alternative for department IDs. Merged with `id` if both are provided. At least one of `id` or `ids` must be provided.
- name: date
- name: date_from
in: query
required: false
schema:
type: string
format: date
example: '2026-03-23'
description: Optional range start date (`YYYY-MM-DD`). Must be used together with `date_to`.
- name: date_to
in: query
required: false
schema:
type: string
format: date
example: '2026-03-24'
description: Optional date override (`YYYY-MM-DD`). Range becomes start of previous day to end of this date.
description: Optional range end date (`YYYY-MM-DD`, inclusive). Must be used together with `date_from`.
responses:
'200':
description: Department weather timeline retrieved successfully
@@ -10651,9 +10659,7 @@ components:
properties:
data:
type: array
minItems: 48
maxItems: 48
description: 48 contiguous hourly slots from start of previous day (`00:00`) to end of anchor day (`23:00`). Anchor day is today unless `date` is provided.
description: Hourly contiguous slots from start of range (`00:00`) to end of range (`23:00`, inclusive). Defaults to yesterday+today (48 entries) when date_from/date_to are not provided.
items:
$ref: '#/components/schemas/DepartmentWeatherTimelineEntry'
required: [data]
@@ -94,18 +94,33 @@ class moduleWeatherAPIRoute
self::requireDepartmentAccess((string)$department_id);
}
$selected_date = self::parseSelectedTimelineDateFromRequest();
$selected_date_range = self::parseSelectedTimelineDateRangeFromRequest();
$timeline_range = self::getDepartmentWeatherTimelineRange(
$selected_date_range['date_from'] ?? null,
$selected_date_range['date_to'] ?? null
);
$departments = self::loadDepartmentsByIds($department_ids);
$coordinates = self::resolveWeatherCoordinates($departments);
if ($coordinates === null) {
$response->error('Selected department(s) do not have GPS coordinates configured', 400);
return;
}
$weather = (new weatherapi())->forecast($coordinates['lat'] . ',' . $coordinates['lon'], 2);
$timeline = self::buildDepartmentWeatherTimeline($department_ids, $departments, $weather, $selected_date);
$department_context = count($department_ids) === 1 ? (string)$department_ids[0] : implode(',', $department_ids);
$timeline = $this->withCachedDepartmentWeatherTimeline(
$department_ids,
$timeline_range,
static function () use ($coordinates, $department_context, $department_ids, $departments, $timeline_range, $user): array {
$weather_days = self::resolveForecastDaysForTimelineRange($timeline_range['start'], $timeline_range['endExclusive']);
$forecast_result = self::fetchDepartmentForecastOrFallback($coordinates, $weather_days);
if (is_string($forecast_result['fallback_reason'])) {
$fallback_message = match ($forecast_result['fallback_reason']) {
'invalid_department_coordinates' => 'Department weather fallback used due to missing or invalid department coordinates',
'weatherapi_location_not_found' => 'Department weather fallback used because WeatherAPI could not resolve the selected department location',
default => 'Department weather fallback used due to location lookup failure',
};
(new logs_o())->add('modules_weatherapi', $department_context, 1, $user->id, 'DEPARTMENTS_WEATHER_FALLBACK', $fallback_message);
}
return self::buildDepartmentWeatherTimeline($department_ids, $departments, $forecast_result['forecast'], $timeline_range);
}
);
(new logs_o())->add('modules_weatherapi', $department_context, 1, $user->id, 'DEPARTMENTS_WEATHER_GET', 'Department weather timeline fetched');
$response->success($timeline, 200);
}, [
@@ -114,6 +129,83 @@ class moduleWeatherAPIRoute
]);
}
/**
* Response cache TTL (seconds) for department weather timeline endpoint.
* Set `DEPARTMENTS_WEATHER_CACHE_TTL` to override.
*/
private function getDepartmentWeatherCacheTtl(): int
{
$raw = getenv('DEPARTMENTS_WEATHER_CACHE_TTL');
if ($raw === false || trim((string)$raw) === '') {
return 60;
}
return max(0, (int)$raw);
}
private function getDepartmentWeatherCacheKey(array $department_ids, array $timeline_range): string
{
$normalized_ids = array_values(array_unique(array_map(static function (mixed $department_id): int {
return (int)$department_id;
}, $department_ids)));
sort($normalized_ids, SORT_NUMERIC);
$start = ($timeline_range['start'] ?? null) instanceof DateTime
? $timeline_range['start']->format('Y-m-d H:i:s')
: '';
$end_exclusive = ($timeline_range['endExclusive'] ?? null) instanceof DateTime
? $timeline_range['endExclusive']->format('Y-m-d H:i:s')
: '';
return 'departments_weather:timeline:v1:' . md5((string)json_encode([
'department_ids' => $normalized_ids,
'start' => $start,
'end_exclusive' => $end_exclusive,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
}
/**
* Best-effort Redis cache wrapper for department weather timeline payloads.
* Falls back to direct computation when Redis is unavailable or TTL is disabled.
*
* @param callable():array $resolver
* @return array
*/
private function withCachedDepartmentWeatherTimeline(array $department_ids, array $timeline_range, callable $resolver): array
{
$cache_ttl = $this->getDepartmentWeatherCacheTtl();
if ($cache_ttl <= 0 || !defined('redis')) {
return (array)$resolver();
}
$cache_key = $this->getDepartmentWeatherCacheKey($department_ids, $timeline_range);
try {
$cached = redis->get($cache_key);
if (is_string($cached) && $cached !== '') {
$decoded = json_decode($cached, true);
if (is_array($decoded)) {
return $decoded;
}
}
} catch (\Throwable $e) {
// Best-effort cache read.
}
$result = (array)$resolver();
try {
$encoded = json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (is_string($encoded)) {
redis->setEx($cache_key, $encoded, $cache_ttl);
}
} catch (\Throwable $e) {
// Best-effort cache write.
}
return $result;
}
private function parseDepartmentIdsFromRequest(): array
{
global $response;
@@ -182,23 +274,49 @@ class moduleWeatherAPIRoute
return [$value];
}
private function parseSelectedTimelineDateFromRequest(): ?string
private function parseSelectedTimelineDateRangeFromRequest(): ?array
{
$parameters = self::getParametersAsArray();
if (!array_key_exists('date', $parameters)) {
$has_date_from = array_key_exists('date_from', $parameters);
$has_date_to = array_key_exists('date_to', $parameters);
if (!$has_date_from && !$has_date_to) {
return null;
}
$date = self::getParameter('date');
if (!is_string($date)) {
global $response;
$response->error('Invalid type. Expected: string Got: ' . gettype($date), 400);
global $response;
if (!$has_date_from || !$has_date_to) {
$missing = [];
if (!$has_date_from) {
$missing[] = 'date_from';
}
if (!$has_date_to) {
$missing[] = 'date_to';
}
$response->error('Missing required parameters: ' . implode(', ', $missing), 400);
}
$selected_date = trim((string)$date);
self::requireDateFormat($selected_date, self::FORMAT_DATE());
$date_from_value = self::getParameter('date_from');
$date_to_value = self::getParameter('date_to');
if (!is_string($date_from_value) || !is_string($date_to_value)) {
$response->error('Invalid type. Expected: string for date_from/date_to', 400);
}
return $selected_date;
$date_from = trim((string)$date_from_value);
$date_to = trim((string)$date_to_value);
self::requireDateFormat($date_from, self::FORMAT_DATE());
self::requireDateFormat($date_to, self::FORMAT_DATE());
$from_dt = new DateTime($date_from . ' 00:00:00');
$to_dt = new DateTime($date_to . ' 00:00:00');
if ($to_dt < $from_dt) {
$response->error('Invalid date range. date_to must be on or after date_from', 400);
}
return [
'date_from' => $date_from,
'date_to' => $date_to,
];
}
private function loadDepartmentsByIds(array $department_ids): array
@@ -218,8 +336,11 @@ class moduleWeatherAPIRoute
$count = 0;
foreach ($departments as $department) {
$lat = (float)$department->latitude->value();
$lon = (float)$department->longitude->value();
$lat = self::normalizeCoordinateValue($department->latitude->value(), -90.0, 90.0);
$lon = self::normalizeCoordinateValue($department->longitude->value(), -180.0, 180.0);
if ($lat === null || $lon === null) {
continue;
}
if ($lat === 0.0 && $lon === 0.0) {
continue;
}
@@ -242,7 +363,81 @@ class moduleWeatherAPIRoute
/**
* @throws Exception
*/
private function buildDepartmentWeatherTimeline(array $department_ids, array $departments, object $forecast, ?string $selected_date = null): array
private function fetchDepartmentForecastOrFallback(?array $coordinates, int $weather_days): array
{
if ($coordinates === null) {
return [
'forecast' => self::createEmptyForecastPayload(),
'fallback_reason' => 'invalid_department_coordinates',
];
}
try {
return [
'forecast' => (new weatherapi())->forecast($coordinates['lat'] . ',' . $coordinates['lon'], $weather_days),
'fallback_reason' => null,
];
} catch (Exception $exception) {
if (!self::isWeatherLocationLookupFailure($exception)) {
throw $exception;
}
return [
'forecast' => self::createEmptyForecastPayload(),
'fallback_reason' => 'weatherapi_location_not_found',
];
}
}
private function createEmptyForecastPayload(): object
{
return (object)[
'forecast' => (object)[
'forecastday' => [],
],
];
}
private function isWeatherLocationLookupFailure(Exception $exception): bool
{
$message = strtolower(trim($exception->getMessage()));
return str_contains($message, 'weatherapi request failed with http 400')
&& str_contains($message, 'no matching location found');
}
private function normalizeCoordinateValue(mixed $value, float $min, float $max): ?float
{
if ($value === null) {
return null;
}
if (is_string($value)) {
$value = trim($value);
if ($value === '') {
return null;
}
}
if (!is_numeric($value)) {
return null;
}
$coordinate = (float)$value;
if (!is_finite($coordinate)) {
return null;
}
if ($coordinate < $min || $coordinate > $max) {
return null;
}
return $coordinate;
}
/**
* @throws Exception
*/
private function buildDepartmentWeatherTimeline(array $department_ids, array $departments, object $forecast, array $timeline_range): array
{
$hourly_weather = [];
foreach (($forecast->forecast->forecastday ?? []) as $day) {
@@ -252,20 +447,21 @@ class moduleWeatherAPIRoute
}
}
$timeline_range = self::getDepartmentWeatherTimelineRange($selected_date);
$timeline_start = $timeline_range['start'];
$timeline_end_exclusive = $timeline_range['endExclusive'];
$entries = [];
$slot = clone $timeline_start;
$workfeed_hours_by_slot = self::loadWorkfeedDepartmentHoursBySlot($departments, $timeline_start, $timeline_end_exclusive);
$workfeed_hours_by_slot = $departments === []
? []
: self::loadWorkfeedDepartmentHoursBySlot($departments, $timeline_start, $timeline_end_exclusive);
$current_slot_key = (new DateTime(date('Y-m-d H:00:00')))->format('Y-m-d H:00');
while ($slot < $timeline_end_exclusive) {
$slot_key = $slot->format('Y-m-d H:00');
$weather = $hourly_weather[$slot_key] ?? 'mostly_clear';
$hours = (float)($workfeed_hours_by_slot[$slot_key] ?? 0.0);
$washes = self::countWashesForHour($department_ids, $slot);
$washes = $department_ids === [] ? 0 : self::countWashesForHour($department_ids, $slot);
$entries[] = [
'date' => $slot->format('Y-m-d'),
'time' => $slot->format('H:00'),
@@ -334,14 +530,19 @@ class moduleWeatherAPIRoute
}
}
private function getDepartmentWeatherTimelineRange(?string $selected_date = null): array
private function getDepartmentWeatherTimelineRange(?string $date_from = null, ?string $date_to = null): array
{
$anchor_date = $selected_date ?? date('Y-m-d');
$start = new DateTime($anchor_date . ' 00:00:00');
$start->sub(new DateInterval('P1D'));
if ($date_from !== null && $date_to !== null) {
$start = new DateTime($date_from . ' 00:00:00');
$end_exclusive = new DateTime($date_to . ' 00:00:00');
$end_exclusive->add(new DateInterval('P1D'));
} else {
$start = new DateTime(date('Y-m-d 00:00:00'));
$start->sub(new DateInterval('P1D'));
$end_exclusive = new DateTime($anchor_date . ' 00:00:00');
$end_exclusive->add(new DateInterval('P1D'));
$end_exclusive = new DateTime(date('Y-m-d 00:00:00'));
$end_exclusive->add(new DateInterval('P1D'));
}
return [
'start' => $start,
@@ -349,6 +550,16 @@ class moduleWeatherAPIRoute
];
}
private function resolveForecastDaysForTimelineRange(DateTime $timeline_start, DateTime $timeline_end_exclusive): int
{
$today_start = new DateTime(date('Y-m-d 00:00:00'));
$effective_start = $timeline_start->getTimestamp() > $today_start->getTimestamp() ? clone $timeline_start : $today_start;
$seconds = max(0, $timeline_end_exclusive->getTimestamp() - $effective_start->getTimestamp());
$days = (int)ceil($seconds / 86400);
return max(1, min(14, $days));
}
private function resolveWorkfeedDepartmentIds(array $departments, workfeed $workfeed): array
{
$resolved_ids = [];
@@ -0,0 +1,96 @@
<?php
app_require('routes/moduleWeatherAPIRoute.php');
use routes\moduleWeatherAPIRoute;
function weather_cache_invoke_private(moduleWeatherAPIRoute $route, string $method, array $args = []): mixed
{
$reflection = new ReflectionClass($route);
$target = $reflection->getMethod($method);
$target->setAccessible(true);
return $target->invokeArgs($route, $args);
}
beforeEach(function (): void {
$_SERVER['REQUEST_URI'] = '/departments/weather';
$this->oldTtl = getenv('DEPARTMENTS_WEATHER_CACHE_TTL');
});
afterEach(function (): void {
if ($this->oldTtl === false) {
putenv('DEPARTMENTS_WEATHER_CACHE_TTL');
return;
}
putenv('DEPARTMENTS_WEATHER_CACHE_TTL=' . $this->oldTtl);
});
it('uses sane ttl defaults and clamps negative ttl to zero', function (): void {
$route = new moduleWeatherAPIRoute();
putenv('DEPARTMENTS_WEATHER_CACHE_TTL');
expect(weather_cache_invoke_private($route, 'getDepartmentWeatherCacheTtl'))->toBe(60);
putenv('DEPARTMENTS_WEATHER_CACHE_TTL=45');
expect(weather_cache_invoke_private($route, 'getDepartmentWeatherCacheTtl'))->toBe(45);
putenv('DEPARTMENTS_WEATHER_CACHE_TTL=-10');
expect(weather_cache_invoke_private($route, 'getDepartmentWeatherCacheTtl'))->toBe(0);
});
it('builds deterministic cache keys for department sets and timeline ranges', function (): void {
$route = new moduleWeatherAPIRoute();
$range = [
'start' => new DateTime('2026-03-24 00:00:00'),
'endExclusive' => new DateTime('2026-03-25 00:00:00'),
];
$differentRange = [
'start' => new DateTime('2026-03-24 00:00:00'),
'endExclusive' => new DateTime('2026-03-26 00:00:00'),
];
$keyA = weather_cache_invoke_private($route, 'getDepartmentWeatherCacheKey', [[1, 3, 5], $range]);
$keyB = weather_cache_invoke_private($route, 'getDepartmentWeatherCacheKey', [[1, 3, 5], $range]);
$keyC = weather_cache_invoke_private($route, 'getDepartmentWeatherCacheKey', [[1, 3, 5], $differentRange]);
expect($keyA)->toBe($keyB);
expect($keyA)->not->toBe($keyC);
expect($keyA)->toStartWith('departments_weather:timeline:v1:');
});
it('builds order-insensitive cache keys for equivalent department id sets', function (): void {
$route = new moduleWeatherAPIRoute();
$range = [
'start' => new DateTime('2026-03-24 00:00:00'),
'endExclusive' => new DateTime('2026-03-25 00:00:00'),
];
$ordered = weather_cache_invoke_private($route, 'getDepartmentWeatherCacheKey', [[1, 3, 5], $range]);
$shuffled = weather_cache_invoke_private($route, 'getDepartmentWeatherCacheKey', [[5, 1, 3], $range]);
expect($ordered)->toBe($shuffled);
});
it('falls back to resolver directly when cache ttl is disabled', function (): void {
$route = new moduleWeatherAPIRoute();
putenv('DEPARTMENTS_WEATHER_CACHE_TTL=0');
$calls = 0;
$result = weather_cache_invoke_private($route, 'withCachedDepartmentWeatherTimeline', [
[1, 3, 5],
[
'start' => new DateTime('2026-03-24 00:00:00'),
'endExclusive' => new DateTime('2026-03-25 00:00:00'),
],
static function () use (&$calls): array {
$calls++;
return ['ok' => true];
},
]);
expect($result)->toBe(['ok' => true]);
expect($calls)->toBe(1);
});
@@ -0,0 +1,133 @@
<?php
app_require('routes/moduleWeatherAPIRoute.php');
use routes\moduleWeatherAPIRoute;
function department_weather_fallback_invoke_private(moduleWeatherAPIRoute $route, string $method, array $args = []): mixed
{
$reflection = new ReflectionClass($route);
$target = $reflection->getMethod($method);
$target->setAccessible(true);
return $target->invokeArgs($route, $args);
}
function department_weather_coordinate_property(mixed $value): object
{
return new class($value) {
public function __construct(private mixed $value)
{
}
public function value(): mixed
{
return $this->value;
}
};
}
function department_weather_fake_department(mixed $latitude, mixed $longitude): object
{
return (object)[
'latitude' => department_weather_coordinate_property($latitude),
'longitude' => department_weather_coordinate_property($longitude),
];
}
beforeEach(function (): void {
$_SERVER['REQUEST_URI'] = '/departments/weather';
});
it('resolves weather coordinates from valid coordinates only', function (): void {
$route = new moduleWeatherAPIRoute();
$coordinates = department_weather_fallback_invoke_private($route, 'resolveWeatherCoordinates', [[
department_weather_fake_department('', ''),
department_weather_fake_department('0', '0'),
department_weather_fake_department('91.0', '12.0'),
department_weather_fake_department('55.6761', '181.0'),
department_weather_fake_department('55.6761', '12.5683'),
department_weather_fake_department(56.2639, 9.5018),
]]);
expect($coordinates)->toBeArray();
expect(round($coordinates['lat'], 4))->toBe(round((55.6761 + 56.2639) / 2, 4));
expect(round($coordinates['lon'], 4))->toBe(round((12.5683 + 9.5018) / 2, 4));
});
it('returns null weather coordinates when all department locations are invalid', function (): void {
$route = new moduleWeatherAPIRoute();
$coordinates = department_weather_fallback_invoke_private($route, 'resolveWeatherCoordinates', [[
department_weather_fake_department('', ''),
department_weather_fake_department('0', '0'),
department_weather_fake_department('95.0', '12.0'),
department_weather_fake_department('55.0', '-181.0'),
]]);
expect($coordinates)->toBeNull();
});
it('classifies weatherapi no matching location errors as location lookup failures', function (): void {
$route = new moduleWeatherAPIRoute();
$is_location_error = department_weather_fallback_invoke_private($route, 'isWeatherLocationLookupFailure', [
new Exception('WeatherAPI request failed with HTTP 400: No matching location found.'),
]);
expect($is_location_error)->toBeTrue();
});
it('does not classify non-location weatherapi errors as location lookup failures', function (): void {
$route = new moduleWeatherAPIRoute();
$is_location_error = department_weather_fallback_invoke_private($route, 'isWeatherLocationLookupFailure', [
new Exception('WeatherAPI request failed with HTTP 401: API key is invalid.'),
]);
expect($is_location_error)->toBeFalse();
});
it('returns an empty forecast fallback when coordinates are unavailable', function (): void {
$route = new moduleWeatherAPIRoute();
$result = department_weather_fallback_invoke_private($route, 'fetchDepartmentForecastOrFallback', [null, 2]);
expect($result['fallback_reason'])->toBe('invalid_department_coordinates');
expect($result['forecast'])->toBeObject();
expect($result['forecast']->forecast->forecastday ?? null)->toBeArray();
expect($result['forecast']->forecast->forecastday ?? [1])->toHaveCount(0);
});
it('builds timeline entries with mostly_clear weather when forecast payload is empty', function (): void {
$route = new moduleWeatherAPIRoute();
$start = new DateTime('2026-03-24 08:00:00');
$end_exclusive = new DateTime('2026-03-24 11:00:00');
$timeline = department_weather_fallback_invoke_private($route, 'buildDepartmentWeatherTimeline', [
[],
[],
(object)['forecast' => (object)['forecastday' => []]],
[
'start' => $start,
'endExclusive' => $end_exclusive,
],
]);
expect($timeline)->toHaveCount(3);
expect($timeline[0]['date'])->toBe('2026-03-24');
expect($timeline[0]['time'])->toBe('08:00');
expect($timeline[0]['weather'])->toBe('mostly_clear');
expect($timeline[0]['washes'])->toBe(0);
expect($timeline[0]['hours'])->toBe(0.0);
expect($timeline[0]['status'])->toBe('unknown');
expect($timeline[1]['weather'])->toBe('mostly_clear');
expect($timeline[2]['weather'])->toBe('mostly_clear');
});
it('wires departments weather route through the forecast fallback path', function (): void {
$routeFile = app_path('routes/moduleWeatherAPIRoute.php');
$content = (string)file_get_contents($routeFile);
expect($content)->toContain('fetchDepartmentForecastOrFallback');
expect($content)->toContain('DEPARTMENTS_WEATHER_FALLBACK');
expect($content)->not->toContain('Selected department(s) do not have GPS coordinates configured');
});
@@ -36,13 +36,37 @@ it('builds a timeline range from start of yesterday to end of today', function (
expect($hourCount)->toBe(48);
});
it('builds a timeline range relative to an explicit selected date override', function (): void {
it('builds a timeline range relative to explicit date_from and date_to override', function (): void {
$route = new moduleWeatherAPIRoute();
$range = weather_timeline_range_invoke_private($route, 'getDepartmentWeatherTimelineRange', ['2026-03-10']);
$range = weather_timeline_range_invoke_private($route, 'getDepartmentWeatherTimelineRange', ['2026-03-08', '2026-03-10']);
expect($range['start']->format('Y-m-d H:i:s'))->toBe('2026-03-09 00:00:00');
expect($range['start']->format('Y-m-d H:i:s'))->toBe('2026-03-08 00:00:00');
expect($range['endExclusive']->format('Y-m-d H:i:s'))->toBe('2026-03-11 00:00:00');
$hourCount = (int)(($range['endExclusive']->getTimestamp() - $range['start']->getTimestamp()) / 3600);
expect($hourCount)->toBe(48);
expect($hourCount)->toBe(72);
});
it('resolves forecast day count for a given timeline range with sane limits', function (): void {
$route = new moduleWeatherAPIRoute();
$start = new DateTime(date('Y-m-d 00:00:00'));
$end = new DateTime(date('Y-m-d 00:00:00'));
$end->add(new DateInterval('P3D'));
$days = weather_timeline_range_invoke_private($route, 'resolveForecastDaysForTimelineRange', [$start, $end]);
expect($days)->toBe(3);
$farEnd = new DateTime(date('Y-m-d 00:00:00'));
$farEnd->add(new DateInterval('P40D'));
$clamped = weather_timeline_range_invoke_private($route, 'resolveForecastDaysForTimelineRange', [$start, $farEnd]);
expect($clamped)->toBe(14);
$pastStart = new DateTime(date('Y-m-d 00:00:00'));
$pastStart->sub(new DateInterval('P10D'));
$pastEnd = new DateTime(date('Y-m-d 00:00:00'));
$pastEnd->sub(new DateInterval('P5D'));
$pastDays = weather_timeline_range_invoke_private($route, 'resolveForecastDaysForTimelineRange', [$pastStart, $pastEnd]);
expect($pastDays)->toBe(1);
});
@@ -33,4 +33,6 @@ it('documents the date key in the openapi department weather timeline schema', f
expect($content)->toContain('format: date');
expect($content)->toContain('type: boolean');
expect($content)->toContain('required: [date, time, current, weather, washes, hours, status]');
expect($content)->toContain('- name: date_from');
expect($content)->toContain('- name: date_to');
});