236 lines
8.6 KiB
PHP
236 lines
8.6 KiB
PHP
<?php
|
|
|
|
use App\Models\Account;
|
|
use App\Models\AiConsent;
|
|
use App\Models\Category;
|
|
use App\Models\Transaction;
|
|
use App\Models\User;
|
|
use App\Services\Ai\AiCohortReportCollector;
|
|
use Carbon\CarbonImmutable;
|
|
use Illuminate\Support\Carbon;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Str;
|
|
|
|
use function Pest\Laravel\artisan;
|
|
|
|
function referenceNow(): CarbonImmutable
|
|
{
|
|
return CarbonImmutable::create(2026, 6, 17, 12, 0, 0, 'UTC');
|
|
}
|
|
|
|
/**
|
|
* Create an eligible (or deliberately ineligible) user with transactions, and
|
|
* optionally a consent and a subscription, all anchored to the given signup.
|
|
*
|
|
* @param array<string, mixed> $opts
|
|
*/
|
|
function cohortUser(CarbonImmutable $signup, array $opts = []): User
|
|
{
|
|
$user = User::factory()->create([
|
|
'email' => $opts['email'] ?? fake()->unique()->safeEmail(),
|
|
'created_at' => $signup,
|
|
'last_active_at' => $opts['lastActiveAt'] ?? null,
|
|
]);
|
|
|
|
$account = Account::factory()->for($user)->create();
|
|
$category = Category::factory()->for($user)->create();
|
|
|
|
Transaction::factory()->count($opts['transactions'] ?? 3)->for($user)->for($account)->create([
|
|
'category_id' => $category->id,
|
|
'created_at' => $signup->addDay(),
|
|
]);
|
|
|
|
if (! empty($opts['consentAt'])) {
|
|
AiConsent::factory()->for($user)->create(['accepted_at' => $opts['consentAt']]);
|
|
}
|
|
|
|
if (! empty($opts['subscription'])) {
|
|
$user->subscriptions()->create([
|
|
'type' => 'default',
|
|
'stripe_id' => 'sub_'.Str::random(12),
|
|
'stripe_status' => $opts['subscription']['status'],
|
|
'stripe_price' => 'price_test',
|
|
'created_at' => $opts['subscription']['at'],
|
|
]);
|
|
}
|
|
|
|
return $user;
|
|
}
|
|
|
|
/**
|
|
* @param array{weeks: list<array<string, mixed>>} $report
|
|
* @return array<string, mixed>
|
|
*/
|
|
function rowForWeek(array $report, CarbonImmutable $signup): array
|
|
{
|
|
$label = $signup->startOfWeek(CarbonImmutable::MONDAY)->format('o-\WW');
|
|
|
|
foreach ($report['weeks'] as $row) {
|
|
if ($row['week'] === $label) {
|
|
return $row;
|
|
}
|
|
}
|
|
|
|
throw new RuntimeException("No cohort row found for week {$label}");
|
|
}
|
|
|
|
beforeEach(function () {
|
|
Carbon::setTestNow(referenceNow());
|
|
config(['ai_suggestions.eligibility_min_transactions' => 3]);
|
|
config(['ai_suggestions.report.excluded_emails' => []]);
|
|
});
|
|
|
|
it('only counts users who imported enough transactions within their first week', function () {
|
|
$signup = referenceNow()->subWeeks(6);
|
|
|
|
cohortUser($signup, ['transactions' => 3, 'lastActiveAt' => $signup->addDays(20)]); // eligible, retained
|
|
cohortUser($signup, ['transactions' => 3, 'lastActiveAt' => $signup->addDays(2)]); // eligible, churned early
|
|
cohortUser($signup, ['transactions' => 2, 'lastActiveAt' => $signup->addDays(20)]); // ineligible (<3 txns)
|
|
|
|
// 3 transactions, but imported on day 10 — outside the 7-day eligibility window.
|
|
$late = cohortUser($signup, ['transactions' => 0, 'lastActiveAt' => $signup->addDays(20)]);
|
|
$account = Account::factory()->for($late)->create();
|
|
Transaction::factory()->count(3)->for($late)->for($account)->create(['created_at' => $signup->addDays(10)]);
|
|
|
|
$report = app(AiCohortReportCollector::class)->collect();
|
|
$row = rowForWeek($report, $signup);
|
|
|
|
expect($row['eligible'])->toBe(2)
|
|
->and($row['retained'])->toBe(1)
|
|
->and($row['retainedRate'])->toBe(0.5)
|
|
->and($row['retentionMature'])->toBeTrue();
|
|
});
|
|
|
|
it('counts trial starts within 14 days and active paid conversions within 30 days', function () {
|
|
$signup = referenceNow()->subWeeks(6);
|
|
|
|
// Trialing subscription started at signup: counts as trial, not as paid.
|
|
cohortUser($signup, [
|
|
'transactions' => 3,
|
|
'subscription' => ['status' => 'trialing', 'at' => $signup->addDays(2)],
|
|
]);
|
|
|
|
// Active subscription within 30 days: counts as both trial and paid.
|
|
cohortUser($signup, [
|
|
'transactions' => 3,
|
|
'subscription' => ['status' => 'active', 'at' => $signup->addDays(5)],
|
|
]);
|
|
|
|
// Active subscription, but only after day 40: outside both windows.
|
|
cohortUser($signup, [
|
|
'transactions' => 3,
|
|
'subscription' => ['status' => 'active', 'at' => $signup->addDays(40)],
|
|
]);
|
|
|
|
$report = app(AiCohortReportCollector::class)->collect();
|
|
$row = rowForWeek($report, $signup);
|
|
|
|
expect($row['eligible'])->toBe(3)
|
|
->and($row['trial'])->toBe(2)
|
|
->and($row['paid'])->toBe(1)
|
|
->and($row['paidMature'])->toBeTrue();
|
|
});
|
|
|
|
it('derives the release anchor from the first consent and splits cohorts pre/post', function () {
|
|
config(['ai_suggestions.report.excluded_emails' => ['staff@whisper.test']]);
|
|
|
|
$release = referenceNow()->subWeeks(4);
|
|
|
|
// Staff account plants the release-anchor consent; excluded from cohort metrics.
|
|
cohortUser($release, [
|
|
'email' => 'staff@whisper.test',
|
|
'transactions' => 3,
|
|
'consentAt' => $release,
|
|
]);
|
|
|
|
$before = referenceNow()->subWeeks(6);
|
|
$after = referenceNow()->subWeeks(2);
|
|
cohortUser($before, ['transactions' => 3]);
|
|
cohortUser($after, ['transactions' => 3]);
|
|
|
|
$report = app(AiCohortReportCollector::class)->collect();
|
|
|
|
expect($report['releaseWeek'])->toBe($release->startOfWeek(CarbonImmutable::MONDAY)->format('o-\WW'))
|
|
->and(rowForWeek($report, $before)['phase'])->toBe('pre')
|
|
->and(rowForWeek($report, $after)['phase'])->toBe('post')
|
|
// Staff account was excluded, so the release week has no eligible users.
|
|
->and(rowForWeek($report, $release)['eligible'])->toBe(0);
|
|
});
|
|
|
|
it('marks cohorts as not mature until the metric horizon has elapsed', function () {
|
|
$recent = referenceNow()->subDays(3); // current week — too young for any 14d metric
|
|
$midAged = referenceNow()->subWeeks(3); // older than 14d, younger than 30d
|
|
|
|
cohortUser($recent, ['transactions' => 3, 'lastActiveAt' => $recent->addDay()]);
|
|
cohortUser($midAged, ['transactions' => 3, 'lastActiveAt' => $midAged->addDays(20)]);
|
|
|
|
$report = app(AiCohortReportCollector::class)->collect();
|
|
|
|
$recentRow = rowForWeek($report, $recent);
|
|
expect($recentRow['retentionMature'])->toBeFalse()
|
|
->and($recentRow['retainedRate'])->toBeNull()
|
|
->and($recentRow['paidRate'])->toBeNull();
|
|
|
|
$midRow = rowForWeek($report, $midAged);
|
|
expect($midRow['retentionMature'])->toBeTrue()
|
|
->and($midRow['retainedRate'])->not->toBeNull()
|
|
->and($midRow['paidMature'])->toBeFalse()
|
|
->and($midRow['paidRate'])->toBeNull();
|
|
});
|
|
|
|
it('flags weeks whose eligible volume is an outlier surge', function () {
|
|
$surgeWeek = referenceNow()->subWeeks(5);
|
|
$quietWeeks = [referenceNow()->subWeeks(8), referenceNow()->subWeeks(7), referenceNow()->subWeeks(6)];
|
|
|
|
foreach ($quietWeeks as $week) {
|
|
cohortUser($week, ['transactions' => 3]);
|
|
}
|
|
|
|
for ($i = 0; $i < 6; $i++) {
|
|
cohortUser($surgeWeek, ['transactions' => 3]);
|
|
}
|
|
|
|
$report = app(AiCohortReportCollector::class)->collect();
|
|
|
|
expect(rowForWeek($report, $surgeWeek)['surge'])->toBeTrue()
|
|
->and(rowForWeek($report, $quietWeeks[0])['surge'])->toBeFalse();
|
|
});
|
|
|
|
it('posts the cohort report embed to the configured discord webhook', function () {
|
|
config(['services.discord.ai_cohort_webhook_url' => 'https://discord.test/hook']);
|
|
Http::fake(['discord.test/*' => Http::response('', 204)]);
|
|
|
|
cohortUser(referenceNow()->subWeeks(6), ['transactions' => 3, 'lastActiveAt' => referenceNow()->subWeeks(3)]);
|
|
|
|
artisan('stats:ai-cohort-report')->assertSuccessful();
|
|
|
|
Http::assertSent(function ($request) {
|
|
return $request->url() === 'https://discord.test/hook'
|
|
&& isset($request['embeds'][0]['title'])
|
|
&& str_contains($request['embeds'][0]['title'], 'AI Suggestions');
|
|
});
|
|
});
|
|
|
|
it('prints to the console without posting when --no-discord is set', function () {
|
|
config(['services.discord.ai_cohort_webhook_url' => 'https://discord.test/hook']);
|
|
Http::fake(['discord.test/*' => Http::response('', 204)]);
|
|
|
|
cohortUser(referenceNow()->subWeeks(6), ['transactions' => 3, 'lastActiveAt' => referenceNow()->subWeeks(3)]);
|
|
|
|
artisan('stats:ai-cohort-report', ['--no-discord' => true])->assertSuccessful();
|
|
|
|
Http::assertNothingSent();
|
|
});
|
|
|
|
it('falls back to the default discord webhook when no dedicated one is set', function () {
|
|
config([
|
|
'services.discord.ai_cohort_webhook_url' => null,
|
|
'services.discord.webhook_url' => 'https://discord.test/default',
|
|
]);
|
|
Http::fake(['discord.test/*' => Http::response('', 204)]);
|
|
|
|
artisan('stats:ai-cohort-report')->assertSuccessful();
|
|
|
|
Http::assertSent(fn ($request) => $request->url() === 'https://discord.test/default');
|
|
});
|