Browse Source

feat(tests): 引入vitest测试框架+单元测试用例

liaoxg 3 tháng trước cách đây
mục cha
commit
0303ab1134

+ 9 - 0
client/package.json

@@ -2,8 +2,17 @@
   "name": "num-energy",
   "version": "1.0.0",
   "description": "数码能量学 AI 智能体小程序",
+  "scripts": {
+    "test": "vitest",
+    "test:run": "vitest run"
+  },
   "dependencies": {
     "pinia": "^2.1.0",
     "vue": "^3.4.0"
+  },
+  "devDependencies": {
+    "@vue/test-utils": "^2.4.11",
+    "happy-dom": "^20.10.2",
+    "vitest": "^4.1.8"
   }
 }

+ 62 - 0
client/tests/setup.js

@@ -0,0 +1,62 @@
+import { vi, beforeEach } from 'vitest'
+
+/**
+ * In-memory store for uni mocked storage APIs.
+ * Cleared between tests via beforeEach.
+ * Exposed as uni.__mockStorage so tests can pre-populate storage values
+ * before store creation (e.g. simulating a saved token).
+ */
+const mockStorage = {}
+
+/**
+ * Mock all uni-app APIs used by stores and common pages.
+ *
+ * The storage mocks (getStorageSync / setStorageSync / removeStorageSync)
+ * operate on the mockStorage object above, which persists across calls
+ * within a test but is wiped before each test.
+ *
+ * Tests can push values into storage by setting:
+ *   uni.__mockStorage.theme = 'light'
+ *   uni.__mockStorage.token = 'my-token'
+ */
+globalThis.uni = {
+  /** Storage — backed by mockStorage */
+  getStorageSync: vi.fn((key) => mockStorage[key]),
+  setStorageSync: vi.fn((key, value) => { mockStorage[key] = value }),
+  removeStorageSync: vi.fn((key) => { delete mockStorage[key] }),
+
+  /** System info */
+  getSystemInfoSync: vi.fn(() => ({ theme: 'dark' })),
+  onThemeChange: vi.fn(),
+
+  /** Tab bar */
+  setTabBarStyle: vi.fn(),
+
+  /** Navigation */
+  getCurrentPages: vi.fn(() => []),
+  navigateTo: vi.fn(),
+  reLaunch: vi.fn(),
+
+  /** Feedback */
+  showModal: vi.fn(),
+  showToast: vi.fn(),
+
+  /** Network */
+  request: vi.fn(),
+  downloadFile: vi.fn(),
+  uploadFile: vi.fn(),
+
+  /** Exposed for test convenience */
+  __mockStorage: mockStorage,
+}
+
+// ── Per-test cleanup ─────────────────────────────────────────────
+
+beforeEach(() => {
+  // Wipe the in-memory storage so no state leaks between tests
+  Object.keys(mockStorage).forEach((k) => delete mockStorage[k])
+
+  // Reset call history and custom return-values for all vi.fn() mocks
+  // (implementations provided in the factory are *not* removed)
+  vi.clearAllMocks()
+})

+ 121 - 0
client/tests/stores/theme.spec.js

@@ -0,0 +1,121 @@
+import { setActivePinia, createPinia } from 'pinia'
+import { beforeEach, describe, it, expect, vi } from 'vitest'
+import { useThemeStore } from '../../stores/theme'
+
+describe('theme store', () => {
+  let store
+
+  beforeEach(() => {
+    setActivePinia(createPinia())
+    store = useThemeStore()
+  })
+
+  // ── init() ──────────────────────────────────────────────────────
+
+  describe('init()', () => {
+    it('loads saved theme from storage', () => {
+      uni.__mockStorage.theme = 'light'
+      store.init()
+      expect(store.theme).toBe('light')
+    })
+
+    it('defaults to dark when no saved theme and no system preference', () => {
+      // mockStorage is already empty (beforeEach cleanup)
+      // getSystemInfoSync returns { theme: 'dark' } so "sysInfo.theme === 'light'" is false
+      store.init()
+      expect(store.theme).toBe('dark')
+    })
+
+    it('falls back to dark when getSystemInfoSync throws', () => {
+      uni.getSystemInfoSync.mockImplementation(() => { throw new Error('no sys') })
+      store.init()
+      expect(store.theme).toBe('dark')
+    })
+
+    it('follows system light preference when no saved theme', () => {
+      uni.getSystemInfoSync.mockReturnValue({ theme: 'light' })
+      store.init()
+      expect(store.theme).toBe('light')
+    })
+
+    it('calls setTabBarStyle with correct colors on init', () => {
+      uni.__mockStorage.theme = 'light'
+      store.init()
+      expect(uni.setTabBarStyle).toHaveBeenCalledWith({
+        color: '#4A5568',
+        selectedColor: '#C9A84C',
+        backgroundColor: '#FFFFFF',
+        borderStyle: 'black',
+      })
+    })
+  })
+
+  // ── toggle() ────────────────────────────────────────────────────
+
+  describe('toggle()', () => {
+    it('switches from dark to light', () => {
+      store.theme = 'dark'
+      store.toggle()
+      expect(store.theme).toBe('light')
+    })
+
+    it('switches from light to dark', () => {
+      store.theme = 'light'
+      store.toggle()
+      expect(store.theme).toBe('dark')
+    })
+
+    it('persists toggled theme to storage', () => {
+      store.theme = 'dark'
+      store.toggle()
+      expect(uni.setStorageSync).toHaveBeenCalledWith('theme', 'light')
+    })
+
+    it('updates tab bar on toggle', () => {
+      store.theme = 'dark'
+      store.toggle()
+      expect(uni.setTabBarStyle).toHaveBeenCalled()
+    })
+  })
+
+  // ── setTheme() ──────────────────────────────────────────────────
+
+  describe('setTheme()', () => {
+    it('sets theme to light and persists', () => {
+      store.setTheme('light')
+      expect(store.theme).toBe('light')
+      expect(uni.setStorageSync).toHaveBeenCalledWith('theme', 'light')
+      expect(uni.setTabBarStyle).toHaveBeenCalledWith({
+        color: '#4A5568',
+        selectedColor: '#C9A84C',
+        backgroundColor: '#FFFFFF',
+        borderStyle: 'black',
+      })
+    })
+
+    it('sets theme to dark and persists', () => {
+      store.theme = 'light'
+      store.setTheme('dark')
+      expect(store.theme).toBe('dark')
+      expect(uni.setStorageSync).toHaveBeenCalledWith('theme', 'dark')
+      expect(uni.setTabBarStyle).toHaveBeenCalledWith({
+        color: '#A0AEC0',
+        selectedColor: '#C9A84C',
+        backgroundColor: '#1E3A5F',
+        borderStyle: 'white',
+      })
+    })
+
+    it('ignores invalid theme values', () => {
+      store.setTheme('invalid')
+      expect(store.theme).toBe('dark') // default
+      expect(uni.setStorageSync).not.toHaveBeenCalled()
+    })
+
+    it('ignores empty string', () => {
+      store.setTheme('')
+      expect(store.theme).toBe('dark')
+      expect(uni.setStorageSync).not.toHaveBeenCalled()
+    })
+  })
+})

+ 502 - 0
client/tests/stores/user.spec.js

@@ -0,0 +1,502 @@
+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)
+    })
+  })
+})

+ 16 - 0
client/vitest.config.js

@@ -0,0 +1,16 @@
+import { defineConfig } from 'vitest/config'
+import { resolve } from 'path'
+
+export default defineConfig({
+  test: {
+    globals: true,
+    environment: 'happy-dom',
+    include: ['tests/**/*.{test,spec}.{js,ts,vue}'],
+    setupFiles: ['./tests/setup.js'],
+  },
+  resolve: {
+    alias: {
+      '@': resolve(__dirname),
+    },
+  },
+})