diff --git a/VERSION.txt b/VERSION.txt index 80ec70aa23..2f0f50c6c7 100644 --- a/VERSION.txt +++ b/VERSION.txt @@ -1 +1 @@ -5.12.23 \ No newline at end of file +5.12.24 \ No newline at end of file diff --git a/app/Elastic/Index/EntityIndex.php b/app/Elastic/Index/EntityIndex.php deleted file mode 100644 index 3c9559f7e7..0000000000 --- a/app/Elastic/Index/EntityIndex.php +++ /dev/null @@ -1,63 +0,0 @@ - [ - 'id' => [ 'type' => 'text' ], - 'name' => [ 'type' => 'text' ], - 'hashed_id' => [ 'type' => 'text' ], - 'number' => [ 'type' => 'text' ], - 'is_deleted' => [ 'type' => 'boolean' ], - 'amount' => [ 'type' => 'long' ], - 'balance' => [ 'type' => 'long' ], - 'due_date' => [ 'type' => 'date' ], - 'date' => [ 'type' => 'date' ], - 'custom_value1' => [ 'type' => 'text' ], - 'custom_value2' => [ 'type' => 'text' ], - 'custom_value3' => [ 'type' => 'text' ], - 'custom_value4' => [ 'type' => 'text' ], - 'company_key' => [ 'type' => 'text' ], - 'po_number' => [ 'type' => 'text' ], - 'line_items' => [ - 'type' => 'nested', - 'properties' => [ - 'product_key' => [ 'type' => 'text' ], - 'notes' => [ 'type' => 'text' ], - 'cost' => [ 'type' => 'long' ], - 'product_cost' => [ 'type' => 'long' ], - 'is_amount_discount' => [ 'type' => 'boolean' ], - 'line_total' => [ 'type' => 'long' ], - 'gross_line_total' => [ 'type' => 'long' ], - 'tax_amount' => [ 'type' => 'long' ], - 'quantity' => [ 'type' => 'float' ], - 'discount' => [ 'type' => 'float' ], - 'tax_name1' => [ 'type' => 'text' ], - 'tax_rate1' => [ 'type' => 'float' ], - 'tax_name2' => [ 'type' => 'text' ], - 'tax_rate2' => [ 'type' => 'float' ], - 'tax_name3' => [ 'type' => 'text' ], - 'tax_rate3' => [ 'type' => 'float' ], - 'custom_value1' => [ 'type' => 'text' ], - 'custom_value2' => [ 'type' => 'text' ], - 'custom_value3' => [ 'type' => 'text' ], - 'custom_value4' => [ 'type' => 'text' ], - 'type_id' => [ 'type' => 'text' ], - 'tax_id' => [ 'type' => 'text' ], - 'task_id' => [ 'type' => 'text' ], - 'expense_id' => [ 'type' => 'text' ], - 'unit_code' => [ 'type' => 'text' ], - ] - ] - ] - ]; - - public function create(string $index_name): void - { - \Elastic\Migrations\Facades\Index::createRaw($index_name, $this->mapping); - } - -} \ No newline at end of file diff --git a/app/Helpers/Bank/Nordigen/Http/NordigenClient.php b/app/Helpers/Bank/Nordigen/Http/NordigenClient.php index 075dd9a4be..f26ec64016 100644 --- a/app/Helpers/Bank/Nordigen/Http/NordigenClient.php +++ b/app/Helpers/Bank/Nordigen/Http/NordigenClient.php @@ -89,21 +89,44 @@ class NordigenClient $allRequisitions = collect(); $offset = null; $limit = 100; + $maxIterations = 1000; // Safety limit to prevent infinite loops + $iteration = 0; do { + $iteration++; + + // Safety check to prevent infinite loops + if ($iteration > $maxIterations) { + nlog("getAllRequisitions: Maximum iterations reached ({$maxIterations}), breaking to prevent infinite loop"); + break; + } + $requisitions = $this->getRequisitions($limit, $offset); - nlog($requisitions); if ($requisitions->isEmpty()) { break; } $allRequisitions = $allRequisitions->merge($requisitions); + // Check if we got fewer results than requested (end of data) + if ($requisitions->count() < $limit) { + break; + } + + // Use the last requisition's ID as the offset for cursor-based pagination $lastRequisition = $requisitions->last(); - $offset = $lastRequisition['id'] ?? null; + $newOffset = $lastRequisition['id'] ?? null; + + // Check if we're making progress (offset is changing) + if ($newOffset === $offset) { + nlog("getAllRequisitions: Offset not changing, likely stuck in loop. Breaking."); + break; + } + + $offset = $newOffset; - } while ($requisitions->count() === $limit && $offset); + } while ($offset); return $allRequisitions; } @@ -120,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); } @@ -130,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); } @@ -140,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); } @@ -150,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); } @@ -160,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(); } @@ -850,6 +873,8 @@ class NordigenClient return collect($data); } + + /** * Handle single response */ diff --git a/app/Helpers/Bank/Nordigen/Nordigen.php b/app/Helpers/Bank/Nordigen/Nordigen.php index 1dfb02a83f..77e5756309 100644 --- a/app/Helpers/Bank/Nordigen/Nordigen.php +++ b/app/Helpers/Bank/Nordigen/Nordigen.php @@ -177,18 +177,27 @@ class Nordigen ); } - + + /** + * validAgreement + * @param string $institution_id + * @param array $_accounts + * @return array|null + * @todo - very expensive! + */ public function validAgreement($institution_id, $_accounts) { $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; } @@ -214,11 +223,11 @@ class Nordigen $out->metadata = $this->client->account($account_id)->getAccountMetaData(); $out->institution = $this->client->institution->getInstitution($out->metadata['institution_id']); - if($out->metadata['status'] == 'READY'){ - $out->data = $this->client->account($account_id)->getAccountDetails()['account']; - $out->balances = $this->client->account($account_id)->getAccountBalances()['balances']; - } - else{ + // if($out->metadata['status'] == 'READY'){ + // $out->data = $this->client->account($account_id)->getAccountDetails()['account']; + // $out->balances = $this->client->account($account_id)->getAccountBalances()['balances']; + // } + // else{ $out->data = [ 'iban' => $out->metadata['iban'], @@ -233,7 +242,7 @@ class Nordigen ], ], ]; - } + // } $it = new AccountTransformer(); return $it->transform($out); diff --git a/app/Http/Controllers/Bank/NordigenController.php b/app/Http/Controllers/Bank/NordigenController.php index ed7864120b..421771f827 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()})"; @@ -224,7 +219,7 @@ class NordigenController extends BaseController ->where('integration_type', BankIntegration::INTEGRATION_TYPE_NORDIGEN) ->where('auto_sync', true) ->each(function ($bank_integration) { - ProcessBankTransactionsNordigen::dispatch($bank_integration); + ProcessBankTransactionsNordigen::dispatch($bank_integration)->delay(now()->addHour()); }); // prevent rerun of this method with same ref diff --git a/app/Http/Controllers/Bank/YodleeController.php b/app/Http/Controllers/Bank/YodleeController.php index 4261401be9..fd29e298d1 100644 --- a/app/Http/Controllers/Bank/YodleeController.php +++ b/app/Http/Controllers/Bank/YodleeController.php @@ -77,7 +77,12 @@ class YodleeController extends BaseController $accounts = $yodlee->getAccounts(); foreach ($accounts as $account) { - if (!BankIntegration::where('bank_account_id', $account['id'])->where('company_id', $company->id)->exists()) { + if ($bi = BankIntegration::where('bank_account_id', $account['id'])->where('company_id', $company->id)->first()) { + $bi->disabled_upstream = false; + $bi->balance = $account['current_balance']; + $bi->currency = $account['account_currency']; + $bi->save(); + } else { $bank_integration = new BankIntegration(); $bank_integration->company_id = $company->id; $bank_integration->account_id = $company->account_id; diff --git a/app/Http/Controllers/TaskController.php b/app/Http/Controllers/TaskController.php index 7e9c9f20e9..1cc28e55ee 100644 --- a/app/Http/Controllers/TaskController.php +++ b/app/Http/Controllers/TaskController.php @@ -512,7 +512,17 @@ class TaskController extends BaseController $ids = $request->input('ids'); - $tasks = Task::withTrashed()->whereIn('id', $this->transformKeys($ids))->company()->get(); + $tasks = Task::withTrashed()->whereIn('id', $this->transformKeys($ids))->company(); + + if ($request->action == 'bulk_update' && $user->can('edit', $tasks->first())) { + + $this->task_repo->bulkUpdate($tasks, $request->column, $request->new_value); + + return $this->listResponse(Task::withTrashed()->whereIn('id', $this->transformKeys($ids))); + + } + + $tasks = $tasks->get(); if ($action == 'template' && $user->can('view', $tasks->first())) { diff --git a/app/Http/Requests/Task/BulkTaskRequest.php b/app/Http/Requests/Task/BulkTaskRequest.php index fc77578f78..02158d7f3b 100644 --- a/app/Http/Requests/Task/BulkTaskRequest.php +++ b/app/Http/Requests/Task/BulkTaskRequest.php @@ -13,6 +13,7 @@ namespace App\Http\Requests\Task; use App\Http\Requests\Request; +use Illuminate\Validation\Rule; class BulkTaskRequest extends Request { @@ -34,12 +35,17 @@ class BulkTaskRequest extends Request public function rules() { + /** @var \App\Models\User $user */ + $user = auth()->user(); + return [ - 'action' => 'required|string', - 'ids' => 'required|array', + 'action' => 'required|string|in:archive,restore,delete,bulk_update,template,start,stop', 'template' => 'sometimes|string', 'template_id' => 'sometimes|string', - 'send_email' => 'sometimes|bool' + 'send_email' => 'sometimes|bool', + 'ids' => ['required','bail','array'], + 'column' => ['required_if:action,bulk_update', 'string', Rule::in(\App\Models\Task::$bulk_update_columns)], + 'new_value' => ['required_if:action,bulk_update|string'], ]; } diff --git a/app/Jobs/Bank/ProcessBankTransactionsNordigen.php b/app/Jobs/Bank/ProcessBankTransactionsNordigen.php index f1c547d49a..dcc4880a1c 100644 --- a/app/Jobs/Bank/ProcessBankTransactionsNordigen.php +++ b/app/Jobs/Bank/ProcessBankTransactionsNordigen.php @@ -75,7 +75,7 @@ class ProcessBankTransactionsNordigen implements ShouldQueue // UPDATE ACCOUNT try { $this->updateAccount(); - // $this->nordigen_account = true; + $this->nordigen_account = true; } catch (\Exception $e) { nlog("Nordigen: {$this->bank_integration->nordigen_account_id} - exited abnormally => " . $e->getMessage()); diff --git a/app/Listeners/Payment/PaymentBalanceActivity.php b/app/Listeners/Payment/PaymentBalanceActivity.php index 719c26a6a9..04e7179f3a 100644 --- a/app/Listeners/Payment/PaymentBalanceActivity.php +++ b/app/Listeners/Payment/PaymentBalanceActivity.php @@ -57,8 +57,8 @@ class PaymentBalanceActivity implements ShouldQueue public function failed($exception) { if ($exception) { - nlog('PaymentBalanceActivity failed', ['exception' => $exception]); - } + nlog('PaymentBalanceActivity failed ' . $exception->getMessage()); + } // config(['queue.failed.driver' => null]); } diff --git a/app/Models/Task.php b/app/Models/Task.php index 88a468ab5f..f354197390 100644 --- a/app/Models/Task.php +++ b/app/Models/Task.php @@ -71,32 +71,6 @@ use App\Libraries\Currency\Conversion\CurrencyApi; * @method static \Illuminate\Database\Eloquent\Builder|Task onlyTrashed() * @method static \Illuminate\Database\Eloquent\Builder|Task query() * @method static \Illuminate\Database\Eloquent\Builder|BaseModel scope() - * @method static \Illuminate\Database\Eloquent\Builder|Task whereAssignedUserId($value) - * @method static \Illuminate\Database\Eloquent\Builder|Task whereClientId($value) - * @method static \Illuminate\Database\Eloquent\Builder|Task whereCompanyId($value) - * @method static \Illuminate\Database\Eloquent\Builder|Task whereCreatedAt($value) - * @method static \Illuminate\Database\Eloquent\Builder|Task whereCustomValue1($value) - * @method static \Illuminate\Database\Eloquent\Builder|Task whereCustomValue2($value) - * @method static \Illuminate\Database\Eloquent\Builder|Task whereCustomValue3($value) - * @method static \Illuminate\Database\Eloquent\Builder|Task whereCustomValue4($value) - * @method static \Illuminate\Database\Eloquent\Builder|Task whereDeletedAt($value) - * @method static \Illuminate\Database\Eloquent\Builder|Task whereDescription($value) - * @method static \Illuminate\Database\Eloquent\Builder|Task whereDuration($value) - * @method static \Illuminate\Database\Eloquent\Builder|Task whereId($value) - * @method static \Illuminate\Database\Eloquent\Builder|Task whereInvoiceDocuments($value) - * @method static \Illuminate\Database\Eloquent\Builder|Task whereInvoiceId($value) - * @method static \Illuminate\Database\Eloquent\Builder|Task whereIsDateBased($value) - * @method static \Illuminate\Database\Eloquent\Builder|Task whereIsDeleted($value) - * @method static \Illuminate\Database\Eloquent\Builder|Task whereIsRunning($value) - * @method static \Illuminate\Database\Eloquent\Builder|Task whereNumber($value) - * @method static \Illuminate\Database\Eloquent\Builder|Task whereProjectId($value) - * @method static \Illuminate\Database\Eloquent\Builder|Task whereRate($value) - * @method static \Illuminate\Database\Eloquent\Builder|Task whereStatusId($value) - * @method static \Illuminate\Database\Eloquent\Builder|Task whereStatusOrder($value) - * @method static \Illuminate\Database\Eloquent\Builder|Task whereStatusSortOrder($value) - * @method static \Illuminate\Database\Eloquent\Builder|Task whereTimeLog($value) - * @method static \Illuminate\Database\Eloquent\Builder|Task whereUpdatedAt($value) - * @method static \Illuminate\Database\Eloquent\Builder|Task whereUserId($value) * @method static \Illuminate\Database\Eloquent\Builder|Task withTrashed() * @method static \Illuminate\Database\Eloquent\Builder|Task withoutTrashed() * @property-read \Illuminate\Database\Eloquent\Collection $documents @@ -109,15 +83,13 @@ class Task extends BaseModel use Filterable; use Searchable; - /** - * Get the index name for the model. - * - * @return string - */ - public function searchableAs(): string - { - return 'tasks_v2'; - } + + public static array $bulk_update_columns = [ + 'status_id', + 'client_id', + 'project_id', + 'assigned_user_id', + ]; protected $fillable = [ 'client_id', @@ -149,12 +121,20 @@ class Task extends BaseModel 'deleted_at' => 'timestamp', ]; - protected $with = [ - // 'project', - ]; + protected $with = []; protected $touches = ['project']; + /** + * Get the index name for the model. + * + * @return string + */ + public function searchableAs(): string + { + return 'tasks_v2'; + } + public function getEntityType() { return self::class; diff --git a/app/Repositories/TaskRepository.php b/app/Repositories/TaskRepository.php index 0234d3fb3c..93c31d0131 100644 --- a/app/Repositories/TaskRepository.php +++ b/app/Repositories/TaskRepository.php @@ -12,9 +12,10 @@ namespace App\Repositories; +use App\Models\Task; +use App\Models\Project; use App\Factory\TaskFactory; use App\Jobs\Task\TaskAssigned; -use App\Models\Task; use App\Utils\Traits\GeneratesCounter; use Illuminate\Database\QueryException; @@ -428,4 +429,34 @@ class TaskRepository extends BaseRepository } + + public function bulkUpdate(\Illuminate\Database\Eloquent\Builder $models, string $column, mixed $new_value): void + { + // First, filter out tasks that have been invoiced + $models->whereNull('invoice_id'); + + if ($column === 'project_id') { + // Handle project_id updates with client_id synchronization + $project = Project::withTrashed() + ->where('id', $new_value) + ->company() + ->first(); + + if ($project) { + /** @var \App\Models\Project $project */ + $models->update([ + 'project_id' => $project->id, + 'client_id' => $project->client_id, + ]); + } + } elseif ($column === 'client_id') { + // If you are updating the client - we will unset the project id! + $models->update([$column => $new_value, 'project_id' => null]); + } + else { + // Assigned User + $models->update([$column => $new_value]); + } + } + } diff --git a/app/Utils/Traits/Pdf/PDF.php b/app/Utils/Traits/Pdf/PDF.php index a3e4962060..4da3c7ac6a 100644 --- a/app/Utils/Traits/Pdf/PDF.php +++ b/app/Utils/Traits/Pdf/PDF.php @@ -39,18 +39,16 @@ class PDF extends FPDI // Set X position based on alignment if ($this->text_alignment == 'L') { - $this->SetX($this->GetX() + $base_x); - // Adjust cell width to account for X offset - $cell_width = $this->GetPageWidth() + $base_x; + $this->SetX($base_x+5); + $cell_width = $this->GetPageWidth(); $this->Cell($cell_width, 5, $trans, 0, 0, 'L'); } elseif ($this->text_alignment == 'R') { - $this->SetX($this->GetX() + $base_x); - // For right alignment, calculate width from X position to right edge - $cell_width = $this->GetPageWidth() + $base_x; + $this->SetX($this->GetPageWidth() - 100 - $base_x); + $cell_width = 100; $this->Cell($cell_width, 5, $trans, 0, 0, 'R'); } else { - // For center alignment, calculate appropriate width - $cell_width = $this->GetPageWidth() + $base_x; + $this->SetX(0); + $cell_width = $this->GetPageWidth(); $this->Cell($cell_width, 5, $trans, 0, 0, 'C'); } } diff --git a/config/ninja.php b/config/ninja.php index e380eb338e..5615863d07 100644 --- a/config/ninja.php +++ b/config/ninja.php @@ -17,8 +17,8 @@ return [ 'require_https' => env('REQUIRE_HTTPS', true), 'app_url' => rtrim(env('APP_URL', ''), '/'), 'app_domain' => env('APP_DOMAIN', 'invoicing.co'), - 'app_version' => env('APP_VERSION', '5.12.23'), - 'app_tag' => env('APP_TAG', '5.12.23'), + 'app_version' => env('APP_VERSION', '5.12.24'), + 'app_tag' => env('APP_TAG', '5.12.24'), 'minimum_client_version' => '5.0.16', 'terms_version' => '1.0.1', 'api_secret' => env('API_SECRET', false), @@ -257,7 +257,7 @@ return [ 'storecove_email_catchall' => env('STORECOVE_CATCHALL_EMAIL',false), 'qvalia_api_key' => env('QVALIA_API_KEY', false), 'qvalia_partner_number' => env('QVALIA_PARTNER_NUMBER', false), - 'pdf_page_numbering_x_alignment' => env('PDF_PAGE_NUMBER_X', -5), + 'pdf_page_numbering_x_alignment' => env('PDF_PAGE_NUMBER_X', 0), 'pdf_page_numbering_y_alignment' => env('PDF_PAGE_NUMBER_Y', -6), 'hosted_einvoice_secret' => env('HOSTED_EINVOICE_SECRET', null), 'e_invoice_quota_warning' => env('E_INVOICE_QUOTA_WARNING', 15), 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); + } +}