diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 7022a050..320ffb68 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,3 +1,4 @@ +{"_type":"issue","id":"stackwright-wao","title":"checkForPlaintextSecret: add high-entropy detection for real tokens (bi-directional check)","description":"The checkForPlaintextSecret function in packages/types/src/types/secret-detection.ts currently warns when entropy is low (\u003c 3.8) — catching human-readable plaintext passwords stored in integration auth YAML fields.\n\nHowever, there are two distinct threat classes that one threshold can't catch:\n- Low entropy (\u003c 3.8): human-readable passwords like \"password123\"\n- High entropy (\u003e 4.5): real cryptographic tokens like JWTs, bearer tokens, API keys stored directly in YAML\n\nThe current check only catches the first class. A JWT stored as `token: \"eyJhbGciOiJSUzI1NiJ9...\"` passes through silently.\n\nSuggested fix: change the condition to `entropy \u003c 3.8 || entropy \u003e 4.5` to catch both classes. Update the warning message to distinguish which case triggered.\n\nAdditional context: checkForPlaintextSecret is currently exported but never called (dead code). This issue should also cover wiring it into the prebuild pipeline at the integration auth processing point in packages/build-scripts/src/prebuild.ts. Consider adding a minimum length guard (e.g., skip values \u003c 32 chars for the high-entropy check) to reduce false positives from short but random-looking strings.","notes":"Tags: security, types, build-scripts","status":"open","priority":1,"issue_type":"task","owner":"bot@per-aspera.dev","created_at":"2026-05-19T01:40:56Z","created_by":"Stackwright Bot","updated_at":"2026-05-19T01:40:56Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"stackwright-a1g","title":"feat: Otter Agents AI-assisted site generation pipeline — complete remaining work","description":"Four-otter pipeline (Foreman, Brand, Theme, Page) core architecture is complete. Remaining work: end-to-end testing of full pipeline, create 3-5 example sites generated by the otter raft, refine handoff protocol between otters, design Collection Otter for Phase 2. Affects packages/otters. GitHub: https://github.com/Per-Aspera-LLC/stackwright/issues/236","status":"open","priority":1,"issue_type":"feature","owner":"bot@per-aspera.dev","created_at":"2026-05-18T22:33:48Z","created_by":"Stackwright Bot","updated_at":"2026-05-18T22:33:48Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"stackwright-ufs","title":"feat(types,core): migrate to explicit type field discrimination in content renderer","description":"Content renderer currently uses Object.entries(item)[0] to discriminate content types — relies on JS object insertion order (not guaranteed), prevents TypeScript discriminated unions, produces poor error messages. Migrate to explicit type field on every content item (z.object({ type: z.literal('...'), ... })). Breaking change — coordinate with next major version bump. Acceptance: update Zod schemas, content renderer, TypeScript unions, all YAML files, tests, JSON schemas, AGENTS.md tables. GitHub: https://github.com/Per-Aspera-LLC/stackwright/issues/344","status":"closed","priority":1,"issue_type":"feature","owner":"bot@per-aspera.dev","created_at":"2026-05-18T22:33:44Z","created_by":"Stackwright Bot","updated_at":"2026-05-19T00:22:32Z","closed_at":"2026-05-19T00:22:32Z","close_reason":"Already implemented: contentRenderer.tsx uses item.type for discrimination, content.ts uses z.literal('type') on all schemas. Shipped before this triage run.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"stackwright-8je","title":"a11y: dark mode #FCC03E amber text on near-white backgrounds fails WCAG AA","description":"Dark theme uses #FCC03E (amber/yellow) for headings and sidebar links but darkColors resolves to near-white backgrounds (#fdfdfd, #f5f5f5, #f6f6f6), producing contrast ratios of 1.51–1.61. WCAG AA requires 4.5:1. Fix: darken darkColors backgrounds to ~#1a1a1a/#2a2a2a. Affects @stackwright/themes dark color configuration. GitHub: https://github.com/Per-Aspera-LLC/stackwright/issues/439","status":"closed","priority":2,"issue_type":"bug","owner":"bot@per-aspera.dev","created_at":"2026-05-18T22:34:07Z","created_by":"Stackwright Bot","updated_at":"2026-05-19T00:21:57Z","closed_at":"2026-05-19T00:21:57Z","close_reason":"Fixed: changed darkColors.primary to #92400E and accent to #B45309 in stackwright-docs stackwright.yml in PR fix/a11y-cluster (PR #448)","dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/.changeset/fix-map-component-api.md b/.changeset/fix-map-component-api.md new file mode 100644 index 00000000..97e8f393 --- /dev/null +++ b/.changeset/fix-map-component-api.md @@ -0,0 +1,6 @@ +--- +"@stackwright/types": patch +"@stackwright/core": patch +--- + +Fix Map content type: assemble MapConfig from flat YAML props (was crashing on render), move Map component to base directory, tighten mapLayerSchema.data to z.unknown(), remove duplicate ZodLike declaration, fix checkForPlaintextSecret entropy threshold direction diff --git a/packages/core/src/components/content/Map.tsx b/packages/core/src/components/base/Map.tsx similarity index 50% rename from packages/core/src/components/content/Map.tsx rename to packages/core/src/components/base/Map.tsx index 0f30daa5..b8aa1792 100644 --- a/packages/core/src/components/content/Map.tsx +++ b/packages/core/src/components/base/Map.tsx @@ -1,6 +1,32 @@ import React from 'react'; import { getMapProvider } from '../../map/map-registry.js'; -import type { MapConfig, StackwrightMapProps } from '../../map/map-provider.js'; +import type { MapConfig } from '../../map/map-provider.js'; +import type { MapContent } from '@stackwright/types'; + +/** + * Props for the Map content component. + * + * We accept flat YAML fields (spread from the content renderer) rather than + * a pre-assembled `config` object. The `config: MapConfig` shape lives on + * MapProviderProps — that's the adapter contract. This component bridges + * the two: it reads flat schema fields and assembles the config before + * handing off to the registered MapProvider. + * + * `type` is omitted from MapContent and re-added as optional string so we + * can absorb the runtime value without TypeScript complaining — the content + * renderer spreads `{ type: 'map', ... }` and we don't want that reaching + * the DOM via `...rest`. + */ +type MapProps = Omit & { + /** Absorbed at runtime from content renderer spread — not passed to DOM. */ + type?: string; + /** Optional CSS class name for the wrapper div. */ + className?: string; + /** Optional additional inline styles for the wrapper div. */ + style?: React.CSSProperties; + /** Accessibility label. */ + 'aria-label'?: string; +}; /** * Map — Content component for rendering interactive maps. @@ -37,13 +63,23 @@ import type { MapConfig, StackwrightMapProps } from '../../map/map-provider.js'; * The Map component is SSR-safe. Map providers should use `useEffect` * or `dynamic(() => import(), { ssr: false })` for client-only rendering. * - * @param props - Map configuration and styling props + * @param props - Flat YAML schema fields for map configuration and styling */ -export function Map(props: StackwrightMapProps & { config: MapConfig }): React.ReactElement { +export function Map(props: MapProps): React.ReactElement { const MapProvider = getMapProvider(); const { - config, + // MapConfig fields — assembled into config object below + center, + zoom, + markers, + layers, + view, + terrain, + // BaseContent fields — absorbed so they don't reach the DOM via ...rest + label: _label, + type: _type, + // Display props height = '500px', width = '100%', color, @@ -53,7 +89,17 @@ export function Map(props: StackwrightMapProps & { config: MapConfig }): React.R ...rest } = props; - // Responsive wrapper styles + // Assemble the provider-facing config from flat schema fields + const config: MapConfig = { + center, + zoom, + markers, + layers, + view, + terrain, + }; + + // Responsive wrapper — inline styles only (no Tailwind in core) const wrapperStyle: React.CSSProperties = { width, height, diff --git a/packages/core/src/components/base/index.ts b/packages/core/src/components/base/index.ts index 72c3cccb..2ae73ede 100644 --- a/packages/core/src/components/base/index.ts +++ b/packages/core/src/components/base/index.ts @@ -14,6 +14,7 @@ export { ContactFormStub } from './ContactFormStub'; export { Alert } from './Alert'; export { LayoutGrid } from './LayoutGrid'; export { CollectionList } from './CollectionList'; +export { Map } from './Map'; export { UnknownContentType } from './UnknownContentType'; export * from './Menu'; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 9242c63a..6283f63b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -79,7 +79,7 @@ export type { ConsentCategory, ConsentState } from './utils/consent'; // Map adapter system export * from './map'; -export { Map } from './components/content/Map'; +// Map is exported via './components/base' (already covered by export * from './components/base' above) export type { MapMarker, MapLayer, diff --git a/packages/core/src/utils/componentRegistry.ts b/packages/core/src/utils/componentRegistry.ts index 22c4ee47..f843bd4c 100644 --- a/packages/core/src/utils/componentRegistry.ts +++ b/packages/core/src/utils/componentRegistry.ts @@ -17,7 +17,7 @@ import { } from '../components/base/'; import { Media } from '../components/media/Media'; import { Timeline } from '../components/narrative/Timeline'; -import { Map } from '../components/content/Map'; +import { Map } from '../components/base/Map'; import NavSidebar from '../components/structural/NavSidebar'; import { getStackwrightImage, diff --git a/packages/core/test/components/map.test.tsx b/packages/core/test/components/map.test.tsx new file mode 100644 index 00000000..4f7afed4 --- /dev/null +++ b/packages/core/test/components/map.test.tsx @@ -0,0 +1,199 @@ +import React from 'react'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { Map } from '../../src/components/base/Map'; +import { registerMapProvider, clearMapProvider } from '../../src/map/map-registry'; +import type { MapProviderProps } from '../../src/map/map-provider'; + +// --------------------------------------------------------------------------- +// Mock MapProvider +// +// Renders a single div with data-testid="map-provider" and serializes the +// received `config` as `data-config` so tests can assert what the Map +// component assembled and passed down. +// --------------------------------------------------------------------------- + +const MockMapProvider = ({ config }: MapProviderProps) => ( +
+); + +// --------------------------------------------------------------------------- +// Shared test fixtures +// --------------------------------------------------------------------------- + +const center = { lat: 37.7749, lng: -122.4194 }; +const zoom = 12; + +const minProps = { label: 'test-map', center, zoom }; + +// Helper to grab the parsed config from the mock provider's data attribute. +function getRenderedConfig(): Record { + const provider = screen.getByTestId('map-provider'); + return JSON.parse(provider.getAttribute('data-config') ?? '{}'); +} + +// --------------------------------------------------------------------------- +// Lifecycle — register/clear mock provider around every test +// --------------------------------------------------------------------------- + +beforeEach(() => { + registerMapProvider(MockMapProvider); +}); + +afterEach(() => { + clearMapProvider(); +}); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('Map component — smoke test', () => { + it('renders without crashing with the minimum valid props', () => { + render(); + expect(screen.getByTestId('map-provider')).toBeInTheDocument(); + }); +}); + +describe('Map component — config assembly (the P0 fix)', () => { + it('assembles a config object from flat center and zoom props', () => { + render(); + const config = getRenderedConfig(); + // The key invariant: center and zoom must not be undefined in the config. + // Before the fix, Map expected a pre-built `config` prop — after the fix + // it builds the config itself from flat YAML fields. + expect(config.center).toEqual(center); + expect(config.zoom).toBe(zoom); + }); + + it('forwards optional markers into the assembled config', () => { + const markers = [{ lat: 37.7749, lng: -122.4194, label: 'SF HQ', popup: '123 Market St' }]; + render(); + expect(getRenderedConfig().markers).toEqual(markers); + }); + + it('forwards optional layers into the assembled config', () => { + const layers = [ + { + type: 'polyline' as const, + data: [ + [37.7749, -122.4194], + [40.7128, -74.006], + ], + style: { color: '#FF5733', width: 3 }, + }, + ]; + render(); + expect(getRenderedConfig().layers).toEqual(layers); + }); + + it('forwards optional view and terrain into the assembled config', () => { + render(); + const config = getRenderedConfig(); + expect(config.view).toBe('globe'); + expect(config.terrain).toBe(true); + }); + + it('forwards all MapConfig fields together', () => { + const markers = [{ lat: center.lat, lng: center.lng, label: 'Pin' }]; + render(); + const config = getRenderedConfig(); + expect(config.center).toEqual(center); + expect(config.zoom).toBe(zoom); + expect(config.markers).toEqual(markers); + expect(config.view).toBe('map'); + expect(config.terrain).toBe(false); + }); +}); + +describe('Map component — DOM attribute hygiene', () => { + it('does not spread label onto the wrapper div', () => { + const { container } = render(); + const wrapper = container.firstChild as HTMLElement; + expect(wrapper.getAttribute('label')).toBeNull(); + }); + + it('does not spread type onto the wrapper div', () => { + const { container } = render(); + const wrapper = container.firstChild as HTMLElement; + // `type` is a valid HTML attribute on some elements but should be absorbed + // by the component, not forwarded to the outer div wrapper. + expect(wrapper.getAttribute('type')).toBeNull(); + }); + + it('does not forward label to the inner MapProvider', () => { + render(); + const provider = screen.getByTestId('map-provider'); + expect(provider.getAttribute('label')).toBeNull(); + }); + + it('does not forward type to the inner MapProvider', () => { + render(); + const provider = screen.getByTestId('map-provider'); + expect(provider.getAttribute('type')).toBeNull(); + }); +}); + +describe('Map component — wrapper dimensions', () => { + it('applies default 500px height when no height prop is given', () => { + const { container } = render(); + const wrapper = container.firstChild as HTMLElement; + expect(wrapper.style.height).toBe('500px'); + }); + + it('applies default 100% width when no width prop is given', () => { + const { container } = render(); + const wrapper = container.firstChild as HTMLElement; + expect(wrapper.style.width).toBe('100%'); + }); + + it('respects an explicit string height prop', () => { + const { container } = render(); + const wrapper = container.firstChild as HTMLElement; + expect(wrapper.style.height).toBe('300px'); + expect(wrapper.style.minHeight).toBe('300px'); + }); + + it('respects a numeric height prop and sets minHeight in px', () => { + const { container } = render(); + const wrapper = container.firstChild as HTMLElement; + // React converts numeric px values to "400px" in inline styles + expect(wrapper.style.height).toBe('400px'); + expect(wrapper.style.minHeight).toBe('400px'); + }); + + it('respects an explicit width prop', () => { + const { container } = render(); + const wrapper = container.firstChild as HTMLElement; + expect(wrapper.style.width).toBe('800px'); + }); +}); + +describe('Map component — theming and styling', () => { + it('applies background color to the wrapper', () => { + const { container } = render(); + const wrapper = container.firstChild as HTMLElement; + expect(wrapper.style.background).toBe('rgb(26, 26, 46)'); + }); + + it('applies color prop to the wrapper', () => { + const { container } = render(); + const wrapper = container.firstChild as HTMLElement; + expect(wrapper.style.color).toBe('rgb(255, 255, 255)'); + }); + + it('passes className to the wrapper div', () => { + const { container } = render(); + const wrapper = container.firstChild as HTMLElement; + expect(wrapper.classList.contains('my-map-class')).toBe(true); + }); + + it('merges additional style props into wrapper without clobbering defaults', () => { + const { container } = render(); + const wrapper = container.firstChild as HTMLElement; + // Default styles must still be there + expect(wrapper.style.borderRadius).toBe('8px'); + // Additional style must be merged in + expect(wrapper.style.border).toBe('2px solid red'); + }); +}); diff --git a/packages/types/TASK-1.4-COMPLETE.md b/packages/types/TASK-1.4-COMPLETE.md deleted file mode 100644 index 5f22645e..00000000 --- a/packages/types/TASK-1.4-COMPLETE.md +++ /dev/null @@ -1,140 +0,0 @@ -# Task 1.4: Add MapContent Zod Schema - COMPLETED ✅ - -## Summary -Successfully added comprehensive Zod schemas for MapContent to `@stackwright/types` package, enabling full YAML validation and JSON schema generation for map-based content. - -## Changes Made - -### 1. Core Schema Definitions (packages/types/src/types/content.ts) -Added four new Zod schemas following Stackwright conventions: - -- **MapMarkerSchema**: Point markers with lat/lng, optional label, icon, popup, altitude, and custom data -- **MapLayerSchema**: Geographic layers (markers, polyline, polygon, heatmap, geojson) with optional styling -- **MapConfigSchema**: Map configuration with center, zoom, markers, layers, and custom style - - Uses `.passthrough()` to allow provider-specific config options (e.g., Mapbox, Google Maps) -- **MapContentSchema**: Extends BaseContentSchema with type='map', config, and optional dimensions - -### 2. Type Integration -- Added MapContent to ContentItem discriminated union -- Added mapContentSchema to contentItemSchema union -- Added 'map' to KNOWN_CONTENT_TYPE_KEYS array -- Exported all TypeScript types: MapMarker, MapLayer, MapConfig, MapContent - -### 3. JSON Schema Generation -- Successfully generated updated content-schema.json with map definitions -- Schema includes proper descriptions for IDE autocomplete -- Validates YAML with type: 'map' - -### 4. Comprehensive Test Coverage (packages/types/test/map-content.test.ts) -Created 16 tests covering: -- Basic marker validation -- Marker with all optional fields -- Invalid coordinate rejection -- Layer types (markers, polyline, heatmap, geojson) -- Layer styling -- Map configuration validation -- Zoom range validation -- Provider-specific config via passthrough -- Map content with markers and dimensions -- Required field validation -- **YAML integration test**: Validates real YAML examples - -### 5. Example YAML File (packages/types/test/fixtures/map-content-example.yaml) -Created comprehensive examples demonstrating: -- Basic single-marker maps -- Multiple marker locations -- Polyline routes -- Heatmap layers -- GeoJSON polygons -- 3D markers with altitude -- Provider-specific configurations - -## Validation Results - -### TypeScript Compilation -```bash -✅ cd packages/types && pnpm tsc --noEmit -``` - -### Build Output -```bash -✅ cd packages/types && pnpm build - - ESM/CJS/DTS files generated successfully - - JSON schemas regenerated -``` - -### Test Results -```bash -✅ cd packages/types && pnpm test - - 49 total tests passed - - 16 map-specific tests (including YAML validation) - - No type conflicts with existing types -``` - -## Schema Features - -### Field Descriptions (for IDE autocomplete) -All fields use `.describe()` for rich developer experience: -- "Latitude coordinate (-90 to 90)" -- "Map zoom level (0-20)" -- "Custom marker metadata" -- "Provider-specific layer styling" - -### Validation Rules -- Latitude/longitude: numeric values -- Zoom: 0-20 range enforced -- Layer type: enum validation -- Required fields: center, zoom -- Optional: markers, layers, style, height, width - -### Provider Flexibility -- `.passthrough()` on MapConfig allows any additional fields -- Supports Mapbox, Google Maps, Leaflet-specific options -- No vendor lock-in - -## Example Usage - -```yaml -content_items: - - type: map - label: office-location - background: gray.50 - height: 400 - config: - center: - lat: 40.7128 - lng: -74.0060 - zoom: 12 - markers: - - lat: 40.7128 - lng: -74.0060 - label: Our Office - icon: building - popup: Visit us! -``` - -## Next Steps - -This schema enables: -1. ✅ YAML validation for map content -2. ✅ TypeScript type safety -3. ✅ IDE autocomplete -4. 🔜 Map component implementation (Task 1.5) -5. 🔜 Provider registration (Mapbox, Google Maps, etc.) - -## Files Modified -- `packages/types/src/types/content.ts` - Added schemas and types -- `packages/types/schemas/content-schema.json` - Auto-generated - -## Files Created -- `packages/types/test/map-content.test.ts` - Comprehensive test suite -- `packages/types/test/fixtures/map-content-example.yaml` - Example usage -- `packages/types/TASK-1.4-COMPLETE.md` - This summary - ---- - -**Status**: ✅ COMPLETE -**Tests**: 16/16 passing -**Build**: ✅ Success -**TypeScript**: ✅ No errors -**Ready for**: Phase 1, Task 1.5 (MapContent Component Implementation) diff --git a/packages/types/src/types/content.ts b/packages/types/src/types/content.ts index 49a8ab49..cbb24418 100644 --- a/packages/types/src/types/content.ts +++ b/packages/types/src/types/content.ts @@ -166,7 +166,7 @@ export const mapLayerTypeSchema = z.enum(['polyline', 'polygon', 'geojson']); export const mapLayerSchema = z.object({ type: mapLayerTypeSchema, - data: z.any(), + data: z.unknown(), style: z .object({ color: z.string().optional(), diff --git a/packages/types/src/types/plugin.ts b/packages/types/src/types/plugin.ts index 9be4bf82..4479c3e8 100644 --- a/packages/types/src/types/plugin.ts +++ b/packages/types/src/types/plugin.ts @@ -27,25 +27,6 @@ export interface ZodLike { }; } -/** - * Minimal structural interface used in place of z.ZodTypeAny / z.ZodSchema - * in the PrebuildPlugin public API. - * - * Using a structural interface prevents zod-version-specific internal types - * from bleeding into the published .d.ts. Any real Zod schema satisfies this - * via duck-typing, so existing implementations are unaffected. - */ -export interface ZodLike { - safeParse(data: unknown): - | { success: true } - | { - success: false; - error: { - issues: Array<{ path: PropertyKey[]; message: string }>; - }; - }; -} - /** * Plugin context provided to plugin hooks */ diff --git a/packages/types/src/types/secret-detection.ts b/packages/types/src/types/secret-detection.ts index ab72871b..eca849bd 100644 --- a/packages/types/src/types/secret-detection.ts +++ b/packages/types/src/types/secret-detection.ts @@ -32,12 +32,13 @@ export function checkForPlaintextSecret(value: string, fieldName: string): strin const entropy = estimateEntropy(value); - // High entropy (>4.5 bits/char) = looks like real secret - // Low entropy (<3.5 bits/char) = looks like plaintext - if (entropy < 3.5) { + // Low-to-moderate entropy (<3.8 bits/char) = likely a human-readable plaintext + // password or hardcoded secret (e.g. 'password123', 'mysecret'). + // Higher entropy (≥3.8 bits/char) = looks sufficiently random; not flagged. + if (entropy < 3.8) { return ( - `SECURITY WARNING: "${fieldName}" appears to contain plaintext secrets. ` + - `Use environment variable references like $API_TOKEN instead.` + `SECURITY WARNING: "${fieldName}" appears to be a low-entropy plaintext value that looks like a hardcoded password or secret. ` + + `Use an environment variable reference like $API_TOKEN instead.` ); } diff --git a/packages/types/test/map-content.test.ts b/packages/types/test/map-content.test.ts new file mode 100644 index 00000000..3f030cbd --- /dev/null +++ b/packages/types/test/map-content.test.ts @@ -0,0 +1,272 @@ +import { describe, it, expect } from 'vitest'; +import { mapMarkerSchema, mapLayerSchema, mapContentSchema } from '../src/types/content'; + +// --------------------------------------------------------------------------- +// Shared fixtures +// --------------------------------------------------------------------------- + +const SF = { lat: 37.7749, lng: -122.4194 }; +const NYC = { lat: 40.7128, lng: -74.006 }; + +const baseMap = { + type: 'map' as const, + label: 'office-map', + center: SF, + zoom: 12, +}; + +// --------------------------------------------------------------------------- +// mapMarkerSchema +// --------------------------------------------------------------------------- + +describe('mapMarkerSchema', () => { + it('accepts a valid marker with required fields only', () => { + const result = mapMarkerSchema.safeParse({ lat: SF.lat, lng: SF.lng, label: 'SF HQ' }); + expect(result.success).toBe(true); + }); + + it('accepts a marker with all optional fields populated', () => { + const result = mapMarkerSchema.safeParse({ + lat: SF.lat, + lng: SF.lng, + label: 'SF HQ', + popup: '123 Market St', + icon: 'map-pin', + altitude: 50, + color: '#FF5733', + }); + expect(result.success).toBe(true); + }); + + it('rejects a marker missing lat', () => { + const result = mapMarkerSchema.safeParse({ lng: SF.lng, label: 'SF HQ' }); + expect(result.success).toBe(false); + }); + + it('rejects a marker missing lng', () => { + const result = mapMarkerSchema.safeParse({ lat: SF.lat, label: 'SF HQ' }); + expect(result.success).toBe(false); + }); + + it('rejects a marker missing label', () => { + const result = mapMarkerSchema.safeParse({ lat: SF.lat, lng: SF.lng }); + expect(result.success).toBe(false); + }); + + it('rejects a marker with non-numeric lat', () => { + const result = mapMarkerSchema.safeParse({ lat: 'north', lng: SF.lng, label: 'Bad' }); + expect(result.success).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// mapLayerSchema +// --------------------------------------------------------------------------- + +describe('mapLayerSchema', () => { + it('accepts a valid polyline layer', () => { + const result = mapLayerSchema.safeParse({ + type: 'polyline', + data: [ + [SF.lat, SF.lng], + [NYC.lat, NYC.lng], + ], + }); + expect(result.success).toBe(true); + }); + + it('accepts a valid polygon layer', () => { + const result = mapLayerSchema.safeParse({ + type: 'polygon', + data: [ + [SF.lat, SF.lng], + [NYC.lat, NYC.lng], + [34.0522, -118.2437], + ], + }); + expect(result.success).toBe(true); + }); + + it('accepts a valid geojson layer', () => { + const result = mapLayerSchema.safeParse({ + type: 'geojson', + data: { type: 'FeatureCollection', features: [] }, + }); + expect(result.success).toBe(true); + }); + + it('rejects an invalid layer type', () => { + const result = mapLayerSchema.safeParse({ type: 'heatmap', data: {} }); + expect(result.success).toBe(false); + }); + + it('data accepts a complex GeoJSON FeatureCollection object (z.unknown())', () => { + // The whole point of z.unknown() vs z.any(): any value passes at the schema + // boundary; the consumer must narrow before using it. This test confirms a + // realistic GeoJSON payload is accepted without Zod rejecting it. + const geojson = { + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + geometry: { type: 'Point', coordinates: [SF.lng, SF.lat] }, + properties: { name: 'SF HQ', priority: 1, tags: ['office', 'main'] }, + }, + { + type: 'Feature', + geometry: { + type: 'LineString', + coordinates: [ + [SF.lng, SF.lat], + [NYC.lng, NYC.lat], + ], + }, + properties: null, + }, + ], + }; + const result = mapLayerSchema.safeParse({ type: 'geojson', data: geojson }); + expect(result.success).toBe(true); + }); + + it('accepts a layer with all optional style fields', () => { + const result = mapLayerSchema.safeParse({ + type: 'polyline', + data: [ + [1, 2], + [3, 4], + ], + style: { + color: '#FF0000', + width: 3, + opacity: 0.8, + fillColor: '#FFAAAA', + fillOpacity: 0.3, + }, + label: 'Route A', + }); + expect(result.success).toBe(true); + }); + + it('optional fields (style, label) are truly optional', () => { + const result = mapLayerSchema.safeParse({ type: 'polygon', data: [] }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.style).toBeUndefined(); + expect(result.data.label).toBeUndefined(); + } + }); +}); + +// --------------------------------------------------------------------------- +// mapContentSchema +// --------------------------------------------------------------------------- + +describe('mapContentSchema', () => { + it('accepts a fully valid map content item', () => { + const result = mapContentSchema.safeParse({ + ...baseMap, + markers: [{ lat: SF.lat, lng: SF.lng, label: 'HQ', popup: '123 Market St' }], + layers: [ + { + type: 'polyline', + data: [ + [SF.lat, SF.lng], + [NYC.lat, NYC.lng], + ], + }, + ], + view: 'map', + terrain: false, + height: '500px', + width: '100%', + }); + expect(result.success).toBe(true); + }); + + it('accepts the minimum required fields only', () => { + const result = mapContentSchema.safeParse(baseMap); + expect(result.success).toBe(true); + }); + + it('rejects when center is missing', () => { + const result = mapContentSchema.safeParse({ type: 'map', label: 'no-center', zoom: 12 }); + expect(result.success).toBe(false); + }); + + it('rejects when zoom is missing', () => { + const result = mapContentSchema.safeParse({ type: 'map', label: 'no-zoom', center: SF }); + expect(result.success).toBe(false); + }); + + it('rejects zoom below 0', () => { + const result = mapContentSchema.safeParse({ ...baseMap, zoom: -1 }); + expect(result.success).toBe(false); + }); + + it('rejects zoom above 20', () => { + const result = mapContentSchema.safeParse({ ...baseMap, zoom: 21 }); + expect(result.success).toBe(false); + }); + + it('accepts zoom at boundary values 0 and 20', () => { + expect(mapContentSchema.safeParse({ ...baseMap, zoom: 0 }).success).toBe(true); + expect(mapContentSchema.safeParse({ ...baseMap, zoom: 20 }).success).toBe(true); + }); + + it('rejects when label is missing', () => { + const result = mapContentSchema.safeParse({ type: 'map', center: SF, zoom: 12 }); + expect(result.success).toBe(false); + }); + + it('all optional fields are truly optional', () => { + const result = mapContentSchema.safeParse(baseMap); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.markers).toBeUndefined(); + expect(result.data.layers).toBeUndefined(); + expect(result.data.view).toBeUndefined(); + expect(result.data.terrain).toBeUndefined(); + expect(result.data.height).toBeUndefined(); + expect(result.data.width).toBeUndefined(); + } + }); + + it('accepts view: "map"', () => { + const result = mapContentSchema.safeParse({ ...baseMap, view: 'map' }); + expect(result.success).toBe(true); + }); + + it('accepts view: "globe"', () => { + const result = mapContentSchema.safeParse({ ...baseMap, view: 'globe' }); + expect(result.success).toBe(true); + }); + + it('rejects an invalid view value', () => { + const result = mapContentSchema.safeParse({ ...baseMap, view: 'satellite' }); + expect(result.success).toBe(false); + }); + + it('accepts numeric height and width', () => { + const result = mapContentSchema.safeParse({ ...baseMap, height: 400, width: 800 }); + expect(result.success).toBe(true); + }); + + it('accepts string height and width', () => { + const result = mapContentSchema.safeParse({ ...baseMap, height: '400px', width: '80%' }); + expect(result.success).toBe(true); + }); + + it('preserves optional color and background from baseContentSchema', () => { + const result = mapContentSchema.safeParse({ + ...baseMap, + color: '#ffffff', + background: '#000000', + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.color).toBe('#ffffff'); + expect(result.data.background).toBe('#000000'); + } + }); +});