create(); $user = User::factory()->create(['tenant_id' => $tenant->id, 'email' => 'client@example.test']); return [(int) $tenant->id, (int) $user->id]; } function feedOps(int $tenantId, ?int $userId, string $event, ?int $entityId, ?array $after, ?array $before, string $mskTime): void { DB::table('tenant_operations_log')->insert([ 'tenant_id' => $tenantId, 'user_id' => $userId, 'entity_type' => 'project', 'entity_id' => $entityId, 'event' => $event, 'payload_before' => $before !== null ? json_encode($before, JSON_UNESCAPED_UNICODE) : null, 'payload_after' => $after !== null ? json_encode($after, JSON_UNESCAPED_UNICODE) : null, 'created_at' => CarbonImmutable::parse($mskTime, 'Europe/Moscow')->utc(), ]); } function feedActivity(int $tenantId, ?int $userId, string $event, int $dealId, string $mskTime): void { DB::table('activity_log')->insert([ 'tenant_id' => $tenantId, 'user_id' => $userId, 'deal_id' => $dealId, 'event' => $event, 'created_at' => CarbonImmutable::parse($mskTime, 'Europe/Moscow')->utc(), ]); } it('берёт имя проекта из журнала, а не номер', function () { [$tenantId, $userId] = feedTenant(); feedOps($tenantId, $userId, 'project.created', 999_001, ['name' => 'Двери'], null, '2026-08-05 10:10:00'); $items = app(TenantActivityFeedService::class)->forTenant($tenantId, 50, 0); expect($items)->toHaveCount(1); expect($items[0]['kind'])->toBe('project'); expect($items[0]['event'])->toBe('project.created'); expect($items[0]['subject'])->toBe('Двери'); expect($items[0]['actor'])->toBe('client@example.test'); }); it('берёт имя удалённого проекта из снимка «до»', function () { [$tenantId, $userId] = feedTenant(); feedOps($tenantId, $userId, 'project.deleted', 999_002, null, ['name' => 'Окна ЕКБ'], '2026-08-05 11:00:00'); $items = app(TenantActivityFeedService::class)->forTenant($tenantId, 50, 0); expect($items[0]['subject'])->toBe('Окна ЕКБ'); }); it('считает, сколько проектов задето групповым действием', function () { [$tenantId, $userId] = feedTenant(); feedOps($tenantId, $userId, 'project.bulk_pause', null, ['ids' => [1, 2, 3]], null, '2026-08-05 12:00:00'); $items = app(TenantActivityFeedService::class)->forTenant($tenantId, 50, 0); expect($items[0]['bulk_count'])->toBe(3); }); it('пускает ручную работу со сделками и НЕ пускает системную', function () { [$tenantId, $userId] = feedTenant(); feedActivity($tenantId, $userId, 'deal.status_changed', 555, '2026-08-05 13:00:00'); feedActivity($tenantId, null, 'deal.created', 556, '2026-08-05 13:05:00'); $items = app(TenantActivityFeedService::class)->forTenant($tenantId, 50, 0); expect($items)->toHaveCount(1); expect($items[0]['event'])->toBe('deal.status_changed'); expect($items[0]['subject'])->toBe('555'); }); it('пускает пополнение и НЕ пускает списания за лид и правку баланса админом', function () { [$tenantId, $userId] = feedTenant(); DB::table('balance_transactions')->insert([ ['tenant_id' => $tenantId, 'user_id' => $userId, 'type' => 'topup', 'amount_rub' => '1000.00', 'created_at' => CarbonImmutable::parse('2026-08-05 14:00:00', 'Europe/Moscow')->utc()], ['tenant_id' => $tenantId, 'user_id' => null, 'type' => 'lead_charge', 'amount_rub' => '-20.00', 'created_at' => CarbonImmutable::parse('2026-08-05 14:05:00', 'Europe/Moscow')->utc()], ['tenant_id' => $tenantId, 'user_id' => null, 'type' => 'manual_adjustment', 'amount_rub' => '500.00', 'created_at' => CarbonImmutable::parse('2026-08-05 14:10:00', 'Europe/Moscow')->utc()], ]); $items = app(TenantActivityFeedService::class)->forTenant($tenantId, 50, 0); expect($items)->toHaveCount(1); expect($items[0]['kind'])->toBe('money'); expect($items[0]['event'])->toBe('balance.topup'); expect($items[0]['amount_rub'])->toBe('1000.00'); }); it('отдаёт свежее первым и умеет постранично', function () { [$tenantId, $userId] = feedTenant(); feedOps($tenantId, $userId, 'project.created', 999_010, ['name' => 'Первый'], null, '2026-08-01 10:00:00'); feedOps($tenantId, $userId, 'project.created', 999_011, ['name' => 'Второй'], null, '2026-08-02 10:00:00'); feedOps($tenantId, $userId, 'project.created', 999_012, ['name' => 'Третий'], null, '2026-08-03 10:00:00'); $service = app(TenantActivityFeedService::class); $first = $service->forTenant($tenantId, 2, 0); expect(array_column($first, 'subject'))->toBe(['Третий', 'Второй']); $second = $service->forTenant($tenantId, 2, 2); expect(array_column($second, 'subject'))->toBe(['Первый']); }); it('считает дела за период тем же отбором, что и лента', function () { [$tenantId, $userId] = feedTenant(); feedOps($tenantId, $userId, 'project.created', 999_020, ['name' => 'Свежий'], null, '2026-08-05 10:00:00'); feedOps($tenantId, $userId, 'project.created', 999_021, ['name' => 'Старый'], null, '2026-05-01 10:00:00'); feedActivity($tenantId, null, 'deal.created', 777, '2026-08-05 10:30:00'); $since = CarbonImmutable::parse('2026-07-09 00:00:00', 'Europe/Moscow')->utc(); expect(app(TenantActivityFeedService::class)->countSince($tenantId, $since))->toBe(1); }); it('карточка клиента отдаёт блок «жив ли» и ленту без системного шума', function () { $tenant = Tenant::factory()->create(['subdomain' => 'testalive']); $user = User::factory()->create(['tenant_id' => $tenant->id, 'email' => 'client@example.test']); feedOps((int) $tenant->id, (int) $user->id, 'project.created', 999_100, ['name' => 'Двери'], null, '2026-08-05 10:10:00'); feedActivity((int) $tenant->id, null, 'deal.created', 888, '2026-08-05 11:00:00'); $resp = $this->getJson('/api/admin/tenants/testalive'); $resp->assertOk() ->assertJsonPath('aliveness.state', 'no_data') ->assertJsonCount(1, 'activity.items') ->assertJsonPath('activity.items.0.event', 'project.created') ->assertJsonPath('activity.items.0.subject', 'Двери') ->assertJsonPath('activity.has_more', false); }); it('отдаёт следующую страницу ленты отдельной ручкой', function () { $tenant = Tenant::factory()->create(['subdomain' => 'testpage']); $user = User::factory()->create(['tenant_id' => $tenant->id]); feedOps((int) $tenant->id, (int) $user->id, 'project.created', 999_200, ['name' => 'Первый'], null, '2026-08-01 10:00:00'); feedOps((int) $tenant->id, (int) $user->id, 'project.created', 999_201, ['name' => 'Второй'], null, '2026-08-02 10:00:00'); $resp = $this->getJson('/api/admin/tenants/testpage/activity?offset=1&limit=1'); $resp->assertOk() ->assertJsonCount(1, 'items') ->assertJsonPath('items.0.subject', 'Первый') ->assertJsonPath('has_more', false); });