diff --git a/app/Application/ProjectService.php b/app/Application/ProjectService.php index cdfaf72..b15de48 100644 --- a/app/Application/ProjectService.php +++ b/app/Application/ProjectService.php @@ -4,6 +4,7 @@ namespace Modules\TasksProjects\Application; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Support\Facades\DB; @@ -23,7 +24,7 @@ final class ProjectService ]; /** - * @param array{status?: string, customer_id?: int, user_id?: int} $filters + * @param array{status?: string, customer_id?: int, user_id?: int, search?: string} $filters * @return Collection */ public function listFor(int $companyId, array $filters = []): Collection @@ -45,6 +46,14 @@ public function listFor(int $companyId, array $filters = []): Collection ->select('project_id')); } + if (isset($filters['search']) && $filters['search'] !== '') { + $search = '%'.$filters['search'].'%'; + + $query->where(static function (Builder $inner) use ($search): void { + $inner->where('name', 'like', $search)->orWhere('identifier', 'like', $search); + }); + } + return $query->orderBy('name')->orderBy('id')->get(); } diff --git a/app/Application/TaskService.php b/app/Application/TaskService.php index 3b08c7c..04c9831 100644 --- a/app/Application/TaskService.php +++ b/app/Application/TaskService.php @@ -35,7 +35,7 @@ public function __construct( ) {} /** - * @param array{project_id?: int, assignee_id?: int, task_status_id?: int, customer_id?: int, search?: string} $filters + * @param array{project_id?: int, assignee_id?: int, task_status_id?: int, customer_id?: int, due_before?: string, due_after?: string, search?: string} $filters * @return Collection */ public function listFor(int $companyId, array $filters = []): Collection @@ -48,6 +48,14 @@ public function listFor(int $companyId, array $filters = []): Collection } } + if (isset($filters['due_after'])) { + $query->where('due_date', '>=', Carbon::parse($filters['due_after'])->toDateString()); + } + + if (isset($filters['due_before'])) { + $query->where('due_date', '<=', Carbon::parse($filters['due_before'])->toDateString()); + } + if (isset($filters['search']) && $filters['search'] !== '') { $query->where('name', 'like', '%'.$filters['search'].'%'); } diff --git a/app/Http/Controllers/BillingController.php b/app/Http/Controllers/BillingController.php new file mode 100644 index 0000000..5d81963 --- /dev/null +++ b/app/Http/Controllers/BillingController.php @@ -0,0 +1,85 @@ +context($request); + $this->authorize($context, Abilities::INVOICE_TASKS); + + $filters = $request->validated(); + + return response()->json(['data' => $this->billing->unbilled( + $context->companyId, + (int) $filters['customer_id'], + $filters['from'] ?? null, + $filters['to'] ?? null, + )]); + } + + /** + * The body the browser posts to the host invoice endpoint. + * + * Zero fractions are preserved so `quantity` stays the two-decimal number + * of hours the preview showed, rather than collapsing to an integer on the + * way out. + */ + public function prepare(PrepareInvoiceRequest $request): JsonResponse + { + $context = $this->context($request); + $this->authorize($context, Abilities::INVOICE_TASKS); + + $validated = $request->validated(); + + $payload = $this->billing->prepare( + $context->companyId, + array_map(intval(...), $validated['entry_ids']), + $validated['grouping'], + ); + + return response()->json(['data' => $payload], 200, [], JSON_PRESERVE_ZERO_FRACTION); + } + + /** Stamp the entries with the invoice and line ids the host handed back. */ + public function confirm(ConfirmInvoiceRequest $request): JsonResponse + { + $context = $this->context($request); + $this->authorize($context, Abilities::INVOICE_TASKS); + + $validated = $request->validated(); + + $items = array_map(static fn (array $item): array => [ + 'invoice_item_id' => (int) $item['invoice_item_id'], + 'entry_ids' => array_map(intval(...), $item['entry_ids']), + ], $validated['items']); + + return response()->json([ + 'stamped' => $this->billing->confirm($context->companyId, (int) $validated['invoice_id'], $items), + ]); + } +} diff --git a/app/Http/Controllers/BoardController.php b/app/Http/Controllers/BoardController.php new file mode 100644 index 0000000..8be6b55 --- /dev/null +++ b/app/Http/Controllers/BoardController.php @@ -0,0 +1,52 @@ +context($request); + $this->authorize($context, Abilities::VIEW_TASK); + + $this->statuses->ensureDefaults($context->companyId); + + $filters = $request->validated(); + $columns = $this->board->columns( + $context->companyId, + isset($filters['project_id']) ? (int) $filters['project_id'] : null, + isset($filters['assignee_id']) ? (int) $filters['assignee_id'] : null, + ); + + return response()->json(['data' => array_map(static fn (array $column): array => [ + 'status' => TaskStatusResource::make($column['status'])->resolve($request), + 'tasks' => TaskResource::collection($column['tasks'])->resolve($request), + ], $columns)]); + } +} diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php new file mode 100644 index 0000000..b1a7966 --- /dev/null +++ b/app/Http/Controllers/Controller.php @@ -0,0 +1,94 @@ +authorizes->require($context, $ability); + } + + protected function allows(CompanyContext $context, string $ability): bool + { + return $this->authorizes->allows($context, $ability); + } + + /** + * Whether the caller sees other members' time: either the ability, or the + * company setting that opens the timesheet to everyone. + */ + protected function canSeeAllTime(CompanyContext $context, ModuleSettings $settings): bool + { + return $this->allows($context, Abilities::VIEW_ALL_TIME) + || $settings->membersSeeAllTime($context->companyId); + } + + /** `?limit=`, defaulted and clamped rather than rejected. */ + protected function limit(Request $request): int + { + $limit = $request->integer('limit'); + + return $limit <= 0 ? self::DEFAULT_LIMIT : min($limit, self::MAX_LIMIT); + } + + /** + * Page an already-loaded collection. + * + * The services own every query and hand back whole collections, so the page + * is cut here rather than in a second query. The lists this module serves + * are one company's projects, tasks and time entries, which is the scale + * that fits in memory comfortably. + * + * @template TValue + * + * @param Collection $items + * @return LengthAwarePaginator + */ + protected function paginate(Collection $items, Request $request): LengthAwarePaginator + { + $perPage = $this->limit($request); + $page = LengthAwarePaginator::resolveCurrentPage(); + + return new LengthAwarePaginator( + $items->forPage($page, $perPage)->values(), + $items->count(), + $perPage, + $page, + [ + 'path' => LengthAwarePaginator::resolveCurrentPath(), + 'query' => $request->query(), + ], + ); + } +} diff --git a/app/Http/Controllers/MembersController.php b/app/Http/Controllers/MembersController.php new file mode 100644 index 0000000..36a589d --- /dev/null +++ b/app/Http/Controllers/MembersController.php @@ -0,0 +1,33 @@ +context($request); + $this->authorize($context, Abilities::VIEW_PROJECT); + + return response()->json(['data' => $this->companyData->companyMembers($context->companyId)]); + } +} diff --git a/app/Http/Controllers/ProjectMembersController.php b/app/Http/Controllers/ProjectMembersController.php new file mode 100644 index 0000000..395cdec --- /dev/null +++ b/app/Http/Controllers/ProjectMembersController.php @@ -0,0 +1,61 @@ +context($request); + $this->authorize($context, Abilities::EDIT_PROJECT); + + return ProjectMemberResource::collection($this->members->listFor($context->companyId, $id)); + } + + public function store(AttachProjectMemberRequest $request, int $id): ProjectMemberResource + { + $context = $this->context($request); + $this->authorize($context, Abilities::EDIT_PROJECT); + + $validated = $request->validated(); + + return new ProjectMemberResource($this->members->attach( + $context->companyId, + $id, + (int) $validated['user_id'], + isset($validated['rate']) ? (int) $validated['rate'] : null, + )); + } + + public function destroy(Request $request, int $id, int $userId): JsonResponse + { + $context = $this->context($request); + $this->authorize($context, Abilities::EDIT_PROJECT); + + $this->members->detach($context->companyId, $id, $userId); + + return response()->json(['success' => true]); + } +} diff --git a/app/Http/Controllers/ProjectsController.php b/app/Http/Controllers/ProjectsController.php new file mode 100644 index 0000000..e9ebe91 --- /dev/null +++ b/app/Http/Controllers/ProjectsController.php @@ -0,0 +1,95 @@ +context($request); + $this->authorize($context, Abilities::VIEW_PROJECT); + + $filters = $request->validated(); + $projects = $this->projects->listFor($context->companyId, array_filter([ + 'status' => $filters['status'] ?? null, + 'customer_id' => isset($filters['customer_id']) ? (int) $filters['customer_id'] : null, + 'user_id' => isset($filters['member_id']) ? (int) $filters['member_id'] : null, + 'search' => $filters['search'] ?? null, + ], static fn (mixed $value): bool => $value !== null)); + + return ProjectResource::collection($this->paginate($projects, $request)); + } + + public function store(StoreProjectRequest $request): ProjectResource + { + $context = $this->context($request); + $this->authorize($context, Abilities::CREATE_PROJECT); + + return new ProjectResource($this->projects->create( + $context->companyId, + $request->validated() + ['creator_id' => $context->userId], + )); + } + + public function show(Request $request, int $id): ProjectResource + { + $context = $this->context($request); + $this->authorize($context, Abilities::VIEW_PROJECT); + + $project = $this->projects->findForCompany($context->companyId, $id); + + return (new ProjectResource($project))->withTotals($this->projects->totals($project)); + } + + public function update(UpdateProjectRequest $request, int $id): ProjectResource + { + $context = $this->context($request); + $this->authorize($context, Abilities::EDIT_PROJECT); + + return new ProjectResource($this->projects->update($context->companyId, $id, $request->validated())); + } + + public function destroy(Request $request, int $id): JsonResponse + { + $context = $this->context($request); + $this->authorize($context, Abilities::DELETE_PROJECT); + + $this->projects->delete($context->companyId, $id); + + return response()->json(['success' => true]); + } + + public function archive(Request $request, int $id): ProjectResource + { + $context = $this->context($request); + $this->authorize($context, Abilities::EDIT_PROJECT); + + return new ProjectResource($this->projects->archive($context->companyId, $id)); + } + + public function unarchive(Request $request, int $id): ProjectResource + { + $context = $this->context($request); + $this->authorize($context, Abilities::EDIT_PROJECT); + + return new ProjectResource($this->projects->unarchive($context->companyId, $id)); + } +} diff --git a/app/Http/Controllers/ReportsController.php b/app/Http/Controllers/ReportsController.php new file mode 100644 index 0000000..46a7c4f --- /dev/null +++ b/app/Http/Controllers/ReportsController.php @@ -0,0 +1,48 @@ +context($request); + $this->authorize($context, Abilities::VIEW_OWN_TIME); + + $filters = $request->validated(); + $from = isset($filters['from']) ? Carbon::parse($filters['from']) : Carbon::now()->startOfMonth(); + $to = isset($filters['to']) ? Carbon::parse($filters['to']) : Carbon::now(); + + return response()->json(['data' => $this->reports->summary( + $context->companyId, + $from->toDateString(), + $to->toDateString(), + $context->userId, + $this->canSeeAllTime($context, $this->settings), + )]); + } +} diff --git a/app/Http/Controllers/SettingsController.php b/app/Http/Controllers/SettingsController.php new file mode 100644 index 0000000..637ad53 --- /dev/null +++ b/app/Http/Controllers/SettingsController.php @@ -0,0 +1,40 @@ +context($request); + $this->authorize($context, Abilities::VIEW_PROJECT); + + return response()->json(['data' => [ + 'default_rate' => $this->settings->defaultRate($context->companyId), + 'rounding_minutes' => $this->settings->roundingMinutes($context->companyId), + 'week_start' => $this->settings->weekStart($context->companyId), + 'members_see_all_time' => $this->settings->membersSeeAllTime($context->companyId), + 'rounding_increments' => ModuleSettings::ROUNDING_INCREMENTS, + ]]); + } +} diff --git a/app/Http/Controllers/TaskStatusesController.php b/app/Http/Controllers/TaskStatusesController.php new file mode 100644 index 0000000..24e22ae --- /dev/null +++ b/app/Http/Controllers/TaskStatusesController.php @@ -0,0 +1,78 @@ +context($request); + $this->authorize($context, Abilities::VIEW_TASK); + + $this->statuses->ensureDefaults($context->companyId); + + return TaskStatusResource::collection($this->statuses->listFor($context->companyId)); + } + + public function store(StoreTaskStatusRequest $request): TaskStatusResource + { + $context = $this->context($request); + $this->authorize($context, Abilities::MANAGE_TASK_STATUS); + + return new TaskStatusResource($this->statuses->create($context->companyId, $request->validated())); + } + + public function update(UpdateTaskStatusRequest $request, int $id): TaskStatusResource + { + $context = $this->context($request); + $this->authorize($context, Abilities::MANAGE_TASK_STATUS); + + return new TaskStatusResource($this->statuses->update($context->companyId, $id, $request->validated())); + } + + public function destroy(Request $request, int $id): JsonResponse + { + $context = $this->context($request); + $this->authorize($context, Abilities::MANAGE_TASK_STATUS); + + $this->statuses->delete($context->companyId, $id); + + return response()->json(['success' => true]); + } + + /** Apply the wanted column order; anything left out follows the listed ids. */ + public function reorder(ReorderTaskStatusesRequest $request): AnonymousResourceCollection + { + $context = $this->context($request); + $this->authorize($context, Abilities::MANAGE_TASK_STATUS); + + $ids = array_map(intval(...), $request->validated()['ids']); + $this->statuses->reorder($context->companyId, $ids); + + return TaskStatusResource::collection($this->statuses->listFor($context->companyId)); + } +} diff --git a/app/Http/Controllers/TasksController.php b/app/Http/Controllers/TasksController.php new file mode 100644 index 0000000..bdc5014 --- /dev/null +++ b/app/Http/Controllers/TasksController.php @@ -0,0 +1,98 @@ +context($request); + $this->authorize($context, Abilities::VIEW_TASK); + + $filters = $request->validated(); + $tasks = $this->tasks->listFor($context->companyId, array_filter([ + 'project_id' => isset($filters['project_id']) ? (int) $filters['project_id'] : null, + 'assignee_id' => isset($filters['assignee_id']) ? (int) $filters['assignee_id'] : null, + 'task_status_id' => isset($filters['task_status_id']) ? (int) $filters['task_status_id'] : null, + 'customer_id' => isset($filters['customer_id']) ? (int) $filters['customer_id'] : null, + 'due_before' => $filters['due_before'] ?? null, + 'due_after' => $filters['due_after'] ?? null, + 'search' => $filters['search'] ?? null, + ], static fn (mixed $value): bool => $value !== null)); + + return TaskResource::collection($this->paginate($tasks, $request)); + } + + public function store(StoreTaskRequest $request): TaskResource + { + $context = $this->context($request); + $this->authorize($context, Abilities::CREATE_TASK); + + return new TaskResource($this->tasks->create( + $context->companyId, + $request->validated() + ['creator_id' => $context->userId], + )); + } + + public function show(Request $request, int $id): TaskResource + { + $context = $this->context($request); + $this->authorize($context, Abilities::VIEW_TASK); + + return new TaskResource($this->tasks->findForCompany($context->companyId, $id)); + } + + public function update(UpdateTaskRequest $request, int $id): TaskResource + { + $context = $this->context($request); + $this->authorize($context, Abilities::EDIT_TASK); + + return new TaskResource($this->tasks->update($context->companyId, $id, $request->validated())); + } + + public function destroy(Request $request, int $id): JsonResponse + { + $context = $this->context($request); + $this->authorize($context, Abilities::DELETE_TASK); + + $this->tasks->delete($context->companyId, $id); + + return response()->json(['success' => true]); + } + + /** Drop a task between two neighbours of the target column. */ + public function move(MoveTaskRequest $request, int $id): TaskResource + { + $context = $this->context($request); + $this->authorize($context, Abilities::EDIT_TASK); + + $validated = $request->validated(); + + return new TaskResource($this->tasks->move( + $context->companyId, + $id, + (int) $validated['task_status_id'], + isset($validated['before_id']) ? (int) $validated['before_id'] : null, + isset($validated['after_id']) ? (int) $validated['after_id'] : null, + )); + } +} diff --git a/app/Http/Controllers/TimeEntriesController.php b/app/Http/Controllers/TimeEntriesController.php new file mode 100644 index 0000000..a16048b --- /dev/null +++ b/app/Http/Controllers/TimeEntriesController.php @@ -0,0 +1,124 @@ +context($request); + $this->authorize($context, Abilities::VIEW_OWN_TIME); + + $filters = $request->validated(); + $entries = $this->entries->listFor( + $context->companyId, + array_filter([ + 'user_id' => isset($filters['user_id']) ? (int) $filters['user_id'] : null, + 'project_id' => isset($filters['project_id']) ? (int) $filters['project_id'] : null, + 'task_id' => isset($filters['task_id']) ? (int) $filters['task_id'] : null, + 'from' => $filters['from'] ?? null, + 'to' => $filters['to'] ?? null, + 'billable' => array_key_exists('billable', $filters) ? $request->boolean('billable') : null, + 'billed' => array_key_exists('billed', $filters) ? $request->boolean('billed') : null, + ], static fn (mixed $value): bool => $value !== null), + $context->userId, + $this->canSeeAllTime($context, $this->settings), + ); + + return TimeEntryResource::collection($this->paginate($entries, $request)); + } + + public function store(StoreTimeEntryRequest $request): TimeEntryResource + { + $context = $this->context($request); + $this->authorize($context, Abilities::VIEW_OWN_TIME); + + $validated = $request->validated(); + $userId = isset($validated['user_id']) ? (int) $validated['user_id'] : $context->userId; + + if ($userId !== $context->userId) { + $this->authorize($context, Abilities::EDIT_ALL_TIME); + } + + return new TimeEntryResource($this->entries->create($context->companyId, ['user_id' => $userId] + $validated)); + } + + public function show(Request $request, int $id): TimeEntryResource + { + $context = $this->context($request); + $this->authorize($context, Abilities::VIEW_OWN_TIME); + + $entry = $this->entries->findForCompany($context->companyId, $id); + + if ($this->belongsToSomeoneElse($context, $entry) && ! $this->canSeeAllTime($context, $this->settings)) { + $this->authorize($context, Abilities::VIEW_ALL_TIME); + } + + return new TimeEntryResource($entry); + } + + public function update(UpdateTimeEntryRequest $request, int $id): TimeEntryResource + { + $context = $this->context($request); + $this->authorize($context, Abilities::VIEW_OWN_TIME); + + $this->requireWriteAccess($context, $this->entries->findForCompany($context->companyId, $id)); + + return new TimeEntryResource($this->entries->update($context->companyId, $id, $request->validated())); + } + + public function destroy(Request $request, int $id): JsonResponse + { + $context = $this->context($request); + $this->authorize($context, Abilities::VIEW_OWN_TIME); + + $this->requireWriteAccess($context, $this->entries->findForCompany($context->companyId, $id)); + $this->entries->delete($context->companyId, $id); + + return response()->json(['success' => true]); + } + + private function requireWriteAccess(CompanyContext $context, TimeEntry $entry): void + { + if ($this->belongsToSomeoneElse($context, $entry)) { + $this->authorize($context, Abilities::EDIT_ALL_TIME); + } + } + + private function belongsToSomeoneElse(CompanyContext $context, TimeEntry $entry): bool + { + return (int) $entry->user_id !== $context->userId; + } +} diff --git a/app/Http/Controllers/TimerController.php b/app/Http/Controllers/TimerController.php new file mode 100644 index 0000000..7d0885f --- /dev/null +++ b/app/Http/Controllers/TimerController.php @@ -0,0 +1,74 @@ +context($request); + $this->authorize($context, Abilities::VIEW_OWN_TIME); + + $entry = $this->timer->running($context->companyId, $context->userId); + + return response()->json([ + 'data' => $entry === null ? null : TimeEntryResource::make($entry)->resolve($request), + ]); + } + + public function start(StartTimerRequest $request): TimeEntryResource + { + $context = $this->context($request); + $this->authorize($context, Abilities::VIEW_OWN_TIME); + + $validated = $request->validated(); + + return new TimeEntryResource($this->timer->start( + $context->companyId, + $context->userId, + (int) $validated['task_id'], + $validated['description'] ?? null, + )); + } + + public function stop(Request $request): TimeEntryResource + { + $context = $this->context($request); + $this->authorize($context, Abilities::VIEW_OWN_TIME); + + return new TimeEntryResource($this->timer->stop($context->companyId, $context->userId)); + } + + /** Throw the running entry away without recording any time. */ + public function destroy(Request $request): JsonResponse + { + $context = $this->context($request); + $this->authorize($context, Abilities::VIEW_OWN_TIME); + + $this->timer->discard($context->companyId, $context->userId); + + return response()->json(['success' => true]); + } +} diff --git a/app/Http/DomainExceptionRenderer.php b/app/Http/DomainExceptionRenderer.php new file mode 100644 index 0000000..21972d3 --- /dev/null +++ b/app/Http/DomainExceptionRenderer.php @@ -0,0 +1,47 @@ + Response::HTTP_CONFLICT, + ]; + + public static function register(Handler $handler): void + { + $handler->renderable(static fn (TasksProjectsException $exception): JsonResponse => self::render($exception)); + } + + public static function render(TasksProjectsException $exception): JsonResponse + { + return new JsonResponse([ + 'message' => $exception->getMessage(), + 'error' => self::errorKey($exception), + ], self::STATUSES[$exception::class] ?? Response::HTTP_UNPROCESSABLE_ENTITY); + } + + /** `TimerAlreadyRunning` becomes `timer_already_running`. */ + public static function errorKey(TasksProjectsException $exception): string + { + return Str::snake(class_basename($exception)); + } +} diff --git a/app/Http/Requests/AttachProjectMemberRequest.php b/app/Http/Requests/AttachProjectMemberRequest.php new file mode 100644 index 0000000..db8dc73 --- /dev/null +++ b/app/Http/Requests/AttachProjectMemberRequest.php @@ -0,0 +1,48 @@ +> */ + public function rules(): array + { + return [ + 'user_id' => ['required', 'integer', 'min:1', Rule::in($this->companyMemberIds())], + 'rate' => ['nullable', 'integer', 'min:0'], + ]; + } + + /** @return array */ + public function messages(): array + { + return [ + 'user_id.in' => 'The selected user is not a member of this company.', + ]; + } + + /** @return list */ + private function companyMemberIds(): array + { + $context = CompanyContext::fromRequest($this); + + return array_map( + static fn (array $member): int => (int) $member['id'], + app(CompanyDataReader::class)->companyMembers($context->companyId), + ); + } +} diff --git a/app/Http/Requests/BoardRequest.php b/app/Http/Requests/BoardRequest.php new file mode 100644 index 0000000..47fd3fe --- /dev/null +++ b/app/Http/Requests/BoardRequest.php @@ -0,0 +1,17 @@ +> */ + public function rules(): array + { + return [ + 'project_id' => ['sometimes', 'integer', 'min:1'], + 'assignee_id' => ['sometimes', 'integer', 'min:1'], + ]; + } +} diff --git a/app/Http/Requests/ConfirmInvoiceRequest.php b/app/Http/Requests/ConfirmInvoiceRequest.php new file mode 100644 index 0000000..fe762dd --- /dev/null +++ b/app/Http/Requests/ConfirmInvoiceRequest.php @@ -0,0 +1,27 @@ +> */ + public function rules(): array + { + return [ + 'invoice_id' => ['required', 'integer', 'min:1'], + 'items' => ['required', 'array', 'min:1'], + 'items.*.invoice_item_id' => ['required', 'integer', 'min:1'], + 'items.*.entry_ids' => ['required', 'array', 'min:1'], + 'items.*.entry_ids.*' => ['integer', 'min:1'], + ]; + } +} diff --git a/app/Http/Requests/ListProjectsRequest.php b/app/Http/Requests/ListProjectsRequest.php new file mode 100644 index 0000000..ed1a5dc --- /dev/null +++ b/app/Http/Requests/ListProjectsRequest.php @@ -0,0 +1,21 @@ +> */ + public function rules(): array + { + return [ + 'status' => ['sometimes', 'string', 'in:'.Project::STATUS_ACTIVE.','.Project::STATUS_ARCHIVED], + 'customer_id' => ['sometimes', 'integer', 'min:1'], + 'member_id' => ['sometimes', 'integer', 'min:1'], + 'search' => ['sometimes', 'string', 'max:255'], + ]; + } +} diff --git a/app/Http/Requests/ListTasksRequest.php b/app/Http/Requests/ListTasksRequest.php new file mode 100644 index 0000000..a8031a1 --- /dev/null +++ b/app/Http/Requests/ListTasksRequest.php @@ -0,0 +1,22 @@ +> */ + public function rules(): array + { + return [ + 'project_id' => ['sometimes', 'integer', 'min:1'], + 'assignee_id' => ['sometimes', 'integer', 'min:1'], + 'task_status_id' => ['sometimes', 'integer', 'min:1'], + 'customer_id' => ['sometimes', 'integer', 'min:1'], + 'due_before' => ['sometimes', 'date'], + 'due_after' => ['sometimes', 'date'], + 'search' => ['sometimes', 'string', 'max:255'], + ]; + } +} diff --git a/app/Http/Requests/ListTimeEntriesRequest.php b/app/Http/Requests/ListTimeEntriesRequest.php new file mode 100644 index 0000000..243f9bd --- /dev/null +++ b/app/Http/Requests/ListTimeEntriesRequest.php @@ -0,0 +1,22 @@ +> */ + public function rules(): array + { + return [ + 'user_id' => ['sometimes', 'integer', 'min:1'], + 'project_id' => ['sometimes', 'integer', 'min:1'], + 'task_id' => ['sometimes', 'integer', 'min:1'], + 'from' => ['sometimes', 'date'], + 'to' => ['sometimes', 'date'], + 'billable' => ['sometimes', 'boolean'], + 'billed' => ['sometimes', 'boolean'], + ]; + } +} diff --git a/app/Http/Requests/ModuleRequest.php b/app/Http/Requests/ModuleRequest.php new file mode 100644 index 0000000..0537cd6 --- /dev/null +++ b/app/Http/Requests/ModuleRequest.php @@ -0,0 +1,23 @@ +> */ + public function rules(): array + { + return [ + 'task_status_id' => ['required', 'integer', 'min:1'], + 'before_id' => ['nullable', 'integer', 'min:1', $this->neighbour()], + 'after_id' => ['nullable', 'integer', 'min:1', $this->neighbour()], + ]; + } + + private function neighbour(): Exists + { + $context = CompanyContext::fromRequest($this); + + return Rule::exists((new Task)->getTable(), 'id') + ->where('company_id', $context->companyId) + ->where('task_status_id', $this->integer('task_status_id')); + } +} diff --git a/app/Http/Requests/PrepareInvoiceRequest.php b/app/Http/Requests/PrepareInvoiceRequest.php new file mode 100644 index 0000000..496ac42 --- /dev/null +++ b/app/Http/Requests/PrepareInvoiceRequest.php @@ -0,0 +1,20 @@ +> */ + public function rules(): array + { + return [ + 'entry_ids' => ['required', 'array', 'min:1'], + 'entry_ids.*' => ['integer', 'min:1'], + 'grouping' => ['required', 'string', 'in:'.implode(',', BillingService::GROUPINGS)], + ]; + } +} diff --git a/app/Http/Requests/ReorderTaskStatusesRequest.php b/app/Http/Requests/ReorderTaskStatusesRequest.php new file mode 100644 index 0000000..6bb39f0 --- /dev/null +++ b/app/Http/Requests/ReorderTaskStatusesRequest.php @@ -0,0 +1,17 @@ +> */ + public function rules(): array + { + return [ + 'ids' => ['required', 'array', 'min:1'], + 'ids.*' => ['integer', 'min:1'], + ]; + } +} diff --git a/app/Http/Requests/ReportSummaryRequest.php b/app/Http/Requests/ReportSummaryRequest.php new file mode 100644 index 0000000..bf240db --- /dev/null +++ b/app/Http/Requests/ReportSummaryRequest.php @@ -0,0 +1,17 @@ +> */ + public function rules(): array + { + return [ + 'from' => ['sometimes', 'date'], + 'to' => ['sometimes', 'date'], + ]; + } +} diff --git a/app/Http/Requests/StartTimerRequest.php b/app/Http/Requests/StartTimerRequest.php new file mode 100644 index 0000000..6c22e34 --- /dev/null +++ b/app/Http/Requests/StartTimerRequest.php @@ -0,0 +1,17 @@ +> */ + public function rules(): array + { + return [ + 'task_id' => ['required', 'integer', 'min:1'], + 'description' => ['nullable', 'string'], + ]; + } +} diff --git a/app/Http/Requests/StoreProjectRequest.php b/app/Http/Requests/StoreProjectRequest.php new file mode 100644 index 0000000..41b821e --- /dev/null +++ b/app/Http/Requests/StoreProjectRequest.php @@ -0,0 +1,27 @@ +> */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + 'customer_id' => ['nullable', 'integer', 'min:1'], + 'identifier' => ['nullable', 'string', 'max:32'], + 'description' => ['nullable', 'string'], + 'colour' => ['nullable', 'string', 'max:16'], + 'status' => ['sometimes', 'string', 'in:'.Project::STATUS_ACTIVE.','.Project::STATUS_ARCHIVED], + 'currency_id' => ['nullable', 'integer', 'min:1'], + 'default_rate' => ['nullable', 'integer', 'min:0'], + 'budget_minutes' => ['nullable', 'integer', 'min:0'], + 'due_date' => ['nullable', 'date'], + ]; + } +} diff --git a/app/Http/Requests/StoreTaskRequest.php b/app/Http/Requests/StoreTaskRequest.php new file mode 100644 index 0000000..0a119c5 --- /dev/null +++ b/app/Http/Requests/StoreTaskRequest.php @@ -0,0 +1,28 @@ +> */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + 'project_id' => ['nullable', 'integer', 'min:1'], + 'customer_id' => ['nullable', 'integer', 'min:1'], + 'task_status_id' => ['nullable', 'integer', 'min:1'], + 'description' => ['nullable', 'string'], + 'assignee_id' => ['nullable', 'integer', 'min:1'], + 'priority' => ['nullable', 'string', 'max:16', 'in:'.implode(',', Task::PRIORITIES)], + 'due_date' => ['nullable', 'date'], + 'estimated_minutes' => ['nullable', 'integer', 'min:0'], + 'billable' => ['sometimes', 'boolean'], + 'rate' => ['nullable', 'integer', 'min:0'], + ]; + } +} diff --git a/app/Http/Requests/StoreTaskStatusRequest.php b/app/Http/Requests/StoreTaskStatusRequest.php new file mode 100644 index 0000000..1f68f4d --- /dev/null +++ b/app/Http/Requests/StoreTaskStatusRequest.php @@ -0,0 +1,20 @@ +> */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + 'colour' => ['nullable', 'string', 'max:16'], + 'position' => ['nullable', 'integer', 'min:0'], + 'is_default' => ['sometimes', 'boolean'], + 'is_closed' => ['sometimes', 'boolean'], + ]; + } +} diff --git a/app/Http/Requests/StoreTimeEntryRequest.php b/app/Http/Requests/StoreTimeEntryRequest.php new file mode 100644 index 0000000..766a2f7 --- /dev/null +++ b/app/Http/Requests/StoreTimeEntryRequest.php @@ -0,0 +1,23 @@ +> */ + public function rules(): array + { + return [ + 'task_id' => ['required', 'integer', 'min:1'], + 'user_id' => ['nullable', 'integer', 'min:1'], + 'started_at' => ['nullable', 'date'], + 'ended_at' => ['nullable', 'date', 'after_or_equal:started_at'], + 'duration_minutes' => ['nullable', 'integer', 'min:0'], + 'description' => ['nullable', 'string'], + 'billable' => ['sometimes', 'boolean'], + 'rate' => ['nullable', 'integer', 'min:0'], + ]; + } +} diff --git a/app/Http/Requests/UnbilledTimeRequest.php b/app/Http/Requests/UnbilledTimeRequest.php new file mode 100644 index 0000000..20f2fe0 --- /dev/null +++ b/app/Http/Requests/UnbilledTimeRequest.php @@ -0,0 +1,18 @@ +> */ + public function rules(): array + { + return [ + 'customer_id' => ['required', 'integer', 'min:1'], + 'from' => ['sometimes', 'date'], + 'to' => ['sometimes', 'date'], + ]; + } +} diff --git a/app/Http/Requests/UpdateProjectRequest.php b/app/Http/Requests/UpdateProjectRequest.php new file mode 100644 index 0000000..5427556 --- /dev/null +++ b/app/Http/Requests/UpdateProjectRequest.php @@ -0,0 +1,27 @@ +> */ + public function rules(): array + { + return [ + 'name' => ['sometimes', 'required', 'string', 'max:255'], + 'customer_id' => ['sometimes', 'nullable', 'integer', 'min:1'], + 'identifier' => ['sometimes', 'nullable', 'string', 'max:32'], + 'description' => ['sometimes', 'nullable', 'string'], + 'colour' => ['sometimes', 'nullable', 'string', 'max:16'], + 'status' => ['sometimes', 'string', 'in:'.Project::STATUS_ACTIVE.','.Project::STATUS_ARCHIVED], + 'currency_id' => ['sometimes', 'nullable', 'integer', 'min:1'], + 'default_rate' => ['sometimes', 'nullable', 'integer', 'min:0'], + 'budget_minutes' => ['sometimes', 'nullable', 'integer', 'min:0'], + 'due_date' => ['sometimes', 'nullable', 'date'], + ]; + } +} diff --git a/app/Http/Requests/UpdateTaskRequest.php b/app/Http/Requests/UpdateTaskRequest.php new file mode 100644 index 0000000..1513592 --- /dev/null +++ b/app/Http/Requests/UpdateTaskRequest.php @@ -0,0 +1,28 @@ +> */ + public function rules(): array + { + return [ + 'name' => ['sometimes', 'required', 'string', 'max:255'], + 'project_id' => ['sometimes', 'nullable', 'integer', 'min:1'], + 'customer_id' => ['sometimes', 'nullable', 'integer', 'min:1'], + 'task_status_id' => ['sometimes', 'integer', 'min:1'], + 'description' => ['sometimes', 'nullable', 'string'], + 'assignee_id' => ['sometimes', 'nullable', 'integer', 'min:1'], + 'priority' => ['sometimes', 'nullable', 'string', 'max:16', 'in:'.implode(',', Task::PRIORITIES)], + 'due_date' => ['sometimes', 'nullable', 'date'], + 'estimated_minutes' => ['sometimes', 'nullable', 'integer', 'min:0'], + 'billable' => ['sometimes', 'boolean'], + 'rate' => ['sometimes', 'nullable', 'integer', 'min:0'], + ]; + } +} diff --git a/app/Http/Requests/UpdateTaskStatusRequest.php b/app/Http/Requests/UpdateTaskStatusRequest.php new file mode 100644 index 0000000..b67a31e --- /dev/null +++ b/app/Http/Requests/UpdateTaskStatusRequest.php @@ -0,0 +1,20 @@ +> */ + public function rules(): array + { + return [ + 'name' => ['sometimes', 'required', 'string', 'max:255'], + 'colour' => ['sometimes', 'nullable', 'string', 'max:16'], + 'position' => ['sometimes', 'integer', 'min:0'], + 'is_default' => ['sometimes', 'boolean'], + 'is_closed' => ['sometimes', 'boolean'], + ]; + } +} diff --git a/app/Http/Requests/UpdateTimeEntryRequest.php b/app/Http/Requests/UpdateTimeEntryRequest.php new file mode 100644 index 0000000..2a04ff1 --- /dev/null +++ b/app/Http/Requests/UpdateTimeEntryRequest.php @@ -0,0 +1,22 @@ +> */ + public function rules(): array + { + return [ + 'task_id' => ['sometimes', 'integer', 'min:1'], + 'started_at' => ['sometimes', 'nullable', 'date'], + 'ended_at' => ['sometimes', 'nullable', 'date', 'after_or_equal:started_at'], + 'duration_minutes' => ['sometimes', 'nullable', 'integer', 'min:0'], + 'description' => ['sometimes', 'nullable', 'string'], + 'billable' => ['sometimes', 'boolean'], + 'rate' => ['sometimes', 'nullable', 'integer', 'min:0'], + ]; + } +} diff --git a/app/Http/Resources/ProjectMemberResource.php b/app/Http/Resources/ProjectMemberResource.php new file mode 100644 index 0000000..9169159 --- /dev/null +++ b/app/Http/Resources/ProjectMemberResource.php @@ -0,0 +1,34 @@ + */ + public function toArray(Request $request): array + { + $member = $this->resource; + + return [ + 'id' => (int) $member->id, + 'company_id' => (int) $member->company_id, + 'project_id' => (int) $member->project_id, + 'user_id' => (int) $member->user_id, + 'rate' => $member->rate === null ? null : (int) $member->rate, + 'created_at' => $member->created_at?->toIso8601String(), + 'updated_at' => $member->updated_at?->toIso8601String(), + ]; + } +} diff --git a/app/Http/Resources/ProjectResource.php b/app/Http/Resources/ProjectResource.php new file mode 100644 index 0000000..574703a --- /dev/null +++ b/app/Http/Resources/ProjectResource.php @@ -0,0 +1,64 @@ +|null */ + private ?array $totals = null; + + /** + * Add the detail screen's totals, which the list deliberately leaves out. + * + * @param array $totals + */ + public function withTotals(array $totals): self + { + $this->totals = $totals; + + return $this; + } + + /** @return array */ + public function toArray(Request $request): array + { + $project = $this->resource; + + $data = [ + 'id' => (int) $project->id, + 'company_id' => (int) $project->company_id, + 'customer_id' => $project->customer_id === null ? null : (int) $project->customer_id, + 'name' => $project->name, + 'identifier' => $project->identifier, + 'description' => $project->description, + 'colour' => $project->colour, + 'status' => $project->status, + 'currency_id' => $project->currency_id === null ? null : (int) $project->currency_id, + 'default_rate' => $project->default_rate === null ? null : (int) $project->default_rate, + 'budget_minutes' => $project->budget_minutes === null ? null : (int) $project->budget_minutes, + 'due_date' => $project->due_date?->toDateString(), + 'creator_id' => $project->creator_id === null ? null : (int) $project->creator_id, + 'is_internal' => $project->isInternal(), + 'created_at' => $project->created_at?->toIso8601String(), + 'updated_at' => $project->updated_at?->toIso8601String(), + ]; + + if ($this->totals !== null) { + $data['totals'] = $this->totals; + } + + return $data; + } +} diff --git a/app/Http/Resources/TaskResource.php b/app/Http/Resources/TaskResource.php new file mode 100644 index 0000000..c692e13 --- /dev/null +++ b/app/Http/Resources/TaskResource.php @@ -0,0 +1,47 @@ + */ + public function toArray(Request $request): array + { + $task = $this->resource; + + return [ + 'id' => (int) $task->id, + 'company_id' => (int) $task->company_id, + 'project_id' => $task->project_id === null ? null : (int) $task->project_id, + 'customer_id' => $task->customer_id === null ? null : (int) $task->customer_id, + 'task_status_id' => (int) $task->task_status_id, + 'number' => (int) $task->number, + 'name' => $task->name, + 'description' => $task->description, + 'assignee_id' => $task->assignee_id === null ? null : (int) $task->assignee_id, + 'priority' => $task->priority, + 'due_date' => $task->due_date?->toDateString(), + 'estimated_minutes' => $task->estimated_minutes === null ? null : (int) $task->estimated_minutes, + 'billable' => (bool) $task->billable, + 'rate' => $task->rate === null ? null : (int) $task->rate, + 'board_position' => (string) $task->board_position, + 'closed_at' => $task->closed_at?->toIso8601String(), + 'creator_id' => $task->creator_id === null ? null : (int) $task->creator_id, + 'created_at' => $task->created_at?->toIso8601String(), + 'updated_at' => $task->updated_at?->toIso8601String(), + ]; + } +} diff --git a/app/Http/Resources/TaskStatusResource.php b/app/Http/Resources/TaskStatusResource.php new file mode 100644 index 0000000..a4a90c7 --- /dev/null +++ b/app/Http/Resources/TaskStatusResource.php @@ -0,0 +1,35 @@ + */ + public function toArray(Request $request): array + { + $status = $this->resource; + + return [ + 'id' => (int) $status->id, + 'company_id' => (int) $status->company_id, + 'name' => $status->name, + 'colour' => $status->colour, + 'position' => (int) $status->position, + 'is_default' => (bool) $status->is_default, + 'is_closed' => (bool) $status->is_closed, + 'created_at' => $status->created_at?->toIso8601String(), + 'updated_at' => $status->updated_at?->toIso8601String(), + ]; + } +} diff --git a/app/Http/Resources/TimeEntryResource.php b/app/Http/Resources/TimeEntryResource.php new file mode 100644 index 0000000..06c7023 --- /dev/null +++ b/app/Http/Resources/TimeEntryResource.php @@ -0,0 +1,46 @@ + */ + public function toArray(Request $request): array + { + $entry = $this->resource; + + return [ + 'id' => (int) $entry->id, + 'company_id' => (int) $entry->company_id, + 'task_id' => (int) $entry->task_id, + 'project_id' => $entry->project_id === null ? null : (int) $entry->project_id, + 'user_id' => (int) $entry->user_id, + 'started_at' => $entry->started_at?->toIso8601String(), + 'ended_at' => $entry->ended_at?->toIso8601String(), + 'duration_minutes' => (int) $entry->duration_minutes, + 'description' => $entry->description, + 'billable' => (bool) $entry->billable, + 'rate' => (int) $entry->rate, + 'amount' => (int) $entry->amount, + 'currency_id' => $entry->currency_id === null ? null : (int) $entry->currency_id, + 'is_running' => $entry->isRunning(), + 'invoice_id' => $entry->invoice_id === null ? null : (int) $entry->invoice_id, + 'invoice_item_id' => $entry->invoice_item_id === null ? null : (int) $entry->invoice_item_id, + 'invoiced_at' => $entry->invoiced_at?->toIso8601String(), + 'created_at' => $entry->created_at?->toIso8601String(), + 'updated_at' => $entry->updated_at?->toIso8601String(), + ]; + } +} diff --git a/app/Providers/TasksProjectsServiceProvider.php b/app/Providers/TasksProjectsServiceProvider.php index 638caf4..9474d4e 100644 --- a/app/Providers/TasksProjectsServiceProvider.php +++ b/app/Providers/TasksProjectsServiceProvider.php @@ -4,10 +4,13 @@ namespace Modules\TasksProjects\Providers; +use Illuminate\Contracts\Debug\ExceptionHandler; use Illuminate\Contracts\Foundation\Application; +use Illuminate\Foundation\Exceptions\Handler; use InvoiceShelf\Modules\Contracts\DataCleanup; use InvoiceShelf\Modules\Contracts\Host\SettingsStore; use InvoiceShelf\Modules\Support\ModuleServiceProvider; +use Modules\TasksProjects\Http\DomainExceptionRenderer; use Modules\TasksProjects\Lifecycle\DataCleanup as TasksProjectsDataCleanup; use Modules\TasksProjects\Support\ModuleRegistration; @@ -37,6 +40,23 @@ public function boot(): void ModuleRegistration::register($modulePath); + $this->registerExceptionRendering(); + $this->loadRoutesFrom($modulePath.'/routes/api.php'); } + + /** + * Map every broken domain rule to its HTTP response in one place. + * + * The services throw a TasksProjectsException rather than returning an + * error, so a single renderable keeps the controllers free of try/catch. + */ + private function registerExceptionRendering(): void + { + $handler = $this->app->make(ExceptionHandler::class); + + if ($handler instanceof Handler) { + DomainExceptionRenderer::register($handler); + } + } } diff --git a/app/Support/Authorizes.php b/app/Support/Authorizes.php new file mode 100644 index 0000000..0c5d52d --- /dev/null +++ b/app/Support/Authorizes.php @@ -0,0 +1,41 @@ +authorization->allows($context->userId, $context->companyId, self::id($ability)); + } + + /** @throws AuthorizationException when the user lacks the ability in this company */ + public function require(CompanyContext $context, string $ability): void + { + if (! $this->allows($context, $ability)) { + throw new AuthorizationException('This action requires the '.self::id($ability).' ability.'); + } + } +} diff --git a/app/Support/CompanyContext.php b/app/Support/CompanyContext.php new file mode 100644 index 0000000..81b127f --- /dev/null +++ b/app/Support/CompanyContext.php @@ -0,0 +1,28 @@ +header('company'), + (int) $request->user()->getAuthIdentifier(), + ); + } +} diff --git a/routes/api.php b/routes/api.php index cb7d514..87fd5df 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,7 +1,72 @@ middleware(['api', 'auth:sanctum', 'company', 'bouncer'])->group(function (): void { - // Routes are added in the backend milestone (M3). See specs/tasks-projects.md "API surface". + Route::get('projects', [ProjectsController::class, 'index'])->name('tasks-projects.projects.index'); + Route::post('projects', [ProjectsController::class, 'store'])->name('tasks-projects.projects.store'); + Route::get('projects/{id}', [ProjectsController::class, 'show'])->name('tasks-projects.projects.show'); + Route::put('projects/{id}', [ProjectsController::class, 'update'])->name('tasks-projects.projects.update'); + Route::delete('projects/{id}', [ProjectsController::class, 'destroy'])->name('tasks-projects.projects.destroy'); + Route::post('projects/{id}/archive', [ProjectsController::class, 'archive'])->name('tasks-projects.projects.archive'); + Route::post('projects/{id}/unarchive', [ProjectsController::class, 'unarchive'])->name('tasks-projects.projects.unarchive'); + + Route::get('projects/{id}/members', [ProjectMembersController::class, 'index'])->name('tasks-projects.project-members.index'); + Route::post('projects/{id}/members', [ProjectMembersController::class, 'store'])->name('tasks-projects.project-members.store'); + Route::delete('projects/{id}/members/{userId}', [ProjectMembersController::class, 'destroy'])->name('tasks-projects.project-members.destroy'); + + Route::get('members', MembersController::class)->name('tasks-projects.members.index'); + + Route::get('tasks', [TasksController::class, 'index'])->name('tasks-projects.tasks.index'); + Route::post('tasks', [TasksController::class, 'store'])->name('tasks-projects.tasks.store'); + Route::get('tasks/{id}', [TasksController::class, 'show'])->name('tasks-projects.tasks.show'); + Route::put('tasks/{id}', [TasksController::class, 'update'])->name('tasks-projects.tasks.update'); + Route::delete('tasks/{id}', [TasksController::class, 'destroy'])->name('tasks-projects.tasks.destroy'); + Route::post('tasks/{id}/move', [TasksController::class, 'move'])->name('tasks-projects.tasks.move'); + + Route::get('board', BoardController::class)->name('tasks-projects.board.index'); + + Route::get('task-statuses', [TaskStatusesController::class, 'index'])->name('tasks-projects.task-statuses.index'); + Route::post('task-statuses', [TaskStatusesController::class, 'store'])->name('tasks-projects.task-statuses.store'); + Route::post('task-statuses/reorder', [TaskStatusesController::class, 'reorder'])->name('tasks-projects.task-statuses.reorder'); + Route::put('task-statuses/{id}', [TaskStatusesController::class, 'update'])->name('tasks-projects.task-statuses.update'); + Route::delete('task-statuses/{id}', [TaskStatusesController::class, 'destroy'])->name('tasks-projects.task-statuses.destroy'); + + Route::get('time-entries', [TimeEntriesController::class, 'index'])->name('tasks-projects.time-entries.index'); + Route::post('time-entries', [TimeEntriesController::class, 'store'])->name('tasks-projects.time-entries.store'); + Route::get('time-entries/{id}', [TimeEntriesController::class, 'show'])->name('tasks-projects.time-entries.show'); + Route::put('time-entries/{id}', [TimeEntriesController::class, 'update'])->name('tasks-projects.time-entries.update'); + Route::delete('time-entries/{id}', [TimeEntriesController::class, 'destroy'])->name('tasks-projects.time-entries.destroy'); + + Route::get('timer', [TimerController::class, 'show'])->name('tasks-projects.timer.show'); + Route::delete('timer', [TimerController::class, 'destroy'])->name('tasks-projects.timer.destroy'); + Route::post('timer/start', [TimerController::class, 'start'])->name('tasks-projects.timer.start'); + Route::post('timer/stop', [TimerController::class, 'stop'])->name('tasks-projects.timer.stop'); + + Route::get('billing/unbilled', [BillingController::class, 'unbilled'])->name('tasks-projects.billing.unbilled'); + Route::post('billing/prepare', [BillingController::class, 'prepare'])->name('tasks-projects.billing.prepare'); + Route::post('billing/confirm', [BillingController::class, 'confirm'])->name('tasks-projects.billing.confirm'); + + Route::get('reports/summary', [ReportsController::class, 'summary'])->name('tasks-projects.reports.summary'); + + Route::get('settings', SettingsController::class)->name('tasks-projects.settings.show'); }); diff --git a/tests/Feature/BillingApiTest.php b/tests/Feature/BillingApiTest.php new file mode 100644 index 0000000..250bc46 --- /dev/null +++ b/tests/Feature/BillingApiTest.php @@ -0,0 +1,320 @@ +companyData->withMember(self::COMPANY, self::DEFAULT_USER, 'Ada Lovelace'); + $this->website = $this->makeProject(self::COMPANY, [ + 'name' => 'Website', + 'customer_id' => self::CUSTOMER, + 'currency_id' => self::CURRENCY, + ]); + $this->landing = $this->task('Landing page', $this->website); + $this->pricing = $this->task('Pricing page', $this->website); + } + + public function test_unbilled_collects_the_customers_billable_time_and_groups_it_four_ways(): void + { + $first = $this->entry($this->landing, 60, '2026-09-01'); + $second = $this->entry($this->pricing, 90, '2026-09-02'); + + $response = $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/billing/unbilled?customer_id='.self::CUSTOMER); + + $response->assertOk(); + $response->assertJsonPath('data.customer_id', self::CUSTOMER); + $response->assertJsonPath('data.minutes', 150); + $response->assertJsonPath('data.entry_ids', [(int) $first->id, (int) $second->id]); + $response->assertJsonPath('data.currencies', [ + ['currency_id' => self::CURRENCY, 'minutes' => 150, 'amount' => 15000], + ]); + $response->assertJsonPath('data.groups.task.0.label', 'Landing page'); + $response->assertJsonPath('data.groups.project.0.label', 'Website'); + $response->assertJsonPath('data.groups.member.0.label', 'Ada Lovelace'); + $response->assertJsonPath('data.groups.summary.0.minutes', 150); + } + + public function test_unbilled_skips_stamped_non_billable_running_and_internal_time(): void + { + $open = $this->entry($this->landing, 60, '2026-09-01'); + $this->entry($this->landing, 60, '2026-09-02', ['billable' => false]); + $this->entry($this->landing, 0, '2026-09-03', ['running_user_id' => self::DEFAULT_USER, 'ended_at' => null]); + $this->entry($this->landing, 60, '2026-09-04', ['invoice_id' => 77, 'invoice_item_id' => 101]); + $this->companyData->withInvoices(self::COMPANY, 77); + + $internal = $this->makeProject(self::COMPANY, ['name' => 'Internal tooling', 'customer_id' => null]); + $stray = $this->makeTask(self::COMPANY, ['name' => 'Stray', 'project_id' => $internal->id, 'customer_id' => self::CUSTOMER]); + $this->entry($stray, 60, '2026-09-05', ['project_id' => $internal->id]); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/billing/unbilled?customer_id='.self::CUSTOMER) + ->assertOk() + ->assertJsonPath('data.entry_ids', [(int) $open->id]); + } + + public function test_time_whose_invoice_vanished_from_the_host_comes_back_to_the_unbilled_list(): void + { + $orphan = $this->entry($this->landing, 60, '2026-09-01', ['invoice_id' => 88, 'invoice_item_id' => 101]); + $this->companyData->withInvoices(self::COMPANY, 77); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/billing/unbilled?customer_id='.self::CUSTOMER) + ->assertOk() + ->assertJsonPath('data.entry_ids', [(int) $orphan->id]); + } + + public function test_unbilled_honours_the_date_range_and_needs_a_customer(): void + { + $this->entry($this->landing, 60, '2026-09-01'); + $inside = $this->entry($this->landing, 60, '2026-09-10'); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/billing/unbilled?customer_id='.self::CUSTOMER.'&from=2026-09-05&to=2026-09-15') + ->assertOk() + ->assertJsonPath('data.entry_ids', [(int) $inside->id]); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/billing/unbilled') + ->assertStatus(422) + ->assertJsonValidationErrors(['customer_id']); + } + + public function test_prepare_returns_the_host_invoice_body_for_every_grouping(): void + { + Carbon::setTestNow('2026-09-15 08:00:00'); + $first = $this->entry($this->landing, 60, '2026-09-01'); + $second = $this->entry($this->pricing, 90, '2026-09-02'); + $ids = [(int) $first->id, (int) $second->id]; + + $byTask = $this->prepare($ids, 'task'); + + $byTask->assertOk(); + $byTask->assertJsonPath('data.invoice_date', '2026-09-15'); + $byTask->assertJsonPath('data.customer_id', self::CUSTOMER); + $byTask->assertJsonPath('data.currency_id', self::CURRENCY); + $byTask->assertJsonPath('data.sub_total', 15000); + $byTask->assertJsonPath('data.total', 15000); + $byTask->assertJsonPath('data.items', [ + ['name' => 'Landing page', 'description' => null, 'quantity' => 1.0, 'price' => self::RATE, 'total' => 6000], + ['name' => 'Pricing page', 'description' => null, 'quantity' => 1.5, 'price' => self::RATE, 'total' => 9000], + ]); + $byTask->assertJsonPath('data.groups', [ + ['entry_ids' => [(int) $first->id]], + ['entry_ids' => [(int) $second->id]], + ]); + + $this->prepare($ids, 'project')->assertJsonPath('data.items.0.name', 'Website')->assertJsonPath('data.items.0.quantity', 2.5); + $this->prepare($ids, 'member')->assertJsonPath('data.items.0.name', 'Ada Lovelace'); + $this->prepare($ids, 'summary') + ->assertJsonPath('data.items.0.name', 'Time') + ->assertJsonPath('data.items.0.quantity', 2.5) + ->assertJsonPath('data.groups', [['entry_ids' => $ids]]); + } + + public function test_prepare_refuses_a_grouping_it_does_not_know_and_an_empty_selection(): void + { + $entry = $this->entry($this->landing, 60, '2026-09-01'); + + $this->asCompany(self::COMPANY) + ->postJson('/api/v1/tasks-projects/billing/prepare', ['entry_ids' => [(int) $entry->id], 'grouping' => 'weekday']) + ->assertStatus(422) + ->assertJsonValidationErrors(['grouping']); + + $this->asCompany(self::COMPANY) + ->postJson('/api/v1/tasks-projects/billing/prepare', ['entry_ids' => [], 'grouping' => 'task']) + ->assertStatus(422) + ->assertJsonValidationErrors(['entry_ids']); + } + + public function test_prepare_refuses_a_selection_spanning_two_customers(): void + { + $ours = $this->entry($this->landing, 60, '2026-09-01'); + $theirs = $this->entry($this->task('Theirs', null, 43), 60, '2026-09-02'); + + $this->prepare([(int) $ours->id, (int) $theirs->id], 'task') + ->assertStatus(422) + ->assertJsonPath('error', 'mixed_billing_selection'); + } + + public function test_prepare_refuses_an_entry_of_another_company(): void + { + $foreignTask = $this->makeTask(self::OTHER_COMPANY, ['customer_id' => self::CUSTOMER]); + $foreign = $this->makeEntry(self::OTHER_COMPANY, (int) $foreignTask->id); + + $this->prepare([(int) $foreign->id], 'task') + ->assertStatus(422) + ->assertJsonPath('error', 'unknown_time_entries'); + } + + public function test_prepare_refuses_non_billable_time_and_time_with_no_customer(): void + { + $free = $this->entry($this->landing, 60, '2026-09-01', ['billable' => false]); + + $this->prepare([(int) $free->id], 'task') + ->assertStatus(422) + ->assertJsonPath('error', 'not_billable'); + + $internal = $this->makeProject(self::COMPANY, ['name' => 'Internal tooling', 'customer_id' => null]); + $task = $this->makeTask(self::COMPANY, ['name' => 'Internal work', 'project_id' => $internal->id]); + $entry = $this->entry($task, 60, '2026-09-02', ['project_id' => $internal->id]); + + $this->prepare([(int) $entry->id], 'task') + ->assertStatus(422) + ->assertJsonPath('error', 'not_billable'); + } + + public function test_prepare_refuses_time_that_is_already_on_a_live_invoice(): void + { + $entry = $this->entry($this->landing, 60, '2026-09-01', ['invoice_id' => 77, 'invoice_item_id' => 101]); + $this->companyData->withInvoices(self::COMPANY, 77); + + $this->prepare([(int) $entry->id], 'task') + ->assertStatus(422) + ->assertJsonPath('error', 'entries_already_invoiced'); + } + + public function test_confirm_stamps_the_entries_and_is_safe_to_repeat(): void + { + $first = $this->entry($this->landing, 60, '2026-09-01'); + $second = $this->entry($this->pricing, 60, '2026-09-02'); + $payload = [ + 'invoice_id' => 77, + 'items' => [ + ['invoice_item_id' => 101, 'entry_ids' => [(int) $first->id]], + ['invoice_item_id' => 102, 'entry_ids' => [(int) $second->id]], + ], + ]; + + $this->asCompany(self::COMPANY) + ->postJson('/api/v1/tasks-projects/billing/confirm', $payload) + ->assertOk() + ->assertExactJson(['stamped' => 2]); + + $this->asCompany(self::COMPANY) + ->postJson('/api/v1/tasks-projects/billing/confirm', $payload) + ->assertOk() + ->assertExactJson(['stamped' => 0]); + + self::assertSame(77, $first->fresh()?->invoice_id); + self::assertSame(101, $first->fresh()?->invoice_item_id); + + $this->companyData->withInvoices(self::COMPANY, 77); + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/billing/unbilled?customer_id='.self::CUSTOMER) + ->assertJsonPath('data.entry_ids', []); + } + + public function test_confirm_refuses_an_entry_that_belongs_to_another_invoice(): void + { + $entry = $this->entry($this->landing, 60, '2026-09-01', ['invoice_id' => 77, 'invoice_item_id' => 101]); + + $this->asCompany(self::COMPANY) + ->postJson('/api/v1/tasks-projects/billing/confirm', [ + 'invoice_id' => 78, + 'items' => [['invoice_item_id' => 201, 'entry_ids' => [(int) $entry->id]]], + ]) + ->assertStatus(422) + ->assertJsonPath('error', 'entries_already_invoiced'); + } + + public function test_confirm_refuses_an_entry_of_another_company_and_stamps_nothing(): void + { + $ours = $this->entry($this->landing, 60, '2026-09-01'); + $foreignTask = $this->makeTask(self::OTHER_COMPANY); + $foreign = $this->makeEntry(self::OTHER_COMPANY, (int) $foreignTask->id); + + $this->asCompany(self::COMPANY) + ->postJson('/api/v1/tasks-projects/billing/confirm', [ + 'invoice_id' => 77, + 'items' => [['invoice_item_id' => 101, 'entry_ids' => [(int) $ours->id, (int) $foreign->id]]], + ]) + ->assertStatus(422) + ->assertJsonPath('error', 'unknown_time_entries'); + + self::assertNull($ours->fresh()?->invoice_id); + } + + public function test_billing_needs_the_invoice_tasks_ability(): void + { + $entry = $this->entry($this->landing, 60, '2026-09-01'); + $this->authorization->deny(Authorizes::id(Abilities::INVOICE_TASKS)); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/billing/unbilled?customer_id='.self::CUSTOMER) + ->assertForbidden(); + + $this->prepare([(int) $entry->id], 'task')->assertForbidden(); + + $this->asCompany(self::COMPANY) + ->postJson('/api/v1/tasks-projects/billing/confirm', [ + 'invoice_id' => 77, + 'items' => [['invoice_item_id' => 101, 'entry_ids' => [(int) $entry->id]]], + ]) + ->assertForbidden(); + } + + /** @param list $entryIds */ + private function prepare(array $entryIds, string $grouping): TestResponse + { + return $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/billing/prepare', [ + 'entry_ids' => $entryIds, + 'grouping' => $grouping, + ]); + } + + private function task(string $name, ?Project $project, int $customerId = self::CUSTOMER): Task + { + return $this->makeTask(self::COMPANY, [ + 'name' => $name, + 'project_id' => $project?->id, + 'customer_id' => $project === null ? $customerId : $project->customer_id, + ]); + } + + /** @param array $attributes */ + private function entry(Task $task, int $minutes, string $day, array $attributes = []): TimeEntry + { + return $this->makeEntry(self::COMPANY, (int) $task->id, $attributes + [ + 'project_id' => $task->project_id, + 'user_id' => self::DEFAULT_USER, + 'started_at' => Carbon::parse($day.' 09:00:00'), + 'ended_at' => Carbon::parse($day.' 09:00:00')->addMinutes($minutes), + 'duration_minutes' => $minutes, + 'rate' => self::RATE, + 'amount' => (int) round($minutes / 60 * self::RATE), + 'currency_id' => self::CURRENCY, + ]); + } +} diff --git a/tests/Feature/MembersApiTest.php b/tests/Feature/MembersApiTest.php new file mode 100644 index 0000000..ede022d --- /dev/null +++ b/tests/Feature/MembersApiTest.php @@ -0,0 +1,49 @@ +companyData + ->withMember(self::COMPANY, 7, 'Ada Lovelace') + ->withMember(self::COMPANY, 8, 'Grace Hopper') + ->withMember(self::OTHER_COMPANY, 9, 'Someone Else'); + + $response = $this->asCompany(self::COMPANY)->getJson('/api/v1/tasks-projects/members'); + + $response->assertOk(); + $response->assertJsonCount(2, 'data'); + $response->assertJsonPath('data.0.id', 7); + $response->assertJsonPath('data.0.name', 'Ada Lovelace'); + $response->assertJsonPath('data.1.name', 'Grace Hopper'); + } + + public function test_a_company_without_members_returns_an_empty_list(): void + { + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/members') + ->assertOk() + ->assertExactJson(['data' => []]); + } + + public function test_the_picker_needs_the_project_view_ability(): void + { + $this->authorization->deny(Authorizes::id(Abilities::VIEW_PROJECT)); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/members') + ->assertForbidden(); + } +} diff --git a/tests/Feature/ProjectsApiTest.php b/tests/Feature/ProjectsApiTest.php new file mode 100644 index 0000000..8d91684 --- /dev/null +++ b/tests/Feature/ProjectsApiTest.php @@ -0,0 +1,266 @@ +makeProject(self::COMPANY, ['name' => 'Alpha']); + $this->makeProject(self::COMPANY, ['name' => 'Beta']); + $this->makeProject(self::OTHER_COMPANY, ['name' => 'Someone else']); + + $response = $this->asCompany(self::COMPANY)->getJson('/api/v1/tasks-projects/projects?limit=1'); + + $response->assertOk(); + $response->assertJsonPath('meta.total', 2); + $response->assertJsonPath('meta.per_page', 1); + $response->assertJsonCount(1, 'data'); + $response->assertJsonPath('data.0.name', 'Alpha'); + } + + public function test_the_list_filters_by_status_customer_member_and_text(): void + { + $website = $this->makeProject(self::COMPANY, ['name' => 'Website', 'customer_id' => self::CUSTOMER]); + $this->makeProject(self::COMPANY, ['name' => 'Internal tooling']); + $archived = $this->makeProject(self::COMPANY, ['name' => 'Old site', 'status' => Project::STATUS_ARCHIVED]); + $this->makeMember(self::COMPANY, (int) $website->id, 12); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/projects?status='.Project::STATUS_ARCHIVED) + ->assertJsonPath('meta.total', 1) + ->assertJsonPath('data.0.id', (int) $archived->id); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/projects?customer_id='.self::CUSTOMER) + ->assertJsonPath('meta.total', 1) + ->assertJsonPath('data.0.id', (int) $website->id); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/projects?member_id=12') + ->assertJsonPath('meta.total', 1) + ->assertJsonPath('data.0.id', (int) $website->id); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/projects?search=tooling') + ->assertJsonPath('meta.total', 1) + ->assertJsonPath('data.0.name', 'Internal tooling'); + } + + public function test_it_creates_a_project_for_the_header_company_and_stamps_the_creator(): void + { + $response = $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/projects', [ + 'name' => 'Website redesign', + 'customer_id' => self::CUSTOMER, + 'identifier' => 'WEB', + 'default_rate' => 12000, + 'budget_minutes' => 600, + 'due_date' => '2026-12-31', + ]); + + $response->assertCreated(); + $response->assertJsonPath('data.company_id', self::COMPANY); + $response->assertJsonPath('data.creator_id', self::DEFAULT_USER); + $response->assertJsonPath('data.status', Project::STATUS_ACTIVE); + $response->assertJsonPath('data.default_rate', 12000); + $response->assertJsonPath('data.due_date', '2026-12-31'); + $response->assertJsonPath('data.is_internal', false); + + self::assertSame(1, Project::query()->forCompany(self::COMPANY)->count()); + } + + public function test_a_project_without_a_customer_is_internal(): void + { + $this->asCompany(self::COMPANY) + ->postJson('/api/v1/tasks-projects/projects', ['name' => 'Internal tooling']) + ->assertCreated() + ->assertJsonPath('data.is_internal', true); + } + + public function test_it_rejects_a_project_without_a_name(): void + { + $this->asCompany(self::COMPANY) + ->postJson('/api/v1/tasks-projects/projects', ['default_rate' => -1]) + ->assertStatus(422) + ->assertJsonValidationErrors(['name', 'default_rate']); + } + + public function test_the_detail_carries_the_totals_the_list_leaves_out(): void + { + $project = $this->makeProject(self::COMPANY, ['customer_id' => self::CUSTOMER, 'currency_id' => 3]); + $status = $this->makeStatus(self::COMPANY, ['is_closed' => true]); + $task = $this->makeTask(self::COMPANY, ['project_id' => $project->id, 'task_status_id' => $status->id, 'closed_at' => now()]); + $this->makeEntry(self::COMPANY, (int) $task->id, ['project_id' => $project->id, 'duration_minutes' => 90, 'amount' => 15000]); + + $response = $this->asCompany(self::COMPANY)->getJson('/api/v1/tasks-projects/projects/'.$project->id); + + $response->assertOk(); + $response->assertJsonPath('data.totals.tasks.total', 1); + $response->assertJsonPath('data.totals.tasks.closed', 1); + $response->assertJsonPath('data.totals.logged_minutes', 90); + $response->assertJsonPath('data.totals.billable_amount', 15000); + $response->assertJsonPath('data.totals.unbilled_amount', 15000); + $response->assertJsonPath('data.totals.currency_id', 3); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/projects') + ->assertJsonMissingPath('data.0.totals'); + } + + public function test_it_updates_a_project(): void + { + $project = $this->makeProject(self::COMPANY); + + $this->asCompany(self::COMPANY) + ->putJson('/api/v1/tasks-projects/projects/'.$project->id, ['name' => 'Renamed', 'colour' => '#ff0000']) + ->assertOk() + ->assertJsonPath('data.name', 'Renamed') + ->assertJsonPath('data.colour', '#ff0000'); + } + + public function test_archive_and_unarchive_flip_the_status(): void + { + $project = $this->makeProject(self::COMPANY); + + $this->asCompany(self::COMPANY) + ->postJson('/api/v1/tasks-projects/projects/'.$project->id.'/archive') + ->assertOk() + ->assertJsonPath('data.status', Project::STATUS_ARCHIVED); + + $this->asCompany(self::COMPANY) + ->postJson('/api/v1/tasks-projects/projects/'.$project->id.'/unarchive') + ->assertOk() + ->assertJsonPath('data.status', Project::STATUS_ACTIVE); + } + + public function test_it_deletes_a_project(): void + { + $project = $this->makeProject(self::COMPANY); + + $this->asCompany(self::COMPANY) + ->deleteJson('/api/v1/tasks-projects/projects/'.$project->id) + ->assertOk() + ->assertJson(['success' => true]); + + self::assertSame(0, Project::query()->forCompany(self::COMPANY)->count()); + } + + public function test_a_project_with_invoiced_time_refuses_to_be_deleted(): void + { + $project = $this->makeProject(self::COMPANY, ['customer_id' => self::CUSTOMER]); + $task = $this->makeTask(self::COMPANY, ['project_id' => $project->id]); + $this->makeEntry(self::COMPANY, (int) $task->id, [ + 'project_id' => $project->id, + 'invoice_id' => 77, + 'invoice_item_id' => 88, + ]); + + $this->asCompany(self::COMPANY) + ->deleteJson('/api/v1/tasks-projects/projects/'.$project->id) + ->assertStatus(422) + ->assertJsonPath('error', 'project_in_use'); + } + + public function test_attaching_a_member_needs_the_user_to_be_a_company_member(): void + { + $project = $this->makeProject(self::COMPANY); + $this->companyData->withMember(self::COMPANY, 12, 'Ada Lovelace'); + + $this->asCompany(self::COMPANY) + ->postJson('/api/v1/tasks-projects/projects/'.$project->id.'/members', ['user_id' => 99]) + ->assertStatus(422) + ->assertJsonValidationErrors(['user_id']); + + $this->asCompany(self::COMPANY) + ->postJson('/api/v1/tasks-projects/projects/'.$project->id.'/members', ['user_id' => 12, 'rate' => 9000]) + ->assertCreated() + ->assertJsonPath('data.user_id', 12) + ->assertJsonPath('data.rate', 9000); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/projects/'.$project->id.'/members') + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.user_id', 12); + } + + public function test_detaching_a_member_leaves_their_time_alone(): void + { + $project = $this->makeProject(self::COMPANY); + $this->makeMember(self::COMPANY, (int) $project->id, 12); + $task = $this->makeTask(self::COMPANY, ['project_id' => $project->id]); + $entry = $this->makeEntry(self::COMPANY, (int) $task->id, ['project_id' => $project->id, 'user_id' => 12]); + + $this->asCompany(self::COMPANY) + ->deleteJson('/api/v1/tasks-projects/projects/'.$project->id.'/members/12') + ->assertOk(); + + self::assertSame(0, ProjectMember::query()->forCompany(self::COMPANY)->count()); + self::assertNotNull(TimeEntry::query()->find($entry->id)); + } + + public function test_a_project_of_another_company_is_not_found(): void + { + $project = $this->makeProject(self::OTHER_COMPANY); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/projects/'.$project->id) + ->assertNotFound(); + + $this->asCompany(self::COMPANY) + ->putJson('/api/v1/tasks-projects/projects/'.$project->id, ['name' => 'Mine now']) + ->assertNotFound(); + + $this->asCompany(self::COMPANY) + ->deleteJson('/api/v1/tasks-projects/projects/'.$project->id) + ->assertNotFound(); + } + + public function test_every_action_refuses_without_its_ability(): void + { + $project = $this->makeProject(self::COMPANY); + $this->companyData->withMember(self::COMPANY, 12, 'Ada Lovelace'); + + $this->authorization->deny( + Authorizes::id(Abilities::VIEW_PROJECT), + Authorizes::id(Abilities::CREATE_PROJECT), + Authorizes::id(Abilities::EDIT_PROJECT), + Authorizes::id(Abilities::DELETE_PROJECT), + ); + + $this->asCompany(self::COMPANY)->getJson('/api/v1/tasks-projects/projects')->assertForbidden(); + $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/projects', ['name' => 'Nope'])->assertForbidden(); + $this->asCompany(self::COMPANY)->getJson('/api/v1/tasks-projects/projects/'.$project->id)->assertForbidden(); + $this->asCompany(self::COMPANY)->putJson('/api/v1/tasks-projects/projects/'.$project->id, ['name' => 'Nope'])->assertForbidden(); + $this->asCompany(self::COMPANY)->deleteJson('/api/v1/tasks-projects/projects/'.$project->id)->assertForbidden(); + $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/projects/'.$project->id.'/archive')->assertForbidden(); + $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/projects/'.$project->id.'/members', ['user_id' => 12])->assertForbidden(); + } + + public function test_the_ability_is_checked_for_the_header_company_and_the_authenticated_user(): void + { + $this->asCompany(self::COMPANY)->getJson('/api/v1/tasks-projects/projects')->assertOk(); + + self::assertSame([ + 'user_id' => self::DEFAULT_USER, + 'company_id' => self::COMPANY, + 'ability' => 'tasks-projects:view-project', + 'resource' => null, + ], $this->authorization->checks[0]); + } +} diff --git a/tests/Feature/ReportsApiTest.php b/tests/Feature/ReportsApiTest.php new file mode 100644 index 0000000..e113429 --- /dev/null +++ b/tests/Feature/ReportsApiTest.php @@ -0,0 +1,188 @@ +companyData + ->withMember(self::COMPANY, self::DEFAULT_USER, 'Ada Lovelace') + ->withMember(self::COMPANY, self::OTHER_USER, 'Grace Hopper'); + + $this->website = $this->makeProject(self::COMPANY, [ + 'name' => 'Website', + 'customer_id' => self::CUSTOMER, + 'currency_id' => 3, + ]); + $this->landing = $this->makeTask(self::COMPANY, [ + 'name' => 'Landing page', + 'project_id' => $this->website->id, + 'customer_id' => self::CUSTOMER, + ]); + } + + public function test_the_summary_totals_per_currency_and_splits_billable_from_the_rest(): void + { + $this->entry(60, '2026-09-01', 6000); + $this->entry(30, '2026-09-02', 3000, ['invoice_id' => 77, 'invoice_item_id' => 101]); + $this->entry(45, '2026-09-03', 0, ['billable' => false]); + $this->entry(60, '2026-09-04', 9000, ['currency_id' => 4]); + + $response = $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/reports/summary?from=2026-09-01&to=2026-09-30'); + + $response->assertOk(); + $response->assertJsonPath('data.from', '2026-09-01'); + $response->assertJsonPath('data.to', '2026-09-30'); + $response->assertJsonPath('data.totals', [ + [ + 'currency_id' => 3, + 'minutes' => 135, + 'amount' => 9000, + 'billable_minutes' => 90, + 'billable_amount' => 9000, + 'unbilled_amount' => 6000, + ], + [ + 'currency_id' => 4, + 'minutes' => 60, + 'amount' => 9000, + 'billable_minutes' => 60, + 'billable_amount' => 9000, + 'unbilled_amount' => 9000, + ], + ]); + $response->assertJsonPath('data.by_billable.0.billable', true); + $response->assertJsonPath('data.by_billable.0.minutes', 90); + $response->assertJsonPath('data.by_billable.1.billable', false); + $response->assertJsonPath('data.by_billable.1.minutes', 45); + } + + public function test_the_summary_breaks_down_by_project_member_and_customer(): void + { + $this->entry(60, '2026-09-01', 6000); + $this->entry(120, '2026-09-02', 12000, ['user_id' => self::OTHER_USER]); + + $response = $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/reports/summary?from=2026-09-01&to=2026-09-30'); + + $response->assertJsonPath('data.by_project.0.label', 'Website'); + $response->assertJsonPath('data.by_project.0.minutes', 180); + $response->assertJsonPath('data.by_member.0.label', 'Ada Lovelace'); + $response->assertJsonPath('data.by_member.1.label', 'Grace Hopper'); + $response->assertJsonPath('data.by_customer.0.customer_id', self::CUSTOMER); + $response->assertJsonPath('data.by_customer.0.minutes', 180); + } + + public function test_the_range_leaves_out_what_falls_outside_it(): void + { + $this->entry(60, '2026-08-31', 6000); + $this->entry(30, '2026-09-10', 3000); + $this->entry(60, '2026-10-01', 6000); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/reports/summary?from=2026-09-01&to=2026-09-30') + ->assertJsonPath('data.totals.0.minutes', 30); + } + + public function test_the_range_defaults_to_the_current_month(): void + { + Carbon::setTestNow('2026-09-15 12:00:00'); + $this->entry(60, '2026-08-20', 6000); + $this->entry(30, '2026-09-10', 3000); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/reports/summary') + ->assertOk() + ->assertJsonPath('data.from', '2026-09-01') + ->assertJsonPath('data.to', '2026-09-15') + ->assertJsonPath('data.totals.0.minutes', 30); + } + + public function test_a_viewer_with_only_their_own_time_reports_on_their_own_time(): void + { + $this->entry(60, '2026-09-01', 6000); + $this->entry(120, '2026-09-02', 12000, ['user_id' => self::OTHER_USER]); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/reports/summary?from=2026-09-01&to=2026-09-30') + ->assertJsonPath('data.totals.0.minutes', 180); + + $this->authorization->deny(Authorizes::id(Abilities::VIEW_ALL_TIME)); + + $response = $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/reports/summary?from=2026-09-01&to=2026-09-30'); + + $response->assertJsonPath('data.totals.0.minutes', 60); + $response->assertJsonPath('data.by_member', [[ + 'user_id' => self::DEFAULT_USER, + 'label' => 'Ada Lovelace', + 'currency_id' => 3, + 'minutes' => 60, + 'amount' => 6000, + 'billable_minutes' => 60, + 'billable_amount' => 6000, + 'unbilled_amount' => 6000, + ]]); + } + + public function test_the_summary_never_reaches_another_company(): void + { + $this->entry(60, '2026-09-01', 6000); + $foreign = $this->makeTask(self::OTHER_COMPANY); + $this->makeEntry(self::OTHER_COMPANY, (int) $foreign->id, ['duration_minutes' => 600, 'amount' => 60000]); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/reports/summary?from=2026-09-01&to=2026-09-30') + ->assertJsonPath('data.totals.0.minutes', 60); + } + + public function test_the_summary_needs_the_own_time_ability(): void + { + $this->authorization->deny(Authorizes::id(Abilities::VIEW_OWN_TIME)); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/reports/summary') + ->assertForbidden(); + } + + /** @param array $attributes */ + private function entry(int $minutes, string $day, int $amount, array $attributes = []): TimeEntry + { + return $this->makeEntry(self::COMPANY, (int) $this->landing->id, $attributes + [ + 'project_id' => $this->website->id, + 'user_id' => self::DEFAULT_USER, + 'started_at' => Carbon::parse($day.' 09:00:00'), + 'ended_at' => Carbon::parse($day.' 09:00:00')->addMinutes($minutes), + 'duration_minutes' => $minutes, + 'rate' => 6000, + 'amount' => $amount, + 'currency_id' => 3, + ]); + } +} diff --git a/tests/Feature/SettingsApiTest.php b/tests/Feature/SettingsApiTest.php new file mode 100644 index 0000000..d7981d5 --- /dev/null +++ b/tests/Feature/SettingsApiTest.php @@ -0,0 +1,72 @@ +asCompany(self::COMPANY)->getJson('/api/v1/tasks-projects/settings'); + + $response->assertOk(); + $response->assertExactJson(['data' => [ + 'default_rate' => 0, + 'rounding_minutes' => ModuleSettings::DEFAULT_ROUNDING_MINUTES, + 'week_start' => ModuleSettings::DEFAULT_WEEK_START, + 'members_see_all_time' => false, + 'rounding_increments' => ModuleSettings::ROUNDING_INCREMENTS, + ]]); + } + + public function test_stored_values_come_back_typed_whatever_the_host_wrote(): void + { + $this->settings->putCompany(self::COMPANY, ModuleSettings::PREFIX.'default_rate', '12000'); + $this->settings->putCompany(self::COMPANY, ModuleSettings::PREFIX.'rounding_minutes', '15'); + $this->settings->putCompany(self::COMPANY, ModuleSettings::PREFIX.'week_start', '0'); + $this->settings->putCompany(self::COMPANY, ModuleSettings::PREFIX.'members_see_all_time', 'YES'); + + $response = $this->asCompany(self::COMPANY)->getJson('/api/v1/tasks-projects/settings'); + + $response->assertOk(); + $response->assertJsonPath('data.default_rate', 12000); + $response->assertJsonPath('data.rounding_minutes', 15); + $response->assertJsonPath('data.week_start', 0); + $response->assertJsonPath('data.members_see_all_time', true); + } + + public function test_an_unusable_stored_value_falls_back_to_the_default(): void + { + $this->settings->putCompany(self::COMPANY, ModuleSettings::PREFIX.'rounding_minutes', 7); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/settings') + ->assertJsonPath('data.rounding_minutes', ModuleSettings::DEFAULT_ROUNDING_MINUTES); + } + + public function test_settings_are_per_company(): void + { + $this->settings->putCompany(self::COMPANY, ModuleSettings::PREFIX.'default_rate', 12000); + + $this->asCompany(10) + ->getJson('/api/v1/tasks-projects/settings') + ->assertJsonPath('data.default_rate', 0); + } + + public function test_reading_the_settings_needs_the_project_view_ability(): void + { + $this->authorization->deny(Authorizes::id(Abilities::VIEW_PROJECT)); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/settings') + ->assertForbidden(); + } +} diff --git a/tests/Feature/TaskStatusesApiTest.php b/tests/Feature/TaskStatusesApiTest.php new file mode 100644 index 0000000..34bbe8e --- /dev/null +++ b/tests/Feature/TaskStatusesApiTest.php @@ -0,0 +1,175 @@ +asCompany(self::COMPANY)->getJson('/api/v1/tasks-projects/task-statuses'); + + $response->assertOk(); + $response->assertJsonCount(4, 'data'); + $response->assertJsonPath('data.0.name', 'Backlog'); + $response->assertJsonPath('data.0.is_default', true); + $response->assertJsonPath('data.3.name', 'Done'); + $response->assertJsonPath('data.3.is_closed', true); + $response->assertJsonPath('data.0.position', 1); + $response->assertJsonPath('data.3.position', 4); + } + + public function test_asking_twice_does_not_seed_the_defaults_again(): void + { + $this->asCompany(self::COMPANY)->getJson('/api/v1/tasks-projects/task-statuses')->assertOk(); + $this->asCompany(self::COMPANY)->getJson('/api/v1/tasks-projects/task-statuses')->assertJsonCount(4, 'data'); + + self::assertSame(count(TaskStatusService::DEFAULTS), TaskStatus::query()->forCompany(self::COMPANY)->count()); + } + + public function test_two_companies_get_their_own_independent_columns(): void + { + $this->asCompany(self::COMPANY)->getJson('/api/v1/tasks-projects/task-statuses')->assertJsonCount(4, 'data'); + $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/task-statuses', ['name' => 'Blocked'])->assertCreated(); + + $this->asCompany(self::OTHER_COMPANY) + ->getJson('/api/v1/tasks-projects/task-statuses') + ->assertJsonCount(4, 'data') + ->assertJsonMissing(['name' => 'Blocked']); + + self::assertSame(5, TaskStatus::query()->forCompany(self::COMPANY)->count()); + self::assertSame(4, TaskStatus::query()->forCompany(self::OTHER_COMPANY)->count()); + } + + public function test_a_created_status_lands_last_and_can_take_the_default_over(): void + { + $this->asCompany(self::COMPANY)->getJson('/api/v1/tasks-projects/task-statuses'); + + $response = $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/task-statuses', [ + 'name' => 'Blocked', + 'colour' => '#ef4444', + 'is_default' => true, + ]); + + $response->assertCreated(); + $response->assertJsonPath('data.position', 5); + $response->assertJsonPath('data.is_default', true); + + $defaults = TaskStatus::query()->forCompany(self::COMPANY)->where('is_default', true)->pluck('name')->all(); + self::assertSame(['Blocked'], $defaults); + } + + public function test_only_one_status_stays_the_default_after_an_update(): void + { + $this->asCompany(self::COMPANY)->getJson('/api/v1/tasks-projects/task-statuses'); + $review = TaskStatus::query()->forCompany(self::COMPANY)->where('name', 'Review')->firstOrFail(); + + $this->asCompany(self::COMPANY) + ->putJson('/api/v1/tasks-projects/task-statuses/'.$review->id, ['is_default' => true]) + ->assertOk() + ->assertJsonPath('data.is_default', true); + + self::assertSame( + ['Review'], + TaskStatus::query()->forCompany(self::COMPANY)->where('is_default', true)->pluck('name')->all(), + ); + } + + public function test_reorder_applies_the_wanted_order_and_returns_the_new_board(): void + { + $this->asCompany(self::COMPANY)->getJson('/api/v1/tasks-projects/task-statuses'); + $statuses = TaskStatus::query()->forCompany(self::COMPANY)->orderBy('position')->pluck('id', 'name')->all(); + + $response = $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/task-statuses/reorder', [ + 'ids' => [$statuses['Done'], $statuses['Review']], + ]); + + $response->assertOk(); + $response->assertJsonPath('data.0.name', 'Done'); + $response->assertJsonPath('data.0.position', 1); + $response->assertJsonPath('data.1.name', 'Review'); + $response->assertJsonPath('data.2.name', 'Backlog'); + $response->assertJsonPath('data.3.name', 'In Progress'); + } + + public function test_reorder_refuses_an_id_from_another_company(): void + { + $this->asCompany(self::COMPANY)->getJson('/api/v1/tasks-projects/task-statuses'); + $foreign = $this->makeStatus(self::OTHER_COMPANY); + + $this->asCompany(self::COMPANY) + ->postJson('/api/v1/tasks-projects/task-statuses/reorder', ['ids' => [$foreign->id]]) + ->assertNotFound(); + } + + public function test_a_status_holding_tasks_cannot_be_deleted(): void + { + $status = $this->makeStatus(self::COMPANY, ['name' => 'Backlog']); + $this->makeStatus(self::COMPANY, ['name' => 'Done', 'position' => 2, 'is_default' => false, 'is_closed' => true]); + $this->makeTask(self::COMPANY, ['task_status_id' => $status->id]); + + $this->asCompany(self::COMPANY) + ->deleteJson('/api/v1/tasks-projects/task-statuses/'.$status->id) + ->assertStatus(422) + ->assertJsonPath('error', 'status_in_use'); + } + + public function test_the_last_status_of_a_company_cannot_be_deleted(): void + { + $status = $this->makeStatus(self::COMPANY); + + $this->asCompany(self::COMPANY) + ->deleteJson('/api/v1/tasks-projects/task-statuses/'.$status->id) + ->assertStatus(422) + ->assertJsonPath('error', 'status_in_use'); + } + + public function test_an_empty_status_is_deleted(): void + { + $this->asCompany(self::COMPANY)->getJson('/api/v1/tasks-projects/task-statuses'); + $review = TaskStatus::query()->forCompany(self::COMPANY)->where('name', 'Review')->firstOrFail(); + + $this->asCompany(self::COMPANY) + ->deleteJson('/api/v1/tasks-projects/task-statuses/'.$review->id) + ->assertOk() + ->assertJson(['success' => true]); + + self::assertSame(3, TaskStatus::query()->forCompany(self::COMPANY)->count()); + } + + public function test_a_status_of_another_company_is_not_found(): void + { + $foreign = $this->makeStatus(self::OTHER_COMPANY); + + $this->asCompany(self::COMPANY) + ->putJson('/api/v1/tasks-projects/task-statuses/'.$foreign->id, ['name' => 'Mine now']) + ->assertNotFound(); + } + + public function test_reading_needs_view_task_and_writing_needs_manage_task_status(): void + { + $status = $this->makeStatus(self::COMPANY); + + $this->authorization->deny(Authorizes::id(Abilities::MANAGE_TASK_STATUS)); + + $this->asCompany(self::COMPANY)->getJson('/api/v1/tasks-projects/task-statuses')->assertOk(); + $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/task-statuses', ['name' => 'Blocked'])->assertForbidden(); + $this->asCompany(self::COMPANY)->putJson('/api/v1/tasks-projects/task-statuses/'.$status->id, ['name' => 'Nope'])->assertForbidden(); + $this->asCompany(self::COMPANY)->deleteJson('/api/v1/tasks-projects/task-statuses/'.$status->id)->assertForbidden(); + $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/task-statuses/reorder', ['ids' => [$status->id]])->assertForbidden(); + + $this->authorization->deny(Authorizes::id(Abilities::VIEW_TASK)); + $this->asCompany(self::COMPANY)->getJson('/api/v1/tasks-projects/task-statuses')->assertForbidden(); + } +} diff --git a/tests/Feature/TasksApiTest.php b/tests/Feature/TasksApiTest.php new file mode 100644 index 0000000..8928d8e --- /dev/null +++ b/tests/Feature/TasksApiTest.php @@ -0,0 +1,284 @@ +asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/tasks', ['name' => 'First']); + $second = $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/tasks', ['name' => 'Second']); + $elsewhere = $this->asCompany(self::OTHER_COMPANY)->postJson('/api/v1/tasks-projects/tasks', ['name' => 'Theirs']); + + $first->assertCreated()->assertJsonPath('data.number', 1); + $second->assertCreated()->assertJsonPath('data.number', 2); + $elsewhere->assertCreated()->assertJsonPath('data.number', 1); + } + + public function test_a_new_task_lands_in_the_default_column_and_takes_the_projects_customer(): void + { + $project = $this->makeProject(self::COMPANY, ['customer_id' => self::CUSTOMER]); + + $response = $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/tasks', [ + 'name' => 'Landing page', + 'project_id' => $project->id, + 'priority' => Task::PRIORITY_HIGH, + 'estimated_minutes' => 120, + ]); + + $response->assertCreated(); + $response->assertJsonPath('data.customer_id', self::CUSTOMER); + $response->assertJsonPath('data.creator_id', self::DEFAULT_USER); + $response->assertJsonPath('data.billable', true); + $response->assertJsonPath('data.priority', Task::PRIORITY_HIGH); + $response->assertJsonPath('data.closed_at', null); + + $default = TaskStatus::query()->forCompany(self::COMPANY)->where('is_default', true)->firstOrFail(); + $response->assertJsonPath('data.task_status_id', (int) $default->id); + } + + public function test_a_standalone_task_can_carry_its_own_customer(): void + { + $this->asCompany(self::COMPANY) + ->postJson('/api/v1/tasks-projects/tasks', ['name' => 'Ad hoc call', 'customer_id' => self::CUSTOMER]) + ->assertCreated() + ->assertJsonPath('data.project_id', null) + ->assertJsonPath('data.customer_id', self::CUSTOMER); + } + + public function test_entering_a_closed_column_stamps_closed_at_and_leaving_it_clears_it(): void + { + $open = $this->makeStatus(self::COMPANY, ['name' => 'Backlog']); + $done = $this->makeStatus(self::COMPANY, ['name' => 'Done', 'position' => 2, 'is_default' => false, 'is_closed' => true]); + $task = $this->makeTask(self::COMPANY, ['task_status_id' => $open->id]); + + $this->asCompany(self::COMPANY) + ->putJson('/api/v1/tasks-projects/tasks/'.$task->id, ['task_status_id' => $done->id]) + ->assertOk() + ->assertJsonPath('data.task_status_id', (int) $done->id); + + self::assertNotNull(Task::query()->findOrFail($task->id)->closed_at); + + $this->asCompany(self::COMPANY) + ->putJson('/api/v1/tasks-projects/tasks/'.$task->id, ['task_status_id' => $open->id]) + ->assertOk() + ->assertJsonPath('data.closed_at', null); + } + + public function test_the_list_filters_by_project_assignee_status_due_date_and_text(): void + { + $project = $this->makeProject(self::COMPANY); + $status = $this->makeStatus(self::COMPANY); + $other = $this->makeStatus(self::COMPANY, ['name' => 'Done', 'position' => 2, 'is_default' => false]); + + $landing = $this->makeTask(self::COMPANY, [ + 'task_status_id' => $status->id, + 'project_id' => $project->id, + 'assignee_id' => 8, + 'name' => 'Landing page', + 'due_date' => '2026-09-10', + ]); + $pricing = $this->makeTask(self::COMPANY, [ + 'task_status_id' => $other->id, + 'name' => 'Pricing table', + 'due_date' => '2026-10-10', + ]); + + $this->assertListReturns([$landing->id], '?project_id='.$project->id); + $this->assertListReturns([$landing->id], '?assignee_id=8'); + $this->assertListReturns([$pricing->id], '?task_status_id='.$other->id); + $this->assertListReturns([$landing->id], '?due_before=2026-09-30'); + $this->assertListReturns([$pricing->id], '?due_after=2026-09-30'); + $this->assertListReturns([$pricing->id], '?search=Pricing'); + } + + public function test_the_list_is_paged_and_never_leaves_the_company(): void + { + $this->makeTask(self::COMPANY, ['name' => 'Mine']); + $this->makeTask(self::OTHER_COMPANY, ['name' => 'Theirs']); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/tasks') + ->assertOk() + ->assertJsonPath('meta.total', 1) + ->assertJsonPath('meta.per_page', 15) + ->assertJsonPath('data.0.name', 'Mine'); + } + + public function test_a_move_lands_between_its_new_neighbours(): void + { + $status = $this->makeStatus(self::COMPANY); + $target = $this->makeStatus(self::COMPANY, ['name' => 'In Progress', 'position' => 2, 'is_default' => false]); + + $first = $this->makeTask(self::COMPANY, ['task_status_id' => $target->id, 'board_position' => '1024.0000000000']); + $second = $this->makeTask(self::COMPANY, ['task_status_id' => $target->id, 'board_position' => '2048.0000000000']); + $dragged = $this->makeTask(self::COMPANY, ['task_status_id' => $status->id]); + + $response = $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/tasks/'.$dragged->id.'/move', [ + 'task_status_id' => $target->id, + 'before_id' => $first->id, + 'after_id' => $second->id, + ]); + + $response->assertOk(); + $response->assertJsonPath('data.task_status_id', (int) $target->id); + $response->assertJsonPath('data.board_position', '1536.0000000000'); + } + + public function test_a_move_to_the_end_of_a_column_appends(): void + { + $status = $this->makeStatus(self::COMPANY); + $last = $this->makeTask(self::COMPANY, ['task_status_id' => $status->id, 'board_position' => '2048.0000000000']); + $dragged = $this->makeTask(self::COMPANY, ['task_status_id' => $status->id, 'board_position' => '1024.0000000000']); + + $this->asCompany(self::COMPANY) + ->postJson('/api/v1/tasks-projects/tasks/'.$dragged->id.'/move', [ + 'task_status_id' => $status->id, + 'before_id' => $last->id, + ]) + ->assertOk() + ->assertJsonPath('data.board_position', '3072.0000000000'); + } + + public function test_a_move_refuses_a_neighbour_outside_the_target_column(): void + { + $status = $this->makeStatus(self::COMPANY); + $target = $this->makeStatus(self::COMPANY, ['name' => 'In Progress', 'position' => 2, 'is_default' => false]); + $stranger = $this->makeTask(self::COMPANY, ['task_status_id' => $status->id]); + $dragged = $this->makeTask(self::COMPANY, ['task_status_id' => $status->id]); + + $this->asCompany(self::COMPANY) + ->postJson('/api/v1/tasks-projects/tasks/'.$dragged->id.'/move', [ + 'task_status_id' => $target->id, + 'before_id' => $stranger->id, + ]) + ->assertStatus(422) + ->assertJsonValidationErrors(['before_id']); + } + + public function test_the_board_groups_tasks_into_columns_in_board_order(): void + { + $backlog = $this->makeStatus(self::COMPANY, ['name' => 'Backlog']); + $doing = $this->makeStatus(self::COMPANY, ['name' => 'In Progress', 'position' => 2, 'is_default' => false]); + + $second = $this->makeTask(self::COMPANY, ['task_status_id' => $backlog->id, 'name' => 'Second', 'board_position' => '2048.0000000000']); + $first = $this->makeTask(self::COMPANY, ['task_status_id' => $backlog->id, 'name' => 'First', 'board_position' => '1024.0000000000']); + $this->makeTask(self::COMPANY, ['task_status_id' => $doing->id, 'name' => 'Underway']); + $this->makeTask(self::OTHER_COMPANY, ['name' => 'Theirs']); + + $response = $this->asCompany(self::COMPANY)->getJson('/api/v1/tasks-projects/board'); + + $response->assertOk(); + $response->assertJsonCount(2, 'data'); + $response->assertJsonPath('data.0.status.name', 'Backlog'); + $response->assertJsonPath('data.0.tasks.0.id', (int) $first->id); + $response->assertJsonPath('data.0.tasks.1.id', (int) $second->id); + $response->assertJsonPath('data.1.status.name', 'In Progress'); + $response->assertJsonPath('data.1.tasks.0.name', 'Underway'); + } + + public function test_the_board_seeds_the_default_columns_and_filters_by_project_and_assignee(): void + { + $project = $this->makeProject(self::COMPANY); + + $response = $this->asCompany(self::COMPANY)->getJson('/api/v1/tasks-projects/board'); + + $response->assertOk(); + $response->assertJsonCount(4, 'data'); + $response->assertJsonPath('data.0.status.name', 'Backlog'); + + $status = TaskStatus::query()->forCompany(self::COMPANY)->where('is_default', true)->firstOrFail(); + $mine = $this->makeTask(self::COMPANY, ['task_status_id' => $status->id, 'project_id' => $project->id, 'assignee_id' => 8]); + $this->makeTask(self::COMPANY, ['task_status_id' => $status->id, 'name' => 'Unassigned']); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/board?project_id='.$project->id) + ->assertJsonCount(1, 'data.0.tasks') + ->assertJsonPath('data.0.tasks.0.id', (int) $mine->id); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/board?assignee_id=8') + ->assertJsonCount(1, 'data.0.tasks') + ->assertJsonPath('data.0.tasks.0.id', (int) $mine->id); + } + + public function test_deleting_a_task_takes_its_uninvoiced_time_with_it(): void + { + $task = $this->makeTask(self::COMPANY); + $this->makeEntry(self::COMPANY, (int) $task->id); + + $this->asCompany(self::COMPANY) + ->deleteJson('/api/v1/tasks-projects/tasks/'.$task->id) + ->assertOk() + ->assertJson(['success' => true]); + + self::assertSame(0, Task::query()->forCompany(self::COMPANY)->count()); + } + + public function test_deleting_a_task_with_invoiced_time_is_refused(): void + { + $task = $this->makeTask(self::COMPANY); + $this->makeEntry(self::COMPANY, (int) $task->id, ['invoice_id' => 77, 'invoice_item_id' => 88]); + + $this->asCompany(self::COMPANY) + ->deleteJson('/api/v1/tasks-projects/tasks/'.$task->id) + ->assertStatus(422) + ->assertJsonPath('error', 'entries_already_invoiced'); + } + + public function test_a_task_of_another_company_is_not_found(): void + { + $task = $this->makeTask(self::OTHER_COMPANY); + + $this->asCompany(self::COMPANY)->getJson('/api/v1/tasks-projects/tasks/'.$task->id)->assertNotFound(); + $this->asCompany(self::COMPANY)->putJson('/api/v1/tasks-projects/tasks/'.$task->id, ['name' => 'Mine now'])->assertNotFound(); + $this->asCompany(self::COMPANY)->deleteJson('/api/v1/tasks-projects/tasks/'.$task->id)->assertNotFound(); + } + + public function test_every_action_refuses_without_its_ability(): void + { + $task = $this->makeTask(self::COMPANY); + + $this->authorization->deny( + Authorizes::id(Abilities::VIEW_TASK), + Authorizes::id(Abilities::CREATE_TASK), + Authorizes::id(Abilities::EDIT_TASK), + Authorizes::id(Abilities::DELETE_TASK), + ); + + $this->asCompany(self::COMPANY)->getJson('/api/v1/tasks-projects/tasks')->assertForbidden(); + $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/tasks', ['name' => 'Nope'])->assertForbidden(); + $this->asCompany(self::COMPANY)->getJson('/api/v1/tasks-projects/tasks/'.$task->id)->assertForbidden(); + $this->asCompany(self::COMPANY)->putJson('/api/v1/tasks-projects/tasks/'.$task->id, ['name' => 'Nope'])->assertForbidden(); + $this->asCompany(self::COMPANY)->deleteJson('/api/v1/tasks-projects/tasks/'.$task->id)->assertForbidden(); + $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/tasks/'.$task->id.'/move', [ + 'task_status_id' => $task->task_status_id, + ])->assertForbidden(); + $this->asCompany(self::COMPANY)->getJson('/api/v1/tasks-projects/board')->assertForbidden(); + } + + /** @param list $expected */ + private function assertListReturns(array $expected, string $query): void + { + $ids = $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/tasks'.$query) + ->assertOk() + ->json('data.*.id'); + + self::assertSame(array_map(intval(...), $expected), $ids); + } +} diff --git a/tests/Feature/TimeEntriesApiTest.php b/tests/Feature/TimeEntriesApiTest.php new file mode 100644 index 0000000..aeb48f7 --- /dev/null +++ b/tests/Feature/TimeEntriesApiTest.php @@ -0,0 +1,259 @@ +taskOnProjectAt(6000); + + $response = $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/time-entries', [ + 'task_id' => $task, + 'started_at' => '2026-09-15 09:00:00', + 'ended_at' => '2026-09-15 10:30:00', + 'description' => 'Pairing on the importer', + ]); + + $response->assertCreated(); + $response->assertJsonPath('data.user_id', self::DEFAULT_USER); + $response->assertJsonPath('data.duration_minutes', 90); + $response->assertJsonPath('data.rate', 6000); + $response->assertJsonPath('data.amount', 9000); + $response->assertJsonPath('data.is_running', false); + $response->assertJsonPath('data.description', 'Pairing on the importer'); + } + + public function test_the_company_rounding_increment_is_applied_when_the_entry_is_saved(): void + { + $this->settings->putCompany(self::COMPANY, ModuleSettings::PREFIX.'rounding_minutes', 15); + $task = $this->taskOnProjectAt(6000); + + $this->asCompany(self::COMPANY) + ->postJson('/api/v1/tasks-projects/time-entries', [ + 'task_id' => $task, + 'started_at' => '2026-09-15 09:00:00', + 'ended_at' => '2026-09-15 09:20:00', + ]) + ->assertCreated() + ->assertJsonPath('data.duration_minutes', 15) + ->assertJsonPath('data.amount', 1500); + } + + public function test_the_rate_is_frozen_at_save_and_survives_a_later_project_rate_change(): void + { + $project = $this->makeProject(self::COMPANY, ['customer_id' => 42, 'default_rate' => 6000]); + $task = $this->makeTask(self::COMPANY, ['project_id' => $project->id]); + + $entry = $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/time-entries', [ + 'task_id' => $task->id, + 'duration_minutes' => 60, + ])->assertCreated()->json('data.id'); + + $this->asCompany(self::COMPANY) + ->putJson('/api/v1/tasks-projects/projects/'.$project->id, ['default_rate' => 9000]) + ->assertOk(); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/time-entries/'.$entry) + ->assertOk() + ->assertJsonPath('data.rate', 6000) + ->assertJsonPath('data.amount', 6000); + } + + public function test_the_list_filters_and_never_shows_a_running_entry(): void + { + $task = $this->taskOnProjectAt(6000); + $this->makeEntry(self::COMPANY, $task, ['duration_minutes' => 30]); + $this->makeEntry(self::COMPANY, $task, ['billable' => false, 'duration_minutes' => 45]); + $this->makeEntry(self::COMPANY, $task, ['invoice_id' => 77, 'invoice_item_id' => 88]); + $this->makeEntry(self::COMPANY, $task, ['running_user_id' => self::DEFAULT_USER, 'ended_at' => null]); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/time-entries') + ->assertOk() + ->assertJsonPath('meta.total', 3); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/time-entries?billed=1') + ->assertJsonPath('meta.total', 1); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/time-entries?billed=0') + ->assertJsonPath('meta.total', 2); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/time-entries?billable=0') + ->assertJsonPath('meta.total', 1); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/time-entries?task_id='.$task.'&from=2026-09-01&to=2026-09-01') + ->assertJsonPath('meta.total', 3); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/time-entries?from=2026-09-02') + ->assertJsonPath('meta.total', 0); + } + + public function test_without_view_all_time_the_list_is_the_callers_own_rows(): void + { + $task = $this->taskOnProjectAt(6000); + $this->makeEntry(self::COMPANY, $task); + $this->makeEntry(self::COMPANY, $task, ['user_id' => self::OTHER_USER]); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/time-entries') + ->assertJsonPath('meta.total', 2); + + $this->authorization->deny(Authorizes::id(Abilities::VIEW_ALL_TIME)); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/time-entries?user_id='.self::OTHER_USER) + ->assertJsonPath('meta.total', 1) + ->assertJsonPath('data.0.user_id', self::DEFAULT_USER); + } + + public function test_the_company_setting_opens_the_timesheet_without_the_ability(): void + { + $task = $this->taskOnProjectAt(6000); + $this->makeEntry(self::COMPANY, $task); + $this->makeEntry(self::COMPANY, $task, ['user_id' => self::OTHER_USER]); + + $this->authorization->deny(Authorizes::id(Abilities::VIEW_ALL_TIME)); + $this->settings->putCompany(self::COMPANY, ModuleSettings::PREFIX.'members_see_all_time', true); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/time-entries') + ->assertJsonPath('meta.total', 2); + } + + public function test_another_members_entry_is_invisible_without_the_ability_or_the_setting(): void + { + $task = $this->taskOnProjectAt(6000); + $entry = $this->makeEntry(self::COMPANY, $task, ['user_id' => self::OTHER_USER]); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/time-entries/'.$entry->id) + ->assertOk(); + + $this->authorization->deny(Authorizes::id(Abilities::VIEW_ALL_TIME)); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/time-entries/'.$entry->id) + ->assertForbidden(); + } + + public function test_writing_over_another_members_entry_needs_edit_all_time(): void + { + $task = $this->taskOnProjectAt(6000); + $mine = $this->makeEntry(self::COMPANY, $task); + $theirs = $this->makeEntry(self::COMPANY, $task, ['user_id' => self::OTHER_USER]); + + $this->authorization->deny(Authorizes::id(Abilities::EDIT_ALL_TIME)); + + $this->asCompany(self::COMPANY) + ->putJson('/api/v1/tasks-projects/time-entries/'.$mine->id, ['duration_minutes' => 30]) + ->assertOk() + ->assertJsonPath('data.duration_minutes', 30); + + $this->asCompany(self::COMPANY) + ->putJson('/api/v1/tasks-projects/time-entries/'.$theirs->id, ['duration_minutes' => 30]) + ->assertForbidden(); + + $this->asCompany(self::COMPANY) + ->deleteJson('/api/v1/tasks-projects/time-entries/'.$theirs->id) + ->assertForbidden(); + + $this->asCompany(self::COMPANY) + ->postJson('/api/v1/tasks-projects/time-entries', [ + 'task_id' => $task, + 'user_id' => self::OTHER_USER, + 'duration_minutes' => 30, + ]) + ->assertForbidden(); + } + + public function test_logging_time_for_someone_else_is_allowed_with_edit_all_time(): void + { + $task = $this->taskOnProjectAt(6000); + + $this->asCompany(self::COMPANY) + ->postJson('/api/v1/tasks-projects/time-entries', [ + 'task_id' => $task, + 'user_id' => self::OTHER_USER, + 'duration_minutes' => 60, + ]) + ->assertCreated() + ->assertJsonPath('data.user_id', self::OTHER_USER); + } + + public function test_an_invoiced_entry_cannot_be_deleted(): void + { + $task = $this->taskOnProjectAt(6000); + $entry = $this->makeEntry(self::COMPANY, $task, ['invoice_id' => 77, 'invoice_item_id' => 88]); + + $this->asCompany(self::COMPANY) + ->deleteJson('/api/v1/tasks-projects/time-entries/'.$entry->id) + ->assertStatus(422) + ->assertJsonPath('error', 'entries_already_invoiced'); + + self::assertNotNull(TimeEntry::query()->find($entry->id)); + } + + public function test_an_uninvoiced_entry_is_deleted(): void + { + $task = $this->taskOnProjectAt(6000); + $entry = $this->makeEntry(self::COMPANY, $task); + + $this->asCompany(self::COMPANY) + ->deleteJson('/api/v1/tasks-projects/time-entries/'.$entry->id) + ->assertOk(); + + self::assertNull(TimeEntry::query()->find($entry->id)); + } + + public function test_an_entry_of_another_company_is_not_found(): void + { + $task = $this->makeTask(self::OTHER_COMPANY); + $entry = $this->makeEntry(self::OTHER_COMPANY, (int) $task->id); + + $this->asCompany(self::COMPANY)->getJson('/api/v1/tasks-projects/time-entries/'.$entry->id)->assertNotFound(); + $this->asCompany(self::COMPANY)->putJson('/api/v1/tasks-projects/time-entries/'.$entry->id, ['duration_minutes' => 1])->assertNotFound(); + $this->asCompany(self::COMPANY)->deleteJson('/api/v1/tasks-projects/time-entries/'.$entry->id)->assertNotFound(); + } + + public function test_everything_here_needs_view_own_time(): void + { + $task = $this->taskOnProjectAt(6000); + $entry = $this->makeEntry(self::COMPANY, $task); + + $this->authorization->deny(Authorizes::id(Abilities::VIEW_OWN_TIME)); + + $this->asCompany(self::COMPANY)->getJson('/api/v1/tasks-projects/time-entries')->assertForbidden(); + $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/time-entries', ['task_id' => $task])->assertForbidden(); + $this->asCompany(self::COMPANY)->getJson('/api/v1/tasks-projects/time-entries/'.$entry->id)->assertForbidden(); + $this->asCompany(self::COMPANY)->putJson('/api/v1/tasks-projects/time-entries/'.$entry->id, ['duration_minutes' => 1])->assertForbidden(); + $this->asCompany(self::COMPANY)->deleteJson('/api/v1/tasks-projects/time-entries/'.$entry->id)->assertForbidden(); + } + + private function taskOnProjectAt(int $rate): int + { + $project = $this->makeProject(self::COMPANY, ['customer_id' => 42, 'default_rate' => $rate, 'currency_id' => 3]); + + return (int) $this->makeTask(self::COMPANY, ['project_id' => $project->id])->id; + } +} diff --git a/tests/Feature/TimerApiTest.php b/tests/Feature/TimerApiTest.php new file mode 100644 index 0000000..1d6e618 --- /dev/null +++ b/tests/Feature/TimerApiTest.php @@ -0,0 +1,168 @@ +asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/timer') + ->assertOk() + ->assertExactJson(['data' => null]); + } + + public function test_starting_opens_an_entry_that_carries_no_money_yet(): void + { + Carbon::setTestNow('2026-09-15 09:00:00'); + $task = $this->taskOnProjectAt(6000); + + $response = $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/timer/start', [ + 'task_id' => $task, + 'description' => 'Fixing the importer', + ]); + + $response->assertCreated(); + $response->assertJsonPath('data.is_running', true); + $response->assertJsonPath('data.user_id', self::DEFAULT_USER); + $response->assertJsonPath('data.duration_minutes', 0); + $response->assertJsonPath('data.amount', 0); + $response->assertJsonPath('data.ended_at', null); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/timer') + ->assertOk() + ->assertJsonPath('data.description', 'Fixing the importer'); + } + + public function test_a_second_start_is_a_conflict(): void + { + $task = $this->taskOnProjectAt(6000); + $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/timer/start', ['task_id' => $task])->assertCreated(); + + $this->asCompany(self::COMPANY) + ->postJson('/api/v1/tasks-projects/timer/start', ['task_id' => $task]) + ->assertStatus(409) + ->assertJsonPath('error', 'timer_already_running'); + } + + public function test_stopping_rounds_the_elapsed_time_and_freezes_the_rate(): void + { + $this->settings->putCompany(self::COMPANY, ModuleSettings::PREFIX.'rounding_minutes', 15); + $task = $this->taskOnProjectAt(6000); + + Carbon::setTestNow('2026-09-15 09:00:00'); + $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/timer/start', ['task_id' => $task])->assertCreated(); + + Carbon::setTestNow('2026-09-15 09:50:00'); + $response = $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/timer/stop'); + + $response->assertOk(); + $response->assertJsonPath('data.is_running', false); + $response->assertJsonPath('data.duration_minutes', 45); + $response->assertJsonPath('data.rate', 6000); + $response->assertJsonPath('data.amount', 4500); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/timer') + ->assertExactJson(['data' => null]); + } + + public function test_the_stopped_entry_joins_the_timesheet(): void + { + $task = $this->taskOnProjectAt(6000); + + Carbon::setTestNow('2026-09-15 09:00:00'); + $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/timer/start', ['task_id' => $task]); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/time-entries') + ->assertJsonPath('meta.total', 0); + + Carbon::setTestNow('2026-09-15 10:00:00'); + $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/timer/stop')->assertOk(); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/time-entries') + ->assertJsonPath('meta.total', 1) + ->assertJsonPath('data.0.duration_minutes', 60); + } + + public function test_discarding_throws_the_running_entry_away(): void + { + $task = $this->taskOnProjectAt(6000); + $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/timer/start', ['task_id' => $task])->assertCreated(); + + $this->asCompany(self::COMPANY) + ->deleteJson('/api/v1/tasks-projects/timer') + ->assertOk() + ->assertJson(['success' => true]); + + self::assertSame(0, TimeEntry::query()->forCompany(self::COMPANY)->count()); + } + + public function test_stopping_or_discarding_an_idle_timer_is_not_found(): void + { + $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/timer/stop')->assertNotFound(); + $this->asCompany(self::COMPANY)->deleteJson('/api/v1/tasks-projects/timer')->assertNotFound(); + } + + public function test_a_task_of_another_company_cannot_be_timed(): void + { + $task = $this->makeTask(self::OTHER_COMPANY); + + $this->asCompany(self::COMPANY) + ->postJson('/api/v1/tasks-projects/timer/start', ['task_id' => $task->id]) + ->assertNotFound(); + } + + public function test_the_running_timer_belongs_to_one_company_at_a_time(): void + { + $here = $this->taskOnProjectAt(6000); + $there = (int) $this->makeTask(self::OTHER_COMPANY)->id; + + $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/timer/start', ['task_id' => $here])->assertCreated(); + + $this->asCompany(self::OTHER_COMPANY) + ->getJson('/api/v1/tasks-projects/timer') + ->assertExactJson(['data' => null]); + + $this->asCompany(self::OTHER_COMPANY) + ->postJson('/api/v1/tasks-projects/timer/start', ['task_id' => $there]) + ->assertCreated(); + + self::assertSame(1, TimeEntry::query()->forCompany(self::COMPANY)->whereNotNull('running_user_id')->count()); + self::assertSame(1, TimeEntry::query()->forCompany(self::OTHER_COMPANY)->whereNotNull('running_user_id')->count()); + } + + public function test_the_timer_needs_the_own_time_ability(): void + { + $task = $this->taskOnProjectAt(6000); + $this->authorization->deny(Authorizes::id(Abilities::VIEW_OWN_TIME)); + + $this->asCompany(self::COMPANY)->getJson('/api/v1/tasks-projects/timer')->assertForbidden(); + $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/timer/start', ['task_id' => $task])->assertForbidden(); + $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/timer/stop')->assertForbidden(); + $this->asCompany(self::COMPANY)->deleteJson('/api/v1/tasks-projects/timer')->assertForbidden(); + } + + private function taskOnProjectAt(int $rate): int + { + $project = $this->makeProject(self::COMPANY, ['customer_id' => 42, 'default_rate' => $rate, 'currency_id' => 3]); + + return (int) $this->makeTask(self::COMPANY, ['project_id' => $project->id])->id; + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php index aeb2c0c..d037519 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -4,12 +4,18 @@ namespace Modules\TasksProjects\Tests; +use Illuminate\Auth\GenericUser; +use Illuminate\Contracts\Debug\ExceptionHandler; +use Illuminate\Foundation\Exceptions\Handler; +use Illuminate\Routing\Router; use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\Auth; use InvoiceShelf\Modules\Contracts\Host\CompanyDataReader; use InvoiceShelf\Modules\Contracts\Host\ModuleAuthorization; use InvoiceShelf\Modules\Contracts\Host\SettingsStore; use InvoiceShelf\Modules\InvoiceShelfModulesServiceProvider; use InvoiceShelf\Modules\Registry; +use Modules\TasksProjects\Http\DomainExceptionRenderer; use Modules\TasksProjects\Models\Project; use Modules\TasksProjects\Models\ProjectMember; use Modules\TasksProjects\Models\Task; @@ -23,6 +29,9 @@ abstract class TestCase extends Orchestra { + /** The user every request acts as until a test says otherwise. */ + public const DEFAULT_USER = 7; + protected MemorySettingsStore $settings; protected MemoryCompanyDataReader $companyData; @@ -40,6 +49,17 @@ protected function setUp(): void $this->app->instance(SettingsStore::class, $this->settings); $this->app->instance(CompanyDataReader::class, $this->companyData); $this->app->instance(ModuleAuthorization::class, $this->authorization); + + $handler = $this->app->make(ExceptionHandler::class); + if ($handler instanceof Handler) { + DomainExceptionRenderer::register($handler); + } + + // `auth:sanctum`, `company` and `bouncer` are host middleware and do not + // exist here, so the harness stands in for all three: it authenticates + // the caller itself and puts the company on the request as a header. + $this->withoutMiddleware(); + $this->actingAsUser(self::DEFAULT_USER); } protected function tearDown(): void @@ -78,6 +98,30 @@ protected function moduleSettings(): ModuleSettings return new ModuleSettings($this->settings); } + /** + * Load the module's own route file, the way the provider does in the host. + * + * @param Router $router + */ + protected function defineRoutes($router): void + { + require dirname(__DIR__).'/routes/api.php'; + } + + /** Authenticate the caller without a host user model. */ + protected function actingAsUser(int $userId): static + { + Auth::setUser(new GenericUser(['id' => $userId])); + + return $this; + } + + /** Send the `company` header the host middleware would have set. */ + protected function asCompany(int $companyId): static + { + return $this->withHeader('company', (string) $companyId); + } + /** @param array $attributes */ protected function makeStatus(int $companyId, array $attributes = []): TaskStatus { diff --git a/tests/Unit/ModuleRoutesTest.php b/tests/Unit/ModuleRoutesTest.php new file mode 100644 index 0000000..223fd52 --- /dev/null +++ b/tests/Unit/ModuleRoutesTest.php @@ -0,0 +1,125 @@ + */ + private const MIDDLEWARE = ['api', 'auth:sanctum', 'company', 'bouncer']; + + public function test_it_registers_the_documented_route_table(): void + { + $routes = array_map( + static fn (Route $route): array => [ + implode('|', array_values(array_diff($route->methods(), ['HEAD']))), + $route->uri(), + (string) $route->getName(), + ], + $this->moduleRoutes(), + ); + + self::assertSame([ + ['GET', 'api/v1/tasks-projects/projects', 'tasks-projects.projects.index'], + ['POST', 'api/v1/tasks-projects/projects', 'tasks-projects.projects.store'], + ['GET', 'api/v1/tasks-projects/projects/{id}', 'tasks-projects.projects.show'], + ['PUT', 'api/v1/tasks-projects/projects/{id}', 'tasks-projects.projects.update'], + ['DELETE', 'api/v1/tasks-projects/projects/{id}', 'tasks-projects.projects.destroy'], + ['POST', 'api/v1/tasks-projects/projects/{id}/archive', 'tasks-projects.projects.archive'], + ['POST', 'api/v1/tasks-projects/projects/{id}/unarchive', 'tasks-projects.projects.unarchive'], + ['GET', 'api/v1/tasks-projects/projects/{id}/members', 'tasks-projects.project-members.index'], + ['POST', 'api/v1/tasks-projects/projects/{id}/members', 'tasks-projects.project-members.store'], + ['DELETE', 'api/v1/tasks-projects/projects/{id}/members/{userId}', 'tasks-projects.project-members.destroy'], + ['GET', 'api/v1/tasks-projects/members', 'tasks-projects.members.index'], + ['GET', 'api/v1/tasks-projects/tasks', 'tasks-projects.tasks.index'], + ['POST', 'api/v1/tasks-projects/tasks', 'tasks-projects.tasks.store'], + ['GET', 'api/v1/tasks-projects/tasks/{id}', 'tasks-projects.tasks.show'], + ['PUT', 'api/v1/tasks-projects/tasks/{id}', 'tasks-projects.tasks.update'], + ['DELETE', 'api/v1/tasks-projects/tasks/{id}', 'tasks-projects.tasks.destroy'], + ['POST', 'api/v1/tasks-projects/tasks/{id}/move', 'tasks-projects.tasks.move'], + ['GET', 'api/v1/tasks-projects/board', 'tasks-projects.board.index'], + ['GET', 'api/v1/tasks-projects/task-statuses', 'tasks-projects.task-statuses.index'], + ['POST', 'api/v1/tasks-projects/task-statuses', 'tasks-projects.task-statuses.store'], + ['POST', 'api/v1/tasks-projects/task-statuses/reorder', 'tasks-projects.task-statuses.reorder'], + ['PUT', 'api/v1/tasks-projects/task-statuses/{id}', 'tasks-projects.task-statuses.update'], + ['DELETE', 'api/v1/tasks-projects/task-statuses/{id}', 'tasks-projects.task-statuses.destroy'], + ['GET', 'api/v1/tasks-projects/time-entries', 'tasks-projects.time-entries.index'], + ['POST', 'api/v1/tasks-projects/time-entries', 'tasks-projects.time-entries.store'], + ['GET', 'api/v1/tasks-projects/time-entries/{id}', 'tasks-projects.time-entries.show'], + ['PUT', 'api/v1/tasks-projects/time-entries/{id}', 'tasks-projects.time-entries.update'], + ['DELETE', 'api/v1/tasks-projects/time-entries/{id}', 'tasks-projects.time-entries.destroy'], + ['GET', 'api/v1/tasks-projects/timer', 'tasks-projects.timer.show'], + ['DELETE', 'api/v1/tasks-projects/timer', 'tasks-projects.timer.destroy'], + ['POST', 'api/v1/tasks-projects/timer/start', 'tasks-projects.timer.start'], + ['POST', 'api/v1/tasks-projects/timer/stop', 'tasks-projects.timer.stop'], + ['GET', 'api/v1/tasks-projects/billing/unbilled', 'tasks-projects.billing.unbilled'], + ['POST', 'api/v1/tasks-projects/billing/prepare', 'tasks-projects.billing.prepare'], + ['POST', 'api/v1/tasks-projects/billing/confirm', 'tasks-projects.billing.confirm'], + ['GET', 'api/v1/tasks-projects/reports/summary', 'tasks-projects.reports.summary'], + ['GET', 'api/v1/tasks-projects/settings', 'tasks-projects.settings.show'], + ], $routes); + } + + public function test_every_route_sits_behind_the_host_middleware_stack(): void + { + foreach ($this->moduleRoutes() as $route) { + foreach (self::MIDDLEWARE as $middleware) { + self::assertContains( + $middleware, + $route->middleware(), + "Route {$route->uri()} is missing the {$middleware} middleware.", + ); + } + } + } + + public function test_every_route_is_named_and_prefixed_by_the_module_slug(): void + { + foreach ($this->moduleRoutes() as $route) { + self::assertStringStartsWith('tasks-projects.', (string) $route->getName()); + } + } + + public function test_no_controller_of_the_module_is_reachable_outside_the_slug_prefix(): void + { + foreach ($this->app['router']->getRoutes()->getRoutes() as $route) { + $action = $route->getAction('controller'); + + if (! is_string($action) || ! str_starts_with($action, 'Modules\\TasksProjects\\')) { + continue; + } + + self::assertStringStartsWith(self::PREFIX.'/', $route->uri()); + } + } + + /** @return list */ + private function moduleRoutes(): array + { + $routes = []; + + foreach ($this->app['router']->getRoutes()->getRoutes() as $route) { + if (str_starts_with($route->uri(), self::PREFIX)) { + $routes[] = $route; + } + } + + self::assertNotSame([], $routes, 'The module registered no routes at all.'); + + return $routes; + } +}