diff --git a/frontend/shared/types/fetch-state.ts b/frontend/shared/types/fetch-state.ts index dd80cdc9e00..d12ad27f00c 100644 --- a/frontend/shared/types/fetch-state.ts +++ b/frontend/shared/types/fetch-state.ts @@ -27,9 +27,6 @@ export interface FetchingError { details?: Record } -export interface FetchState { - isFetching: boolean - hasStarted?: boolean - isFinished?: boolean - fetchingError: FetchingError | null -} +export type FetchState = + | { status: "idle" | "fetching" | "success"; error: null } + | { status: "error"; error: FetchingError } diff --git a/frontend/src/components/VCollectionHeader/VCollectionHeader.vue b/frontend/src/components/VCollectionHeader/VCollectionHeader.vue index 637a3c0e81f..805005563e5 100644 --- a/frontend/src/components/VCollectionHeader/VCollectionHeader.vue +++ b/frontend/src/components/VCollectionHeader/VCollectionHeader.vue @@ -92,7 +92,7 @@ const showCollectionExternalLink = computed(() => { const { getI18nCollectionResultCountLabel } = useI18nResultsCount() const resultsLabel = computed(() => { - if (mediaStore.resultCount === 0 && mediaStore.fetchState.isFetching) { + if (mediaStore.resultCount === 0 && mediaStore.isFetching) { return "" } const resultsCount = mediaStore.results[props.mediaType].count diff --git a/frontend/src/components/VHeader/VHeaderDesktop.vue b/frontend/src/components/VHeader/VHeaderDesktop.vue index f2681c96ec8..0cbab00465c 100644 --- a/frontend/src/components/VHeader/VHeaderDesktop.vue +++ b/frontend/src/components/VHeader/VHeaderDesktop.vue @@ -29,7 +29,7 @@ const uiStore = useUiStore() const isSidebarVisible = inject>(IsSidebarVisibleKey) -const isFetching = computed(() => mediaStore.fetchState.isFetching) +const isFetching = computed(() => mediaStore.isFetching) const { $sendCustomEvent } = useNuxtApp() diff --git a/frontend/src/components/VHeader/VHeaderMobile/VHeaderMobile.vue b/frontend/src/components/VHeader/VHeaderMobile/VHeaderMobile.vue index c8853b98527..47db615c77f 100644 --- a/frontend/src/components/VHeader/VHeaderMobile/VHeaderMobile.vue +++ b/frontend/src/components/VHeader/VHeaderMobile/VHeaderMobile.vue @@ -63,7 +63,7 @@ const isInputFocused = ref(false) const contentSettingsOpen = ref(false) const appliedFilterCount = computed(() => searchStore.appliedFilterCount) -const isFetching = computed(() => mediaStore.fetchState.isFetching) +const isFetching = computed(() => mediaStore.isFetching) /** * The selection range of the input field. Used to make sure that the cursor diff --git a/frontend/src/components/VMediaInfo/VRelatedMedia.vue b/frontend/src/components/VMediaInfo/VRelatedMedia.vue index 80253c682ee..00423be3df8 100644 --- a/frontend/src/components/VMediaInfo/VRelatedMedia.vue +++ b/frontend/src/components/VMediaInfo/VRelatedMedia.vue @@ -33,7 +33,7 @@ watch( { immediate: true } ) -const isFetching = computed(() => relatedMediaStore.fetchState.isFetching) +const isFetching = computed(() => relatedMediaStore.isFetching) const showRelated = computed( () => results.value.items.length > 0 || isFetching.value ) diff --git a/frontend/src/components/meta/CustomButtonComponents.stories.ts b/frontend/src/components/meta/CustomButtonComponents.stories.ts index 28b80280efb..fde4f8534c2 100644 --- a/frontend/src/components/meta/CustomButtonComponents.stories.ts +++ b/frontend/src/components/meta/CustomButtonComponents.stories.ts @@ -11,8 +11,8 @@ const Template: Story = { components: { VLoadMore }, setup() { const mediaStore = useMediaStore() - mediaStore._startFetching("image") - mediaStore.results.image.count = 1 + mediaStore.results.image.page = 1 + mediaStore.results.image.pageCount = 12 return () => h("div", { class: "flex p-4", id: "wrapper" }, [ h(VLoadMore, { diff --git a/frontend/src/composables/use-collection.ts b/frontend/src/composables/use-collection.ts index ffee63b4e59..208dcdf00da 100644 --- a/frontend/src/composables/use-collection.ts +++ b/frontend/src/composables/use-collection.ts @@ -18,7 +18,7 @@ export const useCollection = ({ const searchStore = useSearchStore() const collectionParams = computed(() => searchStore.collectionParams) - const isFetching = computed(() => mediaStore.fetchState.isFetching) + const isFetching = computed(() => mediaStore.isFetching) const media = ref(mediaStore.resultItems[mediaType]) as Ref const creatorUrl = ref() diff --git a/frontend/src/composables/use-search.ts b/frontend/src/composables/use-search.ts index b13bd7af1b3..7d6a53740b6 100644 --- a/frontend/src/composables/use-search.ts +++ b/frontend/src/composables/use-search.ts @@ -73,7 +73,7 @@ export const useSearch = ( return navigateTo(searchPath) } - const isFetching = computed(() => mediaStore.fetchState.isFetching) + const isFetching = computed(() => mediaStore.isFetching) const resultsCount = computed(() => mediaStore.resultCount) const { getI18nCount, getLoading } = useI18nResultsCount() diff --git a/frontend/src/middleware/search.ts b/frontend/src/middleware/search.ts index 182196ce3d5..9a14df112c6 100644 --- a/frontend/src/middleware/search.ts +++ b/frontend/src/middleware/search.ts @@ -49,7 +49,7 @@ export const searchMiddleware = defineNuxtRouteMiddleware(async (to) => { const mediaStore = useMediaStore() const results = await mediaStore.fetchMedia() - const fetchingError = mediaStore.fetchState.fetchingError + const fetchingError = mediaStore.fetchState.error if (!results.length && fetchingError && !handledClientSide(fetchingError)) { showError(createError(fetchingError)) } diff --git a/frontend/src/middleware/single-result.ts b/frontend/src/middleware/single-result.ts index d7d773ae3b9..2189475dbc8 100644 --- a/frontend/src/middleware/single-result.ts +++ b/frontend/src/middleware/single-result.ts @@ -38,7 +38,7 @@ export default defineNuxtRouteMiddleware(async (to, from) => { relatedMediaStore.fetchMedia(mediaType, mediaId), ]) - const fetchingError = singleResultStore.fetchState.fetchingError + const fetchingError = singleResultStore.fetchState.error if ( !singleResultStore.mediaItem && diff --git a/frontend/src/pages/audio/[id]/index.vue b/frontend/src/pages/audio/[id]/index.vue index 64127eeade6..706fd5d4ae7 100644 --- a/frontend/src/pages/audio/[id]/index.vue +++ b/frontend/src/pages/audio/[id]/index.vue @@ -64,7 +64,7 @@ const audio = ref( ? singleResultStore.audio : null ) -const fetchingError = computed(() => singleResultStore.fetchState.fetchingError) +const fetchingError = computed(() => singleResultStore.fetchState.error) const { isHidden, reveal } = useSensitiveMedia(audio.value) diff --git a/frontend/src/pages/image/[id]/index.vue b/frontend/src/pages/image/[id]/index.vue index d7a084b83bf..666e1da5c31 100644 --- a/frontend/src/pages/image/[id]/index.vue +++ b/frontend/src/pages/image/[id]/index.vue @@ -70,7 +70,7 @@ const image = ref( ? singleResultStore.image : null ) -const fetchingError = computed(() => singleResultStore.fetchState.fetchingError) +const fetchingError = computed(() => singleResultStore.fetchState.error) const isLoadingOnClient = computed( () => !(import.meta.server || nuxtApp.isHydrating) ) diff --git a/frontend/src/pages/search.vue b/frontend/src/pages/search.vue index 2065df09393..a80ab4a0989 100644 --- a/frontend/src/pages/search.vue +++ b/frontend/src/pages/search.vue @@ -49,7 +49,8 @@ const { apiSearchQueryParams: query, } = storeToRefs(searchStore) -const { fetchState } = storeToRefs(mediaStore) +const fetchingError = computed(() => mediaStore.fetchState.error) +const isFetching = computed(() => mediaStore.isFetching) const pageTitle = ref(`${searchTerm.value} | Openverse`) watch(searchTerm, () => { @@ -81,9 +82,7 @@ const fetchMedia = async (payload: { shouldPersistMedia?: boolean } = {}) => { * and there is an error status that will not change if retried, don't re-fetch. */ const shouldNotRefetch = - fetchState.value.hasStarted && - fetchingError.value !== null && - !isRetriable(fetchingError.value) + fetchingError.value !== null && !isRetriable(fetchingError.value) if (shouldNotRefetch) { return } @@ -100,9 +99,6 @@ const fetchMedia = async (payload: { shouldPersistMedia?: boolean } = {}) => { return fetchingError.value } -const fetchingError = computed(() => fetchState.value.fetchingError) -const isFetching = computed(() => fetchState.value.isFetching) - /** * This watcher fires even when the queries are equal. We update the path only * when the queries change. diff --git a/frontend/src/stores/media/index.ts b/frontend/src/stores/media/index.ts index 12275259aef..54c5654f507 100644 --- a/frontend/src/stores/media/index.ts +++ b/frontend/src/stores/media/index.ts @@ -12,7 +12,6 @@ import { } from "#shared/constants/media" import { NO_RESULT } from "#shared/constants/errors" import { hash, rand as prng } from "#shared/utils/prng" -import { isRetriable } from "#shared/utils/errors" import { deepFreeze } from "#shared/utils/deep-freeze" import type { AudioDetail, @@ -26,9 +25,6 @@ import { isSearchTypeSupported, useSearchStore } from "~/stores/search" import { useRelatedMediaStore } from "~/stores/media/related-media" import { useApiClient } from "~/composables/use-api-client" -interface SearchFetchState extends Omit { - hasStarted: boolean -} export type MediaStoreResult = { count: number pageCount: number @@ -42,8 +38,8 @@ export interface MediaState { image: MediaStoreResult } mediaFetchState: { - audio: SearchFetchState - image: SearchFetchState + audio: FetchState + image: FetchState } currentPage: number } @@ -58,6 +54,14 @@ export const initialResults = deepFreeze({ items: {}, }) as MediaStoreResult +const areMorePagesAvailable = ({ + page, + pageCount, +}: { + page: number + pageCount: number +}) => page < pageCount + export const useMediaStore = defineStore("media", { state: (): MediaState => ({ results: { @@ -65,18 +69,8 @@ export const useMediaStore = defineStore("media", { [IMAGE]: { ...initialResults }, }, mediaFetchState: { - [AUDIO]: { - isFetching: false, - hasStarted: false, - isFinished: false, - fetchingError: null, - }, - [IMAGE]: { - isFetching: false, - hasStarted: false, - isFinished: false, - fetchingError: null, - }, + [AUDIO]: { status: "idle", error: null }, + [IMAGE]: { status: "idle", error: null }, }, currentPage: 0, }), @@ -146,16 +140,8 @@ export const useMediaStore = defineStore("media", { * Search fetching state for selected search type. For 'All content', aggregates * the values for supported media types. */ - fetchState(): SearchFetchState { + fetchState(): FetchState { if (this._searchType === ALL_MEDIA) { - /** - * For all_media, we return 'All media fetching error' if all types have some kind of error. - */ - const atLeastOne = (property: keyof SearchFetchState) => - supportedMediaTypes.some( - (type) => this.mediaFetchState[type][property] - ) - /** * Returns a combined error for all media types. * @@ -190,26 +176,31 @@ export const useMediaStore = defineStore("media", { return results.length ? results[0] : null } - return { - isFetching: atLeastOne("isFetching"), - fetchingError: allMediaError(), - hasStarted: atLeastOne("hasStarted"), - isFinished: supportedMediaTypes.every( - (type) => this.mediaFetchState[type].isFinished - ), + const error = allMediaError() + if (error) { + return { status: "error", error } } + const status = supportedMediaTypes.some( + (type) => this.mediaFetchState[type].status === "fetching" + ) + ? "fetching" + : supportedMediaTypes.some( + (type) => this.mediaFetchState[type].status === "success" + ) + ? "success" + : "idle" + return { status, error: null } } else if (isSearchTypeSupported(this._searchType)) { return this.mediaFetchState[this._searchType] } else { - return { - isFetching: false, - fetchingError: null, - hasStarted: false, - isFinished: false, - } + return { status: "idle", error: null } } }, + isFetching(): boolean { + return this.fetchState.status === "fetching" + }, + /** * Returns a mixed bag of search results across media types. * @@ -277,35 +268,46 @@ export const useMediaStore = defineStore("media", { return newResults }, + + /** + * Returns an array of media types that can be fetched: + * - current media type, or all media types if the search type is ALL_MEDIA + * - are not currently fetching and don't have error + * - either the first page has not been fetched yet, or there are more pages available + */ _fetchableMediaTypes(): SupportedMediaType[] { return ( (this._searchType !== ALL_MEDIA ? [this._searchType] : [IMAGE, AUDIO]) as SupportedMediaType[] - ).filter( - (type) => - !this.mediaFetchState[type].fetchingError && - !this.mediaFetchState[type].isFetching && - !this.mediaFetchState[type].isFinished - ) + ).filter((type) => { + if (!["idle", "success"].includes(this.mediaFetchState[type].status)) { + return false + } + // Either the first page has not been fetched yet, or there are more pages available. + return ( + this.results[type].page < 1 || + areMorePagesAvailable(this.results[type]) + ) + }) }, + /** + * Used to display the load more button in the UI. + * For all the media types that can be fetched for the current search type + * (i.e., `image` for `image`, or [`image`, `audio`] for `ALL_MEDIA`), checks + * that the first page of the results has been fetched and that the API has more pages. + */ canLoadMore(): boolean { - return ( - this.fetchState.hasStarted && - !this.fetchState.fetchingError && - !this.fetchState.isFinished && - this.resultCount > 0 + return this._fetchableMediaTypes.every( + (type) => this.results[type].pageCount !== 0 ) }, }, actions: { _startFetching(mediaType: SupportedMediaType) { - this.mediaFetchState[mediaType].isFetching = true - this.mediaFetchState[mediaType].hasStarted = true - this.mediaFetchState[mediaType].isFinished = false - this.mediaFetchState[mediaType].fetchingError = null + this.mediaFetchState[mediaType] = { status: "fetching", error: null } }, /** * Called when the request is finished, regardless of whether it was successful or not. @@ -313,36 +315,22 @@ export const useMediaStore = defineStore("media", { * @param error - The string representation of the error, if any. */ _endFetching(mediaType: SupportedMediaType, error?: FetchingError) { - this.mediaFetchState[mediaType].fetchingError = error || null - this.mediaFetchState[mediaType].hasStarted = true - this.mediaFetchState[mediaType].isFetching = false - - if (error && !isRetriable(error)) { - this.mediaFetchState[mediaType].isFinished = true + if (error) { + this.mediaFetchState[mediaType] = { status: "error", error } + } else { + this.mediaFetchState[mediaType] = { status: "success", error: null } } }, - /** - * This is called when there are no more results available in the API for specific query. - * @param mediaType - The media type for which the request was made. - */ - _finishFetchingForQuery(mediaType: SupportedMediaType) { - this.mediaFetchState[mediaType].isFinished = true - this.mediaFetchState[mediaType].hasStarted = true - this.mediaFetchState[mediaType].isFetching = false - }, _resetFetchState() { for (const mediaType of supportedMediaTypes) { - this.mediaFetchState[mediaType].isFetching = false - this.mediaFetchState[mediaType].hasStarted = false - this.mediaFetchState[mediaType].isFinished = false - this.mediaFetchState[mediaType].fetchingError = null + this.mediaFetchState[mediaType] = { status: "idle", error: null } } }, - _updateFetchState( + updateFetchState( mediaType: SupportedMediaType, - action: "reset" | "start" | "end" | "finish", + action: "reset" | "start" | "end", error?: FetchingError ) { switch (action) { @@ -358,47 +346,52 @@ export const useMediaStore = defineStore("media", { this._endFetching(mediaType, error) break } - case "finish": { - this._finishFetchingForQuery(mediaType) - break - } } }, setMedia(params: { mediaType: T media: Record> - mediaCount?: number - page?: number + mediaCount: number + page: number pageCount: number shouldPersistMedia: boolean | undefined }) { const { mediaType, media, - mediaCount, + mediaCount: count, page, pageCount, shouldPersistMedia, } = params - let mediaToSet + let items if (shouldPersistMedia) { - mediaToSet = { ...this.results[mediaType].items, ...media } as Record< + items = { ...this.results[mediaType].items, ...media } as Record< string, DetailFromMediaType > } else { - mediaToSet = media + items = media } - const mediaPage = page || 1 - this.results[mediaType].items = mediaToSet - this.results[mediaType].count = mediaCount || 0 - this.results[mediaType].page = mediaCount === 0 ? 0 : mediaPage - this.results[mediaType].pageCount = pageCount - if (mediaPage >= pageCount) { - this._updateFetchState(mediaType, "finish") + // Edge case when the dead link filtering removed all results from subsequent pages: + // set the pageCount and the count to the current values. + if (page > 1 && count === 0) { + this.results[mediaType] = { + items, + count: Object.keys(items).length, + page: page - 1, + pageCount: page - 1, + } + } else { + this.results[mediaType] = { + items, + count, + page, + pageCount, + } } }, @@ -421,19 +414,12 @@ export const useMediaStore = defineStore("media", { const mediaType = this._searchType const shouldPersistMedia = Boolean(payload.shouldPersistMedia) - const mediaToFetch = this._fetchableMediaTypes - await Promise.allSettled( - mediaToFetch.map((mediaType) => + this._fetchableMediaTypes.map((mediaType) => this.fetchSingleMediaType({ mediaType, shouldPersistMedia }) ) ) - this.currentPage = - mediaType === ALL_MEDIA - ? this.currentPage + 1 - : this.results[mediaType].page - return mediaType === ALL_MEDIA ? this.allMedia : this.resultItems[mediaType] @@ -461,11 +447,12 @@ export const useMediaStore = defineStore("media", { const searchStore = useSearchStore() const queryParams = searchStore.getApiRequestQuery(mediaType) let page = this.results[mediaType].page + 1 + if (shouldPersistMedia) { queryParams.page = `${page}` } - this._updateFetchState(mediaType, "start") + this.updateFetchState(mediaType, "start") const { $sendCustomEvent, $processFetchingError } = useNuxtApp() @@ -505,7 +492,7 @@ export const useMediaStore = defineStore("media", { details: { searchTerm: queryParams.q ?? "" }, } } - this._updateFetchState(mediaType, "end", errorData) + this.updateFetchState(mediaType, "end", errorData) this.setMedia({ mediaType, @@ -515,13 +502,15 @@ export const useMediaStore = defineStore("media", { shouldPersistMedia, page, }) + + this.currentPage = page return mediaCount } catch (error: unknown) { const errorData = $processFetchingError(error, mediaType, "search", { searchTerm: queryParams.q ?? "", }) - this._updateFetchState(mediaType, "end", errorData) + this.updateFetchState(mediaType, "end", errorData) return null } @@ -546,7 +535,7 @@ export const useMediaStore = defineStore("media", { const getMediaErrors = (mediaFetchStates: MediaState["mediaFetchState"]) => { return supportedMediaTypes - .map((mediaType) => mediaFetchStates[mediaType].fetchingError) + .map((mediaType) => mediaFetchStates[mediaType].error) .filter((err): err is FetchingError => err !== null) } diff --git a/frontend/src/stores/media/related-media.ts b/frontend/src/stores/media/related-media.ts index 5d07d5a9ee6..925b608efd2 100644 --- a/frontend/src/stores/media/related-media.ts +++ b/frontend/src/stores/media/related-media.ts @@ -16,7 +16,7 @@ interface RelatedMediaState { export const useRelatedMediaStore = defineStore("related-media", { state: (): RelatedMediaState => ({ mainMediaId: null, - fetchState: { isFetching: false, hasStarted: false, fetchingError: null }, + fetchState: { status: "idle", error: null }, media: [], }), @@ -25,23 +25,22 @@ export const useRelatedMediaStore = defineStore("related-media", { (state) => (id: string): Media | undefined => state.media.find((item) => item.id === id), + isFetching: (state) => state.fetchState.status === "fetching", }, actions: { _endFetching(error?: FetchingError) { - this.fetchState.fetchingError = error || null - this.fetchState.hasStarted = true - this.fetchState.isFetching = false + if (error) { + this.fetchState = { status: "error", error } + } else { + this.fetchState = { status: "success", error: null } + } }, _startFetching() { - this.fetchState.isFetching = true - this.fetchState.hasStarted = true - this.fetchState.fetchingError = null + this.fetchState = { status: "fetching", error: null } }, _resetFetching() { - this.fetchState.isFetching = false - this.fetchState.hasStarted = false - this.fetchState.fetchingError = null + this.fetchState = { status: "idle", error: null } }, async fetchMedia(mediaType: SupportedMediaType, id: string) { diff --git a/frontend/src/stores/media/single-result.ts b/frontend/src/stores/media/single-result.ts index 52184de3658..8f4ef05e317 100644 --- a/frontend/src/stores/media/single-result.ts +++ b/frontend/src/stores/media/single-result.ts @@ -26,7 +26,7 @@ export const useSingleResultStore = defineStore("single-result", { mediaType: null, mediaId: null, mediaItem: null, - fetchState: { isFetching: false, fetchingError: null }, + fetchState: { status: "idle", error: null }, }), getters: { @@ -42,16 +42,19 @@ export const useSingleResultStore = defineStore("single-result", { } return null }, + isFetching: (state) => state.fetchState.status === "fetching", }, actions: { _endFetching(error?: FetchingError) { - this.fetchState.isFetching = false - this.fetchState.fetchingError = error || null + if (error) { + this.fetchState = { status: "error", error } + } else { + this.fetchState = { status: "success", error: null } + } }, _startFetching() { - this.fetchState.isFetching = true - this.fetchState.fetchingError = null + this.fetchState = { status: "fetching", error: null } }, _updateFetchState(action: "start" | "end", option?: FetchingError) { @@ -66,8 +69,7 @@ export const useSingleResultStore = defineStore("single-result", { this.mediaItem = null this.mediaType = null this.mediaId = null - this.fetchState.isFetching = false - this.fetchState.fetchingError = null + this.fetchState = { status: "idle", error: null } }, /** diff --git a/frontend/src/stores/provider.ts b/frontend/src/stores/provider.ts index e3bd51b5ab6..32a1bd9c2cd 100644 --- a/frontend/src/stores/provider.ts +++ b/frontend/src/stores/provider.ts @@ -46,7 +46,7 @@ export const useProviderStore = defineStore("provider", { [AUDIO]: [], [IMAGE]: [], }, - fetchState: { isFetching: false, fetchingError: null }, + fetchState: { status: "idle", error: null }, sourceNames: { [AUDIO]: [], [IMAGE]: [], @@ -55,13 +55,14 @@ export const useProviderStore = defineStore("provider", { actions: { _endFetching(error?: FetchingError) { - this.fetchState.fetchingError = error || null if (error) { - this.fetchState.isFinished = true + this.fetchState = { status: "error", error } + } else { + this.fetchState = { status: "success", error: null } } }, _startFetching() { - this.fetchState.isFetching = true + this.fetchState = { status: "fetching", error: null } }, _updateFetchState(action: "start" | "end", option?: FetchingError) { diff --git a/frontend/test/unit/specs/stores/media-store-fetching.spec.js b/frontend/test/unit/specs/stores/media-store-fetching.spec.js deleted file mode 100644 index dbac93d800a..00000000000 --- a/frontend/test/unit/specs/stores/media-store-fetching.spec.js +++ /dev/null @@ -1,222 +0,0 @@ -// @vitest-environment jsdom - -import { AxiosError } from "axios" -import { createPinia, setActivePinia } from "~~/test/unit/test-utils/pinia" - -import { AUDIO, IMAGE } from "#shared/constants/media" -import { useMediaStore } from "~/stores/media" -import { useSearchStore } from "~/stores/search" - -const mocks = vi.hoisted(() => { - return { - createApiClient: vi.fn(), - } -}) -vi.mock("~/data/api-service", async () => { - const actual = await vi.importActual("~/data/api-service") - return { - ...actual, - createApiClient: mocks.createApiClient, - } -}) - -const testResultItems = (mediaType) => - items(mediaType).reduce((acc, item) => { - acc[item.id] = item - return acc - }, {}) - -const testResult = (mediaType) => ({ - count: 10001, - items: testResultItems(mediaType), - page: 2, - pageCount: 20, -}) -const uuids = [ - "0dea3af1-27a4-4635-bab6-4b9fb76a59f5", - "32c22b5b-f2f9-47db-b64f-6b86c2431942", - "fd527776-00f8-4000-9190-724fc4f07346", - "81e551de-52ab-4852-90eb-bc3973c342a0", -] -const items = (mediaType) => - uuids.map((uuid, i) => ({ - id: uuid, - title: `${mediaType} ${i + 1}`, - creator: `creator ${i + 1}`, - tags: [], - })) - -vi.mock("#app/nuxt", async () => { - const original = await import("#app/nuxt") - return { - ...original, - useRuntimeConfig: vi.fn(() => ({ public: { deploymentEnv: "staging" } })), - useNuxtApp: vi.fn(() => ({ - $captureException: vi.fn(), - $sendCustomEvent: vi.fn(), - $processFetchingError: vi.fn(), - })), - tryUseNuxtApp: vi.fn(() => ({ - $config: { - public: { - deploymentEnv: "staging", - }, - }, - })), - } -}) - -describe("fetchMedia", () => { - beforeEach(() => { - setActivePinia(createPinia()) - mocks.createApiClient.mockRestore() - }) - - it("fetchMedia should fetch all supported media types from the API if search type is ALL_MEDIA", async () => { - const imageSearchMock = vi.fn(() => - Promise.resolve({ data: { results: [] }, eventPayload: {} }) - ) - const audioSearchMock = vi.fn(() => - Promise.resolve({ data: { results: [] }, eventPayload: {} }) - ) - mocks.createApiClient.mockImplementationOnce( - vi.fn(() => ({ search: imageSearchMock })) - ) - mocks.createApiClient.mockImplementationOnce( - vi.fn(() => ({ search: audioSearchMock })) - ) - const searchStore = useSearchStore() - searchStore.setSearchTerm("cat") - - const mediaStore = useMediaStore() - const media = await mediaStore.fetchMedia() - - expect(media).toEqual([]) - expect(mocks.createApiClient).toHaveBeenCalledTimes(2) - expect(imageSearchMock).toHaveBeenCalledTimes(1) - - expect(mocks.createApiClient).toHaveBeenCalledWith({ - accessToken: undefined, - fakeSensitive: false, - }) - expect(imageSearchMock).toHaveBeenCalledWith(IMAGE, { q: "cat" }) - expect(audioSearchMock).toHaveBeenCalledWith(AUDIO, { q: "cat" }) - }) - - it("fetchMedia should fetch only the specified media type from the API if search type is not ALL_MEDIA", async () => { - const searchMock = vi.fn(() => - Promise.resolve({ - data: { - result_count: 10000, - page: 1, - page_count: 50, - results: items(IMAGE), - }, - eventPayload: {}, - }) - ) - mocks.createApiClient.mockImplementationOnce( - vi.fn(() => ({ search: searchMock })) - ) - const searchStore = useSearchStore() - searchStore.setSearchTerm("cat") - searchStore.searchType = IMAGE - - const mediaStore = useMediaStore() - const media = await mediaStore.fetchMedia() - - expect(media.length).toEqual(4) - expect(searchMock).toHaveBeenCalledTimes(1) - expect(searchMock).toHaveBeenCalledWith("image", { q: "cat" }) - expect(mediaStore.currentPage).toEqual(1) - }) - - it("fetchMedia fetches the next page of results", async () => { - const searchMock = vi.fn(() => - Promise.resolve({ - data: { - result_count: 10000, - page: 1, - page_count: 50, - results: items(IMAGE), - }, - eventPayload: {}, - }) - ) - mocks.createApiClient.mockImplementationOnce( - vi.fn(() => ({ search: searchMock })) - ) - const searchStore = useSearchStore() - searchStore.setSearchTerm("cat") - searchStore.searchType = IMAGE - - const mediaStore = useMediaStore() - mediaStore.results.image = testResult(IMAGE) - await mediaStore.fetchMedia({ shouldPersistMedia: true }) - - expect(mediaStore.currentPage).toEqual(3) - expect(searchMock).toHaveBeenCalledWith("image", { page: "3", q: "cat" }) - }) - - it("fetchMedia handles rejected promises", async () => { - const searchMock = vi.fn(() => - Promise.reject(new AxiosError("Request failed", {})) - ) - mocks.createApiClient.mockImplementationOnce( - vi.fn(() => ({ search: searchMock })) - ) - - const searchStore = useSearchStore() - searchStore.setSearchTerm("cat") - searchStore.searchType = AUDIO - - const mediaStore = useMediaStore() - mediaStore.results.audio.items = items(AUDIO) - - expect(mediaStore.results.image.items).toEqual({}) - }) - - it("fetchSingleMediaType should fetch a single media from the API", async () => { - const searchMock = vi.fn(() => - Promise.resolve({ - data: { result_count: 10000, page: 1, page_count: 50, results: [] }, - eventPayload: {}, - }) - ) - mocks.createApiClient.mockImplementationOnce( - vi.fn(() => ({ search: searchMock })) - ) - const searchStore = useSearchStore() - searchStore.setSearchTerm("cat") - - const mediaStore = useMediaStore() - const media = await mediaStore.fetchSingleMediaType({ - mediaType: IMAGE, - shouldPersistMedia: false, - }) - - expect(media).toEqual(10000) - }) - - it("fetchSingleMediaType throws an error no results", async () => { - const searchMock = vi.fn(() => - Promise.resolve({ data: { result_count: 0 }, eventPayload: {} }) - ) - mocks.createApiClient.mockImplementationOnce( - vi.fn(() => ({ search: searchMock })) - ) - - const mediaStore = useMediaStore() - await mediaStore.fetchSingleMediaType({ - mediaType: IMAGE, - shouldPersistMedia: false, - }) - - expect(mediaStore.fetchState).toEqual({ - fetchingError: null, - hasStarted: true, - isFetching: false, - isFinished: false, - }) - }) -}) diff --git a/frontend/test/unit/specs/stores/media-store.spec.ts b/frontend/test/unit/specs/stores/media-store.spec.ts index 9c5402bf2c8..516b6ca97cc 100644 --- a/frontend/test/unit/specs/stores/media-store.spec.ts +++ b/frontend/test/unit/specs/stores/media-store.spec.ts @@ -1,4 +1,5 @@ -import { expect, describe, it, beforeEach } from "vitest" +import { expect, describe, it, beforeEach, vi } from "vitest" +import { AxiosError } from "axios" import { setActivePinia, createPinia } from "~~/test/unit/test-utils/pinia" import { image as imageObject } from "~~/test/unit/fixtures/image" @@ -13,11 +14,27 @@ import { import { NO_RESULT } from "#shared/constants/errors" import { ON } from "#shared/constants/feature-flag" import { deepClone } from "#shared/utils/clone" -import { ImageDetail, Media } from "#shared/types/media" -import { initialResults, useMediaStore } from "~/stores/media" +import type { ImageDetail, Media } from "#shared/types/media" +import { + initialResults, + type MediaStoreResult, + useMediaStore, +} from "~/stores/media" import { useSearchStore } from "~/stores/search" import { useFeatureFlagStore } from "~/stores/feature-flag" +const mocks = vi.hoisted(() => { + return { + createApiClient: vi.fn(), + } +}) +vi.mock("~/data/api-service", async () => { + const actual = await vi.importActual("~/data/api-service") + return { + ...actual, + createApiClient: mocks.createApiClient, + } +}) // Retrieve the type of the first argument to // useMediaStore.setMedia() type SetMediaParams = Parameters< @@ -61,11 +78,52 @@ const testResultItems = (mediaType: SupportedMediaType) => return acc }, {}) -const testResult = (mediaType: SupportedMediaType) => ({ - count: 240, - items: testResultItems(mediaType), - page: 2, - pageCount: 20, +const testResult = ( + mediaType: SupportedMediaType, + { page = 1 }: { page?: number } = {} +) => + ({ + count: 240, + items: testResultItems(mediaType), + page, + pageCount: 20, + }) as MediaStoreResult + +const apiResult = ( + mediaType: SupportedMediaType, + { + count = 240, + page = 1, + page_count = 12, + }: { count?: number; page?: number; page_count?: number } = {} +) => ({ + data: { + result_count: count, + results: count > 0 ? items(mediaType) : [], + page, + page_count, + }, + eventPayload: {}, +}) + +vi.mock("#app/nuxt", async () => { + const original = await import("#app/nuxt") + return { + ...original, + useRuntimeConfig: vi.fn(() => ({ public: { deploymentEnv: "staging" } })), + useNuxtApp: vi.fn(() => ({ + $captureException: vi.fn(), + $sendCustomEvent: vi.fn(), + $processFetchingError: vi.fn(), + })), + tryUseNuxtApp: vi.fn(() => ({ + $config: { + public: { + deploymentEnv: "staging", + }, + }, + })), + } }) describe("media store", () => { @@ -80,16 +138,12 @@ describe("media store", () => { }) expect(mediaStore.mediaFetchState).toEqual({ audio: { - fetchingError: null, - hasStarted: false, - isFetching: false, - isFinished: false, + error: null, + status: "idle", }, image: { - fetchingError: null, - hasStarted: false, - isFetching: false, - isFinished: false, + error: null, + status: "idle", }, }) }) @@ -125,8 +179,10 @@ describe("media store", () => { it("resultItems returns correct items", () => { const mediaStore = useMediaStore() - mediaStore.results.audio = testResult(AUDIO) - mediaStore.results.image = testResult(IMAGE) + mediaStore.results = { + audio: testResult(AUDIO), + image: testResult(IMAGE), + } expect(mediaStore.resultItems).toEqual({ [AUDIO]: audioItems, @@ -136,8 +192,10 @@ describe("media store", () => { it("allMedia returns correct items", () => { const mediaStore = useMediaStore() - mediaStore.results.audio = testResult(AUDIO) - mediaStore.results.image = testResult(IMAGE) + mediaStore.results = { + audio: testResult(AUDIO), + image: testResult(IMAGE), + } expect(mediaStore.allMedia).toEqual([ imageItems[0], @@ -186,79 +244,82 @@ describe("media store", () => { }) it.each` - searchType | audioError | fetchState - ${ALL_MEDIA} | ${{ code: NO_RESULT }} | ${{ - fetchingError: null, - hasStarted: true, - isFetching: true, - isFinished: false, -}} - ${ALL_MEDIA} | ${{ statusCode: 429 }} | ${{ - fetchingError: { - requestKind: "search", - statusCode: 429, - searchType: ALL_MEDIA, - }, - hasStarted: true, - isFetching: true, - isFinished: false, -}} - ${AUDIO} | ${{ statusCode: 429 }} | ${{ - fetchingError: { - requestKind: "search", - statusCode: 429, - searchType: AUDIO, - }, - hasStarted: true, - isFetching: false, - isFinished: true, -}} - ${IMAGE} | ${null} | ${{ - fetchingError: null, - hasStarted: true, - isFetching: true, - isFinished: false, -}} + searchType | audioError | fetchState + ${ALL_MEDIA} | ${{ code: NO_RESULT }} | ${{ error: null, status: "fetching" }} + ${ALL_MEDIA} | ${{ statusCode: 429 }} | ${{ error: { requestKind: "search", statusCode: 429, searchType: ALL_MEDIA }, status: "error" }} `( "fetchState for $searchType returns $fetchState", ({ searchType, audioError, fetchState }) => { const mediaStore = useMediaStore() const searchStore = useSearchStore() - searchStore.setSearchType(searchType) - const audioFetchError = audioError - ? { requestKind: "search", searchType: AUDIO, ...audioError } - : null - mediaStore._updateFetchState(AUDIO, "end", audioFetchError) - mediaStore._updateFetchState(IMAGE, "start") + searchStore.searchType = searchType + const audioFetchError = { + requestKind: "search", + searchType: AUDIO, + ...audioError, + } + mediaStore.updateFetchState(IMAGE, "start") + mediaStore.updateFetchState(AUDIO, "end", audioFetchError) expect(mediaStore.fetchState).toEqual(fetchState) } ) + it("fetchState for audio returns audio error", () => { + const mediaStore = useMediaStore() + const searchStore = useSearchStore() + searchStore.searchType = AUDIO + const error = { + requestKind: "search", + searchType: AUDIO, + statusCode: 429, + code: "ERR_UNKNOWN", + } as const + + mediaStore.updateFetchState(AUDIO, "end", error) + + expect(mediaStore.fetchState).toEqual({ status: "error", error }) + }) + it("fetchState for image is reset after audio error", () => { + const mediaStore = useMediaStore() + const searchStore = useSearchStore() + searchStore.setSearchType(AUDIO) + const error = { + requestKind: "search", + searchType: AUDIO, + statusCode: 429, + code: "ERR_UNKNOWN", + } as const + mediaStore.updateFetchState(AUDIO, "end", error) + + searchStore.setSearchType(IMAGE) + mediaStore.updateFetchState(IMAGE, "start") + + expect(mediaStore.fetchState).toEqual({ status: "fetching", error: null }) + }) + it("returns NO_RESULT error if all media types have NO_RESULT errors", () => { const mediaStore = useMediaStore() const searchStore = useSearchStore() searchStore.setSearchType(ALL_MEDIA) - mediaStore._updateFetchState(AUDIO, "end", { + mediaStore.updateFetchState(AUDIO, "end", { requestKind: "search", searchType: AUDIO, code: NO_RESULT, }) - mediaStore._updateFetchState(IMAGE, "end", { + mediaStore.updateFetchState(IMAGE, "end", { requestKind: "search", searchType: IMAGE, code: NO_RESULT, }) expect(mediaStore.fetchState).toEqual({ - fetchingError: { + error: { requestKind: "search", code: NO_RESULT, searchType: ALL_MEDIA, }, - hasStarted: true, - isFetching: false, - isFinished: true, + status: "error", }) }) @@ -266,14 +327,12 @@ describe("media store", () => { const mediaStore = useMediaStore() const searchStore = useSearchStore() searchStore.setSearchType(ALL_MEDIA) - mediaStore._updateFetchState(AUDIO, "end") - mediaStore._updateFetchState(IMAGE, "end") + mediaStore.updateFetchState(AUDIO, "end") + mediaStore.updateFetchState(IMAGE, "end") expect(mediaStore.fetchState).toEqual({ - fetchingError: null, - hasStarted: true, - isFetching: false, - isFinished: false, + error: null, + status: "success", }) }) @@ -282,7 +341,7 @@ describe("media store", () => { const searchStore = useSearchStore() searchStore.setSearchType(ALL_MEDIA) - mediaStore._updateFetchState(AUDIO, "end", { + mediaStore.updateFetchState(AUDIO, "end", { code: "NO_RESULT", message: "Error", requestKind: "search", @@ -290,7 +349,7 @@ describe("media store", () => { statusCode: 500, }) - mediaStore._updateFetchState(IMAGE, "end", { + mediaStore.updateFetchState(IMAGE, "end", { code: "NO_RESULT", message: "Error", requestKind: "search", @@ -299,16 +358,14 @@ describe("media store", () => { }) expect(mediaStore.fetchState).toEqual({ - fetchingError: { + error: { code: "NO_RESULT", message: "Error", requestKind: "search", searchType: ALL_MEDIA, statusCode: 500, }, - hasStarted: true, - isFetching: false, - isFinished: true, + status: "error", }) }) }) @@ -316,6 +373,7 @@ describe("media store", () => { describe("actions", () => { beforeEach(() => { setActivePinia(createPinia()) + vi.restoreAllMocks() }) it("setMedia updates state persisting images", () => { @@ -377,18 +435,21 @@ describe("media store", () => { it("setMedia updates state with default count and page", () => { const mediaStore = useMediaStore() - const img = imageItems[0] - mediaStore.results.image.items = { [img.id]: img } + const existingImg = imageItems[0] + const img = imageItems[1] + mediaStore.results.image.items = { [existingImg.id]: existingImg } const params: SetMediaParams = { media: { [img.id]: img }, mediaType: IMAGE, shouldPersistMedia: false, pageCount: 1, + mediaCount: 1, + page: 1, } mediaStore.setMedia(params) - expect(mediaStore.results.image.count).toEqual(0) + expect(mediaStore.results.image.count).toEqual(1) expect(mediaStore.results.image.page).toEqual(1) }) @@ -432,5 +493,135 @@ describe("media store", () => { hasLoaded, }) }) + + it("fetchMedia should fetch all supported media types from the API if search type is ALL_MEDIA", async () => { + const searchMock = vi.fn((mediaType: SupportedMediaType) => + Promise.resolve(apiResult(mediaType)) + ) + mocks.createApiClient.mockImplementation( + vi.fn(() => ({ search: searchMock })) + ) + const searchStore = useSearchStore() + searchStore.setSearchTerm("cat") + + const mediaStore = useMediaStore() + const media = await mediaStore.fetchMedia() + + expect(media.length).toEqual(6) + + expect(mocks.createApiClient).toHaveBeenCalledWith({ + accessToken: undefined, + fakeSensitive: false, + }) + + expect(searchMock.mock.calls).toEqual([ + [IMAGE, { q: "cat" }], + [AUDIO, { q: "cat" }], + ]) + }) + + it("fetchMedia should fetch only the specified media type from the API if search type is not ALL_MEDIA", async () => { + const searchMock = vi.fn((mediaType: SupportedMediaType) => + Promise.resolve(apiResult(mediaType)) + ) + mocks.createApiClient.mockImplementation( + vi.fn(() => ({ search: searchMock })) + ) + const searchStore = useSearchStore() + searchStore.setSearchTerm("cat") + searchStore.searchType = IMAGE + + const mediaStore = useMediaStore() + const media = await mediaStore.fetchMedia() + + expect(media.length).toEqual(4) + expect(searchMock).toHaveBeenCalledTimes(1) + expect(searchMock).toHaveBeenCalledWith("image", { q: "cat" }) + expect(mediaStore.currentPage).toEqual(1) + }) + + it("fetchMedia fetches the next page of results", async () => { + const searchMock = vi.fn((mediaType: SupportedMediaType) => + Promise.resolve(apiResult(mediaType, { page: 2 })) + ) + mocks.createApiClient.mockImplementationOnce( + vi.fn(() => ({ search: searchMock })) + ) + const searchStore = useSearchStore() + searchStore.searchTerm = "cat" + searchStore.searchType = IMAGE + + const mediaStore = useMediaStore() + mediaStore.results.image = testResult(IMAGE) + mediaStore.mediaFetchState.image = { status: "success", error: null } + + await mediaStore.fetchMedia({ shouldPersistMedia: true }) + + expect(searchMock).toHaveBeenCalledWith("image", { page: "2", q: "cat" }) + expect(mediaStore.currentPage).toEqual(2) + }) + + it("fetchMedia handles rejected promises", async () => { + mocks.createApiClient.mockImplementationOnce( + vi.fn(() => ({ + search: () => + Promise.reject(new AxiosError("Request failed", "ERR_UNKNOWN")), + })) + ) + + const searchStore = useSearchStore() + searchStore.setSearchTerm("cat") + searchStore.searchType = AUDIO + + const mediaStore = useMediaStore() + mediaStore.results.audio.items = testResultItems(AUDIO) + + expect(mediaStore.results.image.items).toEqual({}) + }) + + it("fetchSingleMediaType should fetch a single media from the API", async () => { + mocks.createApiClient.mockImplementationOnce( + vi.fn(() => ({ + search: (mediaType: SupportedMediaType) => + Promise.resolve(apiResult(mediaType)), + })) + ) + const searchStore = useSearchStore() + searchStore.setSearchTerm("cat") + + const mediaStore = useMediaStore() + const media = await mediaStore.fetchSingleMediaType({ + mediaType: IMAGE, + shouldPersistMedia: false, + }) + + expect(media).toEqual(240) + }) + + it("fetchSingleMediaType throws an error no results", async () => { + mocks.createApiClient.mockImplementationOnce( + vi.fn(() => ({ + search: () => Promise.resolve(apiResult(IMAGE, { count: 0 })), + })) + ) + + const mediaStore = useMediaStore() + await mediaStore.fetchSingleMediaType({ + mediaType: IMAGE, + shouldPersistMedia: false, + }) + + const imageFetchState = mediaStore.mediaFetchState.image + expect(imageFetchState).toEqual({ + status: "error", + error: { + code: NO_RESULT, + details: { searchTerm: "" }, + message: "No results found for ", + requestKind: "search", + searchType: IMAGE, + }, + }) + }) }) }) diff --git a/frontend/test/unit/specs/stores/provider.spec.ts b/frontend/test/unit/specs/stores/provider.spec.ts index 5f8ae4912ad..b4f17408080 100644 --- a/frontend/test/unit/specs/stores/provider.spec.ts +++ b/frontend/test/unit/specs/stores/provider.spec.ts @@ -41,10 +41,7 @@ describe("provider store", () => { it("sets the default state", () => { expect(providerStore.providers.audio.length).toEqual(0) expect(providerStore.providers.image.length).toEqual(0) - expect(providerStore.fetchState).toEqual({ - isFetching: false, - fetchingError: null, - }) + expect(providerStore.fetchState).toEqual({ status: "idle", error: null }) }) it.each` diff --git a/frontend/test/unit/specs/stores/single-result-store.spec.ts b/frontend/test/unit/specs/stores/single-result-store.spec.ts index 8cce22e5f5f..2ff413c85c1 100644 --- a/frontend/test/unit/specs/stores/single-result-store.spec.ts +++ b/frontend/test/unit/specs/stores/single-result-store.spec.ts @@ -1,5 +1,3 @@ -// @vitest-environment jsdom - import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import { createPinia, setActivePinia } from "~~/test/unit/test-utils/pinia" import { getAudioObj } from "~~/test/unit/fixtures/audio" @@ -79,8 +77,8 @@ describe("Media Item Store", () => { describe("state", () => { it("sets default state", () => { expect(singleResultStore.fetchState).toEqual({ - isFetching: false, - fetchingError: null, + status: "idle", + error: null, }) expect(singleResultStore.mediaItem).toEqual(null) expect(singleResultStore.mediaType).toEqual(null)