Skip to content

Latest commit

 

History

History
1030 lines (853 loc) · 35.6 KB

File metadata and controls

1030 lines (853 loc) · 35.6 KB

TaskFlow — Micro-Boxes for Local 8B Execution

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


PROJECT CRUD — 5 Micro-Boxes


P1 · Create Project

Purpose: Implement the POST endpoint that creates a new project scoped to the current tenant.

Input context:

  • Project entity at com.agency.api.project.model.Project with fields: id (UUID), tenantId (UUID), name (String), description (String), createdAt, updatedAt
  • TenantContext bean (CDI, @RequestScoped) exposes getTenantId(): UUID
  • AuthorizationService.requireRole(String role) throws 403 if caller lacks role
  • Quarkus + Hibernate ORM + Panache; Jakarta REST

Instructions:

  1. Create record ProjectRequest(String name, String description) in com.agency.api.project.dto. Add @NotBlank on name.
  2. Create record ProjectResponse(UUID id, String name, String description, Instant createdAt) in the same package. Add a static of(Project p) factory method.
  3. In ProjectService (@ApplicationScoped), add method ProjectResponse create(ProjectRequest req): build a Project, set tenantId from TenantContext, persist, return ProjectResponse.of(entity).
  4. In ProjectResource (@Path("/api/projects"), @Produces(APPLICATION_JSON)), add @POST method: call authorizationService.requireRole("ADMIN"), delegate to projectService.create(req), return Response.status(201).entity(dto).build().
  5. Add entry to requests.http: POST {{baseUrl}}/api/projects with X-Tenant-Id header and JSON body {"name":"Acme","description":"Demo project"}.

Acceptance criteria:

  • POST /api/projects with valid body returns 201 and JSON with id and createdAt.
  • POST /api/projects without name returns 400.
  • 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);
    }
}

P2 · List Projects

Purpose: Implement GET endpoint that returns all projects belonging to the current tenant.

Input context:

  • Same Project entity and ProjectResponse DTO from P1
  • TenantContext provides getTenantId()
  • Panache PanacheRepository<Project> pattern

Instructions:

  1. In ProjectRepository (@ApplicationScoped, extends PanacheRepository<Project>), add method List<Project> findByTenant(UUID tenantId): return list("tenantId", tenantId).
  2. In ProjectService, add List<ProjectResponse> list(): call projectRepository.findByTenant(tenantContext.getTenantId()), map each to ProjectResponse.of(p).
  3. In ProjectResource, add @GET method returning List<ProjectResponse>: call projectService.list(), return Response.ok(dtos).build().
  4. Ensure the response is sorted by createdAt DESC — add ORDER BY createdAt DESC to the Panache query string.
  5. Add entry to requests.http: GET {{baseUrl}}/api/projects with X-Tenant-Id header.

Acceptance criteria:

  • GET /api/projects returns 200 and 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");
    }
}

P3 · Get Project by ID

Purpose: Implement GET endpoint that retrieves a single project by ID, scoped to the current tenant.

Input context:

  • ProjectRepository with findByTenant from P2
  • ProjectResponse DTO from P1
  • Return 404 if project not found or belongs to a different tenant

Instructions:

  1. In ProjectRepository, add Optional<Project> findByIdAndTenant(UUID id, UUID tenantId): return find("id = ?1 and tenantId = ?2", id, tenantId).firstResultOptional().
  2. In ProjectService, add ProjectResponse getById(UUID id): call repository method, throw NotFoundException (Jakarta) if empty, else return ProjectResponse.of(entity).
  3. In ProjectResource, add @GET @Path("/{id}") method with @PathParam("id") UUID id: call projectService.getById(id), return Response.ok(dto).build().
  4. Add entry to requests.http: GET {{baseUrl}}/api/projects/{{projectId}}.

Acceptance criteria:

  • GET /api/projects/{id} returns 200 with the project JSON when it belongs to the current tenant.
  • Returns 404 when 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
    }
}

P4 · Update Project

Purpose: Implement PATCH endpoint to update name and/or description of an existing project.

Input context:

  • ProjectRequest DTO from P1 (reuse; fields are optional for PATCH — relax @NotBlank or create ProjectUpdateRequest with nullable fields)
  • ProjectService.getById from P3
  • Only ADMIN may update

Instructions:

  1. Create record ProjectUpdateRequest(String name, String description) in the DTO package (no @NotBlank — both fields optional; null means "no change").
  2. In ProjectService, add ProjectResponse update(UUID id, ProjectUpdateRequest req): call getById(id) to get the entity (or re-query to get the managed entity), apply non-null fields, merge, return updated ProjectResponse.
  3. In ProjectResource, add @PATCH @Path("/{id}") method: require ADMIN role, delegate to projectService.update(id, req), return Response.ok(dto).build().
  4. Add entry to requests.http: PATCH {{baseUrl}}/api/projects/{{projectId}} with partial JSON body.

Acceptance criteria:

  • PATCH /api/projects/{id} with {"name":"New Name"} returns 200 and updated JSON.
  • Fields omitted from the body remain unchanged.
  • Returns 404 for unknown project or cross-tenant ID.
  • Returns 403 without 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);
    }
}

P5 · Delete Project

Purpose: Implement DELETE endpoint to permanently remove a project owned by the current tenant.

Input context:

  • ProjectRepository from P2
  • Only ADMIN may delete
  • Return 204 No Content on success

Instructions:

  1. In ProjectRepository, add boolean deleteByIdAndTenant(UUID id, UUID tenantId): use delete("id = ?1 and tenantId = ?2", id, tenantId), return count > 0.
  2. In ProjectService, add void delete(UUID id): call repository delete method; if returns false, throw NotFoundException.
  3. In ProjectResource, add @DELETE @Path("/{id}") method: require ADMIN role, call projectService.delete(id), return Response.noContent().build().
  4. Add entry to requests.http: DELETE {{baseUrl}}/api/projects/{{projectId}}.

Acceptance criteria:

  • DELETE /api/projects/{id} returns 204 and the project no longer appears in GET list.
  • Returns 404 for unknown or cross-tenant ID.
  • Returns 403 without 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);
    }
}

TASK CRUD — 6 Micro-Boxes


T1 · Create Task

Purpose: Implement POST endpoint to create a task within a project, scoped to tenant.

Input context:

  • Task entity at com.agency.api.task.model.Task with fields: id (UUID), tenantId (UUID), projectId (UUID), title (String), description (String), status (TaskStatus enum: TODO, IN_PROGRESS, DONE), priority (TaskPriority enum: LOW, MEDIUM, HIGH), assigneeId (UUID nullable), createdAt, updatedAt
  • TenantContext, AuthorizationService (same as Project)
  • ProjectRepository.findByIdAndTenant from P3 — validate project exists before creating task
  • Base path: /api/projects/{projectId}/tasks

Instructions:

  1. Create record TaskCreateRequest(String title, String description, TaskPriority priority, UUID assigneeId) in com.agency.api.task.dto. Add @NotBlank on title.
  2. Create record TaskResponse(UUID id, UUID projectId, String title, String description, TaskStatus status, TaskPriority priority, UUID assigneeId, Instant createdAt) with static of(Task t) factory.
  3. In TaskService (@ApplicationScoped), add TaskResponse create(UUID projectId, TaskCreateRequest req): verify project exists via ProjectRepository.findByIdAndTenant, build Task with status = TODO, persist, return TaskResponse.of(entity).
  4. In TaskResource (@Path("/api/projects/{projectId}/tasks")), add @POST method: require MEMBER or ADMIN role, delegate to taskService.create(projectId, req), return 201.
  5. Add requests.http entry: POST {{baseUrl}}/api/projects/{{projectId}}/tasks.

Acceptance criteria:

  • POST /api/projects/{projectId}/tasks returns 201 with status: "TODO".
  • Returns 404 if projectId does not exist or belongs to another tenant.
  • Returns 400 if title is 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);
    }
}

T2 · List Tasks

Purpose: Implement GET endpoint to list all tasks in a project with optional status filter.

Input context:

  • TaskResponse DTO from T1
  • TaskRepository (Panache) with tenant-scoped queries
  • Optional query param ?status=TODO|IN_PROGRESS|DONE

Instructions:

  1. In TaskRepository, add List<Task> findByProject(UUID projectId, UUID tenantId): return list("projectId = ?1 and tenantId = ?2 ORDER BY createdAt DESC", projectId, tenantId).
  2. Add List<Task> findByProjectAndStatus(UUID projectId, UUID tenantId, TaskStatus status): same query plus and status = ?3.
  3. In TaskService, add List<TaskResponse> list(UUID projectId, TaskStatus statusFilter): branch on null statusFilter, map to TaskResponse.
  4. In TaskResource, add @GET method with @QueryParam("status") TaskStatus status param: call taskService.list(projectId, status), return 200.
  5. Add requests.http entry: GET {{baseUrl}}/api/projects/{{projectId}}/tasks?status=TODO.

Acceptance criteria:

  • GET /api/projects/{projectId}/tasks returns all tasks for that project.
  • ?status=DONE filters 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));
    }
}

T3 · Get Task by ID

Purpose: Implement GET endpoint to retrieve a single task by ID within a project.

Input context:

  • TaskRepository from T2
  • TaskResponse DTO from T1
  • Must verify both projectId and tenantId match

Instructions:

  1. In TaskRepository, add Optional<Task> findByIdAndProject(UUID taskId, UUID projectId, UUID tenantId): return find("id = ?1 and projectId = ?2 and tenantId = ?3", taskId, projectId, tenantId).firstResultOptional().
  2. In TaskService, add TaskResponse getById(UUID projectId, UUID taskId): call repository, throw NotFoundException if empty.
  3. In TaskResource, add @GET @Path("/{taskId}") method: call taskService.getById(projectId, taskId), return 200.
  4. Add requests.http entry: GET {{baseUrl}}/api/projects/{{projectId}}/tasks/{{taskId}}.

Acceptance criteria:

  • Returns 200 with full task JSON when task belongs to the project and tenant.
  • Returns 404 if 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);
    }
}

T4 · Update Task

Purpose: Implement PATCH endpoint to update title, description, priority, or assignee of a task.

Input context:

  • TaskService.getById from T3
  • Managed entity must be re-fetched for mutation (not the DTO)
  • Do not allow status changes here — that belongs to T6

Instructions:

  1. Create record TaskUpdateRequest(String title, String description, TaskPriority priority, UUID assigneeId) — all fields nullable (null = no change).
  2. In TaskService, add TaskResponse update(UUID projectId, UUID taskId, TaskUpdateRequest req): re-fetch the managed Task entity, apply non-null fields (skip status), merge, return updated DTO.
  3. In TaskResource, add @PATCH @Path("/{taskId}") method: require MEMBER or ADMIN role, delegate to taskService.update(...), return 200.
  4. Add requests.http entry: PATCH {{baseUrl}}/api/projects/{{projectId}}/tasks/{{taskId}} with partial JSON.

Acceptance criteria:

  • PATCH with {"priority":"HIGH"} returns 200 and only priority changes.
  • status field in the request body is ignored (no effect).
  • Returns 404 for 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);
    }
}

T5 · Delete Task

Purpose: Implement DELETE endpoint to remove a task from a project.

Input context:

  • TaskRepository from T2
  • Only ADMIN or the task creator may delete (check assigneeId or use ADMIN-only for simplicity)
  • Return 204 No Content

Instructions:

  1. In TaskRepository, add boolean deleteByIdAndProject(UUID taskId, UUID projectId, UUID tenantId): return delete("id = ?1 and projectId = ?2 and tenantId = ?3", ...) > 0.
  2. In TaskService, add void delete(UUID projectId, UUID taskId): call repository; throw NotFoundException if false.
  3. In TaskResource, add @DELETE @Path("/{taskId}") method: require ADMIN role, call taskService.delete(projectId, taskId), return 204.
  4. Add requests.http entry: DELETE {{baseUrl}}/api/projects/{{projectId}}/tasks/{{taskId}}.

Acceptance criteria:

  • DELETE returns 204 and task no longer appears in list.
  • Returns 404 for unknown task.
  • Returns 403 without 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);
    }
}

T6 · Task Status Transition

Purpose: Implement PATCH endpoint to transition a task's status through its lifecycle.

Input context:

  • TaskStatus enum: 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:

  1. Create record TaskStatusRequest(TaskStatus status) with @NotNull on status.
  2. In TaskService, add TaskResponse transition(UUID projectId, UUID taskId, TaskStatus newStatus): fetch entity, validate transition (throw 422 if invalid using WebApplicationException(422)), set new status, merge, return DTO. Allowed: TODO→IN_PROGRESS, IN_PROGRESS→DONE.
  3. In TaskResource, add @PATCH @Path("/{taskId}/status") method: require MEMBER or ADMIN, delegate to taskService.transition(...), return 200.
  4. Add requests.http entries: PATCH .../tasks/{{taskId}}/status with body {"status":"IN_PROGRESS"} and {"status":"DONE"}.

Acceptance criteria:

  • PATCH .../status with {"status":"IN_PROGRESS"} on a TODO task returns 200 and updated status.
  • Attempting DONE→TODO returns 422.
  • Attempting TODO→DONE (skipping IN_PROGRESS) returns 422.
  • Returns 404 for 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);
    }
}

Execution order for Qwen 8B

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.


Shared test helpers (put in BaseIntegrationTest)

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