import { setActivePinia, createPinia } from 'pinia' import { beforeEach, describe, it, expect, vi } from 'vitest' import { useUserStore } from '../../stores/user' import { authApi, profileApi } from '../../utils/api' // ── Mock API module ────────────────────────────────────────────── // vi.mock is hoisted to the top of the file, before any imports. vi.mock('../../utils/api', () => ({ authApi: { login: vi.fn(), }, profileApi: { info: vi.fn(), quota: vi.fn(), commissionSummary: vi.fn(), }, })) describe('user store', () => { beforeEach(() => { setActivePinia(createPinia()) // Reset API mocks between tests so return values don't leak authApi.login.mockReset() profileApi.info.mockReset() profileApi.quota.mockReset() profileApi.commissionSummary.mockReset() }) // ── Initial state ────────────────────────────────────────────── describe('initial state', () => { it('creates with empty token and isLoggedIn=false when no saved token', () => { const store = useUserStore() expect(store.token).toBe('') expect(store.isLoggedIn).toBe(false) expect(store.nickname).toBe('') expect(store.avatarUrl).toBe('') expect(store.isVip).toBe(false) expect(store.vipType).toBeNull() expect(store.vipEndTime).toBeNull() expect(store.referralCode).toBe('') }) it('loads token from storage and sets isLoggedIn=true', () => { uni.__mockStorage.token = 'saved-token-abc' const store = useUserStore() expect(store.token).toBe('saved-token-abc') expect(store.isLoggedIn).toBe(true) }) it('loads empty string when storage returns falsy', () => { uni.__mockStorage.token = '' const store = useUserStore() expect(store.token).toBe('') expect(store.isLoggedIn).toBe(false) }) }) // ── login() ──────────────────────────────────────────────────── describe('login()', () => { it('calls authApi.login with openid, nickname, avatarUrl', async () => { authApi.login.mockResolvedValue({ token: 'new-token', profileIncomplete: false }) const store = useUserStore() await store.login('openid-123', 'Alice', 'http://avatar.url/1', 'ref-999') expect(authApi.login).toHaveBeenCalledWith({ openid: 'openid-123', nickname: 'Alice', avatarUrl: 'http://avatar.url/1', referralCode: 'ref-999', }) }) it('calls authApi.login without referralCode when not provided', async () => { authApi.login.mockResolvedValue({ token: 'new-token', profileIncomplete: false }) const store = useUserStore() await store.login('openid-123', 'Alice', 'http://avatar.url/1', '') expect(authApi.login).toHaveBeenCalledWith({ openid: 'openid-123', nickname: 'Alice', avatarUrl: 'http://avatar.url/1', }) }) it('sets token, nickname, avatarUrl from login response', async () => { authApi.login.mockResolvedValue({ token: 'jwt-token-abc', profileIncomplete: false }) const store = useUserStore() await store.login('oid', 'Bob', 'http://avatar/bob', '') expect(store.token).toBe('jwt-token-abc') expect(store.nickname).toBe('Bob') expect(store.avatarUrl).toBe('http://avatar/bob') }) it('persists token to storage', async () => { authApi.login.mockResolvedValue({ token: 'persisted-token', profileIncomplete: false }) const store = useUserStore() await store.login('oid', 'Bob', '', '') expect(uni.setStorageSync).toHaveBeenCalledWith('token', 'persisted-token') expect(uni.__mockStorage.token).toBe('persisted-token') }) it('handles res being a raw string (no .token wrapper)', async () => { authApi.login.mockResolvedValue('raw-string-token') const store = useUserStore() await store.login('oid', 'Bob', '', '') expect(store.token).toBe('raw-string-token') }) it('sets profileIncomplete from response', async () => { authApi.login.mockResolvedValue({ token: 't', profileIncomplete: true }) const store = useUserStore() await store.login('oid', 'Bob', '', '') expect(store.profileIncomplete).toBe(true) }) }) // ── wechatLogin() ────────────────────────────────────────────── describe('wechatLogin()', () => { it('calls authApi.login with code instead of openid', async () => { authApi.login.mockResolvedValue({ token: 'wx-token', profileIncomplete: false }) profileApi.info.mockResolvedValue({ nickname: 'wxuser', avatarUrl: '' }) const store = useUserStore() await store.wechatLogin('code-abc', 'WeChatUser', 'http://avatar', 'ref') expect(authApi.login).toHaveBeenCalledWith({ code: 'code-abc', nickname: 'WeChatUser', avatarUrl: 'http://avatar', referralCode: 'ref', }) }) it('falls back to default nickname when empty (before fetchProfile completes)', async () => { // The store sets nickname || '微信用户' before calling fetchProfile. // However, fetchProfile always overwrites it with the API value (even undefined). // This test verifies the code path exists and no error is thrown. authApi.login.mockResolvedValue({ token: 'wx-token', profileIncomplete: false }) profileApi.info.mockResolvedValue({ nickname: 'ServerName', avatarUrl: '' }) const store = useUserStore() await store.wechatLogin('code-abc', '', '', '') // fetchProfile response takes precedence over the temporary fallback expect(store.nickname).toBe('ServerName') expect(store.avatarUrl).toBe('') }) it('fetches full profile after login', async () => { authApi.login.mockResolvedValue({ token: 'wx-token', profileIncomplete: false }) profileApi.info.mockResolvedValue({ nickname: 'ServerNick', avatarUrl: 'http://server/avatar', referralCode: 'CODE123', vipType: 'year', vipEndTime: '2027-06-08T00:00:00', gender: 1, birthDate: '1990-01-15', }) const store = useUserStore() await store.wechatLogin('code-abc', 'WeChatUser', 'http://avatar', '') // Token set from login expect(store.token).toBe('wx-token') // Profile loaded from API expect(store.nickname).toBe('ServerNick') expect(store.referralCode).toBe('CODE123') expect(store.vipType).toBe('year') }) it('does not throw when fetchProfile fails', async () => { authApi.login.mockResolvedValue({ token: 'wx-token', profileIncomplete: false }) profileApi.info.mockRejectedValue(new Error('network error')) const store = useUserStore() await expect(store.wechatLogin('code', 'name', '', '')).resolves.not.toThrow() expect(store.token).toBe('wx-token') }) }) // ── fetchProfile() ───────────────────────────────────────────── describe('fetchProfile()', () => { const mockProfile = { nickname: 'TestUser', avatarUrl: 'http://avatar/test', referralCode: 'REF123', vipType: 'month', vipEndTime: '2026-12-31T23:59:59', gender: 1, birthDate: '1990-06-15', bio: 'Hello world', city: 'Shanghai', tags: 'reader,traveler', lookingFor: 2, profileComplete: true, birthYear: 1990, birthMonth: 6, birthDay: 15, } it('updates all user fields from API response', async () => { profileApi.info.mockResolvedValue(mockProfile) const store = useUserStore() await store.fetchProfile() expect(store.nickname).toBe('TestUser') expect(store.avatarUrl).toBe('http://avatar/test') expect(store.referralCode).toBe('REF123') expect(store.vipType).toBe('month') expect(store.vipEndTime).toBe('2026-12-31T23:59:59') expect(store.gender).toBe(1) expect(store.birthDate).toBe('1990-06-15') expect(store.bio).toBe('Hello world') expect(store.city).toBe('Shanghai') expect(store.tags).toBe('reader,traveler') expect(store.lookingFor).toBe(2) expect(store.profileComplete).toBe(true) expect(store.birthYear).toBe(1990) expect(store.birthMonth).toBe(6) expect(store.birthDay).toBe(15) }) it('normalizes vipEndTime from Jackson array [y,m,d,h,m,s] to ISO string', async () => { profileApi.info.mockResolvedValue({ ...mockProfile, vipEndTime: [2026, 6, 8, 23, 59, 59], }) const store = useUserStore() await store.fetchProfile() expect(store.vipEndTime).toBe('2026-06-08T23:59:59') }) it('normalizes vipEndTime with single-digit month/day padding', async () => { profileApi.info.mockResolvedValue({ ...mockProfile, vipEndTime: [2026, 1, 5, 9, 5, 3], }) const store = useUserStore() await store.fetchProfile() expect(store.vipEndTime).toBe('2026-01-05T09:05:03') }) it('handles null vipEndTime', async () => { profileApi.info.mockResolvedValue({ ...mockProfile, vipEndTime: null, }) const store = useUserStore() await store.fetchProfile() expect(store.vipEndTime).toBeNull() expect(store.isVip).toBe(false) }) it('handles undefined vipEndTime', async () => { profileApi.info.mockResolvedValue({ ...mockProfile, vipEndTime: undefined, }) const store = useUserStore() await store.fetchProfile() expect(store.vipEndTime).toBeNull() }) it('sets isVip=true when vipEndTime is in the future', async () => { profileApi.info.mockResolvedValue({ ...mockProfile, vipEndTime: '2099-01-01T00:00:00', // far future }) const store = useUserStore() await store.fetchProfile() expect(store.isVip).toBe(true) expect(store.isVipActive).toBe(true) }) it('sets isVip=false when vipEndTime is in the past', async () => { profileApi.info.mockResolvedValue({ ...mockProfile, vipEndTime: '2020-01-01T00:00:00', // past }) const store = useUserStore() await store.fetchProfile() expect(store.isVip).toBe(false) // isVipActive getter computes from vipEndTime expect(store.isVipActive).toBe(false) }) it('handles missing optional fields with defaults', async () => { profileApi.info.mockResolvedValue({ nickname: 'Minimal', avatarUrl: '', }) const store = useUserStore() await store.fetchProfile() // referralCode is set directly (no || fallback), so stays undefined expect(store.referralCode).toBeUndefined() expect(store.vipType).toBeNull() expect(store.vipEndTime).toBeNull() expect(store.gender).toBe(0) expect(store.birthDate).toBeNull() expect(store.bio).toBe('') expect(store.city).toBe('') expect(store.tags).toBe('') expect(store.lookingFor).toBe(0) expect(store.profileComplete).toBe(false) expect(store.birthYear).toBe(0) expect(store.birthMonth).toBe(0) expect(store.birthDay).toBe(0) expect(store.profileIncomplete).toBe(true) }) it('sets profileIncomplete to true when profileComplete is falsy', async () => { profileApi.info.mockResolvedValue({ nickname: 'Test', avatarUrl: '', profileComplete: false, }) const store = useUserStore() await store.fetchProfile() expect(store.profileIncomplete).toBe(true) }) }) // ── fetchQuota() ─────────────────────────────────────────────── describe('fetchQuota()', () => { const mockQuota = { dailyChartCount: 2, chartLimit: 10, dailyChatCount: 5, chatLimit: 20, dailyShareCount: 3, shareLimit: 5, dailyAcademicCount: 0, academicLimit: 2, } it('updates quota limits from API response', async () => { profileApi.quota.mockResolvedValue(mockQuota) const store = useUserStore() await store.fetchQuota() expect(store.usedCharts).toBe(2) expect(store.maxCharts).toBe(10) expect(store.usedChats).toBe(5) expect(store.maxChats).toBe(20) expect(store.shareCount).toBe(3) expect(store.shareLimit).toBe(5) expect(store.usedAcademic).toBe(0) expect(store.maxAcademic).toBe(2) }) it('falls back to 999 when chart/chats limits are 0 or negative', async () => { profileApi.quota.mockResolvedValue({ dailyChartCount: 0, chartLimit: 0, dailyChatCount: 0, chatLimit: 0, dailyShareCount: 0, shareLimit: 0, dailyAcademicCount: 0, academicLimit: 0, }) const store = useUserStore() await store.fetchQuota() expect(store.maxCharts).toBe(999) expect(store.maxChats).toBe(999) expect(store.shareLimit).toBe(1) // defaults to 1 when 0 or negative expect(store.maxAcademic).toBe(1) // defaults to 1 when 0 or negative }) it('does not throw on API failure (silent catch)', async () => { profileApi.quota.mockRejectedValue(new Error('server error')) const store = useUserStore() await expect(store.fetchQuota()).resolves.not.toThrow() // Default values remain expect(store.usedCharts).toBe(0) expect(store.maxCharts).toBe(3) }) }) // ── fetchCommissionSummary() ─────────────────────────────────── describe('fetchCommissionSummary()', () => { it('updates commission from API response', async () => { profileApi.commissionSummary.mockResolvedValue({ totalEarnings: 1500, availableBalance: 800, }) const store = useUserStore() await store.fetchCommissionSummary() expect(store.totalCommission).toBe(1500) expect(store.availableCommission).toBe(800) }) it('does not throw on API failure (silent catch)', async () => { profileApi.commissionSummary.mockRejectedValue(new Error('error')) const store = useUserStore() await expect(store.fetchCommissionSummary()).resolves.not.toThrow() expect(store.totalCommission).toBe(0) expect(store.availableCommission).toBe(0) }) it('handles missing response fields', async () => { profileApi.commissionSummary.mockResolvedValue({}) const store = useUserStore() await store.fetchCommissionSummary() expect(store.totalCommission).toBe(0) expect(store.availableCommission).toBe(0) }) }) // ── logout() ─────────────────────────────────────────────────── describe('logout()', () => { it('clears token, sets isLoggedIn=false', () => { const store = useUserStore() store.token = 'some-token' store.logout() expect(store.token).toBe('') expect(store.isLoggedIn).toBe(false) }) it('removes token from storage', () => { uni.__mockStorage.token = 'some-token' const store = useUserStore() store.logout() expect(uni.removeStorageSync).toHaveBeenCalledWith('token') expect(uni.__mockStorage.token).toBeUndefined() }) it('resets all state fields to defaults', () => { // Populate with non-default values const store = useUserStore() store.nickname = 'Test' store.avatarUrl = 'http://avatar' store.usedCharts = 5 store.usedChats = 10 store.usedAcademic = 3 store.shareCount = 8 store.shareLimit = 20 store.maxAcademic = 10 store.totalCommission = 500 store.availableCommission = 200 store.profileIncomplete = true store.gender = 1 store.birthDate = '1990-01-01' store.bio = 'bio' store.city = 'city' store.tags = 'tag1,tag2' store.lookingFor = 2 store.profileComplete = true store.birthYear = 1990 store.birthMonth = 1 store.birthDay = 1 store.logout() expect(store.nickname).toBe('') expect(store.avatarUrl).toBe('') expect(store.usedCharts).toBe(0) expect(store.usedChats).toBe(0) expect(store.usedAcademic).toBe(0) expect(store.shareCount).toBe(0) expect(store.shareLimit).toBe(1) expect(store.maxAcademic).toBe(1) expect(store.totalCommission).toBe(0) expect(store.availableCommission).toBe(0) expect(store.profileIncomplete).toBe(false) expect(store.gender).toBe(0) expect(store.birthDate).toBeNull() expect(store.bio).toBe('') expect(store.city).toBe('') expect(store.tags).toBe('') expect(store.lookingFor).toBe(0) expect(store.profileComplete).toBe(false) expect(store.birthYear).toBe(0) expect(store.birthMonth).toBe(0) expect(store.birthDay).toBe(0) }) }) })