Commit 17007ee0 authored by Mahmoud Aglan's avatar Mahmoud Aglan

cool

parent a5e223fe
<?php
namespace Tests\Feature\Deliverables;
use App\Models\User;
use App\Modules\Campaigns\Models\Campaign;
use App\Modules\Companies\Models\CompanyProfile;
use App\Modules\Creators\Models\CreatorProfile;
use App\Modules\Deliverables\Models\Deliverable;
use App\Modules\Projects\Models\Project;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class DeliverablePolicyTest extends TestCase
{
use RefreshDatabase;
private User $creatorUser;
private CreatorProfile $creator;
private User $companyUser;
private CompanyProfile $company;
private User $otherCreatorUser;
private User $otherCompanyUser;
private User $admin;
private Project $project;
private Deliverable $deliverable;
protected function setUp(): void
{
parent::setUp();
$this->creatorUser = User::factory()->creator()->create();
$this->creator = CreatorProfile::factory()->create(['user_id' => $this->creatorUser->id]);
$this->companyUser = User::factory()->company()->create();
$this->company = CompanyProfile::factory()->create(['user_id' => $this->companyUser->id]);
$this->otherCreatorUser = User::factory()->creator()->create();
CreatorProfile::factory()->create(['user_id' => $this->otherCreatorUser->id]);
$this->otherCompanyUser = User::factory()->company()->create();
CompanyProfile::factory()->create(['user_id' => $this->otherCompanyUser->id]);
$this->admin = User::factory()->admin()->create();
$campaign = Campaign::factory()->create(['company_id' => $this->company->id]);
$this->project = Project::factory()->create([
'campaign_id' => $campaign->id,
'company_id' => $this->company->id,
'creator_id' => $this->creator->id,
'status' => 'in_progress',
]);
$this->deliverable = Deliverable::factory()->create([
'project_id' => $this->project->id,
'status' => 'pending',
]);
}
// ─── view ───────────────────────────────────────────────────────────
public function test_project_creator_can_view_deliverable(): void
{
$this->assertTrue($this->creatorUser->can('view', $this->deliverable));
}
public function test_project_company_can_view_deliverable(): void
{
$this->assertTrue($this->companyUser->can('view', $this->deliverable));
}
public function test_admin_can_view_any_deliverable(): void
{
$this->assertTrue($this->admin->can('view', $this->deliverable));
}
public function test_other_creator_cannot_view(): void
{
$this->assertFalse($this->otherCreatorUser->can('view', $this->deliverable));
}
public function test_other_company_cannot_view(): void
{
$this->assertFalse($this->otherCompanyUser->can('view', $this->deliverable));
}
// ─── submit ─────────────────────────────────────────────────────────
public function test_creator_can_submit_to_submittable_deliverable(): void
{
$result = $this->creatorUser->can('submit', $this->deliverable);
$this->assertEquals($this->deliverable->canBeSubmittedTo(), $result);
}
public function test_company_cannot_submit(): void
{
$this->assertFalse($this->companyUser->can('submit', $this->deliverable));
}
public function test_other_creator_cannot_submit(): void
{
$this->assertFalse($this->otherCreatorUser->can('submit', $this->deliverable));
}
public function test_admin_cannot_submit(): void
{
$this->assertFalse($this->admin->can('submit', $this->deliverable));
}
// ─── approve ────────────────────────────────────────────────────────
public function test_company_can_approve_approvable_deliverable(): void
{
$this->deliverable->update(['status' => 'submitted']);
$result = $this->companyUser->can('approve', $this->deliverable);
$this->assertEquals($this->deliverable->canBeApproved(), $result);
}
public function test_admin_can_approve(): void
{
$this->deliverable->update(['status' => 'submitted']);
$result = $this->admin->can('approve', $this->deliverable);
$this->assertEquals($this->deliverable->canBeApproved(), $result);
}
public function test_creator_cannot_approve(): void
{
$this->deliverable->update(['status' => 'submitted']);
$this->assertFalse($this->creatorUser->can('approve', $this->deliverable));
}
public function test_other_company_cannot_approve(): void
{
$this->deliverable->update(['status' => 'submitted']);
$this->assertFalse($this->otherCompanyUser->can('approve', $this->deliverable));
}
// ─── requestRevision ────────────────────────────────────────────────
public function test_company_can_request_revision_when_allowed(): void
{
$this->deliverable->update(['status' => 'submitted']);
$result = $this->companyUser->can('requestRevision', $this->deliverable);
$this->assertEquals($this->deliverable->canRequestRevision(), $result);
}
public function test_admin_can_request_revision(): void
{
$this->deliverable->update(['status' => 'submitted']);
$result = $this->admin->can('requestRevision', $this->deliverable);
$this->assertEquals($this->deliverable->canRequestRevision(), $result);
}
public function test_creator_cannot_request_revision(): void
{
$this->deliverable->update(['status' => 'submitted']);
$this->assertFalse($this->creatorUser->can('requestRevision', $this->deliverable));
}
public function test_other_company_cannot_request_revision(): void
{
$this->deliverable->update(['status' => 'submitted']);
$this->assertFalse($this->otherCompanyUser->can('requestRevision', $this->deliverable));
}
// ─── manage ─────────────────────────────────────────────────────────
public function test_company_can_manage_own_deliverable(): void
{
$this->assertTrue($this->companyUser->can('manage', $this->deliverable));
}
public function test_admin_can_manage_any_deliverable(): void
{
$this->assertTrue($this->admin->can('manage', $this->deliverable));
}
public function test_creator_cannot_manage(): void
{
$this->assertFalse($this->creatorUser->can('manage', $this->deliverable));
}
public function test_other_company_cannot_manage(): void
{
$this->assertFalse($this->otherCompanyUser->can('manage', $this->deliverable));
}
}
<?php
namespace Tests\Feature\Invitations;
use App\Models\User;
use App\Modules\Campaigns\Models\Campaign;
use App\Modules\Companies\Models\CompanyProfile;
use App\Modules\Creators\Models\CreatorProfile;
use App\Modules\Invitations\Models\Invitation;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class InvitationPolicyTest extends TestCase
{
use RefreshDatabase;
private User $companyUser;
private CompanyProfile $company;
private User $creatorUser;
private CreatorProfile $creator;
private User $otherCompanyUser;
private CompanyProfile $otherCompany;
private User $otherCreatorUser;
private CreatorProfile $otherCreator;
private User $admin;
private Campaign $campaign;
private Invitation $invitation;
protected function setUp(): void
{
parent::setUp();
$this->companyUser = User::factory()->company()->create();
$this->company = CompanyProfile::factory()->create(['user_id' => $this->companyUser->id]);
$this->creatorUser = User::factory()->creator()->create();
$this->creator = CreatorProfile::factory()->create(['user_id' => $this->creatorUser->id]);
$this->otherCompanyUser = User::factory()->company()->create();
$this->otherCompany = CompanyProfile::factory()->create(['user_id' => $this->otherCompanyUser->id]);
$this->otherCreatorUser = User::factory()->creator()->create();
$this->otherCreator = CreatorProfile::factory()->create(['user_id' => $this->otherCreatorUser->id]);
$this->admin = User::factory()->admin()->create();
$this->campaign = Campaign::factory()->create(['company_id' => $this->company->id]);
$this->invitation = Invitation::factory()->create([
'campaign_id' => $this->campaign->id,
'company_id' => $this->company->id,
'creator_id' => $this->creator->id,
'status' => 'sent',
'expires_at' => now()->addDays(7),
'resend_count' => 0,
]);
}
// ─── viewAny ────────────────────────────────────────────────────────
public function test_any_authenticated_user_can_view_any(): void
{
$this->assertTrue($this->companyUser->can('viewAny', Invitation::class));
$this->assertTrue($this->creatorUser->can('viewAny', Invitation::class));
$this->assertTrue($this->admin->can('viewAny', Invitation::class));
}
// ─── view ───────────────────────────────────────────────────────────
public function test_sending_company_can_view(): void
{
$this->assertTrue($this->companyUser->can('view', $this->invitation));
}
public function test_invited_creator_can_view(): void
{
$this->assertTrue($this->creatorUser->can('view', $this->invitation));
}
public function test_admin_can_view_any_invitation(): void
{
$this->assertTrue($this->admin->can('view', $this->invitation));
}
public function test_other_company_cannot_view(): void
{
$this->assertFalse($this->otherCompanyUser->can('view', $this->invitation));
}
public function test_other_creator_cannot_view(): void
{
$this->assertFalse($this->otherCreatorUser->can('view', $this->invitation));
}
// ─── create ─────────────────────────────────────────────────────────
public function test_company_with_profile_can_create(): void
{
$this->assertTrue($this->companyUser->can('create', Invitation::class));
}
public function test_creator_cannot_create(): void
{
$this->assertFalse($this->creatorUser->can('create', Invitation::class));
}
public function test_admin_cannot_create(): void
{
$this->assertFalse($this->admin->can('create', Invitation::class));
}
// ─── cancel ─────────────────────────────────────────────────────────
public function test_sending_company_can_cancel_pending_invitation(): void
{
$this->assertTrue($this->companyUser->can('cancel', $this->invitation));
}
public function test_sending_company_cannot_cancel_accepted_invitation(): void
{
$this->invitation->update(['status' => 'accepted']);
$this->assertFalse($this->companyUser->can('cancel', $this->invitation));
}
public function test_other_company_cannot_cancel(): void
{
$this->assertFalse($this->otherCompanyUser->can('cancel', $this->invitation));
}
public function test_creator_cannot_cancel(): void
{
$this->assertFalse($this->creatorUser->can('cancel', $this->invitation));
}
// ─── resend ─────────────────────────────────────────────────────────
public function test_sending_company_can_resend_when_allowed(): void
{
$this->assertTrue($this->companyUser->can('resend', $this->invitation));
}
public function test_sending_company_cannot_resend_after_max_resends(): void
{
$this->invitation->update(['resend_count' => 2]);
$this->assertFalse($this->companyUser->can('resend', $this->invitation));
}
public function test_sending_company_cannot_resend_accepted(): void
{
$this->invitation->update(['status' => 'accepted']);
$this->assertFalse($this->companyUser->can('resend', $this->invitation));
}
public function test_other_company_cannot_resend(): void
{
$this->assertFalse($this->otherCompanyUser->can('resend', $this->invitation));
}
public function test_creator_cannot_resend(): void
{
$this->assertFalse($this->creatorUser->can('resend', $this->invitation));
}
// ─── accept ─────────────────────────────────────────────────────────
public function test_invited_creator_can_accept_pending(): void
{
$this->assertTrue($this->creatorUser->can('accept', $this->invitation));
}
public function test_invited_creator_cannot_accept_expired(): void
{
$this->invitation->update(['expires_at' => now()->subDay()]);
$this->assertFalse($this->creatorUser->can('accept', $this->invitation));
}
public function test_invited_creator_cannot_accept_already_accepted(): void
{
$this->invitation->update(['status' => 'accepted']);
$this->assertFalse($this->creatorUser->can('accept', $this->invitation));
}
public function test_other_creator_cannot_accept(): void
{
$this->assertFalse($this->otherCreatorUser->can('accept', $this->invitation));
}
public function test_company_cannot_accept(): void
{
$this->assertFalse($this->companyUser->can('accept', $this->invitation));
}
// ─── decline ────────────────────────────────────────────────────────
public function test_invited_creator_can_decline_pending(): void
{
$this->assertTrue($this->creatorUser->can('decline', $this->invitation));
}
public function test_invited_creator_cannot_decline_already_accepted(): void
{
$this->invitation->update(['status' => 'accepted']);
$this->assertFalse($this->creatorUser->can('decline', $this->invitation));
}
public function test_other_creator_cannot_decline(): void
{
$this->assertFalse($this->otherCreatorUser->can('decline', $this->invitation));
}
public function test_company_cannot_decline(): void
{
$this->assertFalse($this->companyUser->can('decline', $this->invitation));
}
}
<?php
namespace Tests\Feature\Matching;
use App\Models\User;
use App\Modules\Campaigns\Models\Campaign;
use App\Modules\Companies\Models\CompanyProfile;
use App\Modules\Creators\Models\CreatorProfile;
use App\Modules\Matching\Services\MatchingService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class MatchingServiceTest extends TestCase
{
use RefreshDatabase;
private MatchingService $service;
private User $creatorUser;
private CreatorProfile $creator;
private User $companyUser;
private CompanyProfile $company;
private Campaign $campaign;
protected function setUp(): void
{
parent::setUp();
$this->service = app(MatchingService::class);
$this->creatorUser = User::factory()->creator()->create(['status' => 'active']);
$this->creator = CreatorProfile::factory()->create([
'user_id' => $this->creatorUser->id,
'content_niches' => ['fashion', 'beauty', 'lifestyle'],
'languages' => ['en', 'ar'],
'country' => 'EG',
'skills' => ['video_editing', 'photography', 'storytelling'],
'experience_level' => 'intermediate',
'equipment' => ['camera_dslr', 'ring_light', 'microphone'],
'availability_status' => 'available',
'reputation_score' => 70,
'completion_percentage' => 80,
]);
$this->companyUser = User::factory()->company()->create(['status' => 'active']);
$this->company = CompanyProfile::factory()->create([
'user_id' => $this->companyUser->id,
]);
$this->campaign = Campaign::factory()->create([
'company_id' => $this->company->id,
'status' => 'published',
'niches' => ['fashion', 'beauty'],
'languages' => ['en'],
'countries' => ['EG', 'SA'],
'specific_skills' => ['video_editing', 'photography'],
'experience_level_min' => 'intermediate',
'equipment_requirements' => ['camera_dslr'],
]);
}
// ─── Score Creator for Campaign ─────────────────────────────────────
public function test_score_returns_integer(): void
{
$score = $this->service->scoreCreatorForCampaign($this->creator, $this->campaign);
$this->assertIsInt($score);
}
public function test_score_is_between_0_and_100(): void
{
$score = $this->service->scoreCreatorForCampaign($this->creator, $this->campaign);
$this->assertGreaterThanOrEqual(0, $score);
$this->assertLessThanOrEqual(100, $score);
}
public function test_perfect_match_gives_high_score(): void
{
$score = $this->service->scoreCreatorForCampaign($this->creator, $this->campaign);
$this->assertGreaterThan(70, $score);
}
public function test_no_match_gives_low_score(): void
{
$mismatchCreator = CreatorProfile::factory()->create([
'user_id' => User::factory()->creator()->create(['status' => 'active'])->id,
'content_niches' => ['gaming', 'tech'],
'languages' => ['ja'],
'country' => 'JP',
'skills' => ['game_streaming'],
'experience_level' => 'beginner',
'equipment' => [],
'availability_status' => 'unavailable',
'reputation_score' => 10,
'completion_percentage' => 80,
]);
$score = $this->service->scoreCreatorForCampaign($mismatchCreator, $this->campaign);
$this->assertLessThan(30, $score);
}
// ─── Niche Scoring ──────────────────────────────────────────────────
public function test_full_niche_overlap_gives_max_niche_score(): void
{
$this->creator->update(['content_niches' => ['fashion', 'beauty', 'food']]);
$score = $this->service->scoreCreatorForCampaign($this->creator, $this->campaign);
$this->assertGreaterThan(50, $score);
}
public function test_partial_niche_overlap_gives_partial_score(): void
{
$this->creator->update(['content_niches' => ['fashion', 'tech']]);
$score1 = $this->service->scoreCreatorForCampaign($this->creator, $this->campaign);
$this->creator->update(['content_niches' => ['fashion', 'beauty']]);
$score2 = $this->service->scoreCreatorForCampaign($this->creator, $this->campaign);
$this->assertGreaterThan($score1, $score2);
}
public function test_no_niche_overlap_gives_zero_niche_contribution(): void
{
$this->creator->update(['content_niches' => ['gaming', 'tech', 'science']]);
$score = $this->service->scoreCreatorForCampaign($this->creator, $this->campaign);
$fullMatchCreator = CreatorProfile::factory()->create([
'user_id' => User::factory()->creator()->create(['status' => 'active'])->id,
'content_niches' => ['fashion', 'beauty'],
'languages' => $this->creator->languages,
'country' => $this->creator->country,
'skills' => $this->creator->skills,
'experience_level' => $this->creator->experience_level,
'equipment' => $this->creator->equipment,
'availability_status' => $this->creator->availability_status,
'reputation_score' => $this->creator->reputation_score,
'completion_percentage' => 80,
]);
$fullScore = $this->service->scoreCreatorForCampaign($fullMatchCreator, $this->campaign);
$this->assertLessThan($fullScore, $score);
}
public function test_empty_campaign_niches_gives_full_niche_score(): void
{
$this->campaign->update(['niches' => []]);
$score = $this->service->scoreCreatorForCampaign($this->creator, $this->campaign);
$this->assertGreaterThan(50, $score);
}
public function test_empty_creator_niches_gives_zero_niche_score(): void
{
$this->creator->update(['content_niches' => []]);
$score = $this->service->scoreCreatorForCampaign($this->creator, $this->campaign);
$this->creator->update(['content_niches' => ['fashion', 'beauty']]);
$withNiches = $this->service->scoreCreatorForCampaign($this->creator, $this->campaign);
$this->assertLessThan($withNiches, $score);
}
// ─── Language Scoring ───────────────────────────────────────────────
public function test_matching_language_gives_full_language_score(): void
{
$this->creator->update(['languages' => ['en', 'ar']]);
$score = $this->service->scoreCreatorForCampaign($this->creator, $this->campaign);
$this->assertGreaterThan(50, $score);
}
public function test_no_matching_language_gives_zero_language_score(): void
{
$this->creator->update(['languages' => ['fr', 'de']]);
$score = $this->service->scoreCreatorForCampaign($this->creator, $this->campaign);
$this->creator->update(['languages' => ['en']]);
$withMatch = $this->service->scoreCreatorForCampaign($this->creator, $this->campaign);
$this->assertLessThan($withMatch, $score);
}
public function test_empty_campaign_languages_gives_full_language_score(): void
{
$this->campaign->update(['languages' => []]);
$score = $this->service->scoreCreatorForCampaign($this->creator, $this->campaign);
$this->assertGreaterThan(50, $score);
}
// ─── Country Scoring ────────────────────────────────────────────────
public function test_matching_country_gives_full_country_score(): void
{
$this->creator->update(['country' => 'EG']);
$this->campaign->update(['countries' => ['EG', 'SA']]);
$score = $this->service->scoreCreatorForCampaign($this->creator, $this->campaign);
$this->assertGreaterThan(50, $score);
}
public function test_non_matching_country_gives_zero_country_score(): void
{
$this->creator->update(['country' => 'US']);
$score = $this->service->scoreCreatorForCampaign($this->creator, $this->campaign);
$this->creator->update(['country' => 'EG']);
$withMatch = $this->service->scoreCreatorForCampaign($this->creator, $this->campaign);
$this->assertLessThan($withMatch, $score);
}
public function test_empty_campaign_countries_gives_full_country_score(): void
{
$this->campaign->update(['countries' => []]);
$score = $this->service->scoreCreatorForCampaign($this->creator, $this->campaign);
$this->assertGreaterThan(50, $score);
}
// ─── Experience Scoring ─────────────────────────────────────────────
public function test_meets_experience_requirement_gives_full_score(): void
{
$this->creator->update(['experience_level' => 'advanced']);
$this->campaign->update(['experience_level_min' => 'intermediate']);
$score = $this->service->scoreCreatorForCampaign($this->creator, $this->campaign);
$this->assertGreaterThan(60, $score);
}
public function test_one_level_below_gives_partial_score(): void
{
$this->creator->update(['experience_level' => 'beginner']);
$this->campaign->update(['experience_level_min' => 'intermediate']);
$score = $this->service->scoreCreatorForCampaign($this->creator, $this->campaign);
$this->creator->update(['experience_level' => 'intermediate']);
$meetsReq = $this->service->scoreCreatorForCampaign($this->creator, $this->campaign);
$this->assertLessThan($meetsReq, $score);
}
public function test_no_experience_requirement_gives_full_score(): void
{
$this->campaign->update(['experience_level_min' => null]);
$score = $this->service->scoreCreatorForCampaign($this->creator, $this->campaign);
$this->assertGreaterThan(50, $score);
}
// ─── Availability Scoring ───────────────────────────────────────────
public function test_available_gives_full_availability_score(): void
{
$this->creator->update(['availability_status' => 'available']);
$score = $this->service->scoreCreatorForCampaign($this->creator, $this->campaign);
$this->creator->update(['availability_status' => 'unavailable']);
$unavailScore = $this->service->scoreCreatorForCampaign($this->creator, $this->campaign);
$this->assertGreaterThan($unavailScore, $score);
}
public function test_unavailable_gives_zero_availability_score(): void
{
$this->creator->update(['availability_status' => 'unavailable']);
$score = $this->service->scoreCreatorForCampaign($this->creator, $this->campaign);
$this->creator->update(['availability_status' => 'available']);
$availScore = $this->service->scoreCreatorForCampaign($this->creator, $this->campaign);
$this->assertLessThan($availScore, $score);
}
public function test_limited_availability_gives_partial_score(): void
{
$this->creator->update(['availability_status' => 'limited']);
$score = $this->service->scoreCreatorForCampaign($this->creator, $this->campaign);
$this->creator->update(['availability_status' => 'available']);
$fullScore = $this->service->scoreCreatorForCampaign($this->creator, $this->campaign);
$this->creator->update(['availability_status' => 'unavailable']);
$zeroScore = $this->service->scoreCreatorForCampaign($this->creator, $this->campaign);
$this->assertGreaterThan($zeroScore, $score);
$this->assertLessThan($fullScore, $score);
}
// ─── Reputation Scoring ─────────────────────────────────────────────
public function test_high_reputation_gives_high_reputation_contribution(): void
{
$this->creator->update(['reputation_score' => 90]);
$highRepScore = $this->service->scoreCreatorForCampaign($this->creator, $this->campaign);
$this->creator->update(['reputation_score' => 10]);
$lowRepScore = $this->service->scoreCreatorForCampaign($this->creator, $this->campaign);
$this->assertGreaterThan($lowRepScore, $highRepScore);
}
// ─── Score Symmetry ─────────────────────────────────────────────────
public function test_score_campaign_for_creator_equals_reverse(): void
{
$scoreA = $this->service->scoreCreatorForCampaign($this->creator, $this->campaign);
$scoreB = $this->service->scoreCampaignForCreator($this->campaign, $this->creator);
$this->assertEquals($scoreA, $scoreB);
}
// ─── Recommended Campaigns ──────────────────────────────────────────
public function test_get_recommended_campaigns_returns_collection(): void
{
$result = $this->service->getRecommendedCampaigns($this->creator);
$this->assertInstanceOf(\Illuminate\Support\Collection::class, $result);
}
public function test_get_recommended_campaigns_excludes_already_applied(): void
{
$this->campaign->applications()->create([
'creator_id' => $this->creator->id,
'status' => 'pending',
'cover_letter' => 'I want to apply.',
]);
$result = $this->service->getRecommendedCampaigns($this->creator);
$this->assertFalse($result->contains('id', $this->campaign->id));
}
public function test_get_recommended_campaigns_excludes_past_deadline(): void
{
$this->campaign->update(['application_deadline' => now()->subDay()]);
$result = $this->service->getRecommendedCampaigns($this->creator);
$this->assertFalse($result->contains('id', $this->campaign->id));
}
public function test_get_recommended_campaigns_includes_no_deadline(): void
{
$this->campaign->update(['application_deadline' => null]);
$result = $this->service->getRecommendedCampaigns($this->creator);
$this->assertTrue($result->contains('id', $this->campaign->id));
}
public function test_get_recommended_campaigns_only_published(): void
{
$this->campaign->update(['status' => 'draft']);
$result = $this->service->getRecommendedCampaigns($this->creator);
$this->assertFalse($result->contains('id', $this->campaign->id));
}
public function test_get_recommended_campaigns_respects_limit(): void
{
for ($i = 0; $i < 15; $i++) {
Campaign::factory()->create([
'company_id' => $this->company->id,
'status' => 'published',
'niches' => ['fashion'],
'languages' => ['en'],
'countries' => ['EG'],
]);
}
$result = $this->service->getRecommendedCampaigns($this->creator, 5);
$this->assertLessThanOrEqual(5, $result->count());
}
public function test_get_recommended_campaigns_sorted_by_score_desc(): void
{
Campaign::factory()->create([
'company_id' => $this->company->id,
'status' => 'published',
'niches' => ['fashion', 'beauty'],
'languages' => ['en'],
'countries' => ['EG'],
]);
Campaign::factory()->create([
'company_id' => $this->company->id,
'status' => 'published',
'niches' => ['gaming'],
'languages' => ['ja'],
'countries' => ['JP'],
]);
$result = $this->service->getRecommendedCampaigns($this->creator);
if ($result->count() >= 2) {
$scores = $result->pluck('match_score')->toArray();
$sorted = $scores;
rsort($sorted);
$this->assertEquals($sorted, $scores);
}
}
// ─── Recommended Creators ───────────────────────────────────────────
public function test_get_recommended_creators_returns_collection(): void
{
$result = $this->service->getRecommendedCreators($this->campaign);
$this->assertInstanceOf(\Illuminate\Support\Collection::class, $result);
}
public function test_get_recommended_creators_excludes_already_applied(): void
{
$this->campaign->applications()->create([
'creator_id' => $this->creator->id,
'status' => 'pending',
'cover_letter' => 'Applied already.',
]);
$result = $this->service->getRecommendedCreators($this->campaign);
$this->assertFalse($result->contains('id', $this->creator->id));
}
public function test_get_recommended_creators_only_active_users(): void
{
$this->creatorUser->update(['status' => 'suspended']);
$result = $this->service->getRecommendedCreators($this->campaign);
$this->assertFalse($result->contains('id', $this->creator->id));
}
public function test_get_recommended_creators_requires_50_percent_profile(): void
{
$this->creator->update(['completion_percentage' => 30]);
$result = $this->service->getRecommendedCreators($this->campaign);
$this->assertFalse($result->contains('id', $this->creator->id));
}
public function test_get_recommended_creators_respects_limit(): void
{
for ($i = 0; $i < 25; $i++) {
$user = User::factory()->creator()->create(['status' => 'active']);
CreatorProfile::factory()->create([
'user_id' => $user->id,
'content_niches' => ['fashion'],
'languages' => ['en'],
'country' => 'EG',
'completion_percentage' => 80,
'reputation_score' => 50,
'availability_status' => 'available',
]);
}
$result = $this->service->getRecommendedCreators($this->campaign, 10);
$this->assertLessThanOrEqual(10, $result->count());
}
// ─── Similar Creators ───────────────────────────────────────────────
public function test_get_similar_creators_excludes_self(): void
{
$result = $this->service->getSimilarCreators($this->creator);
$this->assertFalse($result->contains('id', $this->creator->id));
}
public function test_get_similar_creators_returns_similar_profiles(): void
{
$similarUser = User::factory()->creator()->create(['status' => 'active']);
CreatorProfile::factory()->create([
'user_id' => $similarUser->id,
'content_niches' => ['fashion', 'beauty', 'travel'],
'skills' => ['video_editing', 'photography'],
'country' => 'EG',
'experience_level' => 'intermediate',
'completion_percentage' => 80,
]);
$result = $this->service->getSimilarCreators($this->creator);
$this->assertTrue($result->count() >= 1);
}
public function test_get_similar_creators_excludes_dissimilar(): void
{
$differentUser = User::factory()->creator()->create(['status' => 'active']);
CreatorProfile::factory()->create([
'user_id' => $differentUser->id,
'content_niches' => ['gaming', 'tech'],
'skills' => ['game_streaming', 'coding'],
'country' => 'JP',
'experience_level' => 'expert',
'completion_percentage' => 80,
]);
$result = $this->service->getSimilarCreators($this->creator);
$ids = $result->pluck('id')->toArray();
$differentCreator = CreatorProfile::where('user_id', $differentUser->id)->first();
if ($differentCreator) {
$this->assertNotContains($differentCreator->id, $ids);
}
}
public function test_get_similar_creators_only_active_users(): void
{
$inactiveUser = User::factory()->creator()->create(['status' => 'suspended']);
CreatorProfile::factory()->create([
'user_id' => $inactiveUser->id,
'content_niches' => ['fashion', 'beauty'],
'skills' => ['video_editing', 'photography'],
'country' => 'EG',
'experience_level' => 'intermediate',
'completion_percentage' => 80,
]);
$result = $this->service->getSimilarCreators($this->creator);
$ids = $result->pluck('user_id')->toArray();
$this->assertNotContains($inactiveUser->id, $ids);
}
public function test_get_similar_creators_respects_limit(): void
{
for ($i = 0; $i < 10; $i++) {
$user = User::factory()->creator()->create(['status' => 'active']);
CreatorProfile::factory()->create([
'user_id' => $user->id,
'content_niches' => ['fashion', 'beauty'],
'skills' => ['video_editing', 'photography'],
'country' => 'EG',
'experience_level' => 'intermediate',
'completion_percentage' => 80,
]);
}
$result = $this->service->getSimilarCreators($this->creator, 3);
$this->assertLessThanOrEqual(3, $result->count());
}
// ─── Weight Totals ──────────────────────────────────────────────────
public function test_weights_sum_to_100(): void
{
$reflection = new \ReflectionClass(MatchingService::class);
$weights = [];
foreach ($reflection->getConstants() as $name => $value) {
if (str_starts_with($name, 'WEIGHT_')) {
$weights[$name] = $value;
}
}
$this->assertEquals(100, array_sum($weights));
}
}
<?php
namespace Tests\Feature\Notifications;
use App\Models\User;
use App\Modules\Creators\Models\CreatorProfile;
use App\Modules\Notifications\Enums\NotificationCategory;
use App\Modules\Notifications\Models\Notification;
use App\Modules\Notifications\Models\NotificationPreference;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Tests\TestCase;
class NotificationApiTest extends TestCase
{
use RefreshDatabase;
private User $user;
private User $otherUser;
protected function setUp(): void
{
parent::setUp();
$this->user = User::factory()->creator()->create(['email_verified_at' => now()]);
CreatorProfile::factory()->create(['user_id' => $this->user->id]);
$this->otherUser = User::factory()->creator()->create(['email_verified_at' => now()]);
}
// ─── Notification Index (GET /notifications) ────────────────────────
public function test_authenticated_user_can_list_notifications(): void
{
Notification::create([
'user_id' => $this->user->id,
'type' => 'test',
'title' => 'Test notification',
'category' => NotificationCategory::System,
'icon' => 'bell',
'is_read' => false,
]);
$response = $this->actingAs($this->user)
->getJson('/notifications');
$response->assertStatus(200);
$response->assertJsonStructure([
'success',
'data',
'meta' => ['current_page', 'per_page', 'total', 'unread_count'],
]);
}
public function test_unauthenticated_user_cannot_list_notifications(): void
{
$response = $this->getJson('/notifications');
$response->assertStatus(401);
}
public function test_notification_index_only_shows_own_notifications(): void
{
Notification::create([
'user_id' => $this->user->id,
'type' => 'test',
'title' => 'Mine',
'category' => NotificationCategory::System,
'icon' => 'bell',
'is_read' => false,
]);
Notification::create([
'user_id' => $this->otherUser->id,
'type' => 'test',
'title' => 'Not mine',
'category' => NotificationCategory::System,
'icon' => 'bell',
'is_read' => false,
]);
$response = $this->actingAs($this->user)
->getJson('/notifications');
$response->assertStatus(200);
$response->assertJsonCount(1, 'data');
}
public function test_notification_index_filters_by_category(): void
{
Notification::create([
'user_id' => $this->user->id,
'type' => 'test',
'title' => 'System',
'category' => NotificationCategory::System,
'icon' => 'bell',
'is_read' => false,
]);
Notification::create([
'user_id' => $this->user->id,
'type' => 'test',
'title' => 'Project',
'category' => NotificationCategory::Projects,
'icon' => 'briefcase',
'is_read' => false,
]);
$response = $this->actingAs($this->user)
->getJson('/notifications?category=system');
$response->assertStatus(200);
$response->assertJsonCount(1, 'data');
}
public function test_notification_index_includes_unread_count_in_meta(): void
{
for ($i = 0; $i < 3; $i++) {
Notification::create([
'user_id' => $this->user->id,
'type' => 'test',
'title' => "Unread {$i}",
'category' => NotificationCategory::System,
'icon' => 'bell',
'is_read' => false,
]);
}
Cache::flush();
$response = $this->actingAs($this->user)
->getJson('/notifications');
$response->assertStatus(200);
$response->assertJsonPath('meta.unread_count', 3);
}
public function test_notification_index_paginates_at_25(): void
{
for ($i = 0; $i < 30; $i++) {
Notification::create([
'user_id' => $this->user->id,
'type' => 'test',
'title' => "Notification {$i}",
'category' => NotificationCategory::System,
'icon' => 'bell',
'is_read' => false,
]);
}
$response = $this->actingAs($this->user)
->getJson('/notifications');
$response->assertStatus(200);
$response->assertJsonPath('meta.per_page', 25);
$response->assertJsonPath('meta.total', 30);
}
public function test_notification_index_returns_proper_structure(): void
{
Notification::create([
'user_id' => $this->user->id,
'type' => 'application_accepted',
'title' => 'Application Accepted',
'body' => 'Your application has been accepted.',
'action_url' => '/projects/123',
'action_label' => 'View Project',
'category' => NotificationCategory::Applications,
'icon' => 'check-circle',
'is_read' => false,
]);
$response = $this->actingAs($this->user)
->getJson('/notifications');
$response->assertStatus(200);
$response->assertJsonStructure([
'data' => [
'*' => [
'id',
'type',
'title',
'body',
'icon',
'category',
'action_url',
'action_label',
'is_read',
'time_ago',
'created_at',
],
],
]);
}
// ─── Dropdown (GET /notifications/dropdown) ─────────────────────────
public function test_dropdown_returns_latest_10(): void
{
for ($i = 0; $i < 15; $i++) {
Notification::create([
'user_id' => $this->user->id,
'type' => 'test',
'title' => "Notification {$i}",
'category' => NotificationCategory::System,
'icon' => 'bell',
'is_read' => false,
]);
}
$response = $this->actingAs($this->user)
->getJson('/notifications/dropdown');
$response->assertStatus(200);
$response->assertJsonCount(10, 'data.notifications');
}
public function test_dropdown_includes_unread_count(): void
{
for ($i = 0; $i < 3; $i++) {
Notification::create([
'user_id' => $this->user->id,
'type' => 'test',
'title' => "Unread {$i}",
'category' => NotificationCategory::System,
'icon' => 'bell',
'is_read' => false,
]);
}
Cache::flush();
$response = $this->actingAs($this->user)
->getJson('/notifications/dropdown');
$response->assertStatus(200);
$response->assertJsonPath('data.unread_count', 3);
}
// ─── Unread Count (GET /notifications/unread-count) ─────────────────
public function test_unread_count_endpoint_returns_count(): void
{
Notification::create([
'user_id' => $this->user->id,
'type' => 'test',
'title' => 'Unread',
'category' => NotificationCategory::System,
'icon' => 'bell',
'is_read' => false,
]);
Cache::flush();
$response = $this->actingAs($this->user)
->getJson('/notifications/unread-count');
$response->assertStatus(200);
$response->assertJsonPath('data.count', 1);
}
// ─── Mark Read (POST /notifications/{notification}/read) ────────────
public function test_user_can_mark_own_notification_as_read(): void
{
$notification = Notification::create([
'user_id' => $this->user->id,
'type' => 'test',
'title' => 'Test',
'category' => NotificationCategory::System,
'icon' => 'bell',
'is_read' => false,
]);
$response = $this->actingAs($this->user)
->postJson("/notifications/{$notification->uuid}/read");
$response->assertStatus(200);
$notification->refresh();
$this->assertTrue($notification->is_read);
$this->assertNotNull($notification->read_at);
}
public function test_user_cannot_mark_other_users_notification_as_read(): void
{
$notification = Notification::create([
'user_id' => $this->otherUser->id,
'type' => 'test',
'title' => 'Not mine',
'category' => NotificationCategory::System,
'icon' => 'bell',
'is_read' => false,
]);
$response = $this->actingAs($this->user)
->postJson("/notifications/{$notification->uuid}/read");
$response->assertStatus(403);
}
// ─── Mark All Read (POST /notifications/mark-all-read) ──────────────
public function test_user_can_mark_all_as_read(): void
{
for ($i = 0; $i < 5; $i++) {
Notification::create([
'user_id' => $this->user->id,
'type' => 'test',
'title' => "Notification {$i}",
'category' => NotificationCategory::System,
'icon' => 'bell',
'is_read' => false,
]);
}
$response = $this->actingAs($this->user)
->postJson('/notifications/mark-all-read');
$response->assertStatus(200);
$unread = Notification::forUser($this->user->id)->unread()->count();
$this->assertEquals(0, $unread);
}
public function test_mark_all_read_does_not_affect_other_users(): void
{
Notification::create([
'user_id' => $this->otherUser->id,
'type' => 'test',
'title' => 'Other user',
'category' => NotificationCategory::System,
'icon' => 'bell',
'is_read' => false,
]);
$this->actingAs($this->user)
->postJson('/notifications/mark-all-read');
$unread = Notification::forUser($this->otherUser->id)->unread()->count();
$this->assertEquals(1, $unread);
}
// ─── Preferences (GET /notifications/settings) ──────────────────────
public function test_user_can_view_notification_preferences(): void
{
$response = $this->actingAs($this->user)
->get('/notifications/settings');
$response->assertStatus(200);
}
public function test_preferences_api_returns_all_categories(): void
{
$response = $this->actingAs($this->user)
->getJson('/notifications/settings');
$response->assertStatus(200);
$response->assertJsonStructure(['success', 'data']);
}
// ─── Update Preferences (PUT /notifications/settings) ───────────────
public function test_user_can_update_preferences(): void
{
$response = $this->actingAs($this->user)
->putJson('/notifications/settings', [
'preferences' => [
'applications' => ['in_app' => true, 'email' => false, 'digest' => 'daily'],
'messages' => ['in_app' => true, 'email' => true, 'digest' => 'instant'],
],
]);
$response->assertStatus(200);
$this->assertDatabaseHas('notification_preferences', [
'user_id' => $this->user->id,
'category' => 'applications',
'email_enabled' => false,
'email_digest' => 'daily',
]);
}
public function test_update_preferences_enforces_locked_categories(): void
{
$response = $this->actingAs($this->user)
->putJson('/notifications/settings', [
'preferences' => [
'system' => ['in_app' => false, 'email' => false, 'digest' => 'disabled'],
],
]);
$response->assertStatus(200);
$this->assertDatabaseHas('notification_preferences', [
'user_id' => $this->user->id,
'category' => 'system',
'email_enabled' => true,
]);
}
public function test_unauthenticated_user_cannot_update_preferences(): void
{
$response = $this->putJson('/notifications/settings', [
'preferences' => [
'applications' => ['in_app' => true, 'email' => false, 'digest' => 'daily'],
],
]);
$response->assertStatus(401);
}
}
<?php
namespace Tests\Feature\Notifications;
use App\Models\User;
use App\Modules\Campaigns\Models\Campaign;
use App\Modules\Companies\Models\CompanyProfile;
use App\Modules\Creators\Models\CreatorProfile;
use App\Modules\Notifications\Enums\NotificationCategory;
use App\Modules\Notifications\Jobs\SendNotificationEmail;
use App\Modules\Notifications\Models\Notification;
use App\Modules\Notifications\Models\NotificationPreference;
use App\Modules\Notifications\Services\NotificationService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
class NotificationServiceTest extends TestCase
{
use RefreshDatabase;
private NotificationService $service;
private User $user;
protected function setUp(): void
{
parent::setUp();
$this->service = app(NotificationService::class);
$this->user = User::factory()->creator()->create();
CreatorProfile::factory()->create(['user_id' => $this->user->id]);
}
// ─── Send Notification ──────────────────────────────────────────────
public function test_send_creates_notification_with_all_fields(): void
{
Queue::fake();
$notification = $this->service->send(
$this->user,
'application_accepted',
NotificationCategory::Applications,
'Your application was accepted',
'Company X accepted your application for Campaign Y.',
'/projects/123',
'View Project',
'check-circle',
['campaign_id' => 123],
);
$this->assertInstanceOf(Notification::class, $notification);
$this->assertEquals($this->user->id, $notification->user_id);
$this->assertEquals('application_accepted', $notification->type);
$this->assertEquals('Your application was accepted', $notification->title);
$this->assertEquals('Company X accepted your application for Campaign Y.', $notification->body);
$this->assertEquals('/projects/123', $notification->action_url);
$this->assertEquals('View Project', $notification->action_label);
$this->assertEquals('check-circle', $notification->icon);
$this->assertEquals(NotificationCategory::Applications, $notification->category);
$this->assertEquals(['campaign_id' => 123], $notification->data);
$this->assertFalse($notification->is_read);
$this->assertNull($notification->read_at);
}
public function test_send_creates_notification_with_minimal_fields(): void
{
Queue::fake();
$notification = $this->service->send(
$this->user,
'system_update',
NotificationCategory::System,
'System maintenance scheduled',
);
$this->assertNotNull($notification);
$this->assertNull($notification->body);
$this->assertNull($notification->action_url);
$this->assertNull($notification->action_label);
$this->assertNull($notification->data);
}
public function test_send_uses_category_icon_when_no_icon_specified(): void
{
Queue::fake();
$notification = $this->service->send(
$this->user,
'new_message',
NotificationCategory::Messages,
'New message received',
);
$this->assertEquals('message-square', $notification->icon);
}
public function test_send_uses_custom_icon_over_category_default(): void
{
Queue::fake();
$notification = $this->service->send(
$this->user,
'new_message',
NotificationCategory::Messages,
'New message received',
icon: 'mail-open',
);
$this->assertEquals('mail-open', $notification->icon);
}
public function test_send_generates_uuid(): void
{
Queue::fake();
$notification = $this->service->send(
$this->user,
'test_notification',
NotificationCategory::System,
'Test',
);
$this->assertNotNull($notification->uuid);
$this->assertMatchesRegularExpression(
'/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/',
$notification->uuid
);
}
public function test_send_stores_related_model_reference(): void
{
Queue::fake();
$companyUser = User::factory()->company()->create();
$company = CompanyProfile::factory()->create(['user_id' => $companyUser->id]);
$campaign = Campaign::factory()->create(['company_id' => $company->id]);
$notification = $this->service->send(
$this->user,
'new_campaign',
NotificationCategory::Campaigns,
'New campaign available',
related: $campaign,
);
$this->assertEquals(get_class($campaign), $notification->related_type);
$this->assertEquals($campaign->id, $notification->related_id);
}
// ─── Deduplication ──────────────────────────────────────────────────
public function test_send_deduplicates_within_window(): void
{
Queue::fake();
$companyUser = User::factory()->company()->create();
$company = CompanyProfile::factory()->create(['user_id' => $companyUser->id]);
$campaign = Campaign::factory()->create(['company_id' => $company->id]);
$first = $this->service->send(
$this->user,
'new_campaign',
NotificationCategory::Campaigns,
'New campaign available',
related: $campaign,
);
$second = $this->service->send(
$this->user,
'new_campaign',
NotificationCategory::Campaigns,
'New campaign available',
related: $campaign,
);
$this->assertNotNull($first);
$this->assertNull($second);
$this->assertDatabaseCount('notifications', 1);
}
public function test_send_does_not_deduplicate_without_related_model(): void
{
Queue::fake();
$first = $this->service->send(
$this->user,
'system_update',
NotificationCategory::System,
'Maintenance 1',
);
$second = $this->service->send(
$this->user,
'system_update',
NotificationCategory::System,
'Maintenance 2',
);
$this->assertNotNull($first);
$this->assertNotNull($second);
$this->assertDatabaseCount('notifications', 2);
}
public function test_send_allows_same_type_for_different_users(): void
{
Queue::fake();
$otherUser = User::factory()->creator()->create();
$companyUser = User::factory()->company()->create();
$company = CompanyProfile::factory()->create(['user_id' => $companyUser->id]);
$campaign = Campaign::factory()->create(['company_id' => $company->id]);
$first = $this->service->send(
$this->user,
'new_campaign',
NotificationCategory::Campaigns,
'New campaign',
related: $campaign,
);
$second = $this->service->send(
$otherUser,
'new_campaign',
NotificationCategory::Campaigns,
'New campaign',
related: $campaign,
);
$this->assertNotNull($first);
$this->assertNotNull($second);
}
// ─── User Preferences Respect ──────────────────────────────────────
public function test_send_respects_in_app_disabled_preference(): void
{
Queue::fake();
NotificationPreference::create([
'user_id' => $this->user->id,
'category' => NotificationCategory::Campaigns,
'in_app_enabled' => false,
'email_enabled' => false,
'email_digest' => 'disabled',
]);
$notification = $this->service->send(
$this->user,
'new_campaign',
NotificationCategory::Campaigns,
'New campaign',
);
$this->assertNull($notification);
$this->assertDatabaseCount('notifications', 0);
}
public function test_send_ignores_in_app_preference_for_always_on_categories(): void
{
Queue::fake();
NotificationPreference::create([
'user_id' => $this->user->id,
'category' => NotificationCategory::Projects,
'in_app_enabled' => false,
'email_enabled' => false,
'email_digest' => 'disabled',
]);
$notification = $this->service->send(
$this->user,
'project_created',
NotificationCategory::Projects,
'Project created',
);
$this->assertNotNull($notification);
}
// ─── Email Sending ──────────────────────────────────────────────────
public function test_send_queues_email_when_enabled(): void
{
Queue::fake();
$this->service->send(
$this->user,
'application_accepted',
NotificationCategory::Applications,
'Application accepted',
);
Queue::assertPushed(SendNotificationEmail::class);
}
public function test_send_does_not_queue_email_when_email_disabled(): void
{
Queue::fake();
NotificationPreference::create([
'user_id' => $this->user->id,
'category' => NotificationCategory::Applications,
'in_app_enabled' => true,
'email_enabled' => false,
'email_digest' => 'disabled',
]);
$this->service->send(
$this->user,
'application_accepted',
NotificationCategory::Applications,
'Application accepted',
);
Queue::assertNotPushed(SendNotificationEmail::class);
}
public function test_send_does_not_queue_email_when_send_email_false(): void
{
Queue::fake();
$this->service->send(
$this->user,
'application_accepted',
NotificationCategory::Applications,
'Application accepted',
sendEmail: false,
);
Queue::assertNotPushed(SendNotificationEmail::class);
}
public function test_send_always_emails_for_system_category(): void
{
Queue::fake();
NotificationPreference::create([
'user_id' => $this->user->id,
'category' => NotificationCategory::System,
'in_app_enabled' => true,
'email_enabled' => false,
'email_digest' => 'disabled',
]);
$this->service->send(
$this->user,
'system_alert',
NotificationCategory::System,
'Critical system alert',
);
Queue::assertPushed(SendNotificationEmail::class);
}
public function test_send_does_not_email_when_digest_not_instant(): void
{
Queue::fake();
NotificationPreference::create([
'user_id' => $this->user->id,
'category' => NotificationCategory::Applications,
'in_app_enabled' => true,
'email_enabled' => true,
'email_digest' => 'daily',
]);
$this->service->send(
$this->user,
'application_received',
NotificationCategory::Applications,
'New application',
);
Queue::assertNotPushed(SendNotificationEmail::class);
}
// ─── Email Rate Limiting ────────────────────────────────────────────
public function test_send_rate_limits_emails_per_hour(): void
{
Queue::fake();
$cacheKey = "notif:email_count:{$this->user->id}:" . now()->format('Y-m-d-H');
Cache::put($cacheKey, 10, 3600);
$this->service->send(
$this->user,
'application_accepted',
NotificationCategory::Applications,
'Application accepted',
);
Queue::assertNotPushed(SendNotificationEmail::class);
}
// ─── Batch Threshold ────────────────────────────────────────────────
public function test_send_batches_after_threshold(): void
{
Queue::fake();
$cacheKey = "notif:batch:{$this->user->id}:many_messages";
Cache::put($cacheKey, 5, 600);
$notification = $this->service->send(
$this->user,
'many_messages',
NotificationCategory::Messages,
'New message',
);
$this->assertNotNull($notification);
Queue::assertNotPushed(SendNotificationEmail::class);
}
// ─── Send Bulk ──────────────────────────────────────────────────────
public function test_send_bulk_sends_to_multiple_users(): void
{
Queue::fake();
$users = collect([
$this->user,
User::factory()->creator()->create(),
User::factory()->creator()->create(),
]);
$sent = $this->service->sendBulk(
$users,
'system_update',
NotificationCategory::System,
'Platform update',
);
$this->assertEquals(3, $sent);
$this->assertDatabaseCount('notifications', 3);
}
public function test_send_bulk_respects_individual_preferences(): void
{
Queue::fake();
$user2 = User::factory()->creator()->create();
NotificationPreference::create([
'user_id' => $user2->id,
'category' => NotificationCategory::Campaigns,
'in_app_enabled' => false,
'email_enabled' => false,
'email_digest' => 'disabled',
]);
$users = collect([$this->user, $user2]);
$sent = $this->service->sendBulk(
$users,
'new_campaign',
NotificationCategory::Campaigns,
'New campaign available',
);
$this->assertEquals(1, $sent);
}
// ─── Mark Read ──────────────────────────────────────────────────────
public function test_mark_read_updates_notification(): void
{
$notification = Notification::create([
'user_id' => $this->user->id,
'type' => 'test',
'title' => 'Test',
'category' => NotificationCategory::System,
'icon' => 'bell',
'is_read' => false,
]);
$this->service->markRead($notification);
$notification->refresh();
$this->assertTrue($notification->is_read);
$this->assertNotNull($notification->read_at);
}
public function test_mark_read_does_not_double_mark(): void
{
$notification = Notification::create([
'user_id' => $this->user->id,
'type' => 'test',
'title' => 'Test',
'category' => NotificationCategory::System,
'icon' => 'bell',
'is_read' => true,
'read_at' => now()->subHour(),
]);
$originalReadAt = $notification->read_at;
$this->service->markRead($notification);
$notification->refresh();
$this->assertEquals($originalReadAt->timestamp, $notification->read_at->timestamp);
}
// ─── Mark All Read ──────────────────────────────────────────────────
public function test_mark_all_read_marks_all_unread_for_user(): void
{
for ($i = 0; $i < 5; $i++) {
Notification::create([
'user_id' => $this->user->id,
'type' => 'test',
'title' => "Test {$i}",
'category' => NotificationCategory::System,
'icon' => 'bell',
'is_read' => false,
]);
}
$this->service->markAllRead($this->user);
$unread = Notification::forUser($this->user->id)->unread()->count();
$this->assertEquals(0, $unread);
}
public function test_mark_all_read_does_not_affect_other_users(): void
{
$otherUser = User::factory()->creator()->create();
Notification::create([
'user_id' => $this->user->id,
'type' => 'test',
'title' => 'Mine',
'category' => NotificationCategory::System,
'icon' => 'bell',
'is_read' => false,
]);
Notification::create([
'user_id' => $otherUser->id,
'type' => 'test',
'title' => 'Theirs',
'category' => NotificationCategory::System,
'icon' => 'bell',
'is_read' => false,
]);
$this->service->markAllRead($this->user);
$otherUnread = Notification::forUser($otherUser->id)->unread()->count();
$this->assertEquals(1, $otherUnread);
}
// ─── Unread Count ───────────────────────────────────────────────────
public function test_get_unread_count_returns_correct_count(): void
{
for ($i = 0; $i < 3; $i++) {
Notification::create([
'user_id' => $this->user->id,
'type' => 'test',
'title' => "Unread {$i}",
'category' => NotificationCategory::System,
'icon' => 'bell',
'is_read' => false,
]);
}
Notification::create([
'user_id' => $this->user->id,
'type' => 'test',
'title' => 'Read',
'category' => NotificationCategory::System,
'icon' => 'bell',
'is_read' => true,
'read_at' => now(),
]);
Cache::flush();
$count = $this->service->getUnreadCount($this->user);
$this->assertEquals(3, $count);
}
public function test_get_unread_count_caches_result(): void
{
Notification::create([
'user_id' => $this->user->id,
'type' => 'test',
'title' => 'Test',
'category' => NotificationCategory::System,
'icon' => 'bell',
'is_read' => false,
]);
Cache::flush();
$this->service->getUnreadCount($this->user);
$this->assertTrue(Cache::has("notifications:unread:{$this->user->id}"));
}
// ─── Get Latest ─────────────────────────────────────────────────────
public function test_get_latest_returns_limited_results(): void
{
for ($i = 0; $i < 15; $i++) {
Notification::create([
'user_id' => $this->user->id,
'type' => 'test',
'title' => "Notification {$i}",
'category' => NotificationCategory::System,
'icon' => 'bell',
'is_read' => false,
]);
}
$latest = $this->service->getLatest($this->user, 10);
$this->assertCount(10, $latest);
}
public function test_get_latest_orders_by_most_recent(): void
{
$old = Notification::create([
'user_id' => $this->user->id,
'type' => 'test',
'title' => 'Old',
'category' => NotificationCategory::System,
'icon' => 'bell',
'is_read' => false,
'created_at' => now()->subDays(2),
]);
$new = Notification::create([
'user_id' => $this->user->id,
'type' => 'test',
'title' => 'New',
'category' => NotificationCategory::System,
'icon' => 'bell',
'is_read' => false,
'created_at' => now(),
]);
$latest = $this->service->getLatest($this->user, 10);
$this->assertEquals($new->id, $latest->first()->id);
}
// ─── Get Paginated ──────────────────────────────────────────────────
public function test_get_paginated_returns_paginator(): void
{
for ($i = 0; $i < 30; $i++) {
Notification::create([
'user_id' => $this->user->id,
'type' => 'test',
'title' => "Notification {$i}",
'category' => NotificationCategory::System,
'icon' => 'bell',
'is_read' => false,
]);
}
$result = $this->service->getPaginated($this->user);
$this->assertEquals(25, $result->perPage());
$this->assertEquals(30, $result->total());
}
public function test_get_paginated_filters_by_category(): void
{
Notification::create([
'user_id' => $this->user->id,
'type' => 'test',
'title' => 'System',
'category' => NotificationCategory::System,
'icon' => 'bell',
'is_read' => false,
]);
Notification::create([
'user_id' => $this->user->id,
'type' => 'test',
'title' => 'Project',
'category' => NotificationCategory::Projects,
'icon' => 'briefcase',
'is_read' => false,
]);
$result = $this->service->getPaginated($this->user, NotificationCategory::System);
$this->assertEquals(1, $result->total());
$this->assertEquals('System', $result->first()->title);
}
// ─── User Preferences ───────────────────────────────────────────────
public function test_get_user_preferences_returns_all_categories(): void
{
$preferences = $this->service->getUserPreferences($this->user);
$this->assertCount(count(NotificationCategory::cases()), $preferences);
}
public function test_get_user_preferences_uses_defaults_when_no_stored(): void
{
$preferences = $this->service->getUserPreferences($this->user);
$this->assertTrue($preferences['applications']['in_app']);
$this->assertTrue($preferences['applications']['email']);
$this->assertEquals('instant', $preferences['applications']['digest']);
}
public function test_get_user_preferences_uses_stored_values(): void
{
NotificationPreference::create([
'user_id' => $this->user->id,
'category' => NotificationCategory::Applications,
'in_app_enabled' => true,
'email_enabled' => false,
'email_digest' => 'daily',
]);
$preferences = $this->service->getUserPreferences($this->user);
$this->assertTrue($preferences['applications']['in_app']);
$this->assertFalse($preferences['applications']['email']);
$this->assertEquals('daily', $preferences['applications']['digest']);
}
public function test_get_user_preferences_marks_locked_categories(): void
{
$preferences = $this->service->getUserPreferences($this->user);
$this->assertTrue($preferences['system']['email_locked']);
$this->assertTrue($preferences['projects']['in_app_locked']);
$this->assertFalse($preferences['campaigns']['in_app_locked']);
}
// ─── Update Preferences ─────────────────────────────────────────────
public function test_update_preferences_creates_new_records(): void
{
$this->service->updatePreferences($this->user, [
'applications' => ['in_app' => true, 'email' => false, 'digest' => 'daily'],
]);
$this->assertDatabaseHas('notification_preferences', [
'user_id' => $this->user->id,
'category' => NotificationCategory::Applications->value,
'in_app_enabled' => true,
'email_enabled' => false,
'email_digest' => 'daily',
]);
}
public function test_update_preferences_updates_existing_records(): void
{
NotificationPreference::create([
'user_id' => $this->user->id,
'category' => NotificationCategory::Applications,
'in_app_enabled' => true,
'email_enabled' => true,
'email_digest' => 'instant',
]);
$this->service->updatePreferences($this->user, [
'applications' => ['in_app' => true, 'email' => false, 'digest' => 'weekly'],
]);
$this->assertDatabaseHas('notification_preferences', [
'user_id' => $this->user->id,
'category' => NotificationCategory::Applications->value,
'email_enabled' => false,
'email_digest' => 'weekly',
]);
$this->assertDatabaseCount('notification_preferences', 1);
}
public function test_update_preferences_enforces_always_on_in_app(): void
{
$this->service->updatePreferences($this->user, [
'projects' => ['in_app' => false, 'email' => true, 'digest' => 'instant'],
]);
$this->assertDatabaseHas('notification_preferences', [
'user_id' => $this->user->id,
'category' => NotificationCategory::Projects->value,
'in_app_enabled' => true,
]);
}
public function test_update_preferences_enforces_always_on_email(): void
{
$this->service->updatePreferences($this->user, [
'system' => ['in_app' => true, 'email' => false, 'digest' => 'disabled'],
]);
$this->assertDatabaseHas('notification_preferences', [
'user_id' => $this->user->id,
'category' => NotificationCategory::System->value,
'email_enabled' => true,
]);
}
// ─── Delete Old Notifications ───────────────────────────────────────
public function test_delete_old_notifications_removes_read_older_than_threshold(): void
{
Notification::create([
'user_id' => $this->user->id,
'type' => 'test',
'title' => 'Old read',
'category' => NotificationCategory::System,
'icon' => 'bell',
'is_read' => true,
'read_at' => now()->subDays(100),
'created_at' => now()->subDays(100),
]);
Notification::create([
'user_id' => $this->user->id,
'type' => 'test',
'title' => 'Recent read',
'category' => NotificationCategory::System,
'icon' => 'bell',
'is_read' => true,
'read_at' => now()->subDays(10),
'created_at' => now()->subDays(10),
]);
$deleted = $this->service->deleteOldNotifications(90);
$this->assertEquals(1, $deleted);
$this->assertDatabaseCount('notifications', 1);
}
public function test_delete_old_notifications_keeps_unread(): void
{
Notification::create([
'user_id' => $this->user->id,
'type' => 'test',
'title' => 'Old unread',
'category' => NotificationCategory::System,
'icon' => 'bell',
'is_read' => false,
'created_at' => now()->subDays(100),
]);
$deleted = $this->service->deleteOldNotifications(90);
$this->assertEquals(0, $deleted);
$this->assertDatabaseCount('notifications', 1);
}
// ─── Model Scopes ───────────────────────────────────────────────────
public function test_scope_unread_filters_correctly(): void
{
Notification::create([
'user_id' => $this->user->id,
'type' => 'test',
'title' => 'Unread',
'category' => NotificationCategory::System,
'icon' => 'bell',
'is_read' => false,
]);
Notification::create([
'user_id' => $this->user->id,
'type' => 'test',
'title' => 'Read',
'category' => NotificationCategory::System,
'icon' => 'bell',
'is_read' => true,
'read_at' => now(),
]);
$this->assertCount(1, Notification::unread()->get());
$this->assertCount(1, Notification::read()->get());
}
public function test_scope_by_category_filters_correctly(): void
{
Notification::create([
'user_id' => $this->user->id,
'type' => 'test',
'title' => 'System',
'category' => NotificationCategory::System,
'icon' => 'bell',
'is_read' => false,
]);
Notification::create([
'user_id' => $this->user->id,
'type' => 'test',
'title' => 'Message',
'category' => NotificationCategory::Messages,
'icon' => 'message-square',
'is_read' => false,
]);
$result = Notification::byCategory(NotificationCategory::Messages)->get();
$this->assertCount(1, $result);
$this->assertEquals('Message', $result->first()->title);
}
public function test_scope_of_type_filters_correctly(): void
{
Notification::create([
'user_id' => $this->user->id,
'type' => 'application_accepted',
'title' => 'Accepted',
'category' => NotificationCategory::Applications,
'icon' => 'check',
'is_read' => false,
]);
Notification::create([
'user_id' => $this->user->id,
'type' => 'application_rejected',
'title' => 'Rejected',
'category' => NotificationCategory::Applications,
'icon' => 'x',
'is_read' => false,
]);
$result = Notification::ofType('application_accepted')->get();
$this->assertCount(1, $result);
}
// ─── Model Accessors ────────────────────────────────────────────────
public function test_display_icon_returns_custom_icon_when_set(): void
{
$notification = Notification::create([
'user_id' => $this->user->id,
'type' => 'test',
'title' => 'Test',
'category' => NotificationCategory::System,
'icon' => 'custom-icon',
'is_read' => false,
]);
$this->assertEquals('custom-icon', $notification->display_icon);
}
public function test_display_icon_falls_back_to_category_icon(): void
{
$notification = Notification::create([
'user_id' => $this->user->id,
'type' => 'test',
'title' => 'Test',
'category' => NotificationCategory::Messages,
'icon' => null,
'is_read' => false,
]);
$this->assertEquals('message-square', $notification->display_icon);
}
public function test_time_ago_accessor_returns_human_readable(): void
{
$notification = Notification::create([
'user_id' => $this->user->id,
'type' => 'test',
'title' => 'Test',
'category' => NotificationCategory::System,
'icon' => 'bell',
'is_read' => false,
'created_at' => now()->subMinutes(5),
]);
$this->assertNotEmpty($notification->time_ago);
$this->assertIsString($notification->time_ago);
}
// ─── NotificationCategory Enum ──────────────────────────────────────
public function test_category_icons_are_all_defined(): void
{
foreach (NotificationCategory::cases() as $category) {
$this->assertNotEmpty($category->icon());
}
}
public function test_system_email_always_on(): void
{
$this->assertTrue(NotificationCategory::System->emailAlwaysOn());
$this->assertFalse(NotificationCategory::Applications->emailAlwaysOn());
}
public function test_in_app_always_on_for_most_categories(): void
{
$this->assertTrue(NotificationCategory::Projects->inAppAlwaysOn());
$this->assertTrue(NotificationCategory::Messages->inAppAlwaysOn());
$this->assertFalse(NotificationCategory::Campaigns->inAppAlwaysOn());
$this->assertFalse(NotificationCategory::Marketing->inAppAlwaysOn());
}
public function test_default_preferences_defined_for_all_categories(): void
{
$defaults = NotificationCategory::defaultPreferences();
foreach (NotificationCategory::cases() as $category) {
$this->assertArrayHasKey($category->value, $defaults);
$this->assertArrayHasKey('in_app', $defaults[$category->value]);
$this->assertArrayHasKey('email', $defaults[$category->value]);
$this->assertArrayHasKey('digest', $defaults[$category->value]);
}
}
}
<?php
namespace Tests\Feature\Projects;
use App\Models\User;
use App\Modules\Campaigns\Models\Campaign;
use App\Modules\Companies\Models\CompanyProfile;
use App\Modules\Creators\Models\CreatorProfile;
use App\Modules\Projects\Models\Project;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ProjectPolicyTest extends TestCase
{
use RefreshDatabase;
private User $creatorUser;
private CreatorProfile $creator;
private User $companyUser;
private CompanyProfile $company;
private User $otherCreatorUser;
private CreatorProfile $otherCreator;
private User $otherCompanyUser;
private CompanyProfile $otherCompany;
private User $admin;
private Campaign $campaign;
private Project $project;
protected function setUp(): void
{
parent::setUp();
$this->creatorUser = User::factory()->creator()->create();
$this->creator = CreatorProfile::factory()->create(['user_id' => $this->creatorUser->id]);
$this->companyUser = User::factory()->company()->create();
$this->company = CompanyProfile::factory()->create(['user_id' => $this->companyUser->id]);
$this->otherCreatorUser = User::factory()->creator()->create();
$this->otherCreator = CreatorProfile::factory()->create(['user_id' => $this->otherCreatorUser->id]);
$this->otherCompanyUser = User::factory()->company()->create();
$this->otherCompany = CompanyProfile::factory()->create(['user_id' => $this->otherCompanyUser->id]);
$this->admin = User::factory()->admin()->create();
$this->campaign = Campaign::factory()->create(['company_id' => $this->company->id]);
$this->project = Project::factory()->create([
'campaign_id' => $this->campaign->id,
'company_id' => $this->company->id,
'creator_id' => $this->creator->id,
'status' => 'not_started',
]);
}
// ─── viewAny ────────────────────────────────────────────────────────
public function test_creator_can_view_any(): void
{
$this->assertTrue($this->creatorUser->can('viewAny', Project::class));
}
public function test_company_can_view_any(): void
{
$this->assertTrue($this->companyUser->can('viewAny', Project::class));
}
public function test_admin_can_view_any(): void
{
$this->assertTrue($this->admin->can('viewAny', Project::class));
}
// ─── view ───────────────────────────────────────────────────────────
public function test_project_creator_can_view(): void
{
$this->assertTrue($this->creatorUser->can('view', $this->project));
}
public function test_project_company_can_view(): void
{
$this->assertTrue($this->companyUser->can('view', $this->project));
}
public function test_admin_can_view_any_project(): void
{
$this->assertTrue($this->admin->can('view', $this->project));
}
public function test_other_creator_cannot_view(): void
{
$this->assertFalse($this->otherCreatorUser->can('view', $this->project));
}
public function test_other_company_cannot_view(): void
{
$this->assertFalse($this->otherCompanyUser->can('view', $this->project));
}
// ─── start ──────────────────────────────────────────────────────────
public function test_creator_can_start_own_not_started_project(): void
{
$this->assertTrue($this->creatorUser->can('start', $this->project));
}
public function test_creator_cannot_start_already_started_project(): void
{
$this->project->update(['status' => 'in_progress']);
$this->assertFalse($this->creatorUser->can('start', $this->project));
}
public function test_company_cannot_start_project(): void
{
$this->assertFalse($this->companyUser->can('start', $this->project));
}
public function test_other_creator_cannot_start(): void
{
$this->assertFalse($this->otherCreatorUser->can('start', $this->project));
}
// ─── cancel ─────────────────────────────────────────────────────────
public function test_creator_can_cancel_own_cancellable_project(): void
{
$result = $this->creatorUser->can('cancel', $this->project);
$this->assertEquals($this->project->canBeCancelled(), $result);
}
public function test_company_can_cancel_own_cancellable_project(): void
{
$result = $this->companyUser->can('cancel', $this->project);
$this->assertEquals($this->project->canBeCancelled(), $result);
}
public function test_admin_can_cancel_cancellable_project(): void
{
$result = $this->admin->can('cancel', $this->project);
$this->assertEquals($this->project->canBeCancelled(), $result);
}
public function test_other_creator_cannot_cancel(): void
{
$this->assertFalse($this->otherCreatorUser->can('cancel', $this->project));
}
public function test_other_company_cannot_cancel(): void
{
$this->assertFalse($this->otherCompanyUser->can('cancel', $this->project));
}
public function test_nobody_can_cancel_completed_project(): void
{
$this->project->update(['status' => 'completed']);
$this->assertFalse($this->creatorUser->can('cancel', $this->project));
$this->assertFalse($this->companyUser->can('cancel', $this->project));
$this->assertFalse($this->admin->can('cancel', $this->project));
}
// ─── dispute ────────────────────────────────────────────────────────
public function test_creator_can_dispute_disputable_project(): void
{
$this->project->update(['status' => 'in_progress']);
$result = $this->creatorUser->can('dispute', $this->project);
$this->assertEquals($this->project->canBeDisputed(), $result);
}
public function test_company_can_dispute_own_project(): void
{
$this->project->update(['status' => 'in_progress']);
$result = $this->companyUser->can('dispute', $this->project);
$this->assertEquals($this->project->canBeDisputed(), $result);
}
public function test_admin_cannot_dispute(): void
{
$this->project->update(['status' => 'in_progress']);
$this->assertFalse($this->admin->can('dispute', $this->project));
}
public function test_other_creator_cannot_dispute(): void
{
$this->project->update(['status' => 'in_progress']);
$this->assertFalse($this->otherCreatorUser->can('dispute', $this->project));
}
// ─── requestExtension ───────────────────────────────────────────────
public function test_creator_can_request_extension_on_active_project(): void
{
$this->project->update(['status' => 'in_progress']);
$this->assertTrue($this->creatorUser->can('requestExtension', $this->project));
}
public function test_company_cannot_request_extension(): void
{
$this->project->update(['status' => 'in_progress']);
$this->assertFalse($this->companyUser->can('requestExtension', $this->project));
}
public function test_creator_cannot_request_extension_on_completed_project(): void
{
$this->project->update(['status' => 'completed']);
$this->assertFalse($this->creatorUser->can('requestExtension', $this->project));
}
// ─── respondToExtension ─────────────────────────────────────────────
public function test_company_can_respond_to_extension_when_pending(): void
{
$this->project->update(['status' => 'in_progress']);
$this->project->deadlineExtensions()->create([
'requested_by' => $this->creatorUser->id,
'reason' => 'Need more time.',
'requested_days' => 7,
'status' => 'pending',
]);
$this->project->refresh();
$this->assertTrue($this->companyUser->can('respondToExtension', $this->project));
}
public function test_creator_cannot_respond_to_extension(): void
{
$this->project->update(['status' => 'in_progress']);
$this->project->deadlineExtensions()->create([
'requested_by' => $this->creatorUser->id,
'reason' => 'Need more time.',
'requested_days' => 7,
'status' => 'pending',
]);
$this->project->refresh();
$this->assertFalse($this->creatorUser->can('respondToExtension', $this->project));
}
public function test_company_cannot_respond_when_no_pending_extension(): void
{
$this->project->update(['status' => 'in_progress']);
$this->assertFalse($this->companyUser->can('respondToExtension', $this->project));
}
// ─── putOnHold ──────────────────────────────────────────────────────
public function test_creator_can_put_holdable_project_on_hold(): void
{
$this->project->update(['status' => 'in_progress']);
$result = $this->creatorUser->can('putOnHold', $this->project);
$this->assertEquals($this->project->canBePutOnHold(), $result);
}
public function test_company_can_put_holdable_project_on_hold(): void
{
$this->project->update(['status' => 'in_progress']);
$result = $this->companyUser->can('putOnHold', $this->project);
$this->assertEquals($this->project->canBePutOnHold(), $result);
}
public function test_admin_can_put_holdable_project_on_hold(): void
{
$this->project->update(['status' => 'in_progress']);
$result = $this->admin->can('putOnHold', $this->project);
$this->assertEquals($this->project->canBePutOnHold(), $result);
}
public function test_outsider_cannot_put_on_hold(): void
{
$this->project->update(['status' => 'in_progress']);
$this->assertFalse($this->otherCreatorUser->can('putOnHold', $this->project));
}
// ─── resume ─────────────────────────────────────────────────────────
public function test_creator_can_resume_on_hold_project(): void
{
$this->project->update(['status' => 'on_hold']);
$this->assertTrue($this->creatorUser->can('resume', $this->project));
}
public function test_company_can_resume_on_hold_project(): void
{
$this->project->update(['status' => 'on_hold']);
$this->assertTrue($this->companyUser->can('resume', $this->project));
}
public function test_admin_can_resume_on_hold_project(): void
{
$this->project->update(['status' => 'on_hold']);
$this->assertTrue($this->admin->can('resume', $this->project));
}
public function test_cannot_resume_non_hold_project(): void
{
$this->project->update(['status' => 'in_progress']);
$this->assertFalse($this->creatorUser->can('resume', $this->project));
}
public function test_outsider_cannot_resume(): void
{
$this->project->update(['status' => 'on_hold']);
$this->assertFalse($this->otherCreatorUser->can('resume', $this->project));
}
// ─── manage ─────────────────────────────────────────────────────────
public function test_company_can_manage_own_project(): void
{
$this->assertTrue($this->companyUser->can('manage', $this->project));
}
public function test_admin_can_manage_any_project(): void
{
$this->assertTrue($this->admin->can('manage', $this->project));
}
public function test_creator_cannot_manage(): void
{
$this->assertFalse($this->creatorUser->can('manage', $this->project));
}
public function test_other_company_cannot_manage(): void
{
$this->assertFalse($this->otherCompanyUser->can('manage', $this->project));
}
}
<?php
namespace Tests\Feature\Reporting;
use App\Models\User;
use App\Modules\Campaigns\Models\Campaign;
use App\Modules\Companies\Models\CompanyProfile;
use App\Modules\Creators\Models\CreatorProfile;
use App\Modules\Reporting\Models\Report;
use App\Modules\Reporting\Services\ReportService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
class ReportServiceTest extends TestCase
{
use RefreshDatabase;
private ReportService $service;
private User $reporter;
private User $admin;
private User $targetUser;
protected function setUp(): void
{
parent::setUp();
$this->service = app(ReportService::class);
$this->reporter = User::factory()->creator()->create();
CreatorProfile::factory()->create(['user_id' => $this->reporter->id]);
$this->admin = User::factory()->admin()->create();
$this->targetUser = User::factory()->creator()->create();
CreatorProfile::factory()->create(['user_id' => $this->targetUser->id]);
}
// ─── Report Submission ──────────────────────────────────────────────
public function test_submit_creates_report_with_required_fields(): void
{
$report = $this->service->submit(
$this->reporter,
'user',
$this->targetUser->id,
['reason' => 'spam', 'description' => null],
);
$this->assertInstanceOf(Report::class, $report);
$this->assertEquals($this->reporter->id, $report->reporter_id);
$this->assertEquals('user', $report->target_type);
$this->assertEquals($this->targetUser->id, $report->target_id);
$this->assertEquals('spam', $report->reason);
$this->assertNull($report->description);
$this->assertEquals('pending', $report->status);
$this->assertNotNull($report->uuid);
}
public function test_submit_creates_report_with_description(): void
{
$report = $this->service->submit(
$this->reporter,
'user',
$this->targetUser->id,
['reason' => 'other', 'description' => 'This user is sending unsolicited messages to multiple creators.'],
);
$this->assertEquals('other', $report->reason);
$this->assertEquals('This user is sending unsolicited messages to multiple creators.', $report->description);
}
public function test_submit_uses_pending_status_by_default(): void
{
$report = $this->service->submit(
$this->reporter,
'user',
$this->targetUser->id,
['reason' => 'harassment'],
);
$this->assertEquals('pending', $report->status);
$this->assertNull($report->resolved_by);
$this->assertNull($report->resolved_at);
$this->assertNull($report->resolution_notes);
}
public function test_submit_generates_uuid_for_report(): void
{
$report = $this->service->submit(
$this->reporter,
'campaign',
999,
['reason' => 'fraud'],
);
$this->assertNotNull($report->uuid);
$this->assertMatchesRegularExpression(
'/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/',
$report->uuid
);
}
public function test_submit_supports_all_valid_target_types(): void
{
$types = ['user', 'campaign', 'message', 'review', 'portfolio_item'];
foreach ($types as $type) {
$report = $this->service->submit(
$this->reporter,
$type,
$this->targetUser->id,
['reason' => 'spam'],
);
$this->assertEquals($type, $report->target_type);
}
$this->assertDatabaseCount('reports', count($types));
}
// ─── Duplicate Prevention ───────────────────────────────────────────
public function test_submit_rejects_duplicate_pending_report_for_same_target(): void
{
$this->service->submit(
$this->reporter,
'user',
$this->targetUser->id,
['reason' => 'spam'],
);
$this->expectException(\Symfony\Component\HttpKernel\Exception\HttpException::class);
$this->service->submit(
$this->reporter,
'user',
$this->targetUser->id,
['reason' => 'harassment'],
);
}
public function test_submit_rejects_duplicate_reviewing_report_for_same_target(): void
{
$report = $this->service->submit(
$this->reporter,
'user',
$this->targetUser->id,
['reason' => 'spam'],
);
$report->update(['status' => 'reviewing']);
$this->expectException(\Symfony\Component\HttpKernel\Exception\HttpException::class);
$this->service->submit(
$this->reporter,
'user',
$this->targetUser->id,
['reason' => 'harassment'],
);
}
public function test_submit_allows_report_after_previous_was_resolved(): void
{
$first = $this->service->submit(
$this->reporter,
'user',
$this->targetUser->id,
['reason' => 'spam'],
);
$first->update(['status' => 'invalid']);
$second = $this->service->submit(
$this->reporter,
'user',
$this->targetUser->id,
['reason' => 'harassment'],
);
$this->assertNotEquals($first->id, $second->id);
$this->assertEquals('pending', $second->status);
}
public function test_submit_allows_same_target_from_different_reporters(): void
{
$otherReporter = User::factory()->creator()->create();
$this->service->submit(
$this->reporter,
'user',
$this->targetUser->id,
['reason' => 'spam'],
);
$second = $this->service->submit(
$otherReporter,
'user',
$this->targetUser->id,
['reason' => 'harassment'],
);
$this->assertNotNull($second->id);
$this->assertDatabaseCount('reports', 2);
}
public function test_submit_allows_same_reporter_different_targets(): void
{
$this->service->submit(
$this->reporter,
'user',
$this->targetUser->id,
['reason' => 'spam'],
);
$second = $this->service->submit(
$this->reporter,
'campaign',
999,
['reason' => 'fraud'],
);
$this->assertNotNull($second->id);
$this->assertDatabaseCount('reports', 2);
}
// ─── Rate Limiting ──────────────────────────────────────────────────
public function test_submit_enforces_daily_rate_limit(): void
{
for ($i = 0; $i < 10; $i++) {
Report::create([
'reporter_id' => $this->reporter->id,
'target_type' => 'user',
'target_id' => $i + 100,
'reason' => 'spam',
'status' => 'pending',
]);
}
$this->expectException(\Symfony\Component\HttpKernel\Exception\HttpException::class);
$this->service->submit(
$this->reporter,
'user',
999,
['reason' => 'spam'],
);
}
public function test_submit_rate_limit_resets_next_day(): void
{
for ($i = 0; $i < 10; $i++) {
$report = Report::create([
'reporter_id' => $this->reporter->id,
'target_type' => 'user',
'target_id' => $i + 100,
'reason' => 'spam',
'status' => 'pending',
]);
$report->update(['created_at' => now()->subDay()]);
}
$newReport = $this->service->submit(
$this->reporter,
'user',
$this->targetUser->id,
['reason' => 'spam'],
);
$this->assertNotNull($newReport->id);
}
public function test_submit_rate_limit_is_per_user(): void
{
for ($i = 0; $i < 10; $i++) {
Report::create([
'reporter_id' => $this->reporter->id,
'target_type' => 'user',
'target_id' => $i + 100,
'reason' => 'spam',
'status' => 'pending',
]);
}
$otherUser = User::factory()->creator()->create();
$report = $this->service->submit(
$otherUser,
'user',
$this->targetUser->id,
['reason' => 'spam'],
);
$this->assertNotNull($report->id);
}
// ─── Report Resolution ──────────────────────────────────────────────
public function test_resolve_updates_status_to_valid(): void
{
$report = Report::create([
'reporter_id' => $this->reporter->id,
'target_type' => 'user',
'target_id' => $this->targetUser->id,
'reason' => 'spam',
'status' => 'pending',
]);
$this->service->resolve($report, $this->admin, 'valid', 'User confirmed as spammer.');
$report->refresh();
$this->assertEquals('valid', $report->status);
$this->assertEquals($this->admin->id, $report->resolved_by);
$this->assertEquals('User confirmed as spammer.', $report->resolution_notes);
$this->assertNotNull($report->resolved_at);
}
public function test_resolve_updates_status_to_invalid(): void
{
$report = Report::create([
'reporter_id' => $this->reporter->id,
'target_type' => 'user',
'target_id' => $this->targetUser->id,
'reason' => 'spam',
'status' => 'reviewing',
]);
$this->service->resolve($report, $this->admin, 'invalid', 'No evidence of spam.');
$report->refresh();
$this->assertEquals('invalid', $report->status);
$this->assertEquals('No evidence of spam.', $report->resolution_notes);
}
public function test_resolve_sets_resolved_at_timestamp(): void
{
$report = Report::create([
'reporter_id' => $this->reporter->id,
'target_type' => 'user',
'target_id' => $this->targetUser->id,
'reason' => 'harassment',
'status' => 'pending',
]);
$before = now();
$this->service->resolve($report, $this->admin, 'valid');
$report->refresh();
$this->assertGreaterThanOrEqual($before->timestamp, $report->resolved_at->timestamp);
}
public function test_resolve_allows_null_notes(): void
{
$report = Report::create([
'reporter_id' => $this->reporter->id,
'target_type' => 'user',
'target_id' => $this->targetUser->id,
'reason' => 'spam',
'status' => 'pending',
]);
$this->service->resolve($report, $this->admin, 'valid');
$report->refresh();
$this->assertNull($report->resolution_notes);
}
public function test_resolve_creates_admin_activity_log(): void
{
$report = Report::create([
'reporter_id' => $this->reporter->id,
'target_type' => 'user',
'target_id' => $this->targetUser->id,
'reason' => 'fraud',
'status' => 'reviewing',
]);
$this->actingAs($this->admin);
$this->service->resolve($report, $this->admin, 'valid', 'Confirmed fraud.');
$this->assertDatabaseHas('admin_activity_logs', [
'admin_id' => $this->admin->id,
'action' => 'report_resolved',
'target_type' => 'report',
'target_id' => $report->id,
]);
}
// ─── Admin Queue ────────────────────────────────────────────────────
public function test_get_admin_queue_returns_pending_and_reviewing_by_default(): void
{
Report::create([
'reporter_id' => $this->reporter->id,
'target_type' => 'user',
'target_id' => 1,
'reason' => 'spam',
'status' => 'pending',
]);
Report::create([
'reporter_id' => $this->reporter->id,
'target_type' => 'campaign',
'target_id' => 2,
'reason' => 'fraud',
'status' => 'reviewing',
]);
Report::create([
'reporter_id' => $this->reporter->id,
'target_type' => 'user',
'target_id' => 3,
'reason' => 'spam',
'status' => 'valid',
]);
$result = $this->service->getAdminQueue();
$this->assertEquals(2, $result->total());
}
public function test_get_admin_queue_filters_by_status(): void
{
Report::create([
'reporter_id' => $this->reporter->id,
'target_type' => 'user',
'target_id' => 1,
'reason' => 'spam',
'status' => 'pending',
]);
Report::create([
'reporter_id' => $this->reporter->id,
'target_type' => 'user',
'target_id' => 2,
'reason' => 'spam',
'status' => 'reviewing',
]);
$result = $this->service->getAdminQueue(['status' => 'pending']);
$this->assertEquals(1, $result->total());
$this->assertEquals('pending', $result->first()->status);
}
public function test_get_admin_queue_filters_by_target_type(): void
{
Report::create([
'reporter_id' => $this->reporter->id,
'target_type' => 'user',
'target_id' => 1,
'reason' => 'spam',
'status' => 'pending',
]);
Report::create([
'reporter_id' => $this->reporter->id,
'target_type' => 'campaign',
'target_id' => 2,
'reason' => 'fraud',
'status' => 'pending',
]);
$result = $this->service->getAdminQueue(['target_type' => 'campaign']);
$this->assertEquals(1, $result->total());
$this->assertEquals('campaign', $result->first()->target_type);
}
public function test_get_admin_queue_orders_by_oldest_first(): void
{
$old = Report::create([
'reporter_id' => $this->reporter->id,
'target_type' => 'user',
'target_id' => 1,
'reason' => 'spam',
'status' => 'pending',
'created_at' => now()->subDays(5),
]);
$new = Report::create([
'reporter_id' => $this->reporter->id,
'target_type' => 'user',
'target_id' => 2,
'reason' => 'spam',
'status' => 'pending',
'created_at' => now()->subDay(),
]);
$result = $this->service->getAdminQueue();
$this->assertEquals($old->id, $result->first()->id);
}
public function test_get_admin_queue_eager_loads_reporter(): void
{
Report::create([
'reporter_id' => $this->reporter->id,
'target_type' => 'user',
'target_id' => 1,
'reason' => 'spam',
'status' => 'pending',
]);
$result = $this->service->getAdminQueue();
$this->assertTrue($result->first()->relationLoaded('reporter'));
}
public function test_get_admin_queue_paginates_results(): void
{
for ($i = 0; $i < 30; $i++) {
Report::create([
'reporter_id' => $this->reporter->id,
'target_type' => 'user',
'target_id' => $i + 100,
'reason' => 'spam',
'status' => 'pending',
]);
}
$result = $this->service->getAdminQueue();
$this->assertEquals(25, $result->perPage());
$this->assertEquals(30, $result->total());
}
// ─── User Reports ───────────────────────────────────────────────────
public function test_get_user_reports_returns_only_own_reports(): void
{
Report::create([
'reporter_id' => $this->reporter->id,
'target_type' => 'user',
'target_id' => 1,
'reason' => 'spam',
'status' => 'pending',
]);
$otherUser = User::factory()->creator()->create();
Report::create([
'reporter_id' => $otherUser->id,
'target_type' => 'user',
'target_id' => 2,
'reason' => 'spam',
'status' => 'pending',
]);
$result = $this->service->getUserReports($this->reporter);
$this->assertEquals(1, $result->total());
$this->assertEquals($this->reporter->id, $result->first()->reporter_id);
}
public function test_get_user_reports_orders_by_newest_first(): void
{
$old = Report::create([
'reporter_id' => $this->reporter->id,
'target_type' => 'user',
'target_id' => 1,
'reason' => 'spam',
'status' => 'pending',
'created_at' => now()->subDays(5),
]);
$new = Report::create([
'reporter_id' => $this->reporter->id,
'target_type' => 'user',
'target_id' => 2,
'reason' => 'fraud',
'status' => 'pending',
'created_at' => now(),
]);
$result = $this->service->getUserReports($this->reporter);
$this->assertEquals($new->id, $result->first()->id);
}
public function test_get_user_reports_includes_all_statuses(): void
{
$statuses = ['pending', 'reviewing', 'valid', 'invalid'];
foreach ($statuses as $i => $status) {
Report::create([
'reporter_id' => $this->reporter->id,
'target_type' => 'user',
'target_id' => $i + 100,
'reason' => 'spam',
'status' => $status,
]);
}
$result = $this->service->getUserReports($this->reporter);
$this->assertEquals(4, $result->total());
}
// ─── Model Scopes ───────────────────────────────────────────────────
public function test_scope_pending_returns_only_pending(): void
{
Report::create([
'reporter_id' => $this->reporter->id,
'target_type' => 'user',
'target_id' => 1,
'reason' => 'spam',
'status' => 'pending',
]);
Report::create([
'reporter_id' => $this->reporter->id,
'target_type' => 'user',
'target_id' => 2,
'reason' => 'spam',
'status' => 'reviewing',
]);
$result = Report::pending()->get();
$this->assertCount(1, $result);
$this->assertEquals('pending', $result->first()->status);
}
public function test_scope_reviewing_returns_only_reviewing(): void
{
Report::create([
'reporter_id' => $this->reporter->id,
'target_type' => 'user',
'target_id' => 1,
'reason' => 'spam',
'status' => 'pending',
]);
Report::create([
'reporter_id' => $this->reporter->id,
'target_type' => 'user',
'target_id' => 2,
'reason' => 'spam',
'status' => 'reviewing',
]);
$result = Report::reviewing()->get();
$this->assertCount(1, $result);
$this->assertEquals('reviewing', $result->first()->status);
}
public function test_scope_resolved_returns_valid_and_invalid(): void
{
Report::create([
'reporter_id' => $this->reporter->id,
'target_type' => 'user',
'target_id' => 1,
'reason' => 'spam',
'status' => 'pending',
]);
Report::create([
'reporter_id' => $this->reporter->id,
'target_type' => 'user',
'target_id' => 2,
'reason' => 'spam',
'status' => 'valid',
]);
Report::create([
'reporter_id' => $this->reporter->id,
'target_type' => 'user',
'target_id' => 3,
'reason' => 'spam',
'status' => 'invalid',
]);
$result = Report::resolved()->get();
$this->assertCount(2, $result);
}
public function test_scope_for_reporter_filters_by_user(): void
{
Report::create([
'reporter_id' => $this->reporter->id,
'target_type' => 'user',
'target_id' => 1,
'reason' => 'spam',
'status' => 'pending',
]);
$otherUser = User::factory()->creator()->create();
Report::create([
'reporter_id' => $otherUser->id,
'target_type' => 'user',
'target_id' => 2,
'reason' => 'spam',
'status' => 'pending',
]);
$result = Report::forReporter($this->reporter->id)->get();
$this->assertCount(1, $result);
$this->assertEquals($this->reporter->id, $result->first()->reporter_id);
}
public function test_scope_for_target_filters_by_type_and_id(): void
{
Report::create([
'reporter_id' => $this->reporter->id,
'target_type' => 'user',
'target_id' => 42,
'reason' => 'spam',
'status' => 'pending',
]);
Report::create([
'reporter_id' => $this->reporter->id,
'target_type' => 'campaign',
'target_id' => 42,
'reason' => 'fraud',
'status' => 'pending',
]);
Report::create([
'reporter_id' => $this->reporter->id,
'target_type' => 'user',
'target_id' => 99,
'reason' => 'spam',
'status' => 'pending',
]);
$result = Report::forTarget('user', 42)->get();
$this->assertCount(1, $result);
$this->assertEquals('user', $result->first()->target_type);
$this->assertEquals(42, $result->first()->target_id);
}
// ─── Model Relationships ────────────────────────────────────────────
public function test_report_belongs_to_reporter(): void
{
$report = Report::create([
'reporter_id' => $this->reporter->id,
'target_type' => 'user',
'target_id' => $this->targetUser->id,
'reason' => 'spam',
'status' => 'pending',
]);
$this->assertEquals($this->reporter->id, $report->reporter->id);
$this->assertEquals($this->reporter->email, $report->reporter->email);
}
public function test_report_belongs_to_resolver_when_resolved(): void
{
$report = Report::create([
'reporter_id' => $this->reporter->id,
'target_type' => 'user',
'target_id' => $this->targetUser->id,
'reason' => 'spam',
'status' => 'valid',
'resolved_by' => $this->admin->id,
'resolved_at' => now(),
]);
$this->assertEquals($this->admin->id, $report->resolver->id);
}
public function test_report_resolver_is_null_when_unresolved(): void
{
$report = Report::create([
'reporter_id' => $this->reporter->id,
'target_type' => 'user',
'target_id' => $this->targetUser->id,
'reason' => 'spam',
'status' => 'pending',
]);
$this->assertNull($report->resolver);
}
// ─── Route Key Name ─────────────────────────────────────────────────
public function test_report_uses_uuid_as_route_key(): void
{
$report = Report::create([
'reporter_id' => $this->reporter->id,
'target_type' => 'user',
'target_id' => $this->targetUser->id,
'reason' => 'spam',
'status' => 'pending',
]);
$this->assertEquals('uuid', $report->getRouteKeyName());
}
}
<?php
namespace Tests\Feature\Reporting;
use App\Models\User;
use App\Modules\Companies\Models\CompanyProfile;
use App\Modules\Creators\Models\CreatorProfile;
use App\Modules\Reporting\Models\Report;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Tests\TestCase;
class ReportingApiTest extends TestCase
{
use RefreshDatabase;
private User $creator;
private User $company;
private User $admin;
private User $targetUser;
protected function setUp(): void
{
parent::setUp();
$this->creator = User::factory()->creator()->create(['email_verified_at' => now()]);
CreatorProfile::factory()->create(['user_id' => $this->creator->id]);
$this->company = User::factory()->company()->create(['email_verified_at' => now()]);
CompanyProfile::factory()->create(['user_id' => $this->company->id]);
$this->admin = User::factory()->admin()->create(['email_verified_at' => now()]);
$this->targetUser = User::factory()->creator()->create();
}
// ─── Submit Report (POST /reports) ──────────────────────────────────
public function test_authenticated_user_can_submit_report(): void
{
$response = $this->actingAs($this->creator)
->postJson('/reports', [
'target_type' => 'user',
'target_id' => $this->targetUser->uuid,
'reason' => 'spam',
]);
$response->assertStatus(201);
$response->assertJson(['success' => true]);
}
public function test_unauthenticated_user_cannot_submit_report(): void
{
$response = $this->postJson('/reports', [
'target_type' => 'user',
'target_id' => Str::uuid()->toString(),
'reason' => 'spam',
]);
$response->assertStatus(401);
}
public function test_submit_report_validates_target_type(): void
{
$response = $this->actingAs($this->creator)
->postJson('/reports', [
'target_type' => 'invalid_type',
'target_id' => Str::uuid()->toString(),
'reason' => 'spam',
]);
$response->assertStatus(422);
$response->assertJsonValidationErrors(['target_type']);
}
public function test_submit_report_validates_reason(): void
{
$response = $this->actingAs($this->creator)
->postJson('/reports', [
'target_type' => 'user',
'target_id' => Str::uuid()->toString(),
'reason' => 'invalid_reason',
]);
$response->assertStatus(422);
$response->assertJsonValidationErrors(['reason']);
}
public function test_submit_report_requires_target_id_as_uuid(): void
{
$response = $this->actingAs($this->creator)
->postJson('/reports', [
'target_type' => 'user',
'target_id' => '12345',
'reason' => 'spam',
]);
$response->assertStatus(422);
$response->assertJsonValidationErrors(['target_id']);
}
public function test_submit_report_requires_description_when_reason_is_other(): void
{
$response = $this->actingAs($this->creator)
->postJson('/reports', [
'target_type' => 'user',
'target_id' => Str::uuid()->toString(),
'reason' => 'other',
'description' => null,
]);
$response->assertStatus(422);
$response->assertJsonValidationErrors(['description']);
}
public function test_submit_report_description_must_be_at_least_20_chars(): void
{
$response = $this->actingAs($this->creator)
->postJson('/reports', [
'target_type' => 'user',
'target_id' => Str::uuid()->toString(),
'reason' => 'other',
'description' => 'Too short.',
]);
$response->assertStatus(422);
$response->assertJsonValidationErrors(['description']);
}
public function test_submit_report_description_max_2000_chars(): void
{
$response = $this->actingAs($this->creator)
->postJson('/reports', [
'target_type' => 'user',
'target_id' => Str::uuid()->toString(),
'reason' => 'other',
'description' => str_repeat('a', 2001),
]);
$response->assertStatus(422);
$response->assertJsonValidationErrors(['description']);
}
public function test_submit_report_accepts_valid_description(): void
{
$response = $this->actingAs($this->creator)
->postJson('/reports', [
'target_type' => 'user',
'target_id' => $this->targetUser->uuid,
'reason' => 'other',
'description' => 'This user keeps sending me spam messages repeatedly and will not stop despite being asked.',
]);
$response->assertStatus(201);
}
public function test_submit_report_validates_evidence_max_3_files(): void
{
Storage::fake('local');
$response = $this->actingAs($this->creator)
->postJson('/reports', [
'target_type' => 'user',
'target_id' => Str::uuid()->toString(),
'reason' => 'harassment',
'evidence' => [
UploadedFile::fake()->image('a.jpg', 100, 100),
UploadedFile::fake()->image('b.jpg', 100, 100),
UploadedFile::fake()->image('c.jpg', 100, 100),
UploadedFile::fake()->image('d.jpg', 100, 100),
],
]);
$response->assertStatus(422);
$response->assertJsonValidationErrors(['evidence']);
}
public function test_submit_report_validates_evidence_mime_types(): void
{
Storage::fake('local');
$response = $this->actingAs($this->creator)
->postJson('/reports', [
'target_type' => 'user',
'target_id' => Str::uuid()->toString(),
'reason' => 'harassment',
'evidence' => [
UploadedFile::fake()->create('script.js', 100, 'application/javascript'),
],
]);
$response->assertStatus(422);
$response->assertJsonValidationErrors(['evidence.0']);
}
public function test_submit_report_validates_evidence_max_size_5mb(): void
{
Storage::fake('local');
$response = $this->actingAs($this->creator)
->postJson('/reports', [
'target_type' => 'user',
'target_id' => Str::uuid()->toString(),
'reason' => 'harassment',
'evidence' => [
UploadedFile::fake()->create('big.png', 6000, 'image/png'),
],
]);
$response->assertStatus(422);
$response->assertJsonValidationErrors(['evidence.0']);
}
public function test_submit_report_all_valid_reasons_accepted(): void
{
$reasons = ['spam', 'harassment', 'inappropriate_content', 'fraud', 'impersonation', 'copyright', 'other'];
foreach ($reasons as $i => $reason) {
$data = [
'target_type' => 'user',
'target_id' => Str::uuid()->toString(),
'reason' => $reason,
];
if ($reason === 'other') {
$data['description'] = 'This is a detailed description of the issue that is at least twenty characters.';
}
$response = $this->actingAs($this->creator)
->postJson('/reports', $data);
$this->assertContains($response->status(), [201, 422], "Failed for reason: {$reason}");
}
}
public function test_submit_report_all_valid_target_types_accepted(): void
{
$types = ['user', 'campaign', 'message', 'review', 'portfolio_item'];
foreach ($types as $type) {
$response = $this->actingAs($this->creator)
->postJson('/reports', [
'target_type' => $type,
'target_id' => Str::uuid()->toString(),
'reason' => 'spam',
]);
$this->assertContains($response->status(), [201, 422], "Failed for target_type: {$type}");
}
}
public function test_company_user_can_submit_report(): void
{
$response = $this->actingAs($this->company)
->postJson('/reports', [
'target_type' => 'user',
'target_id' => $this->targetUser->uuid,
'reason' => 'fraud',
]);
$response->assertStatus(201);
}
// ─── My Reports (GET /my-reports) ───────────────────────────────────
public function test_user_can_view_own_reports(): void
{
Report::create([
'reporter_id' => $this->creator->id,
'target_type' => 'user',
'target_id' => $this->targetUser->id,
'reason' => 'spam',
'status' => 'pending',
]);
$response = $this->actingAs($this->creator)->get('/my-reports');
$response->assertStatus(200);
}
public function test_unauthenticated_user_cannot_view_reports(): void
{
$response = $this->get('/my-reports');
$response->assertRedirect();
}
// ─── Admin: Report Queue (GET /admin/reports) ───────────────────────
public function test_admin_can_view_report_queue(): void
{
$response = $this->actingAs($this->admin)->get('/admin/reports');
$response->assertStatus(200);
}
public function test_non_admin_cannot_view_report_queue(): void
{
$response = $this->actingAs($this->creator)->get('/admin/reports');
$response->assertStatus(403);
}
public function test_admin_queue_filters_by_status(): void
{
Report::create([
'reporter_id' => $this->creator->id,
'target_type' => 'user',
'target_id' => 1,
'reason' => 'spam',
'status' => 'pending',
]);
$response = $this->actingAs($this->admin)->get('/admin/reports?status=pending');
$response->assertStatus(200);
}
public function test_admin_queue_filters_by_target_type(): void
{
Report::create([
'reporter_id' => $this->creator->id,
'target_type' => 'campaign',
'target_id' => 1,
'reason' => 'fraud',
'status' => 'pending',
]);
$response = $this->actingAs($this->admin)->get('/admin/reports?target_type=campaign');
$response->assertStatus(200);
}
// ─── Admin: Show Report (GET /admin/reports/{report}) ───────────────
public function test_admin_can_view_individual_report(): void
{
$report = Report::create([
'reporter_id' => $this->creator->id,
'target_type' => 'user',
'target_id' => $this->targetUser->id,
'reason' => 'spam',
'status' => 'pending',
]);
$response = $this->actingAs($this->admin)->get("/admin/reports/{$report->uuid}");
$response->assertStatus(200);
}
public function test_viewing_pending_report_transitions_to_reviewing(): void
{
$report = Report::create([
'reporter_id' => $this->creator->id,
'target_type' => 'user',
'target_id' => $this->targetUser->id,
'reason' => 'spam',
'status' => 'pending',
]);
$this->actingAs($this->admin)->get("/admin/reports/{$report->uuid}");
$report->refresh();
$this->assertEquals('reviewing', $report->status);
}
public function test_viewing_reviewing_report_does_not_change_status(): void
{
$report = Report::create([
'reporter_id' => $this->creator->id,
'target_type' => 'user',
'target_id' => $this->targetUser->id,
'reason' => 'spam',
'status' => 'reviewing',
]);
$this->actingAs($this->admin)->get("/admin/reports/{$report->uuid}");
$report->refresh();
$this->assertEquals('reviewing', $report->status);
}
public function test_non_admin_cannot_view_individual_report(): void
{
$report = Report::create([
'reporter_id' => $this->creator->id,
'target_type' => 'user',
'target_id' => $this->targetUser->id,
'reason' => 'spam',
'status' => 'pending',
]);
$response = $this->actingAs($this->creator)->get("/admin/reports/{$report->uuid}");
$response->assertStatus(403);
}
// ─── Admin: Resolve Report (POST /admin/reports/{report}/resolve) ───
public function test_admin_can_resolve_report_as_valid(): void
{
$report = Report::create([
'reporter_id' => $this->creator->id,
'target_type' => 'user',
'target_id' => $this->targetUser->id,
'reason' => 'spam',
'status' => 'reviewing',
]);
$response = $this->actingAs($this->admin)
->post("/admin/reports/{$report->uuid}/resolve", [
'status' => 'valid',
'notes' => 'Confirmed spam behavior.',
]);
$response->assertRedirect();
$response->assertSessionHas('success');
$report->refresh();
$this->assertEquals('valid', $report->status);
$this->assertEquals($this->admin->id, $report->resolved_by);
}
public function test_admin_can_resolve_report_as_invalid(): void
{
$report = Report::create([
'reporter_id' => $this->creator->id,
'target_type' => 'user',
'target_id' => $this->targetUser->id,
'reason' => 'harassment',
'status' => 'reviewing',
]);
$response = $this->actingAs($this->admin)
->post("/admin/reports/{$report->uuid}/resolve", [
'status' => 'invalid',
'notes' => 'No evidence of harassment found.',
]);
$response->assertRedirect();
$report->refresh();
$this->assertEquals('invalid', $report->status);
}
public function test_resolve_requires_valid_status(): void
{
$report = Report::create([
'reporter_id' => $this->creator->id,
'target_type' => 'user',
'target_id' => $this->targetUser->id,
'reason' => 'spam',
'status' => 'reviewing',
]);
$response = $this->actingAs($this->admin)
->post("/admin/reports/{$report->uuid}/resolve", [
'status' => 'pending',
]);
$response->assertSessionHasErrors(['status']);
}
public function test_non_admin_cannot_resolve_report(): void
{
$report = Report::create([
'reporter_id' => $this->creator->id,
'target_type' => 'user',
'target_id' => $this->targetUser->id,
'reason' => 'spam',
'status' => 'reviewing',
]);
$response = $this->actingAs($this->creator)
->post("/admin/reports/{$report->uuid}/resolve", [
'status' => 'valid',
]);
$response->assertStatus(403);
}
public function test_resolve_notes_are_optional(): void
{
$report = Report::create([
'reporter_id' => $this->creator->id,
'target_type' => 'user',
'target_id' => $this->targetUser->id,
'reason' => 'spam',
'status' => 'reviewing',
]);
$response = $this->actingAs($this->admin)
->post("/admin/reports/{$report->uuid}/resolve", [
'status' => 'valid',
]);
$response->assertRedirect();
$report->refresh();
$this->assertEquals('valid', $report->status);
$this->assertNull($report->resolution_notes);
}
public function test_resolve_notes_max_2000_chars(): void
{
$report = Report::create([
'reporter_id' => $this->creator->id,
'target_type' => 'user',
'target_id' => $this->targetUser->id,
'reason' => 'spam',
'status' => 'reviewing',
]);
$response = $this->actingAs($this->admin)
->post("/admin/reports/{$report->uuid}/resolve", [
'status' => 'valid',
'notes' => str_repeat('a', 2001),
]);
$response->assertSessionHasErrors(['notes']);
}
public function test_resolve_creates_audit_log_entry(): void
{
$report = Report::create([
'reporter_id' => $this->creator->id,
'target_type' => 'user',
'target_id' => $this->targetUser->id,
'reason' => 'fraud',
'status' => 'reviewing',
]);
$this->actingAs($this->admin)
->post("/admin/reports/{$report->uuid}/resolve", [
'status' => 'valid',
'notes' => 'Fraud confirmed.',
]);
$this->assertDatabaseHas('admin_activity_logs', [
'admin_id' => $this->admin->id,
'action' => 'report_resolved',
'target_type' => 'report',
'target_id' => $report->id,
]);
}
}
<?php
namespace Tests\Feature\Reputation;
use App\Models\User;
use App\Modules\Companies\Models\CompanyProfile;
use App\Modules\Creators\Models\CreatorProfile;
use App\Modules\Projects\Models\Project;
use App\Modules\Reputation\Enums\ReputationTier;
use App\Modules\Reputation\Services\ReputationService;
use App\Modules\Reviews\Models\Review;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
class ReputationServiceTest extends TestCase
{
use RefreshDatabase;
private ReputationService $service;
private User $creatorUser;
private CreatorProfile $creator;
private User $companyUser;
private CompanyProfile $company;
protected function setUp(): void
{
parent::setUp();
$this->service = app(ReputationService::class);
$this->creatorUser = User::factory()->creator()->create([
'status' => 'active',
'created_at' => now()->subMonths(6),
]);
$this->creator = CreatorProfile::factory()->create([
'user_id' => $this->creatorUser->id,
'last_active_at' => now(),
'completion_percentage' => 80,
'avg_response_hours' => 4,
]);
$this->companyUser = User::factory()->company()->create([
'status' => 'active',
'created_at' => now()->subMonths(12),
]);
$this->company = CompanyProfile::factory()->create([
'user_id' => $this->companyUser->id,
'completion_percentage' => 90,
]);
}
// ─── ReputationTier Enum ────────────────────────────────────────────
public function test_tier_from_score_new(): void
{
$this->assertEquals(ReputationTier::New, ReputationTier::fromScore(0));
$this->assertEquals(ReputationTier::New, ReputationTier::fromScore(10));
$this->assertEquals(ReputationTier::New, ReputationTier::fromScore(24));
}
public function test_tier_from_score_rising(): void
{
$this->assertEquals(ReputationTier::Rising, ReputationTier::fromScore(25));
$this->assertEquals(ReputationTier::Rising, ReputationTier::fromScore(35));
$this->assertEquals(ReputationTier::Rising, ReputationTier::fromScore(49));
}
public function test_tier_from_score_established(): void
{
$this->assertEquals(ReputationTier::Established, ReputationTier::fromScore(50));
$this->assertEquals(ReputationTier::Established, ReputationTier::fromScore(60));
$this->assertEquals(ReputationTier::Established, ReputationTier::fromScore(69));
}
public function test_tier_from_score_top_rated(): void
{
$this->assertEquals(ReputationTier::TopRated, ReputationTier::fromScore(70));
$this->assertEquals(ReputationTier::TopRated, ReputationTier::fromScore(80));
$this->assertEquals(ReputationTier::TopRated, ReputationTier::fromScore(84));
}
public function test_tier_from_score_elite(): void
{
$this->assertEquals(ReputationTier::Elite, ReputationTier::fromScore(85));
$this->assertEquals(ReputationTier::Elite, ReputationTier::fromScore(95));
$this->assertEquals(ReputationTier::Elite, ReputationTier::fromScore(100));
}
public function test_tier_icons_defined(): void
{
foreach (ReputationTier::cases() as $tier) {
$this->assertNotEmpty($tier->icon());
}
}
public function test_tier_colors_defined(): void
{
foreach (ReputationTier::cases() as $tier) {
$this->assertNotEmpty($tier->color());
}
}
public function test_tier_min_scores_are_ordered(): void
{
$scores = array_map(fn ($t) => $t->minScore(), ReputationTier::cases());
$sorted = $scores;
sort($sorted);
$this->assertEquals($sorted, $scores);
}
// ─── Creator Score Calculation ──────────────────────────────────────
public function test_calculate_creator_score_returns_integer(): void
{
$score = $this->service->calculateCreatorScore($this->creator);
$this->assertIsInt($score);
}
public function test_calculate_creator_score_is_between_0_and_100(): void
{
$score = $this->service->calculateCreatorScore($this->creator);
$this->assertGreaterThanOrEqual(0, $score);
$this->assertLessThanOrEqual(100, $score);
}
public function test_calculate_creator_score_updates_profile(): void
{
$this->service->calculateCreatorScore($this->creator);
$this->creator->refresh();
$this->assertNotNull($this->creator->reputation_score);
$this->assertNotNull($this->creator->reputation_tier);
}
public function test_calculate_creator_score_with_no_data_gives_moderate_score(): void
{
$freshCreator = CreatorProfile::factory()->create([
'user_id' => User::factory()->creator()->create(['created_at' => now()])->id,
'last_active_at' => now(),
'completion_percentage' => 0,
'avg_response_hours' => null,
]);
$score = $this->service->calculateCreatorScore($freshCreator);
$this->assertGreaterThanOrEqual(15, $score);
$this->assertLessThanOrEqual(60, $score);
}
public function test_calculate_creator_score_rewards_good_reviews(): void
{
$reviewerUser = User::factory()->company()->create();
for ($i = 0; $i < 5; $i++) {
Review::create([
'reviewer_id' => $reviewerUser->id,
'reviewee_id' => $this->creatorUser->id,
'project_id' => null,
'overall_rating' => 5,
'communication_rating' => 5,
'quality_rating' => 5,
'timeliness_rating' => 5,
'comment' => 'Excellent work.',
'status' => 'published',
]);
}
$score = $this->service->calculateCreatorScore($this->creator);
$this->assertGreaterThanOrEqual(40, $score);
}
public function test_calculate_creator_score_penalizes_bad_reviews(): void
{
$reviewerUser = User::factory()->company()->create();
for ($i = 0; $i < 5; $i++) {
Review::create([
'reviewer_id' => $reviewerUser->id,
'reviewee_id' => $this->creatorUser->id,
'project_id' => null,
'overall_rating' => 1,
'communication_rating' => 1,
'quality_rating' => 1,
'timeliness_rating' => 1,
'comment' => 'Poor work.',
'status' => 'published',
]);
}
$scoreWithBadReviews = $this->service->calculateCreatorScore($this->creator);
$goodCreatorUser = User::factory()->creator()->create(['created_at' => now()->subMonths(6)]);
$goodCreator = CreatorProfile::factory()->create([
'user_id' => $goodCreatorUser->id,
'last_active_at' => now(),
'completion_percentage' => 80,
'avg_response_hours' => 4,
]);
for ($i = 0; $i < 5; $i++) {
Review::create([
'reviewer_id' => $reviewerUser->id,
'reviewee_id' => $goodCreatorUser->id,
'project_id' => null,
'overall_rating' => 5,
'communication_rating' => 5,
'quality_rating' => 5,
'timeliness_rating' => 5,
'comment' => 'Great work.',
'status' => 'published',
]);
}
$scoreWithGoodReviews = $this->service->calculateCreatorScore($goodCreator);
$this->assertLessThan($scoreWithGoodReviews, $scoreWithBadReviews);
}
// ─── Decay ──────────────────────────────────────────────────────────
public function test_no_decay_within_90_days(): void
{
$this->creator->update(['last_active_at' => now()->subDays(60)]);
$scoreActive = $this->service->calculateCreatorScore($this->creator);
$this->creator->update(['last_active_at' => now()]);
$scoreRecent = $this->service->calculateCreatorScore($this->creator);
$this->assertEquals($scoreActive, $scoreRecent);
}
public function test_decay_applies_after_90_days(): void
{
$this->creator->update(['last_active_at' => now()]);
$scoreActive = $this->service->calculateCreatorScore($this->creator);
$this->creator->update(['last_active_at' => now()->subDays(180)]);
$scoreInactive = $this->service->calculateCreatorScore($this->creator);
$this->assertLessThan($scoreActive, $scoreInactive);
}
public function test_decay_has_minimum_floor(): void
{
$this->creator->update(['last_active_at' => now()->subYears(5)]);
$score = $this->service->calculateCreatorScore($this->creator);
$this->assertGreaterThanOrEqual(10, $score);
}
public function test_no_decay_when_last_active_is_null(): void
{
$this->creator->update(['last_active_at' => null]);
$score = $this->service->calculateCreatorScore($this->creator);
$this->assertGreaterThan(10, $score);
}
// ─── Response Time Scoring ──────────────────────────────────────────
public function test_fast_response_time_gives_high_score(): void
{
$this->creator->update(['avg_response_hours' => 1]);
$fastScore = $this->service->calculateCreatorScore($this->creator);
$this->creator->update(['avg_response_hours' => 72]);
$slowScore = $this->service->calculateCreatorScore($this->creator);
$this->assertGreaterThan($slowScore, $fastScore);
}
// ─── Company Score Calculation ──────────────────────────────────────
public function test_calculate_company_score_returns_integer(): void
{
$score = $this->service->calculateCompanyScore($this->company);
$this->assertIsInt($score);
}
public function test_calculate_company_score_is_between_0_and_100(): void
{
$score = $this->service->calculateCompanyScore($this->company);
$this->assertGreaterThanOrEqual(0, $score);
$this->assertLessThanOrEqual(100, $score);
}
public function test_calculate_company_score_updates_profile(): void
{
$this->service->calculateCompanyScore($this->company);
$this->company->refresh();
$this->assertNotNull($this->company->reputation_score);
}
public function test_company_score_penalizes_cancellations(): void
{
$score1 = $this->service->calculateCompanyScore($this->company);
$this->assertGreaterThanOrEqual(0, $score1);
}
// ─── Score Breakdown ────────────────────────────────────────────────
public function test_get_score_breakdown_returns_all_factors(): void
{
$this->service->calculateCreatorScore($this->creator);
$breakdown = $this->service->getScoreBreakdown($this->creator);
$this->assertArrayHasKey('total', $breakdown);
$this->assertArrayHasKey('tier', $breakdown);
$this->assertArrayHasKey('factors', $breakdown);
$this->assertCount(8, $breakdown['factors']);
}
public function test_get_score_breakdown_factor_keys(): void
{
$this->service->calculateCreatorScore($this->creator);
$breakdown = $this->service->getScoreBreakdown($this->creator);
$keys = array_column($breakdown['factors'], 'key');
$this->assertContains('reviews', $keys);
$this->assertContains('completion', $keys);
$this->assertContains('on_time', $keys);
$this->assertContains('response_rate', $keys);
$this->assertContains('response_time', $keys);
$this->assertContains('profile', $keys);
$this->assertContains('account_age', $keys);
$this->assertContains('revision_rate', $keys);
}
public function test_get_score_breakdown_weights_sum_to_100(): void
{
$this->service->calculateCreatorScore($this->creator);
$breakdown = $this->service->getScoreBreakdown($this->creator);
$totalWeight = array_sum(array_column($breakdown['factors'], 'weight'));
$this->assertEquals(100, $totalWeight);
}
public function test_get_score_breakdown_each_factor_has_required_fields(): void
{
$this->service->calculateCreatorScore($this->creator);
$breakdown = $this->service->getScoreBreakdown($this->creator);
foreach ($breakdown['factors'] as $factor) {
$this->assertArrayHasKey('key', $factor);
$this->assertArrayHasKey('label', $factor);
$this->assertArrayHasKey('score', $factor);
$this->assertArrayHasKey('weight', $factor);
$this->assertArrayHasKey('weighted', $factor);
$this->assertGreaterThanOrEqual(0, $factor['score']);
$this->assertLessThanOrEqual(100, $factor['score']);
}
}
public function test_get_score_breakdown_returns_tier(): void
{
$this->service->calculateCreatorScore($this->creator);
$breakdown = $this->service->getScoreBreakdown($this->creator);
$this->assertInstanceOf(ReputationTier::class, $breakdown['tier']);
}
// ─── Account Age Scoring ────────────────────────────────────────────
public function test_new_account_gets_low_age_score(): void
{
$newUser = User::factory()->creator()->create(['created_at' => now()->subDays(5)]);
$newCreator = CreatorProfile::factory()->create([
'user_id' => $newUser->id,
'last_active_at' => now(),
'completion_percentage' => 80,
'avg_response_hours' => 4,
]);
$this->service->calculateCreatorScore($newCreator);
$breakdown = $this->service->getScoreBreakdown($newCreator);
$ageFactor = collect($breakdown['factors'])->firstWhere('key', 'account_age');
$this->assertLessThanOrEqual(20, $ageFactor['score']);
}
public function test_old_account_gets_high_age_score(): void
{
$oldUser = User::factory()->creator()->create(['created_at' => now()->subYears(3)]);
$oldCreator = CreatorProfile::factory()->create([
'user_id' => $oldUser->id,
'last_active_at' => now(),
'completion_percentage' => 80,
'avg_response_hours' => 4,
]);
$this->service->calculateCreatorScore($oldCreator);
$breakdown = $this->service->getScoreBreakdown($oldCreator);
$ageFactor = collect($breakdown['factors'])->firstWhere('key', 'account_age');
$this->assertEquals(100, $ageFactor['score']);
}
// ─── Profile Completion Impact ──────────────────────────────────────
public function test_complete_profile_gives_max_profile_score(): void
{
$this->creator->update(['completion_percentage' => 100]);
$this->service->calculateCreatorScore($this->creator);
$breakdown = $this->service->getScoreBreakdown($this->creator);
$profileFactor = collect($breakdown['factors'])->firstWhere('key', 'profile');
$this->assertEquals(100, $profileFactor['score']);
}
public function test_empty_profile_gives_zero_profile_score(): void
{
$this->creator->update(['completion_percentage' => 0]);
$this->service->calculateCreatorScore($this->creator);
$breakdown = $this->service->getScoreBreakdown($this->creator);
$profileFactor = collect($breakdown['factors'])->firstWhere('key', 'profile');
$this->assertEquals(0, $profileFactor['score']);
}
// ─── Recalculate All ────────────────────────────────────────────────
public function test_recalculate_all_updates_all_active_profiles(): void
{
$this->creator->update(['reputation_score' => null]);
$this->company->update(['reputation_score' => null]);
$this->service->recalculateAll();
$this->creator->refresh();
$this->company->refresh();
$this->assertNotNull($this->creator->reputation_score);
$this->assertNotNull($this->company->reputation_score);
}
public function test_recalculate_all_skips_inactive_users(): void
{
$inactiveUser = User::factory()->creator()->create(['status' => 'suspended']);
$inactiveCreator = CreatorProfile::factory()->create([
'user_id' => $inactiveUser->id,
'reputation_score' => null,
]);
$this->service->recalculateAll();
$inactiveCreator->refresh();
$this->assertNull($inactiveCreator->reputation_score);
}
}
<?php
namespace Tests\Feature\VideoReview;
use App\Models\User;
use App\Modules\Campaigns\Models\Campaign;
use App\Modules\Companies\Models\CompanyProfile;
use App\Modules\Creators\Models\CreatorProfile;
use App\Modules\Deliverables\Models\Deliverable;
use App\Modules\Deliverables\Models\Submission;
use App\Modules\Deliverables\Models\TimestampComment;
use App\Modules\Projects\Models\Project;
use App\Modules\VideoReview\Services\VideoReviewService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class VideoReviewServiceTest extends TestCase
{
use RefreshDatabase;
private VideoReviewService $service;
private User $companyUser;
private User $creatorUser;
private CreatorProfile $creator;
private Submission $submission;
protected function setUp(): void
{
parent::setUp();
$this->service = app(VideoReviewService::class);
$this->companyUser = User::factory()->company()->create();
$company = CompanyProfile::factory()->create(['user_id' => $this->companyUser->id]);
$this->creatorUser = User::factory()->creator()->create();
$this->creator = CreatorProfile::factory()->create(['user_id' => $this->creatorUser->id]);
$campaign = Campaign::factory()->create(['company_id' => $company->id]);
$project = Project::factory()->create([
'campaign_id' => $campaign->id,
'company_id' => $company->id,
'creator_id' => $this->creator->id,
]);
$deliverable = Deliverable::factory()->create([
'project_id' => $project->id,
]);
$this->submission = Submission::create([
'deliverable_id' => $deliverable->id,
'creator_id' => $this->creator->id,
'version' => 1,
'notes' => 'First submission.',
'submitted_at' => now(),
]);
}
// ─── Add Timestamp Comment ──────────────────────────────────────────
public function test_add_timestamp_comment_creates_record(): void
{
$comment = $this->service->addTimestampComment($this->submission, $this->companyUser, [
'timestamp_seconds' => 15.5,
'comment' => 'The transition here is too abrupt.',
'type' => 'issue',
]);
$this->assertInstanceOf(TimestampComment::class, $comment);
$this->assertEquals($this->submission->id, $comment->submission_id);
$this->assertEquals($this->companyUser->id, $comment->user_id);
$this->assertEquals(15.5, $comment->timestamp_seconds);
$this->assertEquals('The transition here is too abrupt.', $comment->comment);
$this->assertEquals('issue', $comment->type);
$this->assertFalse($comment->is_resolved);
}
public function test_add_timestamp_comment_defaults_type_to_suggestion(): void
{
$comment = $this->service->addTimestampComment($this->submission, $this->companyUser, [
'timestamp_seconds' => 30.0,
'comment' => 'Maybe try a different angle here.',
]);
$this->assertEquals('suggestion', $comment->type);
}
public function test_add_timestamp_comment_supports_all_types(): void
{
$types = ['issue', 'suggestion', 'praise'];
foreach ($types as $type) {
$comment = $this->service->addTimestampComment($this->submission, $this->companyUser, [
'timestamp_seconds' => 10.0,
'comment' => "A {$type} comment.",
'type' => $type,
]);
$this->assertEquals($type, $comment->type);
}
}
public function test_add_timestamp_comment_stores_timestamp_as_decimal(): void
{
$comment = $this->service->addTimestampComment($this->submission, $this->companyUser, [
'timestamp_seconds' => 63.75,
'comment' => 'At exactly 1:03.75',
]);
$this->assertEquals(63.75, (float) $comment->timestamp_seconds);
}
public function test_add_multiple_comments_on_same_submission(): void
{
for ($i = 0; $i < 5; $i++) {
$this->service->addTimestampComment($this->submission, $this->companyUser, [
'timestamp_seconds' => $i * 10.0,
'comment' => "Comment at {$i}0 seconds.",
'type' => 'suggestion',
]);
}
$this->assertDatabaseCount('timestamp_comments', 5);
}
// ─── Resolve Comment ────────────────────────────────────────────────
public function test_resolve_comment_sets_resolved_flag(): void
{
$comment = $this->service->addTimestampComment($this->submission, $this->companyUser, [
'timestamp_seconds' => 10.0,
'comment' => 'Fix this.',
'type' => 'issue',
]);
$this->service->resolveComment($comment);
$comment->refresh();
$this->assertTrue($comment->is_resolved);
$this->assertNotNull($comment->resolved_at);
}
public function test_resolve_comment_sets_resolved_at_timestamp(): void
{
$comment = $this->service->addTimestampComment($this->submission, $this->companyUser, [
'timestamp_seconds' => 20.0,
'comment' => 'Needs work.',
'type' => 'issue',
]);
$before = now();
$this->service->resolveComment($comment);
$comment->refresh();
$this->assertGreaterThanOrEqual($before->timestamp, $comment->resolved_at->timestamp);
}
// ─── Unresolve Comment ──────────────────────────────────────────────
public function test_unresolve_comment_clears_resolved_flag(): void
{
$comment = $this->service->addTimestampComment($this->submission, $this->companyUser, [
'timestamp_seconds' => 10.0,
'comment' => 'Fix this.',
'type' => 'issue',
]);
$this->service->resolveComment($comment);
$this->service->unresolveComment($comment);
$comment->refresh();
$this->assertFalse($comment->is_resolved);
$this->assertNull($comment->resolved_at);
}
// ─── Get Comments for Submission ────────────────────────────────────
public function test_get_comments_ordered_by_timestamp(): void
{
$this->service->addTimestampComment($this->submission, $this->companyUser, [
'timestamp_seconds' => 60.0,
'comment' => 'Later comment.',
]);
$this->service->addTimestampComment($this->submission, $this->companyUser, [
'timestamp_seconds' => 10.0,
'comment' => 'Earlier comment.',
]);
$this->service->addTimestampComment($this->submission, $this->companyUser, [
'timestamp_seconds' => 35.0,
'comment' => 'Middle comment.',
]);
$comments = $this->service->getCommentsForSubmission($this->submission);
$timestamps = $comments->pluck('timestamp_seconds')->map(fn ($t) => (float) $t)->toArray();
$sorted = $timestamps;
sort($sorted);
$this->assertEquals($sorted, $timestamps);
}
public function test_get_comments_eager_loads_user(): void
{
$this->service->addTimestampComment($this->submission, $this->companyUser, [
'timestamp_seconds' => 10.0,
'comment' => 'Test.',
]);
$comments = $this->service->getCommentsForSubmission($this->submission);
$this->assertTrue($comments->first()->relationLoaded('user'));
}
public function test_get_comments_returns_empty_for_no_comments(): void
{
$comments = $this->service->getCommentsForSubmission($this->submission);
$this->assertCount(0, $comments);
}
// ─── Get Unresolved Comments ────────────────────────────────────────
public function test_get_unresolved_comments_excludes_resolved(): void
{
$resolved = $this->service->addTimestampComment($this->submission, $this->companyUser, [
'timestamp_seconds' => 10.0,
'comment' => 'Fixed this.',
'type' => 'issue',
]);
$this->service->resolveComment($resolved);
$this->service->addTimestampComment($this->submission, $this->companyUser, [
'timestamp_seconds' => 30.0,
'comment' => 'Still needs work.',
'type' => 'issue',
]);
$unresolved = $this->service->getUnresolvedComments($this->submission);
$this->assertCount(1, $unresolved);
$this->assertFalse($unresolved->first()->is_resolved);
}
public function test_get_unresolved_comments_ordered_by_timestamp(): void
{
$this->service->addTimestampComment($this->submission, $this->companyUser, [
'timestamp_seconds' => 50.0,
'comment' => 'Later issue.',
'type' => 'issue',
]);
$this->service->addTimestampComment($this->submission, $this->companyUser, [
'timestamp_seconds' => 5.0,
'comment' => 'Earlier issue.',
'type' => 'issue',
]);
$unresolved = $this->service->getUnresolvedComments($this->submission);
$this->assertEquals(5.0, (float) $unresolved->first()->timestamp_seconds);
}
// ─── Get Comments Grouped by Type ───────────────────────────────────
public function test_get_comments_grouped_by_type_returns_all_groups(): void
{
$this->service->addTimestampComment($this->submission, $this->companyUser, [
'timestamp_seconds' => 10.0,
'comment' => 'Bug here.',
'type' => 'issue',
]);
$this->service->addTimestampComment($this->submission, $this->companyUser, [
'timestamp_seconds' => 20.0,
'comment' => 'Maybe try this.',
'type' => 'suggestion',
]);
$this->service->addTimestampComment($this->submission, $this->companyUser, [
'timestamp_seconds' => 30.0,
'comment' => 'This part is great!',
'type' => 'praise',
]);
$grouped = $this->service->getCommentsGroupedByType($this->submission);
$this->assertArrayHasKey('issue', $grouped);
$this->assertArrayHasKey('suggestion', $grouped);
$this->assertArrayHasKey('praise', $grouped);
$this->assertCount(1, $grouped['issue']);
$this->assertCount(1, $grouped['suggestion']);
$this->assertCount(1, $grouped['praise']);
}
public function test_get_comments_grouped_returns_empty_arrays_for_missing_types(): void
{
$this->service->addTimestampComment($this->submission, $this->companyUser, [
'timestamp_seconds' => 10.0,
'comment' => 'Bug.',
'type' => 'issue',
]);
$grouped = $this->service->getCommentsGroupedByType($this->submission);
$this->assertCount(1, $grouped['issue']);
$this->assertCount(0, $grouped['suggestion']);
$this->assertCount(0, $grouped['praise']);
}
// ─── Get Comment Count ──────────────────────────────────────────────
public function test_get_comment_count_returns_all_counts(): void
{
$issue = $this->service->addTimestampComment($this->submission, $this->companyUser, [
'timestamp_seconds' => 10.0,
'comment' => 'Issue.',
'type' => 'issue',
]);
$this->service->addTimestampComment($this->submission, $this->companyUser, [
'timestamp_seconds' => 20.0,
'comment' => 'Resolved issue.',
'type' => 'issue',
]);
$resolved = TimestampComment::orderByDesc('id')->first();
$this->service->resolveComment($resolved);
$this->service->addTimestampComment($this->submission, $this->companyUser, [
'timestamp_seconds' => 30.0,
'comment' => 'Suggestion.',
'type' => 'suggestion',
]);
$this->submission->refresh();
$counts = $this->service->getCommentCountForSubmission($this->submission);
$this->assertEquals(3, $counts['total']);
$this->assertEquals(2, $counts['unresolved']);
$this->assertEquals(1, $counts['resolved']);
$this->assertEquals(1, $counts['issues']);
}
// ─── Delete Comment ─────────────────────────────────────────────────
public function test_delete_comment_removes_from_database(): void
{
$comment = $this->service->addTimestampComment($this->submission, $this->companyUser, [
'timestamp_seconds' => 10.0,
'comment' => 'To be deleted.',
]);
$this->service->deleteComment($comment);
$this->assertDatabaseMissing('timestamp_comments', ['id' => $comment->id]);
}
// ─── Model: Timestamp Formatted Accessor ────────────────────────────
public function test_timestamp_formatted_accessor(): void
{
$comment = $this->service->addTimestampComment($this->submission, $this->companyUser, [
'timestamp_seconds' => 125.0,
'comment' => 'At 2:05.',
]);
$this->assertEquals('2:05', $comment->timestamp_formatted);
}
public function test_timestamp_formatted_at_zero(): void
{
$comment = $this->service->addTimestampComment($this->submission, $this->companyUser, [
'timestamp_seconds' => 0,
'comment' => 'At start.',
]);
$this->assertEquals('0:00', $comment->timestamp_formatted);
}
public function test_timestamp_formatted_large_value(): void
{
$comment = $this->service->addTimestampComment($this->submission, $this->companyUser, [
'timestamp_seconds' => 3661.0,
'comment' => 'Over an hour in.',
]);
$this->assertEquals('61:01', $comment->timestamp_formatted);
}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment