Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
167 changes: 162 additions & 5 deletions src/platform/assets/composables/useMediaAssetActions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,12 +56,14 @@ vi.mock('@/stores/dialogStore', () => ({
const mockInvalidateModelsForCategory = vi.hoisted(() => vi.fn())
const mockSetAssetDeleting = vi.hoisted(() => vi.fn())
const mockUpdateHistory = vi.hoisted(() => vi.fn())
const mockUpdateFlatOutputs = vi.hoisted(() => vi.fn())
const mockUpdateInputs = vi.hoisted(() => vi.fn())
const mockHasCategory = vi.hoisted(() => vi.fn())
vi.mock('@/stores/assetsStore', () => ({
useAssetsStore: () => ({
setAssetDeleting: mockSetAssetDeleting,
updateHistory: mockUpdateHistory,
updateFlatOutputs: mockUpdateFlatOutputs,
updateInputs: mockUpdateInputs,
invalidateModelsForCategory: mockInvalidateModelsForCategory,
hasCategory: mockHasCategory
Expand Down Expand Up @@ -146,12 +148,14 @@ vi.mock('../utils/outputAssetUtil', async (importOriginal) => {
})

const mockDeleteAsset = vi.hoisted(() => vi.fn())
const mockGetJobAssetIds = vi.hoisted(() => vi.fn())
const mockCreateAssetExport = vi.hoisted(() =>
vi.fn().mockResolvedValue({ task_id: 'test-task-id', status: 'pending' })
)
vi.mock('../services/assetService', () => ({
assetService: {
deleteAsset: mockDeleteAsset,
getJobAssetIds: mockGetJobAssetIds,
createAssetExport: mockCreateAssetExport
}
}))
Expand All @@ -165,7 +169,7 @@ vi.mock('@/stores/assetExportStore', () => ({

vi.mock('@/scripts/api', () => ({
api: {
deleteItem: vi.fn(),
fetchApi: vi.fn(),
apiURL: vi.fn((path: string) => `http://localhost:8188/api${path}`),
internalURL: vi.fn((path: string) => `http://localhost:8188${path}`),
addEventListener: vi.fn(),
Expand Down Expand Up @@ -301,6 +305,10 @@ describe('useMediaAssetActions', () => {
mockGetAssetType.mockReturnValue('input')
mockResolveOutputAssetItems.mockReset()
mockResolveOutputAssetItems.mockResolvedValue([])
mockGetJobAssetIds.mockResolvedValue([])
vi.mocked(api.fetchApi).mockResolvedValue(
fromAny({ ok: true, status: 200 })
)
})

describe('addWorkflow', () => {
Expand Down Expand Up @@ -1245,7 +1253,7 @@ describe('useMediaAssetActions', () => {
)
})

it('deletes via the history API in OSS instead of failing as an imported file', async () => {
it('deletes the history job in OSS without requiring the cloud asset API', async () => {
const actions = useMediaAssetActions()
const asset = createMockAsset({
id: 'job-temp',
Expand All @@ -1257,13 +1265,162 @@ describe('useMediaAssetActions', () => {
await actions.deleteAssets(asset)

await vi.waitFor(() => {
expect(vi.mocked(api.deleteItem)).toHaveBeenCalledWith(
'history',
'job-temp'
expect(vi.mocked(api.fetchApi)).toHaveBeenCalledWith(
'/history',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ delete: ['job-temp'] })
})
)
})
expect(mockGetJobAssetIds).not.toHaveBeenCalled()
expect(mockDeleteAsset).not.toHaveBeenCalled()
expect(mockUpdateHistory).toHaveBeenCalled()
expect(mockUpdateFlatOutputs).toHaveBeenCalled()
})
})

describe('deleteAssets — output asset records', () => {
beforeEach(() => {
mockGetAssetType.mockReturnValue('output')
mockShowDialog.mockImplementation(
(opts: { props: { onConfirm: () => Promise<void> | void } }) => {
void opts.props.onConfirm()
}
)
})

it.for([false, true])(
'deletes the history job and exact linked asset records when isCloud=%s',
async (cloud) => {
mockIsCloud.value = cloud
mockGetOutputAssetMetadata.mockReturnValue({ jobId: 'job-1' })
mockGetJobAssetIds.mockResolvedValue(['output-uuid-1', 'output-uuid-2'])
mockDeleteAsset.mockResolvedValue(undefined)
const actions = useMediaAssetActions()
const asset = createMockAsset({
id: 'job-1-node-1--generated.png',
name: 'generated.png',
hash: 'generated-content-hash',
tags: ['output'],
user_metadata: { jobId: 'job-1' }
})

await actions.deleteAssets(asset)

expect(vi.mocked(api.fetchApi)).toHaveBeenCalledWith(
'/history',
expect.objectContaining({
body: JSON.stringify({ delete: ['job-1'] })
})
)
expect(mockGetJobAssetIds).toHaveBeenCalledTimes(cloud ? 1 : 0)
expect(mockDeleteAsset).toHaveBeenCalledTimes(cloud ? 2 : 0)
if (cloud) {
expect(mockDeleteAsset).toHaveBeenCalledWith('output-uuid-1')
expect(mockDeleteAsset).toHaveBeenCalledWith('output-uuid-2')
expect(mockDeleteAsset.mock.invocationCallOrder[0]).toBeLessThan(
vi.mocked(api.fetchApi).mock.invocationCallOrder[0]
)
}
expect(mockUpdateFlatOutputs).toHaveBeenCalledOnce()
}
)

it('falls back to deleting history when linked asset lookup is unavailable', async () => {
mockIsCloud.value = true
mockGetJobAssetIds.mockResolvedValue([])
const actions = useMediaAssetActions()
const asset = createMockAsset({
id: 'job-1-node-1--missing.png',
name: 'missing.png',
tags: ['output']
})

await actions.deleteAssets(asset)

expect(mockDeleteAsset).not.toHaveBeenCalled()
expect(vi.mocked(api.fetchApi)).toHaveBeenCalledWith(
'/history',
expect.objectContaining({
body: JSON.stringify({ delete: ['job-1-node-1--missing.png'] })
})
)
expect(mockUpdateHistory).toHaveBeenCalled()
expect(mockUpdateFlatOutputs).toHaveBeenCalled()
})

it('keeps history when linked asset lookup fails', async () => {
mockIsCloud.value = true
mockGetJobAssetIds.mockRejectedValue(new Error('lookup failed'))
const actions = useMediaAssetActions()

await actions.deleteAssets(
createMockAsset({
id: 'job-1',
name: 'generated.png',
tags: ['output']
})
)

expect(vi.mocked(api.fetchApi)).not.toHaveBeenCalled()
expect(useToast().add).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'error' })
)
})

it('reports asset-record deletion failures and refreshes output stores', async () => {
mockIsCloud.value = true
mockGetOutputAssetMetadata.mockReturnValue({ jobId: 'job-1' })
mockGetJobAssetIds.mockResolvedValue(['output-uuid'])
mockDeleteAsset.mockRejectedValue(new Error('delete failed'))
const actions = useMediaAssetActions()
const asset = createMockAsset({
id: 'generated-card-id',
name: 'generated.png',
tags: ['output']
})

await actions.deleteAssets(asset)

expect(vi.mocked(api.fetchApi)).not.toHaveBeenCalled()
expect(mockUpdateHistory).toHaveBeenCalled()
expect(mockUpdateFlatOutputs).toHaveBeenCalled()
expect(useToast().add).toHaveBeenCalledWith(
expect.objectContaining({
severity: 'error',
detail: 'mediaAsset.failedToDeleteAsset'
})
)
})

it('reports a history deletion failure after deleting linked assets', async () => {
mockIsCloud.value = true
mockGetOutputAssetMetadata.mockReturnValue({ jobId: 'job-1' })
mockGetJobAssetIds.mockResolvedValue(['output-uuid'])
mockDeleteAsset.mockResolvedValue(undefined)
vi.mocked(api.fetchApi).mockResolvedValue(
fromAny({ ok: false, status: 500 })
)
const actions = useMediaAssetActions()

await actions.deleteAssets(
createMockAsset({
id: 'generated-card-id',
name: 'generated.png',
tags: ['output']
})
)

expect(mockDeleteAsset).toHaveBeenCalledWith('output-uuid')
expect(mockUpdateHistory).toHaveBeenCalled()
expect(mockUpdateFlatOutputs).toHaveBeenCalled()
expect(useToast().add).toHaveBeenCalledWith(
expect.objectContaining({
severity: 'error',
detail: 'mediaAsset.failedToDeleteAsset'
})
)
})
})

Expand Down
48 changes: 32 additions & 16 deletions src/platform/assets/composables/useMediaAssetActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,17 @@ import { assetService } from '../services/assetService'

const EXCLUDED_TAGS = new Set(['models', 'input', 'output'])

async function deleteHistoryJob(jobId: string): Promise<void> {
const response = await api.fetchApi('/history', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ delete: [jobId] })
})
if (!response.ok) {
throw new Error(`Unable to delete history job: ${response.status}`)
}
}

function createAssetWidgetPath(asset: AssetItem): string {
const metadata = getOutputAssetMetadata(asset.user_metadata)
const assetType = getAssetType(asset, 'input')
Expand Down Expand Up @@ -94,24 +105,24 @@ export function useMediaAssetActions() {
const litegraphService = useLitegraphService()
const nodeDefStore = useNodeDefStore()

/**
* Internal helper to perform the API deletion for a single asset
* Handles both output assets (via history API) and input assets (via asset service)
* @throws Error if deletion fails or is not allowed
*/
const deleteAssetApi = async (
asset: AssetItem,
assetType: string
): Promise<void> => {
// Temp files (e.g. preview-node outputs) are history-backed outputs that
// happen to live in the temp dir, so they delete via the history API too.
if (assetType === 'output' || assetType === 'temp') {
const jobId =
getOutputAssetMetadata(asset.user_metadata)?.jobId || asset.id
if (!jobId) {
throw new Error('Unable to extract job ID from asset')
}
await api.deleteItem('history', jobId)

const assetIds = isCloud ? await assetService.getJobAssetIds(jobId) : []
const results = await Promise.allSettled(
assetIds.map((id) => assetService.deleteAsset(id))
)
const failure = results.find((result) => result.status === 'rejected')
if (failure?.status === 'rejected') throw failure.reason
await deleteHistoryJob(jobId)
} else {
// Input assets can only be deleted in cloud environment
if (!isCloud) {
Expand Down Expand Up @@ -706,24 +717,29 @@ export function useMediaAssetActions() {
const failed = results.filter((r) => r.status === 'rejected')

// Log failed deletions for debugging
failed.forEach((result, index) => {
console.warn(
`Failed to delete asset ${assetArray[index].name}:`,
result.reason
)
results.forEach((result, index) => {
if (result.status === 'rejected') {
console.warn(
`Failed to delete asset ${assetArray[index].name}:`,
result.reason
)
}
})

// Update stores after deletions
const hasOutputAssets = assetArray.some((a) => {
const type = getAssetType(a)
const hasOutputAssets = assetArray.some((asset) => {
const type = getAssetType(asset)
return type === 'output' || type === 'temp'
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
const hasInputAssets = assetArray.some(
(a) => getAssetType(a) === 'input'
(asset, index) =>
results[index].status === 'fulfilled' &&
getAssetType(asset) === 'input'
)

if (hasOutputAssets) {
await assetsStore.updateHistory()
await assetsStore.updateFlatOutputs()
}
if (hasInputAssets) {
await assetsStore.updateInputs()
Expand Down
79 changes: 79 additions & 0 deletions src/platform/assets/services/assetService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,85 @@ describe(assetService.deleteAsset, () => {
expect.objectContaining({ method: 'DELETE' })
)
})

it('treats an already deleted asset as success', async () => {
fetchApiMock.mockResolvedValueOnce(
buildResponse(null, { ok: false, status: 404 })
)

await expect(assetService.deleteAsset('asset-1')).resolves.toBeUndefined()
})
})

describe(assetService.getJobAssetIds, () => {
beforeEach(() => {
vi.clearAllMocks()
})

it('returns every asset ID across offset pages', async () => {
fetchApiMock
.mockResolvedValueOnce(
buildResponse({
assets: [{ id: 'asset-1' }, { id: 'asset-2' }],
pagination: { offset: 0, limit: 500, total: 3, has_more: true }
})
)
.mockResolvedValueOnce(
buildResponse({
assets: [{ id: 'asset-3' }],
pagination: { offset: 2, limit: 500, total: 3, has_more: false }
})
)

await expect(assetService.getJobAssetIds('job/1')).resolves.toEqual([
'asset-1',
'asset-2',
'asset-3'
])

expect(fetchApiMock).toHaveBeenNthCalledWith(
1,
'/jobs/job%2F1/assets?limit=500&offset=0'
)
expect(fetchApiMock).toHaveBeenNthCalledWith(
2,
'/jobs/job%2F1/assets?limit=500&offset=2'
)
})

it('returns no assets when the job assets endpoint is unavailable', async () => {
fetchApiMock.mockResolvedValueOnce(
buildResponse(null, { ok: false, status: 404 })
)

await expect(assetService.getJobAssetIds('job-1')).resolves.toEqual([])
})

it('throws instead of returning an incomplete page without progress', async () => {
fetchApiMock.mockResolvedValueOnce(
buildResponse({
assets: [],
pagination: { offset: 0, limit: 500, total: 1, has_more: true }
})
)

await expect(assetService.getJobAssetIds('job-1')).rejects.toThrow(
'made no progress'
)
})

it('throws when the response offset does not match the requested offset', async () => {
fetchApiMock.mockResolvedValueOnce(
buildResponse({
assets: [{ id: 'asset-1' }],
pagination: { offset: 2, limit: 500, total: 3, has_more: true }
})
)

await expect(assetService.getJobAssetIds('job-1')).rejects.toThrow(
'Invalid job assets pagination offset'
)
})
})

describe(assetService.getAssetModels, () => {
Expand Down
Loading
Loading