import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createPinia, setActivePinia } from 'pinia' import { createApiRouter, drainQueue, 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 {} })) const EVENTS_LIST = /^\/events\?/ const EVENTS_CREATE = (url, options) => url === '/events' && options?.method === 'POST' const EVENT_UPDATE = (url, options) => /^\/events\/[^/]+$/.test(url) && options?.method === 'PUT' // Past the 300 ms autosave debounce. async function settle() { await flush(2) await new Promise(resolve => setTimeout(resolve, 400)) await flush(2) } describe('events store — a new event is stored with its first entry', () => { let eventsStore beforeEach(async () => { setActivePinia(createPinia()) api = createApiRouter() vi.resetModules() const { db } = await import('src/db') await db.events.clear() const { useEventsStore } = await import('stores/events') const { useAuthStore } = await import('stores/auth') api.once(EVENTS_LIST, page([remoteEvent({ id: 'e1', title: 'vorhanden' })])) api.always(EVENTS_CREATE, (url, options) => Promise.resolve({ data: remoteEvent({ ...(options.body ?? {}) }) })) api.always(EVENT_UPDATE, (url, options) => Promise.resolve({ data: remoteEvent({ id: url.split('/').pop(), ...(options.body ?? {}) }) })) const authStore = useAuthStore() eventsStore = useEventsStore() authStore.currentUserId = 'remote-user-1' await flush() }) // A queue still sending when the test ends would hit the next test's API // mock; let it finish first. afterEach(async () => { eventsStore.closePanel() await drainQueue(eventsStore) }) it('opens the panel as a draft without creating an event', async () => { eventsStore.openPanel() await settle() expect(eventsStore.panelOpen).toBe(true) expect(eventsStore.editingEventId).toBeNull() expect(eventsStore.events).toHaveLength(1) expect(api.callsTo(EVENTS_CREATE)).toHaveLength(0) }) it('leaves nothing behind when the empty draft is closed', async () => { eventsStore.openPanel() await flush(2) eventsStore.closePanel() await settle() expect(eventsStore.events).toHaveLength(1) expect(api.callsTo(EVENTS_CREATE)).toHaveLength(0) const { db } = await import('src/db') expect(await db.events.count()).toBe(1) }) it('does not treat date or emotion alone as an entry', async () => { eventsStore.openPanel() await flush(2) eventsStore.ghostDate = '2024-06-01' eventsStore.ghostEmotion = 0.8 await settle() expect(eventsStore.editingEventId).toBeNull() expect(eventsStore.events).toHaveLength(1) expect(api.callsTo(EVENTS_CREATE)).toHaveLength(0) }) it('creates exactly one event with the first title and updates it afterwards', async () => { eventsStore.openPanel() await flush(2) eventsStore.ghostTitle = 'Erster Tag' await flush(2) expect(eventsStore.editingEventId).not.toBeNull() const createdId = eventsStore.editingEventId const created = eventsStore.events.find(event => event.id === createdId) expect(created).toBeDefined() expect(created.title).toBe('Erster Tag') expect(eventsStore.selectedEventId).toBe(createdId) eventsStore.ghostTitle = 'Erster Tag am Meer' eventsStore.ghostNote = 'Sonne' await settle() const creates = api.callsTo(EVENTS_CREATE) expect(creates).toHaveLength(1) expect(creates[0].options.body.title).toBe('Erster Tag') const updates = api.callsTo(EVENT_UPDATE).filter(call => call.url === `/events/${createdId}`) expect(updates.length).toBeGreaterThan(0) expect(updates.at(-1).options.body.title).toBe('Erster Tag am Meer') expect(updates.at(-1).options.body.note).toBe('Sonne') expect(eventsStore.events).toHaveLength(2) }) it('keeps the created event when the panel is closed', async () => { eventsStore.openPanel() await flush(2) eventsStore.ghostNote = 'nur eine Notiz' await flush(2) const createdId = eventsStore.editingEventId eventsStore.closePanel() await settle() expect(eventsStore.events.find(event => event.id === createdId)?.note).toBe('nur eine Notiz') const { db } = await import('src/db') expect(await db.events.get(createdId)).toBeDefined() }) it('starts each new draft clean, even after editing another event', async () => { eventsStore.openPanel('e1') await flush(2) expect(eventsStore.ghostTitle).toBe('vorhanden') eventsStore.openPanel() await flush(2) expect(eventsStore.ghostTitle).toBe('') expect(eventsStore.ghostNote).toBe('') expect(eventsStore.ghostMedia).toEqual([]) expect(eventsStore.ghostHasContent).toBe(false) expect(eventsStore.editingEventId).toBeNull() }) it('does not resurrect the existing event as a "Neues Event" copy', async () => { eventsStore.openPanel() await flush(2) eventsStore.closePanel() await settle() expect(eventsStore.events.map(event => event.title)).toEqual(['vorhanden']) }) }) describe('events store — drafts for the local demo user', () => { let eventsStore beforeEach(async () => { setActivePinia(createPinia()) api = createApiRouter() vi.resetModules() const { db } = await import('src/db') await db.events.clear() const { useEventsStore } = await import('stores/events') const { useAuthStore } = await import('stores/auth') const authStore = useAuthStore() eventsStore = useEventsStore() authStore.currentUserId = 'local-demo' await flush() }) // A queue still sending when the test ends would hit the next test's API // mock; let it finish first. afterEach(async () => { eventsStore.closePanel() await drainQueue(eventsStore) }) it('stores the draft locally with its first entry and never calls the API', async () => { eventsStore.openPanel() await flush(2) expect(eventsStore.events).toHaveLength(0) eventsStore.ghostLocation = 'Berlin' await settle() expect(eventsStore.events).toHaveLength(1) expect(eventsStore.events[0].location).toBe('Berlin') expect(eventsStore.saveState).toBe('saved') expect(api.callsTo(EVENTS_CREATE)).toHaveLength(0) const { db } = await import('src/db') expect(await db.events.count()).toBe(1) }) }) describe('events store — category, people, span and milestone', () => { let eventsStore beforeEach(async () => { setActivePinia(createPinia()) api = createApiRouter() vi.resetModules() const { db } = await import('src/db') await db.events.clear() await db.syncQueue.clear() const { useEventsStore } = await import('stores/events') const { useAuthStore } = await import('stores/auth') api.once(EVENTS_LIST, page([remoteEvent({ id: 'e1', title: 'Umzug', endDate: '2019-04-14', category: 'home', people: ['Lena'], milestone: true })])) api.always(EVENTS_CREATE, (url, options) => Promise.resolve({ data: remoteEvent({ ...(options.body ?? {}) }) })) api.always(EVENT_UPDATE, (url, options) => Promise.resolve({ data: remoteEvent({ id: url.split('/').pop(), ...(options.body ?? {}) }) })) const authStore = useAuthStore() eventsStore = useEventsStore() authStore.currentUserId = 'remote-user-1' await flush() }) afterEach(async () => { eventsStore.closePanel() await drainQueue(eventsStore) }) it('loads the fields from the API and puts them into the draft', async () => { const event = eventsStore.events.find(entry => entry.id === 'e1') expect(event.endDate).toBe('2019-04-14') expect(event.category).toBe('home') expect(event.people).toEqual(['Lena']) expect(event.milestone).toBe(true) eventsStore.openPanel('e1') await flush(2) expect(eventsStore.ghostEndDate).toBe('2019-04-14') expect(eventsStore.ghostCategory).toBe('home') expect(eventsStore.ghostPeople).toEqual(['Lena']) expect(eventsStore.ghostMilestone).toBe(true) }) it('a category or a person is an entry that stores the draft', async () => { eventsStore.openPanel() await flush(2) eventsStore.ghostMilestone = true await settle() // A flag alone is no content. expect(eventsStore.editingEventId).toBeNull() eventsStore.ghostCategory = 'travel' await settle() expect(eventsStore.editingEventId).not.toBeNull() const creates = api.callsTo(EVENTS_CREATE) expect(creates).toHaveLength(1) expect(creates[0].options.body.category).toBe('travel') expect(creates[0].options.body.milestone).toBe(true) expect(creates[0].options.body.people).toEqual([]) expect(creates[0].options.body.endDate).toBeNull() }) it('sends people, span and milestone on update and keeps them on the event', async () => { eventsStore.openPanel('e1') await flush(2) eventsStore.ghostPeople = ['Lena', 'Papa'] eventsStore.ghostEndDate = '2019-04-20' eventsStore.ghostMilestone = false await settle() await drainQueue(eventsStore) const puts = api.callsTo(EVENT_UPDATE) const last = puts[puts.length - 1].options.body expect(last.people).toEqual(['Lena', 'Papa']) expect(last.endDate).toBe('2019-04-20') expect(last.milestone).toBe(false) const event = eventsStore.events.find(entry => entry.id === 'e1') expect(event.people).toEqual(['Lena', 'Papa']) expect(event.endDate).toBe('2019-04-20') expect(event.milestone).toBe(false) const { db } = await import('src/db') expect((await db.events.get('e1')).people).toEqual(['Lena', 'Papa']) }) })