feat: implement state expiry for chores and rewards, adding scheduler and event handling
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m40s
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m40s
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
import NotificationView from '../notification/NotificationView.vue'
|
||||
|
||||
// ── Hoisted mocks ────────────────────────────────────────────────────────────
|
||||
|
||||
let capturedChoreHandler: ((event: any) => void) | null = null
|
||||
let capturedRewardHandler: ((event: any) => void) | null = null
|
||||
|
||||
vi.mock('@/common/eventBus', () => ({
|
||||
eventBus: {
|
||||
on: vi.fn((event: string, handler: (e: any) => void) => {
|
||||
if (event === 'child_chore_confirmation') capturedChoreHandler = handler
|
||||
if (event === 'child_reward_request') capturedRewardHandler = handler
|
||||
}),
|
||||
off: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/common/backendEvents', () => ({ useBackendEvents: vi.fn() }))
|
||||
|
||||
vi.mock('../shared/ItemList.vue', () => ({
|
||||
default: { template: '<div class="item-list-stub" />', props: ['fetchUrl', 'key'] },
|
||||
}))
|
||||
|
||||
vi.mock('../shared/MessageBlock.vue', () => ({
|
||||
default: { template: '<div class="message-block-stub" />', props: ['message'] },
|
||||
}))
|
||||
|
||||
// ── Router ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const mockRouter = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: '/', name: 'Landing', component: { template: '<div />' } },
|
||||
{ path: '/parent/:id', name: 'ParentView', component: { template: '<div />' } },
|
||||
],
|
||||
})
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function mountView() {
|
||||
capturedChoreHandler = null
|
||||
capturedRewardHandler = null
|
||||
return mount(NotificationView, {
|
||||
global: { plugins: [mockRouter] },
|
||||
})
|
||||
}
|
||||
|
||||
function fireChoreConfirmation(operation: string) {
|
||||
capturedChoreHandler?.({ payload: { operation, child_id: 'c1', task_id: 't1' } })
|
||||
}
|
||||
|
||||
function fireRewardRequest(operation: string) {
|
||||
capturedRewardHandler?.({ payload: { operation, child_id: 'c1', reward_id: 'r1' } })
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('NotificationView – chore confirmation handler', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
const refreshingOperations = ['CONFIRMED', 'APPROVED', 'REJECTED', 'CANCELLED', 'RESET']
|
||||
|
||||
refreshingOperations.forEach((op) => {
|
||||
it(`refreshes list on ${op} operation`, async () => {
|
||||
const wrapper = mountView()
|
||||
const before = (wrapper.vm as any).refreshKey
|
||||
fireChoreConfirmation(op)
|
||||
await flushPromises()
|
||||
expect((wrapper.vm as any).refreshKey).toBeGreaterThan(before)
|
||||
})
|
||||
})
|
||||
|
||||
it('does not refresh on unknown operation', async () => {
|
||||
const wrapper = mountView()
|
||||
const before = (wrapper.vm as any).refreshKey
|
||||
fireChoreConfirmation('UNKNOWN_OP')
|
||||
await flushPromises()
|
||||
expect((wrapper.vm as any).refreshKey).toBe(before)
|
||||
})
|
||||
})
|
||||
|
||||
describe('NotificationView – reward request handler', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
const refreshingOperations = ['CREATED', 'CANCELLED', 'GRANTED']
|
||||
|
||||
refreshingOperations.forEach((op) => {
|
||||
it(`refreshes list on ${op} operation`, async () => {
|
||||
const wrapper = mountView()
|
||||
const before = (wrapper.vm as any).refreshKey
|
||||
fireRewardRequest(op)
|
||||
await flushPromises()
|
||||
expect((wrapper.vm as any).refreshKey).toBeGreaterThan(before)
|
||||
})
|
||||
})
|
||||
})
|
||||
158
frontend/vue-app/src/components/__tests__/ParentLayout.spec.ts
Normal file
158
frontend/vue-app/src/components/__tests__/ParentLayout.spec.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
import ParentLayout from '../../layout/ParentLayout.vue'
|
||||
|
||||
// ── Hoisted mocks ────────────────────────────────────────────────────────────
|
||||
|
||||
let capturedChoreHandler: ((event: any) => void) | null = null
|
||||
let capturedRewardHandler: ((event: any) => void) | null = null
|
||||
|
||||
vi.mock('@/common/eventBus', () => ({
|
||||
eventBus: {
|
||||
on: vi.fn((event: string, handler: (e: any) => void) => {
|
||||
if (event === 'child_chore_confirmation') capturedChoreHandler = handler
|
||||
if (event === 'child_reward_request') capturedRewardHandler = handler
|
||||
}),
|
||||
off: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/common/backendEvents', () => ({ useBackendEvents: vi.fn() }))
|
||||
|
||||
// Stub fetch: /api/pending-confirmations returns count, /api/version returns version
|
||||
const mockFetch = vi.fn().mockImplementation((url: string) => {
|
||||
if (url === '/api/pending-confirmations') {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ confirmations: [] }),
|
||||
})
|
||||
}
|
||||
if (url === '/api/version') {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ version: '1.0.0' }),
|
||||
})
|
||||
}
|
||||
return Promise.resolve({ ok: false })
|
||||
})
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
|
||||
// ── Router ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const mockRouter = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: '/parent', name: 'ParentChildrenListView', component: { template: '<div />' } },
|
||||
{ path: '/notifications', name: 'NotificationView', component: { template: '<div />' } },
|
||||
{ path: '/tasks', name: 'TaskView', component: { template: '<div />' } },
|
||||
{ path: '/chores', name: 'ChoreView', component: { template: '<div />' } },
|
||||
{ path: '/rewards', name: 'RewardView', component: { template: '<div />' } },
|
||||
],
|
||||
})
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function mountLayout() {
|
||||
capturedChoreHandler = null
|
||||
capturedRewardHandler = null
|
||||
return mount(ParentLayout, {
|
||||
global: { plugins: [mockRouter] },
|
||||
})
|
||||
}
|
||||
|
||||
function fireChoreConfirmation(operation: string) {
|
||||
capturedChoreHandler?.({ payload: { operation, child_id: 'c1', task_id: 't1' } })
|
||||
}
|
||||
|
||||
function fireRewardRequest(operation: string) {
|
||||
capturedRewardHandler?.({ payload: { operation, child_id: 'c1', reward_id: 'r1' } })
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('ParentLayout – notification badge – chore confirmation handler', () => {
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
mockFetch.mockImplementation((url: string) => {
|
||||
if (url === '/api/pending-confirmations') {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ confirmations: [] }),
|
||||
})
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve({ version: '' }) })
|
||||
})
|
||||
})
|
||||
|
||||
const refreshingOperations = ['CONFIRMED', 'APPROVED', 'REJECTED', 'CANCELLED', 'RESET']
|
||||
|
||||
refreshingOperations.forEach((op) => {
|
||||
it(`refetches notification count on ${op} operation`, async () => {
|
||||
mountLayout()
|
||||
await flushPromises()
|
||||
const callsBefore = mockFetch.mock.calls.filter(
|
||||
(c) => c[0] === '/api/pending-confirmations',
|
||||
).length
|
||||
|
||||
fireChoreConfirmation(op)
|
||||
await flushPromises()
|
||||
|
||||
const callsAfter = mockFetch.mock.calls.filter(
|
||||
(c) => c[0] === '/api/pending-confirmations',
|
||||
).length
|
||||
expect(callsAfter).toBeGreaterThan(callsBefore)
|
||||
})
|
||||
})
|
||||
|
||||
it('does not refetch on unknown operation', async () => {
|
||||
mountLayout()
|
||||
await flushPromises()
|
||||
const callsBefore = mockFetch.mock.calls.filter(
|
||||
(c) => c[0] === '/api/pending-confirmations',
|
||||
).length
|
||||
|
||||
fireChoreConfirmation('UNKNOWN_OP')
|
||||
await flushPromises()
|
||||
|
||||
const callsAfter = mockFetch.mock.calls.filter(
|
||||
(c) => c[0] === '/api/pending-confirmations',
|
||||
).length
|
||||
expect(callsAfter).toBe(callsBefore)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ParentLayout – notification badge – reward request handler', () => {
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
mockFetch.mockImplementation((url: string) => {
|
||||
if (url === '/api/pending-confirmations') {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ confirmations: [] }),
|
||||
})
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve({ version: '' }) })
|
||||
})
|
||||
})
|
||||
|
||||
const refreshingOperations = ['CREATED', 'CANCELLED', 'GRANTED']
|
||||
|
||||
refreshingOperations.forEach((op) => {
|
||||
it(`refetches notification count on ${op} operation`, async () => {
|
||||
mountLayout()
|
||||
await flushPromises()
|
||||
const callsBefore = mockFetch.mock.calls.filter(
|
||||
(c) => c[0] === '/api/pending-confirmations',
|
||||
).length
|
||||
|
||||
fireRewardRequest(op)
|
||||
await flushPromises()
|
||||
|
||||
const callsAfter = mockFetch.mock.calls.filter(
|
||||
(c) => c[0] === '/api/pending-confirmations',
|
||||
).length
|
||||
expect(callsAfter).toBeGreaterThan(callsBefore)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -77,7 +77,8 @@ function handleChoreConfirmation(event: Event) {
|
||||
payload.operation === 'CONFIRMED' ||
|
||||
payload.operation === 'APPROVED' ||
|
||||
payload.operation === 'REJECTED' ||
|
||||
payload.operation === 'CANCELLED'
|
||||
payload.operation === 'CANCELLED' ||
|
||||
payload.operation === 'RESET'
|
||||
) {
|
||||
notificationListCountRef.value = -1
|
||||
refreshKey.value++
|
||||
|
||||
@@ -7,6 +7,8 @@ import type {
|
||||
Event,
|
||||
ChildRewardRequestEventPayload,
|
||||
ChildChoreConfirmationPayload,
|
||||
ChildTasksSetEventPayload,
|
||||
ChildRewardsSetEventPayload,
|
||||
} from '@/common/models'
|
||||
|
||||
const router = useRouter()
|
||||
@@ -60,11 +62,19 @@ function handleRewardRequestBadge(event: Event) {
|
||||
|
||||
function handleChoreConfirmationBadge(event: Event) {
|
||||
const payload = event.payload as ChildChoreConfirmationPayload
|
||||
if (['CONFIRMED', 'APPROVED', 'REJECTED', 'CANCELLED'].includes(payload.operation)) {
|
||||
if (['CONFIRMED', 'APPROVED', 'REJECTED', 'CANCELLED', 'RESET'].includes(payload.operation)) {
|
||||
fetchNotificationCount()
|
||||
}
|
||||
}
|
||||
|
||||
function handleTasksSetBadge(_event: Event) {
|
||||
fetchNotificationCount()
|
||||
}
|
||||
|
||||
function handleRewardsSetBadge(_event: Event) {
|
||||
fetchNotificationCount()
|
||||
}
|
||||
|
||||
// Version fetching
|
||||
const appVersion = ref('')
|
||||
|
||||
@@ -72,6 +82,8 @@ onMounted(async () => {
|
||||
await fetchNotificationCount()
|
||||
eventBus.on('child_reward_request', handleRewardRequestBadge)
|
||||
eventBus.on('child_chore_confirmation', handleChoreConfirmationBadge)
|
||||
eventBus.on('child_tasks_set', handleTasksSetBadge)
|
||||
eventBus.on('child_rewards_set', handleRewardsSetBadge)
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/version')
|
||||
@@ -87,6 +99,8 @@ onMounted(async () => {
|
||||
onUnmounted(() => {
|
||||
eventBus.off('child_reward_request', handleRewardRequestBadge)
|
||||
eventBus.off('child_chore_confirmation', handleChoreConfirmationBadge)
|
||||
eventBus.off('child_tasks_set', handleTasksSetBadge)
|
||||
eventBus.off('child_rewards_set', handleRewardsSetBadge)
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user