From 7bfd700671a4be5322a767e47c42e0a9bba8e74b Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 18 May 2026 20:15:42 -0400 Subject: [PATCH] feat(provenance): provenance store (cached image/post lookups) Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/src/stores/provenance.js | 44 ++++++++++++++++++++++ frontend/test/provenance.spec.js | 62 +++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 frontend/src/stores/provenance.js create mode 100644 frontend/test/provenance.spec.js diff --git a/frontend/src/stores/provenance.js b/frontend/src/stores/provenance.js new file mode 100644 index 0000000..c87680a --- /dev/null +++ b/frontend/src/stores/provenance.js @@ -0,0 +1,44 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { useApi } from '../composables/useApi.js' + +// Provenance is its own system — this store is deliberately independent +// of the modal/tags stores (see project_provenance_separation). +export const useProvenanceStore = defineStore('provenance', () => { + const api = useApi() + + const imageCache = ref({}) // id -> { loading, error, entries|null } + const postCache = ref({}) // id -> { loading, error, data|null } + + async function loadForImage(id) { + const cur = imageCache.value[id] + if (cur && (cur.loading || cur.entries !== null)) return + imageCache.value[id] = { loading: true, error: null, entries: null } + try { + const body = await api.get(`/api/provenance/image/${id}`) + imageCache.value[id] = + { loading: false, error: null, entries: body.provenance } + } catch (e) { + imageCache.value[id] = + { loading: false, error: e.message, entries: null } + } + } + + async function loadForPost(id) { + const cur = postCache.value[id] + if (cur && (cur.loading || cur.data !== null)) return + postCache.value[id] = { loading: true, error: null, data: null } + try { + const body = await api.get(`/api/provenance/post/${id}`) + postCache.value[id] = { loading: false, error: null, data: body } + } catch (e) { + postCache.value[id] = { loading: false, error: e.message, data: null } + } + } + + function imageProv(id) { return imageCache.value[id] || null } + function postInfo(id) { return postCache.value[id] || null } + + return { imageCache, postCache, loadForImage, loadForPost, + imageProv, postInfo } +}) diff --git a/frontend/test/provenance.spec.js b/frontend/test/provenance.spec.js new file mode 100644 index 0000000..7ade1c7 --- /dev/null +++ b/frontend/test/provenance.spec.js @@ -0,0 +1,62 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { setActivePinia, createPinia } from 'pinia' +import { useProvenanceStore } from '../src/stores/provenance.js' + +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)) + } + }) +} + +describe('provenance store', () => { + beforeEach(() => setActivePinia(createPinia())) + afterEach(() => vi.restoreAllMocks()) + + it('loadForImage caches: repeat same id does not refetch', async () => { + const store = useProvenanceStore() + stubFetch(() => ({ status: 200, body: { image_id: 5, provenance: [] } })) + await store.loadForImage(5) + await store.loadForImage(5) + expect(globalThis.fetch.mock.calls.length).toBe(1) + expect(store.imageProv(5).entries).toEqual([]) + }) + + it('loadForImage fetches distinct ids separately', async () => { + const store = useProvenanceStore() + stubFetch(() => ({ + status: 200, + body: { image_id: 1, provenance: [{ provenance_id: 9 }] } + })) + await store.loadForImage(1) + await store.loadForImage(2) + expect(globalThis.fetch.mock.calls.length).toBe(2) + expect(store.imageProv(1).entries.length).toBe(1) + }) + + it('loadForImage records error without throwing', async () => { + const store = useProvenanceStore() + stubFetch(() => ({ status: 404, body: { error: 'not found' } })) + await store.loadForImage(7) + expect(store.imageProv(7).error).toBeTruthy() + expect(store.imageProv(7).entries).toBe(null) + }) + + it('loadForPost caches and stores post/source/artist', async () => { + const store = useProvenanceStore() + stubFetch(() => ({ + status: 200, + body: { post: { id: 3 }, source: { platform: 'patreon' }, + artist: { name: 'A', slug: 'a' } } + })) + await store.loadForPost(3) + await store.loadForPost(3) + expect(globalThis.fetch.mock.calls.length).toBe(1) + expect(store.postInfo(3).data.artist.slug).toBe('a') + }) +})