feat(edit): 编辑上报细节优化
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { createApplicationDatabase, type Database } from '@atlas/database';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { ProductImageDeletionService } from '../src/media/product-image-deletion.service.js';
|
||||
import { IdempotencyService } from '../src/common/idempotency.service.js';
|
||||
import { runMediaAssetDeletion, markMediaAssetDeletionFailed } from '../../worker/src/media-asset-delete.js';
|
||||
|
||||
/** 全部业务实体在结束时回滚,清理对象请求使用替身。 */
|
||||
describe.skipIf(!process.env.DATABASE_URL)('商品图片永久删除数据库闭环', () => {
|
||||
it('校验归属和引用,可靠清理可重试且幂等,删除后不能新增引用', async () => {
|
||||
const db = createApplicationDatabase(process.env.DATABASE_URL!);
|
||||
const rollback = Symbol('rollback');
|
||||
try {
|
||||
await db.transaction(async tx => {
|
||||
const ws = randomUUID(), foreignWs = randomUUID(), user = randomUUID();
|
||||
await tx.query("INSERT INTO atlas.users (id, email, password_hash) VALUES ($1, $2, 'test')", [user, `${user}@example.test`]);
|
||||
for (const id of [ws, foreignWs]) await tx.query("INSERT INTO atlas.workspaces (id,name,slug) VALUES ($1::uuid,'删除测试',$1::text)", [id]);
|
||||
const setWorkspace = (id: string) => tx.query("SELECT set_config('app.workspace_id', $1, true)", [id]);
|
||||
await setWorkspace(ws);
|
||||
const item = randomUUID(), product = randomUUID(), otherProduct = randomUUID(), asset = randomUUID();
|
||||
await tx.query("INSERT INTO atlas.collection_items (id,workspace_id,source_kind,source_item_key) VALUES ($1::uuid,$2,'manual',$1::text)", [item, ws]);
|
||||
await tx.query('INSERT INTO atlas.products (id,workspace_id,collection_item_id) VALUES ($1,$2,$3)', [product, ws, item]);
|
||||
const otherItem = randomUUID();
|
||||
await tx.query("INSERT INTO atlas.collection_items (id,workspace_id,source_kind,source_item_key) VALUES ($1::uuid,$2,'manual',$1::text)", [otherItem, ws]);
|
||||
await tx.query('INSERT INTO atlas.products (id,workspace_id,collection_item_id) VALUES ($1,$2,$3)', [otherProduct, ws, otherItem]);
|
||||
const checksum = 'a'.repeat(64);
|
||||
await tx.query(`INSERT INTO atlas.media_assets (id,workspace_id,storage_provider,bucket,object_key,content_type,byte_size,checksum_sha256,width_px,height_px)
|
||||
VALUES ($1,$2,'local_minio','test','test/canonical','image/png',100,$3,800,800)`, [asset, ws, checksum]);
|
||||
await tx.query(`INSERT INTO atlas.media_upload_intents (workspace_id,product_id,purpose,storage_provider,bucket,object_key,expected_content_type,expected_byte_size,
|
||||
expected_checksum_sha256,status,asset_id,expires_at,completed_at)
|
||||
VALUES ($1,$2,'product_image','local_minio','test','test/duplicate','image/png',100,$3,'ready',$4,now()+interval '1 hour',now())`, [ws,product,checksum,asset]);
|
||||
const scoped = { workspaceTransaction: async (id: string, work: (transaction: typeof tx) => Promise<unknown>) => { await setWorkspace(id); return work(tx); } } as Database;
|
||||
const service = new ProductImageDeletionService(scoped, new IdempotencyService(scoped, { IDEMPOTENCY_KEY_SECRET: 'delete-test' } as never));
|
||||
await expect(service.create(ws,user,otherProduct,randomUUID(),{asset_id:asset})).rejects.toMatchObject({status:404});
|
||||
await tx.query('UPDATE atlas.users SET avatar_asset_id = $1 WHERE id = $2', [asset,user]);
|
||||
await expect(service.create(ws,user,product,randomUUID(),{asset_id:asset})).rejects.toMatchObject({status:409});
|
||||
await tx.query('SAVEPOINT in_use_delete');
|
||||
await expect(tx.query('UPDATE atlas.media_assets SET deleted_at = now(), checksum_sha256 = NULL WHERE id = $1', [asset])).rejects.toMatchObject({code:'23514'});
|
||||
await tx.query('ROLLBACK TO SAVEPOINT in_use_delete');
|
||||
await tx.query('UPDATE atlas.users SET avatar_asset_id = NULL WHERE id = $1', [user]);
|
||||
await tx.query("INSERT INTO atlas.product_image_imports (workspace_id,product_id,source_url,asset_id) VALUES ($1,$2,'https://example.test/shared.png',$3)", [ws,otherProduct,asset]);
|
||||
await expect(service.create(ws,user,product,randomUUID(),{asset_id:asset})).rejects.toMatchObject({status:409});
|
||||
await tx.query('DELETE FROM atlas.product_image_imports WHERE product_id = $1', [otherProduct]);
|
||||
const key = randomUUID();
|
||||
const result = await service.create(ws,user,product,key,{asset_id:asset});
|
||||
const deletion = (result.body as {data:{id:string;status:string}}).data;
|
||||
expect(deletion.status).toBe('queued');
|
||||
expect((await service.create(ws,user,product,key,{asset_id:asset})).body).toEqual(result.body);
|
||||
const removed = (await tx.query('SELECT deleted_at,checksum_sha256,deleted_checksum_sha256 FROM atlas.media_assets WHERE id = $1',[asset])).rows[0];
|
||||
expect(removed.deleted_at).toBeTruthy(); expect(removed.checksum_sha256).toBeNull(); expect(removed.deleted_checksum_sha256).toBe(checksum);
|
||||
expect((await tx.query("SELECT event_payload FROM atlas.outbox_events WHERE event_type = 'media.asset_delete.requested'")).rows).toHaveLength(1);
|
||||
await tx.query('SAVEPOINT deleted_reference');
|
||||
await expect(tx.query("INSERT INTO atlas.product_image_imports (workspace_id,product_id,source_url,asset_id) VALUES ($1,$2,'https://example.test/forbidden.png',$3)",[ws,product,asset])).rejects.toMatchObject({code:'23514'});
|
||||
await tx.query('ROLLBACK TO SAVEPOINT deleted_reference');
|
||||
const send = vi.fn().mockRejectedValueOnce(new Error('存储暂不可用')).mockResolvedValue({});
|
||||
const storage = () => ({ send }) as never;
|
||||
const job = {eventId:randomUUID(),workspaceId:ws,eventType:'media.asset_delete.requested',payload:{deletion_id:deletion.id}};
|
||||
await expect(runMediaAssetDeletion(scoped,storage,job)).rejects.toThrow('存储暂不可用');
|
||||
await markMediaAssetDeletionFailed(scoped,job);
|
||||
const failed = await service.read(ws,product,deletion.id);
|
||||
expect(failed.status).toBe('failed'); expect(failed.operation.status).toBe('failed');
|
||||
await service.retry(ws,user,product,deletion.id,randomUUID());
|
||||
await runMediaAssetDeletion(scoped,storage,job);
|
||||
const succeeded = await service.read(ws,product,deletion.id);
|
||||
expect(succeeded.status).toBe('succeeded'); expect(succeeded.operation.status).toBe('succeeded');
|
||||
expect(send).toHaveBeenCalledTimes(3);
|
||||
await runMediaAssetDeletion(scoped,storage,job);
|
||||
expect(send).toHaveBeenCalledTimes(3);
|
||||
await tx.query(`INSERT INTO atlas.media_assets (workspace_id,storage_provider,bucket,object_key,content_type,byte_size,checksum_sha256)
|
||||
VALUES ($1,'local_minio','test','test/new-object','image/png',100,$2)`,[ws,checksum]);
|
||||
const transfer = randomUUID();
|
||||
await tx.query(`INSERT INTO atlas.collection_asset_transfers (id,workspace_id,collection_item_id,product_id,role,source_url)
|
||||
VALUES ($1,$2,$3,$4,'source_main','https://example.test/source.png')`,[transfer,ws,item,product]);
|
||||
const sourceDelete = await service.create(ws,user,product,randomUUID(),{collection_image_id:transfer});
|
||||
expect((sourceDelete.body as {data:{status:string}}).data.status).toBe('succeeded');
|
||||
expect((await tx.query('SELECT status,deleted_at FROM atlas.collection_asset_transfers WHERE id = $1',[transfer])).rows[0]).toMatchObject({status:'cancelled'});
|
||||
await expect(service.read(foreignWs,product,deletion.id)).rejects.toMatchObject({status:404});
|
||||
throw rollback;
|
||||
});
|
||||
} catch (error) { if (error !== rollback) throw error; }
|
||||
finally { await db.close(); }
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user