--- name: testing-patterns description: Writing and fixing tests, test factories, Event::fake patterns, auth guard testing, PHPUnit configuration, deprecated test groups, and common test pitfalls. --- # Testing Patterns 1033+ tests passing (1 skipped), 0 failures, 0 flaky. PHPUnit 10 with `RefreshDatabase`. Base `TestCase` flushes Redis + array cache in `setUp()` — prevents rate limiter state leaking between tests. 0 deprecated tests remaining (all 40 previously-deprecated files resolved: 18 dead removed, 22 fixed and undeprecated). Dead tests deleted: `DecreaseTeamTotalPhotosTest`, `IncreaseTeamTotalPhotosTest` (listeners removed), `CalculateTagsDifferenceActionTest` (action removed). 32 dead files deleted across v5 audit sessions. ## Key Files - `phpunit.xml` — Config: excludes `deprecated` group, uses `olm_test` DB, Redis DB 2 - `tests/TestCase.php` — Base class: `RefreshDatabase` + Redis flush + `TagKeyCache::forgetAll()` - `tests/Feature/Admin/AdminQueueTest.php` — 11 tests (queue endpoint: filters, pagination, exclusions, auth) - `tests/Feature/UploadValidationTest.php` — 11 tests (EXIF datetime, GPS DMS conversion, zero denominator, 0,0 coords) - `tests/Feature/HasPhotoUploads.php` — Trait for old upload-based tests (deprecated) - `database/factories/PhotoFactory.php` — Photo with user, country, state, geom - `database/factories/Location/CountryFactory.php` — Country with shortcode - `database/factories/Location/StateFactory.php` — State with country FK - `database/factories/Location/CityFactory.php` — City with country + state FKs - `tests/Feature/Bbox/BoundingBoxRetiredTest.php` — 5 tests (all bbox endpoints return 410 Gone) - `database/factories/Litter/Tags/CategoryFactory.php` — Category with unique key - `database/factories/Litter/Tags/LitterObjectFactory.php` — LitterObject with unique key - `database/factories/Litter/Tags/LitterObjectTypeFactory.php` — LitterObjectType with unique key + name - `tests/Feature/Tags/TaggingArchitecturePhase1Test.php` — 20 tests (seeding, relationships, API, idempotency) - `tests/Feature/Tags/ReplacePhotoTagsTest.php` — 5 tests (replace tags, ownership, auth, extra tags cleanup) - `tests/Feature/Teams/TeamPhotosTest.php` — 35 tests (new_tags format, CLO tag edits, member stats, safeguarding, delete, revoke, approval, map) - `tests/Feature/User/PublicProfileTest.php` — 4 tests (public profile data, private returns, privacy settings, 404) - `tests/Feature/Leaderboard/LeaderboardTest.php` — 18 tests (all paths: global, country, state, city scopes) - `tests/Feature/Auth/SanctumTokenAuthTest.php` — Mobile token auth tests - `tests/Feature/Signup/CreateNewUserTest.php` — Registration flow tests - `tests/Feature/Tags/ClassifyTagsServiceTest.php` — 12 tests (category aliases, deprecated tag mapping, unknown tags, getCategory) - `tests/Feature/User/UsersUploadsControllerTest.php` — 9 tests (picked_up filter, pagination, tagged/untagged filters) - `tests/Feature/Photos/PhotoVisibilityTest.php` — 7 tests (per-photo visibility toggle: owner-only, school block, is_public persistence, PhotoObserver dirty tiles) ## Invariants 1. **RefreshDatabase on every test.** The base `TestCase` uses `RefreshDatabase` and flushes Redis in `setUp()` and `tearDown()`. 2. **`photo_tags` uses FK columns.** Tests must create Category/LitterObject records and use their IDs — not strings. 3. **Deprecated tests are excluded by default.** Run with `--group=deprecated` to include them. They use old routes (`/submit`, `/add-tags`) that no longer work with v5. 4. **`Event::fake()` prevents listeners.** If testing event dispatch AND listener side effects (metrics), split into two tests or don't fake. 5. **Notifications table may not exist.** Fake events if testing notification-dispatching code, or create the `notifications` table. ## Patterns ### Base TestCase setup ```php // tests/TestCase.php abstract class TestCase extends BaseTestCase { use CreatesApplication, RefreshDatabase; protected function setUp(): void { parent::setUp(); Redis::connection()->flushdb(); Cache::flush(); // Reset rate limiters (array cache persists between tests) TagKeyCache::forgetAll(); } protected function tearDown(): void { Redis::connection()->flushdb(); parent::tearDown(); } } ``` ### Auth guard patterns ```php // auth:api routes (Passport — mobile legacy) → use 'api' guard $this->actingAs($user, 'api')->postJson('/api/photos/submit', [...]); // auth:sanctum routes (SPA + Sanctum tokens) → use NO guard argument $this->actingAs($user)->postJson('/api/settings/update', [...]); // auth:api,web routes (both guards — teams, v3) → either guard works $this->actingAs($user, 'api')->postJson('/api/v3/tags', [...]); $this->actingAs($user)->postJson('/api/v3/tags', [...]); // also works // CRITICAL: actingAs($user) (no guard) does NOT work for auth:api routes. // actingAs($user, 'api') does NOT work for auth:sanctum routes. // Mismatching guard ↔ middleware = silent 401 with no helpful error message. ``` ### Using factories instead of uploading in tests ```php // BAD: Upload via API requires auth:api, but test uses web guard $this->actingAs($user); // web guard $this->post('/api/photos/submit', [...]); // auth:api → 401, photo not created $photo = $user->photos->last(); // null! // GOOD: Use factories when testing non-upload behavior $photo = Photo::factory()->create(['user_id' => $user->id]); $this->actingAs($user)->post('/api/profile/photos/delete', ['photoid' => $photo->id]); ``` ### Event::fake patterns ```php // Pattern 1: Fake specific events (others still fire) Event::fake([TagsVerifiedByAdmin::class]); // ... do stuff ... Event::assertDispatched(TagsVerifiedByAdmin::class, 1); Event::assertNotDispatched(SchoolDataApproved::class); // Pattern 2: Assert with callback Event::assertDispatched( TagsVerifiedByAdmin::class, fn (TagsVerifiedByAdmin $e) => $e->photo_id === $photo->id ); // Pattern 3: Test both event AND side effects (no fake) // Don't fake — let ProcessPhotoMetrics listener run $this->postJson('/api/v3/tags', $payload); $photo->refresh(); $this->assertNotNull($photo->processed_at); // MetricsService ran ``` ### Spatie Permissions setup (required for team tests) ```php use Spatie\Permission\Models\Permission; use Spatie\Permission\Models\Role; use Spatie\Permission\PermissionRegistrar; protected function setUp(): void { parent::setUp(); // CRITICAL: Reset cached permissions between tests app()[PermissionRegistrar::class]->forgetCachedPermissions(); $permissions = collect([ 'create school team', 'manage school team', 'toggle safeguarding', 'view student identities', ])->map(fn ($name) => Permission::firstOrCreate([ 'name' => $name, 'guard_name' => 'web' ])); $role = Role::firstOrCreate(['name' => 'school_manager', 'guard_name' => 'web']); $role->syncPermissions($permissions); } ``` ### Factory usage — let factories create related models ```php // GOOD: Let factory handle related models $photo = Photo::factory()->create([ 'is_public' => true, 'verified' => VerificationStatus::ADMIN_APPROVED->value, 'city_id' => City::factory(), // PhotoFactory doesn't include city_id by default ]); // BAD: Hardcoding IDs $photo = Photo::factory()->create(['country_id' => 1]); // May not exist ``` ### Team type setup (required for team tests) ```php // team_types.price has no default — always provide it $communityType = TeamType::create(['team' => 'community', 'price' => 0]); $schoolType = TeamType::create(['team' => 'school', 'price' => 0]); ``` ### Seeding tags for tagging tests ```php protected function setUp(): void { parent::setUp(); $this->seed([ GenerateTagsSeeder::class, GenerateBrandsSeeder::class, ]); } ``` ### Seeding tags + types for v5.1 architecture tests ```php protected function setUp(): void { parent::setUp(); $this->seed(GenerateTagsSeeder::class); } // GenerateTagsSeeder seeds all categories, objects, materials, types, and CLO pivots from TagsConfig ``` ### VerificationStatus in assertions ```php // GOOD: Compare enum values $photo->refresh(); $this->assertEquals(VerificationStatus::ADMIN_APPROVED, $photo->verified); // or for ordering: $this->assertTrue($photo->verified->value >= VerificationStatus::ADMIN_APPROVED->value); // BAD: Compare to raw int (fails after enum cast) $this->assertEquals(2, $photo->verified); // Comparing enum to int ``` ### Soft-delete assertions ```php // After SoftDeletes trait added to Photo model: $this->assertSoftDeleted('photos', ['id' => $photo->id]); // NOT: $this->assertDatabaseMissing('photos', ['id' => $photo->id]); // (row still exists, just has deleted_at set) ``` ### Running tests ```bash php artisan test --compact # All (excludes deprecated) php artisan test --compact tests/Feature/Teams/ # Directory php artisan test --compact tests/Feature/Photos/AddTagsToPhotoTest.php # Single file php artisan test --compact --filter=test_method_name # Single test php artisan test --compact --group=deprecated # Deprecated only ``` ### Leaderboard test patterns ```php // Leaderboard route uses auth:sanctum — use actingAs() with NO guard argument $this->actingAs($user)->getJson('/api/leaderboard?locationType=global'); // Seed Redis ZSETs for leaderboard tests Redis::zadd(RedisKeys::xpRanking(RedisKeys::global()), $xp, (string)$user->id); // Seed per-user metrics rows for time-filtered tests DB::table('metrics')->insert([ 'timescale' => 3, // monthly 'location_type' => 0, // global 'location_id' => 0, 'user_id' => $user->id, // > 0 for per-user 'year' => now()->year, 'month' => now()->month, 'xp' => 100, // ... other counters ]); ``` ### Test DB restoration (if all tests fail with "table doesn't exist") ```bash # PointsTest's internal migrate:fresh can corrupt olm_test DB_DATABASE=olm_test DB_USERNAME=root DB_PASSWORD=secret php artisan migrate:fresh --no-interaction DB_DATABASE=olm_test DB_USERNAME=root DB_PASSWORD=secret php artisan db:seed --class=GenerateTagsSeeder --no-interaction DB_DATABASE=olm_test DB_USERNAME=root DB_PASSWORD=secret php artisan db:seed --class=GenerateBrandsSeeder --no-interaction ``` ## Common Mistakes - **Forgetting `PermissionRegistrar::forgetCachedPermissions()` in setUp.** Spatie caches permissions across tests. Reset explicitly. - **Not providing `'price' => 0` for TeamType.** Column has no default — insert fails. - **Faking events when you need side effects.** `Event::fake()` prevents all listeners. If you need MetricsService to run, don't fake `TagsVerifiedByAdmin`. - **Using `assertDatabaseMissing` for soft-deleted records.** Use `assertSoftDeleted` instead. - **Creating PhotoTags with string keys.** `photo_tags.category_id` and `litter_object_id` are integer FKs. Create Category/LitterObject records first. - **Missing `city_id` in photo factory.** The default PhotoFactory doesn't include `city_id`. Add `'city_id' => City::factory()` when testing location-dependent features. - **Mismatching `actingAs()` guard with route middleware.** `actingAs($user)` (web guard) fails on `auth:api` routes. `actingAs($user, 'api')` fails on `auth:sanctum` routes. The mismatch causes silent 401s — no error message, just empty responses. - **Uploading photos via `/api/photos/submit` in web-guard tests.** That route uses `auth:api`. If your test uses `actingAs($user)`, the upload returns 401 and no photo is created. Use `Photo::factory()` for tests that aren't testing upload behavior. - **Expecting `geom` in JSON responses.** `Photo::$hidden = ['geom']` — binary spatial data is excluded from serialization. - **Using `assertNull` for `Redis::zScore()` on missing members.** PHP Redis returns `false` (not `null`) when a ZSET member doesn't exist. Use `assertFalse(Redis::zScore($key, $member))`. - **`HasPhotoUploads` trait double-encoding `address_array`.** The trait was `json_encode()`-ing `address_array` before insert, but the Photo model has an `'array'` cast on that column — causing double-encoding. Fixed to pass the raw array directly and let the model cast handle serialization. - **Empty tags on PUT /api/v3/tags returns 200, not 422.** `ReplacePhotoTagsRequest` validates `tags` as `present|array` (not `required|array|min:1`). Sending `tags: []` clears all tags on a photo. Tests expecting 422 for empty tags must be updated to expect 200. - **Flaky 429s from rate limiter state.** `CACHE_DRIVER=array` in phpunit.xml means rate limiter entries persist between tests in the same PHPUnit process. The base `TestCase::setUp()` calls `Cache::flush()` to prevent this. If you add a new test file that hits throttled routes and see intermittent 429s, verify it extends the base `TestCase`. - **Upload tests need both S3 and bbox disk fakes.** `UploadPhotoController` writes to two disks: the main S3 disk and the `bbox` disk (for thumbnail). Both must be faked: `Storage::fake('s3'); Storage::fake('bbox');`. Missing one causes a real filesystem/S3 call that fails in CI.