Merge branch 'dev'
Deploy / deploy (push) Failing after 11m43s
CI / build (push) Failing after 13m4s

This commit is contained in:
ByungCheol
2026-06-06 14:08:30 +09:00
22 changed files with 2467 additions and 15 deletions
+4
View File
@@ -29,6 +29,10 @@ jobs:
npx oxlint . npx oxlint .
npx eslint . npx eslint .
# 단위/컴포넌트 테스트 — 실패 시 빌드/배포 차단(게이트)
- name: Test
run: npm test
- name: Build - name: Build
run: npm run build run: npm run build
+1
View File
@@ -16,6 +16,7 @@ export default defineConfig([
languageOptions: { languageOptions: {
globals: { globals: {
...globals.browser, ...globals.browser,
__APP_VERSION__: 'readonly',
}, },
}, },
}, },
+1367 -3
View File
File diff suppressed because it is too large Load Diff
+8 -2
View File
@@ -1,12 +1,14 @@
{ {
"name": "sb_pt", "name": "sb_pt",
"version": "0.0.0", "version": "1.0.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"build:app": "vite build --mode capacitor", "build:app": "vite build --mode capacitor",
"test": "vitest run",
"test:watch": "vitest",
"cap:sync": "npm run build:app && cap sync", "cap:sync": "npm run build:app && cap sync",
"cap:android": "cap open android", "cap:android": "cap open android",
"preview": "vite preview", "preview": "vite preview",
@@ -32,15 +34,19 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@pinia/testing": "^1.0.3",
"@vitejs/plugin-vue": "^6.0.6", "@vitejs/plugin-vue": "^6.0.6",
"@vue/test-utils": "^2.4.11",
"eslint": "^10.2.1", "eslint": "^10.2.1",
"eslint-plugin-oxlint": "~1.60.0", "eslint-plugin-oxlint": "~1.60.0",
"eslint-plugin-vue": "~10.8.0", "eslint-plugin-vue": "~10.8.0",
"globals": "^17.5.0", "globals": "^17.5.0",
"jsdom": "^29.1.1",
"npm-run-all2": "^8.0.4", "npm-run-all2": "^8.0.4",
"oxlint": "~1.60.0", "oxlint": "~1.60.0",
"vite": "^8.0.8", "vite": "^8.0.8",
"vite-plugin-vue-devtools": "^8.1.1" "vite-plugin-vue-devtools": "^8.1.1",
"vitest": "^4.1.8"
}, },
"engines": { "engines": {
"node": "^20.19.0 || >=22.12.0" "node": "^20.19.0 || >=22.12.0"
+2 -2
View File
@@ -3,7 +3,7 @@ import { onMounted, onUnmounted, watch } from 'vue'
import { RouterView, useRoute } from 'vue-router' import { RouterView, useRoute } from 'vue-router'
import AppHeader from '@/components/layout/AppHeader.vue' import AppHeader from '@/components/layout/AppHeader.vue'
import AppSidebar from '@/components/layout/AppSidebar.vue' import AppSidebar from '@/components/layout/AppSidebar.vue'
import AppFooter from '@/components/layout/AppFooter.vue' import AppBottomNav from '@/components/layout/AppBottomNav.vue'
import LoginModal from '@/components/LoginModal.vue' import LoginModal from '@/components/LoginModal.vue'
import SignupModal from '@/components/SignupModal.vue' import SignupModal from '@/components/SignupModal.vue'
import ChangePasswordModal from '@/components/ChangePasswordModal.vue' import ChangePasswordModal from '@/components/ChangePasswordModal.vue'
@@ -49,7 +49,7 @@ watch(() => route.fullPath, () => ui.closeSidebar())
<main class="layout-body"> <main class="layout-body">
<RouterView /> <RouterView />
</main> </main>
<AppFooter class="layout-bottom" /> <AppBottomNav class="layout-bottom" />
</div> </div>
<LoginModal /> <LoginModal />
+69
View File
@@ -0,0 +1,69 @@
import { describe, it, expect, vi } from 'vitest'
vi.mock('@/api/http', () => ({
default: {
get: vi.fn(() => Promise.resolve({})),
post: vi.fn(() => Promise.resolve({})),
put: vi.fn(() => Promise.resolve({})),
delete: vi.fn(() => Promise.resolve({})),
},
}))
import http from '@/api/http'
import { accountApi } from '@/api/accountApi'
describe('accountApi 가계부 CRUD 와이어링', () => {
it('list → GET /account/entries (필터 params)', () => {
accountApi.list({ year: 2026, month: 6, type: 'EXPENSE' })
expect(http.get).toHaveBeenCalledWith('/account/entries', {
params: { year: 2026, month: 6, type: 'EXPENSE', category: undefined, walletId: undefined, keyword: undefined, tagId: undefined },
})
})
it('create → POST /account/entries', () => {
const payload = { type: 'EXPENSE', amount: 1000, category: '식비' }
accountApi.create(payload)
expect(http.post).toHaveBeenCalledWith('/account/entries', payload)
})
it('update → PUT /account/entries/:id', () => {
accountApi.update(42, { amount: 2000 })
expect(http.put).toHaveBeenCalledWith('/account/entries/42', { amount: 2000 })
})
it('remove → DELETE /account/entries/:id', () => {
accountApi.remove(42)
expect(http.delete).toHaveBeenCalledWith('/account/entries/42')
})
it('summary → GET /account/summary', () => {
accountApi.summary({ year: 2026, month: 6 })
expect(http.get).toHaveBeenCalledWith('/account/summary', { params: { year: 2026, month: 6 } })
})
it('netWorth → GET /account/networth', () => {
accountApi.netWorth()
expect(http.get).toHaveBeenCalledWith('/account/networth')
})
it('budgetStatus → GET /account/budgets/status', () => {
accountApi.budgetStatus({ year: 2026, month: 6 })
expect(http.get).toHaveBeenCalledWith('/account/budgets/status', { params: { year: 2026, month: 6 } })
})
it('pendingCount → GET /account/entries/pending/count', () => {
accountApi.pendingCount()
expect(http.get).toHaveBeenCalledWith('/account/entries/pending/count')
})
it('confirmEntry → POST /account/entries/:id/confirm (payload 기본값 {})', () => {
accountApi.confirmEntry(7)
expect(http.post).toHaveBeenCalledWith('/account/entries/7/confirm', {})
})
it('createWallet → POST /account/wallets', () => {
const payload = { name: '주거래', type: 'ASSET' }
accountApi.createWallet(payload)
expect(http.post).toHaveBeenCalledWith('/account/wallets', payload)
})
})
+8
View File
@@ -20,4 +20,12 @@ export const authApi = {
changePassword(payload) { changePassword(payload) {
return http.put('/auth/password', payload) return http.put('/auth/password', payload)
}, },
// 가입정보 변경 진입 전 비밀번호 재인증
verifyPassword(password) {
return http.post('/auth/verify-password', { password })
},
// 가입정보(이름/이메일) 변경
updateProfile(payload) {
return http.put('/auth/profile', payload)
},
} }
+60
View File
@@ -0,0 +1,60 @@
import { describe, it, expect, vi } from 'vitest'
// http(axios 인스턴스)를 모킹 — 실제 네트워크 없이 호출 형태만 검증
vi.mock('@/api/http', () => ({
default: {
get: vi.fn(() => Promise.resolve({})),
post: vi.fn(() => Promise.resolve({})),
put: vi.fn(() => Promise.resolve({})),
delete: vi.fn(() => Promise.resolve({})),
},
}))
import http from '@/api/http'
import { authApi } from '@/api/authApi'
describe('authApi 엔드포인트 와이어링', () => {
it('signup → POST /auth/signup', () => {
const payload = { loginId: 'u1', password: 'pw', name: '홍길동' }
authApi.signup(payload)
expect(http.post).toHaveBeenCalledWith('/auth/signup', payload)
})
it('signupEnabled → GET /auth/signup-enabled', () => {
authApi.signupEnabled()
expect(http.get).toHaveBeenCalledWith('/auth/signup-enabled')
})
it('login → POST /auth/login', () => {
const payload = { loginId: 'u1', password: 'pw', rememberMe: true }
authApi.login(payload)
expect(http.post).toHaveBeenCalledWith('/auth/login', payload)
})
it('logout → POST /auth/logout', () => {
authApi.logout()
expect(http.post).toHaveBeenCalledWith('/auth/logout')
})
it('me → GET /auth/me', () => {
authApi.me()
expect(http.get).toHaveBeenCalledWith('/auth/me')
})
it('changePassword → PUT /auth/password', () => {
const payload = { currentPassword: 'a', newPassword: 'bbbbbbbb' }
authApi.changePassword(payload)
expect(http.put).toHaveBeenCalledWith('/auth/password', payload)
})
it('verifyPassword → POST /auth/verify-password (password 래핑)', () => {
authApi.verifyPassword('secret')
expect(http.post).toHaveBeenCalledWith('/auth/verify-password', { password: 'secret' })
})
it('updateProfile → PUT /auth/profile', () => {
const payload = { name: '새이름', email: 'a@b.com' }
authApi.updateProfile(payload)
expect(http.put).toHaveBeenCalledWith('/auth/profile', payload)
})
})
+81
View File
@@ -0,0 +1,81 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { setActivePinia, createPinia } from 'pinia'
// useRouter().push 를 캡처
const { pushMock } = vi.hoisted(() => ({ pushMock: vi.fn() }))
vi.mock('vue-router', () => ({ useRouter: () => ({ push: pushMock }) }))
vi.mock('@/api/authApi', () => ({
authApi: {
login: vi.fn(),
signupEnabled: vi.fn(() => Promise.resolve({ enabled: true })),
},
}))
vi.mock('@capacitor/preferences', () => ({
Preferences: {
set: vi.fn(() => Promise.resolve()),
get: vi.fn(() => Promise.resolve({ value: null })),
remove: vi.fn(() => Promise.resolve()),
},
}))
import { authApi } from '@/api/authApi'
import { useUiStore } from '@/stores/ui'
import LoginModal from '@/components/LoginModal.vue'
function mountOpen() {
const ui = useUiStore()
return { ui, wrapper: mount(LoginModal, { global: { stubs: { teleport: true } } }) }
}
async function submitLogin(wrapper) {
await wrapper.find('input[autocomplete="username"]').setValue('u1')
await wrapper.find('input[autocomplete="current-password"]').setValue('pw')
await wrapper.find('form').trigger('submit')
await flushPromises()
}
describe('LoginModal 로그인 플로우', () => {
beforeEach(() => {
setActivePinia(createPinia())
localStorage.clear()
authApi.login.mockResolvedValue({ token: 'tok', member: { id: 1, loginId: 'u1', name: 'N' } })
})
it('redirect 없으면 로그인 후 대시보드(/)로 이동', async () => {
const { ui, wrapper } = mountOpen()
ui.openLogin('')
await wrapper.vm.$nextTick()
await submitLogin(wrapper)
expect(authApi.login).toHaveBeenCalledWith({ loginId: 'u1', password: 'pw', rememberMe: true })
expect(pushMock).toHaveBeenCalledWith('/')
expect(ui.loginOpen).toBe(false)
})
it('redirect 있으면 보존된 목적지로 이동', async () => {
const { ui, wrapper } = mountOpen()
ui.openLogin('/account/wallets')
await wrapper.vm.$nextTick()
await submitLogin(wrapper)
expect(pushMock).toHaveBeenCalledWith('/account/wallets')
})
it('로그인 실패 시 에러 표시 + 이동 없음', async () => {
authApi.login.mockRejectedValueOnce({ response: { data: { message: '아이디 또는 비밀번호가 올바르지 않습니다.' } } })
const { ui, wrapper } = mountOpen()
ui.openLogin('')
await wrapper.vm.$nextTick()
await submitLogin(wrapper)
expect(pushMock).not.toHaveBeenCalled()
expect(ui.loginOpen).toBe(true)
expect(wrapper.text()).toContain('아이디 또는 비밀번호가 올바르지 않습니다.')
})
})
+6 -2
View File
@@ -13,6 +13,9 @@ function goSignup() {
ui.openSignup() ui.openSignup()
} }
// 소셜 로그인(네이버)은 준비 중 — 잠시 숨김. 구현 완료 시 true 로 전환.
const SOCIAL_LOGIN_ENABLED = false
const form = reactive({ loginId: '', password: '', rememberMe: true }) const form = reactive({ loginId: '', password: '', rememberMe: true })
const loading = ref(false) const loading = ref(false)
const error = ref(null) const error = ref(null)
@@ -41,7 +44,8 @@ async function handleLogin() {
await auth.login(form.loginId, form.password, form.rememberMe) await auth.login(form.loginId, form.password, form.rememberMe)
const redirect = ui.redirectPath const redirect = ui.redirectPath
ui.closeLogin() ui.closeLogin()
if (redirect) router.push(redirect) // 가드가 보존한 목적지가 있으면 그곳으로, 없으면 대시보드(홈)로 이동
router.push(redirect || '/')
} catch (e) { } catch (e) {
error.value = e.response?.data?.message || '로그인에 실패했습니다.' error.value = e.response?.data?.message || '로그인에 실패했습니다.'
} finally { } finally {
@@ -81,7 +85,7 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
<p v-if="error" class="msg error">{{ error }}</p> <p v-if="error" class="msg error">{{ error }}</p>
<button type="button" class="naver" :disabled="loading" @click="handleNaverLogin"> <button v-if="SOCIAL_LOGIN_ENABLED" type="button" class="naver" :disabled="loading" @click="handleNaverLogin">
네이버로 로그인 (준비 ) 네이버로 로그인 (준비 )
</button> </button>
+111
View File
@@ -0,0 +1,111 @@
<script setup>
import { computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
const router = useRouter()
const route = useRoute()
const isHome = computed(() => route.path === '/')
const isSettings = computed(() => route.path.startsWith('/settings'))
function goBack() {
// 히스토리가 있으면 뒤로, 없으면 홈으로 (앱 첫 화면에서 빠져나가지 않게)
if (window.history.length > 1) router.back()
else router.push('/')
}
function goForward() {
router.forward()
}
function goHome() {
if (!isHome.value) router.push('/')
}
function refresh() {
// 현재 화면 데이터를 다시 불러옴 — 저장된 세션은 복원되므로 로그아웃되지 않음
window.location.reload()
}
function goSettings() {
if (!isSettings.value) router.push('/settings')
}
</script>
<template>
<nav class="bottom-nav" aria-label=" 내비게이션">
<button type="button" class="nav-btn" @click="goBack">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M19 12H5" /><path d="M12 19l-7-7 7-7" />
</svg>
<span>뒤로</span>
</button>
<button type="button" class="nav-btn" @click="goForward">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M5 12h14" /><path d="M12 5l7 7-7 7" />
</svg>
<span>앞으로</span>
</button>
<button type="button" class="nav-btn" :class="{ active: isHome }" @click="goHome">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" /><path d="M9 22V12h6v10" />
</svg>
<span></span>
</button>
<button type="button" class="nav-btn" @click="refresh">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M23 4v6h-6" /><path d="M1 20v-6h6" />
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10" /><path d="M1 14l4.64 4.36A9 9 0 0 0 20.49 15" />
</svg>
<span>새로고침</span>
</button>
<button type="button" class="nav-btn" :class="{ active: isSettings }" @click="goSettings">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 21v-7" /><path d="M4 10V3" /><path d="M12 21v-9" /><path d="M12 8V3" />
<path d="M20 21v-5" /><path d="M20 12V3" /><path d="M1 14h6" /><path d="M9 8h6" /><path d="M17 16h6" />
</svg>
<span>설정</span>
</button>
</nav>
</template>
<style scoped>
.bottom-nav {
display: flex;
align-items: stretch;
justify-content: space-around;
height: 56px;
padding-bottom: env(safe-area-inset-bottom, 0);
background: var(--color-background-soft);
border-top: 1px solid var(--color-border);
}
.nav-btn {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.15rem;
border: 0;
background: transparent;
color: var(--color-text);
cursor: pointer;
font-size: 0.68rem;
opacity: 0.78;
transition: opacity 0.15s ease, color 0.15s ease;
}
.nav-btn svg {
width: 22px;
height: 22px;
}
.nav-btn:hover {
opacity: 1;
}
.nav-btn:active {
opacity: 0.5;
}
.nav-btn.active {
color: hsla(160, 100%, 37%, 1);
opacity: 1;
}
</style>
+1 -1
View File
@@ -17,7 +17,7 @@ async function handleLogout() {
<template> <template>
<header class="app-header"> <header class="app-header">
<div class="header-left"> <div class="header-left">
<button class="hamburger" type="button" aria-label="메뉴" @click="ui.toggleSidebar()"> <button v-if="auth.isAuthenticated" class="hamburger" type="button" aria-label="메뉴" @click="ui.toggleSidebar()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<path d="M3 12h18" /><path d="M3 6h18" /><path d="M3 18h18" /> <path d="M3 12h18" /><path d="M3 6h18" /><path d="M3 18h18" />
</svg> </svg>
+1 -4
View File
@@ -15,11 +15,8 @@ const ui = useUiStore()
<button class="drawer-close" type="button" aria-label="닫기" @click="ui.closeSidebar()">×</button> <button class="drawer-close" type="button" aria-label="닫기" @click="ui.closeSidebar()">×</button>
</div> </div>
<nav class="menu"> <nav class="menu">
<RouterLink to="/" class="menu-item"></RouterLink> <!-- 가계부 영역 (홈은 하단 내비게이션으로 이동) -->
<!-- 가계부 영역 -->
<template v-if="auth.isAuthenticated"> <template v-if="auth.isAuthenticated">
<hr class="menu-divider" />
<RouterLink to="/account/entries" class="menu-item">가계부 내역</RouterLink> <RouterLink to="/account/entries" class="menu-item">가계부 내역</RouterLink>
<RouterLink to="/account/stats" class="menu-item">통계</RouterLink> <RouterLink to="/account/stats" class="menu-item">통계</RouterLink>
<RouterLink to="/account/recurrings" class="menu-item">고정 지출</RouterLink> <RouterLink to="/account/recurrings" class="menu-item">고정 지출</RouterLink>
+19
View File
@@ -98,6 +98,25 @@ const router = createRouter({
component: () => import('../views/account/RecurringView.vue'), component: () => import('../views/account/RecurringView.vue'),
meta: { requiresAuth: true }, meta: { requiresAuth: true },
}, },
// 설정 (앱 하단 내비게이션 → 설정)
{
path: '/settings',
name: 'settings',
component: () => import('../views/settings/SettingsView.vue'),
meta: { requiresAuth: true },
},
{
path: '/settings/account',
name: 'settings-account',
component: () => import('../views/settings/AccountInfoView.vue'),
meta: { requiresAuth: true },
},
{
path: '/settings/account/edit',
name: 'settings-account-edit',
component: () => import('../views/settings/ProfileEditView.vue'),
meta: { requiresAuth: true },
},
], ],
}) })
+7 -1
View File
@@ -84,13 +84,19 @@ export const useAuthStore = defineStore('auth', () => {
await clear() await clear()
} }
// 가입정보 변경 등으로 바뀐 사용자 필드를 로컬 세션에 병합 (헤더 이름 등 즉시 반영)
async function applyUser(partial) {
user.value = { ...(user.value || {}), ...partial }
await persist()
}
async function clear() { async function clear() {
token.value = '' token.value = ''
user.value = null user.value = null
await persist() await persist()
} }
return { token, user, ready, isAuthenticated, restore, login, signup, fetchMe, logout, clear } return { token, user, ready, isAuthenticated, restore, login, signup, fetchMe, applyUser, logout, clear }
}) })
function safeParse(value) { function safeParse(value) {
+100
View File
@@ -0,0 +1,100 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
vi.mock('@/api/authApi', () => ({
authApi: {
login: vi.fn(),
logout: vi.fn(() => Promise.resolve()),
me: vi.fn(),
signup: vi.fn(),
signupEnabled: vi.fn(),
},
}))
// 네이티브 저장소 — 테스트에선 no-op (localStorage 미러만 검증)
vi.mock('@capacitor/preferences', () => ({
Preferences: {
set: vi.fn(() => Promise.resolve()),
get: vi.fn(() => Promise.resolve({ value: null })),
remove: vi.fn(() => Promise.resolve()),
clear: vi.fn(() => Promise.resolve()),
},
}))
import { authApi } from '@/api/authApi'
import { useAuthStore } from '@/stores/auth'
describe('auth 스토어', () => {
beforeEach(() => {
setActivePinia(createPinia())
localStorage.clear()
})
it('login: 토큰/사용자 저장 + localStorage 미러 + isAuthenticated', async () => {
authApi.login.mockResolvedValue({
token: 'tok123',
member: { id: 1, loginId: 'u1', name: '홍길동', email: 'a@b.com' },
})
const store = useAuthStore()
await store.login('u1', 'pw', true)
expect(authApi.login).toHaveBeenCalledWith({ loginId: 'u1', password: 'pw', rememberMe: true })
expect(store.token).toBe('tok123')
expect(store.isAuthenticated).toBe(true)
expect(store.user.email).toBe('a@b.com')
expect(localStorage.getItem('token')).toBe('tok123')
expect(JSON.parse(localStorage.getItem('auth_user')).name).toBe('홍길동')
})
it('logout: 상태/스토리지 정리', async () => {
authApi.login.mockResolvedValue({ token: 't', member: { id: 1, loginId: 'u1' } })
const store = useAuthStore()
await store.login('u1', 'pw', false)
expect(store.isAuthenticated).toBe(true)
await store.logout()
expect(authApi.logout).toHaveBeenCalled()
expect(store.token).toBe('')
expect(store.user).toBeNull()
expect(store.isAuthenticated).toBe(false)
expect(localStorage.getItem('token')).toBeNull()
expect(localStorage.getItem('auth_user')).toBeNull()
})
it('logout: 서버 401 이어도 클라이언트는 정리', async () => {
authApi.login.mockResolvedValue({ token: 't', member: { id: 1 } })
authApi.logout.mockRejectedValueOnce(new Error('401'))
const store = useAuthStore()
await store.login('u1', 'pw', false)
await store.logout()
expect(store.token).toBe('')
expect(store.isAuthenticated).toBe(false)
})
it('restore: localStorage 세션 복원 + ready', async () => {
localStorage.setItem('token', 't2')
localStorage.setItem('auth_user', JSON.stringify({ id: 2, name: '복원유저' }))
const store = useAuthStore()
await store.restore()
expect(store.token).toBe('t2')
expect(store.user.name).toBe('복원유저')
expect(store.isAuthenticated).toBe(true)
expect(store.ready).toBe(true)
})
it('applyUser: 변경 필드 병합 + 미러 갱신 (가입정보 변경 반영)', async () => {
authApi.login.mockResolvedValue({
token: 't',
member: { id: 1, loginId: 'u1', name: '원래이름', email: 'old@b.com' },
})
const store = useAuthStore()
await store.login('u1', 'pw', false)
await store.applyUser({ name: '새이름', email: 'new@b.com' })
expect(store.user.name).toBe('새이름')
expect(store.user.loginId).toBe('u1') // 기존 필드 보존
expect(JSON.parse(localStorage.getItem('auth_user')).email).toBe('new@b.com')
})
})
+64
View File
@@ -0,0 +1,64 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
vi.mock('@/api/authApi', () => ({
authApi: {
signupEnabled: vi.fn(() => Promise.resolve({ enabled: true })),
},
}))
import { authApi } from '@/api/authApi'
import { useUiStore } from '@/stores/ui'
describe('ui 스토어', () => {
beforeEach(() => setActivePinia(createPinia()))
it('openLogin: 로그인 열고 회원가입 닫고 redirect 보존', () => {
const ui = useUiStore()
ui.signupOpen = true
ui.openLogin('/account/entries')
expect(ui.loginOpen).toBe(true)
expect(ui.signupOpen).toBe(false)
expect(ui.redirectPath).toBe('/account/entries')
})
it('closeLogin: 닫고 redirect 초기화', () => {
const ui = useUiStore()
ui.openLogin('/x')
ui.closeLogin()
expect(ui.loginOpen).toBe(false)
expect(ui.redirectPath).toBe('')
})
it('openSignup: 회원가입 열고 로그인 닫고 허용여부 재조회', () => {
const ui = useUiStore()
ui.loginOpen = true
ui.openSignup()
expect(ui.signupOpen).toBe(true)
expect(ui.loginOpen).toBe(false)
expect(authApi.signupEnabled).toHaveBeenCalled()
})
it('toggleSidebar / closeSidebar', () => {
const ui = useUiStore()
expect(ui.sidebarOpen).toBe(false)
ui.toggleSidebar()
expect(ui.sidebarOpen).toBe(true)
ui.closeSidebar()
expect(ui.sidebarOpen).toBe(false)
})
it('loadSignupEnabled: API 값 반영', async () => {
authApi.signupEnabled.mockResolvedValueOnce({ enabled: false })
const ui = useUiStore()
await ui.loadSignupEnabled()
expect(ui.signupEnabled).toBe(false)
})
it('loadSignupEnabled: 조회 실패 시 기본 허용(true)', async () => {
authApi.signupEnabled.mockRejectedValueOnce(new Error('net'))
const ui = useUiStore()
await ui.loadSignupEnabled()
expect(ui.signupEnabled).toBe(true)
})
})
+145
View File
@@ -0,0 +1,145 @@
<script setup>
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const auth = useAuthStore()
const router = useRouter()
const u = computed(() => auth.user || {})
const isLocal = computed(() => (u.value.provider || 'LOCAL') === 'LOCAL')
const providerLabel = computed(() => {
switch (u.value.provider) {
case 'NAVER': return '네이버'
case 'LOCAL':
default: return '일반(아이디/비밀번호)'
}
})
const roleLabel = computed(() => (u.value.role === 'ADMIN' ? '관리자' : '일반회원'))
function goEdit() {
router.push('/settings/account/edit')
}
function goBack() {
router.push('/settings')
}
</script>
<template>
<div class="account-info">
<button type="button" class="back" @click="goBack">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M19 12H5" /><path d="M12 19l-7-7 7-7" />
</svg>
<span>설정</span>
</button>
<h1 class="page-title">계정정보</h1>
<section class="card">
<div class="row">
<span class="k">아이디</span>
<span class="v">{{ u.loginId || '-' }}</span>
</div>
<div class="row">
<span class="k">이름</span>
<span class="v">{{ u.name || '-' }}</span>
</div>
<div class="row">
<span class="k">이메일</span>
<span class="v">{{ u.email || '-' }}</span>
</div>
<div class="row">
<span class="k">가입유형</span>
<span class="v">{{ providerLabel }}</span>
</div>
<div class="row">
<span class="k">권한</span>
<span class="v">{{ roleLabel }}</span>
</div>
</section>
<button v-if="isLocal" type="button" class="primary-btn" @click="goEdit">정보변경</button>
<p v-else class="hint">소셜 로그인 계정은 앱에서 가입정보를 변경할 없습니다.</p>
</div>
</template>
<style scoped>
.account-info {
max-width: 560px;
margin: 0 auto;
}
.back {
display: inline-flex;
align-items: center;
gap: 0.3rem;
margin-bottom: 0.75rem;
padding: 0.3rem 0.1rem;
border: 0;
background: transparent;
color: var(--color-text);
opacity: 0.75;
cursor: pointer;
font-size: 0.9rem;
}
.back:hover {
opacity: 1;
}
.back svg {
width: 18px;
height: 18px;
}
.page-title {
font-size: 1.4rem;
margin-bottom: 1rem;
}
.card {
background: var(--color-background-soft);
border: 1px solid var(--color-border);
border-radius: 10px;
overflow: hidden;
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.95rem 1.1rem;
border-bottom: 1px solid var(--color-border);
}
.row:last-child {
border-bottom: 0;
}
.k {
font-size: 0.88rem;
opacity: 0.65;
}
.v {
font-size: 0.95rem;
font-weight: 600;
text-align: right;
word-break: break-all;
}
.primary-btn {
width: 100%;
margin-top: 1.25rem;
padding: 0.8rem 1rem;
border: 1px solid hsla(160, 100%, 37%, 1);
border-radius: 8px;
background: hsla(160, 100%, 37%, 1);
color: #fff;
font-size: 0.98rem;
font-weight: 600;
cursor: pointer;
}
.primary-btn:hover {
background: hsla(160, 100%, 32%, 1);
}
.hint {
margin-top: 1.25rem;
font-size: 0.85rem;
opacity: 0.6;
text-align: center;
}
</style>
+238
View File
@@ -0,0 +1,238 @@
<script setup>
import { reactive, ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { authApi } from '@/api/authApi'
const auth = useAuthStore()
const router = useRouter()
const step = ref('verify') // 'verify' → 'edit'
const password = ref('')
const form = reactive({ name: '', email: '' })
const loading = ref(false)
const error = ref('')
onMounted(() => {
// 소셜 계정은 비밀번호 인증 불가 → 계정정보로 돌려보냄
if ((auth.user?.provider || 'LOCAL') !== 'LOCAL') {
router.replace('/settings/account')
}
})
async function verify() {
error.value = ''
if (!password.value) {
error.value = '비밀번호를 입력하세요.'
return
}
loading.value = true
try {
await authApi.verifyPassword(password.value)
// 인증 통과 → 현재 값으로 폼 채우고 변경 화면 진입
form.name = auth.user?.name || ''
form.email = auth.user?.email || ''
step.value = 'edit'
} catch (e) {
error.value = e.response?.data?.message || '비밀번호 인증에 실패했습니다.'
} finally {
loading.value = false
}
}
async function save() {
error.value = ''
const name = form.name.trim()
if (!name) {
error.value = '이름을 입력하세요.'
return
}
const email = form.email.trim()
if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
error.value = '이메일 형식이 올바르지 않습니다.'
return
}
loading.value = true
try {
const res = await authApi.updateProfile({ name, email: email || null })
await auth.applyUser({ name: res.name, email: res.email })
window.alert('가입정보가 변경되었습니다.')
router.push('/settings/account')
} catch (e) {
error.value = e.response?.data?.message || '가입정보 변경에 실패했습니다.'
} finally {
loading.value = false
}
}
function cancel() {
router.push('/settings/account')
}
</script>
<template>
<div class="profile-edit">
<button type="button" class="back" @click="cancel">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M19 12H5" /><path d="M12 19l-7-7 7-7" />
</svg>
<span>계정정보</span>
</button>
<h1 class="page-title">가입정보 변경</h1>
<!-- 1단계: 비밀번호 재인증 -->
<form v-if="step === 'verify'" class="card form" @submit.prevent="verify">
<p class="guide">본인 확인을 위해 비밀번호를 입력하세요.</p>
<label class="field">
<span class="flabel">비밀번호</span>
<input
v-model="password" type="password" autocomplete="current-password"
placeholder="현재 비밀번호" :disabled="loading"
/>
</label>
<p v-if="error" class="error">{{ error }}</p>
<button class="primary-btn" type="submit" :disabled="loading">
{{ loading ? '확인 중…' : '확인' }}
</button>
</form>
<!-- 2단계: 가입정보 변경 -->
<form v-else class="card form" @submit.prevent="save">
<label class="field">
<span class="flabel">아이디</span>
<input :value="auth.user?.loginId" type="text" disabled />
<span class="fnote">아이디는 변경할 없습니다.</span>
</label>
<label class="field">
<span class="flabel">이름</span>
<input v-model="form.name" type="text" maxlength="100" placeholder="이름" :disabled="loading" />
</label>
<label class="field">
<span class="flabel">이메일</span>
<input v-model="form.email" type="email" maxlength="255" placeholder="이메일 (선택)" :disabled="loading" />
</label>
<p v-if="error" class="error">{{ error }}</p>
<div class="actions">
<button class="ghost-btn" type="button" :disabled="loading" @click="cancel">취소</button>
<button class="primary-btn" type="submit" :disabled="loading">
{{ loading ? '저장 중…' : '저장' }}
</button>
</div>
</form>
</div>
</template>
<style scoped>
.profile-edit {
max-width: 560px;
margin: 0 auto;
}
.back {
display: inline-flex;
align-items: center;
gap: 0.3rem;
margin-bottom: 0.75rem;
padding: 0.3rem 0.1rem;
border: 0;
background: transparent;
color: var(--color-text);
opacity: 0.75;
cursor: pointer;
font-size: 0.9rem;
}
.back:hover {
opacity: 1;
}
.back svg {
width: 18px;
height: 18px;
}
.page-title {
font-size: 1.4rem;
margin-bottom: 1rem;
}
.card {
background: var(--color-background-soft);
border: 1px solid var(--color-border);
border-radius: 10px;
padding: 1.25rem 1.1rem;
}
.form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.guide {
font-size: 0.88rem;
opacity: 0.7;
margin: 0;
}
.field {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.flabel {
font-size: 0.82rem;
opacity: 0.7;
}
.field input {
padding: 0.65rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-background);
color: var(--color-text);
font-size: 0.95rem;
}
.field input:disabled {
opacity: 0.6;
}
.fnote {
font-size: 0.74rem;
opacity: 0.5;
}
.error {
margin: 0;
color: #c0392b;
font-size: 0.85rem;
}
.actions {
display: flex;
gap: 0.6rem;
}
.actions .primary-btn {
flex: 1;
}
.primary-btn {
padding: 0.75rem 1rem;
border: 1px solid hsla(160, 100%, 37%, 1);
border-radius: 8px;
background: hsla(160, 100%, 37%, 1);
color: #fff;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
}
.primary-btn:hover:not(:disabled) {
background: hsla(160, 100%, 32%, 1);
}
.primary-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.ghost-btn {
flex: 1;
padding: 0.75rem 1rem;
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-background);
color: var(--color-text);
font-size: 0.95rem;
cursor: pointer;
}
.ghost-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>
+145
View File
@@ -0,0 +1,145 @@
<script setup>
import { ref } from 'vue'
import { RouterLink } from 'vue-router'
import { Preferences } from '@capacitor/preferences'
const appVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '-'
const clearing = ref(false)
async function clearAppData() {
if (clearing.value) return
const ok = window.confirm(
'앱에 저장된 모든 데이터(로그인 정보·캐시)를 삭제합니다.\n' +
'삭제 후 로그아웃되며 첫 화면으로 이동합니다.\n계속하시겠습니까?',
)
if (!ok) return
clearing.value = true
try {
try { await Preferences.clear() } catch { /* 네이티브 저장소 없음 무시 */ }
try { localStorage.clear() } catch { /* 무시 */ }
try { sessionStorage.clear() } catch { /* 무시 */ }
try {
if (window.caches?.keys) {
const keys = await window.caches.keys()
await Promise.all(keys.map((k) => window.caches.delete(k)))
}
} catch { /* 무시 */ }
} finally {
// 초기화 상태로 첫 화면 재시작
window.location.replace('/')
}
}
</script>
<template>
<div class="settings">
<h1 class="page-title">설정</h1>
<section class="card">
<RouterLink to="/settings/account" class="row row-link">
<div class="row-main">
<span class="row-label">계정정보</span>
<span class="row-sub">아이디·이름·이메일 확인 변경</span>
</div>
<svg class="chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 18l6-6-6-6" />
</svg>
</RouterLink>
<div class="row">
<div class="row-main">
<span class="row-label"> 버전 정보</span>
</div>
<span class="row-value">v{{ appVersion }}</span>
</div>
<button type="button" class="row row-btn" :disabled="clearing" @click="clearAppData">
<div class="row-main">
<span class="row-label danger">App Data 삭제</span>
<span class="row-sub">저장된 로그인 정보·캐시를 모두 비웁니다</span>
</div>
<span class="row-value danger">{{ clearing ? '삭제 중…' : '삭제' }}</span>
</button>
</section>
<p class="copyright">© 2026 SlimBudget. All rights reserved.</p>
</div>
</template>
<style scoped>
.settings {
max-width: 560px;
margin: 0 auto;
}
.page-title {
font-size: 1.4rem;
margin-bottom: 1rem;
}
.card {
background: var(--color-background-soft);
border: 1px solid var(--color-border);
border-radius: 10px;
overflow: hidden;
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
gap: 0.75rem;
padding: 1rem 1.1rem;
text-align: left;
background: transparent;
border: 0;
border-bottom: 1px solid var(--color-border);
color: var(--color-text);
}
.row:last-child {
border-bottom: 0;
}
.row-link,
.row-btn {
cursor: pointer;
}
.row-link:hover,
.row-btn:hover:not(:disabled) {
background: var(--color-background-mute);
}
.row-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.row-main {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.row-label {
font-size: 0.98rem;
font-weight: 600;
}
.row-sub {
font-size: 0.78rem;
opacity: 0.6;
}
.row-value {
font-size: 0.9rem;
opacity: 0.8;
white-space: nowrap;
}
.danger {
color: #c0392b;
}
.chevron {
width: 18px;
height: 18px;
opacity: 0.5;
flex-shrink: 0;
}
.copyright {
margin-top: 1.5rem;
text-align: center;
font-size: 0.78rem;
opacity: 0.5;
}
</style>
+7
View File
@@ -1,11 +1,18 @@
import { fileURLToPath, URL } from 'node:url' import { fileURLToPath, URL } from 'node:url'
import { readFileSync } from 'node:fs'
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
import vueDevTools from 'vite-plugin-vue-devtools' import vueDevTools from 'vite-plugin-vue-devtools'
// 앱 버전 정보(설정 > 앱 버전)에 노출 — package.json 을 단일 출처로 사용
const pkg = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf-8'))
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
define: {
__APP_VERSION__: JSON.stringify(pkg.version),
},
plugins: [ plugins: [
vue(), vue(),
vueDevTools(), vueDevTools(),
+23
View File
@@ -0,0 +1,23 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
// 단위/컴포넌트 테스트 전용 설정. (앱 빌드 설정은 vite.config.js)
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
define: {
// 일부 컴포넌트가 참조하는 빌드타임 상수
__APP_VERSION__: JSON.stringify('test'),
},
test: {
environment: 'jsdom',
globals: true,
include: ['src/**/*.{spec,test}.{js,mjs}'],
clearMocks: true,
},
})