From ff79ec83b35ecf7aa9029b32a941ef7662e7b883 Mon Sep 17 00:00:00 2001 From: Anthony Navarro Date: Thu, 23 Apr 2026 15:51:49 -0700 Subject: [PATCH] Add saved stream stations --- app/Livewire/Hydro/FloodAlerts.php | 2 +- app/Livewire/Hydro/StreamGauge.php | 150 ++++++++- app/Livewire/Pages/Dashboard.php | 32 +- app/Models/SavedStation.php | 1 + ...2_add_state_cd_to_saved_stations_table.php | 34 ++ resources/js/app.js | 30 +- .../livewire/hydro/stream-gauge.blade.php | 41 ++- .../views/livewire/pages/dashboard.blade.php | 69 ++++ tests/Feature/Hydro/FloodAlertsTest.php | 8 +- tests/Feature/Hydro/StreamGaugeSaveTest.php | 297 ++++++++++++++++++ tests/Feature/Hydro/StreamGaugeTest.php | 2 +- tests/Feature/Pages/DashboardTest.php | 86 +++++ 12 files changed, 732 insertions(+), 20 deletions(-) create mode 100644 database/migrations/2026_04_23_152712_add_state_cd_to_saved_stations_table.php create mode 100644 tests/Feature/Hydro/StreamGaugeSaveTest.php diff --git a/app/Livewire/Hydro/FloodAlerts.php b/app/Livewire/Hydro/FloodAlerts.php index d4f4344..1c33864 100644 --- a/app/Livewire/Hydro/FloodAlerts.php +++ b/app/Livewire/Hydro/FloodAlerts.php @@ -89,7 +89,7 @@ public function boot(NWSAlertsService $nwsAlertsService): void /** * Two-letter US state or territory code for the map panel. */ - public string $stateCd = 'va'; + public string $stateCd = 'wa'; /** * All active national flood alerts, sorted by severity. diff --git a/app/Livewire/Hydro/StreamGauge.php b/app/Livewire/Hydro/StreamGauge.php index a8e6592..bfb264f 100644 --- a/app/Livewire/Hydro/StreamGauge.php +++ b/app/Livewire/Hydro/StreamGauge.php @@ -5,6 +5,7 @@ namespace App\Livewire\Hydro; use App\Api\Queries\WaterServicesQuery; +use App\Models\UsgsStation; use App\Services\WaterServicesService; use Illuminate\Support\Facades\Cache; use Illuminate\View\View; @@ -22,6 +23,9 @@ * The map (wire:ignore) survives re-renders — fresh site data is pushed via * the 'stream-gauges-updated' browser event after each load. * + * Deep-link: ?state=va&site=01646500 pre-selects a state and opens the + * sparkline panel for the given site on arrival. Used by Dashboard bookmark links. + * * Note: loading all active sites for a large state (e.g. TX, CA) may be slow. * The USGS IV API returns one time series per site per parameter — a state with * 400 active stream gauges querying two parameters returns 800 time series entries. @@ -66,7 +70,7 @@ public function boot(WaterServicesService $waterServicesService): void /** * Two-letter US state code used to scope the site query. */ - public string $stateCd = 'va'; + public string $stateCd = 'wa'; /** * All active stream gauge sites for the selected state, grouped by site code. @@ -97,12 +101,58 @@ public function boot(WaterServicesService $waterServicesService): void */ public ?string $error = null; + /** + * USGS site numbers that the authenticated user has bookmarked. + * Empty for guests. Refreshed after every save/unsave action. + * + * @var list + */ + public array $savedSiteCodes = []; + + /** + * Flash message shown after a save or unsave action. + */ + public ?string $saveMessage = null; + + /** + * Whether the last save action succeeded (controls flash message colour). + */ + public bool $saveSuccess = false; + /** * Load sites on initial mount. + * + * If ?state= and/or ?site= are present in the URL (deep-link from Dashboard), + * pre-select the state and open the sparkline panel for the given site. */ public function mount(): void { - $this->loadSites(); + $state = request()->query('state'); + $site = request()->query('site'); + + if (is_string($state) && array_key_exists($state, self::US_STATES)) { + $this->stateCd = $state; + } + + // Always fit bounds on initial mount so the map zooms to the active state. + $this->loadSites(fitBounds: true); + $this->loadSavedSiteCodes(); + + if (is_string($site) && $site !== '') { + $this->selectSite($site); + + // Fly the map to the pre-selected site and open its popup. + $siteData = collect($this->sites ?? [])->firstWhere('site_code', $site); + + if ($siteData) { + $this->dispatch( + 'stream-gauge-preselect', + siteCode: $site, + lat: $siteData['lat'], + lng: $siteData['lng'], + ); + } + } } /** @@ -114,6 +164,7 @@ public function updatedStateCd(): void { $this->selectedSiteCode = null; $this->sparklineData = null; + $this->saveMessage = null; $this->loadSites(fitBounds: true); } @@ -141,6 +192,7 @@ public function selectSite(string $siteCode): void $this->selectedSiteCode = $siteCode; $this->sparklineData = null; $this->error = null; + $this->saveMessage = null; try { // Cache sparkline data per site at 15 minutes — USGS readings arrive @@ -172,6 +224,76 @@ function () use ($siteCode): array { } } + /** + * Save the currently-selected stream gauge to the user's Dashboard. + * + * Creates a UsgsStation record if one does not yet exist for this site code + * (using the name and coordinates already held in $sites). Capped at 30 + * bookmarks per user — mirrors the 20-search cap on QuakeWatch. + */ + public function saveStation(): void + { + $user = auth()->user(); + + if (! $user || ! $this->selectedSiteCode) { + return; + } + + if ($user->savedStations()->count() >= 30) { + $this->saveMessage = 'You have reached the 30 saved station limit. Delete one to add more.'; + $this->saveSuccess = false; + + return; + } + + $siteData = collect($this->sites ?? [])->firstWhere('site_code', $this->selectedSiteCode); + $station = UsgsStation::firstOrCreate( + ['site_no' => $this->selectedSiteCode], + [ + 'name' => $this->sparklineData['site_name'] ?? $this->selectedSiteCode, + 'state' => null, + 'site_type' => 'ST', + 'latitude' => (float) ($siteData['lat'] ?? 0), + 'longitude' => (float) ($siteData['lng'] ?? 0), + ], + ); + + $user->savedStations()->firstOrCreate( + ['station_id' => $station->id], + ['state_cd' => $this->stateCd], + ); + + $this->loadSavedSiteCodes(); + $this->saveMessage = 'Station saved to your dashboard.'; + $this->saveSuccess = true; + } + + /** + * Remove the currently-selected stream gauge from the user's saved stations. + */ + public function unsaveStation(): void + { + $user = auth()->user(); + + if (! $user || ! $this->selectedSiteCode) { + return; + } + + $station = UsgsStation::where('site_no', $this->selectedSiteCode)->first(); + + if (! $station) { + return; + } + + $user->savedStations() + ->where('station_id', $station->id) + ->delete(); + + $this->loadSavedSiteCodes(); + $this->saveMessage = null; + $this->saveSuccess = false; + } + /** * Render the StreamGauge component. */ @@ -245,6 +367,30 @@ function (): array { $this->dispatch('stream-gauges-updated', sites: $this->sites ?? [], fitBounds: $fitBounds, stateCd: $this->stateCd); } + /** + * Populate $savedSiteCodes for the authenticated user. + * + * Stores the set of USGS site numbers the user has bookmarked so the view + * can toggle between "Save" and "Saved ✓" without an extra query per render. + */ + private function loadSavedSiteCodes(): void + { + if (! auth()->check()) { + $this->savedSiteCodes = []; + + return; + } + + $this->savedSiteCodes = auth()->user() + ->savedStations() + ->with('station') + ->get() + ->pluck('station.site_no') + ->filter() + ->values() + ->toArray(); + } + /** * Convert a WaterServicesData allValues array into Chart.js-ready labels + data arrays. * diff --git a/app/Livewire/Pages/Dashboard.php b/app/Livewire/Pages/Dashboard.php index a5fde32..41e0818 100644 --- a/app/Livewire/Pages/Dashboard.php +++ b/app/Livewire/Pages/Dashboard.php @@ -5,6 +5,7 @@ namespace App\Livewire\Pages; use App\Models\SavedEarthquakeSearch; +use App\Models\SavedStation; use Illuminate\Database\Eloquent\Collection; use Illuminate\View\View; use Livewire\Attributes\Layout; @@ -30,11 +31,24 @@ public function deleteSearch(int $id): void } /** - * Render the Dashboard page. + * Remove a saved stream gauge station by saved_stations ID. * - * Passes saved searches ordered newest-first. + * Verifies ownership before deleting so IDs cannot be spoofed. + */ + public function deleteStation(int $id): void + { + $record = SavedStation::find($id); + + if ($record && $record->user_id === auth()->id()) { + $record->delete(); + } + } + + /** + * Render the Dashboard page. * - * @param Collection $searches + * Passes saved earthquake searches and saved stream gauge stations, + * both ordered newest-first. */ public function render(): View { @@ -44,6 +58,16 @@ public function render(): View ->latest() ->get(); - return view('livewire.pages.dashboard', ['searches' => $searches]); + /** @var Collection $stations */ + $stations = auth()->user() + ->savedStations() + ->with('station') + ->latest() + ->get(); + + return view('livewire.pages.dashboard', [ + 'searches' => $searches, + 'stations' => $stations, + ]); } } diff --git a/app/Models/SavedStation.php b/app/Models/SavedStation.php index 09f218b..39246bd 100644 --- a/app/Models/SavedStation.php +++ b/app/Models/SavedStation.php @@ -15,6 +15,7 @@ class SavedStation extends Model protected $fillable = [ 'user_id', 'station_id', + 'state_cd', ]; /** diff --git a/database/migrations/2026_04_23_152712_add_state_cd_to_saved_stations_table.php b/database/migrations/2026_04_23_152712_add_state_cd_to_saved_stations_table.php new file mode 100644 index 0000000..45f125f --- /dev/null +++ b/database/migrations/2026_04_23_152712_add_state_cd_to_saved_stations_table.php @@ -0,0 +1,34 @@ +char('state_cd', 2)->nullable()->after('station_id'); + }); + } + + /** + * Reverse the migration. + */ + public function down(): void + { + Schema::table('saved_stations', function (Blueprint $table) { + $table->dropColumn('state_cd'); + }); + } +}; diff --git a/resources/js/app.js b/resources/js/app.js index cb3d178..4ce273e 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -613,8 +613,11 @@ document.addEventListener('alpine:init', () => { window.Alpine.data('streamGaugeMap', ({ elementId, centerLat = 39.5, centerLng = -98.35, zoom = 4, initialSites = [] }) => ({ map: null, markerLayer: null, + /** @type {Record} Markers indexed by site_code for direct lookup. */ + markers: {}, _sitesListener: null, _resetListener: null, + _preselectListener: null, init() { this.map = L.map(elementId).setView([centerLat, centerLng], zoom); @@ -624,7 +627,10 @@ document.addEventListener('alpine:init', () => { maxZoom: 18, }).addTo(this.map); - this.markerLayer = L.markerClusterGroup({ chunkedLoading: true }); + this.markerLayer = L.markerClusterGroup({ + chunkedLoading: true, + disableClusteringAtZoom: 10, + }); this.map.addLayer(this.markerLayer); this.renderMarkers(initialSites); @@ -637,8 +643,24 @@ document.addEventListener('alpine:init', () => { this.map.flyTo([centerLat, centerLng], zoom, { duration: 0.8 }); }; + // Deep-link: fly to a pre-selected site and open its popup. + // Fired by StreamGauge::mount() when ?site= is present in the URL. + // stream-gauges-updated fires first (renderMarkers runs), so markers + // are already stored in this.markers by the time this fires. + this._preselectListener = (e) => { + const { siteCode, lat, lng } = e.detail; + if (lat && lng) { + this.map.flyTo([lat, lng], 12, { duration: 0.8 }); + } + this.map.once('moveend', () => { + const marker = this.markers[siteCode]; + if (marker) marker.openPopup(); + }); + }; + window.addEventListener('stream-gauges-updated', this._sitesListener); window.addEventListener('stream-gauge-map-reset', this._resetListener); + window.addEventListener('stream-gauge-preselect', this._preselectListener); }, /** @@ -652,6 +674,7 @@ document.addEventListener('alpine:init', () => { */ renderMarkers(sites, fitBounds = false, stateCd = null) { this.markerLayer.clearLayers(); + this.markers = {}; sites.forEach((site) => { if (! site.lat || ! site.lng) return; @@ -678,6 +701,10 @@ document.addEventListener('alpine:init', () => { const marker = L.marker([site.lat, site.lng], { icon }); marker.bindPopup(this.popupContent(site), { minWidth: 230 }); this.markerLayer.addLayer(marker); + + if (site.site_code) { + this.markers[site.site_code] = marker; + } }); if (fitBounds) { @@ -775,6 +802,7 @@ document.addEventListener('alpine:init', () => { destroy() { window.removeEventListener('stream-gauges-updated', this._sitesListener); window.removeEventListener('stream-gauge-map-reset', this._resetListener); + window.removeEventListener('stream-gauge-preselect', this._preselectListener); if (this.map) { this.map.remove(); this.map = null; diff --git a/resources/views/livewire/hydro/stream-gauge.blade.php b/resources/views/livewire/hydro/stream-gauge.blade.php index c203e92..6466813 100644 --- a/resources/views/livewire/hydro/stream-gauge.blade.php +++ b/resources/views/livewire/hydro/stream-gauge.blade.php @@ -141,15 +141,42 @@ class="cursor-pointer border-b border-border/50 transition-colors hover:bg-surfa {{ $sparklineData['site_name'] }} - +
+ @auth + @if (in_array($selectedSiteCode, $savedSiteCodes, true)) + + @else + + @endif + @endauth + +
+ @if ($saveMessage) +

+ {{ $saveMessage }} +

+ @endif + {{-- Streamflow chart --}} @if (! empty($sparklineData['streamflow']['data']))
diff --git a/resources/views/livewire/pages/dashboard.blade.php b/resources/views/livewire/pages/dashboard.blade.php index 5138de4..4dca02c 100644 --- a/resources/views/livewire/pages/dashboard.blade.php +++ b/resources/views/livewire/pages/dashboard.blade.php @@ -14,6 +14,75 @@

Your saved searches and personal data.

+ {{-- Saved stream gauges --}} +
+
+

Saved stream gauges

+ @if ($stations->isNotEmpty()) + + {{ $stations->count() }} / 30 + + @endif +
+ + @if ($stations->isEmpty()) +
+

No saved stations yet.

+

+ Open a site on + HydroWatch + and click + Save in the 3-day chart panel. +

+
+ @else +
+ @foreach ($stations as $savedStation) +
+
+
+

{{ $savedStation->station->name }}

+

+ Saved {{ $savedStation->created_at->diffForHumans() }} +

+
+ +
+ +
+
+
Site no.
+
{{ $savedStation->station->site_no }}
+
+ @if ($savedStation->state_cd) +
+
State
+
{{ strtoupper($savedStation->state_cd) }}
+
+ @endif +
+ + + Open in HydroWatch → + +
+ @endforeach +
+ @endif +
+ {{-- Saved earthquake searches --}}
diff --git a/tests/Feature/Hydro/FloodAlertsTest.php b/tests/Feature/Hydro/FloodAlertsTest.php index c74f4dd..f4723b3 100644 --- a/tests/Feature/Hydro/FloodAlertsTest.php +++ b/tests/Feature/Hydro/FloodAlertsTest.php @@ -34,7 +34,7 @@ public function test_component_renders_with_alerts(): void $this->mockAlertService($this->fakeAlerts()); Livewire::test(FloodAlerts::class) - ->assertSet('stateCd', 'va') + ->assertSet('stateCd', 'wa') ->assertSet('error', null) ->assertSet('listError', null); } @@ -86,7 +86,7 @@ public function test_select_alert_from_list_changes_state(): void $this->mockAlertService($this->fakeAlerts()); Livewire::test(FloodAlerts::class) - ->assertSet('stateCd', 'va') + ->assertSet('stateCd', 'wa') ->call('selectAlertFromList', 'urn:oid:2.49.0.1.840.0.TEST', 'md') ->assertSet('stateCd', 'md'); } @@ -99,9 +99,9 @@ public function test_select_alert_from_list_ignores_unknown_state(): void $this->mockAlertService($this->fakeAlerts()); Livewire::test(FloodAlerts::class) - ->assertSet('stateCd', 'va') + ->assertSet('stateCd', 'wa') ->call('selectAlertFromList', 'urn:oid:2.49.0.1.840.0.TEST', 'xx') - ->assertSet('stateCd', 'va'); + ->assertSet('stateCd', 'wa'); } /** diff --git a/tests/Feature/Hydro/StreamGaugeSaveTest.php b/tests/Feature/Hydro/StreamGaugeSaveTest.php new file mode 100644 index 0000000..b309ee5 --- /dev/null +++ b/tests/Feature/Hydro/StreamGaugeSaveTest.php @@ -0,0 +1,297 @@ +create(); + $station = UsgsStation::factory()->create(['site_no' => '01646500']); + + $this->mockServiceWithSite('01646500'); + + Livewire::actingAs($user) + ->test(StreamGauge::class) + ->call('selectSite', '01646500') + ->call('saveStation') + ->assertSet('saveSuccess', true) + ->assertSet('saveMessage', 'Station saved to your dashboard.'); + + $this->assertDatabaseHas('saved_stations', [ + 'user_id' => $user->id, + 'station_id' => $station->id, + ]); + } + + /** + * Saving creates a UsgsStation record when the site is not yet in the DB. + */ + public function test_saving_creates_usgs_station_if_missing(): void + { + $user = User::factory()->create(); + + $this->mockServiceWithSite('09380000'); + + Livewire::actingAs($user) + ->test(StreamGauge::class) + ->call('selectSite', '09380000') + ->call('saveStation') + ->assertSet('saveSuccess', true); + + $this->assertDatabaseHas('usgs_stations', ['site_no' => '09380000']); + $this->assertDatabaseCount('saved_stations', 1); + } + + /** + * Saving the same station twice creates only one saved_stations record. + */ + public function test_saving_same_station_twice_is_idempotent(): void + { + $user = User::factory()->create(); + $station = UsgsStation::factory()->create(['site_no' => '01646500']); + + $this->mockServiceWithSite('01646500'); + + $component = Livewire::actingAs($user)->test(StreamGauge::class); + $component->call('selectSite', '01646500')->call('saveStation'); + $component->call('saveStation'); // second save + + $this->assertDatabaseCount('saved_stations', 1); + } + + /** + * Saving is capped at 30 stations per user. + */ + public function test_save_is_blocked_at_30_station_limit(): void + { + $user = User::factory()->create(); + + // Pre-fill 30 saved stations. + UsgsStation::factory()->count(30)->create()->each(function (UsgsStation $station) use ($user) { + SavedStation::create([ + 'user_id' => $user->id, + 'station_id' => $station->id, + 'state_cd' => 'va', + ]); + }); + + $target = UsgsStation::factory()->create(['site_no' => '01646500']); + $this->mockServiceWithSite('01646500'); + + Livewire::actingAs($user) + ->test(StreamGauge::class) + ->call('selectSite', '01646500') + ->call('saveStation') + ->assertSet('saveSuccess', false) + ->assertSet('saveMessage', 'You have reached the 30 saved station limit. Delete one to add more.'); + + $this->assertDatabaseMissing('saved_stations', [ + 'user_id' => $user->id, + 'station_id' => $target->id, + ]); + } + + /** + * Authenticated user can unsave a previously saved station. + */ + public function test_authenticated_user_can_unsave_station(): void + { + $user = User::factory()->create(); + $station = UsgsStation::factory()->create(['site_no' => '01646500']); + + SavedStation::create([ + 'user_id' => $user->id, + 'station_id' => $station->id, + 'state_cd' => 'va', + ]); + + $this->mockServiceWithSite('01646500'); + + Livewire::actingAs($user) + ->test(StreamGauge::class) + ->call('selectSite', '01646500') + ->call('unsaveStation'); + + $this->assertDatabaseMissing('saved_stations', [ + 'user_id' => $user->id, + 'station_id' => $station->id, + ]); + } + + /** + * Guest calling saveStation silently does nothing. + */ + public function test_guest_cannot_save_station(): void + { + UsgsStation::factory()->create(['site_no' => '01646500']); + $this->mockServiceWithSite('01646500'); + + Livewire::test(StreamGauge::class) + ->call('selectSite', '01646500') + ->call('saveStation'); + + $this->assertDatabaseCount('saved_stations', 0); + } + + /** + * $savedSiteCodes is populated for an authenticated user with bookmarks. + */ + public function test_saved_site_codes_loaded_for_authenticated_user(): void + { + $user = User::factory()->create(); + $station = UsgsStation::factory()->create(['site_no' => '01646500']); + + SavedStation::create([ + 'user_id' => $user->id, + 'station_id' => $station->id, + 'state_cd' => 'va', + ]); + + $this->mockServiceWithSite('01646500'); + + Livewire::actingAs($user) + ->test(StreamGauge::class) + ->assertSet('savedSiteCodes', ['01646500']); + } + + /** + * $savedSiteCodes is empty for a guest. + */ + public function test_saved_site_codes_empty_for_guest(): void + { + $this->mockServiceWithSite('01646500'); + + Livewire::test(StreamGauge::class) + ->assertSet('savedSiteCodes', []); + } + + /** + * Deep-link ?state= pre-selects the state and dispatches the correct event. + */ + public function test_deep_link_state_param_sets_state_cd(): void + { + $this->mockServiceWithSite('01646500'); + + Livewire::withQueryParams(['state' => 'md']) + ->test(StreamGauge::class) + ->assertSet('stateCd', 'md'); + } + + /** + * An invalid ?state= param falls back to the default 'va'. + */ + public function test_invalid_state_param_is_ignored(): void + { + $this->mockServiceWithSite('01646500'); + + Livewire::withQueryParams(['state' => 'xx']) + ->test(StreamGauge::class) + ->assertSet('stateCd', 'wa'); + } + + /** + * Deep-link ?site= pre-selects the sparkline panel for that site. + */ + public function test_deep_link_site_param_selects_site(): void + { + $this->mockServiceWithSite('01646500'); + + Livewire::withQueryParams(['state' => 'va', 'site' => '01646500']) + ->test(StreamGauge::class) + ->assertSet('selectedSiteCode', '01646500') + ->assertSet('sparklineData', fn ($data) => $data !== null); + } + + /** + * state_cd is stored on the saved_stations record when saving. + */ + public function test_state_cd_is_persisted_when_saving(): void + { + $user = User::factory()->create(); + $station = UsgsStation::factory()->create(['site_no' => '01646500']); + + $this->mockServiceWithSite('01646500'); + + Livewire::actingAs($user) + ->test(StreamGauge::class) // default stateCd = 'wa' + ->call('selectSite', '01646500') + ->call('saveStation'); + + $this->assertDatabaseHas('saved_stations', [ + 'user_id' => $user->id, + 'station_id' => $station->id, + 'state_cd' => 'wa', + ]); + } + + /** + * Bind a WaterServicesService mock that returns a fake two-parameter collection + * for the given site code. Used by tests that need a selectable site. + */ + private function mockServiceWithSite(string $siteCode): void + { + $collection = collect([ + new \App\Data\WaterServicesData( + siteCode: $siteCode, + siteName: 'Test Gauge Station', + lat: 38.9495, + lng: -77.1228, + parameterCode: '00060', + parameterName: 'Streamflow, ft³/s', + unitCode: 'ft3/s', + latestValue: 1234.5, + latestDateTime: '2025-04-14T15:45:00.000-04:00', + qualifiers: ['P'], + allValues: [ + ['value' => 1200.0, 'dateTime' => '2025-04-12T12:00:00.000-04:00'], + ['value' => 1234.5, 'dateTime' => '2025-04-14T15:45:00.000-04:00'], + ], + ), + new \App\Data\WaterServicesData( + siteCode: $siteCode, + siteName: 'Test Gauge Station', + lat: 38.9495, + lng: -77.1228, + parameterCode: '00065', + parameterName: 'Gage height, ft', + unitCode: 'ft', + latestValue: 4.56, + latestDateTime: '2025-04-14T15:45:00.000-04:00', + qualifiers: ['P'], + allValues: [ + ['value' => 4.20, 'dateTime' => '2025-04-12T12:00:00.000-04:00'], + ['value' => 4.56, 'dateTime' => '2025-04-14T15:45:00.000-04:00'], + ], + ), + ]); + + $mock = $this->createMock(WaterServicesService::class); + $mock->method('query')->willReturn($collection); + $this->app->instance(WaterServicesService::class, $mock); + } +} diff --git a/tests/Feature/Hydro/StreamGaugeTest.php b/tests/Feature/Hydro/StreamGaugeTest.php index 38f3a05..1cc0a21 100644 --- a/tests/Feature/Hydro/StreamGaugeTest.php +++ b/tests/Feature/Hydro/StreamGaugeTest.php @@ -33,7 +33,7 @@ public function test_component_renders_with_sites(): void $this->mockService($this->fakeSites()); Livewire::test(StreamGauge::class) - ->assertSet('stateCd', 'va') + ->assertSet('stateCd', 'wa') ->assertSet('error', null); } diff --git a/tests/Feature/Pages/DashboardTest.php b/tests/Feature/Pages/DashboardTest.php index 6346ec8..bb3f2e1 100644 --- a/tests/Feature/Pages/DashboardTest.php +++ b/tests/Feature/Pages/DashboardTest.php @@ -4,7 +4,9 @@ namespace Tests\Feature\Pages; +use App\Models\SavedStation; use App\Models\User; +use App\Models\UsgsStation; use Illuminate\Foundation\Testing\RefreshDatabase; use Livewire\Livewire; use Tests\TestCase; @@ -99,4 +101,88 @@ public function test_user_cannot_delete_another_users_search(): void $this->assertDatabaseHas('saved_earthquake_searches', ['id' => $search->id]); } + + /** + * Saved stream gauge stations belonging to the user are displayed. + */ + public function test_saved_stations_are_listed(): void + { + $user = User::factory()->create(); + $station = UsgsStation::factory()->create(['name' => 'Potomac at DC', 'site_no' => '01646500']); + + SavedStation::create([ + 'user_id' => $user->id, + 'station_id' => $station->id, + 'state_cd' => 'va', + ]); + + $this->actingAs($user) + ->get(route('dashboard')) + ->assertOk() + ->assertSee('Potomac at DC'); + } + + /** + * A user can delete their own saved station via the Livewire action. + */ + public function test_user_can_delete_own_station(): void + { + $user = User::factory()->create(); + $station = UsgsStation::factory()->create(); + + $record = SavedStation::create([ + 'user_id' => $user->id, + 'station_id' => $station->id, + 'state_cd' => 'va', + ]); + + Livewire::actingAs($user) + ->test(\App\Livewire\Pages\Dashboard::class) + ->call('deleteStation', $record->id); + + $this->assertDatabaseMissing('saved_stations', ['id' => $record->id]); + } + + /** + * A user cannot delete a saved station belonging to someone else. + */ + public function test_user_cannot_delete_another_users_station(): void + { + $owner = User::factory()->create(); + $other = User::factory()->create(); + $station = UsgsStation::factory()->create(); + + $record = SavedStation::create([ + 'user_id' => $owner->id, + 'station_id' => $station->id, + 'state_cd' => 'va', + ]); + + Livewire::actingAs($other) + ->test(\App\Livewire\Pages\Dashboard::class) + ->call('deleteStation', $record->id); + + $this->assertDatabaseHas('saved_stations', ['id' => $record->id]); + } + + /** + * Dashboard shows the "Open in HydroWatch" deep-link for a saved station. + */ + public function test_dashboard_shows_hydrowatch_deep_link(): void + { + $user = User::factory()->create(); + $station = UsgsStation::factory()->create(['site_no' => '01646500']); + + SavedStation::create([ + 'user_id' => $user->id, + 'station_id' => $station->id, + 'state_cd' => 'va', + ]); + + $this->actingAs($user) + ->get(route('dashboard')) + ->assertOk() + ->assertSee('hydro-watch') + ->assertSee('01646500'); + } }