guard = new SupplierSnapshotGuard; } public function test_grace_until_for_pause_before_21_msk_is_next_day_21_msk(): void { $pausedAt = CarbonImmutable::parse('2026-05-25 14:00:00', 'Europe/Moscow'); $graceUntil = $this->guard->computeGraceUntil($pausedAt); $this->assertSame( '2026-05-26 21:00:00', $graceUntil->setTimezone('Europe/Moscow')->format('Y-m-d H:i:s'), ); } public function test_grace_until_for_pause_after_21_msk_is_day_plus_two_21_msk(): void { $pausedAt = CarbonImmutable::parse('2026-05-25 22:00:00', 'Europe/Moscow'); $graceUntil = $this->guard->computeGraceUntil($pausedAt); $this->assertSame( '2026-05-27 21:00:00', $graceUntil->setTimezone('Europe/Moscow')->format('Y-m-d H:i:s'), ); } public function test_grace_until_for_pause_exactly_at_21_msk_is_day_plus_two_21_msk(): void { $pausedAt = CarbonImmutable::parse('2026-05-25 21:00:00', 'Europe/Moscow'); $graceUntil = $this->guard->computeGraceUntil($pausedAt); $this->assertSame( '2026-05-27 21:00:00', $graceUntil->setTimezone('Europe/Moscow')->format('Y-m-d H:i:s'), ); } public function test_grace_until_handles_utc_input(): void { // 14:00 UTC = 17:00 MSK (до 21:00) → grace = следующее 21:00 МСК +24ч $pausedAt = CarbonImmutable::parse('2026-05-25 14:00:00', 'UTC'); $graceUntil = $this->guard->computeGraceUntil($pausedAt); $this->assertSame( '2026-05-26 21:00:00', $graceUntil->setTimezone('Europe/Moscow')->format('Y-m-d H:i:s'), ); } // ------------------ isProtected --------------------------------------- private function mockLinksExists(int $projectId, bool $exists): void { $builder = \Mockery::mock(); $builder->shouldReceive('where')->with('project_id', $projectId)->andReturnSelf(); $builder->shouldReceive('exists')->andReturn($exists); DB::shouldReceive('table')->with('project_supplier_links')->andReturn($builder); } public function test_is_protected_false_when_no_supplier_links(): void { $project = new Project(['is_active' => true]); $project->id = 1; $this->mockLinksExists(1, false); $this->assertFalse($this->guard->isProtected($project)); } public function test_is_protected_true_when_active_and_linked(): void { $project = new Project(['is_active' => true]); $project->id = 2; $this->mockLinksExists(2, true); $this->assertTrue($this->guard->isProtected($project)); } public function test_is_protected_false_when_paused_without_paused_at_legacy(): void { $project = new Project(['is_active' => false]); $project->id = 3; $project->paused_at = null; $this->mockLinksExists(3, true); $this->assertFalse($this->guard->isProtected($project)); } public function test_is_protected_true_when_paused_recently_within_grace(): void { $project = new Project(['is_active' => false]); $project->id = 4; // paused at 22:00 МСК → grace until +day-after-tomorrow 21:00 МСК $project->paused_at = CarbonImmutable::parse('2026-05-25 22:00:00', 'Europe/Moscow'); $this->mockLinksExists(4, true); // current "now" is one hour after pause — well inside grace window $now = CarbonImmutable::parse('2026-05-25 23:00:00', 'Europe/Moscow'); $this->assertTrue($this->guard->isProtected($project, $now)); } public function test_is_protected_false_when_grace_has_elapsed(): void { $project = new Project(['is_active' => false]); $project->id = 5; $project->paused_at = CarbonImmutable::parse('2026-05-25 14:00:00', 'Europe/Moscow'); $this->mockLinksExists(5, true); // grace_until = 2026-05-26 21:00; "now" — позже $now = CarbonImmutable::parse('2026-05-26 22:00:00', 'Europe/Moscow'); $this->assertFalse($this->guard->isProtected($project, $now)); } // ------------------ assertCanMutateSource ----------------------------- public function test_assert_no_throw_when_unprotected(): void { $project = new Project(['is_active' => true]); $project->id = 6; $this->mockLinksExists(6, false); // not throwing means success $this->guard->assertCanMutateSource($project, 'delete'); $this->assertTrue(true); } public function test_assert_throws_422_with_delete_phrasing(): void { $project = new Project(['is_active' => true]); $project->id = 7; $this->mockLinksExists(7, true); try { $this->guard->assertCanMutateSource($project, 'delete'); $this->fail('Expected HttpResponseException'); } catch (HttpResponseException $e) { $this->assertSame(422, $e->getResponse()->getStatusCode()); $body = json_decode((string) $e->getResponse()->getContent(), true); $msg = $body['errors']['project'][0]; $this->assertStringContainsString('Мы уже начали сбор лидов', $msg); $this->assertStringContainsString('Удалить можно будет послезавтра', $msg); } } public function test_assert_throws_422_with_change_source_phrasing(): void { $project = new Project(['is_active' => true]); $project->id = 8; $this->mockLinksExists(8, true); try { $this->guard->assertCanMutateSource($project, 'change_source'); $this->fail('Expected HttpResponseException'); } catch (HttpResponseException $e) { $msg = json_decode((string) $e->getResponse()->getContent(), true)['errors']['project'][0]; $this->assertStringContainsString('Изменить источник можно будет послезавтра', $msg); } } }