修复 External 去背景重放与画布竞态
Project CI / Repository checks (pull_request) Successful in 3m36s
Project CI / Frontend tests (pull_request) Successful in 3m59s
Project CI / Backend tests (pull_request) Successful in 5m27s
Project CI / Native shell tests (pull_request) Successful in 14m56s

按原始请求指纹在可变预检前返回幂等任务
修正来源身份优先级与跨项目歧义校验
冻结并复验原位替换目标的完整媒体语义
修复 Python helper 原位替换请求并补正向路由测试
This commit is contained in:
2026-08-24 14:11:34 +08:00
parent 015591594e
commit d39a211154
7 changed files with 921 additions and 159 deletions
@@ -119,7 +119,7 @@ client.remove_background(
)
```
Background removal preserves the source pixel size. When `canvasSession` is used, pass the real `source_width` and `source_height`, or provide both `canvasWidth` and `canvasHeight`; the helper rejects missing dimensions instead of guessing a square placeholder. `assetKind` may only describe a static image and must match the authoritative source record. Prefer a project resource ID or asset ID when the same object key has multiple semantic registrations; for a raw object key outside in-place replacement, pass `sourceResourceId` to disambiguate. Without `canvasCompletion`, `targetLayerId` may replace only the layer that points to the same authoritative object as the source, and the server durably binds a raw object key to that target resource for Worker revalidation.
Background removal preserves the source pixel size. For normal canvas placement with `canvasSession`, pass the real `source_width` and `source_height`, or provide both `canvasWidth` and `canvasHeight`; the helper rejects missing dimensions instead of guessing a square placeholder. `assetKind` may only describe a static image and must match the authoritative source record. Prefer a project resource ID or asset ID when the same object key has multiple semantic registrations; for a raw object key outside in-place replacement, pass `sourceResourceId` to disambiguate. Passing `targetLayerId` selects in-place replacement: the helper retains the session's project/library context but does not inject `canvasCompletion`, and it rejects an explicit `canvasCompletion` combined with `targetLayerId`. The target layer must point to the same authoritative object as the source, and the server durably binds a raw object key to that target resource for Worker revalidation.
Helper convenience methods wait locally, but the server still uses short asynchronous submit/status requests. For durable caller-controlled orchestration, call `submit_generation`, persist its `operationId` and idempotency key, then call `get_generation` or `wait_for_generation`.
@@ -100,7 +100,7 @@ A minimal `canvasCompletion` is:
`dialogId` is optional. The placeholder supplies canvas placement and completion coordinates; it is not a final media pixel-size constraint. For successful pixel-art snapping, the result layer uses the final logical-grid PNG dimensions even when they differ from the placeholder. Do not reconstruct canvas state from completion results. Reload the project and asset library when complete authoritative snapshots are needed.
Background removal preserves the source image dimensions. The Python helper therefore requires the real `source_width` and `source_height` whenever `canvasSession` is used without an explicit `canvasWidth` plus `canvasHeight`; it never substitutes a square default. The request `assetKind` is optional, static-image only, and must equal the authoritative source type when one exists. Without `canvasCompletion`, an in-place `targetLayerId` must resolve to the same authoritative source object; a raw object key is bound to that target resource instead of relying on project-list order.
Background removal preserves the source image dimensions. For normal canvas placement, the Python helper therefore requires the real `source_width` and `source_height` whenever `canvasSession` is used without an explicit `canvasWidth` plus `canvasHeight`; it never substitutes a square default. Passing `targetLayerId` instead selects in-place replacement, so the helper keeps the session's project/library fields without injecting `canvasCompletion` and rejects callers that explicitly combine both placement modes. The request `assetKind` is optional, static-image only, and must equal the authoritative source type when one exists. An in-place target must resolve to the same authoritative source object; a raw object key is bound to that target resource instead of relying on project-list order.
Character animation accepts `assetFolderId` and `assetLabel` and persists the final transparent sequence directly. Its completed compact result includes the authoritative `assetKind="character-animation"` resource and asset with `imageSequenceFrames` and `imageSequenceDurationMs`. Use those records directly and never synthesize a duplicate asset from the first frame.
@@ -582,11 +582,16 @@ class GenarrativeExternalClient:
session = fields.get("canvasSession")
if session is None:
session = fields.get("canvas_session")
target_layer_id = normalize_optional_text(fields.get("targetLayerId"))
if target_layer_id and fields.get("canvasCompletion") is not None:
raise GenarrativeApiError(
"targetLayerId and canvasCompletion are mutually exclusive for background removal"
)
canvas_width = fields.get("canvasWidth")
canvas_height = fields.get("canvasHeight")
if (canvas_width is None) != (canvas_height is None):
raise GenarrativeApiError("canvasWidth and canvasHeight must be provided together")
if session is not None and canvas_width is None:
if session is not None and canvas_width is None and not target_layer_id:
if source_width is None or source_height is None:
raise GenarrativeApiError(
"remove_background requires source_width and source_height when canvasSession is used without canvasWidth/canvasHeight"
@@ -599,6 +604,8 @@ class GenarrativeExternalClient:
source_width or 1,
source_height or 1,
)
if target_layer_id:
fields.pop("canvasCompletion", None)
idempotency_key = fields.pop("idempotencyKey", None)
return self.submit_and_wait_generation(
"/api/external/v1/editor/images/background-removals",
@@ -838,6 +845,30 @@ def _self_test() -> None:
assert calls[0]["body"]["canvasCompletion"]["placeholder"]["width"] == 720
assert calls[0]["body"]["canvasCompletion"]["placeholder"]["height"] == 1280
calls.clear()
client.remove_background(
"uploads/source.png",
canvasSession=session,
targetLayerId="layer-1",
assetLabel="原位去背景结果",
)
assert calls[0]["body"]["projectId"] == "proj-demo"
assert calls[0]["body"]["assetFolderId"] == "editor-asset-folder-demo"
assert calls[0]["body"]["assetLabel"] == "原位去背景结果"
assert calls[0]["body"]["targetLayerId"] == "layer-1"
assert "canvasCompletion" not in calls[0]["body"]
calls.clear()
try:
client.remove_background(
"uploads/source.png",
canvasSession=session,
targetLayerId="layer-1",
canvasCompletion={"title": "冲突完成指令"},
)
except GenarrativeApiError as error:
assert "targetLayerId and canvasCompletion are mutually exclusive" in str(error)
else:
raise AssertionError("background removal must reject conflicting canvas placement modes")
assert calls == []
try:
client.remove_background("uploads/source.png", canvasSession=session)
except GenarrativeApiError as error:
@@ -6,7 +6,10 @@ use shared_contracts::external_generation::{
ExternalGenerationJobStatus, ExternalGenerationJobStatusRecord,
};
use shared_kernel::{build_prefixed_uuid_id, offset_datetime_to_unix_micros};
use spacetime_client::{ExternalGenerationJobEnqueueRecordInput, ExternalGenerationJobRecord};
use spacetime_client::{
ExternalGenerationJobEnqueueRecordInput, ExternalGenerationJobGetRecordInput,
ExternalGenerationJobRecord, SpacetimeClientError,
};
use crate::{http_error::AppError, request_context::RequestContext, state::AppState};
@@ -30,6 +33,7 @@ const EDITOR_GENERATION_QUEUE_PROVIDER: &str = "editor-generation-worker";
const MAX_EDITOR_GENERATION_JOB_PAYLOAD_BYTES: usize = 512 * 1024;
const EXTERNAL_API_GENERATION_DEDUPE_PREFIX: &str = "external-api-generation";
const EDITOR_API_REQUEST_GENERATION_DEDUPE_PREFIX: &str = "editor-api-request-generation";
const EXTERNAL_API_REQUEST_FINGERPRINT_FIELD: &str = "_externalApiRequestFingerprint";
pub(crate) const GAME_CREATOR_CLIENT_GENERATION_DEDUPE_PREFIX: &str =
"game-creator-client-generation";
pub(crate) const GAME_CREATOR_CLIENT_GENERATION_SOURCE: &str = "ai-game-creator-client";
@@ -89,6 +93,186 @@ fn build_editor_generation_dedupe_key(
format!("{namespace}:{job_kind}:{:x}", hasher.finalize())
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct ExternalApiEditorGenerationRequestIdentity {
job_id: String,
dedupe_key: String,
request_fingerprint: String,
}
pub(crate) fn external_api_editor_generation_request_identity<T>(
owner_user_id: &str,
job_kind: &str,
payload: &T,
idempotency_key: &str,
) -> Result<ExternalApiEditorGenerationRequestIdentity, AppError>
where
T: Serialize,
{
let payload = serde_json::to_value(payload).map_err(payload_serialization_error)?;
let canonical_payload = canonicalize_external_api_request_value(payload);
let canonical_payload =
serde_json::to_vec(&canonical_payload).map_err(payload_serialization_error)?;
let mut request_hasher = Sha256::new();
request_hasher.update(b"genarrative-external-api-request-v1\0");
request_hasher.update(canonical_payload);
let request_fingerprint = format!("{:x}", request_hasher.finalize());
let dedupe_key = build_editor_generation_dedupe_key(
EXTERNAL_API_GENERATION_DEDUPE_PREFIX,
owner_user_id,
job_kind,
idempotency_key,
);
let mut job_hasher = Sha256::new();
job_hasher.update(b"genarrative-external-api-operation-v1\0");
job_hasher.update(dedupe_key.as_bytes());
let job_digest = format!("{:x}", job_hasher.finalize());
Ok(ExternalApiEditorGenerationRequestIdentity {
job_id: format!("task-{}", &job_digest[..32]),
dedupe_key,
request_fingerprint,
})
}
fn canonicalize_external_api_request_value(value: Value) -> Value {
match value {
Value::Array(values) => Value::Array(
values
.into_iter()
.map(canonicalize_external_api_request_value)
.collect(),
),
Value::Object(values) => {
let mut entries = values.into_iter().collect::<Vec<_>>();
entries.sort_by(|left, right| left.0.cmp(&right.0));
Value::Object(
entries
.into_iter()
.map(|(key, value)| (key, canonicalize_external_api_request_value(value)))
.collect(),
)
}
other => other,
}
}
pub(crate) async fn find_external_api_editor_generation_replay(
state: &AppState,
owner_user_id: &str,
job_kind: &str,
identity: &ExternalApiEditorGenerationRequestIdentity,
) -> Result<Option<ExternalGenerationJobRecord>, AppError> {
let job = match state
.spacetime_client()
.get_external_generation_job(ExternalGenerationJobGetRecordInput {
job_id: identity.job_id.clone(),
owner_user_id: owner_user_id.to_string(),
})
.await
{
Ok(job) => job,
Err(SpacetimeClientError::Procedure(message))
if message == "external_generation_job 不存在" =>
{
return Ok(None);
}
Err(error) => {
return Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": EDITOR_GENERATION_QUEUE_PROVIDER,
"message": format!("读取 External 幂等任务失败:{error}"),
})),
);
}
};
ensure_external_api_editor_generation_request_identity(job, owner_user_id, job_kind, identity)
.map(Some)
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn enqueue_external_api_editor_generation_with_request_identity<T>(
state: &AppState,
owner_user_id: &str,
job_kind: &str,
source_entity_id: impl Into<String>,
request_label: impl Into<String>,
price_mud_points: u64,
payload: &T,
identity: &ExternalApiEditorGenerationRequestIdentity,
) -> Result<ExternalGenerationJobRecord, AppError>
where
T: Serialize,
{
let request_payload_json =
serialize_external_api_editor_generation_payload_with_identity(payload, identity)?;
let job = enqueue_serialized_editor_generation_job_with_identity(
state,
owner_user_id,
job_kind,
source_entity_id,
request_label,
price_mud_points,
request_payload_json,
identity.job_id.clone(),
identity.dedupe_key.clone(),
)
.await?;
ensure_external_api_editor_generation_request_identity(job, owner_user_id, job_kind, identity)
}
fn ensure_external_api_editor_generation_request_identity(
job: ExternalGenerationJobRecord,
owner_user_id: &str,
job_kind: &str,
identity: &ExternalApiEditorGenerationRequestIdentity,
) -> Result<ExternalGenerationJobRecord, AppError> {
let persisted_fingerprint = serde_json::from_str::<Value>(&job.request_payload_json)
.ok()
.and_then(|payload| {
payload
.get(EXTERNAL_API_REQUEST_FINGERPRINT_FIELD)
.and_then(Value::as_str)
.map(str::to_string)
});
if job.job_id == identity.job_id
&& job.dedupe_key == identity.dedupe_key
&& job.job_kind == job_kind
&& job.owner_user_id == owner_user_id
&& persisted_fingerprint.as_deref() == Some(identity.request_fingerprint.as_str())
{
return Ok(job);
}
Err(
AppError::from_status(StatusCode::CONFLICT).with_details(json!({
"provider": EDITOR_GENERATION_QUEUE_PROVIDER,
"message": "Idempotency-Key 已用于不同的生成请求,请复用原请求参数或更换幂等键。",
})),
)
}
fn serialize_external_api_editor_generation_payload_with_identity<T>(
payload: &T,
identity: &ExternalApiEditorGenerationRequestIdentity,
) -> Result<String, AppError>
where
T: Serialize + ?Sized,
{
let mut payload = serde_json::to_value(payload).map_err(payload_serialization_error)?;
payload
.as_object_mut()
.ok_or_else(|| {
payload_serialization_error(serde_json::Error::io(std::io::Error::other(
"编辑器生成任务参数必须是 JSON object",
)))
})?
.insert(
EXTERNAL_API_REQUEST_FINGERPRINT_FIELD.to_string(),
Value::String(identity.request_fingerprint.clone()),
);
serialize_editor_generation_job_payload(&payload)
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn enqueue_editor_generation_job_with_identity<T>(
state: &AppState,
@@ -678,6 +862,163 @@ mod tests {
);
}
#[test]
fn external_background_removal_operation_id_is_key_scoped_and_body_fingerprint_is_separate() {
let first_body = json!({
"sourceImageSrc": "editor-upload/source-a.png",
"projectId": "project-1",
});
let reordered_same_body = json!({
"projectId": "project-1",
"sourceImageSrc": "editor-upload/source-a.png",
});
let changed_body = json!({
"sourceImageSrc": "editor-upload/source-b.png",
"projectId": "project-1",
});
let spoofed_client_source = json!({
"sourceImageSrc": "editor-upload/source-a.png",
"projectId": "project-1",
"generationInputs": {
"source": GAME_CREATOR_CLIENT_GENERATION_SOURCE,
},
});
let first = external_api_editor_generation_request_identity(
"user-1",
EDITOR_BACKGROUND_REMOVAL_JOB_KIND,
&first_body,
"stable-key",
)
.expect("request identity should build");
let reordered = external_api_editor_generation_request_identity(
"user-1",
EDITOR_BACKGROUND_REMOVAL_JOB_KIND,
&reordered_same_body,
"stable-key",
)
.expect("canonical request identity should build");
let changed = external_api_editor_generation_request_identity(
"user-1",
EDITOR_BACKGROUND_REMOVAL_JOB_KIND,
&changed_body,
"stable-key",
)
.expect("changed request identity should build");
let spoofed = external_api_editor_generation_request_identity(
"user-1",
EDITOR_BACKGROUND_REMOVAL_JOB_KIND,
&spoofed_client_source,
"stable-key",
)
.expect("external request identity must ignore payload-controlled namespaces");
assert_eq!(first.job_id, reordered.job_id);
assert_eq!(first.request_fingerprint, reordered.request_fingerprint);
assert_eq!(first.job_id, changed.job_id);
assert_ne!(first.request_fingerprint, changed.request_fingerprint);
assert_eq!(first.job_id, spoofed.job_id);
assert_eq!(first.dedupe_key, spoofed.dedupe_key);
assert_ne!(first.request_fingerprint, spoofed.request_fingerprint);
assert!(first.job_id.starts_with("task-"));
assert_ne!(
first.job_id,
external_api_editor_generation_request_identity(
"user-2",
EDITOR_BACKGROUND_REMOVAL_JOB_KIND,
&first_body,
"stable-key",
)
.unwrap()
.job_id,
);
assert_ne!(
first.job_id,
external_api_editor_generation_request_identity(
"user-1",
EDITOR_BACKGROUND_REMOVAL_JOB_KIND,
&first_body,
"other-key",
)
.unwrap()
.job_id,
);
}
#[test]
fn external_background_removal_replay_compares_raw_request_before_canonical_worker_payload() {
let raw_request = json!({
"sourceImageSrc": "resource-source",
"projectId": "project-output",
});
let identity = external_api_editor_generation_request_identity(
"user-1",
EDITOR_BACKGROUND_REMOVAL_JOB_KIND,
&raw_request,
"stable-key",
)
.expect("request identity should build");
let canonical_worker_payload = json!({
"sourceImageSrc": "resource-source",
"projectId": "project-output",
"sourceResourceId": "resource-source",
"assetKind": "character",
});
let persisted_payload = serialize_external_api_editor_generation_payload_with_identity(
&canonical_worker_payload,
&identity,
)
.expect("canonical worker payload should serialize with its private request fingerprint");
let mut job = queue_job_fixture("queued", None);
job.job_id = identity.job_id.clone();
job.dedupe_key = identity.dedupe_key.clone();
job.job_kind = EDITOR_BACKGROUND_REMOVAL_JOB_KIND.to_string();
job.request_payload_json = persisted_payload.clone();
assert!(
ensure_external_api_editor_generation_request_identity(
job.clone(),
"user-1",
EDITOR_BACKGROUND_REMOVAL_JOB_KIND,
&identity,
)
.is_ok(),
"same raw request must replay even when its canonical worker payload came from mutable preflight",
);
let changed_identity = external_api_editor_generation_request_identity(
"user-1",
EDITOR_BACKGROUND_REMOVAL_JOB_KIND,
&json!({
"sourceImageSrc": "resource-other",
"projectId": "project-output",
}),
"stable-key",
)
.expect("changed request identity should build");
let error = ensure_external_api_editor_generation_request_identity(
job,
"user-1",
EDITOR_BACKGROUND_REMOVAL_JOB_KIND,
&changed_identity,
)
.expect_err("same key with a different raw request must conflict");
assert_eq!(error.status_code(), StatusCode::CONFLICT);
let restored: crate::editor_project::EditorBackgroundRemovalRequest =
serde_json::from_str(&persisted_payload)
.expect("worker must ignore the private queue fingerprint envelope field");
let restored_payload =
serde_json::to_value(restored).expect("worker payload should encode");
assert_eq!(restored_payload["sourceImageSrc"], json!("resource-source"));
assert_eq!(restored_payload["assetKind"], json!("character"));
assert!(
restored_payload
.get(EXTERNAL_API_REQUEST_FINGERPRINT_FIELD)
.is_none(),
"private replay identity must not enter generationInputs or worker provenance",
);
}
#[test]
fn external_api_dedupe_key_preserves_legacy_hash_bytes() {
let dedupe_key = build_editor_generation_dedupe_key(
File diff suppressed because it is too large Load Diff
@@ -858,6 +858,14 @@ pub async fn remove_external_editor_image_background(
let Json(payload) = parse_external_generation_json_payload(&request_context, payload)?;
let payload = EditorBackgroundRemovalRequest::from(payload);
let project_id = payload.project_id.clone();
#[cfg(test)]
if let Some(job) = state.intercept_test_external_background_removal_enqueue(
principal.owner_user_id(),
payload.source_image_src.as_str(),
idempotency_key,
) {
return Ok(external_generation_accepted_response(&request_context, job));
}
let job = enqueue_editor_background_removal_for_owner(
&state,
&request_context,
@@ -2063,6 +2071,107 @@ mod tests {
);
}
#[tokio::test]
async fn external_background_removal_route_accepts_a_valid_submission_once() {
const OWNER_USER_ID: &str = "user-external-background-removal-success";
const SOURCE_IMAGE_SRC: &str = "editor-upload/background-removal-source.png";
const IDEMPOTENCY_KEY: &str = "background-removal-success-contract-test";
const OPERATION_ID: &str = "task-external-background-removal-success";
let state = AppState::new(crate::config::AppConfig::default())
.expect("external background removal success test state should build");
let mut queued_job = external_generation_job_fixture("pending");
queued_job.job_id = OPERATION_ID.to_string();
queued_job.job_kind = "editor_background_removal".to_string();
queued_job.owner_user_id = OWNER_USER_ID.to_string();
state.set_test_external_background_removal_enqueue(
OWNER_USER_ID,
SOURCE_IMAGE_SRC,
IDEMPOTENCY_KEY,
queued_job,
);
let request_body = json!({"sourceImageSrc": SOURCE_IMAGE_SRC}).to_string();
let without_scope = Router::new()
.route(
"/api/external/v1/editor/images/background-removals",
post(remove_external_editor_image_background),
)
.layer(Extension(request_context(false)))
.layer(Extension(ExternalApiPrincipal::for_test(
OWNER_USER_ID,
&[],
)))
.with_state(state.clone());
let forbidden = without_scope
.oneshot(
axum::http::Request::builder()
.method("POST")
.uri("/api/external/v1/editor/images/background-removals")
.header("content-type", "application/json")
.header(IDEMPOTENCY_KEY_HEADER, IDEMPOTENCY_KEY)
.body(Body::from(request_body.clone()))
.expect("external background removal forbidden request should build"),
)
.await
.expect("external background removal forbidden response should return");
assert_eq!(forbidden.status(), StatusCode::FORBIDDEN);
assert_eq!(state.test_editor_generation_enqueue_attempts(), 0);
let app = Router::new()
.route(
"/api/external/v1/editor/images/background-removals",
post(remove_external_editor_image_background),
)
.layer(Extension(request_context(false)))
.layer(Extension(ExternalApiPrincipal::for_test(
OWNER_USER_ID,
&[SCOPE_EDITOR_IMAGE_GENERATE],
)))
.with_state(state.clone());
let response = app
.oneshot(
axum::http::Request::builder()
.method("POST")
.uri("/api/external/v1/editor/images/background-removals")
.header("content-type", "application/json")
.header(IDEMPOTENCY_KEY_HEADER, IDEMPOTENCY_KEY)
.body(Body::from(request_body))
.expect("external background removal success request should build"),
)
.await
.expect("external background removal success response should return");
assert_eq!(response.status(), StatusCode::ACCEPTED);
assert_eq!(
response
.headers()
.get("location")
.and_then(|value| value.to_str().ok()),
Some("/api/external/v1/generations/task-external-background-removal-success")
);
let body = axum::body::to_bytes(response.into_body(), 64 * 1024)
.await
.expect("external background removal success body should collect");
let payload: Value = serde_json::from_slice(&body)
.expect("external background removal success body should be JSON");
assert_eq!(payload["operationId"], json!(OPERATION_ID));
assert_eq!(payload["status"], json!("queued"));
assert_eq!(
payload["statusUrl"],
json!(format!("/api/external/v1/generations/{OPERATION_ID}"))
);
assert_eq!(
payload["pollAfterMs"],
json!(EXTERNAL_GENERATION_POLL_AFTER_MS)
);
assert_eq!(
state.test_editor_generation_enqueue_attempts(),
1,
"valid External background removal should enqueue exactly once",
);
}
#[tokio::test]
async fn external_generic_image_generation_rejects_scene_asset_kind_before_queueing() {
assert_external_generic_image_scene_bypass_is_rejected_before_queueing(
+56
View File
@@ -27,6 +27,8 @@ use platform_llm::{LlmClient, LlmConfig, LlmError, LlmProvider, OpenAiChatTokenB
use platform_matting::{MattingClient, MattingConfig};
use platform_oss::{OssClient, OssConfig, OssError};
use platform_wechat::{WechatClient, WechatConfig, pay::WechatPayClient};
#[cfg(test)]
use spacetime_client::ExternalGenerationJobRecord;
use spacetime_client::{
EditorGenerationModelPricingRecord, EditorGenerationPricingConfigRecord,
EditorGenerationPricingConfigUpsertRecordInput, EditorGenerationPricingTierRecord,
@@ -128,6 +130,15 @@ impl BackpressureState {
#[derive(Clone)]
pub struct AppState(Arc<AppStateInner>);
#[cfg(test)]
#[derive(Clone)]
struct TestExternalBackgroundRemovalEnqueue {
expected_owner_user_id: String,
expected_source_image_src: String,
expected_idempotency_key: String,
job: ExternalGenerationJobRecord,
}
impl fmt::Debug for AppState {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_tuple("AppState").field(self.0.as_ref()).finish()
@@ -256,6 +267,9 @@ pub struct AppStateInner {
test_editor_generation_enqueue_attempts: AtomicUsize,
#[cfg(test)]
test_fail_editor_generation_enqueue: AtomicBool,
#[cfg(test)]
test_external_background_removal_enqueue:
Arc<Mutex<Option<TestExternalBackgroundRemovalEnqueue>>>,
oss_client: Option<OssClient>,
#[cfg_attr(test, allow(dead_code))]
auth_store: InMemoryAuthStore,
@@ -611,6 +625,8 @@ impl AppState {
test_editor_generation_enqueue_attempts: AtomicUsize::new(0),
#[cfg(test)]
test_fail_editor_generation_enqueue: AtomicBool::new(false),
#[cfg(test)]
test_external_background_removal_enqueue: Arc::new(Mutex::new(None)),
oss_client,
auth_store,
password_entry_service,
@@ -810,6 +826,46 @@ impl AppState {
.load(Ordering::Acquire)
}
#[cfg(test)]
pub(crate) fn set_test_external_background_removal_enqueue(
&self,
expected_owner_user_id: impl Into<String>,
expected_source_image_src: impl Into<String>,
expected_idempotency_key: impl Into<String>,
job: ExternalGenerationJobRecord,
) {
*self
.test_external_background_removal_enqueue
.lock()
.expect("test external background removal enqueue should lock") =
Some(TestExternalBackgroundRemovalEnqueue {
expected_owner_user_id: expected_owner_user_id.into(),
expected_source_image_src: expected_source_image_src.into(),
expected_idempotency_key: expected_idempotency_key.into(),
job,
});
}
#[cfg(test)]
pub(crate) fn intercept_test_external_background_removal_enqueue(
&self,
owner_user_id: &str,
source_image_src: &str,
idempotency_key: &str,
) -> Option<ExternalGenerationJobRecord> {
let fixture = self
.test_external_background_removal_enqueue
.lock()
.expect("test external background removal enqueue should lock")
.clone()?;
assert_eq!(owner_user_id, fixture.expected_owner_user_id);
assert_eq!(source_image_src, fixture.expected_source_image_src);
assert_eq!(idempotency_key, fixture.expected_idempotency_key);
self.test_editor_generation_enqueue_attempts
.fetch_add(1, Ordering::AcqRel);
Some(fixture.job)
}
#[cfg(any())]
pub async fn upsert_creation_entry_type_config(
&self,