createMock(HandoverRepository::class); $productService = $productService ?? $this->createMock(SoftwareProductService::class); return new HandoverService($repository, $productService, $revisionService, $notificationService); } private function handoverRow(array $overrides = []): array { return array_merge([ 'id' => 1, 'tenant_id' => self::TENANT_ID, 'status' => HandoverService::STATUS_IN_PROGRESS, 'assigned_to' => 0, 'assigned_by' => 0, 'debitor_name' => 'Acme Corp', 'domain_url' => 'example.com', 'due_date' => null, 'assigned_to_label' => null, 'assigned_to_email' => null, 'assigned_by_label' => null, 'assigned_by_email' => null, ], $overrides); } private function mockProductService(bool $exists = true, bool $hasSchema = true): SoftwareProductService { $mock = $this->createMock(SoftwareProductService::class); if (!$exists) { $mock->method('findByCode')->willReturn(null); } else { $mock->method('findByCode')->willReturn([ 'id' => 1, 'code' => 'PROD-A', 'name' => 'Product A', 'active' => 1, 'handover_protocol_schema' => $hasSchema ? self::VALID_SCHEMA_JSON : null, ]); } return $mock; } // ── Create tests ───────────────────────────────────────────────── public function testCreateHappyPath(): void { $repo = $this->createMock(HandoverRepository::class); $repo->expects($this->once())->method('insert')->willReturn(99); $service = $this->createService($repo, $this->mockProductService()); $result = $service->create(self::TENANT_ID, 'D10001', 'Acme Corp', 'PROD-A', 'DNS00001', 'example.com', self::USER_ID); $this->assertTrue($result['ok']); $this->assertSame(99, $result['id']); $this->assertEmpty($result['errors']); } public function testCreateWithInvalidProductCode(): void { $service = $this->createService(productService: $this->mockProductService(exists: false)); $result = $service->create(self::TENANT_ID, 'D10001', 'Acme Corp', 'INVALID', 'DNS00001', 'example.com', self::USER_ID); $this->assertFalse($result['ok']); $this->assertNull($result['id']); $this->assertArrayHasKey('product_code', $result['errors']); } public function testCreateWithProductWithoutSchema(): void { $service = $this->createService(productService: $this->mockProductService(hasSchema: false)); $result = $service->create(self::TENANT_ID, 'D10001', 'Acme Corp', 'PROD-A', 'DNS00001', 'example.com', self::USER_ID); $this->assertFalse($result['ok']); $this->assertNull($result['id']); $this->assertArrayHasKey('product_code', $result['errors']); } public function testCreateWithEmptyDebitorNo(): void { $service = $this->createService(); $result = $service->create(self::TENANT_ID, '', 'Acme Corp', 'PROD-A', 'DNS00001', 'example.com', self::USER_ID); $this->assertFalse($result['ok']); $this->assertArrayHasKey('debitor_no', $result['errors']); } // ── Update fields tests ────────────────────────────────────────── public function testUpdateFieldsHappyPath(): void { $repo = $this->createMock(HandoverRepository::class); $repo->method('findById')->willReturn([ 'id' => 1, 'status' => 'draft', 'schema_snapshot' => self::VALID_SCHEMA_JSON, ]); $repo->expects($this->once())->method('updateFieldValues')->willReturn(true); $service = $this->createService($repo); $result = $service->updateFields(self::TENANT_ID, 1, ['name' => 'Test', 'priority' => 'high'], self::USER_ID, HandoverService::PERMISSION_CREATE); $this->assertTrue($result['ok']); } public function testUpdateFieldsRejectsUnknownKeys(): void { $repo = $this->createMock(HandoverRepository::class); $repo->method('findById')->willReturn([ 'id' => 1, 'status' => 'draft', 'schema_snapshot' => self::VALID_SCHEMA_JSON, ]); $service = $this->createService($repo); $result = $service->updateFields(self::TENANT_ID, 1, ['unknown_field' => 'value'], self::USER_ID, HandoverService::PERMISSION_CREATE); $this->assertFalse($result['ok']); $this->assertArrayHasKey('unknown_field', $result['errors']); } // ── Status transition tests ────────────────────────────────────── public function testChangeStatusByCreateUser(): void { $repo = $this->createMock(HandoverRepository::class); $repo->method('findById')->willReturn(['id' => 1, 'status' => 'draft']); $repo->method('updateStatus')->willReturn(true); $service = $this->createService($repo); // Can set in_progress $result = $service->changeStatus(self::TENANT_ID, 1, 'in_progress', self::USER_ID, HandoverService::PERMISSION_CREATE); $this->assertTrue($result['ok']); } public function testChangeStatusCreateUserCannotArchive(): void { $repo = $this->createMock(HandoverRepository::class); $repo->method('findById')->willReturn(['id' => 1, 'status' => 'completed']); $service = $this->createService($repo); $result = $service->changeStatus(self::TENANT_ID, 1, 'archived', self::USER_ID, HandoverService::PERMISSION_CREATE); $this->assertFalse($result['ok']); $this->assertArrayHasKey('status', $result['errors']); } public function testChangeStatusManageUserCanArchive(): void { $repo = $this->createMock(HandoverRepository::class); $repo->method('findById')->willReturn(['id' => 1, 'status' => 'completed']); $repo->method('updateStatus')->willReturn(true); $service = $this->createService($repo); $result = $service->changeStatus(self::TENANT_ID, 1, 'archived', self::USER_ID, HandoverService::PERMISSION_MANAGE); $this->assertTrue($result['ok']); } // ── Edit permission tests ──────────────────────────────────────── public function testEditDeniedForCreateUserOnCompletedHandover(): void { $repo = $this->createMock(HandoverRepository::class); $repo->method('findById')->willReturn([ 'id' => 1, 'status' => 'completed', 'schema_snapshot' => self::VALID_SCHEMA_JSON, ]); $service = $this->createService($repo); $result = $service->updateFields(self::TENANT_ID, 1, ['name' => 'Test'], self::USER_ID, HandoverService::PERMISSION_CREATE); $this->assertFalse($result['ok']); $this->assertArrayHasKey('general', $result['errors']); } public function testEditAllowedForManageUserOnCompletedHandover(): void { $repo = $this->createMock(HandoverRepository::class); $repo->method('findById')->willReturn([ 'id' => 1, 'status' => 'completed', 'schema_snapshot' => self::VALID_SCHEMA_JSON, ]); $repo->expects($this->once())->method('updateFieldValues')->willReturn(true); $service = $this->createService($repo); $result = $service->updateFields(self::TENANT_ID, 1, ['name' => 'Test'], self::USER_ID, HandoverService::PERMISSION_MANAGE); $this->assertTrue($result['ok']); } // ── Revision integration tests ────────────────────────────────── public function testCreateCreatesInitialRevision(): void { $repo = $this->createMock(HandoverRepository::class); $repo->expects($this->once())->method('insert')->willReturn(99); $revisionService = $this->createMock(HandoverRevisionService::class); $revisionService->expects($this->once()) ->method('createRevision') ->with( self::TENANT_ID, 99, [], HandoverService::STATUS_DRAFT, 1, self::USER_ID, HandoverRevisionService::CHANGE_TYPE_INITIAL ); $service = $this->createService($repo, $this->mockProductService(), $revisionService); $result = $service->create(self::TENANT_ID, 'D10001', 'Acme Corp', 'PROD-A', 'DNS00001', 'example.com', self::USER_ID); $this->assertTrue($result['ok']); } public function testCreateWithEmptyDomainNo(): void { $service = $this->createService(productService: $this->mockProductService()); $result = $service->create(self::TENANT_ID, 'D10001', 'Acme Corp', 'PROD-A', '', 'example.com', self::USER_ID); $this->assertFalse($result['ok']); $this->assertArrayHasKey('domain_no', $result['errors']); } public function testCreatePassesDomainToRepository(): void { $repo = $this->createMock(HandoverRepository::class); $repo->expects($this->once()) ->method('insert') ->with($this->callback(function (array $data): bool { return ($data['domain_no'] ?? '') === 'DNS00001' && ($data['domain_url'] ?? '') === 'example.com'; })) ->willReturn(99); $service = $this->createService($repo, $this->mockProductService()); $result = $service->create(self::TENANT_ID, 'D10001', 'Acme Corp', 'PROD-A', 'DNS00001', 'example.com', self::USER_ID); $this->assertTrue($result['ok']); } public function testCreateRevisionAfterSaveSetsBothWhenFieldsAndStatusChanged(): void { $repo = $this->createMock(HandoverRepository::class); $revisionService = $this->createMock(HandoverRevisionService::class); $revisionService->expects($this->once()) ->method('createRevision') ->with( self::TENANT_ID, 1, ['name' => 'Test'], 'in_progress', 1, self::USER_ID, HandoverRevisionService::CHANGE_TYPE_BOTH ); $service = $this->createService($repo, null, $revisionService); $service->createRevisionAfterSave(self::TENANT_ID, 1, ['name' => 'Test'], 'in_progress', true, true, 1, self::USER_ID); } public function testCreateRevisionAfterSaveSetsFieldsWhenOnlyFieldsChanged(): void { $repo = $this->createMock(HandoverRepository::class); $revisionService = $this->createMock(HandoverRevisionService::class); $revisionService->expects($this->once()) ->method('createRevision') ->with( $this->anything(), $this->anything(), $this->anything(), $this->anything(), $this->anything(), $this->anything(), HandoverRevisionService::CHANGE_TYPE_FIELDS ); $service = $this->createService($repo, null, $revisionService); $service->createRevisionAfterSave(self::TENANT_ID, 1, ['name' => 'Test'], 'draft', true, false, 1, self::USER_ID); } public function testCreateRevisionAfterSaveSetsStatusWhenOnlyStatusChanged(): void { $repo = $this->createMock(HandoverRepository::class); $revisionService = $this->createMock(HandoverRevisionService::class); $revisionService->expects($this->once()) ->method('createRevision') ->with( self::TENANT_ID, 1, ['name' => 'Test'], 'completed', 1, self::USER_ID, HandoverRevisionService::CHANGE_TYPE_STATUS ); $service = $this->createService($repo, null, $revisionService); $service->createRevisionAfterSave(self::TENANT_ID, 1, ['name' => 'Test'], 'completed', false, true, 1, self::USER_ID); } public function testCreateRevisionAfterSaveDefaultsToFieldsWhenNoContentChanged(): void { $repo = $this->createMock(HandoverRepository::class); $revisionService = $this->createMock(HandoverRevisionService::class); $revisionService->expects($this->once()) ->method('createRevision') ->with( self::TENANT_ID, 1, ['name' => 'Test'], 'draft', 1, self::USER_ID, HandoverRevisionService::CHANGE_TYPE_FIELDS ); $service = $this->createService($repo, null, $revisionService); $service->createRevisionAfterSave(self::TENANT_ID, 1, ['name' => 'Test'], 'draft', false, false, 1, self::USER_ID); } // ── Assign tests ───────────────────────────────────────────────── public function testAssignHappyPath(): void { $repo = $this->createMock(HandoverRepository::class); $repo->method('findById')->willReturn($this->handoverRow(['status' => HandoverService::STATUS_IN_PROGRESS])); $repo->expects($this->once())->method('assign')->willReturn(true); $repo->expects($this->never())->method('updateStatus'); $service = $this->createService($repo); $result = $service->assign(self::TENANT_ID, 1, 99, self::USER_ID, null, HandoverService::PERMISSION_MANAGE); $this->assertTrue($result['ok']); $this->assertEmpty($result['errors']); } public function testAssignDraftHandoverTransitionsToInProgress(): void { $repo = $this->createMock(HandoverRepository::class); $repo->method('findById')->willReturn($this->handoverRow(['status' => HandoverService::STATUS_DRAFT])); $repo->method('assign')->willReturn(true); $repo->expects($this->once()) ->method('updateStatus') ->with(self::TENANT_ID, 1, HandoverService::STATUS_IN_PROGRESS, self::USER_ID); $service = $this->createService($repo); $result = $service->assign(self::TENANT_ID, 1, 99, self::USER_ID, null, HandoverService::PERMISSION_MANAGE); $this->assertTrue($result['ok']); } public function testAssignDeniedForCreatePermission(): void { $repo = $this->createMock(HandoverRepository::class); $repo->expects($this->never())->method('assign'); $service = $this->createService($repo); $result = $service->assign(self::TENANT_ID, 1, 99, self::USER_ID, null, HandoverService::PERMISSION_CREATE); $this->assertFalse($result['ok']); $this->assertArrayHasKey('general', $result['errors']); } public function testAssignFailsWhenAssignedToIsZero(): void { $repo = $this->createMock(HandoverRepository::class); $repo->method('findById')->willReturn($this->handoverRow()); $repo->expects($this->never())->method('assign'); $service = $this->createService($repo); $result = $service->assign(self::TENANT_ID, 1, 0, self::USER_ID, null, HandoverService::PERMISSION_MANAGE); $this->assertFalse($result['ok']); $this->assertArrayHasKey('assigned_to', $result['errors']); } public function testAssignFailsWhenHandoverNotFound(): void { $repo = $this->createMock(HandoverRepository::class); $repo->method('findById')->willReturn(null); $service = $this->createService($repo); $result = $service->assign(self::TENANT_ID, 1, 99, self::USER_ID, null, HandoverService::PERMISSION_MANAGE); $this->assertFalse($result['ok']); $this->assertArrayHasKey('general', $result['errors']); } public function testAssignSendsNotificationEmail(): void { $repo = $this->createMock(HandoverRepository::class); $repo->method('findById')->willReturn($this->handoverRow([ 'assigned_to_email' => 'employee@example.com', 'assigned_to_label' => 'John Doe', ])); $repo->method('assign')->willReturn(true); $notificationService = $this->createMock(HandoverNotificationService::class); $notificationService->expects($this->once())->method('notifyAssigned'); $service = $this->createService($repo, notificationService: $notificationService); $service->assign(self::TENANT_ID, 1, 99, self::USER_ID, null, HandoverService::PERMISSION_MANAGE); } public function testAssignWithDueDatePassesItToRepository(): void { $repo = $this->createMock(HandoverRepository::class); $repo->method('findById')->willReturn($this->handoverRow()); $repo->expects($this->once()) ->method('assign') ->with(self::TENANT_ID, 1, 99, self::USER_ID, '2026-12-31', self::USER_ID) ->willReturn(true); $service = $this->createService($repo); $result = $service->assign(self::TENANT_ID, 1, 99, self::USER_ID, '2026-12-31', HandoverService::PERMISSION_MANAGE); $this->assertTrue($result['ok']); } // ── Submit for review tests ─────────────────────────────────────── public function testSubmitForReviewHappyPathByAssignee(): void { $assigneeId = 99; $repo = $this->createMock(HandoverRepository::class); $repo->method('findById')->willReturn($this->handoverRow([ 'assigned_to' => $assigneeId, 'status' => HandoverService::STATUS_IN_PROGRESS, ])); $repo->expects($this->once()) ->method('updateStatus') ->with(self::TENANT_ID, 1, HandoverService::STATUS_UNDER_REVIEW, $assigneeId) ->willReturn(true); $service = $this->createService($repo); $result = $service->submitForReview(self::TENANT_ID, 1, $assigneeId, HandoverService::PERMISSION_CREATE); $this->assertTrue($result['ok']); } public function testSubmitForReviewAllowedForManager(): void { $repo = $this->createMock(HandoverRepository::class); $repo->method('findById')->willReturn($this->handoverRow([ 'assigned_to' => 99, 'status' => HandoverService::STATUS_DRAFT, ])); $repo->method('updateStatus')->willReturn(true); $service = $this->createService($repo); $result = $service->submitForReview(self::TENANT_ID, 1, self::USER_ID, HandoverService::PERMISSION_MANAGE); $this->assertTrue($result['ok']); } public function testSubmitForReviewDeniedForNonAssignee(): void { $repo = $this->createMock(HandoverRepository::class); $repo->method('findById')->willReturn($this->handoverRow([ 'assigned_to' => 99, 'status' => HandoverService::STATUS_IN_PROGRESS, ])); $service = $this->createService($repo); $result = $service->submitForReview(self::TENANT_ID, 1, self::USER_ID, HandoverService::PERMISSION_CREATE); $this->assertFalse($result['ok']); $this->assertArrayHasKey('general', $result['errors']); } public function testSubmitForReviewDeniedWhenStatusIsUnderReview(): void { $assigneeId = 99; $repo = $this->createMock(HandoverRepository::class); $repo->method('findById')->willReturn($this->handoverRow([ 'assigned_to' => $assigneeId, 'status' => HandoverService::STATUS_UNDER_REVIEW, ])); $service = $this->createService($repo); $result = $service->submitForReview(self::TENANT_ID, 1, $assigneeId, HandoverService::PERMISSION_CREATE); $this->assertFalse($result['ok']); $this->assertArrayHasKey('status', $result['errors']); } public function testSubmitForReviewDeniedWhenStatusIsCompleted(): void { $assigneeId = 99; $repo = $this->createMock(HandoverRepository::class); $repo->method('findById')->willReturn($this->handoverRow([ 'assigned_to' => $assigneeId, 'status' => HandoverService::STATUS_COMPLETED, ])); $service = $this->createService($repo); $result = $service->submitForReview(self::TENANT_ID, 1, $assigneeId, HandoverService::PERMISSION_CREATE); $this->assertFalse($result['ok']); $this->assertArrayHasKey('status', $result['errors']); } public function testSubmitForReviewSendsReviewRequestedEmail(): void { $assigneeId = 99; $repo = $this->createMock(HandoverRepository::class); $repo->method('findById')->willReturn($this->handoverRow([ 'assigned_to' => $assigneeId, 'status' => HandoverService::STATUS_IN_PROGRESS, 'assigned_by_email' => 'manager@example.com', ])); $repo->method('updateStatus')->willReturn(true); $notificationService = $this->createMock(HandoverNotificationService::class); $notificationService->expects($this->once())->method('notifyReviewRequested'); $service = $this->createService($repo, notificationService: $notificationService); $service->submitForReview(self::TENANT_ID, 1, $assigneeId, HandoverService::PERMISSION_CREATE); } public function testSubmitForReviewCreatesRevision(): void { $assigneeId = 99; $repo = $this->createMock(HandoverRepository::class); $repo->method('findById')->willReturn($this->handoverRow([ 'assigned_to' => $assigneeId, 'status' => HandoverService::STATUS_IN_PROGRESS, ])); $repo->method('updateStatus')->willReturn(true); $revisionService = $this->createMock(HandoverRevisionService::class); $revisionService->expects($this->once()) ->method('createRevision') ->with( self::TENANT_ID, 1, [], HandoverService::STATUS_UNDER_REVIEW, 1, $assigneeId, HandoverRevisionService::CHANGE_TYPE_STATUS ); $service = $this->createService($repo, revisionService: $revisionService); $service->submitForReview(self::TENANT_ID, 1, $assigneeId, HandoverService::PERMISSION_CREATE); } // ── Resend notification tests ───────────────────────────────────── public function testResendNotificationHappyPath(): void { $repo = $this->createMock(HandoverRepository::class); $repo->method('findById')->willReturn($this->handoverRow(['assigned_to' => 99])); $notificationService = $this->createMock(HandoverNotificationService::class); $notificationService->expects($this->once())->method('notifyAssigned'); $service = $this->createService($repo, notificationService: $notificationService); $result = $service->resendNotification(self::TENANT_ID, 1, HandoverService::PERMISSION_MANAGE); $this->assertTrue($result['ok']); } public function testResendNotificationDeniedForCreatePermission(): void { $notificationService = $this->createMock(HandoverNotificationService::class); $notificationService->expects($this->never())->method('notifyAssigned'); $service = $this->createService(notificationService: $notificationService); $result = $service->resendNotification(self::TENANT_ID, 1, HandoverService::PERMISSION_CREATE); $this->assertFalse($result['ok']); $this->assertArrayHasKey('general', $result['errors']); } public function testResendNotificationFailsWhenNoAssignee(): void { $repo = $this->createMock(HandoverRepository::class); $repo->method('findById')->willReturn($this->handoverRow(['assigned_to' => 0])); $notificationService = $this->createMock(HandoverNotificationService::class); $notificationService->expects($this->never())->method('notifyAssigned'); $service = $this->createService($repo, notificationService: $notificationService); $result = $service->resendNotification(self::TENANT_ID, 1, HandoverService::PERMISSION_MANAGE); $this->assertFalse($result['ok']); $this->assertArrayHasKey('general', $result['errors']); } // ── Status label / variant tests ───────────────────────────────── public function testStatusLabelUnderReview(): void { $this->assertNotEmpty(HandoverService::statusLabel(HandoverService::STATUS_UNDER_REVIEW)); } public function testStatusVariantUnderReview(): void { $this->assertSame('info', HandoverService::statusVariant(HandoverService::STATUS_UNDER_REVIEW)); } // ── Delete tests ───────────────────────────────────────────────── public function testDeleteByIdsHappyPath(): void { $repo = $this->createMock(HandoverRepository::class); $repo->expects($this->once()) ->method('deleteByIds') ->with(self::TENANT_ID, [1, 2, 3]) ->willReturn(3); $service = $this->createService($repo); $result = $service->deleteByIds(self::TENANT_ID, [1, 2, 3], HandoverService::PERMISSION_MANAGE); $this->assertTrue($result['ok']); $this->assertSame(3, $result['count']); } public function testDeleteByIdsDeniedForCreatePermission(): void { $repo = $this->createMock(HandoverRepository::class); $repo->expects($this->never())->method('deleteByIds'); $service = $this->createService($repo); $result = $service->deleteByIds(self::TENANT_ID, [1], HandoverService::PERMISSION_CREATE); $this->assertFalse($result['ok']); $this->assertSame(0, $result['count']); } public function testDeleteByIdsWithEmptyArray(): void { $repo = $this->createMock(HandoverRepository::class); $repo->expects($this->never())->method('deleteByIds'); $service = $this->createService($repo); $result = $service->deleteByIds(self::TENANT_ID, [], HandoverService::PERMISSION_MANAGE); $this->assertFalse($result['ok']); } }