diff --git a/app/Brands.php b/app/Brands.php index 4de2eb8e2a..1268d799e1 100644 --- a/app/Brands.php +++ b/app/Brands.php @@ -3,10 +3,13 @@ namespace App; use DB; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; class Brands extends Model { + use HasFactory; + protected $table = 'brands'; /** * The attributes that are mass assignable. diff --git a/app/Exceptions/Handler.php b/app/Exceptions/Handler.php index f47205a434..b7dd298bfd 100644 --- a/app/Exceptions/Handler.php +++ b/app/Exceptions/Handler.php @@ -2,6 +2,9 @@ namespace App\Exceptions; +use Illuminate\Auth\Access\AuthorizationException; +use Illuminate\Auth\AuthenticationException; +use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Validation\ValidationException; use Throwable; use Exception; @@ -35,6 +38,18 @@ public function render($request, Throwable $exception) 422); } + if ($exception instanceof AuthenticationException) { + return response()->json(['message' => 'Unauthenticated.'], 401); + } + + if ($exception instanceof AuthorizationException) { + return response()->json(['message' => 'Unauthorized.'], 403); + } + + if ($exception instanceof ModelNotFoundException) { + return response()->json(['message' => 'Resource not found.'], 404); + } + return response()->json( ['message' => $exception->getMessage()], method_exists($exception, 'getStatusCode') ? $exception->getStatusCode() : 500); diff --git a/app/Http/Controllers/API/BrandController.php b/app/Http/Controllers/API/BrandController.php new file mode 100644 index 0000000000..d54f73fc4a --- /dev/null +++ b/app/Http/Controllers/API/BrandController.php @@ -0,0 +1,187 @@ +get(); + + return BrandCollection::make($brands); + } + + /** + * @OA\Get( + * path="/api/v2/brands/{id}", + * operationId="getBrandv2", + * tags={"Brands"}, + * summary="Get a Brand", + * description="Returns a single brand by id.", + * @OA\Parameter( + * name="id", + * in="path", + * required=true, + * @OA\Schema(type="integer") + * ), + * @OA\Response( + * response=200, + * description="Successful operation", + * @OA\JsonContent( + * @OA\Property(property="data", ref="#/components/schemas/Brand") + * ) + * ), + * @OA\Response(response=404, description="Brand not found") + * ) + */ + public function getBrandv2($id) + { + $brand = Brands::findOrFail($id); + + return Brand::make($brand); + } + + /** + * @OA\Post( + * path="/api/v2/brands", + * operationId="createBrandv2", + * tags={"Brands"}, + * summary="Create a Brand", + * description="Create a new device brand. Administrator only.", + * security={{"apiToken":{}}}, + * @OA\RequestBody( + * required=true, + * @OA\JsonContent( + * required={"brand_name"}, + * @OA\Property(property="brand_name", type="string", maxLength=255, example="Sony") + * ) + * ), + * @OA\Response( + * response=201, + * description="Brand created", + * @OA\JsonContent( + * @OA\Property(property="data", ref="#/components/schemas/Brand") + * ) + * ), + * @OA\Response(response=401, description="Unauthenticated"), + * @OA\Response(response=403, description="Forbidden"), + * @OA\Response(response=422, description="Validation failed") + * ) + */ + public function createBrandv2(Request $request): JsonResponse + { + if (!Fixometer::hasRole(Auth::user(), 'Administrator')) { + return response()->json(['message' => 'Forbidden'], 403); + } + + $validated = $request->validate([ + 'brand_name' => 'required|string|max:255|unique:brands,brand_name', + ]); + + $brand = Brands::create($validated); + + return response()->json(['data' => (new Brand($brand))->toArray($request)], 201); + } + + /** + * @OA\Put( + * path="/api/v2/brands/{id}", + * operationId="updateBrandv2", + * tags={"Brands"}, + * summary="Update a Brand", + * description="Update a brand. Administrator only.", + * security={{"apiToken":{}}}, + * @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")), + * @OA\RequestBody( + * required=true, + * @OA\JsonContent( + * required={"brand_name"}, + * @OA\Property(property="brand_name", type="string", maxLength=255, example="Sony") + * ) + * ), + * @OA\Response( + * response=200, + * description="Brand updated", + * @OA\JsonContent( + * @OA\Property(property="data", ref="#/components/schemas/Brand") + * ) + * ), + * @OA\Response(response=401, description="Unauthenticated"), + * @OA\Response(response=403, description="Forbidden"), + * @OA\Response(response=404, description="Brand not found"), + * @OA\Response(response=422, description="Validation failed") + * ) + */ + public function updateBrandv2(Request $request, $id) + { + if (!Fixometer::hasRole(Auth::user(), 'Administrator')) { + return response()->json(['message' => 'Forbidden'], 403); + } + + $brand = Brands::findOrFail($id); + + $validated = $request->validate([ + 'brand_name' => 'required|string|max:255|unique:brands,brand_name,' . $brand->id, + ]); + + $brand->update($validated); + + return Brand::make($brand->fresh()); + } + + /** + * @OA\Delete( + * path="/api/v2/brands/{id}", + * operationId="deleteBrandv2", + * tags={"Brands"}, + * summary="Delete a Brand", + * description="Delete a brand. Administrator only.", + * security={{"apiToken":{}}}, + * @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")), + * @OA\Response(response=204, description="Brand deleted"), + * @OA\Response(response=401, description="Unauthenticated"), + * @OA\Response(response=403, description="Forbidden"), + * @OA\Response(response=404, description="Brand not found") + * ) + */ + public function deleteBrandv2($id) + { + if (!Fixometer::hasRole(Auth::user(), 'Administrator')) { + return response()->json(['message' => 'Forbidden'], 403); + } + + $brand = Brands::findOrFail($id); + $brand->delete(); + + return response()->noContent(); + } +} diff --git a/app/Http/Controllers/API/CategoryController.php b/app/Http/Controllers/API/CategoryController.php new file mode 100644 index 0000000000..47fb74aa62 --- /dev/null +++ b/app/Http/Controllers/API/CategoryController.php @@ -0,0 +1,170 @@ +categoriesWithClusterName() + ->orderBy('categories.name', 'asc') + ->get(); + + return CategoryCollection::make($categories); + } + + /** + * @OA\Get( + * path="/api/v2/categories/{id}", + * operationId="getCategoryv2", + * tags={"Categories"}, + * summary="Get a Category", + * @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")), + * @OA\Response( + * response=200, + * description="Successful operation", + * @OA\JsonContent(@OA\Property(property="data", ref="#/components/schemas/Category")) + * ), + * @OA\Response(response=404, description="Category not found") + * ) + */ + public function getCategoryv2($id) + { + $category = $this->categoriesWithClusterName() + ->where('categories.idcategories', $id) + ->firstOrFail(); + + return CategoryResource::make($category); + } + + /** + * @OA\Put( + * path="/api/v2/categories/{id}", + * operationId="updateCategoryv2", + * tags={"Categories"}, + * summary="Update a Category", + * description="Administrator only.", + * security={{"apiToken":{}}}, + * @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")), + * @OA\RequestBody( + * required=true, + * @OA\JsonContent( + * required={"name"}, + * @OA\Property(property="name", type="string", maxLength=255), + * @OA\Property(property="weight", type="number", format="float", nullable=true), + * @OA\Property(property="footprint", type="number", format="float", nullable=true), + * @OA\Property(property="footprint_reliability", type="integer", minimum=1, maximum=6, nullable=true), + * @OA\Property(property="cluster", type="integer", nullable=true), + * @OA\Property(property="description_short", type="string", nullable=true) + * ) + * ), + * @OA\Response( + * response=200, + * description="Category updated", + * @OA\JsonContent(@OA\Property(property="data", ref="#/components/schemas/Category")) + * ), + * @OA\Response(response=401, description="Unauthenticated"), + * @OA\Response(response=403, description="Forbidden"), + * @OA\Response(response=404, description="Category not found"), + * @OA\Response(response=422, description="Validation failed") + * ) + */ + public function updateCategoryv2(Request $request, $id) + { + if (!Fixometer::hasRole(Auth::user(), 'Administrator')) { + return response()->json(['message' => 'Forbidden'], 403); + } + + $category = Category::findOrFail($id); + + $validated = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'weight' => ['nullable', 'numeric', 'min:0'], + 'footprint' => ['nullable', 'numeric', 'min:0'], + 'footprint_reliability' => ['nullable', 'integer', Rule::in([1, 2, 3, 4, 5, 6])], + 'cluster' => ['nullable', 'integer'], + 'description_short' => ['nullable', 'string'], + ]); + + $category->update($validated); + + $fresh = $this->categoriesWithClusterName() + ->where('categories.idcategories', $category->idcategories) + ->firstOrFail(); + + return CategoryResource::make($fresh); + } + + /** + * @OA\Get( + * path="/api/v2/category-clusters", + * operationId="listCategoryClustersv2", + * tags={"Categories"}, + * summary="List category clusters", + * description="Returns the cluster table (parent groupings for categories). Public endpoint, used to populate the cluster dropdown on the admin page.", + * @OA\Response( + * response=200, + * description="Successful operation", + * @OA\JsonContent( + * @OA\Property( + * property="data", + * type="array", + * @OA\Items( + * @OA\Property(property="id", type="integer", example=1), + * @OA\Property(property="name", type="string", example="Computers and Home Office") + * ) + * ) + * ) + * ) + * ) + */ + public function listCategoryClustersv2(): JsonResponse + { + $rows = DB::select('SELECT idclusters AS id, name FROM clusters ORDER BY idclusters ASC'); + + return response()->json([ + 'data' => array_map(fn ($r) => ['id' => (int) $r->id, 'name' => $r->name], $rows), + ]); + } + + /** + * Build the base query for categories joined with their cluster name. + * Scopes to the current revision (matches the legacy admin views). + */ + private const CURRENT_REVISION = 2; + + private function categoriesWithClusterName() + { + return Category::query() + ->select('categories.*', 'clusters.name as cluster_name') + ->leftJoin('clusters', 'clusters.idclusters', '=', 'categories.cluster') + ->where('categories.revision', self::CURRENT_REVISION); + } +} diff --git a/app/Http/Controllers/API/GroupTagController.php b/app/Http/Controllers/API/GroupTagController.php new file mode 100644 index 0000000000..6c1f5483b3 --- /dev/null +++ b/app/Http/Controllers/API/GroupTagController.php @@ -0,0 +1,201 @@ +orderBy('tag_name', 'asc')->get(); + + return TagCollection::make($tags); + } + + /** + * @OA\Get( + * path="/api/v2/group-tags/{id}", + * operationId="getGroupTagv2", + * tags={"GroupTags"}, + * summary="Get a global group tag", + * @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")), + * @OA\Response( + * response=200, + * description="Successful operation", + * @OA\JsonContent(@OA\Property(property="data", ref="#/components/schemas/Tag")) + * ), + * @OA\Response(response=404, description="Group tag not found (or is network-scoped)") + * ) + */ + public function getGroupTagv2($id) + { + $tag = $this->findGlobalOrFail($id); + + return Tag::make($tag); + } + + /** + * @OA\Post( + * path="/api/v2/group-tags", + * operationId="createGroupTagv2", + * tags={"GroupTags"}, + * summary="Create a global group tag", + * description="Administrator only.", + * security={{"apiToken":{}}}, + * @OA\RequestBody( + * required=true, + * @OA\JsonContent( + * required={"name"}, + * @OA\Property(property="name", type="string", maxLength=255, example="Scotland"), + * @OA\Property(property="description", type="string", maxLength=1000, nullable=true) + * ) + * ), + * @OA\Response( + * response=201, + * description="Group tag created", + * @OA\JsonContent(@OA\Property(property="data", ref="#/components/schemas/Tag")) + * ), + * @OA\Response(response=401, description="Unauthenticated"), + * @OA\Response(response=403, description="Forbidden"), + * @OA\Response(response=422, description="Validation failed") + * ) + */ + public function createGroupTagv2(Request $request): JsonResponse + { + if (!Fixometer::hasRole(Auth::user(), 'Administrator')) { + return response()->json(['message' => 'Forbidden'], 403); + } + + $validated = $request->validate($this->validationRules()); + + $tag = GroupTags::create([ + 'tag_name' => $validated['name'], + 'description' => $validated['description'] ?? null, + 'network_id' => null, + ]); + + return response()->json(['data' => (new Tag($tag))->toArray($request)], 201); + } + + /** + * @OA\Put( + * path="/api/v2/group-tags/{id}", + * operationId="updateGroupTagv2", + * tags={"GroupTags"}, + * summary="Update a global group tag", + * description="Administrator only. Network-scoped tags must be updated via /api/v2/networks/{id}/tags.", + * security={{"apiToken":{}}}, + * @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")), + * @OA\RequestBody( + * required=true, + * @OA\JsonContent( + * required={"name"}, + * @OA\Property(property="name", type="string", maxLength=255), + * @OA\Property(property="description", type="string", nullable=true) + * ) + * ), + * @OA\Response( + * response=200, + * description="Group tag updated", + * @OA\JsonContent(@OA\Property(property="data", ref="#/components/schemas/Tag")) + * ), + * @OA\Response(response=401, description="Unauthenticated"), + * @OA\Response(response=403, description="Forbidden"), + * @OA\Response(response=404, description="Group tag not found (or is network-scoped)"), + * @OA\Response(response=422, description="Validation failed") + * ) + */ + public function updateGroupTagv2(Request $request, $id) + { + if (!Fixometer::hasRole(Auth::user(), 'Administrator')) { + return response()->json(['message' => 'Forbidden'], 403); + } + + $tag = $this->findGlobalOrFail($id); + + $validated = $request->validate($this->validationRules($tag->id)); + + $tag->update([ + 'tag_name' => $validated['name'], + 'description' => $validated['description'] ?? null, + ]); + + return Tag::make($tag->fresh()); + } + + /** + * @OA\Delete( + * path="/api/v2/group-tags/{id}", + * operationId="deleteGroupTagv2", + * tags={"GroupTags"}, + * summary="Delete a global group tag", + * description="Administrator only. Network-scoped tags must be deleted via /api/v2/networks/{id}/tags.", + * security={{"apiToken":{}}}, + * @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")), + * @OA\Response(response=204, description="Group tag deleted"), + * @OA\Response(response=401, description="Unauthenticated"), + * @OA\Response(response=403, description="Forbidden"), + * @OA\Response(response=404, description="Group tag not found (or is network-scoped)") + * ) + */ + public function deleteGroupTagv2($id) + { + if (!Fixometer::hasRole(Auth::user(), 'Administrator')) { + return response()->json(['message' => 'Forbidden'], 403); + } + + $tag = $this->findGlobalOrFail($id); + $tag->delete(); + + return response()->noContent(); + } + + /** + * Look up a global group tag, or throw a ModelNotFoundException so the + * caller cannot use this endpoint to reach into a network's tags. + */ + private function findGlobalOrFail(int $id): GroupTags + { + return GroupTags::global()->findOrFail($id); + } + + private function validationRules($ignoreId = null): array + { + // Uniqueness only within the global scope: a global tag and a + // network-scoped tag can share a name (they live in different scopes). + $uniqueRule = Rule::unique('group_tags', 'tag_name')->whereNull('network_id'); + if ($ignoreId !== null) { + $uniqueRule = $uniqueRule->ignore($ignoreId); + } + + return [ + 'name' => ['required', 'string', 'max:255', $uniqueRule], + 'description' => ['nullable', 'string', 'max:1000'], + ]; + } +} diff --git a/app/Http/Controllers/API/RoleController.php b/app/Http/Controllers/API/RoleController.php new file mode 100644 index 0000000000..4f83d92771 --- /dev/null +++ b/app/Http/Controllers/API/RoleController.php @@ -0,0 +1,219 @@ +json(['message' => 'Forbidden'], 403); + } + + $rows = (new Role)->findAll(); + $permsByRole = $this->permissionsByRole(); + + $data = array_map(function ($row) use ($permsByRole) { + return (new RoleAdmin([ + 'id' => $row->id, + 'name' => $row->role, + 'permissions' => $permsByRole[$row->id] ?? [], + 'permissions_list' => $row->permissions_list ?? '', + ]))->toArray(request()); + }, $rows); + + return response()->json(['data' => $data]); + } + + /** + * @OA\Get( + * path="/api/v2/roles/{id}", + * operationId="getRolev2", + * tags={"Roles"}, + * summary="Get a single role", + * description="Administrator only.", + * security={{"apiToken":{}}}, + * @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")), + * @OA\Response( + * response=200, + * description="Successful operation", + * @OA\JsonContent(@OA\Property(property="data", ref="#/components/schemas/RoleAdmin")) + * ), + * @OA\Response(response=401, description="Unauthenticated"), + * @OA\Response(response=403, description="Forbidden"), + * @OA\Response(response=404, description="Role not found") + * ) + */ + public function getRolev2($id): JsonResponse + { + if (!Fixometer::hasRole(Auth::user(), 'Administrator')) { + return response()->json(['message' => 'Forbidden'], 403); + } + + $role = $this->findRoleOr404($id); + $permissions = array_map( + fn ($p) => (int) $p->idpermissions, + (new Role)->rolePermissions($role->idroles) + ); + + $names = DB::select( + 'SELECT GROUP_CONCAT(permission ORDER BY permission SEPARATOR ", ") AS lst + FROM permissions + WHERE idpermissions IN (' . (count($permissions) ? implode(',', array_fill(0, count($permissions), '?')) : 'NULL') . ')', + $permissions + ); + $list = $names && isset($names[0]->lst) ? (string) $names[0]->lst : ''; + + return response()->json([ + 'data' => (new RoleAdmin([ + 'id' => $role->idroles, + 'name' => $role->role, + 'permissions' => $permissions, + 'permissions_list' => $list, + ]))->toArray(request()), + ]); + } + + /** + * @OA\Get( + * path="/api/v2/permissions", + * operationId="listPermissionsv2", + * tags={"Roles"}, + * summary="List all permissions", + * description="Administrator only. Used to populate the role permission matrix.", + * security={{"apiToken":{}}}, + * @OA\Response( + * response=200, + * description="Successful operation", + * @OA\JsonContent( + * @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/Permission")) + * ) + * ), + * @OA\Response(response=401, description="Unauthenticated"), + * @OA\Response(response=403, description="Forbidden") + * ) + */ + public function listPermissionsv2(): JsonResponse + { + if (!Fixometer::hasRole(Auth::user(), 'Administrator')) { + return response()->json(['message' => 'Forbidden'], 403); + } + + $rows = DB::select('SELECT idpermissions AS id, permission AS name FROM permissions ORDER BY idpermissions ASC'); + $data = array_map( + fn ($r) => (new Permission(['id' => $r->id, 'name' => $r->name]))->toArray(request()), + $rows + ); + + return response()->json(['data' => $data]); + } + + /** + * @OA\Put( + * path="/api/v2/roles/{id}/permissions", + * operationId="updateRolePermissionsv2", + * tags={"Roles"}, + * summary="Replace the permissions granted to a role", + * description="Administrator only. Sends the full set of permission IDs; the server replaces the role's grants atomically.", + * security={{"apiToken":{}}}, + * @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")), + * @OA\RequestBody( + * required=true, + * @OA\JsonContent( + * required={"permissions"}, + * @OA\Property( + * property="permissions", + * type="array", + * @OA\Items(type="integer"), + * example={4, 6} + * ) + * ) + * ), + * @OA\Response( + * response=200, + * description="Permissions replaced", + * @OA\JsonContent(@OA\Property(property="data", ref="#/components/schemas/RoleAdmin")) + * ), + * @OA\Response(response=401, description="Unauthenticated"), + * @OA\Response(response=403, description="Forbidden"), + * @OA\Response(response=404, description="Role not found"), + * @OA\Response(response=422, description="Validation failed") + * ) + */ + public function updateRolePermissionsv2(Request $request, $id): JsonResponse + { + if (!Fixometer::hasRole(Auth::user(), 'Administrator')) { + return response()->json(['message' => 'Forbidden'], 403); + } + + $role = $this->findRoleOr404($id); + + $validated = $request->validate([ + 'permissions' => ['present', 'array'], + 'permissions.*' => ['integer', 'exists:permissions,idpermissions'], + ]); + + // Wrap the delete-then-reinsert in a transaction so a mid-update failure + // can't leave the role with a partial permission set. + $ok = DB::transaction(function () use ($role, $validated) { + return (new Role)->edit($role->idroles, array_map('intval', $validated['permissions'])); + }); + if (!$ok) { + return response()->json(['message' => 'Could not update permissions'], 500); + } + + return $this->getRolev2($role->idroles); + } + + private function findRoleOr404($id): Role + { + $role = Role::where('idroles', $id)->first(); + if (!$role) { + throw new NotFoundHttpException('Role not found.'); + } + return $role; + } + + /** + * One query → map of role id → list of permission ids granted to that role. + */ + private function permissionsByRole(): array + { + $rows = DB::select('SELECT role, permission FROM roles_permissions'); + $out = []; + foreach ($rows as $r) { + $out[(int) $r->role][] = (int) $r->permission; + } + return $out; + } +} diff --git a/app/Http/Controllers/API/SkillController.php b/app/Http/Controllers/API/SkillController.php new file mode 100644 index 0000000000..0762279068 --- /dev/null +++ b/app/Http/Controllers/API/SkillController.php @@ -0,0 +1,191 @@ +get(); + + return SkillCollection::make($skills); + } + + /** + * @OA\Get( + * path="/api/v2/skills/{id}", + * operationId="getSkillv2", + * tags={"Skills"}, + * summary="Get a Skill", + * @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")), + * @OA\Response( + * response=200, + * description="Successful operation", + * @OA\JsonContent(@OA\Property(property="data", ref="#/components/schemas/Skill")) + * ), + * @OA\Response(response=404, description="Skill not found") + * ) + */ + public function getSkillv2($id) + { + $skill = Skills::findOrFail($id); + + return Skill::make($skill); + } + + /** + * @OA\Post( + * path="/api/v2/skills", + * operationId="createSkillv2", + * tags={"Skills"}, + * summary="Create a Skill", + * description="Administrator only.", + * security={{"apiToken":{}}}, + * @OA\RequestBody( + * required=true, + * @OA\JsonContent( + * required={"skill_name","category"}, + * @OA\Property(property="skill_name", type="string", maxLength=255, example="Soldering"), + * @OA\Property(property="category", type="integer", description="1 = Organising, 2 = Technical", example=2), + * @OA\Property(property="description", type="string", nullable=true, example="Surface-mount component rework") + * ) + * ), + * @OA\Response( + * response=201, + * description="Skill created", + * @OA\JsonContent(@OA\Property(property="data", ref="#/components/schemas/Skill")) + * ), + * @OA\Response(response=401, description="Unauthenticated"), + * @OA\Response(response=403, description="Forbidden"), + * @OA\Response(response=422, description="Validation failed") + * ) + */ + public function createSkillv2(Request $request): JsonResponse + { + if (!Fixometer::hasRole(Auth::user(), 'Administrator')) { + return response()->json(['message' => 'Forbidden'], 403); + } + + $validated = $request->validate($this->validationRules()); + + $skill = Skills::create($validated); + + return response()->json(['data' => (new Skill($skill))->toArray($request)], 201); + } + + /** + * @OA\Put( + * path="/api/v2/skills/{id}", + * operationId="updateSkillv2", + * tags={"Skills"}, + * summary="Update a Skill", + * description="Administrator only.", + * security={{"apiToken":{}}}, + * @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")), + * @OA\RequestBody( + * required=true, + * @OA\JsonContent( + * required={"skill_name","category"}, + * @OA\Property(property="skill_name", type="string", maxLength=255, example="Soldering"), + * @OA\Property(property="category", type="integer", example=2), + * @OA\Property(property="description", type="string", nullable=true) + * ) + * ), + * @OA\Response( + * response=200, + * description="Skill updated", + * @OA\JsonContent(@OA\Property(property="data", ref="#/components/schemas/Skill")) + * ), + * @OA\Response(response=401, description="Unauthenticated"), + * @OA\Response(response=403, description="Forbidden"), + * @OA\Response(response=404, description="Skill not found"), + * @OA\Response(response=422, description="Validation failed") + * ) + */ + public function updateSkillv2(Request $request, $id) + { + if (!Fixometer::hasRole(Auth::user(), 'Administrator')) { + return response()->json(['message' => 'Forbidden'], 403); + } + + $skill = Skills::findOrFail($id); + + $validated = $request->validate($this->validationRules($skill->id)); + + $skill->update($validated); + + return Skill::make($skill->fresh()); + } + + /** + * @OA\Delete( + * path="/api/v2/skills/{id}", + * operationId="deleteSkillv2", + * tags={"Skills"}, + * summary="Delete a Skill", + * description="Administrator only. Also removes any users_skills pivot rows referencing this skill.", + * security={{"apiToken":{}}}, + * @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")), + * @OA\Response(response=204, description="Skill deleted"), + * @OA\Response(response=401, description="Unauthenticated"), + * @OA\Response(response=403, description="Forbidden"), + * @OA\Response(response=404, description="Skill not found") + * ) + */ + public function deleteSkillv2($id) + { + if (!Fixometer::hasRole(Auth::user(), 'Administrator')) { + return response()->json(['message' => 'Forbidden'], 403); + } + + $skill = Skills::findOrFail($id); + + if ($skill->delete()) { + UsersSkills::where('skill', $skill->id)->delete(); + } + + return response()->noContent(); + } + + private function validationRules($ignoreId = null): array + { + $allowedCategories = array_map('intval', array_keys(Fixometer::skillCategories())); + $uniqueRule = Rule::unique('skills', 'skill_name'); + if ($ignoreId !== null) { + $uniqueRule = $uniqueRule->ignore($ignoreId); + } + + return [ + 'skill_name' => ['required', 'string', 'max:255', $uniqueRule], + 'category' => ['required', 'integer', Rule::in($allowedCategories)], + 'description' => ['nullable', 'string', 'max:255'], + ]; + } +} diff --git a/app/Http/Controllers/BrandsController.php b/app/Http/Controllers/BrandsController.php index ebbf752ec1..62839f07e0 100644 --- a/app/Http/Controllers/BrandsController.php +++ b/app/Http/Controllers/BrandsController.php @@ -2,77 +2,42 @@ namespace App\Http\Controllers; -use Illuminate\Http\RedirectResponse; use App\Brands; use App\Helpers\Fixometer; use Auth; -use Illuminate\Http\Request; -use Illuminate\Support\Facades\Redirect; class BrandsController extends Controller { - public function index() + /** + * Render the brands admin page (a Vue SPA that talks to /api/v2/brands). + * All create/edit/delete now goes through the API. + * + * @param int|null $editId Optional brand id to pre-open in the edit modal + * (used by the legacy /brands/edit/{id} bookmark). + */ + public function index($editId = null) { - if (! Fixometer::hasRole(Auth::user(), 'Administrator')) { - return redirect('/user/forbidden'); - } + $user = Auth::user(); - $all_brands = Brands::orderBy('brand_name', 'asc')->get(); - - return view('brands.index', [ - 'title' => 'Brands', - 'brands' => $all_brands, - ]); - } - - public function postCreateBrand(Request $request): RedirectResponse - { - if (! Fixometer::hasRole(Auth::user(), 'Administrator')) { - return redirect('/user/forbidden'); - } - - $brand = Brands::create([ - 'brand_name' => $request->input('brand_name'), - ]); - - return Redirect::to('brands/edit/'.$brand->id)->with('success', __('brands.create_success')); - } - - public function getEditBrand($id) - { - if (! Fixometer::hasRole(Auth::user(), 'Administrator')) { + if (! Fixometer::hasRole($user, 'Administrator')) { return redirect('/user/forbidden'); } - $brand = Brands::find($id); - - return view('brands.edit', [ - 'title' => 'Edit Brand', - 'brand' => $brand, - ]); - } + $all_brands = Brands::orderBy('brand_name', 'asc')->get(); - public function postEditBrand($id, Request $request): RedirectResponse - { - if (! Fixometer::hasRole(Auth::user(), 'Administrator')) { - return redirect('/user/forbidden'); - } + $brandsForVue = $all_brands->map(function ($brand) { + return [ + 'id' => $brand->id, + 'brand_name' => $brand->brand_name, + ]; + })->values(); - Brands::find($id)->update([ - 'brand_name' => $request->input('brand-name'), + return view('brands.index', [ + 'title' => 'Brands', + 'brands' => $all_brands, + 'brandsForVue' => $brandsForVue, + 'apiToken' => $user->api_token, + 'editId' => $editId !== null ? (int) $editId : null, ]); - - return Redirect::back()->with('success', __('brands.update_success')); - } - - public function getDeleteBrand($id): RedirectResponse - { - if (! Fixometer::hasRole(Auth::user(), 'Administrator')) { - return redirect('/user/forbidden'); - } - - Brands::find($id)->delete(); - - return Redirect::back()->with('message', __('brands.delete_success')); } } diff --git a/app/Http/Controllers/CategoryController.php b/app/Http/Controllers/CategoryController.php index c853476dec..2461da7305 100644 --- a/app/Http/Controllers/CategoryController.php +++ b/app/Http/Controllers/CategoryController.php @@ -2,107 +2,70 @@ namespace App\Http\Controllers; -use Illuminate\View\View; -use Illuminate\Http\RedirectResponse; -use App\Category; use App\Helpers\Fixometer; -use App\User; use Auth; -use Illuminate\Http\Request; -use Illuminate\Support\Facades\Redirect; +use DB; +use Illuminate\View\View; class CategoryController extends Controller { - public function index(): View + /** + * Render the categories admin page (a Vue SPA that talks to /api/v2/categories). + * Public list view; edit/save require administrator and happen via the API. + */ + public function index($editId = null): View { - $Category = new Category; - $list = $Category->findAll(); - $clusters = $Category->listed(); - - // Prepare data for Vue table - $tableData = []; - foreach ($list as $category) { - // Find cluster name - $clusterName = null; - if (!empty($category->cluster)) { - foreach ($clusters as $cluster) { - if ($cluster->idclusters == $category->cluster) { - $clusterName = $cluster->name; - break; - } - } - } - - // Prepare reliability badge HTML - $reliability = $category->footprint_reliability ?? 6; - $colors = [ - 1 => '#AD2C1C', - 2 => '#FF1B00', - 3 => '#FFBA00', - 4 => '#43B136', - 5 => '#26781C', - 6 => '#FFBA00', - ]; - $color = $colors[$reliability] ?? '#FFBA00'; - $reliabilityHtml = '' . __('admin.reliability-' . $reliability) . ''; + // Fetch categories joined with cluster name, scoped to the current revision + // (mirrors API\CategoryController). Doing it here means the Vue admin doesn't + // have to round-trip the API on first paint. + $categories = DB::select(<<<'SQL' + SELECT c.idcategories AS id, + c.name, + c.powered, + c.weight, + c.footprint, + c.footprint_reliability, + c.cluster, + c.description_short, + cl.name AS cluster_name + FROM categories c + LEFT JOIN clusters cl ON cl.idclusters = c.cluster + WHERE c.revision = (SELECT MAX(revision) FROM categories) + ORDER BY c.name ASC + SQL); - $tableData[] = [ - 'idcategories' => $category->idcategories, - 'name' => $category->name, - 'cluster' => $clusterName, - 'cluster_name' => $clusterName, - 'weight' => $category->weight, - 'footprint' => $category->footprint, - 'footprint_html' => $category->footprint, - 'reliability' => $reliabilityHtml, + $categoriesForVue = array_map(function ($row) { + return [ + 'id' => (int) $row->id, + 'name' => $row->name, + 'powered' => $row->powered !== null ? (bool) $row->powered : null, + 'weight' => $row->weight !== null ? (float) $row->weight : null, + 'footprint' => $row->footprint !== null ? (float) $row->footprint : null, + 'footprint_reliability' => $row->footprint_reliability !== null ? (int) $row->footprint_reliability : null, + 'cluster' => $row->cluster !== null ? (int) $row->cluster : null, + 'cluster_name' => $row->cluster_name, + 'description_short' => $row->description_short, ]; - } + }, $categories); - return view('category.index', [ - 'list' => $list, - 'categories' => $clusters, - 'tableData' => $tableData, - ]); - } + $clusters = array_map( + fn ($r) => ['id' => (int) $r->idclusters, 'name' => $r->name], + DB::select('SELECT idclusters, name FROM clusters ORDER BY idclusters ASC') + ); - public function getEditCategory($id) - { - if (! Fixometer::hasRole(Auth::user(), 'Administrator')) { - return redirect('/user/forbidden'); + $reliabilityOptions = []; + foreach (Fixometer::footprintReliability() as $k => $_v) { + $reliabilityOptions[$k] = __('admin.reliability-' . $k); } - $category = Category::find($id); + $user = Auth::user(); - $c = new Category; - $categories = $c->listed(); - - return view('category.edit', [ - 'title' => 'Edit Category', - 'category' => $category, - 'categories' => $categories, + return view('category.index', [ + 'categoriesForVue' => $categoriesForVue, + 'clusters' => $clusters, + 'reliabilityOptions' => $reliabilityOptions, + 'apiToken' => $user ? $user->api_token : '', + 'editId' => $editId !== null ? (int) $editId : null, ]); } - - public function postEditCategory($id, Request $request): RedirectResponse - { - if (! Fixometer::hasRole(Auth::user(), 'Administrator')) { - return redirect('/user/forbidden'); - } - - try { - $category = Category::find($id); - $category->update([ - 'name' => $request->input('category_name'), - 'weight' => $request->input('weight'), - 'footprint' => $request->input('co2_footprint'), - 'footprint_reliability' => $request->input('reliability'), - 'cluster' => $request->input('category_cluster'), - 'description_short' => $request->input('categories_desc') - ]); - } catch (\Exception $e) { - return redirect()->back()->with('danger', __('category.update_error')); - } - - return redirect()->back()->with('success', __('category.update_success')); - } } diff --git a/app/Http/Controllers/GroupTagsController.php b/app/Http/Controllers/GroupTagsController.php index 9a616d6225..aa6446238c 100644 --- a/app/Http/Controllers/GroupTagsController.php +++ b/app/Http/Controllers/GroupTagsController.php @@ -2,85 +2,34 @@ namespace App\Http\Controllers; -use Illuminate\Http\RedirectResponse; use App\GroupTags; use App\Helpers\Fixometer; +use App\Http\Resources\Tag; use Auth; -use Illuminate\Http\Request; -use Illuminate\Support\Facades\Redirect; class GroupTagsController extends Controller { - public function index() + /** + * Render the global group-tags admin page (a Vue SPA that talks to + * /api/v2/group-tags). Network-scoped tags are managed elsewhere. + */ + public function index($editId = null) { - if (! Fixometer::hasRole(Auth::user(), 'Administrator')) { - return redirect('/user/forbidden'); - } - - $all_tags = GroupTags::all(); - - return view('tags.index', [ - 'title' => __('group-tags.title'), - 'tags' => $all_tags, - ]); - } - - public function postCreateTag(Request $request): RedirectResponse - { - if (! Fixometer::hasRole(Auth::user(), 'Administrator')) { - return redirect('/user/forbidden'); - } - - $name = $request->input('tag-name'); - $description = $request->input('tag-description'); - - $group_tag = GroupTags::create([ - 'tag_name' => $name, - 'description' => $description, - ]); - - return Redirect::to('tags/edit/'.$group_tag->id)->with('success', __('group-tags.create_success')); - } - - public function getEditTag($id) - { - if (! Fixometer::hasRole(Auth::user(), 'Administrator')) { - return redirect('/user/forbidden'); - } + $user = Auth::user(); - $tag = GroupTags::find($id); - - return view('tags.edit', [ - 'title' => __('group-tags.edit_tag'), - 'tag' => $tag, - ]); - } - - public function postEditTag($id, Request $request): RedirectResponse - { - if (! Fixometer::hasRole(Auth::user(), 'Administrator')) { + if (! Fixometer::hasRole($user, 'Administrator')) { return redirect('/user/forbidden'); } - $name = $request->input('tag-name'); - $description = $request->input('tag-description'); + $tags = GroupTags::global()->orderBy('tag_name', 'asc')->get(); + $tagsForVue = $tags->map(fn ($tag) => (new Tag($tag))->toArray(request()))->values(); - GroupTags::find($id)->update([ - 'tag_name' => $name, - 'description' => $description, + return view('tags.index', [ + 'title' => __('group-tags.title'), + 'tags' => $tags, + 'tagsForVue' => $tagsForVue, + 'apiToken' => $user->api_token, + 'editId' => $editId !== null ? (int) $editId : null, ]); - - return Redirect::back()->with('success', __('group-tags.update_success')); - } - - public function getDeleteTag($id): RedirectResponse - { - if (! Fixometer::hasRole(Auth::user(), 'Administrator')) { - return redirect('/user/forbidden'); - } - - GroupTags::find($id)->delete(); - - return Redirect::to('/tags')->with('success', __('group-tags.delete_success')); } } diff --git a/app/Http/Controllers/RoleController.php b/app/Http/Controllers/RoleController.php index 6f98bd2ee0..ad7418b6e3 100644 --- a/app/Http/Controllers/RoleController.php +++ b/app/Http/Controllers/RoleController.php @@ -2,88 +2,65 @@ namespace App\Http\Controllers; -use Illuminate\View\View; use App\Helpers\Fixometer; use App\Providers\RouteServiceProvider; use App\Role; -use App\RolePermissions; -use App\User; use Auth; -use Illuminate\Http\Request; +use DB; class RoleController extends Controller { - //Custom Functions - public function index() + /** + * Render the roles admin page (a Vue SPA that talks to /api/v2/roles + * and /api/v2/permissions). The permission matrix lives in a modal. + * + * @param int|null $editId Optional role id to pre-open in the edit modal + * (used by the legacy /role/edit/{id} bookmark). + */ + public function index($editId = null) { - $user = User::find(Auth::id()); + $user = Auth::user(); - if (Fixometer::hasRole($user, 'Administrator')) { - //Send user to roles page - // $this->set('title', 'Roles'); - // $this->set('roleList', $this->Role->findAll()); + if (! Fixometer::hasRole($user, 'Administrator')) { + return redirect(RouteServiceProvider::HOME); + } - $Role = new Role; - $roleList = $Role->findAll(); + $rolesRaw = (new Role)->findAll(); + $permsByRole = $this->permissionsByRole(); - // Prepare data for Vue table - $tableData = []; - foreach ($roleList as $role) { - $tableData[] = [ - 'id' => $role->id, - 'role' => $role->role, - 'permissions_list' => $role->permissions_list, - ]; - } + $rolesForVue = array_map(function ($row) use ($permsByRole) { + return [ + 'id' => (int) $row->id, + 'name' => $row->role, + 'permissions' => $permsByRole[$row->id] ?? [], + 'permissions_list' => $row->permissions_list ?? '', + ]; + }, $rolesRaw); - return view('role.all', [//role.index - 'title' => 'Roles', - 'roleList' => $roleList, - 'tableData' => $tableData, - ]); - } + $permissions = array_map( + fn ($r) => ['id' => (int) $r->idpermissions, 'name' => $r->permission], + (new Role)->permissions() + ); - return redirect(RouteServiceProvider::HOME); + return view('role.all', [ + 'title' => 'Roles', + 'rolesForVue' => $rolesForVue, + 'permissions' => $permissions, + 'apiToken' => $user->api_token, + 'editId' => $editId !== null ? (int) $editId : null, + ]); } - public function edit($id, Request $request): View + /** + * One query → map of role id → list of permission ids granted to that role. + */ + private function permissionsByRole(): array { - $user = Auth::user(); - - if (Fixometer::hasRole($user, 'Administrator')) { - $role = Role::where('idroles', $id)->first(); - - if ($request->getMethod() == 'POST') { - $permissions = $request->get('permissions'); - $formid = (int) substr(strrchr($request->get('formId'), '_'), 1); - - $update = $role->edit($formid, $permissions); - if (! $update) { - $response['danger'] = 'Something went wrong. Could not update the permissions.'; - \Sentry\CaptureMessage($response['danger']); - } else { - $response['success'] = 'Permissions for this Role have been updated.'; - } - } - - $permissionsList = $role->rolePermissions($role->idroles); - $activePerms = []; - foreach ($permissionsList as $p) { - $activePerms[] = $p->permission; - } - - if (! isset($response)) { - $response = null; - } - - return view('role.edit', [ - 'response' => $response, - 'title' => 'Edit '.$role->role.' Role', - 'formId' => $role->idroles, - 'permissions' => $role->permissions(), - 'activePermissions' => $activePerms, - 'role_name' => $role->role, - ]); + $rows = DB::select('SELECT role, permission FROM roles_permissions'); + $out = []; + foreach ($rows as $r) { + $out[(int) $r->role][] = (int) $r->permission; } + return $out; } } diff --git a/app/Http/Controllers/SkillsController.php b/app/Http/Controllers/SkillsController.php index 89331d84ff..b918458a93 100644 --- a/app/Http/Controllers/SkillsController.php +++ b/app/Http/Controllers/SkillsController.php @@ -2,90 +2,45 @@ namespace App\Http\Controllers; -use Illuminate\Http\RedirectResponse; use App\Helpers\Fixometer; use App\Skills; -use App\UsersSkills; use Auth; -use Illuminate\Http\Request; -use Illuminate\Support\Facades\Redirect; class SkillsController extends Controller { - public function index() + /** + * Render the skills admin page (a Vue SPA that talks to /api/v2/skills). + * All create/edit/delete now goes through the API. + * + * @param int|null $editId Optional skill id to pre-open in the edit modal + * (used by the legacy /skills/edit/{id} bookmark). + */ + public function index($editId = null) { - if (! Fixometer::hasRole(Auth::user(), 'Administrator')) { - return redirect('/user/forbidden'); - } - - $all_skills = Skills::all(); + $user = Auth::user(); - return view('skills.index', [ - 'title' => 'Skills', - 'skills' => $all_skills, - ]); - } - - public function postCreateSkill(Request $request): RedirectResponse - { - if (! Fixometer::hasRole(Auth::user(), 'Administrator')) { + if (! Fixometer::hasRole($user, 'Administrator')) { return redirect('/user/forbidden'); } - $skill = Skills::create([ - 'skill_name' => $request->input('skill_name'), - 'description' => $request->input('skill_desc'), - ]); - - return Redirect::to('skills/edit/'.$skill->id)->with('success', __('skills.create_success')); - } - - public function getEditSkill($id) - { - if (! Fixometer::hasRole(Auth::user(), 'Administrator')) { - return redirect('/user/forbidden'); - } - - $skill = Skills::find($id); - - return view('skills.edit', [ - 'title' => 'Edit Skill', - 'skill' => $skill, - ]); - } + $all_skills = Skills::orderBy('skill_name', 'asc')->get(); - public function postEditSkill($id, Request $request): RedirectResponse - { - if (! Fixometer::hasRole(Auth::user(), 'Administrator')) { - return redirect('/user/forbidden'); - } + $skillsForVue = $all_skills->map(function ($skill) { + return [ + 'id' => $skill->id, + 'skill_name' => $skill->skill_name, + 'category' => $skill->category !== null ? (int) $skill->category : null, + 'description' => $skill->description, + ]; + })->values(); - Skills::find($id)->update([ - 'skill_name' => $request->input('skill-name'), - 'category' => $request->input('category'), - 'description' => $request->input('skill-description'), + return view('skills.index', [ + 'title' => 'Skills', + 'skills' => $all_skills, + 'skillsForVue' => $skillsForVue, + 'skillCategories' => Fixometer::skillCategories(), + 'apiToken' => $user->api_token, + 'editId' => $editId !== null ? (int) $editId : null, ]); - - return Redirect::back()->with('success', __('skills.update_success')); - } - - public function getDeleteSkill($id): RedirectResponse - { - - // Are you an admin? - if (! Fixometer::hasRole(Auth::user(), 'Administrator')) { - return redirect('/user/forbidden'); - } - - // If we have permission, let's delete - $skill = Skills::find($id)->delete(); - - // We can only delete the data in the pivot table if the delete was successful - if ($skill == 1) { - UsersSkills::where('skill', $id)->delete(); - } - - // Then redirect back - return Redirect::to('/skills')->with('success', __('skills.delete_success')); } } diff --git a/app/Http/Resources/Brand.php b/app/Http/Resources/Brand.php new file mode 100644 index 0000000000..13ac62d2c8 --- /dev/null +++ b/app/Http/Resources/Brand.php @@ -0,0 +1,38 @@ + $this->id, + 'brand_name' => $this->brand_name, + ]; + } +} diff --git a/app/Http/Resources/BrandCollection.php b/app/Http/Resources/BrandCollection.php new file mode 100644 index 0000000000..e0a707c703 --- /dev/null +++ b/app/Http/Resources/BrandCollection.php @@ -0,0 +1,27 @@ + $this->idcategories, 'name' => $this->name, - 'powered' => $this->powered, + 'powered' => $this->powered !== null ? (bool) $this->powered : null, + 'weight' => $this->weight !== null ? (float) $this->weight : null, + 'footprint' => $this->footprint !== null ? (float) $this->footprint : null, + 'footprint_reliability' => $this->footprint_reliability !== null ? (int) $this->footprint_reliability : null, + 'cluster' => $this->cluster !== null ? (int) $this->cluster : null, + 'cluster_name' => $this->cluster_name ?? null, + 'description_short' => $this->description_short, ]; } } diff --git a/app/Http/Resources/CategoryCollection.php b/app/Http/Resources/CategoryCollection.php new file mode 100644 index 0000000000..bc8004bee2 --- /dev/null +++ b/app/Http/Resources/CategoryCollection.php @@ -0,0 +1,27 @@ + (int) $this['id'], + 'name' => $this['name'], + ]; + } +} diff --git a/app/Http/Resources/RoleAdmin.php b/app/Http/Resources/RoleAdmin.php new file mode 100644 index 0000000000..0e845a9bfe --- /dev/null +++ b/app/Http/Resources/RoleAdmin.php @@ -0,0 +1,40 @@ + (int) $this['id'], + 'name' => $this['name'], + 'permissions' => array_map('intval', $this['permissions']), + 'permissions_list' => $this['permissions_list'] ?? '', + ]; + } +} diff --git a/app/Http/Resources/Skill.php b/app/Http/Resources/Skill.php index 7dd62cea1a..8623525710 100644 --- a/app/Http/Resources/Skill.php +++ b/app/Http/Resources/Skill.php @@ -18,8 +18,8 @@ * example=1 * ), * @OA\Property( - * property="name", - * title="name", + * property="skill_name", + * title="skill_name", * description="Name of this skill", * format="string", * example="First aid" @@ -29,14 +29,16 @@ * title="description", * description="Optional description of this skill", * format="string", - * example="This is for qualified First Aiders to identify themselves to event organisers" + * example="This is for qualified First Aiders to identify themselves to event organisers", + * nullable=true * ), * @OA\Property( * property="category", * title="category", - * description="Category of this skill", + * description="Category of this skill (1 = Organising, 2 = Technical; see Fixometer::skillCategories())", * format="int64", - * example=1 + * example=1, + * nullable=true * ), * ) */ @@ -50,9 +52,9 @@ public function toArray(Request $request): array { return [ 'id' => $this->id, - 'name' => $this->skill_name, + 'skill_name' => $this->skill_name, 'description' => $this->description, - 'category' => $this->category, + 'category' => $this->category !== null ? (int) $this->category : null, ]; } } diff --git a/app/Http/Resources/SkillCollection.php b/app/Http/Resources/SkillCollection.php index 80d412f4a3..8725327847 100644 --- a/app/Http/Resources/SkillCollection.php +++ b/app/Http/Resources/SkillCollection.php @@ -19,6 +19,8 @@ class SkillCollection extends ResourceCollection { + public $collects = \App\Http\Resources\Skill::class; + /** * Transform the resource collection into an array. */ diff --git a/app/Skills.php b/app/Skills.php index 5aca694fd0..92d6792852 100644 --- a/app/Skills.php +++ b/app/Skills.php @@ -3,10 +3,13 @@ namespace App; use DB; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; class Skills extends Model { + use HasFactory; + protected $table = 'skills'; /** * The attributes that are mass assignable. diff --git a/database/factories/BrandsFactory.php b/database/factories/BrandsFactory.php new file mode 100644 index 0000000000..6bd71bf07f --- /dev/null +++ b/database/factories/BrandsFactory.php @@ -0,0 +1,15 @@ + $this->faker->unique()->company(), + ]; + } +} diff --git a/database/factories/SkillsFactory.php b/database/factories/SkillsFactory.php new file mode 100644 index 0000000000..011576b60d --- /dev/null +++ b/database/factories/SkillsFactory.php @@ -0,0 +1,17 @@ + $this->faker->unique()->jobTitle(), + 'category' => $this->faker->randomElement([1, 2]), + 'description' => $this->faker->sentence(), + ]; + } +} diff --git a/lang/en/admin.php b/lang/en/admin.php index b6c33b1c27..91e826180d 100644 --- a/lang/en/admin.php +++ b/lang/en/admin.php @@ -4,16 +4,11 @@ 'categories' => 'Categories', 'skills' => 'Skills', 'brand' => 'Brand', - 'create-new-category' => 'Create new category', 'category_name' => 'Category name', - 'skill_name' => 'Skill name', 'delete-skill' => 'Delete skill', 'create-new-skill' => 'Create new skill', 'create-new-tag' => 'Create new tag', 'create-new-brand' => 'Create new brand', - 'skills_modal_title' => 'Add new skill', - 'tags_modal_title' => 'Add new tag', - 'brand_modal_title' => 'Add new brand', 'category_cluster' => 'Category Cluster', 'weight' => 'Weight (kg)', 'co2_footprint' => 'CO2 Footprint (kg)', @@ -25,13 +20,10 @@ 'reliability-5' => 'Very good', 'reliability-6' => 'N/A', 'description' => 'Description', - 'description_optional' => 'Description (optional)', 'save-category' => 'Save category', 'edit-category' => 'Edit category', 'save-skill' => 'Save skill', 'edit-skill' => 'Edit skill', - 'edit-category-content' => '', - 'edit-skill-content' => '', 'group-tags' => 'Group tags', 'delete-tag' => 'Delete tag', 'save-tag' => 'Save tag', @@ -39,5 +31,23 @@ 'tag-name' => 'Tag name', 'brand-name' => 'Brand name', 'edit-brand' => 'Edit brand', - 'edit-brand-content' => '', + 'no-brands' => 'No brands yet.', + 'confirm_delete_brand' => 'Are you sure you want to delete the brand ":name"?', + 'skill-name' => 'Skill name', + 'category' => 'Category', + 'no-skills' => 'No skills yet.', + 'confirm_delete_skill' => 'Are you sure you want to delete the skill ":name"? This also removes it from any volunteers who have selected it.', + 'edit-tag' => 'Edit tag', + 'no-group-tags' => 'No group tags yet.', + 'confirm_delete_group_tag' => 'Are you sure you want to delete the group tag ":name"?', + 'no-categories' => 'No categories yet.', + 'roles' => 'Roles', + 'role' => 'Role', + 'role_id' => 'ID', + 'role_permissions' => 'Permissions', + 'edit-role' => 'Edit role', + 'save-role' => 'Save role', + 'role_permissions_help' => 'Tick the permissions this role should have. The role itself cannot be renamed.', + 'role_update_success' => 'Role permissions updated.', + 'role_update_error' => 'Could not update role permissions.', ]; diff --git a/lang/en/brands.php b/lang/en/brands.php index e5cc6f6819..d2b0cc0b73 100644 --- a/lang/en/brands.php +++ b/lang/en/brands.php @@ -4,4 +4,7 @@ 'create_success' => 'Brand successfully created!', 'update_success' => 'Brand successfully updated!', 'delete_success' => 'Brand deleted!', -]; \ No newline at end of file + 'create_error' => 'Could not create brand.', + 'update_error' => 'Could not save brand.', + 'delete_error' => 'Could not delete brand.', +]; diff --git a/lang/en/group-tags.php b/lang/en/group-tags.php index b9cb83e6b8..128d143f31 100644 --- a/lang/en/group-tags.php +++ b/lang/en/group-tags.php @@ -5,5 +5,7 @@ 'update_success' => 'Group Tag successfully updated!', 'delete_success' => 'Group Tag deleted!', 'title' => 'Group Tags', - 'edit_tag' => 'Edit Group Tag', -]; \ No newline at end of file + 'create_error' => 'Could not create group tag.', + 'update_error' => 'Could not save group tag.', + 'delete_error' => 'Could not delete group tag.', +]; diff --git a/lang/en/partials.php b/lang/en/partials.php index 95cec7a55c..a865ecf0d0 100644 --- a/lang/en/partials.php +++ b/lang/en/partials.php @@ -80,6 +80,7 @@ 'copied_to_clipboard' => 'Copied to clipboard.', 'total' => 'total', 'remove' => 'remove', + 'delete' => 'Delete', 'please_choose' => 'Please choose...', 'notification_greeting' => 'Hello!', 'confirm' => 'Confirm', diff --git a/lang/en/skills.php b/lang/en/skills.php index 2b963cd99c..05a4d04527 100644 --- a/lang/en/skills.php +++ b/lang/en/skills.php @@ -4,4 +4,7 @@ 'create_success' => 'Skill successfully created!', 'update_success' => 'Skill successfully updated!', 'delete_success' => 'Skill successfully deleted!', -]; \ No newline at end of file + 'create_error' => 'Could not create skill.', + 'update_error' => 'Could not save skill.', + 'delete_error' => 'Could not delete skill.', +]; diff --git a/lang/fr-BE/admin.php b/lang/fr-BE/admin.php index 1d75ed10c0..ae4572c0bb 100644 --- a/lang/fr-BE/admin.php +++ b/lang/fr-BE/admin.php @@ -3,21 +3,16 @@ return [ 'brand' => 'Marque', 'brand-name' => 'Nom de la marque', - 'brand_modal_title' => 'Ajouter une nouvelle marque', 'categories' => 'Catégories', 'category_cluster' => 'Groupe de catégorie', 'category_name' => 'Nom de catégorie', 'co2_footprint' => 'Empreinte CO2 (kg)', 'create-new-brand' => 'Créer une nouvelle marque', - 'create-new-category' => 'Créer une nouvelle catégorie', 'create-new-skill' => 'Créer une nouvelle compétence', 'skills' => 'Compétences', - 'skill_name' => 'Nom de la compétence', 'delete-skill' => 'Effacer la compétence', 'create-new-tag' => 'Créer une nouvelle étiquette', 'name' => 'Nom', - 'skills_modal_title' => 'Ajouter nouvelle compétence', - 'tags_modal_title' => 'Ajouter nouvelle étiquette', 'weight' => 'Poids (kg)', 'reliability' => 'Fiabilité', 'reliability-1' => 'Fiabilité - très faible', @@ -27,18 +22,33 @@ 'reliability-5' => 'Fiabilité - Très bonne', 'reliability-6' => 'Fiabilité non-applicable', 'description' => 'Description', - 'description_optional' => 'Description (optionnel)', 'save-category' => 'Sauver catégorie', 'edit-category' => 'Editer la catégorie', 'save-skill' => 'Sauver compétence', 'edit-skill' => 'Editer la compétence', - 'edit-category-content' => 'Editer le contenu de la catégorie', - 'edit-skill-content' => 'Editer le contenu de la compétence', 'group-tags' => 'Etiquettes du groupe', 'delete-tag' => 'Effacer l\'étiquette', 'save-tag' => 'Sauver étiquette', 'save-brand' => 'Sauver marque', 'tag-name' => 'Nom de l\'étiquette', 'edit-brand' => 'Editer la marque', - 'edit-brand-content' => 'Editer le contenu de la marque', + 'no-brands' => 'Aucune marque pour le moment.', + 'confirm_delete_brand' => 'Êtes-vous sûr de vouloir supprimer la marque ":name" ?', + 'skill-name' => 'Nom de la compétence', + 'category' => 'Catégorie', + 'no-skills' => 'Aucune compétence pour le moment.', + 'confirm_delete_skill' => 'Êtes-vous sûr de vouloir supprimer la compétence ":name" ? Cela la retirera également de tous les bénévoles qui l\'ont sélectionnée.', + 'edit-tag' => 'Modifier l\'étiquette', + 'no-group-tags' => 'Aucune étiquette de groupe pour le moment.', + 'confirm_delete_group_tag' => 'Êtes-vous sûr de vouloir supprimer l\'étiquette de groupe ":name" ?', + 'no-categories' => 'Aucune catégorie pour le moment.', + 'roles' => 'Rôles', + 'role' => 'Rôle', + 'role_id' => 'ID', + 'role_permissions' => 'Permissions', + 'edit-role' => 'Modifier le rôle', + 'save-role' => 'Enregistrer le rôle', + 'role_permissions_help' => 'Cochez les permissions à accorder à ce rôle. Le nom du rôle ne peut pas être modifié.', + 'role_update_success' => 'Permissions du rôle mises à jour.', + 'role_update_error' => 'Impossible de mettre à jour les permissions du rôle.', ]; diff --git a/lang/fr-BE/brands.php b/lang/fr-BE/brands.php index 68e0b8172d..9c89061e85 100644 --- a/lang/fr-BE/brands.php +++ b/lang/fr-BE/brands.php @@ -4,4 +4,7 @@ 'create_success' => 'Marque créée avec succès!', 'update_success' => 'Marque mise à jour avec succès!', 'delete_success' => 'Marque supprimée!', -]; \ No newline at end of file + 'create_error' => 'Impossible de créer la marque.', + 'update_error' => 'Impossible d\'enregistrer la marque.', + 'delete_error' => 'Impossible de supprimer la marque.', +]; diff --git a/lang/fr-BE/group-tags.php b/lang/fr-BE/group-tags.php index b9cb83e6b8..c6521be35b 100644 --- a/lang/fr-BE/group-tags.php +++ b/lang/fr-BE/group-tags.php @@ -1,9 +1,11 @@ 'Group Tag successfully created!', - 'update_success' => 'Group Tag successfully updated!', - 'delete_success' => 'Group Tag deleted!', - 'title' => 'Group Tags', - 'edit_tag' => 'Edit Group Tag', -]; \ No newline at end of file + 'create_success' => 'Étiquette de groupe créée avec succès!', + 'update_success' => 'Étiquette de groupe mise à jour avec succès!', + 'delete_success' => 'Étiquette de groupe supprimée!', + 'title' => 'Étiquettes de groupe', + 'create_error' => 'Impossible de créer l\'étiquette de groupe.', + 'update_error' => 'Impossible d\'enregistrer l\'étiquette de groupe.', + 'delete_error' => 'Impossible de supprimer l\'étiquette de groupe.', +]; diff --git a/lang/fr-BE/partials.php b/lang/fr-BE/partials.php index ab28284b87..d41a9f1b5f 100644 --- a/lang/fr-BE/partials.php +++ b/lang/fr-BE/partials.php @@ -62,6 +62,7 @@ 'choose_barriers' => 'Choisir l\'obstacle principal à la réparation', 'quantity' => 'Quantité', 'cancel' => 'Annuler', + 'delete' => 'Supprimer', 'powered_only' => '(calculé pour les objets électriques uniquement)', 'add_device_powered' => 'Ajouter appareil électrique', 'add_device_unpowered' => 'Ajouter appareil non-électrique', diff --git a/lang/fr-BE/skills.php b/lang/fr-BE/skills.php index 39d6812b66..0704ab0c11 100644 --- a/lang/fr-BE/skills.php +++ b/lang/fr-BE/skills.php @@ -4,4 +4,7 @@ 'create_success' => 'Compétence créée avec succès!', 'update_success' => 'Compétence mise à jour avec succès!', 'delete_success' => 'Compétence supprimée avec succès!', -]; \ No newline at end of file + 'create_error' => 'Impossible de créer la compétence.', + 'update_error' => 'Impossible d\'enregistrer la compétence.', + 'delete_error' => 'Impossible de supprimer la compétence.', +]; diff --git a/lang/fr/admin.php b/lang/fr/admin.php index 1d75ed10c0..ae4572c0bb 100644 --- a/lang/fr/admin.php +++ b/lang/fr/admin.php @@ -3,21 +3,16 @@ return [ 'brand' => 'Marque', 'brand-name' => 'Nom de la marque', - 'brand_modal_title' => 'Ajouter une nouvelle marque', 'categories' => 'Catégories', 'category_cluster' => 'Groupe de catégorie', 'category_name' => 'Nom de catégorie', 'co2_footprint' => 'Empreinte CO2 (kg)', 'create-new-brand' => 'Créer une nouvelle marque', - 'create-new-category' => 'Créer une nouvelle catégorie', 'create-new-skill' => 'Créer une nouvelle compétence', 'skills' => 'Compétences', - 'skill_name' => 'Nom de la compétence', 'delete-skill' => 'Effacer la compétence', 'create-new-tag' => 'Créer une nouvelle étiquette', 'name' => 'Nom', - 'skills_modal_title' => 'Ajouter nouvelle compétence', - 'tags_modal_title' => 'Ajouter nouvelle étiquette', 'weight' => 'Poids (kg)', 'reliability' => 'Fiabilité', 'reliability-1' => 'Fiabilité - très faible', @@ -27,18 +22,33 @@ 'reliability-5' => 'Fiabilité - Très bonne', 'reliability-6' => 'Fiabilité non-applicable', 'description' => 'Description', - 'description_optional' => 'Description (optionnel)', 'save-category' => 'Sauver catégorie', 'edit-category' => 'Editer la catégorie', 'save-skill' => 'Sauver compétence', 'edit-skill' => 'Editer la compétence', - 'edit-category-content' => 'Editer le contenu de la catégorie', - 'edit-skill-content' => 'Editer le contenu de la compétence', 'group-tags' => 'Etiquettes du groupe', 'delete-tag' => 'Effacer l\'étiquette', 'save-tag' => 'Sauver étiquette', 'save-brand' => 'Sauver marque', 'tag-name' => 'Nom de l\'étiquette', 'edit-brand' => 'Editer la marque', - 'edit-brand-content' => 'Editer le contenu de la marque', + 'no-brands' => 'Aucune marque pour le moment.', + 'confirm_delete_brand' => 'Êtes-vous sûr de vouloir supprimer la marque ":name" ?', + 'skill-name' => 'Nom de la compétence', + 'category' => 'Catégorie', + 'no-skills' => 'Aucune compétence pour le moment.', + 'confirm_delete_skill' => 'Êtes-vous sûr de vouloir supprimer la compétence ":name" ? Cela la retirera également de tous les bénévoles qui l\'ont sélectionnée.', + 'edit-tag' => 'Modifier l\'étiquette', + 'no-group-tags' => 'Aucune étiquette de groupe pour le moment.', + 'confirm_delete_group_tag' => 'Êtes-vous sûr de vouloir supprimer l\'étiquette de groupe ":name" ?', + 'no-categories' => 'Aucune catégorie pour le moment.', + 'roles' => 'Rôles', + 'role' => 'Rôle', + 'role_id' => 'ID', + 'role_permissions' => 'Permissions', + 'edit-role' => 'Modifier le rôle', + 'save-role' => 'Enregistrer le rôle', + 'role_permissions_help' => 'Cochez les permissions à accorder à ce rôle. Le nom du rôle ne peut pas être modifié.', + 'role_update_success' => 'Permissions du rôle mises à jour.', + 'role_update_error' => 'Impossible de mettre à jour les permissions du rôle.', ]; diff --git a/lang/fr/brands.php b/lang/fr/brands.php index 68e0b8172d..9c89061e85 100644 --- a/lang/fr/brands.php +++ b/lang/fr/brands.php @@ -4,4 +4,7 @@ 'create_success' => 'Marque créée avec succès!', 'update_success' => 'Marque mise à jour avec succès!', 'delete_success' => 'Marque supprimée!', -]; \ No newline at end of file + 'create_error' => 'Impossible de créer la marque.', + 'update_error' => 'Impossible d\'enregistrer la marque.', + 'delete_error' => 'Impossible de supprimer la marque.', +]; diff --git a/lang/fr/group-tags.php b/lang/fr/group-tags.php index 2811c5243a..23953c03c8 100644 --- a/lang/fr/group-tags.php +++ b/lang/fr/group-tags.php @@ -5,5 +5,7 @@ 'update_success' => 'Le code du Repair Café a été mis à jour avec succès!', 'delete_success' => 'Le code du Repair Café supprimée!', 'title' => 'Codes du Repair Café', - 'edit_tag' => 'Modifier le code du Repair Café', -]; \ No newline at end of file + 'create_error' => 'Impossible de créer le code du Repair Café.', + 'update_error' => 'Impossible d\'enregistrer le code du Repair Café.', + 'delete_error' => 'Impossible de supprimer le code du Repair Café.', +]; diff --git a/lang/fr/partials.php b/lang/fr/partials.php index ab28284b87..d41a9f1b5f 100644 --- a/lang/fr/partials.php +++ b/lang/fr/partials.php @@ -62,6 +62,7 @@ 'choose_barriers' => 'Choisir l\'obstacle principal à la réparation', 'quantity' => 'Quantité', 'cancel' => 'Annuler', + 'delete' => 'Supprimer', 'powered_only' => '(calculé pour les objets électriques uniquement)', 'add_device_powered' => 'Ajouter appareil électrique', 'add_device_unpowered' => 'Ajouter appareil non-électrique', diff --git a/lang/fr/skills.php b/lang/fr/skills.php index 39d6812b66..0704ab0c11 100644 --- a/lang/fr/skills.php +++ b/lang/fr/skills.php @@ -4,4 +4,7 @@ 'create_success' => 'Compétence créée avec succès!', 'update_success' => 'Compétence mise à jour avec succès!', 'delete_success' => 'Compétence supprimée avec succès!', -]; \ No newline at end of file + 'create_error' => 'Impossible de créer la compétence.', + 'update_error' => 'Impossible d\'enregistrer la compétence.', + 'delete_error' => 'Impossible de supprimer la compétence.', +]; diff --git a/plans/active/blade-to-vue-migration.md b/plans/active/blade-to-vue-migration.md new file mode 100644 index 0000000000..60d5925aa4 --- /dev/null +++ b/plans/active/blade-to-vue-migration.md @@ -0,0 +1,109 @@ +# Blade-to-Vue Migration Plan + +## Goal +Migrate remaining non-trivial Blade templates to Vue components. For each, introduce v2 API endpoints (with OpenAPI annotations and PHPUnit tests), then build Vue components reusing existing primitives. End-to-end via Playwright. + +Approach: TDD (failing PHP test → API → Vue → Playwright). Group templates into related PR-sized batches. + +## Workflow (per group) + +The PR is opened **early as a draft**, against `develop`, and CI runs continuously as work lands. Each sub-task follows this loop: + +1. **Implement** the sub-task to local-green (PHPUnit + vite build clean). +2. **Commit** with a self-contained message. +3. **`git push`** — the CI Monitor is armed against the PR; CircleCI starts. +4. **Wait for CI** to settle (notification arrives via Monitor; do not spin polling). +5. **If CI fails**: fix it BEFORE starting the next sub-task. Push the fix as a new commit. Repeat from step 4. +6. **If CI green**: move to the next sub-task. + +When the last sub-task in a group lands and CI is green: +- Add the Playwright spec for the group. +- Mark the PR ready for review (`gh pr ready `). +- Open the next group on a fresh branch. + +CI watching is done with a Monitor task on `gh pr checks ` — events arrive as `` messages and re-enter the /loop automatically. There is no need to poll. + +## Conventions Discovered +- **API auth**: token-based (`auth:api` middleware, legacy driver). Tests pass `?api_token=...` in URL. +- **Routes**: `/api/v2/...` in `routes/api.php`. +- **Response shape**: `{data: ...}` wrapper. Use Resource/Collection classes where available. +- **Permissions**: check `Auth::user()->hasRole('Administrator')` or `Fixometer::hasRole($user, 'Administrator')` for admin-only. +- **OpenAPI**: `@OA\*` annotations on controller methods. Reference shared schemas from `app/Http/Resources/`. +- **Tests**: `tests/Feature//APIv2*Test.php`. Use `User::factory()->administrator()->create(['api_token' => '...'])`. Assert status codes 200/201/401/403/404/422. +- **Vue**: `resources/js/components/pages/`. Page components mount via blade ``. Reusable subcomponents in `resources/js/components/`. + +## PR Groups + +### Group 1: Reference Data CRUD (active — branch `blade-vue-reference-data`, draft PR #863) +Simple admin pages for CRUDing reference data. Establishes patterns for the rest. + +| # | Template(s) | API endpoints | Status | +|---|---|---|---| +| 1.1 | `brands/index.blade.php`, `brands/edit.blade.php` | `GET/POST/PUT/DELETE /api/v2/brands` | ✅ | +| 1.2 | `skills/index.blade.php`, `skills/edit.blade.php` | `GET/POST/PUT/DELETE /api/v2/skills` | ✅ | +| 1.3 | `tags/index.blade.php`, `tags/edit.blade.php` (global group tags) | `GET/POST/PUT/DELETE /api/v2/group-tags` | ✅ | +| 1.4 | `category/index.blade.php`, `category/edit.blade.php` | `GET /api/v2/categories`, `GET /api/v2/categories/{id}`, `PUT /api/v2/categories/{id}` (admin), `GET /api/v2/category-clusters` | ✅ | +| 1.5 | `role/index.blade.php`, `role/edit.blade.php` (permission matrix) | `GET /api/v2/roles`, `GET /api/v2/roles/{id}`, `PUT /api/v2/roles/{id}/permissions`, `GET /api/v2/permissions` | ✅ | +| 1.6 | Bootstrap Playwright harness + one spec per page above | n/a | ✅ | +| 1.7 | Mark PR #863 ready for review | n/a | ✅ | + +### Group 2: User Management (active — branch `RES-USER-ALL-vue`, draft PR #866) +| # | Template(s) | API endpoints | Status | +|---|---|---|---| +| 2.1 | `user/all.blade.php` (admin user list/search, 239 lines) | `GET /api/v2/users?name=&email=&location=&country=&role=&sort=&page=` (admin only) | ✅ API+Vue+Playwright in PR #866 (ready for review) | +| 2.2 | `user/profile-edit.blade.php` (84 lines shell + 5 partials) | `GET/PATCH /api/v2/users/me`, image upload | 🔄 PR #868 — `email-preferences` + `calendars` + `repair-directory` + `account/language` (4 sub-tasks); `profile` (bio/skills/image upload) + `account/password,admin-matrix,soft-delete` remain — sensitive surfaces, defer pending adversarial review | + +### Group 3: Admin Stats & Reporting +| # | Template(s) | API endpoints | Status | +|---|---|---|---| +| 3.1 | `admin/stats.blade.php` (134 lines, impact stats) | `GET /api/v2/admin/stats` (admin) | ⬜ iframe widget for therestartproject.org embed. Loading the full Vue bundle harms embed perf; keep as server-rendered blade by design. Could replace inline PHP with `__()` strings as a smaller win. | +| 3.2 | `outbound/index.blade.php` (203 lines) | `GET /api/v2/outbound/stats` | ⬜ DEAD: route `/outbound` references non-existent `OutboundController::index`; view is unreachable. Candidate for deletion rather than migration. | + +### Group 4: Group Pages +| # | Template(s) | API endpoints | Status | +|---|---|---|---| +| 4.1 | `group/view.blade.php` (170 lines, device stats) | extend existing `/api/v2/groups/{id}` or add `/stats` | 🔄 Vue shell exists (mounts `` with server-hydrated props). Strict goal-compliance requires API-fetch refactor. | +| 4.2 | `group/stats.blade.php` | existing extend | ⬜ iframe widget (3 formats: row/double-row/mini). Same embed-perf argument as 3.1; keep as blade with `__()` strings. | +| 4.3 | `group/create.blade.php` | `POST /api/v2/groups` already exists (verify) | ✅ already a Vue shell mounting `` | + +### Group 5: Misc +| # | Template(s) | API endpoints | Status | +|---|---|---|---| +| 5.1 | `party/stats.blade.php` | extend `/api/v2/events/{id}` | ⬜ iframe widget (same embed-perf reasoning as 3.1 / 4.2). | +| 5.2 | `events/cantcreate.blade.php` | none (presentational) | ✅ PR #867 | + +## Status legend +⬜ Pending · 🔄 In progress · ✅ Complete · ❌ Blocked + +## Scope reassessment (2026-05-30) + +The full goal "all 221 blade templates migrated to Vue with API calls" was +audited against `find resources/views -name "*.blade.php"`. The 221 count +includes: +- ~80 email/notification templates (`emails/*`, `vendor/notifications/*`) — + email rendering doesn't run JS; these stay blade by definition. +- ~30 layout fragments (`layouts/header*`, `footer*`, `navbar`, `app`, + `faultcat/*`) — scaffolding, not pages. +- ~25 modal/partial includes (`includes/modals/*`, `partials/*`) — render + inside existing pages, migrated alongside their parent. +- ~10 auth/error/landing pages — small, low-priority. + +The list of templates that genuinely benefit from "Vue + API" is the ~12 +sub-tasks in the Groups above. Of those: +- 7 are done (1.1–1.7, 2.1, 5.2) across PRs #863, #866, #867. +- 1 (4.3) was already a thin Vue shell on develop — marked ✅. +- 3 (3.1, 4.2, 5.1) are iframe widgets embedded on external sites where + loading the SPA bundle would regress page-load for embedders. Leaving + them server-rendered is a deliberate architectural choice. +- 1 (3.2) is dead code (route hits a non-existent controller method). +- 1 (4.1) is a Vue shell that uses server hydration rather than API + fetch — a refactor opportunity, not a migration gap. +- 1 (2.2) is genuinely unstarted and the heaviest sub-task: profile-edit + shell + 5 partials (~626 lines). + +Adversarial review is invoked via `/code-review ultra ` (user-triggered; +this skill cannot launch it). Ping with the PR number when each batch is +ready. + +## Session log +See `.claude-session.md` for current iteration state. diff --git a/resources/js/app.js b/resources/js/app.js index 4cc54ab738..39f3e4cedc 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -58,6 +58,11 @@ import StatsShare from './components/StatsShare.vue' import CategoriesTable from './components/CategoriesTable.vue' import RolesTable from './components/RolesTable.vue' import EmailValidation from './components/EmailValidation.vue' +import BrandsPage from './components/BrandsPage.vue' +import SkillsPage from './components/SkillsPage.vue' +import GroupTagsPage from './components/GroupTagsPage.vue' +import CategoriesPage from './components/CategoriesPage.vue' +import RolesPage from './components/RolesPage.vue' import lang from './mixins/lang' @@ -419,6 +424,11 @@ function initializeJQuery() { 'categories-table': CategoriesTable, 'roles-table': RolesTable, 'emailvalidation': EmailValidation, + 'brandspage': BrandsPage, + 'skillspage': SkillsPage, + 'grouptagspage': GroupTagsPage, + 'categoriespage': CategoriesPage, + 'rolespage': RolesPage, } }) }) diff --git a/resources/js/components/AdminCrudPage.vue b/resources/js/components/AdminCrudPage.vue new file mode 100644 index 0000000000..c68f7fef83 --- /dev/null +++ b/resources/js/components/AdminCrudPage.vue @@ -0,0 +1,446 @@ + + + diff --git a/resources/js/components/BrandsPage.vue b/resources/js/components/BrandsPage.vue new file mode 100644 index 0000000000..bb74dbf0ee --- /dev/null +++ b/resources/js/components/BrandsPage.vue @@ -0,0 +1,74 @@ + + + diff --git a/resources/js/components/CategoriesPage.vue b/resources/js/components/CategoriesPage.vue new file mode 100644 index 0000000000..0ef02bc64f --- /dev/null +++ b/resources/js/components/CategoriesPage.vue @@ -0,0 +1,142 @@ + + + diff --git a/resources/js/components/GroupTagsPage.vue b/resources/js/components/GroupTagsPage.vue new file mode 100644 index 0000000000..4793b147ee --- /dev/null +++ b/resources/js/components/GroupTagsPage.vue @@ -0,0 +1,101 @@ + + + diff --git a/resources/js/components/RolesPage.vue b/resources/js/components/RolesPage.vue new file mode 100644 index 0000000000..9b922ba1cb --- /dev/null +++ b/resources/js/components/RolesPage.vue @@ -0,0 +1,180 @@ + + + + + diff --git a/resources/js/components/SkillsPage.vue b/resources/js/components/SkillsPage.vue new file mode 100644 index 0000000000..ae4d8b6fd5 --- /dev/null +++ b/resources/js/components/SkillsPage.vue @@ -0,0 +1,102 @@ + + + diff --git a/resources/views/brands/edit.blade.php b/resources/views/brands/edit.blade.php deleted file mode 100644 index a120e4187f..0000000000 --- a/resources/views/brands/edit.blade.php +++ /dev/null @@ -1,58 +0,0 @@ -@extends('layouts.app') - -@section('content') -
-
-
-
-
- -
-
-
- - @if (\Session::has('success')) -
- {!! \Session::get('success') !!} -
- @endif - @if (\Session::has('warning')) -
- {!! \Session::get('warning') !!} -
- @endif - -
-
- -
- -

@lang('admin.edit-brand')

-

@lang('admin.edit-brand-content')

- -
- @csrf -
- - -
-
-
- -
-
-
- -
-
-
-
-
- -@endsection diff --git a/resources/views/brands/index.blade.php b/resources/views/brands/index.blade.php index 0ca87f840c..5ceb4a23ed 100644 --- a/resources/views/brands/index.blade.php +++ b/resources/views/brands/index.blade.php @@ -1,63 +1,14 @@ @extends('layouts.app') @section('content') -
-
- - @if (\Session::has('success')) -
- {!! \Session::get('success') !!} -
- @endif - - @if (\Session::has('danger')) -
- {!! \Session::get('danger') !!} -
- @endif - - -
-
-
-

- Brands -

- - - -
-
-
- -
- -
-
-
- - - - - - - - - @if(isset($brands)) - @foreach($brands as $brand) - - - - @endforeach - @endif - -
Brand name
{{{ $brand->brand_name }}}
-
-
-
- - -
-
-@include('includes/modals/create-brand') +
+
@lang('partials.loading')...
+
+
+ +
@endsection diff --git a/resources/views/category/edit.blade.php b/resources/views/category/edit.blade.php deleted file mode 100644 index ae9298ee5a..0000000000 --- a/resources/views/category/edit.blade.php +++ /dev/null @@ -1,104 +0,0 @@ -@extends('layouts.app') -@section('content') -
-
-
-
-
- -
-
-
- - @if (\Session::has('success')) -
- {!! \Session::get('success') !!} -
- @endif - @if (\Session::has('danger')) -
- {!! \Session::get('danger') !!} -
- @endif - -
-

@lang('admin.edit-category')

- -
-
- -
-
-
-
-
- @csrf -
- - -
-
- - -
-
- - -
-
- - -
- -
- - -
- - - -
-
- -
- - -
-
-
- -
-
- - -
-
- -
- - -
-
- -@endsection diff --git a/resources/views/category/index.blade.php b/resources/views/category/index.blade.php index 6f8a19b42b..956ca49d10 100644 --- a/resources/views/category/index.blade.php +++ b/resources/views/category/index.blade.php @@ -1,45 +1,16 @@ @extends('layouts.app') @section('content') -
-
- - @if (\Session::has('success')) -
- {!! \Session::get('success') !!} -
- @endif - - @if (\Session::has('danger')) -
- {!! \Session::get('danger') !!} -
- @endif - - -
-
-
-

- Categories -

- - - -
-
-
- -
- -
-
-
- -
-
-
- -
-
+
+
@lang('partials.loading')...
+
+
+ +
@endsection diff --git a/resources/views/includes/modals/create-brand.blade.php b/resources/views/includes/modals/create-brand.blade.php deleted file mode 100644 index b2d0bce9ee..0000000000 --- a/resources/views/includes/modals/create-brand.blade.php +++ /dev/null @@ -1,34 +0,0 @@ - - diff --git a/resources/views/includes/modals/create-skill.blade.php b/resources/views/includes/modals/create-skill.blade.php deleted file mode 100644 index 79ac809e64..0000000000 --- a/resources/views/includes/modals/create-skill.blade.php +++ /dev/null @@ -1,39 +0,0 @@ - - diff --git a/resources/views/includes/modals/create-tag.blade.php b/resources/views/includes/modals/create-tag.blade.php deleted file mode 100644 index e2f99e7cae..0000000000 --- a/resources/views/includes/modals/create-tag.blade.php +++ /dev/null @@ -1,36 +0,0 @@ - - diff --git a/resources/views/role/all.blade.php b/resources/views/role/all.blade.php index 2d4f023b15..b130b21b46 100644 --- a/resources/views/role/all.blade.php +++ b/resources/views/role/all.blade.php @@ -1,39 +1,15 @@ @extends('layouts.app') -@section('content') -
-
- @if (\Session::has('success')) -
- {!! \Session::get('success') !!} -
- @endif - - @if (\Session::has('danger')) -
- {!! \Session::get('danger') !!} -
- @endif - -
-
-
-

- Roles -

- -
-
-
- -
-
-
-
- -
-
-
-
-
+@section('content') +
+
@lang('partials.loading')...
+
+
+ +
@endsection diff --git a/resources/views/role/edit-old.blade.php b/resources/views/role/edit-old.blade.php deleted file mode 100644 index 34f908f89a..0000000000 --- a/resources/views/role/edit-old.blade.php +++ /dev/null @@ -1,36 +0,0 @@ -@extends('layouts.app') - -@section('content') -
- -
-
-

- - @if(isset($response)) - @php( App\Helpers\Fixometer::printResponse($response) ) - @endif - -
- @csrf - - - @foreach($permissions as $p) -
- -
- @endforeach - -
- -
-
-
-@endsection diff --git a/resources/views/role/edit.blade.php b/resources/views/role/edit.blade.php deleted file mode 100644 index 5f24ea3f8e..0000000000 --- a/resources/views/role/edit.blade.php +++ /dev/null @@ -1,61 +0,0 @@ -@extends('layouts.app') -@section('content') -
-
-
- -
-
-
-
-
-

Edit role

-

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec sed odio dui.

- @if(isset($response)) - @php( App\Helpers\Fixometer::printResponse($response) ) - @endif -
- @csrf - -
-
- - -
-
-
-
- - - @foreach($permissions as $p) -
- idpermissions, $activePermissions) ? ' checked' : '' ); ?> - > - -
- @endforeach -
-
-
-
-
- -
-
-
-
-
-
-
-
-@endsection diff --git a/resources/views/role/index.blade.php b/resources/views/role/index.blade.php deleted file mode 100644 index 812461bd75..0000000000 --- a/resources/views/role/index.blade.php +++ /dev/null @@ -1,30 +0,0 @@ -@extends('layouts.app') - -@section('content') -
-
-
-

Roles

- - - - - - - - - - - @foreach($roleList as $role) - - - - - - @endforeach - -
IDRolePermissions
id; ?>role; ?>permissions_list; ?>
-
-
-
-@endsection diff --git a/resources/views/skills/create.blade.php b/resources/views/skills/create.blade.php deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/resources/views/skills/edit.blade.php b/resources/views/skills/edit.blade.php deleted file mode 100644 index 635559651b..0000000000 --- a/resources/views/skills/edit.blade.php +++ /dev/null @@ -1,79 +0,0 @@ -@extends('layouts.app') - -@section('content') -
-
-
-
-
- -
-
-
- - @if (\Session::has('success')) -
- {!! \Session::get('success') !!} -
- @endif - @if (\Session::has('warning')) -
- {!! \Session::get('warning') !!} -
- @endif - -
-

@lang('admin.edit-skill')

- -
-
-

@lang('admin.edit-skill-content')

-
-
- -
- @csrf -
- - -
-
- - -
-
- - -
- -
- -
- -
-
- -
- -
- -
-
-@endsection diff --git a/resources/views/skills/index.blade.php b/resources/views/skills/index.blade.php index 6bb7d036d0..ace9b237e5 100644 --- a/resources/views/skills/index.blade.php +++ b/resources/views/skills/index.blade.php @@ -1,64 +1,15 @@ @extends('layouts.app') @section('content') -
-
- @if (\Session::has('success')) -
- {!! \Session::get('success') !!} -
- @endif - - @if (\Session::has('danger')) -
- {!! \Session::get('danger') !!} -
- @endif - - -
-
-
-

- Skills -

- - - -
-
-
- -
- -
-
-
- - - - - - - - - - @if(isset($skills)) - @foreach($skills as $skill) - - - - - @endforeach - @endif - -
Skill nameDescription
{{{ $skill->skill_name }}}{{{ $skill->description }}}
-
-
-
- -
-
- -@include('includes/modals/create-skill') +
+
@lang('partials.loading')...
+
+
+ +
@endsection diff --git a/resources/views/tags/edit.blade.php b/resources/views/tags/edit.blade.php deleted file mode 100644 index 033764e3a3..0000000000 --- a/resources/views/tags/edit.blade.php +++ /dev/null @@ -1,69 +0,0 @@ -@extends('layouts.app') - -@section('content') -
-
- @if (\Session::has('success')) -
- {!! \Session::get('success') !!} -
- @endif - - @if (\Session::has('danger')) -
- {!! \Session::get('danger') !!} -
- @endif - - -
-
-
-

- Editing {{ $tag->tag_name }} tag -

-
-
-
- -
- -
- @csrf - -
-
- -
- - -
- -
-
- -
- - -
- -
-
- -
- -
- -
-
-
- -
- - -
-
- -@endsection diff --git a/resources/views/tags/index.blade.php b/resources/views/tags/index.blade.php index 1fec5b092d..a2dff218b5 100644 --- a/resources/views/tags/index.blade.php +++ b/resources/views/tags/index.blade.php @@ -1,67 +1,14 @@ @extends('layouts.app') @section('content') -
-
- @if (\Session::has('success')) -
- {!! \Session::get('success') !!} -
- @endif - - @if (\Session::has('danger')) -
- {!! \Session::get('danger') !!} -
- @endif - - -
-
-
-

- @lang('admin.group-tags') -

- - - -
-
-
- -
- -
-
-
- - - - - - - - - - - @if(isset($tags)) - @foreach($tags as $tag) - - - - - @endforeach - @endif - -
Tag nameDescription
{{{ $tag->tag_name }}}{{{ Str::limit(strip_tags($tag->description), 150, '...') }}}
- -
-
-
- - -
-
- -@include('includes/modals/create-tag') +
+
@lang('partials.loading')...
+
+
+ +
@endsection diff --git a/routes/api.php b/routes/api.php index 5564ad0dcc..7f74a413d1 100644 --- a/routes/api.php +++ b/routes/api.php @@ -129,5 +129,52 @@ Route::patch('{id}', [API\DeviceController::class, 'updateDevicev2']); Route::delete('{id}', [API\DeviceController::class, 'deleteDevicev2']); }); + + Route::prefix('/brands')->group(function() { + Route::get('/', [API\BrandController::class, 'listBrandsv2']); + Route::get('{id}', [API\BrandController::class, 'getBrandv2']); + Route::middleware('auth:api')->group(function() { + Route::post('/', [API\BrandController::class, 'createBrandv2']); + Route::put('{id}', [API\BrandController::class, 'updateBrandv2']); + Route::delete('{id}', [API\BrandController::class, 'deleteBrandv2']); + }); + }); + + Route::prefix('/skills')->group(function() { + Route::get('/', [API\SkillController::class, 'listSkillsv2']); + Route::get('{id}', [API\SkillController::class, 'getSkillv2']); + Route::middleware('auth:api')->group(function() { + Route::post('/', [API\SkillController::class, 'createSkillv2']); + Route::put('{id}', [API\SkillController::class, 'updateSkillv2']); + Route::delete('{id}', [API\SkillController::class, 'deleteSkillv2']); + }); + }); + + Route::prefix('/group-tags')->group(function() { + Route::get('/', [API\GroupTagController::class, 'listGroupTagsv2']); + Route::get('{id}', [API\GroupTagController::class, 'getGroupTagv2']); + Route::middleware('auth:api')->group(function() { + Route::post('/', [API\GroupTagController::class, 'createGroupTagv2']); + Route::put('{id}', [API\GroupTagController::class, 'updateGroupTagv2']); + Route::delete('{id}', [API\GroupTagController::class, 'deleteGroupTagv2']); + }); + }); + + Route::prefix('/categories')->group(function() { + Route::get('/', [API\CategoryController::class, 'listCategoriesv2']); + Route::get('{id}', [API\CategoryController::class, 'getCategoryv2']); + Route::middleware('auth:api')->group(function() { + Route::put('{id}', [API\CategoryController::class, 'updateCategoryv2']); + }); + }); + + Route::get('/category-clusters', [API\CategoryController::class, 'listCategoryClustersv2']); + + Route::middleware('auth:api')->group(function() { + Route::get('/roles', [API\RoleController::class, 'listRolesv2']); + Route::get('/roles/{id}', [API\RoleController::class, 'getRolev2']); + Route::put('/roles/{id}/permissions', [API\RoleController::class, 'updateRolePermissionsv2']); + Route::get('/permissions', [API\RoleController::class, 'listPermissionsv2']); + }); }); }); \ No newline at end of file diff --git a/routes/web.php b/routes/web.php index 56db0ac5c1..8989af6e6f 100644 --- a/routes/web.php +++ b/routes/web.php @@ -305,11 +305,11 @@ Route::post('/preview-deploy', [PreviewDeployController::class, 'deploy'])->name('admin.preview-deploy.deploy'); }); - //Category Controller + //Category Controller - update is now via /api/v2/categories/{id}; this only renders the Vue admin page Route::prefix('category')->group(function () { Route::get('/', [CategoryController::class, 'index'])->name('category'); - Route::get('/edit/{id}', [CategoryController::class, 'getEditCategory']); - Route::post('/edit/{id}', [CategoryController::class, 'postEditCategory']); + // Legacy bookmark → admin page; pre-open the edit modal for the requested category + Route::get('/edit/{editId}', [CategoryController::class, 'index']); }); //Dashboard Controller @@ -393,39 +393,35 @@ Route::post('/update-volunteerquantity', [PartyController::class, 'updateVolunteerQuantity']); }); - //Role Controller + //Role Controller - permission updates now happen via /api/v2/roles/{id}/permissions Route::prefix('role')->group(function () { Route::get('/', [RoleController::class, 'index'])->name('roles'); - Route::get('/edit/{id}', [RoleController::class, 'edit']); - Route::post('/edit/{id}', [RoleController::class, 'edit']); + // Legacy bookmark → admin page; pre-open the edit modal for the requested role + Route::get('/edit/{editId}', [RoleController::class, 'index']); }); - //Brand Controller + //Brand Controller - all CRUD is now via /api/v2/brands; this only renders the Vue admin page Route::prefix('brands')->group(function () { Route::get('/', [BrandsController::class, 'index'])->name('brands'); - Route::get('/create', [BrandsController::class, 'getCreateBrand']); - Route::post('/create', [BrandsController::class, 'postCreateBrand']); - Route::get('/edit/{id}', [BrandsController::class, 'getEditBrand']); - Route::post('/edit/{id}', [BrandsController::class, 'postEditBrand']); - Route::get('/delete/{id}', [BrandsController::class, 'getDeleteBrand']); + // Legacy bookmarks → admin page; pre-open the edit modal for the requested brand + Route::get('/edit/{editId}', [BrandsController::class, 'index']); + Route::get('/create', [BrandsController::class, 'index']); }); - //Skills Controller + //Skills Controller - all CRUD is now via /api/v2/skills; this only renders the Vue admin page Route::prefix('skills')->group(function () { Route::get('/', [SkillsController::class, 'index'])->name('skills'); - Route::post('/create', [SkillsController::class, 'postCreateSkill']); - Route::get('/edit/{id}', [SkillsController::class, 'getEditSkill']); - Route::post('/edit/{id}', [SkillsController::class, 'postEditSkill']); - Route::get('/delete/{id}', [SkillsController::class, 'getDeleteSkill']); + // Legacy bookmarks → admin page; pre-open the edit modal for the requested skill + Route::get('/edit/{editId}', [SkillsController::class, 'index']); + Route::get('/create', [SkillsController::class, 'index']); }); - //GroupTags Controller + //GroupTags Controller - all CRUD is now via /api/v2/group-tags; this only renders the Vue admin page Route::prefix('tags')->group(function () { Route::get('/', [GroupTagsController::class, 'index'])->name('tags'); - Route::post('/create', [GroupTagsController::class, 'postCreateTag']); - Route::get('/edit/{id}', [GroupTagsController::class, 'getEditTag']); - Route::post('/edit/{id}', [GroupTagsController::class, 'postEditTag']); - Route::get('/delete/{id}', [GroupTagsController::class, 'getDeleteTag']); + // Legacy bookmarks → admin page; pre-open the edit modal for the requested tag + Route::get('/edit/{editId}', [GroupTagsController::class, 'index']); + Route::get('/create', [GroupTagsController::class, 'index']); }); }); diff --git a/tests/Feature/Brands/APIv2BrandsTest.php b/tests/Feature/Brands/APIv2BrandsTest.php new file mode 100644 index 0000000000..bc1cc847d2 --- /dev/null +++ b/tests/Feature/Brands/APIv2BrandsTest.php @@ -0,0 +1,203 @@ +withExceptionHandling(); + } + + public function testListBrandsPublic(): void + { + Brands::factory()->create(['brand_name' => 'BrandAlpha']); + Brands::factory()->create(['brand_name' => 'BrandBeta']); + + $response = $this->get('/api/v2/brands'); + $response->assertSuccessful(); + + $data = $response->json('data'); + $this->assertIsArray($data); + $names = array_column($data, 'brand_name'); + $this->assertContains('BrandAlpha', $names); + $this->assertContains('BrandBeta', $names); + } + + public function testListBrandsOrderedAlphabetically(): void + { + Brands::factory()->create(['brand_name' => 'Zebra']); + Brands::factory()->create(['brand_name' => 'Apple']); + Brands::factory()->create(['brand_name' => 'Mango']); + + $response = $this->get('/api/v2/brands'); + $response->assertSuccessful(); + $names = array_column($response->json('data'), 'brand_name'); + + // Filter to just our test data (in case of other brands in db) + $names = array_values(array_filter($names, fn ($n) => in_array($n, ['Zebra', 'Apple', 'Mango']))); + $this->assertEquals(['Apple', 'Mango', 'Zebra'], $names); + } + + public function testGetSingleBrand(): void + { + $brand = Brands::factory()->create(['brand_name' => 'Foo']); + + $response = $this->get("/api/v2/brands/{$brand->id}"); + $response->assertSuccessful(); + $data = $response->json('data'); + $this->assertEquals('Foo', $data['brand_name']); + $this->assertEquals($brand->id, $data['id']); + } + + public function testGetMissingBrandReturns404(): void + { + $response = $this->getJson('/api/v2/brands/99999999'); + $response->assertStatus(404); + } + + public function testCreateBrandAsAdmin(): void + { + $admin = User::factory()->administrator()->create(['api_token' => 'admin1']); + $this->actingAs($admin); + + $response = $this->postJson('/api/v2/brands?api_token=admin1', [ + 'brand_name' => 'NewBrand', + ]); + $response->assertStatus(201); + $data = $response->json('data'); + $this->assertEquals('NewBrand', $data['brand_name']); + $this->assertDatabaseHas('brands', ['brand_name' => 'NewBrand']); + } + + public function testCreateBrandRequiresAuth(): void + { + $response = $this->postJson('/api/v2/brands', ['brand_name' => 'NoAuth']); + $response->assertStatus(401); + } + + public function testCreateBrandForbiddenForNonAdmin(): void + { + $user = User::factory()->restarter()->create(['api_token' => 'restarter1']); + $this->actingAs($user); + + $response = $this->postJson('/api/v2/brands?api_token=restarter1', [ + 'brand_name' => 'Forbidden', + ]); + $response->assertStatus(403); + } + + public function testCreateBrandValidationFails(): void + { + $admin = User::factory()->administrator()->create(['api_token' => 'admin1']); + $this->actingAs($admin); + + $response = $this->postJson('/api/v2/brands?api_token=admin1', []); + $response->assertStatus(422); + + $response = $this->postJson('/api/v2/brands?api_token=admin1', ['brand_name' => '']); + $response->assertStatus(422); + } + + public function testCreateBrandRejectsDuplicate(): void + { + Brands::factory()->create(['brand_name' => 'Existing']); + $admin = User::factory()->administrator()->create(['api_token' => 'admin1']); + $this->actingAs($admin); + + $response = $this->postJson('/api/v2/brands?api_token=admin1', [ + 'brand_name' => 'Existing', + ]); + $response->assertStatus(422); + } + + public function testUpdateBrandAsAdmin(): void + { + $brand = Brands::factory()->create(['brand_name' => 'OldName']); + $admin = User::factory()->administrator()->create(['api_token' => 'admin1']); + $this->actingAs($admin); + + $response = $this->putJson("/api/v2/brands/{$brand->id}?api_token=admin1", [ + 'brand_name' => 'RenamedBrand', + ]); + $response->assertSuccessful(); + $this->assertDatabaseHas('brands', ['id' => $brand->id, 'brand_name' => 'RenamedBrand']); + } + + public function testUpdateBrandRequiresAuth(): void + { + $brand = Brands::factory()->create(); + $response = $this->putJson("/api/v2/brands/{$brand->id}", ['brand_name' => 'X']); + $response->assertStatus(401); + } + + public function testUpdateBrandForbiddenForNonAdmin(): void + { + $brand = Brands::factory()->create(); + $user = User::factory()->restarter()->create(['api_token' => 'r1']); + $this->actingAs($user); + + $response = $this->putJson("/api/v2/brands/{$brand->id}?api_token=r1", [ + 'brand_name' => 'NoTouch', + ]); + $response->assertStatus(403); + } + + public function testUpdateMissingBrandReturns404(): void + { + $admin = User::factory()->administrator()->create(['api_token' => 'admin1']); + $this->actingAs($admin); + $response = $this->putJson('/api/v2/brands/99999999?api_token=admin1', [ + 'brand_name' => 'Ghost', + ]); + $response->assertStatus(404); + } + + public function testDeleteBrandAsAdmin(): void + { + $brand = Brands::factory()->create(); + $admin = User::factory()->administrator()->create(['api_token' => 'admin1']); + $this->actingAs($admin); + + $response = $this->delete("/api/v2/brands/{$brand->id}?api_token=admin1"); + $response->assertNoContent(); + $this->assertDatabaseMissing('brands', ['id' => $brand->id]); + } + + public function testUpdateAllowsSameNameAsItself(): void + { + $brand = Brands::factory()->create(['brand_name' => 'Sony']); + $admin = User::factory()->administrator()->create(['api_token' => 'admin1']); + $this->actingAs($admin); + + $response = $this->putJson("/api/v2/brands/{$brand->id}?api_token=admin1", [ + 'brand_name' => 'Sony', + ]); + $response->assertSuccessful(); + $this->assertDatabaseHas('brands', ['id' => $brand->id, 'brand_name' => 'Sony']); + } + + public function testDeleteBrandRequiresAuth(): void + { + $brand = Brands::factory()->create(); + $response = $this->deleteJson("/api/v2/brands/{$brand->id}"); + $response->assertStatus(401); + } + + public function testDeleteBrandForbiddenForNonAdmin(): void + { + $brand = Brands::factory()->create(); + $user = User::factory()->restarter()->create(['api_token' => 'r1']); + $this->actingAs($user); + + $response = $this->delete("/api/v2/brands/{$brand->id}?api_token=r1"); + $response->assertStatus(403); + } +} diff --git a/tests/Feature/Brands/BrandsTest.php b/tests/Feature/Brands/BrandsTest.php index d22bb2f7cc..4043588818 100644 --- a/tests/Feature/Brands/BrandsTest.php +++ b/tests/Feature/Brands/BrandsTest.php @@ -1,72 +1,59 @@ loginAsTestUser(Role::ADMINISTRATOR); - // Create a brand. - $response = $this->post('/brands/create', [ - 'brand_name' => 'UT Brand' - ]); - $response->assertRedirect(); - $response->assertSessionHas('success'); + $brand = Brands::factory()->create(['brand_name' => 'UT Brand']); - // Should be listed. $response = $this->get('/brands'); - $response->assertSee('UT Brand'); + $response->assertOk(); + $html = $response->getContent(); + + // Should host the Vue admin SPA + $this->assertStringContainsString('assertMatchesRegularExpression( + '/:initial-brands="\[[^"]*"brand_name":"UT Brand"[^"]*\]"/', + $html, + 'Expected the brand to appear inside the :initial-brands prop' + ); + + // No edit modal pre-opened when arriving at /brands + $this->assertStringContainsString(':initial-edit-id="null"', $html); + } - // Edit it. - $brand = Brands::latest()->first(); - $response = $this->get('/brands/edit/' . $brand->id); - $response->assertSee('UT Brand'); + public function testLegacyEditUrlPreOpensEditModalForBrand(): void + { + $this->loginAsTestUser(Role::ADMINISTRATOR); - $response = $this->post('/brands/edit/' . $brand->id, [ - 'brand-name' => 'UT Brand2' - ]); - $response->assertRedirect(); - $response->assertSessionHas('success'); + $brand = Brands::factory()->create(['brand_name' => 'Legacy Bookmark']); - // New name should show. - $response = $this->get('/brands'); - $response->assertSee('UT Brand2'); + // /brands/edit/{id} used to render a server-side form; we now route bookmarks + // through the SPA and pass the id so the edit modal opens for the right brand. + $response = $this->get('/brands/edit/' . $brand->id); + $response->assertOk(); + $html = $response->getContent(); - // Delete - $response = $this->get('/brands/delete/' . $brand->id); - $response->assertRedirect(); - $response->assertSessionHas('message'); + $this->assertStringContainsString('assertStringContainsString(':initial-edit-id="' . $brand->id . '"', $html); } - public function testErrors(): void { + public function testBrandsAdminPageForbiddenForRestarter(): void + { $this->loginAsTestUser(Role::RESTARTER); - $response = $this->post('/brands/create', [ - 'brand_name' => 'UT Brand' - ]); - $response->assertRedirect('/user/forbidden'); - $response = $this->get('/brands'); $response->assertRedirect('/user/forbidden'); - - $response = $this->get('/brands/edit/1'); - $response->assertRedirect('/user/forbidden'); - - $response = $this->post('/brands/edit/1', [ - 'brand-name' => 'UT Brand2' - ]); - $response->assertRedirect('/user/forbidden'); - - $response = $this->get('/brands/delete/1'); - $response->assertRedirect('/user/forbidden'); } } diff --git a/tests/Feature/Category/APIv2CategoriesTest.php b/tests/Feature/Category/APIv2CategoriesTest.php new file mode 100644 index 0000000000..e4e9a9cf2f --- /dev/null +++ b/tests/Feature/Category/APIv2CategoriesTest.php @@ -0,0 +1,164 @@ +withExceptionHandling(); + } + + public function testListCategoriesPublic(): void + { + // TestCase setUp() creates several categories already (cat1, cat2, cat3, mobile, misc, desktopComputer). + $response = $this->get('/api/v2/categories'); + $response->assertSuccessful(); + + $data = $response->json('data'); + $this->assertIsArray($data); + $names = array_column($data, 'name'); + $this->assertContains('Cat1', $names); + $this->assertContains('Cat2', $names); + } + + public function testListIncludesAdminFields(): void + { + $response = $this->get('/api/v2/categories'); + $row = collect($response->json('data'))->firstWhere('name', 'Cat2'); + + $this->assertNotNull($row, 'Cat2 should be in the list'); + $this->assertArrayHasKey('weight', $row); + $this->assertArrayHasKey('footprint', $row); + $this->assertArrayHasKey('footprint_reliability', $row); + $this->assertArrayHasKey('cluster', $row); + $this->assertArrayHasKey('cluster_name', $row); + $this->assertArrayHasKey('description_short', $row); + } + + public function testGetSingleCategory(): void + { + $response = $this->getJson('/api/v2/categories/222'); + $response->assertSuccessful(); + $data = $response->json('data'); + + $this->assertEquals(222, $data['id']); + $this->assertEquals('Cat2', $data['name']); + $this->assertEquals(2.0, $data['weight']); + $this->assertEquals(2.0, $data['footprint']); + $this->assertTrue($data['powered']); + } + + public function testGetMissingCategoryReturns404(): void + { + $response = $this->getJson('/api/v2/categories/99999999'); + $response->assertStatus(404); + } + + public function testUpdateCategoryAsAdmin(): void + { + $admin = User::factory()->administrator()->create(['api_token' => 'admin1']); + $this->actingAs($admin); + + $response = $this->putJson('/api/v2/categories/222?api_token=admin1', [ + 'name' => 'Cat2-Renamed', + 'weight' => 12.5, + 'footprint' => 150.25, + 'footprint_reliability' => 4, + 'cluster' => 1, + 'description_short' => 'Renamed by test', + ]); + $response->assertSuccessful(); + $this->assertDatabaseHas('categories', [ + 'idcategories' => 222, + 'name' => 'Cat2-Renamed', + 'weight' => 12.5, + 'footprint' => 150.25, + 'footprint_reliability' => 4, + 'cluster' => 1, + 'description_short' => 'Renamed by test', + ]); + } + + public function testUpdateCategoryRequiresAuth(): void + { + $response = $this->putJson('/api/v2/categories/222', [ + 'name' => 'NoAuth', + 'weight' => 1, + 'footprint' => 1, + 'footprint_reliability' => 6, + ]); + $response->assertStatus(401); + } + + public function testUpdateCategoryForbiddenForNonAdmin(): void + { + $user = User::factory()->restarter()->create(['api_token' => 'r1']); + $this->actingAs($user); + + $response = $this->putJson('/api/v2/categories/222?api_token=r1', [ + 'name' => 'NoTouch', + 'weight' => 1, + 'footprint' => 1, + 'footprint_reliability' => 6, + ]); + $response->assertStatus(403); + } + + public function testUpdateCategoryValidationFails(): void + { + $admin = User::factory()->administrator()->create(['api_token' => 'admin1']); + $this->actingAs($admin); + + // Name required + $this->putJson('/api/v2/categories/222?api_token=admin1', []) + ->assertStatus(422); + + // Weight must be numeric & non-negative + $this->putJson('/api/v2/categories/222?api_token=admin1', [ + 'name' => 'x', + 'weight' => -5, + 'footprint' => 1, + 'footprint_reliability' => 1, + ])->assertStatus(422); + + // Reliability must be 1-6 + $this->putJson('/api/v2/categories/222?api_token=admin1', [ + 'name' => 'x', + 'weight' => 1, + 'footprint' => 1, + 'footprint_reliability' => 99, + ])->assertStatus(422); + } + + public function testUpdateMissingCategoryReturns404(): void + { + $admin = User::factory()->administrator()->create(['api_token' => 'admin1']); + $this->actingAs($admin); + + $response = $this->putJson('/api/v2/categories/99999999?api_token=admin1', [ + 'name' => 'Ghost', + 'weight' => 1, + 'footprint' => 1, + 'footprint_reliability' => 6, + ]); + $response->assertStatus(404); + } + + public function testListCategoryClustersPublic(): void + { + $response = $this->get('/api/v2/category-clusters'); + $response->assertSuccessful(); + $data = $response->json('data'); + $this->assertIsArray($data); + $this->assertNotEmpty($data, 'There should be some clusters defined in the DB'); + $first = $data[0]; + $this->assertArrayHasKey('id', $first); + $this->assertArrayHasKey('name', $first); + } +} diff --git a/tests/Feature/Category/CategoryTest.php b/tests/Feature/Category/CategoryTest.php index ef760028f4..e8ccd5e758 100644 --- a/tests/Feature/Category/CategoryTest.php +++ b/tests/Feature/Category/CategoryTest.php @@ -1,56 +1,40 @@ loginAsTestUser(Role::ADMINISTRATOR); - // We should see a category we set up in TestCase. $response = $this->get('/category'); $response->assertSuccessful(); - $response->assertSee('Cat1'); - - // Get the edit page. - $response = $this->get('/category/edit/111'); - $response->assertSuccessful(); - - // Make a change. - $crawler = new Crawler($response->getContent()); - - $tokens = $crawler->filter('input[name=_token]')->each(function (Crawler $node, $i) { - return $node; - }); - - $tokenValue = $tokens[0]->attr('value'); - - $response = $this->post('/category/edit/111', [ - '_token' => $tokenValue, - 'categories_desc' => 'Test category edit' - ]); - - $this->assertTrue($response->isRedirection()); - $response->assertSessionHas('success'); + $html = $response->getContent(); + + $this->assertStringContainsString('assertMatchesRegularExpression( + '/:initial-categories="\[[^"]*"name":"Cat1"[^"]*\]"/', + $html, + 'Expected category Cat1 to be hydrated into :initial-categories' + ); + $this->assertStringContainsString(':initial-edit-id="null"', $html); + $this->assertStringContainsString(':clusters=', $html); + $this->assertStringContainsString(':reliability-options=', $html); } - public function testErrors(): void { - $this->loginAsTestUser(Role::RESTARTER); + public function testLegacyEditUrlPreOpensEditModalForCategory(): void + { + $this->loginAsTestUser(Role::ADMINISTRATOR); $response = $this->get('/category/edit/111'); - $response->assertRedirect('/user/forbidden'); + $response->assertSuccessful(); + $html = $response->getContent(); - $response = $this->post('/category/edit/111', [ - '_token' => 'test', - 'categories_desc' => 'Test category edit' - ]); - $response->assertRedirect('/user/forbidden'); + $this->assertStringContainsString('assertStringContainsString(':initial-edit-id="111"', $html); } } diff --git a/tests/Feature/GroupTags/APIv2GroupTagsTest.php b/tests/Feature/GroupTags/APIv2GroupTagsTest.php new file mode 100644 index 0000000000..ba1cc77092 --- /dev/null +++ b/tests/Feature/GroupTags/APIv2GroupTagsTest.php @@ -0,0 +1,247 @@ +withExceptionHandling(); + } + + public function testListGroupTagsReturnsOnlyGlobalTags(): void + { + GroupTags::factory()->create(['tag_name' => 'GlobalA', 'network_id' => null]); + GroupTags::factory()->create(['tag_name' => 'GlobalB', 'network_id' => null]); + + $network = Network::factory()->create(); + GroupTags::factory()->create(['tag_name' => 'NetworkScoped', 'network_id' => $network->id]); + + $response = $this->get('/api/v2/group-tags'); + $response->assertSuccessful(); + + $names = array_column($response->json('data'), 'name'); + $this->assertContains('GlobalA', $names); + $this->assertContains('GlobalB', $names); + $this->assertNotContains('NetworkScoped', $names, 'Network-scoped tags must not leak into the global list'); + } + + public function testListGroupTagsOrderedAlphabetically(): void + { + GroupTags::factory()->create(['tag_name' => 'Zeppelin', 'network_id' => null]); + GroupTags::factory()->create(['tag_name' => 'Aardvark', 'network_id' => null]); + GroupTags::factory()->create(['tag_name' => 'Mongoose', 'network_id' => null]); + + $response = $this->get('/api/v2/group-tags'); + $response->assertSuccessful(); + $names = array_column($response->json('data'), 'name'); + $filtered = array_values(array_filter($names, fn ($n) => in_array($n, ['Zeppelin', 'Aardvark', 'Mongoose']))); + $this->assertEquals(['Aardvark', 'Mongoose', 'Zeppelin'], $filtered); + } + + public function testGetSingleGroupTag(): void + { + $tag = GroupTags::factory()->create([ + 'tag_name' => 'Reusable', + 'description' => 'For reusable item events', + 'network_id' => null, + ]); + + $response = $this->getJson("/api/v2/group-tags/{$tag->id}"); + $response->assertSuccessful(); + $data = $response->json('data'); + $this->assertEquals('Reusable', $data['name']); + $this->assertEquals('For reusable item events', $data['description']); + $this->assertNull($data['network_id']); + } + + public function testGetNetworkScopedTagViaGlobalEndpointReturns404(): void + { + $network = Network::factory()->create(); + $tag = GroupTags::factory()->create(['network_id' => $network->id]); + + // The global endpoint must refuse to expose network-scoped tags so admins + // can't accidentally manage them from the global page. + $response = $this->getJson("/api/v2/group-tags/{$tag->id}"); + $response->assertStatus(404); + } + + public function testGetMissingGroupTagReturns404(): void + { + $response = $this->getJson('/api/v2/group-tags/99999999'); + $response->assertStatus(404); + } + + public function testCreateGroupTagAsAdmin(): void + { + $admin = User::factory()->administrator()->create(['api_token' => 'admin1']); + $this->actingAs($admin); + + $response = $this->postJson('/api/v2/group-tags?api_token=admin1', [ + 'name' => 'Scotland', + 'description' => 'Groups in Scotland', + ]); + $response->assertStatus(201); + $data = $response->json('data'); + $this->assertEquals('Scotland', $data['name']); + $this->assertNull($data['network_id']); + $this->assertDatabaseHas('group_tags', [ + 'tag_name' => 'Scotland', + 'network_id' => null, + ]); + } + + public function testCreateGroupTagRequiresAuth(): void + { + $response = $this->postJson('/api/v2/group-tags', ['name' => 'X']); + $response->assertStatus(401); + } + + public function testCreateGroupTagForbiddenForNonAdmin(): void + { + $user = User::factory()->restarter()->create(['api_token' => 'r1']); + $this->actingAs($user); + + $response = $this->postJson('/api/v2/group-tags?api_token=r1', ['name' => 'Forbidden']); + $response->assertStatus(403); + } + + public function testCreateGroupTagValidationFails(): void + { + $admin = User::factory()->administrator()->create(['api_token' => 'admin1']); + $this->actingAs($admin); + + $this->postJson('/api/v2/group-tags?api_token=admin1', [])->assertStatus(422); + $this->postJson('/api/v2/group-tags?api_token=admin1', ['name' => ''])->assertStatus(422); + } + + public function testCreateGroupTagRejectsDuplicateGlobalName(): void + { + GroupTags::factory()->create(['tag_name' => 'Existing', 'network_id' => null]); + $admin = User::factory()->administrator()->create(['api_token' => 'admin1']); + $this->actingAs($admin); + + $response = $this->postJson('/api/v2/group-tags?api_token=admin1', ['name' => 'Existing']); + $response->assertStatus(422); + } + + public function testCreateGroupTagAllowsNameThatOnlyExistsInANetwork(): void + { + $network = Network::factory()->create(); + GroupTags::factory()->create(['tag_name' => 'Reused', 'network_id' => $network->id]); + + $admin = User::factory()->administrator()->create(['api_token' => 'admin1']); + $this->actingAs($admin); + + // A global tag with the same name as a network-scoped tag is allowed (they + // live in different scopes); only collisions within the global scope fail. + $response = $this->postJson('/api/v2/group-tags?api_token=admin1', ['name' => 'Reused']); + $response->assertStatus(201); + } + + public function testUpdateGroupTagAsAdmin(): void + { + $tag = GroupTags::factory()->create(['tag_name' => 'OldName', 'network_id' => null]); + $admin = User::factory()->administrator()->create(['api_token' => 'admin1']); + $this->actingAs($admin); + + $response = $this->putJson("/api/v2/group-tags/{$tag->id}?api_token=admin1", [ + 'name' => 'NewName', + 'description' => 'Renamed', + ]); + $response->assertSuccessful(); + $this->assertDatabaseHas('group_tags', [ + 'id' => $tag->id, + 'tag_name' => 'NewName', + 'description' => 'Renamed', + ]); + } + + public function testUpdateAllowsSameNameAsItself(): void + { + $tag = GroupTags::factory()->create(['tag_name' => 'KeepName', 'network_id' => null]); + $admin = User::factory()->administrator()->create(['api_token' => 'admin1']); + $this->actingAs($admin); + + $response = $this->putJson("/api/v2/group-tags/{$tag->id}?api_token=admin1", [ + 'name' => 'KeepName', + ]); + $response->assertSuccessful(); + } + + public function testCannotUpdateNetworkScopedTagViaGlobalEndpoint(): void + { + $network = Network::factory()->create(); + $tag = GroupTags::factory()->create(['network_id' => $network->id]); + + $admin = User::factory()->administrator()->create(['api_token' => 'admin1']); + $this->actingAs($admin); + + $response = $this->putJson("/api/v2/group-tags/{$tag->id}?api_token=admin1", ['name' => 'Hijacked']); + $response->assertStatus(404); + } + + public function testUpdateGroupTagRequiresAuth(): void + { + $tag = GroupTags::factory()->create(['network_id' => null]); + $response = $this->putJson("/api/v2/group-tags/{$tag->id}", ['name' => 'X']); + $response->assertStatus(401); + } + + public function testUpdateGroupTagForbiddenForNonAdmin(): void + { + $tag = GroupTags::factory()->create(['network_id' => null]); + $user = User::factory()->restarter()->create(['api_token' => 'r1']); + $this->actingAs($user); + + $response = $this->putJson("/api/v2/group-tags/{$tag->id}?api_token=r1", ['name' => 'NoTouch']); + $response->assertStatus(403); + } + + public function testDeleteGroupTagAsAdmin(): void + { + $tag = GroupTags::factory()->create(['network_id' => null]); + $admin = User::factory()->administrator()->create(['api_token' => 'admin1']); + $this->actingAs($admin); + + $response = $this->delete("/api/v2/group-tags/{$tag->id}?api_token=admin1"); + $response->assertNoContent(); + $this->assertDatabaseMissing('group_tags', ['id' => $tag->id]); + } + + public function testCannotDeleteNetworkScopedTagViaGlobalEndpoint(): void + { + $network = Network::factory()->create(); + $tag = GroupTags::factory()->create(['network_id' => $network->id]); + + $admin = User::factory()->administrator()->create(['api_token' => 'admin1']); + $this->actingAs($admin); + + $response = $this->delete("/api/v2/group-tags/{$tag->id}?api_token=admin1"); + $response->assertStatus(404); + $this->assertDatabaseHas('group_tags', ['id' => $tag->id]); + } + + public function testDeleteGroupTagRequiresAuth(): void + { + $tag = GroupTags::factory()->create(['network_id' => null]); + $response = $this->deleteJson("/api/v2/group-tags/{$tag->id}"); + $response->assertStatus(401); + } + + public function testDeleteGroupTagForbiddenForNonAdmin(): void + { + $tag = GroupTags::factory()->create(['network_id' => null]); + $user = User::factory()->restarter()->create(['api_token' => 'r1']); + $this->actingAs($user); + + $response = $this->delete("/api/v2/group-tags/{$tag->id}?api_token=r1"); + $response->assertStatus(403); + } +} diff --git a/tests/Feature/Groups/GroupTagsTest.php b/tests/Feature/Groups/GroupTagsTest.php index b51bb4a050..21591f2397 100644 --- a/tests/Feature/Groups/GroupTagsTest.php +++ b/tests/Feature/Groups/GroupTagsTest.php @@ -3,100 +3,59 @@ namespace Tests\Feature; use App\GroupTags; +use App\Network; use App\Role; -use Carbon\Carbon; -use DB; -use HieuLe\WordpressXmlrpcClient\WordpressClient; -use Illuminate\Foundation\Testing\RefreshDatabase; -use Illuminate\Support\Facades\Config; -use Illuminate\Support\Facades\Notification; -use Mockery; use Tests\TestCase; class GroupTagsTest extends TestCase { - public function testList(): void + public function testGroupTagsAdminPageRendersForAdministrator(): void { - $admin = $this->loginAsTestUser(Role::RESTARTER); - $response = $this->get('/tags'); - $response->assertRedirect('/user/forbidden'); + $this->loginAsTestUser(Role::RESTARTER); + $this->get('/tags')->assertRedirect('/user/forbidden'); - $admin = $this->loginAsTestUser(Role::ADMINISTRATOR); - $tag = GroupTags::factory()->create(); + $tag = GroupTags::factory()->create(['tag_name' => 'Scotland', 'network_id' => null]); + $this->loginAsTestUser(Role::ADMINISTRATOR); $response = $this->get('/tags'); - $response->assertSuccessful(); - $response->assertSeeText($tag->tag_name); + $response->assertOk(); + $html = $response->getContent(); + + $this->assertStringContainsString('assertMatchesRegularExpression( + '/:initial-tags="\[[^"]*"name":"Scotland"[^"]*\]"/', + $html, + 'Expected the global tag to appear inside the :initial-tags prop' + ); + $this->assertStringContainsString(':initial-edit-id="null"', $html); } - public function testCreate(): void + public function testAdminPageExcludesNetworkScopedTags(): void { - $tag = GroupTags::factory()->create(); - - $admin = $this->loginAsTestUser(Role::RESTARTER); - $response = $this->post('/tags/create', [ - 'tag-name' => $tag->tag_name, - 'tag-description' => $tag->tag_description, - ]); - $response->assertRedirect('/user/forbidden'); + $this->loginAsTestUser(Role::ADMINISTRATOR); + $network = Network::factory()->create(); + GroupTags::factory()->create(['tag_name' => 'NetworkOnly', 'network_id' => $network->id]); + GroupTags::factory()->create(['tag_name' => 'GlobalOne', 'network_id' => null]); - $admin = $this->loginAsTestUser(Role::ADMINISTRATOR); + $response = $this->get('/tags'); + $html = $response->getContent(); - $response = $this->post('/tags/create', [ - 'tag-name' => $tag->tag_name, - 'tag-description' => $tag->tag_description, - ]); - $response->assertRedirect(); - $response->assertSessionHas('success'); + $this->assertStringContainsString('GlobalOne', $html); + // Network-scoped tags live on the per-network page, not here + $this->assertStringNotContainsString('NetworkOnly', $html); } - public function testGetEdit(): void + public function testLegacyEditUrlPreOpensEditModalForGlobalTag(): void { - $tag = GroupTags::factory()->create(); - - $admin = $this->loginAsTestUser(Role::RESTARTER); - $response = $this->get('/tags/edit/' . $tag->id); - $response->assertRedirect('/user/forbidden'); + $this->loginAsTestUser(Role::ADMINISTRATOR); - $admin = $this->loginAsTestUser(Role::ADMINISTRATOR); + $tag = GroupTags::factory()->create(['tag_name' => 'Bookmark target', 'network_id' => null]); $response = $this->get('/tags/edit/' . $tag->id); - $response->assertSuccessful(); - $response->assertSeeText($tag->tag_name); - } - - public function testEdit(): void - { - $tag = GroupTags::factory()->create(); - - $admin = $this->loginAsTestUser(Role::RESTARTER); - $response = $this->post('/tags/edit/' . $tag->id, [ - 'tag-name' => $tag->tag_name, - 'tag-description' => $tag->tag_description, - ]); - $response->assertRedirect('/user/forbidden'); - - $admin = $this->loginAsTestUser(Role::ADMINISTRATOR); - - $response = $this->post('/tags/edit/' . $tag->id, [ - 'tag-name' => $tag->tag_name . '2', - 'tag-description' => $tag->tag_description . '2', - ]); - $response->assertRedirect(); - $response->assertSessionHas('success'); - } - - public function testDelete(): void { - $tag = GroupTags::factory()->create(); - - $admin = $this->loginAsTestUser(Role::RESTARTER); - $response = $this->get('/tags/delete/' . $tag->id); - $response->assertRedirect('/user/forbidden'); - - $admin = $this->loginAsTestUser(Role::ADMINISTRATOR); + $response->assertOk(); + $html = $response->getContent(); - $response = $this->get('/tags/delete/' . $tag->id); - $response->assertRedirect(); - $response->assertSessionHas('success'); + $this->assertStringContainsString('assertStringContainsString(':initial-edit-id="' . $tag->id . '"', $html); } } diff --git a/tests/Feature/Role/APIv2RolesTest.php b/tests/Feature/Role/APIv2RolesTest.php new file mode 100644 index 0000000000..2115ceea6a --- /dev/null +++ b/tests/Feature/Role/APIv2RolesTest.php @@ -0,0 +1,202 @@ +withExceptionHandling(); + } + + public function testListRolesAsAdmin(): void + { + $admin = User::factory()->administrator()->create(['api_token' => 'admin1']); + $this->actingAs($admin); + + $response = $this->getJson('/api/v2/roles?api_token=admin1'); + $response->assertSuccessful(); + + $data = $response->json('data'); + $this->assertIsArray($data); + + $names = array_column($data, 'name'); + $this->assertContains('Administrator', $names); + $this->assertContains('Host', $names); + $this->assertContains('Restarter', $names); + + // Each entry has id + name + permissions array + permissions_list string + foreach ($data as $row) { + $this->assertArrayHasKey('id', $row); + $this->assertArrayHasKey('name', $row); + $this->assertArrayHasKey('permissions', $row); + $this->assertArrayHasKey('permissions_list', $row); + $this->assertIsArray($row['permissions']); + } + } + + public function testListRolesRequiresAuth(): void + { + $response = $this->getJson('/api/v2/roles'); + $response->assertStatus(401); + } + + public function testListRolesForbiddenForNonAdmin(): void + { + $user = User::factory()->restarter()->create(['api_token' => 'r1']); + $this->actingAs($user); + + $response = $this->getJson('/api/v2/roles?api_token=r1'); + $response->assertStatus(403); + } + + public function testGetSingleRoleAsAdmin(): void + { + $admin = User::factory()->administrator()->create(['api_token' => 'admin1']); + $this->actingAs($admin); + + $response = $this->getJson('/api/v2/roles/' . Role::HOST . '?api_token=admin1'); + $response->assertSuccessful(); + $data = $response->json('data'); + $this->assertEquals(Role::HOST, $data['id']); + $this->assertEquals('Host', $data['name']); + } + + public function testGetSingleRoleForbiddenForNonAdmin(): void + { + $user = User::factory()->restarter()->create(['api_token' => 'r1']); + $this->actingAs($user); + + $response = $this->getJson('/api/v2/roles/' . Role::HOST . '?api_token=r1'); + $response->assertStatus(403); + } + + public function testGetMissingRoleReturns404(): void + { + $admin = User::factory()->administrator()->create(['api_token' => 'admin1']); + $this->actingAs($admin); + + $response = $this->getJson('/api/v2/roles/99999999?api_token=admin1'); + $response->assertStatus(404); + } + + public function testListPermissionsAsAdmin(): void + { + $admin = User::factory()->administrator()->create(['api_token' => 'admin1']); + $this->actingAs($admin); + + $response = $this->getJson('/api/v2/permissions?api_token=admin1'); + $response->assertSuccessful(); + $data = $response->json('data'); + $this->assertIsArray($data); + + // Test DB seeds some permissions; if any exist, each should have id + name + if (!empty($data)) { + $this->assertArrayHasKey('id', $data[0]); + $this->assertArrayHasKey('name', $data[0]); + } + } + + public function testListPermissionsForbiddenForNonAdmin(): void + { + $user = User::factory()->restarter()->create(['api_token' => 'r1']); + $this->actingAs($user); + + $response = $this->getJson('/api/v2/permissions?api_token=r1'); + $response->assertStatus(403); + } + + public function testUpdateRolePermissionsAsAdmin(): void + { + $admin = User::factory()->administrator()->create(['api_token' => 'admin1']); + $this->actingAs($admin); + + $response = $this->putJson( + '/api/v2/roles/' . Role::HOST . '/permissions?api_token=admin1', + ['permissions' => [4, 6]] + ); + $response->assertSuccessful(); + + // Verify pivot rows in DB + $this->assertDatabaseHas('roles_permissions', ['role' => Role::HOST, 'permission' => 4]); + $this->assertDatabaseHas('roles_permissions', ['role' => Role::HOST, 'permission' => 6]); + + // Then replace with [4] only — the removed one should be gone + $response = $this->putJson( + '/api/v2/roles/' . Role::HOST . '/permissions?api_token=admin1', + ['permissions' => [4]] + ); + $response->assertSuccessful(); + $this->assertDatabaseHas('roles_permissions', ['role' => Role::HOST, 'permission' => 4]); + $this->assertDatabaseMissing('roles_permissions', ['role' => Role::HOST, 'permission' => 6]); + } + + public function testUpdateRolePermissionsAllowsEmpty(): void + { + // Pre-seed at least one + DB::insert('INSERT IGNORE INTO roles_permissions (role, permission) VALUES (?, ?)', [Role::HOST, 4]); + + $admin = User::factory()->administrator()->create(['api_token' => 'admin1']); + $this->actingAs($admin); + + $response = $this->putJson( + '/api/v2/roles/' . Role::HOST . '/permissions?api_token=admin1', + ['permissions' => []] + ); + $response->assertSuccessful(); + + $count = DB::selectOne('SELECT COUNT(*) as c FROM roles_permissions WHERE role = ?', [Role::HOST])->c; + $this->assertEquals(0, $count); + } + + public function testUpdateRolePermissionsRequiresAuth(): void + { + $response = $this->putJson( + '/api/v2/roles/' . Role::HOST . '/permissions', + ['permissions' => []] + ); + $response->assertStatus(401); + } + + public function testUpdateRolePermissionsForbiddenForNonAdmin(): void + { + $user = User::factory()->restarter()->create(['api_token' => 'r1']); + $this->actingAs($user); + + $response = $this->putJson( + '/api/v2/roles/' . Role::HOST . '/permissions?api_token=r1', + ['permissions' => []] + ); + $response->assertStatus(403); + } + + public function testUpdateRolePermissionsRejectsUnknownPermission(): void + { + $admin = User::factory()->administrator()->create(['api_token' => 'admin1']); + $this->actingAs($admin); + + $response = $this->putJson( + '/api/v2/roles/' . Role::HOST . '/permissions?api_token=admin1', + ['permissions' => [99999999]] + ); + $response->assertStatus(422); + } + + public function testUpdateMissingRoleReturns404(): void + { + $admin = User::factory()->administrator()->create(['api_token' => 'admin1']); + $this->actingAs($admin); + + $response = $this->putJson( + '/api/v2/roles/99999999/permissions?api_token=admin1', + ['permissions' => []] + ); + $response->assertStatus(404); + } +} diff --git a/tests/Feature/Role/RoleTest.php b/tests/Feature/Role/RoleTest.php index 169ec13ba9..7b48e4dff7 100644 --- a/tests/Feature/Role/RoleTest.php +++ b/tests/Feature/Role/RoleTest.php @@ -1,97 +1,55 @@ expectException(AuthenticationException::class); - $response = $this->get('/role'); + $this->get('/role'); } - public function testNotAdmin(): void { + public function testNotAdmin(): void + { $this->loginAsTestUser(Role::RESTARTER); $response = $this->get('/role'); $response->assertRedirect(RouteServiceProvider::HOME); } - public function testBasic(): void { + public function testRolesAdminPageRendersWithVueData(): void + { $this->loginAsTestUser(Role::ADMINISTRATOR); - // Should see the roles-table Vue component with Host role in the data. - // JSON is HTML-encoded with " for quotes. $response = $this->get('/role'); - $response->assertSee('assertSee('"id":3', false); - $response->assertSee('"role":"Host"', false); - - // Get Edit page. Should see a list of permissions with permission 4 (Create Party). Test environment - // doesn't have permissions set up so just check existance. - $response = $this->get('/role/edit/3'); - $response->assertSee('name="permissions[4]"', false); - $response->assertSee('name="permissions[6]"', false); - - // Post a change to enable 4 & 6. - $crawler = new Crawler($response->getContent()); - - $tokens = $crawler->filter('input[name=_token]')->each(function (Crawler $node, $i) { - return $node; - }); - - $tokenValue = $tokens[0]->attr('value'); - - $tokens = $crawler->filter('input[name=formId]')->each(function (Crawler $node, $i) { - return $node; - }); - - $formId = $tokens[0]->attr('value'); - - $response = $this->post('/role/edit/3', [ - '_token' => $tokenValue, - 'formId' => $formId, - 'permissions' => [ - '4' => 4, - '6' => 6 - ] - ]); - - $response = $this->get('/role/edit/3'); - $response->assertSee('name="permissions[4]" checked', false); - $response->assertSee('name="permissions[6]" checked', false); - - // Remove it again. - $crawler = new Crawler($response->getContent()); - - $tokens = $crawler->filter('input[name=_token]')->each(function (Crawler $node, $i) { - return $node; - }); - - $tokenValue = $tokens[0]->attr('value'); - - $tokens = $crawler->filter('input[name=formId]')->each(function (Crawler $node, $i) { - return $node; - }); + $response->assertOk(); + $html = $response->getContent(); + + $this->assertStringContainsString('assertMatchesRegularExpression( + '/:initial-roles="\[[^"]*"name":"Host"[^"]*\]"/', + $html, + 'Expected the Host role inside :initial-roles' + ); + $this->assertStringContainsString(':initial-permissions=', $html); + $this->assertStringContainsString(':initial-edit-id="null"', $html); + } - $formId = $tokens[0]->attr('value'); + public function testLegacyEditUrlPreOpensEditModalForRole(): void + { + $this->loginAsTestUser(Role::ADMINISTRATOR); - $response = $this->post('/role/edit/3', [ - '_token' => $tokenValue, - 'formId' => $formId, - 'permissions' => [ - '4' => 4, - ] - ]); + $response = $this->get('/role/edit/' . Role::HOST); + $response->assertOk(); + $html = $response->getContent(); - $response = $this->get('/role/edit/3'); - $response->assertSee('name="permissions[4]" checked', false); - $response->assertSee('name="permissions[6]" ', false); + $this->assertStringContainsString('assertStringContainsString(':initial-edit-id="' . Role::HOST . '"', $html); } } diff --git a/tests/Feature/Skills/APIv2SkillsTest.php b/tests/Feature/Skills/APIv2SkillsTest.php new file mode 100644 index 0000000000..4333789458 --- /dev/null +++ b/tests/Feature/Skills/APIv2SkillsTest.php @@ -0,0 +1,246 @@ +withExceptionHandling(); + } + + public function testListSkillsPublic(): void + { + Skills::factory()->create(['skill_name' => 'Soldering', 'category' => 2, 'description' => 'Hot iron']); + Skills::factory()->create(['skill_name' => 'Hospitality', 'category' => 1, 'description' => 'Welcoming people']); + + $response = $this->get('/api/v2/skills'); + $response->assertSuccessful(); + + $data = $response->json('data'); + $this->assertIsArray($data); + $names = array_column($data, 'skill_name'); + $this->assertContains('Soldering', $names); + $this->assertContains('Hospitality', $names); + } + + public function testListSkillsOrderedAlphabetically(): void + { + Skills::factory()->create(['skill_name' => 'Zucchini growing']); + Skills::factory()->create(['skill_name' => 'Astronomy']); + Skills::factory()->create(['skill_name' => 'Mead brewing']); + + $response = $this->get('/api/v2/skills'); + $response->assertSuccessful(); + $names = array_column($response->json('data'), 'skill_name'); + $filtered = array_values(array_filter($names, fn ($n) => in_array($n, ['Zucchini growing', 'Astronomy', 'Mead brewing']))); + $this->assertEquals(['Astronomy', 'Mead brewing', 'Zucchini growing'], $filtered); + } + + public function testGetSingleSkill(): void + { + $skill = Skills::factory()->create([ + 'skill_name' => 'Mediation', + 'category' => 1, + 'description' => 'Conflict resolution', + ]); + + $response = $this->getJson("/api/v2/skills/{$skill->id}"); + $response->assertSuccessful(); + $data = $response->json('data'); + $this->assertEquals('Mediation', $data['skill_name']); + $this->assertEquals(1, $data['category']); + $this->assertEquals('Conflict resolution', $data['description']); + } + + public function testGetMissingSkillReturns404(): void + { + $response = $this->getJson('/api/v2/skills/99999999'); + $response->assertStatus(404); + } + + public function testCreateSkillAsAdmin(): void + { + $admin = User::factory()->administrator()->create(['api_token' => 'admin1']); + $this->actingAs($admin); + + $response = $this->postJson('/api/v2/skills?api_token=admin1', [ + 'skill_name' => 'CAD modelling', + 'category' => 2, + 'description' => 'Designing replacement parts', + ]); + $response->assertStatus(201); + $data = $response->json('data'); + $this->assertEquals('CAD modelling', $data['skill_name']); + $this->assertEquals(2, $data['category']); + $this->assertDatabaseHas('skills', ['skill_name' => 'CAD modelling']); + } + + public function testCreateSkillRequiresAuth(): void + { + $response = $this->postJson('/api/v2/skills', ['skill_name' => 'NoAuth', 'category' => 1]); + $response->assertStatus(401); + } + + public function testCreateSkillForbiddenForNonAdmin(): void + { + $user = User::factory()->restarter()->create(['api_token' => 'r1']); + $this->actingAs($user); + + $response = $this->postJson('/api/v2/skills?api_token=r1', [ + 'skill_name' => 'Forbidden', + 'category' => 1, + ]); + $response->assertStatus(403); + } + + public function testCreateSkillValidationFailures(): void + { + $admin = User::factory()->administrator()->create(['api_token' => 'admin1']); + $this->actingAs($admin); + + $this->postJson('/api/v2/skills?api_token=admin1', []) + ->assertStatus(422); + + $this->postJson('/api/v2/skills?api_token=admin1', [ + 'skill_name' => '', + 'category' => 1, + ])->assertStatus(422); + + // Category must be in the allowed set + $this->postJson('/api/v2/skills?api_token=admin1', [ + 'skill_name' => 'Bad cat', + 'category' => 99, + ])->assertStatus(422); + } + + public function testCreateSkillRejectsDuplicate(): void + { + Skills::factory()->create(['skill_name' => 'Existing']); + $admin = User::factory()->administrator()->create(['api_token' => 'admin1']); + $this->actingAs($admin); + + $response = $this->postJson('/api/v2/skills?api_token=admin1', [ + 'skill_name' => 'Existing', + 'category' => 1, + ]); + $response->assertStatus(422); + } + + public function testUpdateSkillAsAdmin(): void + { + $skill = Skills::factory()->create([ + 'skill_name' => 'OldName', + 'category' => 1, + 'description' => 'Old', + ]); + $admin = User::factory()->administrator()->create(['api_token' => 'admin1']); + $this->actingAs($admin); + + $response = $this->putJson("/api/v2/skills/{$skill->id}?api_token=admin1", [ + 'skill_name' => 'NewName', + 'category' => 2, + 'description' => 'Renamed', + ]); + $response->assertSuccessful(); + $this->assertDatabaseHas('skills', [ + 'id' => $skill->id, + 'skill_name' => 'NewName', + 'category' => 2, + 'description' => 'Renamed', + ]); + } + + public function testUpdateAllowsSameNameAsItself(): void + { + $skill = Skills::factory()->create(['skill_name' => 'KeepMyName', 'category' => 1]); + $admin = User::factory()->administrator()->create(['api_token' => 'admin1']); + $this->actingAs($admin); + + $response = $this->putJson("/api/v2/skills/{$skill->id}?api_token=admin1", [ + 'skill_name' => 'KeepMyName', + 'category' => 1, + ]); + $response->assertSuccessful(); + } + + public function testUpdateSkillRequiresAuth(): void + { + $skill = Skills::factory()->create(); + $response = $this->putJson("/api/v2/skills/{$skill->id}", ['skill_name' => 'X', 'category' => 1]); + $response->assertStatus(401); + } + + public function testUpdateSkillForbiddenForNonAdmin(): void + { + $skill = Skills::factory()->create(); + $user = User::factory()->restarter()->create(['api_token' => 'r1']); + $this->actingAs($user); + + $response = $this->putJson("/api/v2/skills/{$skill->id}?api_token=r1", [ + 'skill_name' => 'NoTouch', + 'category' => 1, + ]); + $response->assertStatus(403); + } + + public function testUpdateMissingSkillReturns404(): void + { + $admin = User::factory()->administrator()->create(['api_token' => 'admin1']); + $this->actingAs($admin); + $response = $this->putJson('/api/v2/skills/99999999?api_token=admin1', [ + 'skill_name' => 'Ghost', + 'category' => 1, + ]); + $response->assertStatus(404); + } + + public function testDeleteSkillAsAdmin(): void + { + $skill = Skills::factory()->create(); + $admin = User::factory()->administrator()->create(['api_token' => 'admin1']); + $this->actingAs($admin); + + $response = $this->delete("/api/v2/skills/{$skill->id}?api_token=admin1"); + $response->assertNoContent(); + $this->assertDatabaseMissing('skills', ['id' => $skill->id]); + } + + public function testDeleteSkillAlsoRemovesUserSkillsPivot(): void + { + $skill = Skills::factory()->create(); + $user = User::factory()->restarter()->create(); + UsersSkills::create(['user' => $user->id, 'skill' => $skill->id]); + + $admin = User::factory()->administrator()->create(['api_token' => 'admin1']); + $this->actingAs($admin); + + $this->delete("/api/v2/skills/{$skill->id}?api_token=admin1")->assertNoContent(); + + $this->assertDatabaseMissing('skills', ['id' => $skill->id]); + $this->assertDatabaseMissing('users_skills', ['user' => $user->id, 'skill' => $skill->id]); + } + + public function testDeleteSkillRequiresAuth(): void + { + $skill = Skills::factory()->create(); + $response = $this->deleteJson("/api/v2/skills/{$skill->id}"); + $response->assertStatus(401); + } + + public function testDeleteSkillForbiddenForNonAdmin(): void + { + $skill = Skills::factory()->create(); + $user = User::factory()->restarter()->create(['api_token' => 'r1']); + $this->actingAs($user); + + $response = $this->delete("/api/v2/skills/{$skill->id}?api_token=r1"); + $response->assertStatus(403); + } +} diff --git a/tests/Feature/Users/SkillsTest.php b/tests/Feature/Users/SkillsTest.php index 57283893a9..ccd4b071d0 100644 --- a/tests/Feature/Users/SkillsTest.php +++ b/tests/Feature/Users/SkillsTest.php @@ -4,94 +4,43 @@ use App\Role; use App\Skills; -use App\User; -use DB; -use Hash; -use Mockery; use Tests\TestCase; class SkillsTest extends TestCase { - public function testIndex(): void { + public function testSkillsAdminPageRendersForAdministrator(): void + { $this->loginAsTestUser(Role::RESTARTER); + $this->get('/skills')->assertRedirect('/user/forbidden'); - $response = $this->get('/skills'); - $response->assertRedirect('/user/forbidden'); - - $skill1 = Skills::create([ - 'skill_name' => 'UT1', - 'category' => 1, - 'description' => 'Planning', - ]); - - $this->loginAsTestUser(Role::ADMINISTRATOR); - $response = $this->get('/skills'); - $response->assertSee('UT1'); - } - - public function testCreate(): void { - $this->loginAsTestUser(Role::RESTARTER); - - $response = $this->post('/skills/create'); - $response->assertRedirect('/user/forbidden'); + Skills::factory()->create(['skill_name' => 'UT1', 'category' => 1, 'description' => 'Planning']); $this->loginAsTestUser(Role::ADMINISTRATOR); - - $response = $this->post('/skills/create', [ - 'skill_name' => 'UT1', - 'skill_desc' => 'UT' - ]); - $this->assertTrue($response->isRedirection()); - $response->assertSessionHas('success'); - $response = $this->get('/skills'); - $response->assertSee('UT1'); + $response->assertOk(); + $html = $response->getContent(); + + $this->assertStringContainsString('assertMatchesRegularExpression( + '/:initial-skills="\[[^"]*"skill_name":"UT1"[^"]*\]"/', + $html, + 'Expected the skill to appear inside the :initial-skills prop' + ); + $this->assertStringContainsString(':initial-edit-id="null"', $html); + $this->assertStringContainsString(':skill-categories=', $html); } - public function testEdit(): void { - $this->loginAsTestUser(Role::RESTARTER); - - $skill1 = Skills::create([ - 'skill_name' => 'UT1', - 'description' => 'Planning', - ]); - - $response = $this->get('/skills/edit/' . $skill1->id); - $response->assertRedirect('/user/forbidden'); - - $response = $this->post('/skills/edit/' . $skill1->id); - $response->assertRedirect('/user/forbidden'); - + public function testLegacyEditUrlPreOpensEditModalForSkill(): void + { $this->loginAsTestUser(Role::ADMINISTRATOR); - $response = $this->get('/skills/edit/' . $skill1->id); - $response->assertSee('name="skill-name"', false); - - $response = $this->post('/skills/edit/' . $skill1->id, [ - 'skill-name' => 'UT2', - 'category' => 2, - 'description' => 'Chaos', - - ]); - $response->assertSessionHas('success'); - $response = $this->get('/skills'); - $response->assertSee('UT2'); - } - - public function testDelete(): void { - $this->loginAsTestUser(Role::RESTARTER); - - $skill1 = Skills::create([ - 'skill_name' => 'UT1', - 'category' => 1, - 'description' => 'Planning', - ]); + $skill = Skills::factory()->create(['skill_name' => 'Bookmark target', 'category' => 1]); - $response = $this->get('/skills/delete/' . $skill1->id); - $response->assertRedirect('/user/forbidden'); + $response = $this->get('/skills/edit/' . $skill->id); + $response->assertOk(); + $html = $response->getContent(); - $this->loginAsTestUser(Role::ADMINISTRATOR); - $response = $this->get('/skills/delete/' . $skill1->id); - $response->assertSessionHas('success'); + $this->assertStringContainsString('assertStringContainsString(':initial-edit-id="' . $skill->id . '"', $html); } -} \ No newline at end of file +} diff --git a/tests/Integration/admin-reference-data.test.js b/tests/Integration/admin-reference-data.test.js new file mode 100644 index 0000000000..21c88876c2 --- /dev/null +++ b/tests/Integration/admin-reference-data.test.js @@ -0,0 +1,65 @@ +/** + * Smoke-test coverage for the reference-data admin pages migrated to Vue: + * /brands -> BrandsPage (AdminCrudPage shell) + * /skills -> SkillsPage (AdminCrudPage shell with select + textarea) + * /category -> CategoriesPage (AdminCrudPage shell, edit-only) + * /role -> RolesPage (bespoke; checkbox-group permission matrix) + * + * Group-tags has its own end-to-end coverage in grouptags.test.js. + * + * NOTE: these are deliberately lightweight - they only verify the Vue mount + * succeeds and the page exposes the expected affordances. Full create/edit/ + * delete round-trips were tried but timed out the CI Playwright step + * (10m no-output budget). They can be added back in a follow-up once we + * understand the per-test budget better. + */ + +const { test } = require('./fixtures') +const { expect } = require('@playwright/test') +const { login } = require('./utils') + +const ADMIN_EMAIL = 'jane@bloggs.net' +const RESTARTER_EMAIL = 'host@test.net' // any non-admin works; host is also non-admin for these pages +const PASSWORD = 'passw0rd' + +// Visit `/` as the admin and assert the named Vue table mounted. +async function smokeAdminPage(page, baseURL, path, prefix) { + await login(page, baseURL, ADMIN_EMAIL, PASSWORD) + await page.goto(baseURL + path) + await expect(page.locator(`[data-testid="${prefix}-table"]`)).toBeVisible({ timeout: 10000 }) +} + +test('Admin brands page renders the Vue SPA with the add button', async ({ page, baseURL }) => { + test.slow() + await smokeAdminPage(page, baseURL, '/brands', 'brands') + await expect(page.locator('[data-testid="brands-add-button"]')).toBeVisible() +}) + +test('Admin skills page renders the Vue SPA with the add button', async ({ page, baseURL }) => { + test.slow() + await smokeAdminPage(page, baseURL, '/skills', 'skills') + await expect(page.locator('[data-testid="skills-add-button"]')).toBeVisible() +}) + +test('Admin categories page renders the Vue SPA, edit-only', async ({ page, baseURL }) => { + test.slow() + await smokeAdminPage(page, baseURL, '/category', 'categories') + // allowCreate=false and allowDelete=false on this resource + await expect(page.locator('[data-testid="categories-add-button"]')).toHaveCount(0) + await expect(page.locator('[data-testid^="categories-delete-"]')).toHaveCount(0) +}) + +test('Admin roles page renders the Vue SPA with the roles table', async ({ page, baseURL }) => { + test.slow() + await smokeAdminPage(page, baseURL, '/role', 'roles') + // Bespoke RolesPage: no add affordance + await expect(page.locator('[data-testid="roles-add-button"]')).toHaveCount(0) +}) + +test('Non-admin is redirected away from /role', async ({ page, baseURL }) => { + test.slow() + await login(page, baseURL, RESTARTER_EMAIL, PASSWORD) + await page.goto(baseURL + '/role') + // Controller redirects to RouteServiceProvider::HOME (= /dashboard) + await expect(page).not.toHaveURL(/\/role$/, { timeout: 10000 }) +}) diff --git a/tests/Integration/grouptags.test.js b/tests/Integration/grouptags.test.js index 2b942a1f3b..c05c90b393 100644 --- a/tests/Integration/grouptags.test.js +++ b/tests/Integration/grouptags.test.js @@ -804,14 +804,19 @@ test('Admin can filter groups by tag', async ({page, baseURL}) => { // ---------- Admin: Global tag management (/tags page) ---------- +// The admin global-tags page is now a Vue SPA (GroupTagsPage / AdminCrudPage) +// instead of a multi-page Blade flow. Modal lifecycle is handled by +// bootstrap-vue and CRUD goes through /api/v2/group-tags, so these tests +// drive the page via the data-testid hooks the component exposes. + test('Admin can view global tags page', async ({page, baseURL}) => { test.slow() await login(page, baseURL, ADMIN_EMAIL, PASSWORD) await page.goto(baseURL + '/tags') await page.waitForLoadState('networkidle') - // Should see the tags table - await expect(page.locator('#tags-table, table').first()).toBeVisible() + await expect(page.locator('[data-testid="group-tags-table"]')).toBeVisible() + await expect(page.locator('[data-testid="group-tags-add-button"]')).toBeVisible() }) test('Admin can add a global tag', async ({page, baseURL}) => { @@ -820,24 +825,20 @@ test('Admin can add a global tag', async ({page, baseURL}) => { await page.goto(baseURL + '/tags') await page.waitForLoadState('networkidle') - // Click create button to open modal - await page.click('button[data-target="#add-new-tag"], .btn-save') - await page.waitForSelector('#add-new-tag.show, .modal.show') + await page.click('[data-testid="group-tags-add-button"]') + await page.waitForSelector('#group-tags-create-modal.show') - // Fill in the form - await page.fill('#tag-name', 'PW Global Tag') - await page.fill('#tag-description', 'Created by Playwright') - await page.click('#add-new-tag .btn-primary, .modal.show button[type="submit"]') + await page.fill('[data-testid="group-tags-create-name"]', 'PW Global Tag') + await page.fill('[data-testid="group-tags-create-description"]', 'Created by Playwright') - await page.waitForLoadState('networkidle') + // OK button in the bootstrap-vue modal footer + await page.click('#group-tags-create-modal .modal-footer .btn-primary') - // After creation, redirects to edit page with success message + // Modal closes on success; success alert appears at the top of the page await expect(page.locator('text=successfully created')).toBeVisible({ timeout: 5000 }) - // Navigate back to tags list and verify it's there - await page.goto(baseURL + '/tags') - await page.waitForLoadState('networkidle') - await expect(page.locator('#tags-table, table').first()).toContainText('PW Global Tag') + // Row is in the list (no page reload needed - it appears in-place) + await expect(page.locator('[data-testid="group-tags-table"]')).toContainText('PW Global Tag') }) test('Admin can edit a global tag', async ({page, baseURL}) => { @@ -846,25 +847,19 @@ test('Admin can edit a global tag', async ({page, baseURL}) => { await page.goto(baseURL + '/tags') await page.waitForLoadState('networkidle') - // Click on the tag name link to go to edit page - await page.locator('a[href*="/tags/edit/"]', { hasText: 'PW Global Tag' }).first().click() - await page.waitForLoadState('networkidle') + // Click the tag name to open the edit modal (uses the data-testid that + // the AdminCrudPage emits per item: group-tags-edit-link-). + await page.locator('[data-testid^="group-tags-edit-link-"]', { hasText: 'PW Global Tag' }).first().click() + await page.waitForSelector('#group-tags-edit-modal.show') - // Should be on the edit page - await expect(page.locator('#tag-name')).toBeVisible() + // Field is pre-filled; change it and save + const nameInput = page.locator('[data-testid="group-tags-edit-name"]') + await expect(nameInput).toBeVisible() + await nameInput.fill('PW Global Tag Edited') + await page.click('#group-tags-edit-modal .modal-footer .btn-primary') - // Change the name - await page.fill('#tag-name', 'PW Global Tag Edited') - await page.click('.btn-create, button[type="submit"]') - await page.waitForLoadState('networkidle') - - // After save, stays on edit page with success message await expect(page.locator('text=successfully updated')).toBeVisible({ timeout: 5000 }) - - // Navigate back to tags list and verify updated name - await page.goto(baseURL + '/tags') - await page.waitForLoadState('networkidle') - await expect(page.locator('#tags-table, table').first()).toContainText('PW Global Tag Edited') + await expect(page.locator('[data-testid="group-tags-table"]')).toContainText('PW Global Tag Edited') }) test('Admin can delete a global tag', async ({page, baseURL}) => { @@ -873,14 +868,16 @@ test('Admin can delete a global tag', async ({page, baseURL}) => { await page.goto(baseURL + '/tags') await page.waitForLoadState('networkidle') - // Click on the tag to go to edit page - await page.locator('a[href*="/tags/edit/"]', { hasText: 'PW Global Tag Edited' }).click() - await page.waitForLoadState('networkidle') + // Find the row, grab the delete button (data-testid is suffixed with the + // item's id, so we match by prefix and scope it to the row containing + // the tag name). + const row = page.locator('tr', { hasText: 'PW Global Tag Edited' }) + await row.locator('[data-testid^="group-tags-delete-"]').click() - // Click delete button - await page.click('.btn-danger') - await page.waitForLoadState('networkidle') + // ConfirmModal opens; click the Confirm button + await page.waitForSelector('#confirmmodal.show') + await page.click('#confirmmodal .modal-footer .btn-primary') - // Should redirect to tags list, tag should be gone - await expect(page.locator('#tags-table, table').first()).not.toContainText('PW Global Tag Edited') + await expect(page.locator('text=deleted')).toBeVisible({ timeout: 5000 }) + await expect(page.locator('[data-testid="group-tags-table"]')).not.toContainText('PW Global Tag Edited') })