[AZ-310] [AZ-311] Route tile endpoints through ITileService

Move cache+DB+download logic for /tiles/{z}/{x}/{y} and
/api/satellite/tiles/latlon out of Program.cs into TileService.
Endpoints now inject only ITileService + ILogger. Service owns
IMemoryCache (1h absolute / 30min sliding preserved). Added
TileBytes DTO; ITileService gains GetOrDownloadTileAsync and
DownloadAndStoreSingleTileAsync. 5 new unit tests cover cache
hit, repo hit, downloader fallback, and AZ-311 happy + error.

Build clean (0/0), unit suite 40/40. Resolves architecture
baseline F3 in code (docs handled by AZ-315).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-10 06:06:11 +03:00
parent 220277b9c7
commit 12b582deac
12 changed files with 387 additions and 121 deletions
@@ -0,0 +1,79 @@
# Refactor: route ServeTile through ITileService
**Task**: AZ-310_refactor_servetile_via_tileservice
**Name**: ServeTile via ITileService.GetOrDownloadTileAsync
**Description**: Move tile cache+DB+download logic from the `/tiles/{z}/{x}/{y}` endpoint into `ITileService` and make the endpoint a thin handler.
**Complexity**: 3 points
**Dependencies**: None
**Component**: TileDownloader (currently in SatelliteProvider.Services)
**Tracker**: AZ-310
**Epic**: AZ-309
## Problem
`Program.cs:141` (`ServeTile`) directly injects `ISatelliteDownloader`, `ITileRepository`, and `IMemoryCache`. It re-implements cache-or-DB-or-download logic that overlaps with `TileService` but is not delegated. This is architecture baseline finding F3 (Medium).
## Outcome
- All tile-serving logic for `/tiles/{z}/{x}/{y}` is owned by `TileService`.
- `ServeTile` handler is a thin function: validate route, call service, write headers, return bytes.
- `IMemoryCache` is no longer injected at the endpoint level for tile serving.
## Scope
### Included
- New method `Task<TileBytes> GetOrDownloadTileAsync(int z, int x, int y, CancellationToken ct = default)` on `ITileService`. `TileBytes` is a record `(byte[] Bytes, string ContentType, string ETag, TimeSpan MaxAge)`.
- Implementation in `TileService` owns the in-memory cache (DI-injected).
- New unit tests for `GetOrDownloadTileAsync` (cache hit, repo hit, downloader fallback).
- `Program.cs` ServeTile handler thinned.
### Excluded
- Adding a new integration test for `/tiles/{z}/{x}/{y}` (existing smoke does not cover it; out of scope here).
- Renaming any database tables, columns, or DTOs.
## Acceptance Criteria
**AC-1: Endpoint route preserved**
Given the API is running
When a client calls `GET /tiles/{z}/{x}/{y}`
Then the response shape (image/jpeg bytes, ETag header, Cache-Control header) is unchanged from before the refactor.
**AC-2: Cache hit path**
Given a tile has previously been served and is still in the in-memory cache
When `GetOrDownloadTileAsync` is called for the same `(z,x,y)`
Then the result comes from cache and neither the repository nor the downloader is invoked.
**AC-3: Repo hit path**
Given a tile is not in cache but exists in the database with a valid file path
When `GetOrDownloadTileAsync` is called
Then the file is read from disk, cached, and returned without invoking the downloader.
**AC-4: Downloader fallback path**
Given a tile is neither in cache nor in the database
When `GetOrDownloadTileAsync` is called
Then `ISatelliteDownloader.DownloadSingleTileAsync` is invoked, the downloaded tile is inserted into the repository, the bytes are read, cached, and returned.
**AC-5: Endpoint no longer injects downloader/repo/cache**
Given the post-refactor `Program.cs`
When the `ServeTile` handler is inspected
Then it injects only `ITileService`, `HttpContext`, and `ILogger<Program>` (not `ISatelliteDownloader`, `ITileRepository`, or `IMemoryCache`).
## Unit Tests
| AC Ref | What to Test | Required Outcome |
|--------|--------------|------------------|
| AC-2 | Cache hit | Mock cache returns bytes; repo + downloader mocks asserted unused |
| AC-3 | Repo hit | Mock repo returns tile entity with existing file path; downloader mock asserted unused |
| AC-4 | Downloader fallback | Mock repo returns null; downloader returns DownloadedTileInfoV2; assert insert + return |
## Constraints
- Public route, query/body shape, response shape preserved.
- No new external libraries.
- Cache lifetime (1h absolute, 30min sliding) preserved exactly.
## Risks & Mitigation
**Risk 1: Cache singleton lifetime semantics change**
- *Risk*: Moving `IMemoryCache` from endpoint scope into service scope might alter cache key collision behavior.
- *Mitigation*: TileService is registered as `Singleton`; `IMemoryCache` is also Singleton. Cache keys remain `tile_{z}_{x}_{y}`.
@@ -0,0 +1,66 @@
# Refactor: route GetTileByLatLon through ITileService
**Task**: AZ-311_refactor_gettilebylatlon_via_tileservice
**Name**: GetTileByLatLon via ITileService.DownloadAndStoreSingleTileAsync
**Description**: Move single-tile download+insert logic from `/api/satellite/tiles/latlon` into `ITileService` and thin the endpoint handler.
**Complexity**: 2 points
**Dependencies**: AZ-310
**Component**: TileDownloader (currently in SatelliteProvider.Services)
**Tracker**: AZ-311
**Epic**: AZ-309
## Problem
`Program.cs:206` (`GetTileByLatLon`) injects `ISatelliteDownloader` and `ITileRepository` and re-implements download-then-insert. Same root cause as AZ-310 (architecture baseline F3).
## Outcome
- `GetTileByLatLon` handler is thin: call service, project to response, return.
- `TileService` owns the download+insert flow for single-tile requests.
## Scope
### Included
- New method `Task<TileMetadata> DownloadAndStoreSingleTileAsync(double latitude, double longitude, int zoomLevel, CancellationToken ct = default)` on `ITileService`.
- Implementation calls `ISatelliteDownloader.DownloadSingleTileAsync` then `ITileRepository.InsertAsync`.
- New unit tests for the new method.
- `Program.cs` `GetTileByLatLon` handler thinned.
### Excluded
- Changes to `DownloadTileResponse` shape.
- Changes to `ISatelliteDownloader` (zoom validation chain is unchanged).
## Acceptance Criteria
**AC-1: Endpoint contract preserved**
Given the API is running
When a client calls `GET /api/satellite/tiles/latlon?Latitude=...&Longitude=...&ZoomLevel=...`
Then the response is the same `DownloadTileResponse` JSON shape as before the refactor.
**AC-2: Service owns the flow**
Given valid lat/lon/zoom
When `DownloadAndStoreSingleTileAsync` is called
Then `ISatelliteDownloader.DownloadSingleTileAsync` is invoked once, `ITileRepository.InsertAsync` is invoked once, and the resulting `TileMetadata` is returned.
**AC-3: Endpoint no longer injects downloader/repo**
Given the post-refactor `Program.cs`
When the `GetTileByLatLon` handler is inspected
Then it injects `ITileService` and `ILogger<Program>` only (no `ISatelliteDownloader` or `ITileRepository`).
## Unit Tests
| AC Ref | What to Test | Required Outcome |
|--------|--------------|------------------|
| AC-2 | Happy path | Mocks for downloader + repo wired; assert one call each; assert TileMetadata fields match the downloaded tile |
| AC-2 | Downloader throws | Service propagates the exception; repo `InsertAsync` is not called |
## Constraints
- HTTP route, query parameter names, and response JSON shape preserved exactly.
- Zoom validation in `GoogleMapsDownloaderV2.DownloadSingleTileAsync` keeps firing.
## Risks & Mitigation
**Risk 1: TileMetadata projection drift**
- *Risk*: The endpoint currently constructs `DownloadTileResponse` directly from a `TileEntity`. After refactor it must do so from `TileMetadata`.
- *Mitigation*: Compare every field one-to-one in the new code; existing field-level mapping is straightforward.