diff --git a/app/Helpers/Bank/Nordigen/Http/NordigenClient.php b/app/Helpers/Bank/Nordigen/Http/NordigenClient.php index f3ee8f16be..f26ec64016 100644 --- a/app/Helpers/Bank/Nordigen/Http/NordigenClient.php +++ b/app/Helpers/Bank/Nordigen/Http/NordigenClient.php @@ -143,7 +143,7 @@ class NordigenClient $params['offset'] = $offset; } - $response = $this->httpClient->get("{$this->baseUrl}/agreements/", $params); + $response = $this->httpClient->get("{$this->baseUrl}/agreements/enduser", $params); return $this->handlePaginatedResponse($response); } @@ -153,7 +153,7 @@ class NordigenClient */ public function getAgreement(string $agreementId): ?array { - $response = $this->httpClient->get("{$this->baseUrl}/agreements/{$agreementId}/"); + $response = $this->httpClient->get("{$this->baseUrl}/agreements/enduser{$agreementId}/"); return $this->handleResponse($response); } @@ -163,7 +163,7 @@ class NordigenClient */ public function createAgreement(array $data): ?array { - $response = $this->httpClient->post("{$this->baseUrl}/agreements/", $data); + $response = $this->httpClient->post("{$this->baseUrl}/agreements/enduser", $data); return $this->handleResponse($response); } @@ -173,7 +173,7 @@ class NordigenClient */ public function updateAgreement(string $agreementId, array $data): ?array { - $response = $this->httpClient->put("{$this->baseUrl}/agreements/{$agreementId}/", $data); + $response = $this->httpClient->put("{$this->baseUrl}/agreements/enduser/{$agreementId}/", $data); return $this->handleResponse($response); } @@ -183,7 +183,7 @@ class NordigenClient */ public function deleteAgreement(string $agreementId): bool { - $response = $this->httpClient->delete("{$this->baseUrl}/agreements/{$agreementId}/"); + $response = $this->httpClient->delete("{$this->baseUrl}/agreements/enduser/{$agreementId}/"); return $response->successful(); } diff --git a/app/Helpers/Bank/Nordigen/Nordigen.php b/app/Helpers/Bank/Nordigen/Nordigen.php index 4a4e917d33..fa68380bd5 100644 --- a/app/Helpers/Bank/Nordigen/Nordigen.php +++ b/app/Helpers/Bank/Nordigen/Nordigen.php @@ -180,6 +180,9 @@ class Nordigen /** * validAgreement + * @param string $institution_id + * @param array $_accounts + * @return array|null * @todo - very expensive! */ public function validAgreement($institution_id, $_accounts) @@ -188,11 +191,13 @@ class Nordigen $nc = new \App\Helpers\Bank\Nordigen\Http\NordigenClient($this->client->getAccessToken()); $requisitions = $nc->getAllRequisitions(); - $requisitions->filter(function($requisition) use ($institution_id, $_accounts){ + $requisition = $requisitions->filter(function($requisition) use ($institution_id, $_accounts){ if($requisition['institution_id'] == $institution_id && !empty(array_intersect($requisition['accounts'], $_accounts))){ return $requisition; } }); + + return $requisition->first()->toArray() ?? null; } diff --git a/app/Http/Controllers/Bank/NordigenController.php b/app/Http/Controllers/Bank/NordigenController.php index ed7864120b..e0861ff6ab 100644 --- a/app/Http/Controllers/Bank/NordigenController.php +++ b/app/Http/Controllers/Bank/NordigenController.php @@ -82,11 +82,6 @@ class NordigenController extends BaseController $agreement = $nordigen->createAgreement($institution, $institution['max_access_valid_for_days'], $txDays);//@2025-07-01: this is the correct way to get the access days - // $agreement = $nordigen->createAgreement($institution, $data['access_days'] ?? 9999, $txDays); - - //this does not work in a multi tenant environment, it simply grabs the first agreement, without differentiating between companies. we may need to store the current requistion... - // $agreement = $nordigen->firstValidAgreement($institution['id'], $data['access_days'] ?? 0, $txDays) - // ?? $nordigen->createAgreement($institution, $data['max_access_valid_for_days'] ?? 90, $txDays); } catch (\Exception $e) { $debug = "{$e->getMessage()} ({$e->getCode()})"; diff --git a/tests/Feature/TaskApiValidationTest.php b/tests/Feature/TaskApiValidationTest.php new file mode 100644 index 0000000000..6186b2e69c --- /dev/null +++ b/tests/Feature/TaskApiValidationTest.php @@ -0,0 +1,623 @@ +makeTestData(); + + Session::start(); + + $this->faker = \Faker\Factory::create(); + + Model::reguard(); + + // Create test data + $this->testClient = Client::factory()->create([ + 'user_id' => $this->user->id, + 'company_id' => $this->company->id, + ]); + + $this->testProject = Project::factory()->create([ + 'user_id' => $this->user->id, + 'company_id' => $this->company->id, + 'client_id' => $this->testClient->id, + ]); + + $this->testUser = User::factory()->create([ + 'account_id' => $this->account->id, + ]); + } + + // ==================== VALID PAYLOADS (200 STATUS) ==================== + + public function testCreateTaskWithValidPayloadReturns200() + { + $data = [ + 'client_id' => $this->testClient->hashed_id, + 'description' => 'Test Task Description', + 'time_log' => json_encode([ + [time() - 3600, time(), 'Working on task', true] + ]), + ]; + + $response = $this->withHeaders([ + 'X-API-SECRET' => config('ninja.api_secret'), + 'X-API-TOKEN' => $this->token, + ])->postJson("/api/v1/tasks", $data); + + $response->assertStatus(200); + $response->assertJsonStructure([ + 'data' => [ + 'id', + 'description', + 'client_id', + 'time_log', + ] + ]); + } + + public function testCreateTaskWithProjectReturns200() + { + $data = [ + 'client_id' => $this->testClient->hashed_id, + 'project_id' => $this->testProject->hashed_id, + 'description' => 'Test Task with Project', + 'time_log' => json_encode([]), + ]; + + $response = $this->withHeaders([ + 'X-API-SECRET' => config('ninja.api_secret'), + 'X-API-TOKEN' => $this->token, + ])->postJson("/api/v1/tasks", $data); + + $response->assertStatus(200); + $response->assertJson([ + 'data' => [ + 'description' => 'Test Task with Project', + 'project_id' => $this->testProject->hashed_id, + ] + ]); + } + + public function testUpdateTaskWithValidPayloadReturns200() + { + $task = Task::factory()->create([ + 'user_id' => $this->user->id, + 'company_id' => $this->company->id, + 'client_id' => $this->testClient->id, + 'description' => 'Original Description', + ]); + + $data = [ + 'description' => 'Updated Description', + 'time_log' => json_encode([ + [time() - 1800, time(), 'Updated time log', true] + ]), + ]; + + $response = $this->withHeaders([ + 'X-API-SECRET' => config('ninja.api_secret'), + 'X-API-TOKEN' => $this->token, + ])->putJson("/api/v1/tasks/{$task->hashed_id}", $data); + + $response->assertStatus(200); + $response->assertJson([ + 'data' => [ + 'description' => 'Updated Description', + ] + ]); + } + + public function testCreateTaskWithValidTimeLogArrayReturns200() + { + $data = [ + 'client_id' => $this->testClient->hashed_id, + 'description' => 'Test Task with Array Time Log', + 'time_log' => [ + [time() - 3600, time() - 1800, 'Working on task', true], + [time() - 1800, time() - 900, 'Break time', false], + ], + ]; + + $response = $this->withHeaders([ + 'X-API-SECRET' => config('ninja.api_secret'), + 'X-API-TOKEN' => $this->token, + ])->postJson("/api/v1/tasks", $data); + + $response->assertStatus(200); + } + + public function testCreateTaskWithEmptyTimeLogReturns200() + { + $data = [ + 'client_id' => $this->testClient->hashed_id, + 'description' => 'Test Task with Empty Time Log', + 'time_log' => json_encode([]), + ]; + + $response = $this->withHeaders([ + 'X-API-SECRET' => config('ninja.api_secret'), + 'X-API-TOKEN' => $this->token, + ])->postJson("/api/v1/tasks", $data); + + $response->assertStatus(200); + } + + // ==================== INVALID PAYLOADS (422 STATUS) ==================== + + public function testCreateTaskWithoutClientIdReturns200() + { + $data = [ + 'description' => 'Test Task without Client', + 'time_log' => json_encode([]), + ]; + + $response = $this->withHeaders([ + 'X-API-SECRET' => config('ninja.api_secret'), + 'X-API-TOKEN' => $this->token, + ])->postJson("/api/v1/tasks", $data); + + $response->assertStatus(200); + $response->assertJsonStructure([ + 'data' => [ + 'id', + 'description', + 'client_id', // Should be null + ] + ]); + } + + public function testCreateTaskWithInvalidClientIdReturns422() + { + $data = [ + 'client_id' => 'invalid-client-id', + 'description' => 'Test Task with Invalid Client', + 'time_log' => json_encode([]), + ]; + + $response = $this->withHeaders([ + 'X-API-SECRET' => config('ninja.api_secret'), + 'X-API-TOKEN' => $this->token, + ])->postJson("/api/v1/tasks", $data); + + $response->assertStatus(422); + $response->assertJsonValidationErrors(['client_id']); + } + + public function testCreateTaskWithNonExistentClientIdReturns422() + { + $data = [ + 'client_id' => $this->encodePrimaryKey(99999), + 'description' => 'Test Task with Non-existent Client', + 'time_log' => json_encode([]), + ]; + + $response = $this->withHeaders([ + 'X-API-SECRET' => config('ninja.api_secret'), + 'X-API-TOKEN' => $this->token, + ])->postJson("/api/v1/tasks", $data); + + $response->assertStatus(422); + $response->assertJsonValidationErrors(['client_id']); + } + + public function testCreateTaskWithInvalidProjectIdReturns200() + { + $data = [ + 'client_id' => $this->testClient->hashed_id, + 'project_id' => 'invalid-project-id', + 'description' => 'Test Task with Invalid Project', + 'time_log' => json_encode([]), + ]; + + $response = $this->withHeaders([ + 'X-API-SECRET' => config('ninja.api_secret'), + 'X-API-TOKEN' => $this->token, + ])->postJson("/api/v1/tasks", $data); + + $response->assertStatus(200); + // Invalid project_id should be silently removed + $response->assertJsonMissing(['project_id']); + } + + public function testCreateTaskWithNonExistentProjectIdReturns200() + { + $data = [ + 'client_id' => $this->testClient->hashed_id, + 'project_id' => $this->encodePrimaryKey(99999), + 'description' => 'Test Task with Non-existent Project', + 'time_log' => json_encode([]), + ]; + + $response = $this->withHeaders([ + 'X-API-SECRET' => config('ninja.api_secret'), + 'X-API-TOKEN' => $this->token, + ])->postJson("/api/v1/tasks", $data); + + $response->assertStatus(200); + // Non-existent project_id should be silently removed + $response->assertJsonMissing(['project_id']); + } + + // ==================== TIME_LOG VALIDATION TESTS ==================== + + public function testCreateTaskWithInvalidTimeLogFormatReturns200() + { + $data = [ + 'client_id' => $this->testClient->hashed_id, + 'description' => 'Test Task with Invalid Time Log', + 'time_log' => 'invalid-json-string', + ]; + + $response = $this->withHeaders([ + 'X-API-SECRET' => config('ninja.api_secret'), + 'X-API-TOKEN' => $this->token, + ])->postJson("/api/v1/tasks", $data); + + $response->assertStatus(200); + // Invalid JSON should be converted to empty array + $response->assertJson([ + 'data' => [ + 'time_log' => '[]' + ] + ]); + } + + public function testCreateTaskWithTimeLogTooManyElementsReturns422() + { + $data = [ + 'client_id' => $this->testClient->hashed_id, + 'description' => 'Test Task with Too Many Time Log Elements', + 'time_log' => json_encode([ + [time() - 3600, time(), 'Working', true, 'extra-element'] + ]), + ]; + + $response = $this->withHeaders([ + 'X-API-SECRET' => config('ninja.api_secret'), + 'X-API-TOKEN' => $this->token, + ])->postJson("/api/v1/tasks", $data); + + $response->assertStatus(422); + $response->assertJsonValidationErrors(['time_log']); + } + + public function testCreateTaskWithNonIntegerTimestampsReturns200() + { + $data = [ + 'client_id' => $this->testClient->hashed_id, + 'description' => 'Test Task with Non-integer Timestamps', + 'time_log' => json_encode([ + ['not-a-timestamp', time(), 'Working', true] + ]), + ]; + + $response = $this->withHeaders([ + 'X-API-SECRET' => config('ninja.api_secret'), + 'X-API-TOKEN' => $this->token, + ])->postJson("/api/v1/tasks", $data); + + $response->assertStatus(200); + // Non-integer timestamps should be converted to integers + $response->assertJsonStructure([ + 'data' => [ + 'time_log' + ] + ]); + } + + public function testCreateTaskWithNonBooleanBillableFlagReturns200() + { + $data = [ + 'client_id' => $this->testClient->hashed_id, + 'description' => 'Test Task with Non-boolean Billable', + 'time_log' => json_encode([ + [time() - 3600, time(), 'Working', 'not-a-boolean'] + ]), + ]; + + $response = $this->withHeaders([ + 'X-API-SECRET' => config('ninja.api_secret'), + 'X-API-TOKEN' => $this->token, + ])->postJson("/api/v1/tasks", $data); + + $response->assertStatus(200); + // Non-boolean billable flag should be converted to true + $response->assertJsonStructure([ + 'data' => [ + 'time_log' + ] + ]); + } + + public function testCreateTaskWithOverlappingTimeLogReturns422() + { + $startTime = time() - 3600; + $endTime = time() - 1800; + $overlapStart = time() - 2700; // Overlaps with first entry + $overlapEnd = time() - 900; + + $data = [ + 'client_id' => $this->testClient->hashed_id, + 'description' => 'Test Task with Overlapping Time Log', + 'time_log' => json_encode([ + [$startTime, $endTime, 'First session', true], + [$overlapStart, $overlapEnd, 'Overlapping session', true], + ]), + ]; + + $response = $this->withHeaders([ + 'X-API-SECRET' => config('ninja.api_secret'), + 'X-API-TOKEN' => $this->token, + ])->postJson("/api/v1/tasks", $data); + + $response->assertStatus(422); + $response->assertJsonValidationErrors(['time_log']); + } + + public function testCreateTaskWithStartTimeAfterEndTimeReturns422() + { + $data = [ + 'client_id' => $this->testClient->hashed_id, + 'description' => 'Test Task with Invalid Time Order', + 'time_log' => json_encode([ + [time(), time() - 3600, 'Start after end', true] // Start time after end time + ]), + ]; + + $response = $this->withHeaders([ + 'X-API-SECRET' => config('ninja.api_secret'), + 'X-API-TOKEN' => $this->token, + ])->postJson("/api/v1/tasks", $data); + + $response->assertStatus(422); + $response->assertJsonValidationErrors(['time_log']); + } + + // ==================== UPDATE VALIDATION TESTS ==================== + + public function testUpdateTaskWithInvalidClientIdReturns422() + { + $task = Task::factory()->create([ + 'user_id' => $this->user->id, + 'company_id' => $this->company->id, + 'client_id' => $this->testClient->id, + ]); + + $data = [ + 'client_id' => 'invalid-client-id', + 'description' => 'Updated Description', + ]; + + $response = $this->withHeaders([ + 'X-API-SECRET' => config('ninja.api_secret'), + 'X-API-TOKEN' => $this->token, + ])->putJson("/api/v1/tasks/{$task->hashed_id}", $data); + + $response->assertStatus(422); + $response->assertJsonValidationErrors(['client_id']); + } + + public function testUpdateTaskWithInvalidProjectIdReturns200() + { + $task = Task::factory()->create([ + 'user_id' => $this->user->id, + 'company_id' => $this->company->id, + 'client_id' => $this->testClient->id, + ]); + + $data = [ + 'project_id' => 'invalid-project-id', + 'description' => 'Updated Description', + ]; + + $response = $this->withHeaders([ + 'X-API-SECRET' => config('ninja.api_secret'), + 'X-API-TOKEN' => $this->token, + ])->putJson("/api/v1/tasks/{$task->hashed_id}", $data); + + $response->assertStatus(200); + // Invalid project_id should be silently removed + $response->assertJsonMissing(['project_id']); + } + + public function testUpdateTaskWithInvalidTimeLogReturns200() + { + $task = Task::factory()->create([ + 'user_id' => $this->user->id, + 'company_id' => $this->company->id, + 'client_id' => $this->testClient->id, + ]); + + $data = [ + 'time_log' => json_encode([ + [time() - 3600, time(), 'Working', 'not-a-boolean'] + ]), + ]; + + $response = $this->withHeaders([ + 'X-API-SECRET' => config('ninja.api_secret'), + 'X-API-TOKEN' => $this->token, + ])->putJson("/api/v1/tasks/{$task->hashed_id}", $data); + + $response->assertStatus(200); + // Invalid data should be sanitized + $response->assertJsonStructure([ + 'data' => [ + 'time_log' + ] + ]); + } + + // ==================== EDGE CASES ==================== + + public function testCreateTaskWithProjectFromDifferentClientReturns200() + { + // Create a different client and project + $otherClient = Client::factory()->create([ + 'user_id' => $this->user->id, + 'company_id' => $this->company->id, + ]); + + $otherProject = Project::factory()->create([ + 'user_id' => $this->user->id, + 'company_id' => $this->company->id, + 'client_id' => $otherClient->id, + ]); + + $data = [ + 'client_id' => $this->testClient->hashed_id, + 'project_id' => $otherProject->hashed_id, // Project belongs to different client + 'description' => 'Test Task with Mismatched Project', + 'time_log' => json_encode([]), + ]; + + $response = $this->withHeaders([ + 'X-API-SECRET' => config('ninja.api_secret'), + 'X-API-TOKEN' => $this->token, + ])->postJson("/api/v1/tasks", $data); + + $response->assertStatus(200); + // Project from different client should be silently removed + $response->assertJsonMissing(['project_id']); + } + + public function testCreateTaskWithDeletedClientReturns422() + { + $deletedClient = Client::factory()->create([ + 'user_id' => $this->user->id, + 'company_id' => $this->company->id, + 'is_deleted' => true, + ]); + + $data = [ + 'client_id' => $deletedClient->hashed_id, + 'description' => 'Test Task with Deleted Client', + 'time_log' => json_encode([]), + ]; + + $response = $this->withHeaders([ + 'X-API-SECRET' => config('ninja.api_secret'), + 'X-API-TOKEN' => $this->token, + ])->postJson("/api/v1/tasks", $data); + + $response->assertStatus(422); + $response->assertJsonValidationErrors(['client_id']); + } + + public function testCreateTaskWithDeletedProjectReturns422() + { + $deletedProject = Project::factory()->create([ + 'user_id' => $this->user->id, + 'company_id' => $this->company->id, + 'client_id' => $this->testClient->id, + 'is_deleted' => true, + ]); + + $data = [ + 'client_id' => $this->testClient->hashed_id, + 'project_id' => $deletedProject->hashed_id, + 'description' => 'Test Task with Deleted Project', + 'time_log' => json_encode([]), + ]; + + $response = $this->withHeaders([ + 'X-API-SECRET' => config('ninja.api_secret'), + 'X-API-TOKEN' => $this->token, + ])->postJson("/api/v1/tasks", $data); + + $response->assertStatus(422); + $response->assertJsonValidationErrors(['project_id']); + } + + public function testCreateTaskWithValidTimeLogWithZeroEndTimeReturns200() + { + $data = [ + 'client_id' => $this->testClient->hashed_id, + 'description' => 'Test Task with Running Timer', + 'time_log' => json_encode([ + [time() - 3600, 0, 'Currently running', true] // 0 means timer is running + ]), + ]; + + $response = $this->withHeaders([ + 'X-API-SECRET' => config('ninja.api_secret'), + 'X-API-TOKEN' => $this->token, + ])->postJson("/api/v1/tasks", $data); + + $response->assertStatus(200); + } + + public function testCreateTaskWithValidTimeLogWithOnlyTwoElementsReturns200() + { + $data = [ + 'client_id' => $this->testClient->hashed_id, + 'description' => 'Test Task with Minimal Time Log', + 'time_log' => json_encode([ + [time() - 3600, time()] // Only start and end time + ]), + ]; + + $response = $this->withHeaders([ + 'X-API-SECRET' => config('ninja.api_secret'), + 'X-API-TOKEN' => $this->token, + ])->postJson("/api/v1/tasks", $data); + + $response->assertStatus(200); + } + + public function testCreateTaskWithValidTimeLogWithThreeElementsReturns200() + { + $data = [ + 'client_id' => $this->testClient->hashed_id, + 'description' => 'Test Task with Three Element Time Log', + 'time_log' => json_encode([ + [time() - 3600, time(), 'Working on task'] // Start, end, description + ]), + ]; + + $response = $this->withHeaders([ + 'X-API-SECRET' => config('ninja.api_secret'), + 'X-API-TOKEN' => $this->token, + ])->postJson("/api/v1/tasks", $data); + + $response->assertStatus(200); + } +} diff --git a/tests/Unit/TaskRepositoryBulkUpdateTest.php b/tests/Unit/TaskRepositoryBulkUpdateTest.php new file mode 100644 index 0000000000..1153dc2eae --- /dev/null +++ b/tests/Unit/TaskRepositoryBulkUpdateTest.php @@ -0,0 +1,381 @@ +makeTestData(); + + Session::start(); + + Model::reguard(); + + $this->taskRepository = new TaskRepository(); + + // Create test client + $this->testClient = Client::factory()->create([ + 'user_id' => $this->user->id, + 'company_id' => $this->company->id, + ]); + + // Create test project + $this->testProject = Project::factory()->create([ + 'user_id' => $this->user->id, + 'company_id' => $this->company->id, + 'client_id' => $this->testClient->id, + ]); + + $this->testUser = User::factory()->create([ + 'account_id' => $this->account->id, + ]); + } + + public function testBulkUpdateProjectIdUpdatesClientId() + { + // Create tasks with different clients + $task1 = Task::factory()->create([ + 'user_id' => $this->user->id, + 'company_id' => $this->company->id, + 'client_id' => $this->testClient->id, + 'project_id' => null, + 'invoice_id' => null, + ]); + + $task2 = Task::factory()->create([ + 'user_id' => $this->user->id, + 'company_id' => $this->company->id, + 'client_id' => $this->testClient->id, + 'project_id' => null, + 'invoice_id' => null, + ]); + + // Create a different client and project + $otherClient = Client::factory()->create([ + 'user_id' => $this->user->id, + 'company_id' => $this->company->id, + ]); + + $otherProject = Project::factory()->create([ + 'user_id' => $this->user->id, + 'company_id' => $this->company->id, + 'client_id' => $otherClient->id, + ]); + + // Get the query builder for the tasks + $models = Task::whereIn('id', [$task1->id, $task2->id]); + + // Bulk update project_id + $this->taskRepository->bulkUpdate($models, 'project_id', $otherProject->id); + + // Refresh models from database + $task1->refresh(); + $task2->refresh(); + + // Assert both tasks now have the new project and client + $this->assertEquals($otherProject->id, $task1->project_id); + $this->assertEquals($otherClient->id, $task1->client_id); + $this->assertEquals($otherProject->id, $task2->project_id); + $this->assertEquals($otherClient->id, $task2->client_id); + } + + public function testBulkUpdateProjectIdWithNonExistentProject() + { + // Create a task + $task = Task::factory()->create([ + 'user_id' => $this->user->id, + 'company_id' => $this->company->id, + 'client_id' => $this->testClient->id, + 'project_id' => null, + 'invoice_id' => null, + ]); + + $originalClientId = $task->client_id; + $originalProjectId = $task->project_id; + + // Get the query builder for the task + $models = Task::where('id', $task->id); + + // Try to bulk update with non-existent project ID + $this->taskRepository->bulkUpdate($models, 'project_id', 99999); + + // Refresh model from database + $task->refresh(); + + // Assert task remains unchanged + $this->assertEquals($originalClientId, $task->client_id); + $this->assertEquals($originalProjectId, $task->project_id); + } + + public function testBulkUpdateClientIdUnsetsProjectId() + { + // Create a task with a project + $task = Task::factory()->create([ + 'user_id' => $this->user->id, + 'company_id' => $this->company->id, + 'client_id' => $this->testClient->id, + 'project_id' => $this->project->id, + 'invoice_id' => null, + ]); + + // Create a different client + $newClient = Client::factory()->create([ + 'user_id' => $this->user->id, + 'company_id' => $this->company->id, + ]); + + // Get the query builder for the task + $models = Task::where('id', $task->id); + + // Bulk update client_id + $this->taskRepository->bulkUpdate($models, 'client_id', $newClient->id); + + // Refresh model from database + $task->refresh(); + + // Assert client_id is updated and project_id is null + $this->assertEquals($newClient->id, $task->client_id); + $this->assertNull($task->project_id); + } + + public function testBulkUpdateAssignedUser() + { + // Create tasks + $task1 = Task::factory()->create([ + 'user_id' => $this->user->id, + 'company_id' => $this->company->id, + 'client_id' => $this->testClient->id, + 'assigned_user_id' => null, + 'invoice_id' => null, + ]); + + $task2 = Task::factory()->create([ + 'user_id' => $this->user->id, + 'company_id' => $this->company->id, + 'client_id' => $this->testClient->id, + 'assigned_user_id' => null, + 'invoice_id' => null, + ]); + + // Get the query builder for the tasks + $models = Task::whereIn('id', [$task1->id, $task2->id]); + + // Bulk update assigned_user_id + $this->taskRepository->bulkUpdate($models, 'assigned_user_id', $this->testUser->id); + + // Refresh models from database + $task1->refresh(); + $task2->refresh(); + + // Assert both tasks now have the assigned user + $this->assertEquals($this->testUser->id, $task1->assigned_user_id); + $this->assertEquals($this->testUser->id, $task2->assigned_user_id); + } + + public function testBulkUpdateSkipsInvoicedTasks() + { + // Create an invoice first + $invoice = \App\Models\Invoice::factory()->create([ + 'user_id' => $this->user->id, + 'company_id' => $this->company->id, + 'client_id' => $this->testClient->id, + ]); + + // Create tasks - one invoiced, one not + $invoicedTask = Task::factory()->create([ + 'user_id' => $this->user->id, + 'company_id' => $this->company->id, + 'client_id' => $this->testClient->id, + 'assigned_user_id' => null, + 'invoice_id' => $invoice->id, // This task is invoiced + ]); + + $regularTask = Task::factory()->create([ + 'user_id' => $this->user->id, + 'company_id' => $this->company->id, + 'client_id' => $this->testClient->id, + 'assigned_user_id' => null, + 'invoice_id' => null, // This task is not invoiced + ]); + + // Get the query builder for both tasks + $models = Task::whereIn('id', [$invoicedTask->id, $regularTask->id]); + + // Bulk update assigned_user_id + $this->taskRepository->bulkUpdate($models, 'assigned_user_id', $this->testUser->id); + + // Refresh models from database + $invoicedTask->refresh(); + $regularTask->refresh(); + + // Assert invoiced task is unchanged + $this->assertNull($invoicedTask->assigned_user_id); + + // Assert regular task is updated + $this->assertEquals($this->testUser->id, $regularTask->assigned_user_id); + } + + public function testBulkUpdateWithSoftDeletedProject() + { + // Create a task + $task = Task::factory()->create([ + 'user_id' => $this->user->id, + 'company_id' => $this->company->id, + 'client_id' => $this->testClient->id, + 'project_id' => null, + 'invoice_id' => null, + ]); + + // Soft delete the project + $this->testProject->delete(); + + // Get the query builder for the task + $models = Task::where('id', $task->id); + + // Bulk update project_id (should work with soft deleted project) + $this->taskRepository->bulkUpdate($models, 'project_id', $this->testProject->id); + + // Refresh model from database + $task->refresh(); + + // Assert task is updated with the soft deleted project + $this->assertEquals($this->testProject->id, $task->project_id); + $this->assertEquals($this->testClient->id, $task->client_id); + } + + public function testBulkUpdateWithDifferentColumnTypes() + { + // Create a task + $task = Task::factory()->create([ + 'user_id' => $this->user->id, + 'company_id' => $this->company->id, + 'client_id' => $this->testClient->id, + 'description' => 'Original Description', + 'rate' => 50.00, + 'invoice_id' => null, + ]); + + // Test string column update + $models = Task::where('id', $task->id); + $this->taskRepository->bulkUpdate($models, 'description', 'New Description'); + $task->refresh(); + $this->assertEquals('New Description', $task->description); + + // Test numeric column update + $models = Task::where('id', $task->id); + $this->taskRepository->bulkUpdate($models, 'rate', 75.50); + $task->refresh(); + $this->assertEquals(75.50, $task->rate); + } + + public function testBulkUpdatePerformanceWithLargeDataset() + { + // Create many tasks + $tasks = Task::factory()->count(100)->create([ + 'user_id' => $this->user->id, + 'company_id' => $this->company->id, + 'client_id' => $this->testClient->id, + 'assigned_user_id' => null, + 'invoice_id' => null, + ]); + + $taskIds = $tasks->pluck('id')->toArray(); + + // Get the query builder for all tasks + $models = Task::whereIn('id', $taskIds); + + // Measure execution time + $startTime = microtime(true); + + // Bulk update assigned_user_id + $this->taskRepository->bulkUpdate($models, 'assigned_user_id', $this->testUser->id); + + $endTime = microtime(true); + $executionTime = $endTime - $startTime; + + // Assert all tasks are updated + $updatedTasks = Task::whereIn('id', $taskIds)->get(); + foreach ($updatedTasks as $task) { + $this->assertEquals($this->testUser->id, $task->assigned_user_id); + } + + // Assert execution time is reasonable (less than 1 second for 100 records) + $this->assertLessThan(1.0, $executionTime, 'Bulk update should be fast for 100 records'); + } + + public function testBulkUpdateWithEmptyResultSet() + { + // Get query builder for non-existent tasks + $models = Task::where('id', 99999); + + // This should not throw an error + $this->taskRepository->bulkUpdate($models, 'assigned_user_id', $this->testUser->id); + + // No assertions needed - just ensuring no exceptions are thrown + $this->assertTrue(true); + } + + public function testBulkUpdateProjectIdWithTrashedProject() + { + // Create a task + $task = Task::factory()->create([ + 'user_id' => $this->user->id, + 'company_id' => $this->company->id, + 'client_id' => $this->testClient->id, + 'project_id' => null, + 'invoice_id' => null, + ]); + + // Soft delete the project + $this->testProject->delete(); + + // Get the query builder for the task + $models = Task::where('id', $task->id); + + // Bulk update project_id (should work with soft deleted project) + $this->taskRepository->bulkUpdate($models, 'project_id', $this->testProject->id); + + // Refresh model from database + $task->refresh(); + + // Assert task is updated with the soft deleted project + $this->assertEquals($this->testProject->id, $task->project_id); + $this->assertEquals($this->testClient->id, $task->client_id); + } +}