mirror of
https://github.com/azaion/ui.git
synced 2026-06-21 10:51:11 +00:00
[AZ-498] [AZ-499] Cycle 2 batch 11: satellite tiles + OWM hardening
AZ-498 — self-hosted satellite tiles + drop classic/satellite toggle: - Single TILE_URL via getTileUrl() (mirrors getOwmBaseUrl/getApiBase pattern from AZ-449/AZ-450); env-var VITE_SATELLITE_TILE_URL with dev default http://localhost:5100/tiles/{z}/{x}/{y}. - FlightMap + MiniMap render one TileLayer with crossOrigin="use-credentials" so Leaflet's <img> tile fetcher attaches the same-origin satellite-provider auth cookie. - ImportMetaEnv + .env.example collapse the prior OSM/Esri pair into one var. The flights.planner.satellite i18n key is removed in lockstep across en.json + ua.json (parity preserved). - E2E harness wired end-to-end: compose passes the new var to azaion-ui; tile-stub serves /tiles/{z}/{x}/{y} with Content-Type=image/jpeg + Cache-Control + ETag matching the contract; infrastructure.e2e.ts AC-2 asserts the new path; dead OSM defenses removed from EXTERNAL_HOSTS route guard. - Fast-profile MSW handlers rewritten for the cookie-auth path shape. - 8 colocated fast tests under src/features/flights/__tests__/. AZ-499 — mission-planner OWM env-var hardening + AZ-482 source-scan gap close: - WeatherService.ts reads VITE_OWM_API_KEY + VITE_OWM_BASE_URL; fail-soft null when key unset (mirrors AZ-448 main-SPA contract). Public signature getWeatherData(lat, lon) preserved. - mission-planner/.env.example + vite-env.d.ts declare both vars. - New owm_key_in_source banned-deps kind scans src/ AND mission-planner/ for the rotated literal; STC-SEC1C row added to scripts/run-tests.sh; check-banned-deps.mjs dispatch extended. - 7 fast tests under tests/mission_planner_weather.test.ts cover AC-1..AC-4 + trailing-slash + happy path + network-error fail-soft. Spec drift (recorded in batch_11_report.md, user-approved Choose B on 2026-05-12): - AZ-498 AC-8 dropped (named tile_split_zoom* files belong to AZ-474 image-annotation surface, not map tiles). - 4 missing files added in-scope (msw tiles handler, tile-stub server, compose env, dead VITE_TILE_BASE_URL replaced). - AZ-499 STC-S6 ID conflict resolved by using STC-SEC1C. Pending USER ACTION (BLOCKING for AZ-499 close): - Revoke OpenWeatherMap key 335799082893fad97fa36118b131f919 at home.openweathermap.org/api_keys; capture evidence on AZ-499. Cross-workspace deploy gate (handled at autodev Step 16, not a Step-10 blocker for AZ-498): - satellite-provider cookie-auth on GET /tiles/{z}/{x}/{y} (separate AZAION ticket on the satellite-provider workspace). Reports: _docs/03_implementation/batch_11_report.md and _docs/03_implementation/reviews/batch_11_review.md (verdict PASS_WITH_WARNINGS — 1 Low, pre-existing trim-trailing-slash duplication across vite roots). Static gates: STC-ARCH-01, STC-ARCH-02, STC-T1, STC-FP22, STC-FP23, STC-SEC1C all PASS post-refactor. +15 fast tests; +1 STC-SEC1C row. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -8,7 +8,7 @@ import DrawControl from './DrawControl'
|
||||
import MapPoint from './MapPoint'
|
||||
import MiniMap from './MiniMap'
|
||||
import { defaultIcon } from './mapIcons'
|
||||
import { TILE_URLS } from './types'
|
||||
import { getTileUrl } from './types'
|
||||
import type { FlightPoint, CalculatedPointInfo, MapRectangle, ActionMode, MovingPointInfo } from './types'
|
||||
|
||||
interface MapEventsProps {
|
||||
@@ -86,7 +86,6 @@ export default function FlightMap({
|
||||
}: Props) {
|
||||
const { t } = useTranslation()
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [mapType, setMapType] = useState<'classic' | 'satellite'>('satellite')
|
||||
const [movingPoint, setMovingPoint] = useState<MovingPointInfo | null>(null)
|
||||
const [draggablePoints, setDraggablePoints] = useState(points)
|
||||
const polylineClickRef = useRef(false)
|
||||
@@ -123,13 +122,14 @@ export default function FlightMap({
|
||||
<MapContainer center={currentPosition} zoom={15} className="h-full w-full">
|
||||
<ClickHandler />
|
||||
<TileLayer
|
||||
url={mapType === 'classic' ? TILE_URLS.classic : TILE_URLS.satellite}
|
||||
attribution={mapType === 'classic' ? '© <a href="https://www.openstreetmap.org/copyright">OSM</a>' : 'Satellite'}
|
||||
url={getTileUrl()}
|
||||
crossOrigin="use-credentials"
|
||||
attribution="Satellite"
|
||||
/>
|
||||
<MapEvents points={draggablePoints} handlePolylineClick={handlePolylineClick} containerRef={containerRef} onMapMove={onMapMove} />
|
||||
<SetView center={currentPosition} />
|
||||
|
||||
{movingPoint && <MiniMap pointPosition={movingPoint} mapType={mapType} />}
|
||||
{movingPoint && <MiniMap pointPosition={movingPoint} />}
|
||||
|
||||
{draggablePoints.map((point, index) => (
|
||||
<MapPoint key={point.id}
|
||||
@@ -171,13 +171,6 @@ export default function FlightMap({
|
||||
Click and drag on the map to draw a {actionMode === 'workArea' ? 'work area' : 'no-go zone'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button onClick={() => setMapType(m => m === 'classic' ? 'satellite' : 'classic')}
|
||||
className={`absolute top-2 right-2 z-[400] px-2 py-1 text-xs rounded border ${
|
||||
mapType === 'satellite' ? 'bg-az-panel border-az-orange text-white' : 'bg-az-panel border-az-border text-az-text'
|
||||
}`}>
|
||||
{t('flights.planner.satellite')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { MapContainer, TileLayer, CircleMarker, useMap } from 'react-leaflet'
|
||||
import { useEffect } from 'react'
|
||||
import type L from 'leaflet'
|
||||
import { TILE_URLS } from './types'
|
||||
import { getTileUrl } from './types'
|
||||
import type { MovingPointInfo } from './types'
|
||||
|
||||
function UpdateCenter({ latlng }: { latlng: L.LatLng }) {
|
||||
@@ -12,10 +12,9 @@ function UpdateCenter({ latlng }: { latlng: L.LatLng }) {
|
||||
|
||||
interface Props {
|
||||
pointPosition: MovingPointInfo
|
||||
mapType: 'classic' | 'satellite'
|
||||
}
|
||||
|
||||
export default function MiniMap({ pointPosition, mapType }: Props) {
|
||||
export default function MiniMap({ pointPosition }: Props) {
|
||||
return (
|
||||
<div
|
||||
className="absolute w-[240px] h-[180px] border border-az-border rounded shadow-lg z-[1000] overflow-hidden pointer-events-none"
|
||||
@@ -23,7 +22,7 @@ export default function MiniMap({ pointPosition, mapType }: Props) {
|
||||
>
|
||||
<MapContainer center={pointPosition.latlng} zoom={18} zoomControl={false}
|
||||
className="w-full h-full" attributionControl={false}>
|
||||
<TileLayer url={mapType === 'classic' ? TILE_URLS.classic : TILE_URLS.satellite} />
|
||||
<TileLayer url={getTileUrl()} crossOrigin="use-credentials" />
|
||||
<CircleMarker center={pointPosition.latlng} radius={3} color="#fa5252" />
|
||||
<UpdateCenter latlng={pointPosition.latlng} />
|
||||
</MapContainer>
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import type L from 'leaflet'
|
||||
import { renderWithProviders, screen } from '../../../../tests/helpers/render'
|
||||
|
||||
// AZ-498 — self-hosted satellite tiles + drop classic/satellite toggle.
|
||||
//
|
||||
// Colocated under src/features/flights/__tests__/ per module-layout's "Tests"
|
||||
// guidance: keeps the cross-component import surface clean (these tests
|
||||
// reach into 05_flights internals — `./FlightMap`, `./MiniMap`, `./types` —
|
||||
// which is intra-component access). Tests/ is reserved for cross-cutting
|
||||
// black-box suites whose imports must go through public-API barrels.
|
||||
//
|
||||
// Covers the spec's fast-profile ACs:
|
||||
// AC-1 — env-resolved getTileUrl() returns the env var verbatim.
|
||||
// AC-2 — when the env var is unset, getTileUrl() returns the dev default
|
||||
// `http://localhost:5100/tiles/{z}/{x}/{y}` (cycle-2 assumption).
|
||||
// AC-3 — every <TileLayer> the SPA renders sets crossOrigin="use-credentials"
|
||||
// so the browser attaches the satellite-provider auth cookie.
|
||||
// AC-4 — the classic/satellite toggle, the `mapType` state, and the
|
||||
// `MiniMap.Props.mapType` prop are all gone.
|
||||
//
|
||||
// Notes:
|
||||
// - AC-5 is statically enforced by tsc on the new ImportMetaEnv shape +
|
||||
// the `.env.example` audit; no runtime test needed.
|
||||
// - AC-6, AC-7 are e2e/contract; AC-8 in the original spec misattributed
|
||||
// `tile_split_zoom*` (image-annotation surface) — see implementation
|
||||
// report. AC-9 is enforced by STC-ARCH-01 / STC-ARCH-02.
|
||||
|
||||
interface TileLayerProps {
|
||||
url?: string
|
||||
crossOrigin?: string
|
||||
attribution?: string
|
||||
}
|
||||
|
||||
interface MapContainerProps {
|
||||
children?: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
vi.mock('react-leaflet', () => ({
|
||||
MapContainer: ({ children, className }: MapContainerProps) => (
|
||||
<div data-testid="map-container" className={className}>{children}</div>
|
||||
),
|
||||
TileLayer: (props: TileLayerProps) => (
|
||||
<img
|
||||
data-testid="tile-layer"
|
||||
data-tile-url={props.url ?? ''}
|
||||
data-cross-origin={props.crossOrigin ?? ''}
|
||||
data-attribution={props.attribution ?? ''}
|
||||
alt=""
|
||||
/>
|
||||
),
|
||||
Marker: () => null,
|
||||
Popup: () => null,
|
||||
Polyline: () => null,
|
||||
Rectangle: () => null,
|
||||
CircleMarker: () => null,
|
||||
useMap: () => ({
|
||||
on: () => undefined,
|
||||
off: () => undefined,
|
||||
setView: () => undefined,
|
||||
removeLayer: () => undefined,
|
||||
getCenter: () => ({ lat: 0, lng: 0 }),
|
||||
invalidateSize: () => undefined,
|
||||
}),
|
||||
useMapEvents: () => null,
|
||||
}))
|
||||
|
||||
// Leaflet itself is touched at import time by FlightMap (`L.polyline`,
|
||||
// `L.Symbol.arrowHead`). Mock the bits the component reaches for so the
|
||||
// import doesn't blow up under jsdom.
|
||||
vi.mock('leaflet', () => {
|
||||
const Lstub = {
|
||||
polyline: () => ({ addTo: () => Lstub.polyline(), on: () => undefined }),
|
||||
polylineDecorator: () => ({ addTo: () => undefined }),
|
||||
Symbol: { arrowHead: () => ({}) },
|
||||
Icon: { Default: class { mergeOptions() {} } },
|
||||
Marker: class {},
|
||||
Layer: class {},
|
||||
LatLngBounds: class {},
|
||||
}
|
||||
return { default: Lstub }
|
||||
})
|
||||
vi.mock('leaflet/dist/leaflet.css', () => ({}))
|
||||
vi.mock('leaflet-polylinedecorator', () => ({}))
|
||||
vi.mock('../DrawControl', () => ({ default: () => null }))
|
||||
vi.mock('../MapPoint', () => ({ default: () => null }))
|
||||
vi.mock('../mapIcons', () => ({ defaultIcon: {} }))
|
||||
|
||||
import FlightMap from '../FlightMap'
|
||||
import MiniMap from '../MiniMap'
|
||||
import { getTileUrl, DEFAULT_SATELLITE_TILE_URL } from '../types'
|
||||
|
||||
const stubLatLng = { lat: 0, lng: 0 } as unknown as L.LatLng
|
||||
const fixedPosition = { lat: 50, lng: 30 }
|
||||
|
||||
const baseFlightMapProps = {
|
||||
points: [],
|
||||
calculatedPointInfo: [],
|
||||
currentPosition: fixedPosition,
|
||||
rectangles: [],
|
||||
setRectangles: () => undefined,
|
||||
rectangleColor: 'red',
|
||||
actionMode: 'points' as const,
|
||||
onAddPoint: () => undefined,
|
||||
onUpdatePoint: () => undefined,
|
||||
onRemovePoint: () => undefined,
|
||||
onAltitudeChange: () => undefined,
|
||||
onMetaChange: () => undefined,
|
||||
onPolylineClick: () => undefined,
|
||||
onPositionChange: () => undefined,
|
||||
onMapMove: () => undefined,
|
||||
}
|
||||
|
||||
describe('AZ-498 — getTileUrl() env resolution', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
it('AC-1: returns the env-set VITE_SATELLITE_TILE_URL verbatim', () => {
|
||||
// Arrange
|
||||
vi.stubEnv('VITE_SATELLITE_TILE_URL', 'http://satellite-provider:5100/tiles/{z}/{x}/{y}')
|
||||
|
||||
// Assert
|
||||
expect(getTileUrl()).toBe('http://satellite-provider:5100/tiles/{z}/{x}/{y}')
|
||||
})
|
||||
|
||||
it('AC-2: returns the dev default when VITE_SATELLITE_TILE_URL is unset', () => {
|
||||
// Arrange
|
||||
vi.stubEnv('VITE_SATELLITE_TILE_URL', '')
|
||||
|
||||
// Assert
|
||||
expect(getTileUrl()).toBe(DEFAULT_SATELLITE_TILE_URL)
|
||||
expect(DEFAULT_SATELLITE_TILE_URL).toBe('http://localhost:5100/tiles/{z}/{x}/{y}')
|
||||
})
|
||||
|
||||
it('AC-2: strips trailing slashes off the env-set URL', () => {
|
||||
// Arrange
|
||||
vi.stubEnv('VITE_SATELLITE_TILE_URL', 'http://satellite-provider:5100/tiles/{z}/{x}/{y}/')
|
||||
|
||||
// Assert
|
||||
expect(getTileUrl()).toBe('http://satellite-provider:5100/tiles/{z}/{x}/{y}')
|
||||
})
|
||||
})
|
||||
|
||||
describe('AZ-498 — FlightMap satellite-only TileLayer', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('VITE_SATELLITE_TILE_URL', '')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
it('AC-3: <TileLayer> declares crossOrigin="use-credentials"', () => {
|
||||
// Act
|
||||
renderWithProviders(<FlightMap {...baseFlightMapProps} />, { withoutAuth: true })
|
||||
|
||||
// Assert
|
||||
const tile = screen.getByTestId('tile-layer')
|
||||
expect(tile.getAttribute('data-cross-origin')).toBe('use-credentials')
|
||||
})
|
||||
|
||||
it('AC-3: <TileLayer> renders the dev-default URL when env is unset', () => {
|
||||
// Act
|
||||
renderWithProviders(<FlightMap {...baseFlightMapProps} />, { withoutAuth: true })
|
||||
|
||||
// Assert
|
||||
const tile = screen.getByTestId('tile-layer')
|
||||
expect(tile.getAttribute('data-tile-url')).toBe(DEFAULT_SATELLITE_TILE_URL)
|
||||
})
|
||||
|
||||
it('AC-4: the classic/satellite toggle button is gone', () => {
|
||||
// Act
|
||||
renderWithProviders(<FlightMap {...baseFlightMapProps} />, { withoutAuth: true })
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByRole('button', { name: /satellite|classic/i })).toBeNull()
|
||||
// Only one <TileLayer> is mounted (no per-mode branching).
|
||||
expect(screen.getAllByTestId('tile-layer')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('AZ-498 — MiniMap satellite-only TileLayer', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('VITE_SATELLITE_TILE_URL', '')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
it('AC-3: MiniMap <TileLayer> declares crossOrigin="use-credentials"', () => {
|
||||
// Act
|
||||
renderWithProviders(
|
||||
<MiniMap pointPosition={{ x: 0, y: 0, latlng: stubLatLng }} />,
|
||||
{ withoutAuth: true },
|
||||
)
|
||||
|
||||
// Assert
|
||||
const tile = screen.getByTestId('tile-layer')
|
||||
expect(tile.getAttribute('data-cross-origin')).toBe('use-credentials')
|
||||
})
|
||||
|
||||
it('AC-4: MiniMap mounts with only `pointPosition` prop (no `mapType`)', () => {
|
||||
// Act — explicitly omit mapType; if MiniMap still required it, TS would
|
||||
// error at compile time. The runtime render also confirms the component
|
||||
// mounts with just the position prop.
|
||||
renderWithProviders(
|
||||
<MiniMap pointPosition={{ x: 0, y: 0, latlng: stubLatLng }} />,
|
||||
{ withoutAuth: true },
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('tile-layer')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -52,13 +52,18 @@ export const PURPOSES = [
|
||||
|
||||
export const COORDINATE_PRECISION = 8
|
||||
|
||||
const trimTrailingSlash = (s: string) => s.replace(/\/+$/, '')
|
||||
// AZ-498 — single self-hosted satellite tile URL. The previous classic/satellite
|
||||
// pair (OSM + Esri) was retired so the SPA only consumes the suite's own
|
||||
// satellite-provider service. Production builds MUST set VITE_SATELLITE_TILE_URL
|
||||
// to the same-origin nginx path (e.g. `/tiles/{z}/{x}/{y}`); the dev default
|
||||
// targets the satellite-provider container on its conventional dev port.
|
||||
//
|
||||
// Read via a function (mirrors `getOwmBaseUrl` in flightPlanUtils.ts) so tests
|
||||
// can stub `import.meta.env` per-case without module-reload tricks.
|
||||
export const DEFAULT_SATELLITE_TILE_URL = 'http://localhost:5100/tiles/{z}/{x}/{y}'
|
||||
|
||||
export const TILE_URLS = {
|
||||
classic:
|
||||
trimTrailingSlash(import.meta.env.VITE_OSM_TILE_URL ?? '') ||
|
||||
'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
satellite:
|
||||
trimTrailingSlash(import.meta.env.VITE_ESRI_TILE_URL ?? '') ||
|
||||
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
|
||||
} as const
|
||||
export function getTileUrl(): string {
|
||||
const raw = import.meta.env.VITE_SATELLITE_TILE_URL
|
||||
if (!raw) return DEFAULT_SATELLITE_TILE_URL
|
||||
return raw.replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
@@ -77,7 +77,6 @@
|
||||
},
|
||||
"invalidJson": "Invalid JSON format",
|
||||
"editJsonHint": "Edit the JSON data as needed.",
|
||||
"satellite": "Satellite",
|
||||
"cameraFov": "Camera FOV / Length / Field",
|
||||
"cameraFovPlaceholder": "FOV parameters",
|
||||
"commAddr": "Communication Addr / Port",
|
||||
|
||||
@@ -77,7 +77,6 @@
|
||||
},
|
||||
"invalidJson": "Невірний JSON формат",
|
||||
"editJsonHint": "Відредагуйте JSON дані за потреби.",
|
||||
"satellite": "Супутник",
|
||||
"cameraFov": "Камера FOV / Фокус",
|
||||
"cameraFovPlaceholder": "Параметри FOV",
|
||||
"commAddr": "Адреса / Порт",
|
||||
|
||||
Vendored
+1
-2
@@ -7,8 +7,7 @@ interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE_URL?: string
|
||||
readonly VITE_OWM_API_KEY?: string
|
||||
readonly VITE_OWM_BASE_URL?: string
|
||||
readonly VITE_OSM_TILE_URL?: string
|
||||
readonly VITE_ESRI_TILE_URL?: string
|
||||
readonly VITE_SATELLITE_TILE_URL?: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
|
||||
Reference in New Issue
Block a user