Workpack: cf923b52-5e1e-4660-a290-53f8131a18be
Parent boxes: 453096d9 (Project CRUD), df56b2c0 (Task CRUD)
Base package: com.agency.api
Target model: Qwen 8B (local)
Constraint: < 500 tokens of instructions per box
Purpose: Implement the POST endpoint that creates a new project scoped to the current tenant.
Input context:
Projectentity atcom.agency.api.project.model.Projectwith fields:id(UUID),tenantId(UUID),name(String),description(String),createdAt,updatedAtTenantContextbean (CDI,@RequestScoped) exposesgetTenantId(): UUIDAuthorizationService.requireRole(String role)throws 403 if caller lacks role- Quarkus + Hibernate ORM + Panache; Jakarta REST
Instructions:
- Create record
ProjectRequest(String name, String description)incom.agency.api.project.dto. Add@NotBlankonname. - Create record
ProjectResponse(UUID id, String name, String description, Instant createdAt)in the same package. Add a staticof(Project p)factory method. - In
ProjectService(@ApplicationScoped), add methodProjectResponse create(ProjectRequest req): build aProject, settenantIdfromTenantContext, persist, returnProjectResponse.of(entity). - In
ProjectResource(@Path("/api/projects"),@Produces(APPLICATION_JSON)), add@POSTmethod: callauthorizationService.requireRole("ADMIN"), delegate toprojectService.create(req), returnResponse.status(201).entity(dto).build(). - Add entry to
requests.http:POST {{baseUrl}}/api/projectswithX-Tenant-Idheader and JSON body{"name":"Acme","description":"Demo project"}.
Acceptance criteria:
POST /api/projectswith valid body returns201and JSON withidandcreatedAt.POST /api/projectswithoutnamereturns400.- Calling without ADMIN role returns
403.
Tests:
@QuarkusTest
@TestHTTPEndpoint(ProjectResource.class)
class CreateProjectTest {
@Test
void createProject_validBody_returns201() {
given()
.header("X-Tenant-Id", TENANT_ID)
.header("Authorization", "Bearer " + adminToken())
.contentType(ContentType.JSON)
.body("""
{"name": "Acme", "description": "Demo project"}
""")
.when()
.post()
.then()
.statusCode(201)
.body("id", notNullValue())
.body("name", equalTo("Acme"))
.body("createdAt", notNullValue());
}
@Test
void createProject_blankName_returns400() {
given()
.header("X-Tenant-Id", TENANT_ID)
.header("Authorization", "Bearer " + adminToken())
.contentType(ContentType.JSON)
.body("""{"name": "", "description": "x"}""")
.when()
.post()
.then()
.statusCode(400);
}
@Test
void createProject_noAdminRole_returns403() {
given()
.header("X-Tenant-Id", TENANT_ID)
.header("Authorization", "Bearer " + memberToken())
.contentType(ContentType.JSON)
.body("""{"name": "Acme"}""")
.when()
.post()
.then()
.statusCode(403);
}
}Purpose: Implement GET endpoint that returns all projects belonging to the current tenant.
Input context:
- Same
Projectentity andProjectResponseDTO from P1 TenantContextprovidesgetTenantId()- Panache
PanacheRepository<Project>pattern
Instructions:
- In
ProjectRepository(@ApplicationScoped, extendsPanacheRepository<Project>), add methodList<Project> findByTenant(UUID tenantId):return list("tenantId", tenantId). - In
ProjectService, addList<ProjectResponse> list(): callprojectRepository.findByTenant(tenantContext.getTenantId()), map each toProjectResponse.of(p). - In
ProjectResource, add@GETmethod returningList<ProjectResponse>: callprojectService.list(), returnResponse.ok(dtos).build(). - Ensure the response is sorted by
createdAt DESC— addORDER BY createdAt DESCto the Panache query string. - Add entry to
requests.http:GET {{baseUrl}}/api/projectswithX-Tenant-Idheader.
Acceptance criteria:
GET /api/projectsreturns200and a JSON array (may be empty).- Projects from other tenants never appear in the response.
- Order is newest-first.
Tests:
@QuarkusTest
@TestHTTPEndpoint(ProjectResource.class)
class ListProjectsTest {
@Test
void listProjects_returnsTenantProjects() {
// seed two projects for TENANT_A, one for TENANT_B
UUID idA1 = createProject(TENANT_A, "Alpha");
UUID idA2 = createProject(TENANT_A, "Beta");
createProject(TENANT_B, "Other");
List<String> names =
given()
.header("X-Tenant-Id", TENANT_A)
.header("Authorization", "Bearer " + memberToken(TENANT_A))
.when()
.get()
.then()
.statusCode(200)
.extract().jsonPath().getList("name", String.class);
assertThat(names).containsExactlyInAnyOrder("Alpha", "Beta");
}
@Test
void listProjects_emptyTenant_returnsEmptyArray() {
given()
.header("X-Tenant-Id", UUID.randomUUID()) // unknown tenant
.header("Authorization", "Bearer " + memberToken())
.when()
.get()
.then()
.statusCode(200)
.body("$", hasSize(0));
}
@Test
void listProjects_orderedNewestFirst() {
UUID old = createProject(TENANT_A, "Old");
UUID newer = createProject(TENANT_A, "New");
List<String> names =
given()
.header("X-Tenant-Id", TENANT_A)
.header("Authorization", "Bearer " + memberToken(TENANT_A))
.when().get()
.then().statusCode(200)
.extract().jsonPath().getList("name", String.class);
assertThat(names.get(0)).isEqualTo("New");
}
}Purpose: Implement GET endpoint that retrieves a single project by ID, scoped to the current tenant.
Input context:
ProjectRepositorywithfindByTenantfrom P2ProjectResponseDTO from P1- Return
404if project not found or belongs to a different tenant
Instructions:
- In
ProjectRepository, addOptional<Project> findByIdAndTenant(UUID id, UUID tenantId):return find("id = ?1 and tenantId = ?2", id, tenantId).firstResultOptional(). - In
ProjectService, addProjectResponse getById(UUID id): call repository method, throwNotFoundException(Jakarta) if empty, else returnProjectResponse.of(entity). - In
ProjectResource, add@GET @Path("/{id}")method with@PathParam("id") UUID id: callprojectService.getById(id), returnResponse.ok(dto).build(). - Add entry to
requests.http:GET {{baseUrl}}/api/projects/{{projectId}}.
Acceptance criteria:
GET /api/projects/{id}returns200with the project JSON when it belongs to the current tenant.- Returns
404when the ID does not exist or belongs to another tenant.
Tests:
@QuarkusTest
@TestHTTPEndpoint(ProjectResource.class)
class GetProjectByIdTest {
@Test
void getById_exists_returns200() {
UUID id = createProject(TENANT_A, "Acme");
given()
.header("X-Tenant-Id", TENANT_A)
.header("Authorization", "Bearer " + memberToken(TENANT_A))
.when()
.get("/{id}", id)
.then()
.statusCode(200)
.body("id", equalTo(id.toString()))
.body("name", equalTo("Acme"));
}
@Test
void getById_unknownId_returns404() {
given()
.header("X-Tenant-Id", TENANT_A)
.header("Authorization", "Bearer " + memberToken(TENANT_A))
.when()
.get("/{id}", UUID.randomUUID())
.then()
.statusCode(404);
}
@Test
void getById_crossTenant_returns404() {
UUID id = createProject(TENANT_B, "B's project");
given()
.header("X-Tenant-Id", TENANT_A) // wrong tenant
.header("Authorization", "Bearer " + memberToken(TENANT_A))
.when()
.get("/{id}", id)
.then()
.statusCode(404); // must not leak cross-tenant data as 403
}
}Purpose: Implement PATCH endpoint to update name and/or description of an existing project.
Input context:
ProjectRequestDTO from P1 (reuse; fields are optional for PATCH — relax@NotBlankor createProjectUpdateRequestwith nullable fields)ProjectService.getByIdfrom P3- Only ADMIN may update
Instructions:
- Create record
ProjectUpdateRequest(String name, String description)in the DTO package (no@NotBlank— both fields optional; null means "no change"). - In
ProjectService, addProjectResponse update(UUID id, ProjectUpdateRequest req): callgetById(id)to get the entity (or re-query to get the managed entity), apply non-null fields, merge, return updatedProjectResponse. - In
ProjectResource, add@PATCH @Path("/{id}")method: require ADMIN role, delegate toprojectService.update(id, req), returnResponse.ok(dto).build(). - Add entry to
requests.http:PATCH {{baseUrl}}/api/projects/{{projectId}}with partial JSON body.
Acceptance criteria:
PATCH /api/projects/{id}with{"name":"New Name"}returns200and updated JSON.- Fields omitted from the body remain unchanged.
- Returns
404for unknown project or cross-tenant ID. - Returns
403without ADMIN role.
Tests:
@QuarkusTest
@TestHTTPEndpoint(ProjectResource.class)
class UpdateProjectTest {
@Test
void update_nameOnly_returnsUpdated() {
UUID id = createProject(TENANT_A, "Old Name");
given()
.header("X-Tenant-Id", TENANT_A)
.header("Authorization", "Bearer " + adminToken(TENANT_A))
.contentType(ContentType.JSON)
.body("""{"name": "New Name"}""")
.when()
.patch("/{id}", id)
.then()
.statusCode(200)
.body("name", equalTo("New Name"))
.body("description", equalTo("Original description")); // unchanged
}
@Test
void update_bothFields_returnsUpdated() {
UUID id = createProject(TENANT_A, "Old", "Old desc");
given()
.header("X-Tenant-Id", TENANT_A)
.header("Authorization", "Bearer " + adminToken(TENANT_A))
.contentType(ContentType.JSON)
.body("""{"name":"New","description":"New desc"}""")
.when()
.patch("/{id}", id)
.then()
.statusCode(200)
.body("name", equalTo("New"))
.body("description", equalTo("New desc"));
}
@Test
void update_memberRole_returns403() {
UUID id = createProject(TENANT_A, "X");
given()
.header("X-Tenant-Id", TENANT_A)
.header("Authorization", "Bearer " + memberToken(TENANT_A))
.contentType(ContentType.JSON)
.body("""{"name":"Hacked"}""")
.when()
.patch("/{id}", id)
.then()
.statusCode(403);
}
@Test
void update_unknownId_returns404() {
given()
.header("X-Tenant-Id", TENANT_A)
.header("Authorization", "Bearer " + adminToken(TENANT_A))
.contentType(ContentType.JSON)
.body("""{"name":"x"}""")
.when()
.patch("/{id}", UUID.randomUUID())
.then()
.statusCode(404);
}
}Purpose: Implement DELETE endpoint to permanently remove a project owned by the current tenant.
Input context:
ProjectRepositoryfrom P2- Only ADMIN may delete
- Return
204 No Contenton success
Instructions:
- In
ProjectRepository, addboolean deleteByIdAndTenant(UUID id, UUID tenantId): usedelete("id = ?1 and tenantId = ?2", id, tenantId), returncount > 0. - In
ProjectService, addvoid delete(UUID id): call repository delete method; if returnsfalse, throwNotFoundException. - In
ProjectResource, add@DELETE @Path("/{id}")method: require ADMIN role, callprojectService.delete(id), returnResponse.noContent().build(). - Add entry to
requests.http:DELETE {{baseUrl}}/api/projects/{{projectId}}.
Acceptance criteria:
DELETE /api/projects/{id}returns204and the project no longer appears in GET list.- Returns
404for unknown or cross-tenant ID. - Returns
403without ADMIN role.
Tests:
@QuarkusTest
@TestHTTPEndpoint(ProjectResource.class)
class DeleteProjectTest {
@Test
void delete_existing_returns204AndRemoved() {
UUID id = createProject(TENANT_A, "ToDelete");
given()
.header("X-Tenant-Id", TENANT_A)
.header("Authorization", "Bearer " + adminToken(TENANT_A))
.when()
.delete("/{id}", id)
.then()
.statusCode(204);
// verify it's gone
given()
.header("X-Tenant-Id", TENANT_A)
.header("Authorization", "Bearer " + adminToken(TENANT_A))
.when()
.get("/{id}", id)
.then()
.statusCode(404);
}
@Test
void delete_unknownId_returns404() {
given()
.header("X-Tenant-Id", TENANT_A)
.header("Authorization", "Bearer " + adminToken(TENANT_A))
.when()
.delete("/{id}", UUID.randomUUID())
.then()
.statusCode(404);
}
@Test
void delete_memberRole_returns403() {
UUID id = createProject(TENANT_A, "X");
given()
.header("X-Tenant-Id", TENANT_A)
.header("Authorization", "Bearer " + memberToken(TENANT_A))
.when()
.delete("/{id}", id)
.then()
.statusCode(403);
}
}Purpose: Implement POST endpoint to create a task within a project, scoped to tenant.
Input context:
Taskentity atcom.agency.api.task.model.Taskwith fields:id(UUID),tenantId(UUID),projectId(UUID),title(String),description(String),status(TaskStatusenum:TODO,IN_PROGRESS,DONE),priority(TaskPriorityenum:LOW,MEDIUM,HIGH),assigneeId(UUID nullable),createdAt,updatedAtTenantContext,AuthorizationService(same as Project)ProjectRepository.findByIdAndTenantfrom P3 — validate project exists before creating task- Base path:
/api/projects/{projectId}/tasks
Instructions:
- Create record
TaskCreateRequest(String title, String description, TaskPriority priority, UUID assigneeId)incom.agency.api.task.dto. Add@NotBlankontitle. - Create record
TaskResponse(UUID id, UUID projectId, String title, String description, TaskStatus status, TaskPriority priority, UUID assigneeId, Instant createdAt)with staticof(Task t)factory. - In
TaskService(@ApplicationScoped), addTaskResponse create(UUID projectId, TaskCreateRequest req): verify project exists viaProjectRepository.findByIdAndTenant, buildTaskwithstatus = TODO, persist, returnTaskResponse.of(entity). - In
TaskResource(@Path("/api/projects/{projectId}/tasks")), add@POSTmethod: requireMEMBERorADMINrole, delegate totaskService.create(projectId, req), return201. - Add
requests.httpentry:POST {{baseUrl}}/api/projects/{{projectId}}/tasks.
Acceptance criteria:
POST /api/projects/{projectId}/tasksreturns201withstatus: "TODO".- Returns
404ifprojectIddoes not exist or belongs to another tenant. - Returns
400iftitleis blank.
Tests:
@QuarkusTest
class CreateTaskTest {
@Test
void createTask_valid_returns201WithTodoStatus() {
UUID projectId = createProject(TENANT_A, "P1");
given()
.header("X-Tenant-Id", TENANT_A)
.header("Authorization", "Bearer " + memberToken(TENANT_A))
.contentType(ContentType.JSON)
.body("""
{"title":"Fix login bug","priority":"HIGH"}
""")
.when()
.post("/api/projects/{pid}/tasks", projectId)
.then()
.statusCode(201)
.body("id", notNullValue())
.body("status", equalTo("TODO"))
.body("priority", equalTo("HIGH"));
}
@Test
void createTask_projectNotFound_returns404() {
given()
.header("X-Tenant-Id", TENANT_A)
.header("Authorization", "Bearer " + memberToken(TENANT_A))
.contentType(ContentType.JSON)
.body("""{"title":"x"}""")
.when()
.post("/api/projects/{pid}/tasks", UUID.randomUUID())
.then()
.statusCode(404);
}
@Test
void createTask_crossTenantProject_returns404() {
UUID projectId = createProject(TENANT_B, "B's project");
given()
.header("X-Tenant-Id", TENANT_A)
.header("Authorization", "Bearer " + memberToken(TENANT_A))
.contentType(ContentType.JSON)
.body("""{"title":"x"}""")
.when()
.post("/api/projects/{pid}/tasks", projectId)
.then()
.statusCode(404);
}
@Test
void createTask_blankTitle_returns400() {
UUID projectId = createProject(TENANT_A, "P1");
given()
.header("X-Tenant-Id", TENANT_A)
.header("Authorization", "Bearer " + memberToken(TENANT_A))
.contentType(ContentType.JSON)
.body("""{"title":""}""")
.when()
.post("/api/projects/{pid}/tasks", projectId)
.then()
.statusCode(400);
}
}Purpose: Implement GET endpoint to list all tasks in a project with optional status filter.
Input context:
TaskResponseDTO from T1TaskRepository(Panache) with tenant-scoped queries- Optional query param
?status=TODO|IN_PROGRESS|DONE
Instructions:
- In
TaskRepository, addList<Task> findByProject(UUID projectId, UUID tenantId):return list("projectId = ?1 and tenantId = ?2 ORDER BY createdAt DESC", projectId, tenantId). - Add
List<Task> findByProjectAndStatus(UUID projectId, UUID tenantId, TaskStatus status): same query plusand status = ?3. - In
TaskService, addList<TaskResponse> list(UUID projectId, TaskStatus statusFilter): branch on null statusFilter, map toTaskResponse. - In
TaskResource, add@GETmethod with@QueryParam("status") TaskStatus statusparam: calltaskService.list(projectId, status), return200. - Add
requests.httpentry:GET {{baseUrl}}/api/projects/{{projectId}}/tasks?status=TODO.
Acceptance criteria:
GET /api/projects/{projectId}/tasksreturns all tasks for that project.?status=DONEfilters correctly.- Returns
[](not 404) when no tasks exist. - Tasks from other projects or tenants never appear.
Tests:
@QuarkusTest
class ListTasksTest {
@Test
void listTasks_noFilter_returnsAll() {
UUID pid = createProject(TENANT_A, "P");
createTask(TENANT_A, pid, "T1", TaskStatus.TODO);
createTask(TENANT_A, pid, "T2", TaskStatus.DONE);
given()
.header("X-Tenant-Id", TENANT_A)
.header("Authorization", "Bearer " + memberToken(TENANT_A))
.when()
.get("/api/projects/{pid}/tasks", pid)
.then()
.statusCode(200)
.body("$", hasSize(2));
}
@Test
void listTasks_withStatusFilter_returnsFiltered() {
UUID pid = createProject(TENANT_A, "P");
createTask(TENANT_A, pid, "T1", TaskStatus.TODO);
createTask(TENANT_A, pid, "T2", TaskStatus.DONE);
given()
.header("X-Tenant-Id", TENANT_A)
.header("Authorization", "Bearer " + memberToken(TENANT_A))
.queryParam("status", "TODO")
.when()
.get("/api/projects/{pid}/tasks", pid)
.then()
.statusCode(200)
.body("$", hasSize(1))
.body("[0].title", equalTo("T1"));
}
@Test
void listTasks_crossTenantIsolation() {
UUID pid = createProject(TENANT_A, "P");
createTask(TENANT_B, pid, "Intruder", TaskStatus.TODO); // TENANT_B task in TENANT_A's project ID
given()
.header("X-Tenant-Id", TENANT_A)
.header("Authorization", "Bearer " + memberToken(TENANT_A))
.when()
.get("/api/projects/{pid}/tasks", pid)
.then()
.statusCode(200)
.body("title", not(hasItem("Intruder")));
}
@Test
void listTasks_emptyProject_returnsEmptyArray() {
UUID pid = createProject(TENANT_A, "Empty");
given()
.header("X-Tenant-Id", TENANT_A)
.header("Authorization", "Bearer " + memberToken(TENANT_A))
.when()
.get("/api/projects/{pid}/tasks", pid)
.then()
.statusCode(200)
.body("$", hasSize(0));
}
}Purpose: Implement GET endpoint to retrieve a single task by ID within a project.
Input context:
TaskRepositoryfrom T2TaskResponseDTO from T1- Must verify both
projectIdandtenantIdmatch
Instructions:
- In
TaskRepository, addOptional<Task> findByIdAndProject(UUID taskId, UUID projectId, UUID tenantId):return find("id = ?1 and projectId = ?2 and tenantId = ?3", taskId, projectId, tenantId).firstResultOptional(). - In
TaskService, addTaskResponse getById(UUID projectId, UUID taskId): call repository, throwNotFoundExceptionif empty. - In
TaskResource, add@GET @Path("/{taskId}")method: calltaskService.getById(projectId, taskId), return200. - Add
requests.httpentry:GET {{baseUrl}}/api/projects/{{projectId}}/tasks/{{taskId}}.
Acceptance criteria:
- Returns
200with full task JSON when task belongs to the project and tenant. - Returns
404if task ID is unknown, belongs to another project, or another tenant.
Tests:
@QuarkusTest
class GetTaskByIdTest {
@Test
void getTask_exists_returns200() {
UUID pid = createProject(TENANT_A, "P");
UUID tid = createTask(TENANT_A, pid, "Fix bug", TaskStatus.TODO);
given()
.header("X-Tenant-Id", TENANT_A)
.header("Authorization", "Bearer " + memberToken(TENANT_A))
.when()
.get("/api/projects/{pid}/tasks/{tid}", pid, tid)
.then()
.statusCode(200)
.body("id", equalTo(tid.toString()))
.body("title", equalTo("Fix bug"))
.body("status", equalTo("TODO"));
}
@Test
void getTask_wrongProject_returns404() {
UUID pid1 = createProject(TENANT_A, "P1");
UUID pid2 = createProject(TENANT_A, "P2");
UUID tid = createTask(TENANT_A, pid1, "T", TaskStatus.TODO);
given()
.header("X-Tenant-Id", TENANT_A)
.header("Authorization", "Bearer " + memberToken(TENANT_A))
.when()
.get("/api/projects/{pid}/tasks/{tid}", pid2, tid) // wrong project
.then()
.statusCode(404);
}
@Test
void getTask_unknownId_returns404() {
UUID pid = createProject(TENANT_A, "P");
given()
.header("X-Tenant-Id", TENANT_A)
.header("Authorization", "Bearer " + memberToken(TENANT_A))
.when()
.get("/api/projects/{pid}/tasks/{tid}", pid, UUID.randomUUID())
.then()
.statusCode(404);
}
}Purpose: Implement PATCH endpoint to update title, description, priority, or assignee of a task.
Input context:
TaskService.getByIdfrom T3- Managed entity must be re-fetched for mutation (not the DTO)
- Do not allow status changes here — that belongs to T6
Instructions:
- Create record
TaskUpdateRequest(String title, String description, TaskPriority priority, UUID assigneeId)— all fields nullable (null = no change). - In
TaskService, addTaskResponse update(UUID projectId, UUID taskId, TaskUpdateRequest req): re-fetch the managedTaskentity, apply non-null fields (skip status), merge, return updated DTO. - In
TaskResource, add@PATCH @Path("/{taskId}")method: requireMEMBERorADMINrole, delegate totaskService.update(...), return200. - Add
requests.httpentry:PATCH {{baseUrl}}/api/projects/{{projectId}}/tasks/{{taskId}}with partial JSON.
Acceptance criteria:
PATCHwith{"priority":"HIGH"}returns200and only priority changes.statusfield in the request body is ignored (no effect).- Returns
404for unknown task.
Tests:
@QuarkusTest
class UpdateTaskTest {
@Test
void update_priorityOnly_otherFieldsUnchanged() {
UUID pid = createProject(TENANT_A, "P");
UUID tid = createTask(TENANT_A, pid, "Fix bug", TaskStatus.TODO, TaskPriority.LOW);
given()
.header("X-Tenant-Id", TENANT_A)
.header("Authorization", "Bearer " + memberToken(TENANT_A))
.contentType(ContentType.JSON)
.body("""{"priority":"HIGH"}""")
.when()
.patch("/api/projects/{pid}/tasks/{tid}", pid, tid)
.then()
.statusCode(200)
.body("priority", equalTo("HIGH"))
.body("title", equalTo("Fix bug")) // unchanged
.body("status", equalTo("TODO")); // unchanged
}
@Test
void update_statusFieldIgnored() {
UUID pid = createProject(TENANT_A, "P");
UUID tid = createTask(TENANT_A, pid, "T", TaskStatus.TODO);
given()
.header("X-Tenant-Id", TENANT_A)
.header("Authorization", "Bearer " + memberToken(TENANT_A))
.contentType(ContentType.JSON)
.body("""{"status":"DONE"}""") // should be ignored
.when()
.patch("/api/projects/{pid}/tasks/{tid}", pid, tid)
.then()
.statusCode(200)
.body("status", equalTo("TODO")); // still TODO
}
@Test
void update_unknownTask_returns404() {
UUID pid = createProject(TENANT_A, "P");
given()
.header("X-Tenant-Id", TENANT_A)
.header("Authorization", "Bearer " + memberToken(TENANT_A))
.contentType(ContentType.JSON)
.body("""{"title":"x"}""")
.when()
.patch("/api/projects/{pid}/tasks/{tid}", pid, UUID.randomUUID())
.then()
.statusCode(404);
}
}Purpose: Implement DELETE endpoint to remove a task from a project.
Input context:
TaskRepositoryfrom T2- Only ADMIN or the task creator may delete (check
assigneeIdor use ADMIN-only for simplicity) - Return
204 No Content
Instructions:
- In
TaskRepository, addboolean deleteByIdAndProject(UUID taskId, UUID projectId, UUID tenantId):return delete("id = ?1 and projectId = ?2 and tenantId = ?3", ...) > 0. - In
TaskService, addvoid delete(UUID projectId, UUID taskId): call repository; throwNotFoundExceptionif false. - In
TaskResource, add@DELETE @Path("/{taskId}")method: requireADMINrole, calltaskService.delete(projectId, taskId), return204. - Add
requests.httpentry:DELETE {{baseUrl}}/api/projects/{{projectId}}/tasks/{{taskId}}.
Acceptance criteria:
DELETEreturns204and task no longer appears in list.- Returns
404for unknown task. - Returns
403without ADMIN role.
Tests:
@QuarkusTest
class DeleteTaskTest {
@Test
void delete_existing_returns204AndTaskGone() {
UUID pid = createProject(TENANT_A, "P");
UUID tid = createTask(TENANT_A, pid, "T", TaskStatus.TODO);
given()
.header("X-Tenant-Id", TENANT_A)
.header("Authorization", "Bearer " + adminToken(TENANT_A))
.when()
.delete("/api/projects/{pid}/tasks/{tid}", pid, tid)
.then()
.statusCode(204);
given()
.header("X-Tenant-Id", TENANT_A)
.header("Authorization", "Bearer " + memberToken(TENANT_A))
.when()
.get("/api/projects/{pid}/tasks/{tid}", pid, tid)
.then()
.statusCode(404);
}
@Test
void delete_memberRole_returns403() {
UUID pid = createProject(TENANT_A, "P");
UUID tid = createTask(TENANT_A, pid, "T", TaskStatus.TODO);
given()
.header("X-Tenant-Id", TENANT_A)
.header("Authorization", "Bearer " + memberToken(TENANT_A))
.when()
.delete("/api/projects/{pid}/tasks/{tid}", pid, tid)
.then()
.statusCode(403);
}
@Test
void delete_unknownId_returns404() {
UUID pid = createProject(TENANT_A, "P");
given()
.header("X-Tenant-Id", TENANT_A)
.header("Authorization", "Bearer " + adminToken(TENANT_A))
.when()
.delete("/api/projects/{pid}/tasks/{tid}", pid, UUID.randomUUID())
.then()
.statusCode(404);
}
}Purpose: Implement PATCH endpoint to transition a task's status through its lifecycle.
Input context:
TaskStatusenum:TODO → IN_PROGRESS → DONE(no backwards transition for now)- Valid transitions:
TODO→IN_PROGRESS,IN_PROGRESS→DONE - Invalid transitions return
422 Unprocessable Entity - Any MEMBER may transition their own tasks; ADMIN can transition any
Instructions:
- Create record
TaskStatusRequest(TaskStatus status)with@NotNullonstatus. - In
TaskService, addTaskResponse transition(UUID projectId, UUID taskId, TaskStatus newStatus): fetch entity, validate transition (throw422if invalid usingWebApplicationException(422)), set new status, merge, return DTO. Allowed:TODO→IN_PROGRESS,IN_PROGRESS→DONE. - In
TaskResource, add@PATCH @Path("/{taskId}/status")method: requireMEMBERorADMIN, delegate totaskService.transition(...), return200. - Add
requests.httpentries:PATCH .../tasks/{{taskId}}/statuswith body{"status":"IN_PROGRESS"}and{"status":"DONE"}.
Acceptance criteria:
PATCH .../statuswith{"status":"IN_PROGRESS"}on aTODOtask returns200and updated status.- Attempting
DONE→TODOreturns422. - Attempting
TODO→DONE(skipping IN_PROGRESS) returns422. - Returns
404for unknown task.
Tests:
@QuarkusTest
class TaskStatusTransitionTest {
@Test
void transition_todoToInProgress_returns200() {
UUID pid = createProject(TENANT_A, "P");
UUID tid = createTask(TENANT_A, pid, "T", TaskStatus.TODO);
given()
.header("X-Tenant-Id", TENANT_A)
.header("Authorization", "Bearer " + memberToken(TENANT_A))
.contentType(ContentType.JSON)
.body("""{"status":"IN_PROGRESS"}""")
.when()
.patch("/api/projects/{pid}/tasks/{tid}/status", pid, tid)
.then()
.statusCode(200)
.body("status", equalTo("IN_PROGRESS"));
}
@Test
void transition_inProgressToDone_returns200() {
UUID pid = createProject(TENANT_A, "P");
UUID tid = createTask(TENANT_A, pid, "T", TaskStatus.IN_PROGRESS);
given()
.header("X-Tenant-Id", TENANT_A)
.header("Authorization", "Bearer " + memberToken(TENANT_A))
.contentType(ContentType.JSON)
.body("""{"status":"DONE"}""")
.when()
.patch("/api/projects/{pid}/tasks/{tid}/status", pid, tid)
.then()
.statusCode(200)
.body("status", equalTo("DONE"));
}
@Test
void transition_todoToDone_returns422() {
UUID pid = createProject(TENANT_A, "P");
UUID tid = createTask(TENANT_A, pid, "T", TaskStatus.TODO);
given()
.header("X-Tenant-Id", TENANT_A)
.header("Authorization", "Bearer " + memberToken(TENANT_A))
.contentType(ContentType.JSON)
.body("""{"status":"DONE"}""")
.when()
.patch("/api/projects/{pid}/tasks/{tid}/status", pid, tid)
.then()
.statusCode(422);
}
@Test
void transition_doneToTodo_returns422() {
UUID pid = createProject(TENANT_A, "P");
UUID tid = createTask(TENANT_A, pid, "T", TaskStatus.DONE);
given()
.header("X-Tenant-Id", TENANT_A)
.header("Authorization", "Bearer " + memberToken(TENANT_A))
.contentType(ContentType.JSON)
.body("""{"status":"TODO"}""")
.when()
.patch("/api/projects/{pid}/tasks/{tid}/status", pid, tid)
.then()
.statusCode(422);
}
@Test
void transition_unknownTask_returns404() {
UUID pid = createProject(TENANT_A, "P");
given()
.header("X-Tenant-Id", TENANT_A)
.header("Authorization", "Bearer " + memberToken(TENANT_A))
.contentType(ContentType.JSON)
.body("""{"status":"IN_PROGRESS"}""")
.when()
.patch("/api/projects/{pid}/tasks/{tid}/status", pid, UUID.randomUUID())
.then()
.statusCode(404);
}
}P1 → P2 → P3 → P4 → P5
T1 → T2 → T3 → T4 → T5 → T6
Run P-series first (Project entity must exist before Task foreign key is used). Each box can be executed independently once its dependencies are complete.
protected static final UUID TENANT_A = UUID.fromString("aaaaaaaa-0000-0000-0000-000000000001");
protected static final UUID TENANT_B = UUID.fromString("bbbbbbbb-0000-0000-0000-000000000002");
protected UUID createProject(UUID tenantId, String name) {
return createProject(tenantId, name, "");
}
protected UUID createProject(UUID tenantId, String name, String description) {
return UUID.fromString(
given()
.header("X-Tenant-Id", tenantId)
.header("Authorization", "Bearer " + adminToken(tenantId))
.contentType(ContentType.JSON)
.body(Map.of("name", name, "description", description))
.when()
.post("/api/projects")
.then()
.statusCode(201)
.extract().path("id")
);
}
protected UUID createTask(UUID tenantId, UUID projectId, String title, TaskStatus status) {
return createTask(tenantId, projectId, title, status, TaskPriority.MEDIUM);
}
protected UUID createTask(UUID tenantId, UUID projectId, String title,
TaskStatus status, TaskPriority priority) {
// create as TODO first (status can't be set on creation)
UUID id = UUID.fromString(
given()
.header("X-Tenant-Id", tenantId)
.header("Authorization", "Bearer " + memberToken(tenantId))
.contentType(ContentType.JSON)
.body(Map.of("title", title, "priority", priority.name()))
.when()
.post("/api/projects/{pid}/tasks", projectId)
.then()
.statusCode(201)
.extract().path("id")
);
// advance status if needed
if (status == TaskStatus.IN_PROGRESS || status == TaskStatus.DONE) {
transitionTask(tenantId, projectId, id, TaskStatus.IN_PROGRESS);
}
if (status == TaskStatus.DONE) {
transitionTask(tenantId, projectId, id, TaskStatus.DONE);
}
return id;
}
private void transitionTask(UUID tenantId, UUID projectId, UUID taskId, TaskStatus status) {
given()
.header("X-Tenant-Id", tenantId)
.header("Authorization", "Bearer " + memberToken(tenantId))
.contentType(ContentType.JSON)
.body(Map.of("status", status.name()))
.when()
.patch("/api/projects/{pid}/tasks/{tid}/status", projectId, taskId)
.then()
.statusCode(200);
}