diff --git a/openapi.yaml b/openapi.yaml index f4c2a441..ddbcb1b3 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -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] diff --git a/services/nginx/app/routes/moduleWeatherAPIRoute.php b/services/nginx/app/routes/moduleWeatherAPIRoute.php index 793cdf4c..3dc3e352 100644 --- a/services/nginx/app/routes/moduleWeatherAPIRoute.php +++ b/services/nginx/app/routes/moduleWeatherAPIRoute.php @@ -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 = []; diff --git a/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherCacheHelpersTest.php b/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherCacheHelpersTest.php new file mode 100644 index 00000000..0b68546f --- /dev/null +++ b/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherCacheHelpersTest.php @@ -0,0 +1,96 @@ +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); +}); diff --git a/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherFallbackBehaviorTest.php b/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherFallbackBehaviorTest.php new file mode 100644 index 00000000..c7180bfd --- /dev/null +++ b/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherFallbackBehaviorTest.php @@ -0,0 +1,133 @@ +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'); +}); diff --git a/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherTimelineRangeTest.php b/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherTimelineRangeTest.php index 8b1a38b1..d0fda0c9 100644 --- a/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherTimelineRangeTest.php +++ b/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherTimelineRangeTest.php @@ -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); }); diff --git a/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherTimelineSchemaConformanceTest.php b/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherTimelineSchemaConformanceTest.php index 797715bc..0d820813 100644 --- a/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherTimelineSchemaConformanceTest.php +++ b/services/nginx/app/tests/Unit/Workfeed/DepartmentWeatherTimelineSchemaConformanceTest.php @@ -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'); });