'https://example.com/article', 'status' => 'discovered', 'title' => 'An Example Article', 'instance_id' => null, 'posted_at' => null, 'fetched_at' => null, ]); $fresh = $page->fresh(); $this->assertNotNull($fresh); $this->assertSame('https://example.com/article', $fresh->url); $this->assertSame('An Example Article', $fresh->title); $this->assertNull($fresh->instance_id); } public function test_page_instance_relationship_returns_the_owning_instance(): void { $instance = Instance::factory() ->type(InstanceType::Mastodon) ->enabled() ->create(); $page = Page::create([ 'url' => 'https://example.com/post/1', 'status' => 'discovered', 'instance_id' => $instance->id, ]); $fresh = $page->fresh(); $this->assertInstanceOf(Instance::class, $fresh->instance); $this->assertSame($instance->id, $fresh->instance->id); } public function test_page_outgoing_and_incoming_links_relationships(): void { $source = Page::factory()->create(['url' => 'https://example.com/source']); $target = Page::factory()->create(['url' => 'https://example.com/target']); PageLink::create([ 'source_page_id' => $source->id, 'target_page_id' => $target->id, ]); $freshSource = $source->fresh(); $freshTarget = $target->fresh(); $this->assertCount(1, $freshSource->outgoingLinks); $this->assertCount(0, $freshSource->incomingLinks); $this->assertCount(1, $freshTarget->incomingLinks); $this->assertCount(0, $freshTarget->outgoingLinks); $this->assertSame($source->id, $freshTarget->incomingLinks->first()->source_page_id); $this->assertSame($target->id, $freshSource->outgoingLinks->first()->target_page_id); } public function test_page_language_is_fillable_and_persists(): void { $page = Page::create([ 'url' => 'https://example.com/crawled', 'status' => 'discovered', 'language' => 'en', ]); $fresh = $page->fresh(); $this->assertNotNull($fresh); $this->assertSame('en', $fresh->language); $unset = Page::create([ 'url' => 'https://example.com/no-language', 'status' => 'discovered', ]); $this->assertNull($unset->fresh()->language); } public function test_page_has_many_crawls(): void { // createQuietly() skips the PageObserver so no auto-crawl row is inserted; // this test is about HasMany scoping, not observer side effects. $page = Page::factory()->createQuietly(); $other = Page::factory()->createQuietly(); PageCrawl::create(['page_id' => $page->id, 'domain' => 'example.com']); PageCrawl::create(['page_id' => $page->id, 'domain' => 'example.com']); PageCrawl::create(['page_id' => $page->id, 'domain' => 'example.com']); PageCrawl::create(['page_id' => $other->id, 'domain' => 'other.com']); $crawls = $page->fresh()->crawls; $this->assertCount(3, $crawls); foreach ($crawls as $crawl) { $this->assertInstanceOf(PageCrawl::class, $crawl); $this->assertSame($page->id, $crawl->page_id); } } public function test_page_latest_crawl_returns_row_with_latest_created_at(): void { // createQuietly() skips the PageObserver; this test is about latestOfMany ordering, // not observer side effects. Using create() would add an observer crawl whose // created_at is now(), making the test fragile once the hardcoded sentinel date passes. $page = Page::factory()->createQuietly(); $old = PageCrawl::create(['page_id' => $page->id, 'domain' => 'example.com']); $old->created_at = Carbon::parse('2026-01-01 08:00:00'); $old->save(); $middle = PageCrawl::create(['page_id' => $page->id, 'domain' => 'example.com']); $middle->created_at = Carbon::parse('2026-03-15 12:00:00'); $middle->save(); $newest = PageCrawl::create(['page_id' => $page->id, 'domain' => 'example.com', 'error_message' => 'sentinel-latest']); $newest->created_at = Carbon::parse('2026-05-10 18:00:00'); $newest->save(); $latest = $page->fresh()->latestCrawl; $this->assertInstanceOf(PageCrawl::class, $latest); $this->assertSame('sentinel-latest', $latest->error_message); } public function test_language_confidence_is_fillable_nullable_and_cast_to_float(): void { // Column must exist, be nullable (null round-trips cleanly), be mass-assignable, // and the 'float' cast must be applied so we get a PHP float back, not a string. $withConfidence = Page::factory()->createQuietly([ 'language' => 'en', 'language_confidence' => 0.857, ]); $fresh = $withConfidence->fresh(); $this->assertNotNull($fresh); $this->assertIsFloat($fresh->language_confidence); $this->assertEqualsWithDelta(0.857, $fresh->language_confidence, 0.001); $withoutConfidence = Page::factory()->createQuietly(); $this->assertNull($withoutConfidence->fresh()->language_confidence); } public function test_page_status_is_cast_to_enum(): void { $cases = [ ['string' => 'discovered', 'enum' => PageStatusEnum::Discovered], ['string' => 'fetched', 'enum' => PageStatusEnum::Fetched], ['string' => 'failed', 'enum' => PageStatusEnum::Failed], ]; foreach ($cases as ['string' => $raw, 'enum' => $expected]) { $page = Page::create([ 'url' => 'https://example.com/' . $raw, 'status' => $raw, ]); $fresh = $page->fresh(); $this->assertInstanceOf(PageStatusEnum::class, $fresh->status, "status '{$raw}' should cast to PageStatusEnum"); $this->assertSame($expected, $fresh->status, "status '{$raw}' should equal PageStatusEnum::{$expected->name}"); } } }