diff --git a/NOTES.md b/NOTES.md new file mode 100644 index 0000000..89a2cf5 --- /dev/null +++ b/NOTES.md @@ -0,0 +1,61 @@ +# Notes + +## Events + +As someone who has worked with an entore EDA in a previous role I was very keen to make the alert generation dependent on a real event "ListingUpdated". This is based on a created() or updated() observer method by a listener called ListingObserver. The conditions are: + +- Status _was_ changed +- Listing status is now "Live" +- The status before was NOT live + +I also love events because in EDA this opens up the possibility in the future for other cool things to happen when the event occurs such as hooking in different notification types or triggering Analytics events. + +The requirement of the task mentioned “a listing became live” as a domain concept, so a domain event was the right fit for this. It's set up using `QUEUE_CONNECTION=sync` so the listener will do the job straight away, but this could easily become a queue job (SQS or Laravel Queue driver) and would certainly do this at scale. + +The resulting matches are persisted in the alerts table. If we later wanted email notifications, I'd probably dispatch separate queued jobs from newly created alerts, and track delivery separately from read_at, because read state and notification-delivery state are different concerns." + +## Models & Relationships + +True to the current system architecture I created a new model for "SavedSearch" and "Alert", a user can have many of each and both `belongTo` a User. + +### SavedSearch + +I used separate columns for the search criteria because there are only four fields with clearly defined types. It also makes the SavedSearch data queryable, whereas a data column containing a JSON string would require much more processing when querying. + +### Alert + +An alert is a very simple model/DB structure with foreign keys for user ID, savedSearch ID and listing ID. An alert can never be duplicated because I protect it in two ways: firstOrCreate() checks for an existing user + listing combination, and there's also a DB constraint on user_id and listing_id. No PII in this table is a deliberate choice. + +Alerts are unique per user per listing (enforced by the DB constraint), so the process is idempotent. + +## Tradeoffs & Decisions + +- No backfill. Alerts are only generated for listings that become live after a search is saved. Existing live listings are discoverable through normal search — flooding the alerts list on save would be noisy. +- One alert per user per listing. If a listing matches two of a user's saved searches, they get one alert (linked to the first matching search). The unique constraint on [user_id, listing_id] enforces this. +- Null = no restriction. A saved search with only region: Manchester matches any price, bedroom count, or property type in Manchester. +- Criteria are deliberately limited to what the task specifies. A price range (min + max) would be much better for users, post code radius instead of a hardcoded region, or garden/parking flags are natural extensions but would be scope creep here. + +One tradeoff is that this code doesn't deal with a listing being updated, only when it goes live. So if a house drops in value into someones criteria range it would not trigger the observer. This would be a potential improvement to make. + +The biggest risk to scaling is the SavedSearc::query()->cursor() which could potentially have millions of records, to run the matches() function against - this could run many times a day. I used matches() for this exercise for simplicity, but in a production environment would pass the matching logic to SQL, e.g: + +``` +SavedSearch::query() + ->where(fn ($q) => + $q->whereNull('max_price') + ->orWhere('max_price', '>=', $listing->price) + ) + // other criteria... + ->cursor(); +``` + +## Alert processing — next steps + +A second job would dispatch email/push notifications asynchronously, respecting user preferences and digest frequency. + +The alerts table currently serves as both a record of matches and the user-facing alert list. In production it would also act as a staging area for delivery: + +1. A scheduled worker queries alerts that have not yet been delivered (a notified_at column or a separate alert_notifications table would track this). +2. The worker batches alerts per user into a digest to avoid spamming — the support team's concern ("don't spam people") is a real constraint. +3. Each batch is dispatched as a queued notification (Laravel's mail and/or database channels), decoupling delivery from generation so failures can be retried independently. +4. The database uniqueness constraint on [user_id, listing_id] means the generation side is idempotent, so a retried or duplicated job cannot produce duplicate alerts, so the worker only needs to track delivery state. diff --git a/app/Events/ListingBecameLive.php b/app/Events/ListingBecameLive.php new file mode 100644 index 0000000..c5d1f05 --- /dev/null +++ b/app/Events/ListingBecameLive.php @@ -0,0 +1,14 @@ +user() + ->alerts() + ->with('listing.branch', 'savedSearch') + ->latest() + ->paginate(15); + + return Inertia::render('Alerts/Index', [ + 'alerts' => AlertResource::collection($alerts), + ]); + } +} diff --git a/app/Http/Controllers/SavedSearchController.php b/app/Http/Controllers/SavedSearchController.php new file mode 100644 index 0000000..e333ce2 --- /dev/null +++ b/app/Http/Controllers/SavedSearchController.php @@ -0,0 +1,50 @@ +user() + ->savedSearches() + ->latest() + ->get(); + + return Inertia::render('SavedSearches/Index', [ + 'savedSearches' => SavedSearchResource::collection($searches), + 'regions' => Branch::query()->distinct()->orderBy('region')->pluck('region'), + 'propertyTypes' => PropertyType::options(), + ]); + } + + public function store(SavedSearchRequest $request): RedirectResponse + { + $request->user()->savedSearches()->create( + $request->validated(), + ); + + return redirect()->route('saved-searches.index'); + } + + public function destroy(Request $request, SavedSearch $savedSearch): RedirectResponse + { + if ($request->user()->cannot('delete', $savedSearch)) { + abort(403); + } + + $savedSearch->delete(); + + return redirect()->route('saved-searches.index'); + } +} diff --git a/app/Http/Requests/SavedSearchRequest.php b/app/Http/Requests/SavedSearchRequest.php new file mode 100644 index 0000000..06aa6a5 --- /dev/null +++ b/app/Http/Requests/SavedSearchRequest.php @@ -0,0 +1,46 @@ + + */ + public function rules(): array + { + return [ + 'max_price' => ['nullable', 'integer', 'min:1'], + 'min_bedrooms' => ['nullable', 'integer', 'min:0', 'max:20'], + 'property_type' => ['nullable', new Enum(PropertyType::class)], + 'region' => ['nullable', 'string', 'max:100'], + ]; + } + + /** + * A saved search with no criteria would match everything, which is not + * useful and would generate noise. Require at least one. + * + * @param Validator $validator + */ + public function withValidator($validator): void + { + $validator->after(function ($validator) { + $criteria = ['max_price', 'min_bedrooms', 'property_type', 'region']; + + if (! collect($criteria)->contains(fn (string $field) => $this->filled($field))) { + $validator->errors()->add('criteria', 'At least one search criterion is required.'); + } + }); + } +} diff --git a/app/Http/Resources/AlertResource.php b/app/Http/Resources/AlertResource.php new file mode 100644 index 0000000..fd1cf46 --- /dev/null +++ b/app/Http/Resources/AlertResource.php @@ -0,0 +1,26 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'listing' => new ListingResource($this->listing), + 'saved_search' => new SavedSearchResource($this->savedSearch), + 'created_at' => $this->created_at->toIso8601String(), + ]; + } +} diff --git a/app/Http/Resources/SavedSearchResource.php b/app/Http/Resources/SavedSearchResource.php new file mode 100644 index 0000000..8e66df5 --- /dev/null +++ b/app/Http/Resources/SavedSearchResource.php @@ -0,0 +1,29 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'max_price' => $this->max_price, + 'min_bedrooms' => $this->min_bedrooms, + 'property_type' => $this->property_type?->value, + 'property_type_label' => $this->property_type?->label(), + 'region' => $this->region, + 'created_at' => $this->created_at->toIso8601String(), + ]; + } +} diff --git a/app/Listeners/GenerateSavedSearchAlerts.php b/app/Listeners/GenerateSavedSearchAlerts.php new file mode 100644 index 0000000..b485e7f --- /dev/null +++ b/app/Listeners/GenerateSavedSearchAlerts.php @@ -0,0 +1,31 @@ +listing->loadMissing('branch'); + + SavedSearch::query() + ->cursor() + ->filter(fn (SavedSearch $search) => $search->matches($listing)) + ->each(function (SavedSearch $search) use ($listing) { + Alert::query()->firstOrCreate( + [ + 'user_id' => $search->user_id, + 'listing_id' => $listing->id, + ], + [ + 'saved_search_id' => $search->id, + ], + ); + }); + } +} diff --git a/app/Models/Alert.php b/app/Models/Alert.php new file mode 100644 index 0000000..cc08a43 --- /dev/null +++ b/app/Models/Alert.php @@ -0,0 +1,56 @@ + */ + use HasFactory; + + protected $fillable = [ + 'user_id', + 'saved_search_id', + 'listing_id', + ]; + + /** + * @return BelongsTo + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * @return BelongsTo + */ + public function savedSearch(): BelongsTo + { + return $this->belongsTo(SavedSearch::class); + } + + /** + * @return BelongsTo + */ + public function listing(): BelongsTo + { + return $this->belongsTo(Listing::class); + } +} diff --git a/app/Models/Listing.php b/app/Models/Listing.php index 45efad3..d8639f5 100644 --- a/app/Models/Listing.php +++ b/app/Models/Listing.php @@ -4,7 +4,9 @@ use App\Enums\ListingStatus; use App\Enums\PropertyType; +use App\Observers\ListingObserver; use Database\Factories\ListingFactory; +use Illuminate\Database\Eloquent\Attributes\ObservedBy; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -31,6 +33,7 @@ * @property Carbon $updated_at * @property-read Branch $branch */ +#[ObservedBy(ListingObserver::class)] class Listing extends Model { /** @use HasFactory */ diff --git a/app/Models/SavedSearch.php b/app/Models/SavedSearch.php new file mode 100644 index 0000000..5265fe2 --- /dev/null +++ b/app/Models/SavedSearch.php @@ -0,0 +1,80 @@ + */ + use HasFactory; + + protected $fillable = [ + 'user_id', + 'max_price', + 'min_bedrooms', + 'property_type', + 'region', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'max_price' => 'integer', + 'min_bedrooms' => 'integer', + 'property_type' => PropertyType::class, + ]; + } + + /** + * @return BelongsTo + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * Does the given listing satisfy every criterion on this saved search? + * Null criteria are wildcards — they do not restrict the match. + */ + public function matches(Listing $listing): bool + { + if ($this->max_price !== null && $listing->price > $this->max_price) { + return false; + } + + if ($this->min_bedrooms !== null && $listing->bedrooms < $this->min_bedrooms) { + return false; + } + + if ($this->property_type !== null && $listing->property_type !== $this->property_type) { + return false; + } + + if ($this->region !== null && $listing->branch->region !== $this->region) { + return false; + } + + return true; + } +} diff --git a/app/Models/User.php b/app/Models/User.php index f6ba1d2..920ea91 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -7,6 +7,7 @@ use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Hidden; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; @@ -29,4 +30,20 @@ protected function casts(): array 'password' => 'hashed', ]; } + + /** + * @return HasMany + */ + public function savedSearches(): HasMany + { + return $this->hasMany(SavedSearch::class); + } + + /** + * @return HasMany + */ + public function alerts(): HasMany + { + return $this->hasMany(Alert::class); + } } diff --git a/app/Observers/ListingObserver.php b/app/Observers/ListingObserver.php new file mode 100644 index 0000000..f927038 --- /dev/null +++ b/app/Observers/ListingObserver.php @@ -0,0 +1,28 @@ +status === ListingStatus::Live) { + ListingBecameLive::dispatch($listing); + } + } + + public function updated(Listing $listing): void + { + if ( + $listing->wasChanged('status') + && $listing->status === ListingStatus::Live + && $listing->getOriginal('status') !== ListingStatus::Live->value + ) { + ListingBecameLive::dispatch($listing); + } + } +} diff --git a/app/Policies/SavedSearchPolicy.php b/app/Policies/SavedSearchPolicy.php new file mode 100644 index 0000000..08c9a7d --- /dev/null +++ b/app/Policies/SavedSearchPolicy.php @@ -0,0 +1,19 @@ +id === $savedSearch->user_id; + } + + public function delete(User $user, SavedSearch $savedSearch): bool + { + return $user->id === $savedSearch->user_id; + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 0f6d5ab..e64a757 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,7 +2,10 @@ namespace App\Providers; +use App\Events\ListingBecameLive; +use App\Listeners\GenerateSavedSearchAlerts; use Illuminate\Http\Resources\Json\JsonResource; +use Illuminate\Support\Facades\Event; use Illuminate\Support\ServiceProvider; class AppServiceProvider extends ServiceProvider @@ -24,5 +27,7 @@ public function boot(): void // envelope around every resource just gets in the way. Paginated // collections keep their data/links/meta structure regardless. JsonResource::withoutWrapping(); + + Event::listen(ListingBecameLive::class, GenerateSavedSearchAlerts::class); } } diff --git a/database/factories/AlertFactory.php b/database/factories/AlertFactory.php new file mode 100644 index 0000000..29a8d3f --- /dev/null +++ b/database/factories/AlertFactory.php @@ -0,0 +1,29 @@ + + */ +class AlertFactory extends Factory +{ + protected $model = Alert::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'user_id' => User::factory(), + 'saved_search_id' => SavedSearch::factory(), + 'listing_id' => Listing::factory(), + ]; + } +} diff --git a/database/factories/SavedSearchFactory.php b/database/factories/SavedSearchFactory.php new file mode 100644 index 0000000..1395b28 --- /dev/null +++ b/database/factories/SavedSearchFactory.php @@ -0,0 +1,33 @@ + + */ +class SavedSearchFactory extends Factory +{ + protected $model = SavedSearch::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'user_id' => User::factory(), + 'max_price' => fake()->numberBetween(150, 800) * 1000, + 'min_bedrooms' => fake()->numberBetween(1, 4), + 'property_type' => fake()->randomElement(PropertyType::cases()), + 'region' => fake()->randomElement([ + 'Manchester', 'Leeds', 'Liverpool', 'Sheffield', + 'Newcastle', 'Nottingham', 'Bristol', 'Birmingham', + ]), + ]; + } +} diff --git a/database/migrations/2026_10_04_141649_create_saved_searches_table.php b/database/migrations/2026_10_04_141649_create_saved_searches_table.php new file mode 100644 index 0000000..c259f3b --- /dev/null +++ b/database/migrations/2026_10_04_141649_create_saved_searches_table.php @@ -0,0 +1,28 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->unsignedInteger('max_price')->nullable(); + $table->unsignedTinyInteger('min_bedrooms')->nullable(); + $table->string('property_type')->nullable(); + $table->string('region')->nullable(); + $table->timestamps(); + + $table->index('user_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('saved_searches'); + } +}; diff --git a/database/migrations/2026_10_04_184703_create_alerts_table.php b/database/migrations/2026_10_04_184703_create_alerts_table.php new file mode 100644 index 0000000..2324ef7 --- /dev/null +++ b/database/migrations/2026_10_04_184703_create_alerts_table.php @@ -0,0 +1,27 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->foreignId('saved_search_id')->constrained()->cascadeOnDelete(); + $table->foreignId('listing_id')->constrained()->cascadeOnDelete(); + $table->timestamps(); + + $table->index('saved_search_id'); + $table->unique(['user_id', 'listing_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('alerts'); + } +}; diff --git a/resources/js/pages/Alerts/Index.vue b/resources/js/pages/Alerts/Index.vue new file mode 100644 index 0000000..f42ae82 --- /dev/null +++ b/resources/js/pages/Alerts/Index.vue @@ -0,0 +1,73 @@ + + + diff --git a/resources/js/pages/SavedSearches/Index.vue b/resources/js/pages/SavedSearches/Index.vue new file mode 100644 index 0000000..0a71220 --- /dev/null +++ b/resources/js/pages/SavedSearches/Index.vue @@ -0,0 +1,46 @@ + + + diff --git a/routes/web.php b/routes/web.php index ce04c19..5d51f02 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,7 +1,15 @@ name('listings.index'); Route::get('/listings/{listing}', [ListingController::class, 'show'])->name('listings.show'); + +Route::get('/saved-searches', [SavedSearchController::class, 'index'])->name('saved-searches.index'); +Route::post('/saved-searches', [SavedSearchController::class, 'store'])->name('saved-searches.store'); +Route::delete('/saved-searches/{savedSearch}', [SavedSearchController::class, 'destroy'])->name('saved-searches.destroy'); + +Route::get('/alerts', [AlertController::class, 'index'])->name('alerts.index'); diff --git a/tests/Feature/AlertGenerationTest.php b/tests/Feature/AlertGenerationTest.php new file mode 100644 index 0000000..83ef259 --- /dev/null +++ b/tests/Feature/AlertGenerationTest.php @@ -0,0 +1,194 @@ +create(['region' => 'Manchester']); + $user = User::factory()->create(); + + $search = SavedSearch::factory()->for($user)->create([ + 'max_price' => 300_000, + 'min_bedrooms' => 2, + 'property_type' => PropertyType::Flat, + 'region' => 'Manchester', + ]); + + $listing = Listing::factory()->for($branch)->create([ + 'price' => 250_000, + 'bedrooms' => 3, + 'property_type' => PropertyType::Flat, + 'status' => ListingStatus::Draft, + ]); + + $this->assertDatabaseCount('alerts', 0); + + $listing->update([ + 'status' => ListingStatus::Live, + 'listed_at' => now(), + ]); + + $this->assertDatabaseCount('alerts', 1); + $this->assertDatabaseHas('alerts', [ + 'user_id' => $user->id, + 'saved_search_id' => $search->id, + 'listing_id' => $listing->id, + ]); + } + + public function test_no_alert_for_a_non_matching_saved_search(): void + { + $branch = Branch::factory()->create(['region' => 'Manchester']); + $user = User::factory()->create(); + + SavedSearch::factory()->for($user)->create([ + 'max_price' => 200_000, + 'min_bedrooms' => null, + 'property_type' => null, + 'region' => null, + ]); + + $listing = Listing::factory()->for($branch)->create([ + 'price' => 400_000, + 'status' => ListingStatus::Draft, + ]); + + $listing->update([ + 'status' => ListingStatus::Live, + 'listed_at' => now(), + ]); + + $this->assertDatabaseCount('alerts', 0); + } + + public function test_no_alert_when_a_listing_is_created_as_draft(): void + { + $user = User::factory()->create(); + + SavedSearch::factory()->for($user)->create([ + 'max_price' => null, + 'min_bedrooms' => null, + 'property_type' => null, + 'region' => null, + ]); + + Listing::factory()->draft()->create(); + + $this->assertDatabaseCount('alerts', 0); + } + + public function test_no_alert_when_an_already_live_listing_is_updated(): void + { + $branch = Branch::factory()->create(['region' => 'Manchester']); + $user = User::factory()->create(); + + SavedSearch::factory()->for($user)->create([ + 'max_price' => null, + 'min_bedrooms' => null, + 'property_type' => null, + 'region' => null, + ]); + + $listing = Listing::factory()->for($branch)->create([ + 'status' => ListingStatus::Draft, + ]); + + $listing->update([ + 'status' => ListingStatus::Live, + 'listed_at' => now(), + ]); + + $this->assertDatabaseCount('alerts', 1); + + $listing->update(['price' => $listing->price + 10_000]); + + $this->assertDatabaseCount('alerts', 1); + } + + public function test_alert_is_generated_when_a_listing_is_created_directly_as_live(): void + { + $branch = Branch::factory()->create(['region' => 'Leeds']); + $user = User::factory()->create(); + + $search = SavedSearch::factory()->for($user)->create([ + 'region' => 'Leeds', + 'max_price' => null, + 'min_bedrooms' => null, + 'property_type' => null, + ]); + + $listing = Listing::factory()->live()->for($branch)->create(); + + $this->assertDatabaseCount('alerts', 1); + $this->assertDatabaseHas('alerts', [ + 'user_id' => $user->id, + 'saved_search_id' => $search->id, + 'listing_id' => $listing->id, + ]); + } + + public function test_duplicate_alert_is_not_created_when_multiple_searches_match(): void + { + $branch = Branch::factory()->create(['region' => 'Manchester']); + $user = User::factory()->create(); + + SavedSearch::factory()->for($user)->create([ + 'region' => 'Manchester', + 'max_price' => null, + 'min_bedrooms' => null, + 'property_type' => null, + ]); + + SavedSearch::factory()->for($user)->create([ + 'max_price' => 500_000, + 'region' => null, + 'min_bedrooms' => null, + 'property_type' => null, + ]); + + Listing::factory()->live()->for($branch)->create(['price' => 250_000]); + + $this->assertDatabaseCount('alerts', 1); + } + + public function test_alerts_are_scoped_to_the_correct_users(): void + { + $branch = Branch::factory()->create(['region' => 'Manchester']); + + $alice = User::factory()->create(); + $bob = User::factory()->create(); + + SavedSearch::factory()->for($alice)->create([ + 'region' => 'Manchester', + 'max_price' => null, + 'min_bedrooms' => null, + 'property_type' => null, + ]); + + SavedSearch::factory()->for($bob)->create([ + 'region' => 'Leeds', + 'max_price' => null, + 'min_bedrooms' => null, + 'property_type' => null, + ]); + + Listing::factory()->live()->for($branch)->create(); + + $this->assertDatabaseCount('alerts', 1); + $this->assertDatabaseHas('alerts', ['user_id' => $alice->id]); + $this->assertDatabaseMissing('alerts', ['user_id' => $bob->id]); + } +} diff --git a/tests/Feature/AlertPageTest.php b/tests/Feature/AlertPageTest.php new file mode 100644 index 0000000..14dbc31 --- /dev/null +++ b/tests/Feature/AlertPageTest.php @@ -0,0 +1,151 @@ +orderBy('id')->first(); + } + + public function test_index_renders_the_authenticated_users_alerts(): void + { + $this->seed(); + $user = $this->demoUser(); + + $search = SavedSearch::factory()->for($user)->create(); + + Alert::factory(3)->for($user)->for($search)->create(); + + // Another user's alert — must not appear. + Alert::factory()->create(); + + $this->get('/alerts') + ->assertOk() + ->assertInertia(fn (AssertableInertia $page) => $page + ->component('Alerts/Index') + ->has('alerts.data', 3) + ); + } + + public function test_index_does_not_show_another_users_alerts(): void + { + $this->seed(); + + $otherUser = User::factory()->create(); + Alert::factory(2)->for($otherUser)->create(); + + $this->get('/alerts') + ->assertOk() + ->assertInertia(fn (AssertableInertia $page) => $page + ->has('alerts.data', 0) + ); + } + + public function test_index_includes_listing_data_for_the_ui(): void + { + $this->seed(); + $user = $this->demoUser(); + + $branch = Branch::factory()->create(['region' => 'Manchester']); + $search = SavedSearch::factory()->for($user)->create([ + 'region' => 'Manchester', + 'max_price' => null, + 'min_bedrooms' => null, + 'property_type' => null, + ]); + + $listing = Listing::factory()->for($branch)->create([ + 'price' => 250_000, + 'bedrooms' => 3, + 'property_type' => PropertyType::Flat, + ]); + + Alert::factory()->for($user)->create([ + 'saved_search_id' => $search->id, + 'listing_id' => $listing->id, + ]); + + $this->get('/alerts') + ->assertOk() + ->assertInertia(fn (AssertableInertia $page) => $page + ->has('alerts.data.0', fn (AssertableInertia $alert) => $alert + ->has('id') + ->has('created_at') + ->has('listing', fn (AssertableInertia $l) => $l + ->where('id', $listing->id) + ->where('price', 250_000) + ->where('bedrooms', 3) + ->where('property_type', 'flat') + ->has('address_line_1') + ->has('branch.region') + ->etc() + ) + ->has('saved_search', fn (AssertableInertia $s) => $s + ->where('region', 'Manchester') + ->etc() + ) + ) + ); + } + + public function test_index_is_paginated(): void + { + $this->seed(); + $user = $this->demoUser(); + + $search = SavedSearch::factory()->for($user)->create(); + + Alert::factory(20)->for($user)->for($search)->create(); + + $this->get('/alerts') + ->assertOk() + ->assertInertia(fn (AssertableInertia $page) => $page + ->has('alerts.data', 15) + ->has('alerts.links') + ); + } + + public function test_index_orders_alerts_newest_first(): void + { + $this->seed(); + $user = $this->demoUser(); + + $search = SavedSearch::factory()->for($user)->create(); + + $oldListing = Listing::factory()->create(); + $newListing = Listing::factory()->create(); + + Alert::factory()->for($user)->create([ + 'saved_search_id' => $search->id, + 'listing_id' => $oldListing->id, + 'created_at' => now()->subDay(), + ]); + + Alert::factory()->for($user)->create([ + 'saved_search_id' => $search->id, + 'listing_id' => $newListing->id, + 'created_at' => now(), + ]); + + $this->get('/alerts') + ->assertOk() + ->assertInertia(fn (AssertableInertia $page) => $page + ->where('alerts.data.0.listing.id', $newListing->id) + ->where('alerts.data.1.listing.id', $oldListing->id) + ); + } +} diff --git a/tests/Feature/SavedSearchMatchTest.php b/tests/Feature/SavedSearchMatchTest.php new file mode 100644 index 0000000..ca2023e --- /dev/null +++ b/tests/Feature/SavedSearchMatchTest.php @@ -0,0 +1,234 @@ +create(['region' => $attributes['region'] ?? 'Manchester']); + unset($attributes['region']); + + return Listing::factory()->live()->for($branch)->create($attributes); + } + + public function test_matches_when_all_criteria_are_satisfied(): void + { + $listing = $this->makeListing([ + 'price' => 250_000, + 'bedrooms' => 3, + 'property_type' => PropertyType::Flat, + 'region' => 'Manchester', + ]); + + $search = SavedSearch::factory()->create([ + 'max_price' => 300_000, + 'min_bedrooms' => 2, + 'property_type' => PropertyType::Flat, + 'region' => 'Manchester', + ]); + + $this->assertTrue($search->matches($listing)); + } + + public function test_does_not_match_when_price_exceeds_max(): void + { + $listing = $this->makeListing(['price' => 400_000]); + + $search = SavedSearch::factory()->create([ + 'max_price' => 300_000, + 'min_bedrooms' => null, + 'property_type' => null, + 'region' => null, + ]); + + $this->assertFalse($search->matches($listing)); + } + + public function test_matches_when_price_equals_max(): void + { + $listing = $this->makeListing(['price' => 300_000]); + + $search = SavedSearch::factory()->create([ + 'max_price' => 300_000, + 'min_bedrooms' => null, + 'property_type' => null, + 'region' => null, + ]); + + $this->assertTrue($search->matches($listing)); + } + + public function test_does_not_match_when_bedrooms_below_minimum(): void + { + $listing = $this->makeListing(['bedrooms' => 1]); + + $search = SavedSearch::factory()->create([ + 'min_bedrooms' => 2, + 'max_price' => null, + 'property_type' => null, + 'region' => null, + ]); + + $this->assertFalse($search->matches($listing)); + } + + public function test_matches_when_bedrooms_equal_minimum(): void + { + $listing = $this->makeListing(['bedrooms' => 2]); + + $search = SavedSearch::factory()->create([ + 'min_bedrooms' => 2, + 'max_price' => null, + 'property_type' => null, + 'region' => null, + ]); + + $this->assertTrue($search->matches($listing)); + } + + public function test_does_not_match_when_property_type_differs(): void + { + $listing = $this->makeListing(['property_type' => PropertyType::Detached]); + + $search = SavedSearch::factory()->create([ + 'property_type' => PropertyType::Flat, + 'max_price' => null, + 'min_bedrooms' => null, + 'region' => null, + ]); + + $this->assertFalse($search->matches($listing)); + } + + public function test_does_not_match_when_region_differs(): void + { + $listing = $this->makeListing(['region' => 'Leeds']); + + $search = SavedSearch::factory()->create([ + 'region' => 'Manchester', + 'max_price' => null, + 'min_bedrooms' => null, + 'property_type' => null, + ]); + + $this->assertFalse($search->matches($listing)); + } + + public function test_null_max_price_matches_any_price(): void + { + $listing = $this->makeListing(['price' => 9_999_999]); + + $search = SavedSearch::factory()->create([ + 'max_price' => null, + 'min_bedrooms' => null, + 'property_type' => null, + 'region' => null, + ]); + + $this->assertTrue($search->matches($listing)); + } + + public function test_null_min_bedrooms_matches_any_bedroom_count(): void + { + $listing = $this->makeListing(['bedrooms' => 1]); + + $search = SavedSearch::factory()->create([ + 'max_price' => null, + 'min_bedrooms' => null, + 'property_type' => null, + 'region' => null, + ]); + + $this->assertTrue($search->matches($listing)); + } + + public function test_null_property_type_matches_any_type(): void + { + $listing = $this->makeListing(['property_type' => PropertyType::Bungalow]); + + $search = SavedSearch::factory()->create([ + 'property_type' => null, + 'max_price' => null, + 'min_bedrooms' => null, + 'region' => null, + ]); + + $this->assertTrue($search->matches($listing)); + } + + public function test_null_region_matches_any_region(): void + { + $listing = $this->makeListing(['region' => 'Newcastle']); + + $search = SavedSearch::factory()->create([ + 'region' => null, + 'max_price' => null, + 'min_bedrooms' => null, + 'property_type' => null, + ]); + + $this->assertTrue($search->matches($listing)); + } + + public function test_all_null_criteria_matches_any_listing(): void + { + $listing = $this->makeListing(); + + $search = SavedSearch::factory()->create([ + 'max_price' => null, + 'min_bedrooms' => null, + 'property_type' => null, + 'region' => null, + ]); + + $this->assertTrue($search->matches($listing)); + } + + public function test_partial_criteria_only_restricts_specified_fields(): void + { + $listing = $this->makeListing([ + 'price' => 500_000, + 'bedrooms' => 1, + 'property_type' => PropertyType::Terraced, + 'region' => 'Bristol', + ]); + + $search = SavedSearch::factory()->create([ + 'region' => 'Bristol', + 'max_price' => null, + 'min_bedrooms' => null, + 'property_type' => null, + ]); + + $this->assertTrue($search->matches($listing)); + } + + public function test_fails_on_first_unmet_criterion_among_mixed_criteria(): void + { + $listing = $this->makeListing([ + 'price' => 200_000, + 'bedrooms' => 4, + 'property_type' => PropertyType::Detached, + 'region' => 'Leeds', + ]); + + $search = SavedSearch::factory()->create([ + 'max_price' => 250_000, + 'min_bedrooms' => 3, + 'property_type' => PropertyType::Detached, + 'region' => 'Manchester', + ]); + + $this->assertFalse($search->matches($listing)); + } +} diff --git a/tests/Feature/SavedSearchTest.php b/tests/Feature/SavedSearchTest.php new file mode 100644 index 0000000..f120174 --- /dev/null +++ b/tests/Feature/SavedSearchTest.php @@ -0,0 +1,162 @@ +orderBy('id')->first(); + } + + public function test_index_lists_the_authenticated_users_saved_searches(): void + { + $this->seed(); + + SavedSearch::factory(3)->for($this->demoUser())->create(); + SavedSearch::factory(2)->create(); + + $this->get('/saved-searches') + ->assertOk() + ->assertInertia(fn (AssertableInertia $page) => $page + ->component('SavedSearches/Index') + ->has('savedSearches', 3) + ); + } + + public function test_index_provides_region_and_property_type_options(): void + { + $this->seed(); + + $this->get('/saved-searches') + ->assertOk() + ->assertInertia(fn (AssertableInertia $page) => $page + ->has('regions') + ->has('propertyTypes', count(PropertyType::cases()), fn (AssertableInertia $type) => $type + ->hasAll('value', 'label') + ) + ); + } + + public function test_store_creates_a_saved_search_for_the_authenticated_user(): void + { + $this->seed(); + + $this->post('/saved-searches', [ + 'max_price' => 350000, + 'min_bedrooms' => 2, + 'property_type' => 'flat', + 'region' => 'Manchester', + ])->assertRedirect(route('saved-searches.index')); + + $this->assertDatabaseHas('saved_searches', [ + 'user_id' => $this->demoUser()->id, + 'max_price' => 350000, + 'min_bedrooms' => 2, + 'property_type' => 'flat', + 'region' => 'Manchester', + ]); + } + + public function test_store_accepts_partial_criteria(): void + { + $this->seed(); + + $this->post('/saved-searches', [ + 'region' => 'Leeds', + ])->assertRedirect(route('saved-searches.index')); + + $this->assertDatabaseHas('saved_searches', [ + 'user_id' => $this->demoUser()->id, + 'max_price' => null, + 'min_bedrooms' => null, + 'property_type' => null, + 'region' => 'Leeds', + ]); + } + + public function test_store_rejects_when_no_criteria_are_provided(): void + { + $this->seed(); + + $this->post('/saved-searches', []) + ->assertSessionHasErrors('criteria'); + + $this->assertDatabaseCount('saved_searches', 0); + } + + public function test_store_rejects_all_blank_criteria(): void + { + $this->seed(); + + $this->post('/saved-searches', [ + 'max_price' => '', + 'min_bedrooms' => '', + 'property_type' => '', + 'region' => '', + ])->assertSessionHasErrors('criteria'); + + $this->assertDatabaseCount('saved_searches', 0); + } + + public function test_store_validates_property_type(): void + { + $this->seed(); + + $this->post('/saved-searches', [ + 'property_type' => 'castle', + ])->assertSessionHasErrors('property_type'); + } + + public function test_store_validates_max_price_is_non_negative(): void + { + $this->seed(); + + $this->post('/saved-searches', [ + 'max_price' => -1, + ])->assertSessionHasErrors('max_price'); + } + + public function test_store_validates_min_bedrooms_range(): void + { + $this->seed(); + + $this->post('/saved-searches', [ + 'min_bedrooms' => 21, + ])->assertSessionHasErrors('min_bedrooms'); + } + + public function test_destroy_deletes_the_users_saved_search(): void + { + $this->seed(); + + $search = SavedSearch::factory()->for($this->demoUser())->create(); + + $this->delete("/saved-searches/{$search->id}") + ->assertRedirect(route('saved-searches.index')); + + $this->assertDatabaseMissing('saved_searches', ['id' => $search->id]); + } + + public function test_destroy_rejects_another_users_saved_search(): void + { + $this->seed(); + + $otherUser = User::factory()->create(); + $search = SavedSearch::factory()->for($otherUser)->create(); + + $this->delete("/saved-searches/{$search->id}") + ->assertForbidden(); + + $this->assertDatabaseHas('saved_searches', ['id' => $search->id]); + } +}