diff --git a/frontend/src/stores/admin.js b/frontend/src/stores/admin.js index 3b05893..86cd059 100644 --- a/frontend/src/stores/admin.js +++ b/frontend/src/stores/admin.js @@ -7,186 +7,114 @@ export const useAdminStore = defineStore('admin', () => { const api = useApi() const lastError = ref(null) - // --- Tier-C: artist cascade --------------------------------------- - - async function projectArtistCascade(slug) { + // Every admin action runs through _guard: reset lastError, run the call, + // surface its message to lastError on failure, and re-throw so callers still + // see the rejection. One copy of the capture/rethrow instead of 13. + async function _guard(fn) { lastError.value = null try { - return await api.post( - `/api/admin/artists/${encodeURIComponent(slug)}/cascade-delete`, - { body: { dry_run: true } }, - ) + return await fn() } catch (e) { lastError.value = e.message throw e } } - async function dispatchArtistCascade(slug, confirm) { - lastError.value = null - try { - return await api.post( - `/api/admin/artists/${encodeURIComponent(slug)}/cascade-delete`, - { body: { dry_run: false, confirm } }, - ) - } catch (e) { - lastError.value = e.message - throw e - } + // The Tier-A maintenance endpoints share one shape: POST a dry_run flag + // (default the SAFE preview), optionally with extra body fields. The backend's + // same predicate drives preview + apply, so the UI just toggles dryRun. + function _dryRunPost(url, { dryRun = true, ...extra } = {}) { + return _guard(() => api.post(url, { body: { dry_run: dryRun, ...extra } })) + } + + // --- Tier-C: artist cascade --------------------------------------- + + function projectArtistCascade(slug) { + return _guard(() => api.post( + `/api/admin/artists/${encodeURIComponent(slug)}/cascade-delete`, + { body: { dry_run: true } }, + )) + } + + function dispatchArtistCascade(slug, confirm) { + return _guard(() => api.post( + `/api/admin/artists/${encodeURIComponent(slug)}/cascade-delete`, + { body: { dry_run: false, confirm } }, + )) } // --- Tier-C: bulk image delete ------------------------------------ - async function projectBulkImageDelete(imageIds) { - lastError.value = null - try { - return await api.post( - '/api/admin/images/bulk-delete', - { body: { image_ids: imageIds, dry_run: true } }, - ) - } catch (e) { - lastError.value = e.message - throw e - } + function projectBulkImageDelete(imageIds) { + return _guard(() => api.post( + '/api/admin/images/bulk-delete', + { body: { image_ids: imageIds, dry_run: true } }, + )) } - async function dispatchBulkImageDelete(imageIds, confirm) { - lastError.value = null - try { - return await api.post( - '/api/admin/images/bulk-delete', - { body: { image_ids: imageIds, dry_run: false, confirm } }, - ) - } catch (e) { - lastError.value = e.message - throw e - } + function dispatchBulkImageDelete(imageIds, confirm) { + return _guard(() => api.post( + '/api/admin/images/bulk-delete', + { body: { image_ids: imageIds, dry_run: false, confirm } }, + )) } // --- Tier-B: tag delete + merge ----------------------------------- - async function deleteTag(tagId) { - lastError.value = null - try { - return await api.delete(`/api/admin/tags/${tagId}`) - } catch (e) { - lastError.value = e.message - throw e - } + function deleteTag(tagId) { + return _guard(() => api.delete(`/api/admin/tags/${tagId}`)) } - async function mergeTags(destId, sourceId) { - lastError.value = null - try { - return await api.post( - `/api/admin/tags/${destId}/merge`, - { body: { source_id: sourceId } }, - ) - } catch (e) { - lastError.value = e.message - throw e - } + function mergeTags(destId, sourceId) { + return _guard(() => api.post( + `/api/admin/tags/${destId}/merge`, + { body: { source_id: sourceId } }, + )) } async function tagUsageCount(tagId) { - lastError.value = null - try { + return _guard(async () => { const body = await api.get(`/api/admin/tags/${tagId}/usage-count`) return body.count - } catch (e) { - lastError.value = e.message - throw e - } + }) } - // --- Tier-A: prune unused ----------------------------------------- + // --- Tier-A: dry-run/apply maintenance ---------------------------- - async function pruneUnusedTags({ dryRun = true } = {}) { - lastError.value = null - try { - return await api.post( - '/api/admin/tags/prune-unused', - { body: { dry_run: dryRun } }, - ) - } catch (e) { - lastError.value = e.message - throw e - } + function pruneUnusedTags(opts = {}) { + return _dryRunPost('/api/admin/tags/prune-unused', opts) } - // Tier-A: delete bare posts (no images + no attachments) — the empty-post - // flood shells. Preview/apply parity on the backend; UI previews then confirms. - async function pruneBarePosts({ dryRun = true } = {}) { - lastError.value = null - try { - return await api.post( - '/api/admin/posts/prune-bare', - { body: { dry_run: dryRun } }, - ) - } catch (e) { - lastError.value = e.message - throw e - } + // Delete bare posts (no images + no attachments) — the empty-post flood + // shells. Preview/apply parity on the backend; UI previews then confirms. + function pruneBarePosts(opts = {}) { + return _dryRunPost('/api/admin/posts/prune-bare', opts) } - // Tier-A: unify duplicate post rows (gallery-dl attachment-id + native post-id - // for the same real post) onto one post-id-keyed keeper. Images untouched. - // Preview/apply parity on the backend; UI previews then confirms. - async function reconcileDuplicatePosts({ dryRun = true, sourceId = null } = {}) { - lastError.value = null - try { - return await api.post( - '/api/admin/posts/reconcile-duplicates', - { body: { dry_run: dryRun, source_id: sourceId } }, - ) - } catch (e) { - lastError.value = e.message - throw e - } + // Unify duplicate post rows (gallery-dl attachment-id + native post-id for the + // same real post) onto one post-id-keyed keeper. Images untouched. sourceId + // (optional) scopes to one source; sent as source_id in the body. + function reconcileDuplicatePosts({ sourceId = null, ...opts } = {}) { + return _dryRunPost('/api/admin/posts/reconcile-duplicates', { + ...opts, source_id: sourceId, + }) } - async function purgeLegacyTags({ dryRun = true } = {}) { - lastError.value = null - try { - return await api.post( - '/api/admin/tags/purge-legacy', - { body: { dry_run: dryRun } }, - ) - } catch (e) { - lastError.value = e.message - throw e - } + function purgeLegacyTags(opts = {}) { + return _dryRunPost('/api/admin/tags/purge-legacy', opts) } // Destructive: deletes ALL general + character tags so the operator can // re-tag from scratch via auto-suggest. fandom + series preserved. - async function resetContentTagging({ dryRun = true } = {}) { - lastError.value = null - try { - return await api.post( - '/api/admin/tags/reset-content', - { body: { dry_run: dryRun } }, - ) - } catch (e) { - lastError.value = e.message - throw e - } + function resetContentTagging(opts = {}) { + return _dryRunPost('/api/admin/tags/reset-content', opts) } // #714: Title-Case the back-catalog + merge case/whitespace-variant tags. // dry-run returns a projection inline; live returns {task_id} (long op — // FK repoints) the caller polls via pollTaskUntilDone. - async function normalizeTags({ dryRun = true } = {}) { - lastError.value = null - try { - return await api.post( - '/api/admin/tags/normalize', - { body: { dry_run: dryRun } }, - ) - } catch (e) { - lastError.value = e.message - throw e - } + function normalizeTags(opts = {}) { + return _dryRunPost('/api/admin/tags/normalize', opts) } // --- Task progress polling (taps FC-3i activity dashboard) -------- diff --git a/frontend/test/adminStore.spec.js b/frontend/test/adminStore.spec.js new file mode 100644 index 0000000..2dcbce8 --- /dev/null +++ b/frontend/test/adminStore.spec.js @@ -0,0 +1,85 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { setActivePinia, createPinia } from 'pinia' +import { useAdminStore } from '../src/stores/admin.js' + +// Covers the two helpers the admin store actions route through (DRY Finding C, +// #753): _dryRunPost (URL + dry_run body, sourceId→source_id) and _guard +// (lastError capture + rethrow). The store had no frontend test before. + +function stubFetch(handler) { + globalThis.fetch = vi.fn(async (url, init) => { + const { status, body } = handler(url, init) + return { + ok: status >= 200 && status < 300, + status, + statusText: String(status), + text: async () => (body == null ? '' : JSON.stringify(body)), + } + }) +} + +function lastCallBody(calls) { + return JSON.parse(calls.at(-1).init.body) +} + +describe('admin store', () => { + beforeEach(() => setActivePinia(createPinia())) + afterEach(() => vi.restoreAllMocks()) + + it('_dryRunPost sends the apply flag to the right endpoint', async () => { + const s = useAdminStore() + const calls = [] + stubFetch((url, init) => { + calls.push({ url, init }) + return { status: 200, body: { deleted: 0 } } + }) + await s.pruneUnusedTags({ dryRun: false }) + const c = calls.at(-1) + expect(c.url).toContain('/api/admin/tags/prune-unused') + expect(c.init.method).toBe('POST') + expect(lastCallBody(calls)).toEqual({ dry_run: false }) + }) + + it('_dryRunPost defaults to the safe preview', async () => { + const s = useAdminStore() + const calls = [] + stubFetch((url, init) => { + calls.push({ url, init }) + return { status: 200, body: { count: 0 } } + }) + await s.pruneBarePosts() + expect(lastCallBody(calls)).toEqual({ dry_run: true }) + }) + + it('reconcileDuplicatePosts maps sourceId → source_id', async () => { + const s = useAdminStore() + const calls = [] + stubFetch((url, init) => { + calls.push({ url, init }) + return { status: 200, body: { groups: 0 } } + }) + await s.reconcileDuplicatePosts({ dryRun: false, sourceId: 42 }) + const c = calls.at(-1) + expect(c.url).toContain('/api/admin/posts/reconcile-duplicates') + expect(lastCallBody(calls)).toEqual({ dry_run: false, source_id: 42 }) + }) + + it('_guard captures the error message on lastError and rethrows', async () => { + const s = useAdminStore() + stubFetch(() => ({ status: 500, body: { error: 'boom' } })) + await expect(s.deleteTag(7)).rejects.toThrow('boom') + expect(s.lastError).toBe('boom') + }) + + it('_guard clears lastError on a subsequent success', async () => { + const s = useAdminStore() + stubFetch(() => ({ status: 500, body: { error: 'boom' } })) + await expect(s.deleteTag(7)).rejects.toThrow() + expect(s.lastError).toBe('boom') + + stubFetch(() => ({ status: 200, body: { count: 3 } })) + const n = await s.tagUsageCount(7) + expect(n).toBe(3) // tagUsageCount returns body.count, not the raw body + expect(s.lastError).toBe(null) + }) +})