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:
+12
-6
@@ -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);
|
||||
});
|
||||
|
||||
+2
@@ -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');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user