feat(backend): commandes web tickets/sprints + factorisation DTO (#86 lot 1)

Expose les commandes tickets/sprints sur le transport web (web-server/lib.rs),
avec factorisation des DTO tickets partagés entre Tauri et web dans un
nouveau module backend/src/ticket_dto.rs (app-tauri/src/tickets.rs
consomme désormais ce module commun).

Lot 1 du ticket #86, backend/web-server. Le lot 2 (frontend) suit sur une
nouvelle branche depuis develop à jour.

QA vert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-21 07:20:39 +02:00
parent 5d262231e2
commit 5a7431b442
7 changed files with 1998 additions and 1223 deletions

View File

@ -34,18 +34,31 @@ use tokio::task::JoinHandle;
use uuid::Uuid;
use application::{
AuthenticateSessionInput, CloseTerminalInput, GetProjectWorkStateInput, LaunchAgentInput,
ListDevicesInput, McpRuntime, OpenProjectInput, PairAttemptDecision, PairDeviceInput,
RateLimitKey, RenameDeviceInput, ResizeTerminalInput, RevokeDeviceInput,
RotateConversationLogInput, TouchDeviceInput, WriteToTerminalInput,
AssignIssueAgentInput, AssignTicketToSprintInput, AuthenticateSessionInput, CloseTerminalInput,
CreateSprintInput, DeleteIssueInput, DeleteSprintInput, GetProjectWorkStateInput,
LaunchAgentInput, LinkIssuesInput, ListDevicesInput, ListIssuesInput, ListSprintsInput,
McpRuntime, OpenProjectInput, PairAttemptDecision, PairDeviceInput, RateLimitKey,
ReadIssueCarnetInput, ReadIssueInput, RenameDeviceInput, RenameSprintInput,
ReorderSprintsInput, ResizeTerminalInput, RevokeDeviceInput, RotateConversationLogInput,
TouchDeviceInput, UnassignTicketFromSprintInput, UnlinkIssuesInput, UpdateIssueCarnetInput,
WriteToTerminalInput,
};
use domain::ports::PtyHandle;
use domain::IssueActor;
use domain::{AuthenticatedDevice, DeviceId, DeviceName, Project, SessionId, SessionTokenHash};
use backend::dto::{
parse_agent_id, parse_node_id, parse_project_id, parse_session_id, parse_task_id,
BackgroundTaskDto, ErrorDto, HealthRequestDto, HealthResponseDto, LaunchAgentRequestDto,
OpenTerminalRequestDto, ProjectDto, ProjectListDto, ProjectWorkStateDto, TerminalSessionDto,
create_input, paginate, parse_agent_id, parse_agent_id_dto, parse_link_kind_dto, parse_node_id,
parse_project_id, parse_ref_dto, parse_session_id, parse_sprint_id_dto,
parse_sprint_status_dto, parse_task_id, sort_ticket_rows, sprint_version_dto, update_input,
version_dto, BackgroundTaskDto, ErrorDto, HealthRequestDto, HealthResponseDto,
LaunchAgentRequestDto, OpenTerminalRequestDto, ProjectDto, ProjectListDto, ProjectWorkStateDto,
SprintCreateRequestDto, SprintDeleteRequestDto, SprintDto, SprintListDto, SprintListRequestDto,
SprintRenameRequestDto, SprintReorderRequestDto, TerminalSessionDto, TicketAssignRequestDto,
TicketCarnetDto, TicketCreateRequestDto, TicketDeleteRequestDto, TicketDto,
TicketLinkCommandRequestDto, TicketListPageInput, TicketListRequestDto, TicketReadRequestDto,
TicketSprintAssignRequestDto, TicketSprintUnassignRequestDto, TicketUnlinkCommandRequestDto,
TicketUpdateCarnetRequestDto, TicketUpdateRequestDto,
};
use backend::events::DomainEventDto;
type PtyChunk = Vec<u8>;
@ -2296,6 +2309,23 @@ async fn invoke(
"list_background_tasks" => invoke_list_background_tasks(&request.args, &state.app).await,
"cancel_background_task" => invoke_cancel_background_task(&request.args, &state.app).await,
"retry_background_task" => invoke_retry_background_task(&request.args, &state.app).await,
"ticket_create" => invoke_ticket_create(&request.args, &state.app).await,
"ticket_read" => invoke_ticket_read(&request.args, &state.app).await,
"ticket_list" => invoke_ticket_list(&request.args, &state.app).await,
"ticket_update" => invoke_ticket_update(&request.args, &state.app).await,
"ticket_delete" => invoke_ticket_delete(&request.args, &state.app).await,
"ticket_read_carnet" => invoke_ticket_read_carnet(&request.args, &state.app).await,
"ticket_update_carnet" => invoke_ticket_update_carnet(&request.args, &state.app).await,
"ticket_link" => invoke_ticket_link(&request.args, &state.app).await,
"ticket_unlink" => invoke_ticket_unlink(&request.args, &state.app).await,
"ticket_assign" => invoke_ticket_assign(&request.args, &state.app).await,
"ticket_assign_sprint" => invoke_ticket_assign_sprint(&request.args, &state.app).await,
"ticket_unassign_sprint" => invoke_ticket_unassign_sprint(&request.args, &state.app).await,
"sprint_create" => invoke_sprint_create(&request.args, &state.app).await,
"sprint_list" => invoke_sprint_list(&request.args, &state.app).await,
"sprint_rename" => invoke_sprint_rename(&request.args, &state.app).await,
"sprint_reorder" => invoke_sprint_reorder(&request.args, &state.app).await,
"sprint_delete" => invoke_sprint_delete(&request.args, &state.app).await,
_ => Err(ErrorDto {
code: "UNKNOWN_COMMAND".to_owned(),
message: format!("unknown command: {}", request.command),
@ -2304,7 +2334,10 @@ async fn invoke(
match result {
Ok(value) => json_response(StatusCode::OK, &value, origin),
Err(error) => error_dto_response(status_for_error(&error), error, origin),
Err(error) => {
let error = normalize_invoke_error(error);
error_dto_response(status_for_error(&error), error, origin)
}
}
}
@ -2440,6 +2473,323 @@ async fn invoke_retry_background_task(
serde_json::to_value(BackgroundTaskDto::from(output.task)).map_err(serialization_error)
}
async fn invoke_ticket_create(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
let request = required_request::<TicketCreateRequestDto>("ticket_create", args)?;
let project = resolve_project_readonly(&request.project_id, state).await?;
let issue = state
.create_issue
.execute(create_input(project, request, IssueActor::User)?)
.await
.map_err(ErrorDto::from)?
.issue;
serde_json::to_value(TicketDto::from_issue(issue, None)).map_err(serialization_error)
}
async fn invoke_ticket_read(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
let request = required_request::<TicketReadRequestDto>("ticket_read", args)?;
let project = resolve_project_readonly(&request.project_id, state).await?;
let issue_ref = parse_ref_dto(&request.r#ref)?;
let issue = state
.read_issue
.execute(ReadIssueInput {
project: project.clone(),
issue_ref,
})
.await
.map_err(ErrorDto::from)?
.issue;
let carnet = if request.include_carnet.unwrap_or(false) {
Some(
state
.read_issue_carnet
.execute(ReadIssueCarnetInput { project, issue_ref })
.await
.map_err(ErrorDto::from)?
.carnet
.carnet
.as_str()
.to_owned(),
)
} else {
None
};
serde_json::to_value(TicketDto::from_issue(issue, carnet)).map_err(serialization_error)
}
async fn invoke_ticket_list(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
let request = required_request::<TicketListRequestDto>("ticket_list", args)?;
let project = resolve_project_readonly(&request.project_id, state).await?;
let page = TicketListPageInput::from_request(request)?;
let mut rows = state
.list_issues
.execute(ListIssuesInput {
project,
filter: page.filter,
})
.await
.map_err(ErrorDto::from)?
.issues;
sort_ticket_rows(&mut rows, page.sort);
let dto = paginate(rows, page.limit, page.cursor, page.sort)?;
serde_json::to_value(dto).map_err(serialization_error)
}
async fn invoke_ticket_update(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
let request = required_request::<TicketUpdateRequestDto>("ticket_update", args)?;
let project = resolve_project_readonly(&request.project_id, state).await?;
let issue = state
.update_issue
.execute(update_input(project, request, IssueActor::User)?)
.await
.map_err(ErrorDto::from)?
.issue;
serde_json::to_value(TicketDto::from_issue(issue, None)).map_err(serialization_error)
}
async fn invoke_ticket_delete(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
let request = required_request::<TicketDeleteRequestDto>("ticket_delete", args)?;
let project = resolve_project_readonly(&request.project_id, state).await?;
state
.delete_issue
.execute(DeleteIssueInput {
project,
issue_ref: parse_ref_dto(&request.r#ref)?,
})
.await
.map_err(ErrorDto::from)?;
Ok(Value::Null)
}
async fn invoke_ticket_read_carnet(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
let request = required_request::<TicketReadRequestDto>("ticket_read_carnet", args)?;
let project = resolve_project_readonly(&request.project_id, state).await?;
let carnet = state
.read_issue_carnet
.execute(ReadIssueCarnetInput {
project,
issue_ref: parse_ref_dto(&request.r#ref)?,
})
.await
.map_err(ErrorDto::from)?
.carnet;
serde_json::to_value(TicketCarnetDto::from(carnet)).map_err(serialization_error)
}
async fn invoke_ticket_update_carnet(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
let request = required_request::<TicketUpdateCarnetRequestDto>("ticket_update_carnet", args)?;
let project = resolve_project_readonly(&request.project_id, state).await?;
let issue_ref = parse_ref_dto(&request.r#ref)?;
state
.update_issue_carnet
.execute(UpdateIssueCarnetInput {
project: project.clone(),
issue_ref,
expected_version: version_dto(request.expected_version)?,
carnet: request.carnet,
actor: IssueActor::User,
})
.await
.map_err(ErrorDto::from)?;
let issue = state
.read_issue
.execute(ReadIssueInput { project, issue_ref })
.await
.map_err(ErrorDto::from)?
.issue;
serde_json::to_value(TicketDto::from_issue(issue, None)).map_err(serialization_error)
}
async fn invoke_ticket_link(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
let request = required_request::<TicketLinkCommandRequestDto>("ticket_link", args)?;
let project = resolve_project_readonly(&request.project_id, state).await?;
let issue = state
.link_issues
.execute(LinkIssuesInput {
project,
issue_ref: parse_ref_dto(&request.r#ref)?,
target: parse_ref_dto(&request.target_ref)?,
kind: parse_link_kind_dto(&request.kind)?,
expected_version: version_dto(request.expected_version)?,
actor: IssueActor::User,
})
.await
.map_err(ErrorDto::from)?
.issue;
serde_json::to_value(TicketDto::from_issue(issue, None)).map_err(serialization_error)
}
async fn invoke_ticket_unlink(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
let request = required_request::<TicketUnlinkCommandRequestDto>("ticket_unlink", args)?;
let project = resolve_project_readonly(&request.project_id, state).await?;
let issue = state
.unlink_issues
.execute(UnlinkIssuesInput {
project,
issue_ref: parse_ref_dto(&request.r#ref)?,
target: parse_ref_dto(&request.target_ref)?,
kind: request
.kind
.as_deref()
.map(parse_link_kind_dto)
.transpose()?,
expected_version: version_dto(request.expected_version)?,
actor: IssueActor::User,
})
.await
.map_err(ErrorDto::from)?
.issue;
serde_json::to_value(TicketDto::from_issue(issue, None)).map_err(serialization_error)
}
async fn invoke_ticket_assign(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
let request = required_request::<TicketAssignRequestDto>("ticket_assign", args)?;
let project = resolve_project_readonly(&request.project_id, state).await?;
let issue = state
.assign_issue_agent
.execute(AssignIssueAgentInput {
project,
issue_ref: parse_ref_dto(&request.r#ref)?,
agent_id: parse_agent_id_dto(&request.agent_id)?,
assigned: request.assigned,
expected_version: version_dto(request.expected_version)?,
actor: IssueActor::User,
})
.await
.map_err(ErrorDto::from)?
.issue;
serde_json::to_value(TicketDto::from_issue(issue, None)).map_err(serialization_error)
}
async fn invoke_ticket_assign_sprint(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
let request = required_request::<TicketSprintAssignRequestDto>("ticket_assign_sprint", args)?;
let project = resolve_project_readonly(&request.project_id, state).await?;
let issue = state
.assign_ticket_to_sprint
.execute(AssignTicketToSprintInput {
project,
issue_ref: parse_ref_dto(&request.r#ref)?,
sprint_id: parse_sprint_id_dto(&request.sprint_id)?,
expected_version: version_dto(request.expected_version)?,
actor: IssueActor::User,
})
.await
.map_err(ErrorDto::from)?
.issue;
serde_json::to_value(TicketDto::from_issue(issue, None)).map_err(serialization_error)
}
async fn invoke_ticket_unassign_sprint(
args: &Value,
state: &BackendCore,
) -> Result<Value, ErrorDto> {
let request =
required_request::<TicketSprintUnassignRequestDto>("ticket_unassign_sprint", args)?;
let project = resolve_project_readonly(&request.project_id, state).await?;
let issue = state
.unassign_ticket_from_sprint
.execute(UnassignTicketFromSprintInput {
project,
issue_ref: parse_ref_dto(&request.r#ref)?,
expected_version: version_dto(request.expected_version)?,
actor: IssueActor::User,
})
.await
.map_err(ErrorDto::from)?
.issue;
serde_json::to_value(TicketDto::from_issue(issue, None)).map_err(serialization_error)
}
async fn invoke_sprint_create(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
let request = required_request::<SprintCreateRequestDto>("sprint_create", args)?;
let project = resolve_project_readonly(&request.project_id, state).await?;
let sprint = state
.create_sprint
.execute(CreateSprintInput {
project,
name: request.name,
status: request
.status
.as_deref()
.map(parse_sprint_status_dto)
.transpose()?,
actor: IssueActor::User,
})
.await
.map_err(ErrorDto::from)?
.sprint;
serde_json::to_value(SprintDto::from_sprint(sprint, 0)).map_err(serialization_error)
}
async fn invoke_sprint_list(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
let request = required_request::<SprintListRequestDto>("sprint_list", args)?;
let project = resolve_project_readonly(&request.project_id, state).await?;
let rows = state
.list_sprints
.execute(ListSprintsInput { project })
.await
.map_err(ErrorDto::from)?
.sprints;
serde_json::to_value(SprintListDto::from(rows)).map_err(serialization_error)
}
async fn invoke_sprint_rename(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
let request = required_request::<SprintRenameRequestDto>("sprint_rename", args)?;
let project = resolve_project_readonly(&request.project_id, state).await?;
let sprint = state
.rename_sprint
.execute(RenameSprintInput {
project,
sprint_id: parse_sprint_id_dto(&request.sprint_id)?,
expected_version: sprint_version_dto(request.expected_version)?,
name: request.name,
actor: IssueActor::User,
})
.await
.map_err(ErrorDto::from)?
.sprint;
serde_json::to_value(SprintDto::from_sprint(sprint, 0)).map_err(serialization_error)
}
async fn invoke_sprint_reorder(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
let request = required_request::<SprintReorderRequestDto>("sprint_reorder", args)?;
let project = resolve_project_readonly(&request.project_id, state).await?;
state
.reorder_sprints
.execute(ReorderSprintsInput {
project: project.clone(),
ordered_ids: request
.ordered_ids
.iter()
.map(|id| parse_sprint_id_dto(id))
.collect::<Result<Vec<_>, _>>()?,
actor: IssueActor::User,
})
.await
.map_err(ErrorDto::from)?;
let rows = state
.list_sprints
.execute(ListSprintsInput { project })
.await
.map_err(ErrorDto::from)?
.sprints;
serde_json::to_value(SprintListDto::from(rows)).map_err(serialization_error)
}
async fn invoke_sprint_delete(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
let request = required_request::<SprintDeleteRequestDto>("sprint_delete", args)?;
let project = resolve_project_readonly(&request.project_id, state).await?;
state
.delete_sprint
.execute(DeleteSprintInput {
project,
sprint_id: parse_sprint_id_dto(&request.sprint_id)?,
actor: IssueActor::User,
})
.await
.map_err(ErrorDto::from)?;
Ok(Value::Null)
}
fn task_id_arg(args: &Value, command: &str) -> Result<domain::TaskId, ErrorDto> {
args.get("taskId")
.and_then(Value::as_str)
@ -2492,6 +2842,16 @@ fn background_error(err: BackgroundTaskPortError) -> ErrorDto {
}
}
fn normalize_invoke_error(error: ErrorDto) -> ErrorDto {
if error.code == "INVALID" && error.message.contains("version conflict") {
return ErrorDto {
code: "CONFLICT".to_owned(),
message: error.message,
};
}
error
}
async fn resolve_project_readonly(
project_id: &str,
state: &BackendCore,
@ -2527,6 +2887,16 @@ where
}
}
fn required_request<T>(command: &str, args: &Value) -> Result<T, ErrorDto>
where
T: for<'de> Deserialize<'de>,
{
optional_request(args)?.ok_or_else(|| ErrorDto {
code: "INVALID".to_owned(),
message: format!("{command} requires args.request"),
})
}
fn validate_request_origin(
headers: &HeaderMap,
config: &ServerConfig,
@ -2792,6 +3162,7 @@ fn status_for_error(error: &ErrorDto) -> StatusCode {
"UNKNOWN_COMMAND" => StatusCode::BAD_REQUEST,
"INVALID" => StatusCode::BAD_REQUEST,
"NOT_FOUND" => StatusCode::NOT_FOUND,
"CONFLICT" => StatusCode::CONFLICT,
"FORBIDDEN" => StatusCode::FORBIDDEN,
"UNAUTHORIZED" => StatusCode::UNAUTHORIZED,
_ => StatusCode::INTERNAL_SERVER_ERROR,
@ -3766,6 +4137,48 @@ mod tests {
output.project.id.to_string()
}
async fn create_agent_for_project_for_test(
state: &Arc<ServerState>,
project_id: &str,
name: &str,
) -> String {
let project = resolve_project_readonly(project_id, &state.app)
.await
.expect("test project resolves");
let profile_id = ProfileId::from_uuid(Uuid::new_v4());
let profile = AgentProfile::new(
profile_id,
format!("{name} profile"),
"/bin/sh",
vec!["-c".to_owned(), "printf ready".to_owned()],
ContextInjection::env("IDEA_CONTEXT").expect("valid env injection"),
None,
"{agentRunDir}",
None,
)
.expect("valid profile");
state
.app
.save_profile
.execute(SaveProfileInput { profile })
.await
.expect("test profile saved");
state
.app
.create_agent
.execute(CreateAgentInput {
project,
name: format!("{name} agent"),
profile_id,
initial_content: Some("Test agent context".to_owned()),
})
.await
.expect("test agent created")
.agent
.id
.to_string()
}
async fn create_background_task_for_test(
state: &Arc<ServerState>,
project_id: &str,
@ -3795,6 +4208,24 @@ mod tests {
(task_id, owner)
}
async fn invoke_request_for_test(
state: Arc<ServerState>,
cookie: &str,
command: &str,
args: Value,
) -> (StatusCode, Value) {
let response = request(
state,
Method::POST,
"/api/invoke",
json!({ "command": command, "args": args }),
&[("cookie", cookie)],
)
.await;
let (status, body, _) = response_json(response).await;
(status, body)
}
async fn create_raw_cli_agent_for_test(
state: &Arc<ServerState>,
name: &str,
@ -5890,6 +6321,343 @@ mod tests {
assert_eq!(tasks[0]["state"], "queued");
}
#[tokio::test]
async fn ticket_and_sprint_commands_require_authenticated_invoke() {
let state = state();
for command in [
"ticket_list",
"ticket_create",
"sprint_list",
"sprint_create",
] {
let response = request(
Arc::clone(&state),
Method::POST,
"/api/invoke",
json!({ "command": command, "args": {} }),
&[],
)
.await;
let (status, body, _) = response_json(response).await;
assert_eq!(status, StatusCode::UNAUTHORIZED);
assert_eq!(body["code"], "UNAUTHORIZED");
}
}
#[tokio::test]
async fn authorized_ticket_invoke_routes_ticket_carnet_link_and_assign_commands() {
let state = state();
let project_id = create_project_for_test(&state, "Web Tickets").await;
let agent_id = create_agent_for_project_for_test(&state, &project_id, "Ticket owner").await;
let cookie = pair_and_cookie(Arc::clone(&state)).await;
let (status, created) = invoke_request_for_test(
Arc::clone(&state),
&cookie,
"ticket_create",
json!({
"request": {
"projectId": project_id,
"title": "Wire ticket",
"description": "Created over /api/invoke",
"priority": "high",
"status": "open"
}
}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(created["title"], "Wire ticket");
assert_eq!(created["priority"], "high");
let ticket_ref = created["ref"].as_str().unwrap().to_owned();
let mut ticket_version = created["version"].as_u64().unwrap();
let (status, read) = invoke_request_for_test(
Arc::clone(&state),
&cookie,
"ticket_read",
json!({ "request": { "projectId": project_id, "ref": ticket_ref, "includeCarnet": true } }),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(read["ref"], ticket_ref);
assert!(read["carnet"].is_string());
let (status, list) = invoke_request_for_test(
Arc::clone(&state),
&cookie,
"ticket_list",
json!({ "request": { "projectId": project_id, "statuses": ["open"] } }),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(list["items"].as_array().unwrap().len(), 1);
assert_eq!(list["items"][0]["ref"], ticket_ref);
let (status, updated) = invoke_request_for_test(
Arc::clone(&state),
&cookie,
"ticket_update",
json!({
"request": {
"projectId": project_id,
"ref": ticket_ref,
"title": "Wire ticket updated",
"status": "inProgress",
"expectedVersion": ticket_version
}
}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(updated["title"], "Wire ticket updated");
assert_eq!(updated["status"], "inProgress");
let stale_version = ticket_version;
let (status, conflict) = invoke_request_for_test(
Arc::clone(&state),
&cookie,
"ticket_update",
json!({
"request": {
"projectId": project_id,
"ref": ticket_ref,
"title": "Stale update",
"expectedVersion": stale_version
}
}),
)
.await;
assert_eq!(status, StatusCode::CONFLICT);
assert_eq!(conflict["code"], "CONFLICT");
let (status, carnet) = invoke_request_for_test(
Arc::clone(&state),
&cookie,
"ticket_read_carnet",
json!({ "request": { "projectId": project_id, "ref": ticket_ref } }),
)
.await;
assert_eq!(status, StatusCode::OK);
let carnet_version = carnet["version"].as_u64().unwrap();
let (status, after_carnet_update) = invoke_request_for_test(
Arc::clone(&state),
&cookie,
"ticket_update_carnet",
json!({
"request": {
"projectId": project_id,
"ref": ticket_ref,
"carnet": "## QA\nCarnet updated through web transport.",
"expectedVersion": carnet_version
}
}),
)
.await;
assert_eq!(status, StatusCode::OK);
ticket_version = after_carnet_update["version"].as_u64().unwrap();
let (status, target) = invoke_request_for_test(
Arc::clone(&state),
&cookie,
"ticket_create",
json!({ "request": { "projectId": project_id, "title": "Linked target" } }),
)
.await;
assert_eq!(status, StatusCode::OK, "target create failed: {target}");
let target_ref = target["ref"].as_str().unwrap().to_owned();
let (status, linked) = invoke_request_for_test(
Arc::clone(&state),
&cookie,
"ticket_link",
json!({
"request": {
"projectId": project_id,
"ref": ticket_ref,
"targetRef": target_ref,
"kind": "blocks",
"expectedVersion": ticket_version
}
}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(linked["links"].as_array().unwrap().len(), 1);
ticket_version = linked["version"].as_u64().unwrap();
let (status, unlinked) = invoke_request_for_test(
Arc::clone(&state),
&cookie,
"ticket_unlink",
json!({
"request": {
"projectId": project_id,
"ref": ticket_ref,
"targetRef": target_ref,
"kind": "blocks",
"expectedVersion": ticket_version
}
}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert!(unlinked["links"].as_array().unwrap().is_empty());
ticket_version = unlinked["version"].as_u64().unwrap();
let (status, assigned) = invoke_request_for_test(
Arc::clone(&state),
&cookie,
"ticket_assign",
json!({
"request": {
"projectId": project_id,
"ref": ticket_ref,
"agentId": agent_id,
"assigned": true,
"expectedVersion": ticket_version
}
}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(assigned["assignedAgentIds"][0], agent_id);
let (status, deleted) = invoke_request_for_test(
Arc::clone(&state),
&cookie,
"ticket_delete",
json!({ "request": { "projectId": project_id, "ref": target_ref } }),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(deleted, Value::Null);
}
#[tokio::test]
async fn authorized_sprint_invoke_routes_sprint_and_ticket_sprint_commands() {
let state = state();
let project_id = create_project_for_test(&state, "Web Sprints").await;
let cookie = pair_and_cookie(Arc::clone(&state)).await;
let (status, ticket) = invoke_request_for_test(
Arc::clone(&state),
&cookie,
"ticket_create",
json!({ "request": { "projectId": project_id, "title": "Sprint ticket" } }),
)
.await;
assert_eq!(status, StatusCode::OK);
let ticket_ref = ticket["ref"].as_str().unwrap().to_owned();
let mut ticket_version = ticket["version"].as_u64().unwrap();
let (status, sprint_a) = invoke_request_for_test(
Arc::clone(&state),
&cookie,
"sprint_create",
json!({ "request": { "projectId": project_id, "name": "Alpha", "status": "planned" } }),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(sprint_a["name"], "Alpha");
let sprint_a_id = sprint_a["id"].as_str().unwrap().to_owned();
let sprint_a_version = sprint_a["version"].as_u64().unwrap();
let (status, sprint_b) = invoke_request_for_test(
Arc::clone(&state),
&cookie,
"sprint_create",
json!({ "request": { "projectId": project_id, "name": "Beta" } }),
)
.await;
assert_eq!(status, StatusCode::OK);
let sprint_b_id = sprint_b["id"].as_str().unwrap().to_owned();
let (status, list) = invoke_request_for_test(
Arc::clone(&state),
&cookie,
"sprint_list",
json!({ "request": { "projectId": project_id } }),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(list["items"].as_array().unwrap().len(), 2);
let (status, renamed) = invoke_request_for_test(
Arc::clone(&state),
&cookie,
"sprint_rename",
json!({
"request": {
"projectId": project_id,
"sprintId": sprint_a_id,
"name": "Alpha renamed",
"expectedVersion": sprint_a_version
}
}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(renamed["name"], "Alpha renamed");
let (status, reordered) = invoke_request_for_test(
Arc::clone(&state),
&cookie,
"sprint_reorder",
json!({ "request": { "projectId": project_id, "orderedIds": [sprint_b_id, sprint_a_id] } }),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(reordered["items"][0]["id"], sprint_b_id);
let (status, assigned) = invoke_request_for_test(
Arc::clone(&state),
&cookie,
"ticket_assign_sprint",
json!({
"request": {
"projectId": project_id,
"ref": ticket_ref,
"sprintId": sprint_a_id,
"expectedVersion": ticket_version
}
}),
)
.await;
assert_eq!(status, StatusCode::OK, "sprint assign failed: {assigned}");
assert_eq!(assigned["sprintId"], sprint_a_id);
ticket_version = assigned["version"].as_u64().unwrap();
let (status, unassigned) = invoke_request_for_test(
Arc::clone(&state),
&cookie,
"ticket_unassign_sprint",
json!({
"request": {
"projectId": project_id,
"ref": ticket_ref,
"expectedVersion": ticket_version
}
}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert!(unassigned["sprintId"].is_null());
let (status, deleted) = invoke_request_for_test(
Arc::clone(&state),
&cookie,
"sprint_delete",
json!({ "request": { "projectId": project_id, "sprintId": sprint_b_id } }),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(deleted, Value::Null);
}
#[tokio::test]
async fn background_actions_are_allowlisted_with_auth_gate() {
let state = state();