commit
9c8544b977
|
|
@ -1 +1 @@
|
|||
5.12.23
|
||||
5.12.24
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Elastic\Index;
|
||||
|
||||
class EntityIndex
|
||||
{
|
||||
|
||||
public array $mapping = [
|
||||
'properties' => [
|
||||
'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);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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())) {
|
||||
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
];
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<int, \App\Models\Document> $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;
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,623 @@
|
|||
<?php
|
||||
/**
|
||||
* Invoice Ninja (https://invoiceninja.com).
|
||||
*
|
||||
* @link https://github.com/invoiceninja/invoiceninja source repository
|
||||
*
|
||||
* @copyright Copyright (c) 2025. Invoice Ninja LLC (https://invoiceninja.com)
|
||||
*
|
||||
* @license https://www.elastic.co/licensing/elastic-license
|
||||
*/
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Client;
|
||||
use App\Models\Project;
|
||||
use App\Models\Task;
|
||||
use App\Models\User;
|
||||
use App\Utils\Traits\MakesHash;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\Session;
|
||||
use Tests\MockAccountData;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Test Task API validation and status codes
|
||||
*/
|
||||
class TaskApiValidationTest extends TestCase
|
||||
{
|
||||
use MakesHash;
|
||||
use DatabaseTransactions;
|
||||
use MockAccountData;
|
||||
|
||||
private $faker;
|
||||
private Client $testClient;
|
||||
private Project $testProject;
|
||||
private User $testUser;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,381 @@
|
|||
<?php
|
||||
/**
|
||||
* Invoice Ninja (https://invoiceninja.com).
|
||||
*
|
||||
* @link https://github.com/invoiceninja/invoiceninja source repository
|
||||
*
|
||||
* @copyright Copyright (c) 2025. Invoice Ninja LLC (https://invoiceninja.com)
|
||||
*
|
||||
* @license https://www.elastic.co/licensing/elastic-license
|
||||
*/
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use Tests\TestCase;
|
||||
use App\Models\Task;
|
||||
use App\Models\Client;
|
||||
use App\Models\Project;
|
||||
use App\Models\User;
|
||||
use App\Repositories\TaskRepository;
|
||||
use Tests\MockAccountData;
|
||||
use App\Utils\Traits\MakesHash;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Session;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
|
||||
/**
|
||||
* Test TaskRepository::bulkUpdate() method
|
||||
*/
|
||||
class TaskRepositoryBulkUpdateTest extends TestCase
|
||||
{
|
||||
use MakesHash;
|
||||
use DatabaseTransactions;
|
||||
use MockAccountData;
|
||||
|
||||
private TaskRepository $taskRepository;
|
||||
private Client $testClient;
|
||||
private Project $testProject;
|
||||
private User $testUser;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue