import { describe, it, expect, vi, beforeEach } from 'vitest' import { ref, computed, readonly } from 'vue' // --- Nuxt auto-import stubs --- vi.stubGlobal('computed', computed) vi.stubGlobal('readonly', readonly) // useState: return a fresh ref per key, reset between tests const stateStore = {} vi.stubGlobal('useState', (key, init) => { if (!stateStore[key]) { stateStore[key] = ref(init ? init() : null) } return stateStore[key] }) function resetState() { for (const key of Object.keys(stateStore)) { delete stateStore[key] } } // $fetch mock const fetchMock = vi.fn() vi.stubGlobal('$fetch', fetchMock) // --- Tests --- describe('useOnboarding', () => { let useOnboarding beforeEach(async () => { resetState() fetchMock.mockReset() // Default: status endpoint returns all-false goals fetchMock.mockImplementation((url) => { if (url === '/api/onboarding/status') { return Promise.resolve({ goals: { hasProfileTags: false, hasVisitedEvent: false, hasEngagedEcology: false, hasClickedWiki: false, }, completedAt: null, }) } return Promise.resolve(null) }) // Re-import to get clean module state vi.resetModules() const mod = await import('../../../app/composables/useOnboarding.js') useOnboarding = mod.useOnboarding }) // 9.1: Initializes goals from API response it('9.1: initializes goals from API response', async () => { fetchMock.mockImplementation((url) => { if (url === '/api/onboarding/status') { return Promise.resolve({ goals: { hasProfileTags: true, hasVisitedEvent: false, hasEngagedEcology: true, hasClickedWiki: false, }, completedAt: null, }) } return Promise.resolve(null) }) const { goals, loading } = useOnboarding() // Wait for fetchStatus to complete await vi.waitFor(() => { expect(loading.value).toBe(false) }) expect(goals.value.hasProfileTags).toBe(true) expect(goals.value.hasVisitedEvent).toBe(false) expect(goals.value.hasEngagedEcology).toBe(true) expect(goals.value.hasClickedWiki).toBe(false) }) // 9.2: isComplete true when all goals true it('9.2: isComplete is true when all goals are true', async () => { fetchMock.mockImplementation((url) => { if (url === '/api/onboarding/status') { return Promise.resolve({ goals: { hasProfileTags: true, hasVisitedEvent: true, hasEngagedEcology: true, hasClickedWiki: true, }, completedAt: '2026-04-01T00:00:00Z', }) } if (url === '/api/events/recommended') return Promise.resolve([]) if (url === '/api/ecology/suggestions') return Promise.resolve({ suggestions: [] }) if (url === '/api/wiki/recommended') return Promise.resolve([]) return Promise.resolve(null) }) const { isComplete, completedAt, loading } = useOnboarding() await vi.waitFor(() => { expect(loading.value).toBe(false) }) expect(isComplete.value).toBe(true) expect(completedAt.value).toBe('2026-04-01T00:00:00Z') }) // 9.3: isComplete false with partial goals it('9.3: isComplete is false with partial goals', async () => { fetchMock.mockImplementation((url) => { if (url === '/api/onboarding/status') { return Promise.resolve({ goals: { hasProfileTags: true, hasVisitedEvent: true, hasEngagedEcology: false, hasClickedWiki: false, }, completedAt: null, }) } return Promise.resolve(null) }) const { isComplete, loading } = useOnboarding() await vi.waitFor(() => { expect(loading.value).toBe(false) }) expect(isComplete.value).toBe(false) }) // 9.4: currentSuggestion returns profile tags when no tags set (priority 1) it('9.4: currentSuggestion returns profile tags when no tags set', async () => { const { currentSuggestion, loading } = useOnboarding() await vi.waitFor(() => { expect(loading.value).toBe(false) }) expect(currentSuggestion.value.key).toBe('profileTags') expect(currentSuggestion.value.action).toBe('/member/profile') expect(currentSuggestion.value.actionText).toBe('Set up tags') }) // 9.5: currentSuggestion returns event when tags set but no event visit (priority 2) it('9.5: currentSuggestion returns event when tags set but no event visit', async () => { fetchMock.mockImplementation((url) => { if (url === '/api/onboarding/status') { return Promise.resolve({ goals: { hasProfileTags: true, hasVisitedEvent: false, hasEngagedEcology: false, hasClickedWiki: false, }, completedAt: null, }) } return Promise.resolve(null) }) const { currentSuggestion, loading } = useOnboarding() await vi.waitFor(() => { expect(loading.value).toBe(false) }) expect(currentSuggestion.value.key).toBe('visitEvent') expect(currentSuggestion.value.action).toBe('/events') expect(currentSuggestion.value.actionText).toBe('Browse events') }) // 9.6: currentSuggestion returns ecology when tags + event done (priority 3) it('9.6: currentSuggestion returns ecology when tags + event done', async () => { fetchMock.mockImplementation((url) => { if (url === '/api/onboarding/status') { return Promise.resolve({ goals: { hasProfileTags: true, hasVisitedEvent: true, hasEngagedEcology: false, hasClickedWiki: false, }, completedAt: null, }) } return Promise.resolve(null) }) const { currentSuggestion, loading } = useOnboarding() await vi.waitFor(() => { expect(loading.value).toBe(false) }) expect(currentSuggestion.value.key).toBe('ecology') expect(currentSuggestion.value.action).toBe('/ecology') }) // 9.7: currentSuggestion returns wiki when only wiki remaining (priority 4) it('9.7: currentSuggestion returns wiki when only wiki remaining', async () => { fetchMock.mockImplementation((url) => { if (url === '/api/onboarding/status') { return Promise.resolve({ goals: { hasProfileTags: true, hasVisitedEvent: true, hasEngagedEcology: true, hasClickedWiki: false, }, completedAt: null, }) } return Promise.resolve(null) }) const { currentSuggestion, loading } = useOnboarding() await vi.waitFor(() => { expect(loading.value).toBe(false) }) expect(currentSuggestion.value.key).toBe('wiki') expect(currentSuggestion.value.action).toBe('https://wiki.ghostguild.org') expect(currentSuggestion.value.isExternal).toBe(true) }) // 9.8: trackGoal fires POST and is skipped when complete it('9.8: trackGoal fires POST and is skipped when complete', async () => { fetchMock.mockImplementation((url) => { if (url === '/api/onboarding/status') { return Promise.resolve({ goals: { hasProfileTags: true, hasVisitedEvent: true, hasEngagedEcology: true, hasClickedWiki: true, }, completedAt: '2026-04-01T00:00:00Z', }) } if (url === '/api/events/recommended') return Promise.resolve([]) if (url === '/api/ecology/suggestions') return Promise.resolve({ suggestions: [] }) if (url === '/api/wiki/recommended') return Promise.resolve([]) return Promise.resolve(null) }) const { trackGoal, loading } = useOnboarding() await vi.waitFor(() => { expect(loading.value).toBe(false) }) await trackGoal('eventPageVisited') // Should NOT have called the track endpoint since isComplete is true const trackCalls = fetchMock.mock.calls.filter( (c) => c[0] === '/api/onboarding/track' ) expect(trackCalls).toHaveLength(0) }) // 9.9: Suggestion mode uses injectable pickCategory it('9.9: suggestion mode uses injectable pickCategory', async () => { fetchMock.mockImplementation((url) => { if (url === '/api/onboarding/status') { return Promise.resolve({ goals: { hasProfileTags: true, hasVisitedEvent: true, hasEngagedEcology: true, hasClickedWiki: true, }, completedAt: '2026-04-01T00:00:00Z', }) } if (url === '/api/events/recommended') { return Promise.resolve([{ _id: 'e1', title: 'Game Jam' }]) } if (url === '/api/ecology/suggestions') { return Promise.resolve({ suggestions: [{ name: 'Alex' }] }) } if (url === '/api/wiki/recommended') { return Promise.resolve([{ title: 'Co-op Guide', url: 'https://wiki.example.com/coop' }]) } return Promise.resolve(null) }) // Always pick 'wiki' const { currentSuggestion, loading } = useOnboarding({ pickCategory: () => 'wiki', }) await vi.waitFor(() => { expect(loading.value).toBe(false) }) expect(currentSuggestion.value.key).toBe('wiki') expect(currentSuggestion.value.text).toContain('Co-op Guide') expect(currentSuggestion.value.isExternal).toBe(true) }) // 9.10: Suggestion mode falls back when selected category empty it('9.10: suggestion mode falls back when selected category empty', async () => { fetchMock.mockImplementation((url) => { if (url === '/api/onboarding/status') { return Promise.resolve({ goals: { hasProfileTags: true, hasVisitedEvent: true, hasEngagedEcology: true, hasClickedWiki: true, }, completedAt: '2026-04-01T00:00:00Z', }) } if (url === '/api/events/recommended') { return Promise.resolve([{ _id: 'e1', title: 'Game Jam' }]) } if (url === '/api/ecology/suggestions') { return Promise.resolve({ suggestions: [] }) } if (url === '/api/wiki/recommended') { return Promise.resolve([]) } return Promise.resolve(null) }) // pickCategory only gets non-empty categories, so it should only see 'events' const pickedCategories = [] const { currentSuggestion, loading } = useOnboarding({ pickCategory: (cats) => { pickedCategories.push([...cats]) return cats[0] }, }) await vi.waitFor(() => { expect(loading.value).toBe(false) }) // Access computed first to trigger pickCategory expect(currentSuggestion.value.key).toBe('event') expect(currentSuggestion.value.text).toContain('Game Jam') // Only events had results, so pickCategory should only have received ['events'] expect(pickedCategories[0]).toEqual(['events']) }) // 9.11: Suggestion mode shows fallback when all categories empty it('9.11: suggestion mode shows fallback when all categories empty', async () => { fetchMock.mockImplementation((url) => { if (url === '/api/onboarding/status') { return Promise.resolve({ goals: { hasProfileTags: true, hasVisitedEvent: true, hasEngagedEcology: true, hasClickedWiki: true, }, completedAt: '2026-04-01T00:00:00Z', }) } if (url === '/api/events/recommended') return Promise.resolve([]) if (url === '/api/ecology/suggestions') return Promise.resolve({ suggestions: [] }) if (url === '/api/wiki/recommended') return Promise.resolve([]) return Promise.resolve(null) }) const { currentSuggestion, loading } = useOnboarding() await vi.waitFor(() => { expect(loading.value).toBe(false) }) expect(currentSuggestion.value.key).toBe('empty') expect(currentSuggestion.value.text).toBe('No suggestions right now') }) })