ghostguild-org/tests/client/composables/useOnboarding.test.js

390 lines
12 KiB
JavaScript

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).toBeNull()
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')
})
})