user.spec.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502
  1. import { setActivePinia, createPinia } from 'pinia'
  2. import { beforeEach, describe, it, expect, vi } from 'vitest'
  3. import { useUserStore } from '../../stores/user'
  4. import { authApi, profileApi } from '../../utils/api'
  5. // ── Mock API module ──────────────────────────────────────────────
  6. // vi.mock is hoisted to the top of the file, before any imports.
  7. vi.mock('../../utils/api', () => ({
  8. authApi: {
  9. login: vi.fn(),
  10. },
  11. profileApi: {
  12. info: vi.fn(),
  13. quota: vi.fn(),
  14. commissionSummary: vi.fn(),
  15. },
  16. }))
  17. describe('user store', () => {
  18. beforeEach(() => {
  19. setActivePinia(createPinia())
  20. // Reset API mocks between tests so return values don't leak
  21. authApi.login.mockReset()
  22. profileApi.info.mockReset()
  23. profileApi.quota.mockReset()
  24. profileApi.commissionSummary.mockReset()
  25. })
  26. // ── Initial state ──────────────────────────────────────────────
  27. describe('initial state', () => {
  28. it('creates with empty token and isLoggedIn=false when no saved token', () => {
  29. const store = useUserStore()
  30. expect(store.token).toBe('')
  31. expect(store.isLoggedIn).toBe(false)
  32. expect(store.nickname).toBe('')
  33. expect(store.avatarUrl).toBe('')
  34. expect(store.isVip).toBe(false)
  35. expect(store.vipType).toBeNull()
  36. expect(store.vipEndTime).toBeNull()
  37. expect(store.referralCode).toBe('')
  38. })
  39. it('loads token from storage and sets isLoggedIn=true', () => {
  40. uni.__mockStorage.token = 'saved-token-abc'
  41. const store = useUserStore()
  42. expect(store.token).toBe('saved-token-abc')
  43. expect(store.isLoggedIn).toBe(true)
  44. })
  45. it('loads empty string when storage returns falsy', () => {
  46. uni.__mockStorage.token = ''
  47. const store = useUserStore()
  48. expect(store.token).toBe('')
  49. expect(store.isLoggedIn).toBe(false)
  50. })
  51. })
  52. // ── login() ────────────────────────────────────────────────────
  53. describe('login()', () => {
  54. it('calls authApi.login with openid, nickname, avatarUrl', async () => {
  55. authApi.login.mockResolvedValue({ token: 'new-token', profileIncomplete: false })
  56. const store = useUserStore()
  57. await store.login('openid-123', 'Alice', 'http://avatar.url/1', 'ref-999')
  58. expect(authApi.login).toHaveBeenCalledWith({
  59. openid: 'openid-123',
  60. nickname: 'Alice',
  61. avatarUrl: 'http://avatar.url/1',
  62. referralCode: 'ref-999',
  63. })
  64. })
  65. it('calls authApi.login without referralCode when not provided', async () => {
  66. authApi.login.mockResolvedValue({ token: 'new-token', profileIncomplete: false })
  67. const store = useUserStore()
  68. await store.login('openid-123', 'Alice', 'http://avatar.url/1', '')
  69. expect(authApi.login).toHaveBeenCalledWith({
  70. openid: 'openid-123',
  71. nickname: 'Alice',
  72. avatarUrl: 'http://avatar.url/1',
  73. })
  74. })
  75. it('sets token, nickname, avatarUrl from login response', async () => {
  76. authApi.login.mockResolvedValue({ token: 'jwt-token-abc', profileIncomplete: false })
  77. const store = useUserStore()
  78. await store.login('oid', 'Bob', 'http://avatar/bob', '')
  79. expect(store.token).toBe('jwt-token-abc')
  80. expect(store.nickname).toBe('Bob')
  81. expect(store.avatarUrl).toBe('http://avatar/bob')
  82. })
  83. it('persists token to storage', async () => {
  84. authApi.login.mockResolvedValue({ token: 'persisted-token', profileIncomplete: false })
  85. const store = useUserStore()
  86. await store.login('oid', 'Bob', '', '')
  87. expect(uni.setStorageSync).toHaveBeenCalledWith('token', 'persisted-token')
  88. expect(uni.__mockStorage.token).toBe('persisted-token')
  89. })
  90. it('handles res being a raw string (no .token wrapper)', async () => {
  91. authApi.login.mockResolvedValue('raw-string-token')
  92. const store = useUserStore()
  93. await store.login('oid', 'Bob', '', '')
  94. expect(store.token).toBe('raw-string-token')
  95. })
  96. it('sets profileIncomplete from response', async () => {
  97. authApi.login.mockResolvedValue({ token: 't', profileIncomplete: true })
  98. const store = useUserStore()
  99. await store.login('oid', 'Bob', '', '')
  100. expect(store.profileIncomplete).toBe(true)
  101. })
  102. })
  103. // ── wechatLogin() ──────────────────────────────────────────────
  104. describe('wechatLogin()', () => {
  105. it('calls authApi.login with code instead of openid', async () => {
  106. authApi.login.mockResolvedValue({ token: 'wx-token', profileIncomplete: false })
  107. profileApi.info.mockResolvedValue({ nickname: 'wxuser', avatarUrl: '' })
  108. const store = useUserStore()
  109. await store.wechatLogin('code-abc', 'WeChatUser', 'http://avatar', 'ref')
  110. expect(authApi.login).toHaveBeenCalledWith({
  111. code: 'code-abc',
  112. nickname: 'WeChatUser',
  113. avatarUrl: 'http://avatar',
  114. referralCode: 'ref',
  115. })
  116. })
  117. it('falls back to default nickname when empty (before fetchProfile completes)', async () => {
  118. // The store sets nickname || '微信用户' before calling fetchProfile.
  119. // However, fetchProfile always overwrites it with the API value (even undefined).
  120. // This test verifies the code path exists and no error is thrown.
  121. authApi.login.mockResolvedValue({ token: 'wx-token', profileIncomplete: false })
  122. profileApi.info.mockResolvedValue({ nickname: 'ServerName', avatarUrl: '' })
  123. const store = useUserStore()
  124. await store.wechatLogin('code-abc', '', '', '')
  125. // fetchProfile response takes precedence over the temporary fallback
  126. expect(store.nickname).toBe('ServerName')
  127. expect(store.avatarUrl).toBe('')
  128. })
  129. it('fetches full profile after login', async () => {
  130. authApi.login.mockResolvedValue({ token: 'wx-token', profileIncomplete: false })
  131. profileApi.info.mockResolvedValue({
  132. nickname: 'ServerNick',
  133. avatarUrl: 'http://server/avatar',
  134. referralCode: 'CODE123',
  135. vipType: 'year',
  136. vipEndTime: '2027-06-08T00:00:00',
  137. gender: 1,
  138. birthDate: '1990-01-15',
  139. })
  140. const store = useUserStore()
  141. await store.wechatLogin('code-abc', 'WeChatUser', 'http://avatar', '')
  142. // Token set from login
  143. expect(store.token).toBe('wx-token')
  144. // Profile loaded from API
  145. expect(store.nickname).toBe('ServerNick')
  146. expect(store.referralCode).toBe('CODE123')
  147. expect(store.vipType).toBe('year')
  148. })
  149. it('does not throw when fetchProfile fails', async () => {
  150. authApi.login.mockResolvedValue({ token: 'wx-token', profileIncomplete: false })
  151. profileApi.info.mockRejectedValue(new Error('network error'))
  152. const store = useUserStore()
  153. await expect(store.wechatLogin('code', 'name', '', '')).resolves.not.toThrow()
  154. expect(store.token).toBe('wx-token')
  155. })
  156. })
  157. // ── fetchProfile() ─────────────────────────────────────────────
  158. describe('fetchProfile()', () => {
  159. const mockProfile = {
  160. nickname: 'TestUser',
  161. avatarUrl: 'http://avatar/test',
  162. referralCode: 'REF123',
  163. vipType: 'month',
  164. vipEndTime: '2026-12-31T23:59:59',
  165. gender: 1,
  166. birthDate: '1990-06-15',
  167. bio: 'Hello world',
  168. city: 'Shanghai',
  169. tags: 'reader,traveler',
  170. lookingFor: 2,
  171. profileComplete: true,
  172. birthYear: 1990,
  173. birthMonth: 6,
  174. birthDay: 15,
  175. }
  176. it('updates all user fields from API response', async () => {
  177. profileApi.info.mockResolvedValue(mockProfile)
  178. const store = useUserStore()
  179. await store.fetchProfile()
  180. expect(store.nickname).toBe('TestUser')
  181. expect(store.avatarUrl).toBe('http://avatar/test')
  182. expect(store.referralCode).toBe('REF123')
  183. expect(store.vipType).toBe('month')
  184. expect(store.vipEndTime).toBe('2026-12-31T23:59:59')
  185. expect(store.gender).toBe(1)
  186. expect(store.birthDate).toBe('1990-06-15')
  187. expect(store.bio).toBe('Hello world')
  188. expect(store.city).toBe('Shanghai')
  189. expect(store.tags).toBe('reader,traveler')
  190. expect(store.lookingFor).toBe(2)
  191. expect(store.profileComplete).toBe(true)
  192. expect(store.birthYear).toBe(1990)
  193. expect(store.birthMonth).toBe(6)
  194. expect(store.birthDay).toBe(15)
  195. })
  196. it('normalizes vipEndTime from Jackson array [y,m,d,h,m,s] to ISO string', async () => {
  197. profileApi.info.mockResolvedValue({
  198. ...mockProfile,
  199. vipEndTime: [2026, 6, 8, 23, 59, 59],
  200. })
  201. const store = useUserStore()
  202. await store.fetchProfile()
  203. expect(store.vipEndTime).toBe('2026-06-08T23:59:59')
  204. })
  205. it('normalizes vipEndTime with single-digit month/day padding', async () => {
  206. profileApi.info.mockResolvedValue({
  207. ...mockProfile,
  208. vipEndTime: [2026, 1, 5, 9, 5, 3],
  209. })
  210. const store = useUserStore()
  211. await store.fetchProfile()
  212. expect(store.vipEndTime).toBe('2026-01-05T09:05:03')
  213. })
  214. it('handles null vipEndTime', async () => {
  215. profileApi.info.mockResolvedValue({
  216. ...mockProfile,
  217. vipEndTime: null,
  218. })
  219. const store = useUserStore()
  220. await store.fetchProfile()
  221. expect(store.vipEndTime).toBeNull()
  222. expect(store.isVip).toBe(false)
  223. })
  224. it('handles undefined vipEndTime', async () => {
  225. profileApi.info.mockResolvedValue({
  226. ...mockProfile,
  227. vipEndTime: undefined,
  228. })
  229. const store = useUserStore()
  230. await store.fetchProfile()
  231. expect(store.vipEndTime).toBeNull()
  232. })
  233. it('sets isVip=true when vipEndTime is in the future', async () => {
  234. profileApi.info.mockResolvedValue({
  235. ...mockProfile,
  236. vipEndTime: '2099-01-01T00:00:00', // far future
  237. })
  238. const store = useUserStore()
  239. await store.fetchProfile()
  240. expect(store.isVip).toBe(true)
  241. expect(store.isVipActive).toBe(true)
  242. })
  243. it('sets isVip=false when vipEndTime is in the past', async () => {
  244. profileApi.info.mockResolvedValue({
  245. ...mockProfile,
  246. vipEndTime: '2020-01-01T00:00:00', // past
  247. })
  248. const store = useUserStore()
  249. await store.fetchProfile()
  250. expect(store.isVip).toBe(false)
  251. // isVipActive getter computes from vipEndTime
  252. expect(store.isVipActive).toBe(false)
  253. })
  254. it('handles missing optional fields with defaults', async () => {
  255. profileApi.info.mockResolvedValue({
  256. nickname: 'Minimal',
  257. avatarUrl: '',
  258. })
  259. const store = useUserStore()
  260. await store.fetchProfile()
  261. // referralCode is set directly (no || fallback), so stays undefined
  262. expect(store.referralCode).toBeUndefined()
  263. expect(store.vipType).toBeNull()
  264. expect(store.vipEndTime).toBeNull()
  265. expect(store.gender).toBe(0)
  266. expect(store.birthDate).toBeNull()
  267. expect(store.bio).toBe('')
  268. expect(store.city).toBe('')
  269. expect(store.tags).toBe('')
  270. expect(store.lookingFor).toBe(0)
  271. expect(store.profileComplete).toBe(false)
  272. expect(store.birthYear).toBe(0)
  273. expect(store.birthMonth).toBe(0)
  274. expect(store.birthDay).toBe(0)
  275. expect(store.profileIncomplete).toBe(true)
  276. })
  277. it('sets profileIncomplete to true when profileComplete is falsy', async () => {
  278. profileApi.info.mockResolvedValue({
  279. nickname: 'Test',
  280. avatarUrl: '',
  281. profileComplete: false,
  282. })
  283. const store = useUserStore()
  284. await store.fetchProfile()
  285. expect(store.profileIncomplete).toBe(true)
  286. })
  287. })
  288. // ── fetchQuota() ───────────────────────────────────────────────
  289. describe('fetchQuota()', () => {
  290. const mockQuota = {
  291. dailyChartCount: 2,
  292. chartLimit: 10,
  293. dailyChatCount: 5,
  294. chatLimit: 20,
  295. dailyShareCount: 3,
  296. shareLimit: 5,
  297. dailyAcademicCount: 0,
  298. academicLimit: 2,
  299. }
  300. it('updates quota limits from API response', async () => {
  301. profileApi.quota.mockResolvedValue(mockQuota)
  302. const store = useUserStore()
  303. await store.fetchQuota()
  304. expect(store.usedCharts).toBe(2)
  305. expect(store.maxCharts).toBe(10)
  306. expect(store.usedChats).toBe(5)
  307. expect(store.maxChats).toBe(20)
  308. expect(store.shareCount).toBe(3)
  309. expect(store.shareLimit).toBe(5)
  310. expect(store.usedAcademic).toBe(0)
  311. expect(store.maxAcademic).toBe(2)
  312. })
  313. it('falls back to 999 when chart/chats limits are 0 or negative', async () => {
  314. profileApi.quota.mockResolvedValue({
  315. dailyChartCount: 0,
  316. chartLimit: 0,
  317. dailyChatCount: 0,
  318. chatLimit: 0,
  319. dailyShareCount: 0,
  320. shareLimit: 0,
  321. dailyAcademicCount: 0,
  322. academicLimit: 0,
  323. })
  324. const store = useUserStore()
  325. await store.fetchQuota()
  326. expect(store.maxCharts).toBe(999)
  327. expect(store.maxChats).toBe(999)
  328. expect(store.shareLimit).toBe(1) // defaults to 1 when 0 or negative
  329. expect(store.maxAcademic).toBe(1) // defaults to 1 when 0 or negative
  330. })
  331. it('does not throw on API failure (silent catch)', async () => {
  332. profileApi.quota.mockRejectedValue(new Error('server error'))
  333. const store = useUserStore()
  334. await expect(store.fetchQuota()).resolves.not.toThrow()
  335. // Default values remain
  336. expect(store.usedCharts).toBe(0)
  337. expect(store.maxCharts).toBe(3)
  338. })
  339. })
  340. // ── fetchCommissionSummary() ───────────────────────────────────
  341. describe('fetchCommissionSummary()', () => {
  342. it('updates commission from API response', async () => {
  343. profileApi.commissionSummary.mockResolvedValue({
  344. totalEarnings: 1500,
  345. availableBalance: 800,
  346. })
  347. const store = useUserStore()
  348. await store.fetchCommissionSummary()
  349. expect(store.totalCommission).toBe(1500)
  350. expect(store.availableCommission).toBe(800)
  351. })
  352. it('does not throw on API failure (silent catch)', async () => {
  353. profileApi.commissionSummary.mockRejectedValue(new Error('error'))
  354. const store = useUserStore()
  355. await expect(store.fetchCommissionSummary()).resolves.not.toThrow()
  356. expect(store.totalCommission).toBe(0)
  357. expect(store.availableCommission).toBe(0)
  358. })
  359. it('handles missing response fields', async () => {
  360. profileApi.commissionSummary.mockResolvedValue({})
  361. const store = useUserStore()
  362. await store.fetchCommissionSummary()
  363. expect(store.totalCommission).toBe(0)
  364. expect(store.availableCommission).toBe(0)
  365. })
  366. })
  367. // ── logout() ───────────────────────────────────────────────────
  368. describe('logout()', () => {
  369. it('clears token, sets isLoggedIn=false', () => {
  370. const store = useUserStore()
  371. store.token = 'some-token'
  372. store.logout()
  373. expect(store.token).toBe('')
  374. expect(store.isLoggedIn).toBe(false)
  375. })
  376. it('removes token from storage', () => {
  377. uni.__mockStorage.token = 'some-token'
  378. const store = useUserStore()
  379. store.logout()
  380. expect(uni.removeStorageSync).toHaveBeenCalledWith('token')
  381. expect(uni.__mockStorage.token).toBeUndefined()
  382. })
  383. it('resets all state fields to defaults', () => {
  384. // Populate with non-default values
  385. const store = useUserStore()
  386. store.nickname = 'Test'
  387. store.avatarUrl = 'http://avatar'
  388. store.usedCharts = 5
  389. store.usedChats = 10
  390. store.usedAcademic = 3
  391. store.shareCount = 8
  392. store.shareLimit = 20
  393. store.maxAcademic = 10
  394. store.totalCommission = 500
  395. store.availableCommission = 200
  396. store.profileIncomplete = true
  397. store.gender = 1
  398. store.birthDate = '1990-01-01'
  399. store.bio = 'bio'
  400. store.city = 'city'
  401. store.tags = 'tag1,tag2'
  402. store.lookingFor = 2
  403. store.profileComplete = true
  404. store.birthYear = 1990
  405. store.birthMonth = 1
  406. store.birthDay = 1
  407. store.logout()
  408. expect(store.nickname).toBe('')
  409. expect(store.avatarUrl).toBe('')
  410. expect(store.usedCharts).toBe(0)
  411. expect(store.usedChats).toBe(0)
  412. expect(store.usedAcademic).toBe(0)
  413. expect(store.shareCount).toBe(0)
  414. expect(store.shareLimit).toBe(1)
  415. expect(store.maxAcademic).toBe(1)
  416. expect(store.totalCommission).toBe(0)
  417. expect(store.availableCommission).toBe(0)
  418. expect(store.profileIncomplete).toBe(false)
  419. expect(store.gender).toBe(0)
  420. expect(store.birthDate).toBeNull()
  421. expect(store.bio).toBe('')
  422. expect(store.city).toBe('')
  423. expect(store.tags).toBe('')
  424. expect(store.lookingFor).toBe(0)
  425. expect(store.profileComplete).toBe(false)
  426. expect(store.birthYear).toBe(0)
  427. expect(store.birthMonth).toBe(0)
  428. expect(store.birthDay).toBe(0)
  429. })
  430. })
  431. })