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

This commit is contained in:
2026-04-21 23:21:53 -04:00
parent bc481527c8
commit 6982fa561f
8 changed files with 764 additions and 2 deletions

View File

@@ -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)
})
})
})

View 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)
})
})
})

View File

@@ -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++

View File

@@ -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>