Refactored frontend directory
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 2m46s
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 2m46s
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
// Use plain objects — the guard only reads `.value`, so full Vue refs are unnecessary
|
||||
const { isAuthReadyMock, isUserLoggedInMock, isParentAuthenticatedMock } = vi.hoisted(() => ({
|
||||
isAuthReadyMock: { value: true },
|
||||
isUserLoggedInMock: { value: false },
|
||||
isParentAuthenticatedMock: { value: false },
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/auth', () => ({
|
||||
isAuthReady: isAuthReadyMock,
|
||||
isUserLoggedIn: isUserLoggedInMock,
|
||||
isParentAuthenticated: isParentAuthenticatedMock,
|
||||
enforceParentExpiry: vi.fn(),
|
||||
setPendingReturnUrl: vi.fn(),
|
||||
}))
|
||||
|
||||
// Import router AFTER mocks are in place
|
||||
const { default: router } = await import('../index')
|
||||
|
||||
// Helper — navigate and return the resolved path
|
||||
async function navigate(path: string): Promise<string> {
|
||||
await router.push(path)
|
||||
return router.currentRoute.value.path
|
||||
}
|
||||
|
||||
describe('router auth guard', () => {
|
||||
beforeEach(async () => {
|
||||
isAuthReadyMock.value = true
|
||||
// Park at /auth/reset-password as a neutral starting point:
|
||||
// - it is always reachable when logged out
|
||||
// - it doesn't match any route a test assertion lands on
|
||||
isUserLoggedInMock.value = false
|
||||
isParentAuthenticatedMock.value = false
|
||||
await router.push('/auth/reset-password')
|
||||
})
|
||||
|
||||
// ── Redirect logged-in users away from /auth ──────────────────────────────
|
||||
|
||||
it('redirects logged-in parent user from /auth to /parent', async () => {
|
||||
isUserLoggedInMock.value = true
|
||||
isParentAuthenticatedMock.value = true
|
||||
|
||||
const path = await navigate('/auth')
|
||||
expect(path).toBe('/parent')
|
||||
})
|
||||
|
||||
it('redirects logged-in child user from /auth to /child', async () => {
|
||||
isUserLoggedInMock.value = true
|
||||
isParentAuthenticatedMock.value = false
|
||||
|
||||
const path = await navigate('/auth')
|
||||
expect(path).toBe('/child')
|
||||
})
|
||||
|
||||
it('redirects logged-in parent user from /auth/login to /parent', async () => {
|
||||
isUserLoggedInMock.value = true
|
||||
isParentAuthenticatedMock.value = true
|
||||
|
||||
const path = await navigate('/auth/login')
|
||||
expect(path).toBe('/parent')
|
||||
})
|
||||
|
||||
it('redirects logged-in child user from /auth/signup to /child', async () => {
|
||||
isUserLoggedInMock.value = true
|
||||
isParentAuthenticatedMock.value = false
|
||||
|
||||
const path = await navigate('/auth/signup')
|
||||
expect(path).toBe('/child')
|
||||
})
|
||||
|
||||
it('redirects logged-in child user from /auth/forgot-password to /child', async () => {
|
||||
isUserLoggedInMock.value = true
|
||||
isParentAuthenticatedMock.value = false
|
||||
|
||||
const path = await navigate('/auth/forgot-password')
|
||||
expect(path).toBe('/child')
|
||||
})
|
||||
|
||||
it('allows logged-in parent user to access /auth/reset-password', async () => {
|
||||
isUserLoggedInMock.value = true
|
||||
isParentAuthenticatedMock.value = true
|
||||
|
||||
const path = await navigate('/auth/reset-password')
|
||||
expect(path).toBe('/auth/reset-password')
|
||||
})
|
||||
|
||||
it('allows logged-in child user to access /auth/reset-password', async () => {
|
||||
isUserLoggedInMock.value = true
|
||||
isParentAuthenticatedMock.value = false
|
||||
|
||||
const path = await navigate('/auth/reset-password')
|
||||
expect(path).toBe('/auth/reset-password')
|
||||
})
|
||||
|
||||
// ── Unauthenticated users may access /auth ────────────────────────────────
|
||||
|
||||
it('allows unauthenticated user to access /auth', async () => {
|
||||
isUserLoggedInMock.value = false
|
||||
|
||||
const path = await navigate('/auth')
|
||||
expect(path).toBe('/auth')
|
||||
})
|
||||
|
||||
it('allows unauthenticated user to access /auth/login', async () => {
|
||||
isUserLoggedInMock.value = false
|
||||
|
||||
const path = await navigate('/auth/login')
|
||||
expect(path).toBe('/auth/login')
|
||||
})
|
||||
|
||||
// ── Unauthenticated users are redirected to / from protected routes ────────
|
||||
|
||||
it('redirects unauthenticated user from /parent to /', async () => {
|
||||
isUserLoggedInMock.value = false
|
||||
|
||||
const path = await navigate('/parent')
|
||||
expect(path).toBe('/')
|
||||
})
|
||||
|
||||
it('redirects unauthenticated user from /child to /', async () => {
|
||||
isUserLoggedInMock.value = false
|
||||
|
||||
const path = await navigate('/child')
|
||||
expect(path).toBe('/')
|
||||
})
|
||||
|
||||
// ── Authenticated users are routed to the correct section ─────────────────
|
||||
|
||||
it('allows parent-authenticated user to access /parent', async () => {
|
||||
isUserLoggedInMock.value = true
|
||||
isParentAuthenticatedMock.value = true
|
||||
|
||||
const path = await navigate('/parent')
|
||||
expect(path).toBe('/parent')
|
||||
})
|
||||
|
||||
it('allows child user to access /child', async () => {
|
||||
isUserLoggedInMock.value = true
|
||||
isParentAuthenticatedMock.value = false
|
||||
|
||||
const path = await navigate('/child')
|
||||
expect(path).toBe('/child')
|
||||
})
|
||||
|
||||
it('redirects child user away from /parent to /child', async () => {
|
||||
isUserLoggedInMock.value = true
|
||||
isParentAuthenticatedMock.value = false
|
||||
|
||||
const path = await navigate('/parent')
|
||||
expect(path).toBe('/child')
|
||||
})
|
||||
|
||||
it('redirects parent user away from /child to /parent', async () => {
|
||||
isUserLoggedInMock.value = true
|
||||
isParentAuthenticatedMock.value = true
|
||||
|
||||
const path = await navigate('/child')
|
||||
expect(path).toBe('/parent')
|
||||
})
|
||||
|
||||
// ── ParentPinSetup is always accessible ───────────────────────────────────
|
||||
|
||||
it('allows access to /parent/pin-setup regardless of auth state', async () => {
|
||||
isUserLoggedInMock.value = false
|
||||
|
||||
const path = await navigate('/parent/pin-setup')
|
||||
expect(path).toBe('/parent/pin-setup')
|
||||
})
|
||||
|
||||
// ── Landing page (/) ────────────────────────────────────────────────────────
|
||||
|
||||
it('allows unauthenticated user to access / (landing page)', async () => {
|
||||
isUserLoggedInMock.value = false
|
||||
|
||||
const path = await navigate('/')
|
||||
expect(path).toBe('/')
|
||||
})
|
||||
|
||||
it('redirects logged-in child user from / to /child', async () => {
|
||||
isUserLoggedInMock.value = true
|
||||
isParentAuthenticatedMock.value = false
|
||||
|
||||
const path = await navigate('/')
|
||||
expect(path).toBe('/child')
|
||||
})
|
||||
|
||||
it('redirects logged-in parent user from / to /parent', async () => {
|
||||
isUserLoggedInMock.value = true
|
||||
isParentAuthenticatedMock.value = true
|
||||
|
||||
const path = await navigate('/')
|
||||
expect(path).toBe('/parent')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,314 @@
|
||||
import { watch } from 'vue'
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import ChildLayout from '../layout/ChildLayout.vue'
|
||||
import ParentLayout from '../layout/ParentLayout.vue'
|
||||
import ChildrenListView from '../components/shared/ChildrenListView.vue'
|
||||
import ChildView from '../components/child/ChildView.vue'
|
||||
import ParentView from '../components/child/ParentView.vue'
|
||||
import TaskSubNav from '../components/task/TaskSubNav.vue'
|
||||
import ChoreView from '../components/task/ChoreView.vue'
|
||||
import KindnessView from '../components/task/KindnessView.vue'
|
||||
import PenaltyView from '../components/task/PenaltyView.vue'
|
||||
import ChoreEditView from '@/components/task/ChoreEditView.vue'
|
||||
import KindnessEditView from '@/components/task/KindnessEditView.vue'
|
||||
import PenaltyEditView from '@/components/task/PenaltyEditView.vue'
|
||||
import RewardView from '../components/reward/RewardView.vue'
|
||||
import RewardEditView from '@/components/reward/RewardEditView.vue'
|
||||
import ChildEditView from '@/components/child/ChildEditView.vue'
|
||||
import ChoreAssignView from '@/components/child/ChoreAssignView.vue'
|
||||
import KindnessAssignView from '@/components/child/KindnessAssignView.vue'
|
||||
import PenaltyAssignView from '@/components/child/PenaltyAssignView.vue'
|
||||
import RewardAssignView from '@/components/child/RewardAssignView.vue'
|
||||
import NotificationView from '@/components/notification/NotificationView.vue'
|
||||
import AuthLayout from '@/layout/AuthLayout.vue'
|
||||
import Signup from '@/components/auth/Signup.vue'
|
||||
import AuthLanding from '@/components/auth/AuthLanding.vue'
|
||||
import Login from '@/components/auth/Login.vue'
|
||||
import {
|
||||
isUserLoggedIn,
|
||||
isParentAuthenticated,
|
||||
isAuthReady,
|
||||
enforceParentExpiry,
|
||||
setPendingReturnUrl,
|
||||
} from '../stores/auth'
|
||||
import ParentPinSetup from '@/components/auth/ParentPinSetup.vue'
|
||||
import LandingPage from '@/components/landing/LandingPage.vue'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/auth',
|
||||
component: AuthLayout,
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'AuthLanding',
|
||||
component: AuthLanding,
|
||||
},
|
||||
{
|
||||
path: 'signup',
|
||||
name: 'Signup',
|
||||
component: Signup,
|
||||
},
|
||||
{
|
||||
path: 'login',
|
||||
name: 'Login',
|
||||
component: Login,
|
||||
},
|
||||
{
|
||||
path: 'verify',
|
||||
name: 'VerifySignup',
|
||||
component: () => import('@/components/auth/VerifySignup.vue'),
|
||||
},
|
||||
{
|
||||
path: 'forgot-password',
|
||||
name: 'ForgotPassword',
|
||||
component: () => import('@/components/auth/ForgotPassword.vue'),
|
||||
},
|
||||
{
|
||||
path: 'reset-password',
|
||||
name: 'ResetPassword',
|
||||
component: () => import('@/components/auth/ResetPassword.vue'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/child',
|
||||
component: ChildLayout,
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'ChildrenListView',
|
||||
component: ChildrenListView,
|
||||
},
|
||||
{
|
||||
path: ':id',
|
||||
name: 'ChildView',
|
||||
component: ChildView,
|
||||
props: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/parent',
|
||||
component: ParentLayout,
|
||||
meta: { requiresAuth: true },
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'ParentChildrenListView',
|
||||
component: ChildrenListView,
|
||||
},
|
||||
{
|
||||
path: ':id',
|
||||
name: 'ParentView',
|
||||
component: ParentView,
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: 'children/create',
|
||||
name: 'CreateChild',
|
||||
component: ChildEditView,
|
||||
},
|
||||
{
|
||||
path: ':id/edit',
|
||||
name: 'ChildEditView',
|
||||
component: ChildEditView,
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: 'tasks',
|
||||
component: TaskSubNav,
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'TaskView',
|
||||
redirect: { name: 'ChoreView' },
|
||||
},
|
||||
{
|
||||
path: 'chores',
|
||||
name: 'ChoreView',
|
||||
component: ChoreView,
|
||||
},
|
||||
{
|
||||
path: 'kindness',
|
||||
name: 'KindnessView',
|
||||
component: KindnessView,
|
||||
},
|
||||
{
|
||||
path: 'penalties',
|
||||
name: 'PenaltyView',
|
||||
component: PenaltyView,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: 'tasks/chores/create',
|
||||
name: 'CreateChore',
|
||||
component: ChoreEditView,
|
||||
},
|
||||
{
|
||||
path: 'tasks/chores/:id/edit',
|
||||
name: 'EditChore',
|
||||
component: ChoreEditView,
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: 'tasks/kindness/create',
|
||||
name: 'CreateKindness',
|
||||
component: KindnessEditView,
|
||||
},
|
||||
{
|
||||
path: 'tasks/kindness/:id/edit',
|
||||
name: 'EditKindness',
|
||||
component: KindnessEditView,
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: 'tasks/penalties/create',
|
||||
name: 'CreatePenalty',
|
||||
component: PenaltyEditView,
|
||||
},
|
||||
{
|
||||
path: 'tasks/penalties/:id/edit',
|
||||
name: 'EditPenalty',
|
||||
component: PenaltyEditView,
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: 'rewards',
|
||||
name: 'RewardView',
|
||||
component: RewardView,
|
||||
props: false,
|
||||
},
|
||||
{
|
||||
path: 'rewards/create',
|
||||
name: 'CreateReward',
|
||||
component: RewardEditView,
|
||||
},
|
||||
{
|
||||
path: 'rewards/:id/edit',
|
||||
name: 'EditReward',
|
||||
component: RewardEditView,
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: ':id/assign-chores',
|
||||
name: 'ChoreAssignView',
|
||||
component: ChoreAssignView,
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: ':id/assign-kindness',
|
||||
name: 'KindnessAssignView',
|
||||
component: KindnessAssignView,
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: ':id/assign-penalties',
|
||||
name: 'PenaltyAssignView',
|
||||
component: PenaltyAssignView,
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: ':id/assign-rewards',
|
||||
name: 'RewardAssignView',
|
||||
component: RewardAssignView,
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: 'notifications',
|
||||
name: 'NotificationView',
|
||||
component: NotificationView,
|
||||
props: false,
|
||||
},
|
||||
{
|
||||
path: 'profile',
|
||||
name: 'UserProfile',
|
||||
component: () => import('@/components/profile/UserProfile.vue'),
|
||||
},
|
||||
{
|
||||
path: 'pin-setup',
|
||||
name: 'ParentPinSetup',
|
||||
component: ParentPinSetup,
|
||||
meta: { requiresAuth: true, allowNoParent: true },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
name: 'LandingPage',
|
||||
component: LandingPage,
|
||||
meta: { isPublic: true },
|
||||
},
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
scrollBehavior() {
|
||||
return { top: 0, left: 0, behavior: 'smooth' }
|
||||
},
|
||||
})
|
||||
|
||||
// Auth guard
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
if (!isAuthReady.value) {
|
||||
await new Promise((resolve) => {
|
||||
const stop = watch(isAuthReady, (ready) => {
|
||||
if (ready) {
|
||||
stop()
|
||||
resolve(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// If already logged in and trying to access /auth or landing, redirect to appropriate view
|
||||
// Allow reset-password and verify through even when logged in (valid use-case from profile page)
|
||||
const authBypassRoutes = ['/auth/reset-password', '/auth/verify']
|
||||
if (
|
||||
(to.path.startsWith('/auth') || to.path === '/') &&
|
||||
isUserLoggedIn.value &&
|
||||
!authBypassRoutes.some((p) => to.path.startsWith(p))
|
||||
) {
|
||||
if (isParentAuthenticated.value) {
|
||||
return next('/parent')
|
||||
} else {
|
||||
return next('/child')
|
||||
}
|
||||
}
|
||||
|
||||
// Always allow /auth, landing page, and /parent/pin-setup
|
||||
if (to.path.startsWith('/auth') || to.path === '/' || to.name === 'ParentPinSetup') {
|
||||
return next()
|
||||
}
|
||||
|
||||
// If not logged in, redirect to landing page
|
||||
if (!isUserLoggedIn.value) {
|
||||
return next('/')
|
||||
}
|
||||
|
||||
// If parent-authenticated, allow all /parent routes
|
||||
// Enforce expiry first so an elapsed session is caught immediately on navigation
|
||||
enforceParentExpiry()
|
||||
if (isParentAuthenticated.value && to.path.startsWith('/parent')) {
|
||||
return next()
|
||||
}
|
||||
|
||||
// If not parent-authenticated, allow all /child routes
|
||||
if (!isParentAuthenticated.value && to.path.startsWith('/child')) {
|
||||
return next()
|
||||
}
|
||||
|
||||
// Otherwise, redirect based on parent authentication
|
||||
if (isParentAuthenticated.value) {
|
||||
return next('/parent')
|
||||
} else {
|
||||
if (to.path.startsWith('/parent')) {
|
||||
setPendingReturnUrl(to.fullPath)
|
||||
}
|
||||
return next('/child')
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
Reference in New Issue
Block a user