import { beforeEach, describe, expect, it, vi } from 'vitest' import { createPinia, setActivePinia } from 'pinia' import { defineComponent, h } from 'vue' import { mount } from '@vue/test-utils' import { routerKey } from 'vue-router' import { createApiRouter, flush, page, remoteEvent } from './helpers' let api = createApiRouter() vi.mock('src/services/apiClient', () => ({ API_BASE: 'http://api.test', apiJson: (...args) => api.apiJson(...args), apiFetch: vi.fn(), getToken: vi.fn(async () => 'token'), setToken: vi.fn(async () => {}), clearToken: vi.fn(async () => {}), onUnauthorized: vi.fn(() => () => {}), revokeToken: vi.fn(async () => {}), loginRemote: vi.fn(), ApiError: class ApiError extends Error {} })) // Static imports on purpose: Quasar installs `$q` per mounted app, and a // module reset between tests would leave the stores and the components with // different copies of the same singletons. A fresh Pinia per test isolates // the store state. import { db } from 'src/db' import { useEventsStore } from 'stores/events' import { useSettingsStore } from 'stores/settings' import { useAuthStore } from 'stores/auth' import { useStoreFeedback } from 'composables/useStoreFeedback' import EventPanel from 'components/EventPanel.vue' const EVENTS_LIST = /^\/events\?/ function fakeRouter(path = '/') { return { currentRoute: { value: { path } }, replace: vi.fn() } } /** * A component that does nothing but run the composable. It stands in for the * layout, which owns a WebGL scene and cannot be mounted in a DOM-only * environment. */ const FeedbackHost = defineComponent({ setup() { useStoreFeedback() return () => h('div') } }) describe('useStoreFeedback — store failures become notifications', () => { let notify let eventsStore let settingsStore let authStore let router beforeEach(async () => { setActivePinia(createPinia()) api = createApiRouter() await db.events.clear() eventsStore = useEventsStore() settingsStore = useSettingsStore() authStore = useAuthStore() router = fakeRouter('/') const wrapper = mount(FeedbackHost, { global: { provide: { [routerKey]: router } } }) // `$q.notify` is looked up on each call, so spying on the mounted app's // `$q` intercepts what the composable sends. notify = vi.spyOn(wrapper.vm.$q, 'notify').mockImplementation(() => () => {}) }) it('shows a write failure as a negative notification', async () => { eventsStore.lastError = 'Das Event konnte nicht gespeichert werden.' await flush(1) expect(notify).toHaveBeenCalledTimes(1) expect(notify.mock.calls[0][0]).toMatchObject({ type: 'negative', message: 'Das Event konnte nicht gespeichert werden.' }) }) it('shows a load failure as a warning', async () => { eventsStore.loadError = 'Die Events konnten nicht geladen werden.' await flush(1) expect(notify.mock.calls[0][0]).toMatchObject({ type: 'warning' }) }) it('shows a settings failure as a warning', async () => { settingsStore.lastError = 'Die Einstellungen konnten nicht gespeichert werden.' await flush(1) expect(notify.mock.calls[0][0]).toMatchObject({ type: 'warning', message: 'Die Einstellungen konnten nicht gespeichert werden.' }) }) it('consumes the message so the identical error notifies again', async () => { eventsStore.lastError = 'Gleiche Meldung' await flush(1) expect(eventsStore.lastError).toBeNull() eventsStore.lastError = 'Gleiche Meldung' await flush(1) expect(notify).toHaveBeenCalledTimes(2) }) it('does not notify when the error is cleared', async () => { eventsStore.lastError = null await flush(1) expect(notify).not.toHaveBeenCalled() }) it('redirects to /login once the session has ended', async () => { authStore.currentUserId = 'remote-user-1' await flush(1) expect(router.replace).not.toHaveBeenCalled() authStore.currentUserId = null await flush(1) expect(router.replace).toHaveBeenCalledWith('/login') }) it('does not redirect when already on /login', async () => { router.currentRoute.value.path = '/login' authStore.currentUserId = 'remote-user-1' await flush(1) authStore.currentUserId = null await flush(1) expect(router.replace).not.toHaveBeenCalled() }) }) describe('EventPanel — the save state is visible', () => { let eventsStore beforeEach(async () => { setActivePinia(createPinia()) api = createApiRouter() await db.events.clear() api.once(EVENTS_LIST, page([remoteEvent({ id: 'e1', title: 'Event A' })])) api.always(/^\/events\/e1$/, (url, options) => Promise.resolve({ data: remoteEvent({ id: 'e1', ...(options.body ?? {}) }) })) eventsStore = useEventsStore() useAuthStore().currentUserId = 'remote-user-1' await flush() }) function mountPanel() { return mount(EventPanel, { global: { stubs: { // Not under test, and they pull in portals and carousels. QDialog: true, QCarousel: true, QCarouselSlide: true, QPopupProxy: true, QDate: true } } }) } it('renders nothing about saving on a freshly opened panel', async () => { eventsStore.openPanel('e1') const wrapper = mountPanel() await flush(1) expect(wrapper.find('.event-panel__save-state').exists()).toBe(false) }) it('shows "Gespeichert" after a successful autosave', async () => { eventsStore.openPanel('e1') const wrapper = mountPanel() await flush(1) eventsStore.ghostTitle = 'geändert' await flush(1) eventsStore.saveGhostNow() await flush() const state = wrapper.find('.event-panel__save-state') expect(state.exists()).toBe(true) expect(state.text()).toBe('Gespeichert') expect(state.classes()).toContain('event-panel__save-state--saved') }) it('shows "Nicht gespeichert" when the server rejects the write', async () => { api = createApiRouter() api.always(/^\/events\/e1$/, () => Promise.reject( Object.assign(new Error('unprocessable'), { status: 422 }) )) eventsStore.openPanel('e1') const wrapper = mountPanel() await flush(1) eventsStore.ghostTitle = 'abgelehnt' await flush(1) eventsStore.saveGhostNow() await flush() const state = wrapper.find('.event-panel__save-state') expect(state.find('span').text()).toBe('Nicht gespeichert') expect(state.classes()).toContain('event-panel__save-state--error') expect(state.attributes('role')).toBe('status') // A failed write offers a way out. expect(state.find('.event-panel__save-retry').text()).toBe('Erneut versuchen') }) it('shows "Wird gespeichert …" while the request is in flight', async () => { let resolveWrite api = createApiRouter() api.always(/^\/events\/e1$/, () => new Promise((resolve) => { resolveWrite = resolve })) eventsStore.openPanel('e1') const wrapper = mountPanel() await flush(1) eventsStore.ghostTitle = 'unterwegs' await flush(1) eventsStore.saveGhostNow() // The write leaves through the queue, which takes a few IndexedDB turns. await flush() expect(wrapper.find('.event-panel__save-state').text()).toBe('Wird gespeichert …') expect(resolveWrite).toBeTypeOf('function') resolveWrite({ data: remoteEvent({ id: 'e1', title: 'unterwegs' }) }) await flush() expect(wrapper.find('.event-panel__save-state').text()).toBe('Gespeichert') }) it('starts clean again when another event is opened', async () => { eventsStore.openPanel('e1') const wrapper = mountPanel() await flush(1) eventsStore.ghostTitle = 'geändert' await flush(1) eventsStore.saveGhostNow() await flush() expect(wrapper.find('.event-panel__save-state').text()).toBe('Gespeichert') eventsStore.openPanel('e1') await flush(1) expect(wrapper.find('.event-panel__save-state').exists()).toBe(false) }) })