invoiceninja/tests/Feature/TaskApiValidationTest.php

625 lines
20 KiB
PHP

<?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);
}
}