diff --git a/packages/ai-server/pyproject.toml b/packages/ai-server/pyproject.toml index d5119777..ef33a8d3 100644 --- a/packages/ai-server/pyproject.toml +++ b/packages/ai-server/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "decoded-ai" -version = "1.5.0" +version = "1.5.1" description = "" authors = [ {name = "CIOI",email = "rhkr9693@gmail.com"} diff --git a/packages/ai-server/scripts/backfill_starstyle_posts.py b/packages/ai-server/scripts/backfill_starstyle_posts.py index e0a1b25f..c1602eab 100644 --- a/packages/ai-server/scripts/backfill_starstyle_posts.py +++ b/packages/ai-server/scripts/backfill_starstyle_posts.py @@ -81,6 +81,15 @@ def _env(name: str, *, required: bool = True) -> str: _SUPABASE_URL = _env("ASSETS_DATABASE_API_URL").rstrip("/") _SERVICE_ROLE_KEY = _env("ASSETS_DATABASE_SERVICE_ROLE_KEY") +# R2 — starstyle 의 og:image 는 hotlink 보호 + HTTPS→HTTP 리다이렉트 때문에 +# admin (HTTPS) 에서 직접 fetch 가 안 됨. 백필 시 R2 에 미러링해서 image_url 을 +# R2 public URL 로 저장 → admin 이 vercel proxy 우회. +_R2_ACCOUNT_ID = _env("RAW_POSTS_R2_ACCOUNT_ID", required=False) +_R2_ACCESS_KEY = _env("RAW_POSTS_R2_ACCESS_KEY_ID", required=False) +_R2_SECRET_KEY = _env("RAW_POSTS_R2_SECRET_ACCESS_KEY", required=False) +_R2_BUCKET = _env("RAW_POSTS_R2_BUCKET", required=False) or "raw" +_R2_PUBLIC_URL = (_env("RAW_POSTS_R2_PUBLIC_URL", required=False) or "").rstrip("/") + _INSERT_BATCH = 100 logger = logging.getLogger("backfill_starstyle") @@ -89,7 +98,7 @@ def _env(name: str, *, required: bool = True) -> str: # ---------------------------------------------------------- types & helpers -@dataclass(frozen=True) +@dataclass class PostData: post_id: str slug: str @@ -311,6 +320,93 @@ async def fetch_existing_external_ids(http: httpx.AsyncClient) -> Set[str]: return out +# --------------------------------------------------- R2 mirror (hero image) + + +def _r2_client(): + """boto3 S3 client for Cloudflare R2. Returns None if not configured.""" + if not (_R2_ACCOUNT_ID and _R2_ACCESS_KEY and _R2_SECRET_KEY): + return None + import boto3 # local import — backfill 만 사용 + from botocore.client import Config + + return boto3.client( + "s3", + endpoint_url=f"https://{_R2_ACCOUNT_ID}.r2.cloudflarestorage.com", + aws_access_key_id=_R2_ACCESS_KEY, + aws_secret_access_key=_R2_SECRET_KEY, + config=Config(signature_version="s3v4"), + region_name="auto", + ) + + +def _build_r2_key(external_id: str) -> str: + """``starstyle/{shard}/{id}.jpg``. pipeline._build_r2_key 와 같은 형식. + + starstyle 의 hero 는 항상 jpg (og:image 의 ``.jpg`` 확장자) — 다양한 + 포맷을 처리하지 않고 jpg 로 통일. 다른 포맷이 들어오면 그대로 jpg 로 저장 + (R2 ContentType 에서 결정, 브라우저는 sniffing). + """ + safe = "".join(c if c.isalnum() else "-" for c in external_id).strip("-") or "x" + shard = safe[:2] or "_" + return f"starstyle/{shard}/{safe}.jpg" + + +async def mirror_image_to_r2( + http: httpx.AsyncClient, + *, + s3, + image_url: str, + external_id: str, + referer: str, +) -> Optional[str]: + """upstream 이미지 다운로드 → R2 put → R2 public URL 반환. + + 실패 시 None — caller 가 image_url 로 upstream URL 그대로 사용. + 이미 R2 에 있으면 (HEAD 200) 다운로드 skip. + """ + if s3 is None or not _R2_PUBLIC_URL: + return None + key = _build_r2_key(external_id) + public_url = f"{_R2_PUBLIC_URL}/{key}" + # HEAD 로 존재 확인 — 재실행 시 중복 업로드 방지. + try: + await asyncio.to_thread(s3.head_object, Bucket=_R2_BUCKET, Key=key) + return public_url + except Exception: + pass + + try: + resp = await http.get( + image_url, + headers={"User-Agent": _USER_AGENT, "Referer": referer}, + follow_redirects=True, + timeout=30, + ) + resp.raise_for_status() + data = resp.content + ct = (resp.headers.get("content-type") or "image/jpeg").split(";")[0].strip() + if not ct.startswith("image/"): + logger.warning("mirror: skip %s — bad content-type %s", external_id, ct) + return None + except Exception as exc: + logger.warning("mirror: download failed for %s — %s", external_id, exc) + return None + + try: + await asyncio.to_thread( + s3.put_object, + Bucket=_R2_BUCKET, + Key=key, + Body=data, + ContentType=ct, + ) + except Exception as exc: + logger.warning("mirror: R2 put failed for %s — %s", external_id, exc) + return None + return public_url + + async def ensure_global_feed_source(http: httpx.AsyncClient) -> str: body = [ { @@ -531,6 +627,55 @@ async def _run(args) -> int: source_id = await ensure_global_feed_source(http) logger.info(" source_id = %s", source_id) + # R2 mirror — admin (HTTPS) 에서 starstyle 이미지 hotlink 가 막혀 + # backfill 시 R2 에 미러링하고 image_url 을 R2 public URL 로 저장. + s3 = _r2_client() + if s3 is not None: + logger.info( + "R2 mirror: bucket=%s public=%s — mirroring %d images", + _R2_BUCKET, + _R2_PUBLIC_URL, + len(posts_new), + ) + sem = asyncio.Semaphore(args.concurrency) + mirrored = 0 + failed = 0 + + async def _mirror_one(d: PostData): + nonlocal mirrored, failed + async with sem: + new_url = await mirror_image_to_r2( + http, + s3=s3, + image_url=d.image_url, + external_id=d.post_id, + referer=d.url, + ) + if new_url: + d.image_url = new_url + mirrored += 1 + else: + failed += 1 + if (mirrored + failed) % 50 == 0: + logger.info( + " mirror progress: %d done (%d mirrored, %d failed)", + mirrored + failed, + mirrored, + failed, + ) + + await asyncio.gather(*(_mirror_one(p) for p in posts_new)) + logger.info( + "R2 mirror done: %d mirrored, %d failed (fallback to upstream)", + mirrored, + failed, + ) + else: + logger.warning( + "R2 not configured (RAW_POSTS_R2_* env missing) — " + "image_url 은 upstream URL 그대로 (admin preview 깨짐)" + ) + dispatch_id = f"backfill-{uuid.uuid4().hex[:12]}" total_inserted = 0 n_batches = (len(posts_new) + _INSERT_BATCH - 1) // _INSERT_BATCH diff --git a/packages/api-server/Cargo.toml b/packages/api-server/Cargo.toml index d7c92da2..e4de5283 100644 --- a/packages/api-server/Cargo.toml +++ b/packages/api-server/Cargo.toml @@ -75,7 +75,7 @@ tokio-test = "0.4" [package] name = "decoded-api" -version = "0.10.0" +version = "0.10.1" edition = "2021" license = "MIT" default-run = "decoded-api" diff --git a/packages/api-server/src/app_state.rs b/packages/api-server/src/app_state.rs index da22cb81..a65ebcc7 100644 --- a/packages/api-server/src/app_state.rs +++ b/packages/api-server/src/app_state.rs @@ -32,7 +32,10 @@ pub struct AppState { pub post_list_cache: Arc, // Trait 기반 클라이언트 - pub storage_client: Arc, + pub operation_storage: Arc, + /// raw_posts 전용 R2 (#466 R2 cleanup) — ai-server 가 hero/thumbnail 을 + /// 올리는 별도 버킷. delete_item 시 verify 안 된 raw_post 의 R2 객체 삭제. + pub assets_storage: Arc, pub search_client: Arc, pub affiliate_client: Arc, pub embedding_client: Arc, @@ -63,7 +66,7 @@ impl AppState { let assets_db = Arc::new(config.create_assets_db_connection().await?); tracing::info!("Assets DB connection established"); - let storage_client: Arc = + let operation_storage: Arc = match CloudflareR2Client::new(&config.storage).await { Ok(client) => { tracing::info!("CloudflareR2Client initialized successfully"); @@ -78,6 +81,24 @@ impl AppState { } }; + // raw_posts 전용 R2 (#466 R2 cleanup) — ai-server 가 쓰는 별도 버킷. + // 미설정 시 dummy 로 fallback (delete cleanup 만 영향, 운영 노이즈 X). + let assets_storage: Arc = + match CloudflareR2Client::new(&config.assets_storage).await { + Ok(client) => { + tracing::info!("raw_posts CloudflareR2Client initialized successfully"); + Arc::new(client) + } + Err(e) => { + tracing::warn!( + "Failed to initialize raw_posts CloudflareR2Client: {}. \ + R2 cleanup on raw_post delete will be skipped.", + e + ); + Arc::new(crate::services::DummyStorageClient::default()) + } + }; + let search_client: Arc = match MeilisearchClient::new(&config.search) { Ok(client) => { tracing::info!("MeilisearchClient initialized successfully"); @@ -156,7 +177,8 @@ impl AppState { config, category_cache, post_list_cache, - storage_client, + operation_storage, + assets_storage, search_client, affiliate_client, embedding_client, diff --git a/packages/api-server/src/config.rs b/packages/api-server/src/config.rs index 8e7f3239..0d923722 100644 --- a/packages/api-server/src/config.rs +++ b/packages/api-server/src/config.rs @@ -52,6 +52,10 @@ pub struct AppConfig { pub assets_database: AssetsDatabaseConfig, pub auth: AuthConfig, pub storage: StorageConfig, + /// raw_posts 전용 R2 (#466 R2 cleanup) — ai-server 가 hero/thumbnail 을 + /// 업로드하는 별도 버킷. ``RAW_POSTS_R2_*`` env. delete_item 시 verify + /// 안 된 raw_post 의 R2 객체 정리에 사용. + pub assets_storage: StorageConfig, pub search: SearchConfig, pub affiliate: AffiliateConfig, pub ai_service: AiServiceConfig, @@ -271,6 +275,21 @@ impl AppConfig { .unwrap_or_else(|_| "decoded-images".to_string()), public_url: std::env::var("R2_PUBLIC_URL").unwrap_or_else(|_| String::new()), }, + assets_storage: StorageConfig { + endpoint: std::env::var("RAW_POSTS_R2_ACCOUNT_ID") + .map(|id| format!("https://{}.r2.cloudflarestorage.com", id)) + .unwrap_or_else(|_| String::new()), + account_id: std::env::var("RAW_POSTS_R2_ACCOUNT_ID") + .unwrap_or_else(|_| String::new()), + access_key_id: std::env::var("RAW_POSTS_R2_ACCESS_KEY_ID") + .unwrap_or_else(|_| String::new()), + secret_access_key: std::env::var("RAW_POSTS_R2_SECRET_ACCESS_KEY") + .unwrap_or_else(|_| String::new()), + bucket_name: std::env::var("RAW_POSTS_R2_BUCKET") + .unwrap_or_else(|_| "raw".to_string()), + public_url: std::env::var("RAW_POSTS_R2_PUBLIC_URL") + .unwrap_or_else(|_| String::new()), + }, search: SearchConfig { url: std::env::var("MEILISEARCH_URL") .unwrap_or_else(|_| "http://localhost:7700".to_string()), diff --git a/packages/api-server/src/domains/posts/service.rs b/packages/api-server/src/domains/posts/service.rs index 47ff3fe9..55ddfbf5 100644 --- a/packages/api-server/src/domains/posts/service.rs +++ b/packages/api-server/src/domains/posts/service.rs @@ -291,9 +291,9 @@ pub async fn create_post_without_solutions( .inspect_err(|_e| { // Post 생성 실패 시 업로드된 이미지 삭제 let image_key_clone = image_key.clone(); - let storage_client = state.storage_client.clone(); + let storage = state.operation_storage.clone(); tokio::spawn(async move { - if let Err(delete_err) = storage_client.delete(&image_key_clone).await { + if let Err(delete_err) = storage.delete(&image_key_clone).await { tracing::warn!( "Failed to delete orphaned image {}: {}", image_key_clone, @@ -363,9 +363,9 @@ pub async fn create_post_with_solutions( .inspect_err(|_e| { // Post 생성 실패 시 업로드된 이미지 삭제 let image_key_clone = image_key.clone(); - let storage_client = state.storage_client.clone(); + let storage = state.operation_storage.clone(); tokio::spawn(async move { - if let Err(delete_err) = storage_client.delete(&image_key_clone).await { + if let Err(delete_err) = storage.delete(&image_key_clone).await { tracing::warn!( "Failed to delete orphaned image {}: {}", image_key_clone, @@ -1734,7 +1734,7 @@ pub async fn upload_image( // StorageClient를 사용하여 업로드 let image_url = state - .storage_client + .operation_storage .upload(&key, image_data, content_type) .await .map_err(|e| AppError::ExternalService(format!("Failed to upload image: {}", e)))?; @@ -2022,9 +2022,9 @@ pub async fn create_try_post( .inspect_err(|_| { // 실패 시 업로드된 이미지 삭제 let image_key_clone = image_key.clone(); - let storage_client = state.storage_client.clone(); + let storage = state.operation_storage.clone(); tokio::spawn(async move { - if let Err(e) = storage_client.delete(&image_key_clone).await { + if let Err(e) = storage.delete(&image_key_clone).await { tracing::warn!( "Failed to delete orphaned try image {}: {}", image_key_clone, diff --git a/packages/api-server/src/domains/raw_posts/handlers.rs b/packages/api-server/src/domains/raw_posts/handlers.rs index f55d2ab7..5f5f7390 100644 --- a/packages/api-server/src/domains/raw_posts/handlers.rs +++ b/packages/api-server/src/domains/raw_posts/handlers.rs @@ -267,7 +267,14 @@ pub async fn delete_item( State(state): State, Path(id): Path, ) -> AppResult { - service::delete_item(state.assets_db.as_ref(), id).await?; + service::delete_item( + state.assets_db.as_ref(), + state.db.as_ref(), + state.assets_storage.as_ref(), + &state.config.assets_storage.public_url, + id, + ) + .await?; Ok(StatusCode::NO_CONTENT) } diff --git a/packages/api-server/src/domains/raw_posts/service.rs b/packages/api-server/src/domains/raw_posts/service.rs index 9d34c546..33c19f62 100644 --- a/packages/api-server/src/domains/raw_posts/service.rs +++ b/packages/api-server/src/domains/raw_posts/service.rs @@ -12,9 +12,11 @@ use uuid::Uuid; use crate::entities::{ assets_pipeline_settings as settings_entity, assets_raw_post_sources as src_entity, - assets_raw_posts as post_entity, AssetsPipelineSettings, AssetsRawPostSources, AssetsRawPosts, + assets_raw_posts as post_entity, posts as posts_entity, AssetsPipelineSettings, + AssetsRawPostSources, AssetsRawPosts, Posts, }; use crate::error::{AppError, AppResult}; +use crate::services::StorageClient; use super::dto::{ CreateRawPostSourceDto, DiscoveryPipelineHealth, ListItemsQuery, ListSourcesQuery, @@ -597,17 +599,90 @@ pub async fn get_item(db: &DatabaseConnection, id: Uuid) -> AppResult { /// admin 이 discovered/processed/error 어느 상태든 raw_post 를 영구 삭제 /// (#359). pipeline_events 는 FK ON DELETE CASCADE 로 함께 정리. -pub async fn delete_item(db: &DatabaseConnection, id: Uuid) -> AppResult<()> { +/// +/// R2 cleanup (#466): raw_post.image_url 이 raw_posts R2 객체이고 +/// verified `posts.image_url` 가 동일 URL 을 참조하지 않으면 R2 객체도 삭제. +/// verify 후 posts 가 같은 URL 을 가리키는 경우 R2 보존 (verified post 가 깨짐). +pub async fn delete_item( + assets_db: &DatabaseConnection, + prod_db: &DatabaseConnection, + storage: &dyn StorageClient, + assets_r2_public_url: &str, + id: Uuid, +) -> AppResult<()> { + // 1) raw_post 조회 — image_url 만 필요. 없으면 404. + let raw = AssetsRawPosts::find_by_id(id) + .one(assets_db) + .await + .map_err(AppError::DatabaseError)? + .ok_or_else(|| AppError::NotFound(format!("raw_post {id} not found")))?; + + let image_url = raw.image_url.clone(); + + // 2) DELETE — 우선순위: prod posts FK violation 등 별도 cascade 없음. let res = AssetsRawPosts::delete_by_id(id) - .exec(db) + .exec(assets_db) .await .map_err(AppError::DatabaseError)?; if res.rows_affected == 0 { return Err(AppError::NotFound(format!("raw_post {id} not found"))); } + + // 3) R2 cleanup — best effort. 실패해도 raw_post 삭제는 이미 성공. + if let Some(url) = image_url { + if let Some(key) = extract_assets_r2_key(&url, assets_r2_public_url) { + // verify 후 posts 가 같은 image_url 을 참조 중이면 보존. + let referenced = Posts::find() + .filter(posts_entity::Column::ImageUrl.eq(url.clone())) + .count(prod_db) + .await + .unwrap_or(0); + if referenced == 0 { + if let Err(e) = storage.delete(&key).await { + tracing::warn!( + "raw_posts.delete_item: R2 delete failed for key={} err={}", + key, + e + ); + } else { + tracing::info!( + "raw_posts.delete_item: R2 object deleted (key={}, raw_post={})", + key, + id + ); + } + } else { + tracing::info!( + "raw_posts.delete_item: R2 object kept — referenced by {} verified post(s) (url={})", + referenced, + url + ); + } + } + } Ok(()) } +/// raw_posts R2 public URL 에서 객체 키 추출. +/// +/// 예: +/// public_url = "https://pub-xxx.r2.dev" +/// url = "https://pub-xxx.r2.dev/starstyle/92/926700.jpg" +/// → Some("starstyle/92/926700.jpg") +/// +/// 다른 도메인 / 빈 public_url / 키가 비면 None — caller 가 cleanup skip. +fn extract_assets_r2_key(url: &str, public_url: &str) -> Option { + let prefix = public_url.trim_end_matches('/'); + if prefix.is_empty() { + return None; + } + let stripped = url.strip_prefix(prefix)?.trim_start_matches('/'); + if stripped.is_empty() { + return None; + } + Some(stripped.to_string()) +} + /// Insert-or-update a single raw_post given by the ai-server callback. /// Uniqueness key: `(platform, external_id)`. pub async fn upsert_raw_post( @@ -1636,6 +1711,41 @@ mod tests { assert!(err.contains("unrecognized starstyle")); } + #[test] + fn extract_assets_r2_key_strips_public_url_prefix() { + let public = "https://pub-64ff29549cdf47ee94d338bca8d04819.r2.dev"; + let url = "https://pub-64ff29549cdf47ee94d338bca8d04819.r2.dev/starstyle/92/926700.jpg"; + assert_eq!( + super::extract_assets_r2_key(url, public).as_deref(), + Some("starstyle/92/926700.jpg") + ); + } + + #[test] + fn extract_assets_r2_key_handles_trailing_slash() { + let public = "https://pub-x.r2.dev/"; + let url = "https://pub-x.r2.dev/starstyle/92/x.jpg"; + assert_eq!( + super::extract_assets_r2_key(url, public).as_deref(), + Some("starstyle/92/x.jpg") + ); + } + + #[test] + fn extract_assets_r2_key_returns_none_for_other_domain() { + let public = "https://pub-x.r2.dev"; + let url = "http://www.starstyle.com/wp-content/uploads/x.jpg"; + assert_eq!(super::extract_assets_r2_key(url, public), None); + } + + #[test] + fn extract_assets_r2_key_returns_none_for_empty_public_url() { + assert_eq!( + super::extract_assets_r2_key("https://anywhere/x.jpg", ""), + None + ); + } + #[test] fn parse_source_identifier_instagram_at_handle() { let (_, id) = super::parse_source_identifier("instagram", "@Foo.Bar").unwrap(); diff --git a/packages/api-server/src/domains/solutions/tests.rs b/packages/api-server/src/domains/solutions/tests.rs index c3daf6e4..45f082e4 100644 --- a/packages/api-server/src/domains/solutions/tests.rs +++ b/packages/api-server/src/domains/solutions/tests.rs @@ -232,6 +232,7 @@ mod mock_db_tests { user_id: Some(fixtures::test_uuid(10)), sort: "recent".to_string(), pagination: Pagination::new(1, 20), + has_url: None, }; let result = service::admin_list_solutions(&db, query).await; assert!(result.is_ok(), "unexpected err: {:?}", result.err()); @@ -259,6 +260,7 @@ mod mock_db_tests { user_id: None, sort: "popular".to_string(), pagination: Pagination::new(1, 20), + has_url: None, }; assert!(service::admin_list_solutions(&db, query).await.is_ok()); } @@ -284,6 +286,7 @@ mod mock_db_tests { user_id: None, sort: "verified".to_string(), pagination: Pagination::new(1, 20), + has_url: None, }; assert!(service::admin_list_solutions(&db, query).await.is_ok()); } @@ -309,6 +312,7 @@ mod mock_db_tests { user_id: None, sort: "adopted".to_string(), pagination: Pagination::new(1, 20), + has_url: None, }; assert!(service::admin_list_solutions(&db, query).await.is_ok()); } @@ -334,6 +338,7 @@ mod mock_db_tests { user_id: None, sort: "unknown_sort_key".to_string(), pagination: Pagination::new(1, 20), + has_url: None, }; assert!(service::admin_list_solutions(&db, query).await.is_ok()); } diff --git a/packages/api-server/src/middleware/metrics.rs b/packages/api-server/src/middleware/metrics.rs index 1f275589..1cc1edc8 100644 --- a/packages/api-server/src/middleware/metrics.rs +++ b/packages/api-server/src/middleware/metrics.rs @@ -96,6 +96,14 @@ mod tests { bucket_name: "test".to_string(), public_url: String::new(), }, + assets_storage: StorageConfig { + endpoint: String::new(), + account_id: String::new(), + access_key_id: String::new(), + secret_access_key: String::new(), + bucket_name: "raw".to_string(), + public_url: String::new(), + }, search: SearchConfig { url: "http://localhost:7700".to_string(), api_key: String::new(), @@ -125,7 +133,8 @@ mod tests { config, category_cache: Arc::new(CategoryCache::new()), post_list_cache: Arc::new(PostListCache::new()), - storage_client: Arc::new(DummyStorageClient::default()), + operation_storage: Arc::new(DummyStorageClient::default()), + assets_storage: Arc::new(DummyStorageClient::default()), search_client: Arc::new(DummySearchClient), affiliate_client: Arc::new(DummyAffiliateClient), embedding_client: Arc::new(DummyEmbeddingClient), diff --git a/packages/api-server/src/tests/helpers.rs b/packages/api-server/src/tests/helpers.rs index 2710a4ea..2175e937 100644 --- a/packages/api-server/src/tests/helpers.rs +++ b/packages/api-server/src/tests/helpers.rs @@ -65,6 +65,14 @@ pub fn test_config() -> AppConfig { bucket_name: "test-bucket".to_string(), public_url: "https://cdn.test.com".to_string(), }, + assets_storage: StorageConfig { + endpoint: "https://test-assets.r2.cloudflarestorage.com".to_string(), + account_id: "test-account".to_string(), + access_key_id: "test-key".to_string(), + secret_access_key: "test-secret".to_string(), + bucket_name: "raw".to_string(), + public_url: "https://pub-test-assets.r2.dev".to_string(), + }, search: SearchConfig { url: "http://localhost:7700".to_string(), api_key: "test-master-key".to_string(), @@ -134,7 +142,8 @@ pub fn test_app_state_with_assets( config: test_config(), category_cache: Arc::new(CategoryCache::new()), post_list_cache: Arc::new(crate::domains::posts::cache::PostListCache::new()), - storage_client: Arc::new(DummyStorageClient::default()), + operation_storage: Arc::new(DummyStorageClient::default()), + assets_storage: Arc::new(DummyStorageClient::default()), search_client: Arc::new(DummySearchClient), affiliate_client: Arc::new(DummyAffiliateClient), embedding_client: Arc::new(DummyEmbeddingClient), @@ -158,7 +167,7 @@ pub fn test_app_state(db: DatabaseConnection) -> AppState { /// 커스텀 trait 클라이언트를 주입할 수 있는 AppState pub fn test_app_state_with_clients( db: Arc, - storage_client: Arc, + operation_storage: Arc, search_client: Arc, affiliate_client: Arc, embedding_client: Arc, @@ -176,7 +185,8 @@ pub fn test_app_state_with_clients( config: test_config(), category_cache: Arc::new(CategoryCache::new()), post_list_cache: Arc::new(crate::domains::posts::cache::PostListCache::new()), - storage_client, + operation_storage, + assets_storage: Arc::new(crate::services::DummyStorageClient::default()), search_client, affiliate_client, embedding_client,