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

103
.github/specs/feat-state-expire.md vendored Normal file
View File

@@ -0,0 +1,103 @@
# Feature: Chore and Reward states expire at the end of the day.
## Overview
Chores and rewards can be pending, too late, etc. At the start of a new day, the state of chores and rewards should be reset. Notifications in the notification nav selector should be cleared out as well.
**Goal:** Chores and reward states get reset at the start of a new day.
**User Story:** As a parent, if I forget to approve/reject a pending chore or reward by the next day, then those items will have to be selected again by the child to go back to pending.
As a parent, if a scheduled chore is too late, the next day that chore will reset, but still follow it's schedule.
**Rules:**
Create frontend and backend unit tests
---
## Data Model Changes
### Backend Model
No model changes were required. `PendingConfirmation` already inherits `created_at: float` (Unix timestamp) from `BaseModel`, which is sufficient to determine whether a record is from a prior calendar day.
### Frontend Model
No model changes required. The `ChildChoreConfirmationPayload` and `ChildRewardRequestEventPayload` types are unchanged; the existing `RESET` and `CANCELLED` operations are reused.
---
## Backend Implementation
A new hourly scheduler (`backend/utils/state_expiry_scheduler.py`) runs idempotently to expire stale pending records:
- `_get_today_start_timestamp(tz_str)` computes the Unix timestamp for midnight today in the user's IANA timezone, falling back to UTC.
- `expire_stale_pending_for_user(user_id, tz_str)` queries `pending_confirmations_db` for records where `status='pending'` and `created_at < today_start` for that user. Each matching record is deleted from the database, then an SSE event is fired: `CHILD_CHORE_CONFIRMATION(RESET)` for chores, `CHILD_REWARD_REQUEST(CANCELLED)` for rewards. Uses `send_event_to_user` directly since there is no HTTP request context.
- `run_state_expiry_check(app)` iterates all verified users and calls `expire_stale_pending_for_user` for each. Skips entirely when `DB_ENV=e2e`.
- `start_state_expiry_scheduler(app)` registers the hourly cron job via APScheduler and also runs an immediate check on startup so any records that expired while the server was down are cleared promptly.
The scheduler is registered in `backend/main.py` alongside the existing digest and deletion schedulers.
## Backend Tests
Tests live in `backend/tests/test_state_expiry_scheduler.py`. Coverage:
- `_get_today_start_timestamp` returns a float before now, falls back to UTC on `None` or invalid timezone, and shifts midnight correctly across timezones.
- Pending chore from yesterday is deleted and fires a `CHILD_CHORE_CONFIRMATION(RESET)` SSE event with the correct `task_id`.
- Pending reward from yesterday is deleted and fires a `CHILD_REWARD_REQUEST(CANCELLED)` SSE event with the correct `reward_id`.
- Pending record created today is not deleted.
- Record with `status='approved'` from yesterday is not deleted.
- User with no pending records produces no errors and fires no SSE events.
- Multiple stale records for one user are all expired in a single call.
- `run_state_expiry_check` expires records for all verified users across multiple users.
- Only stale records are expired; fresh records from today are preserved.
- `DB_ENV=e2e` causes the scheduler to skip all processing.
---
## Frontend Implementation
Two files were updated to handle the `RESET` operation fired when chore pending states expire:
- `NotificationView.vue`: Added `'RESET'` to the `child_chore_confirmation` operation allow-list so the notification list refreshes when a pending chore confirmation is expired by the scheduler.
- `ParentLayout.vue`: Added `'RESET'` to the `child_chore_confirmation` handler allow-list so the notification badge count is re-fetched when a pending chore confirmation is expired.
The child view (`ChildView.vue`) required no changes — it already refreshes on any `child_chore_confirmation` operation regardless of the operation value.
## Frontend Tests
Tests live in `src/components/__tests__/NotificationView.spec.ts` and `src/components/__tests__/ParentLayout.spec.ts`.
- `NotificationView.vue` increments `refreshKey` on each of `CONFIRMED`, `APPROVED`, `REJECTED`, `CANCELLED`, and `RESET` operations for `child_chore_confirmation`.
- `NotificationView.vue` does not increment `refreshKey` on unknown operations.
- `NotificationView.vue` increments `refreshKey` on `CREATED`, `CANCELLED`, and `GRANTED` operations for `child_reward_request`.
- `ParentLayout.vue` re-fetches the notification count on each of `CONFIRMED`, `APPROVED`, `REJECTED`, `CANCELLED`, and `RESET` operations for `child_chore_confirmation`.
- `ParentLayout.vue` does not re-fetch on unknown operations.
- `ParentLayout.vue` re-fetches the notification count on `CREATED`, `CANCELLED`, and `GRANTED` operations for `child_reward_request`.
---
## Future Considerations
- Push notifications (Web Push) currently reference pending items; expired records will not trigger a push notification removal. A future improvement could send a silent push to dismiss stale items from the notification tray.
- If digest emails are scheduled near midnight, the expiry scheduler may clear items before the digest runs. The digest scheduler already skips empty pending lists gracefully, so no action is needed, but the ordering could be made explicit.
---
## Acceptance Criteria (Definition of Done)
### Backend
- [x] Hourly scheduler deletes `PendingConfirmation` records with `status='pending'` created on a prior calendar day (per user timezone)
- [x] `CHILD_CHORE_CONFIRMATION(RESET)` SSE event is fired for each expired chore record
- [x] `CHILD_REWARD_REQUEST(CANCELLED)` SSE event is fired for each expired reward record
- [x] Scheduler skips processing when `DB_ENV=e2e`
- [x] Scheduler runs on startup to handle records that expired during downtime
- [x] `status='approved'` records are not deleted
- [x] Records created today are not deleted
- [x] Backend unit tests pass (17 tests)
### Frontend
- [x] Notification badge in `ParentLayout.vue` re-fetches count on `RESET` operation
- [x] Notification list in `NotificationView.vue` refreshes on `RESET` operation
- [x] Frontend unit tests pass (18 tests)

View File

@@ -27,6 +27,7 @@ from events.sse import sse_response_for_user, send_to_user
from api.utils import get_current_user_id from api.utils import get_current_user_id
from utils.account_deletion_scheduler import start_deletion_scheduler from utils.account_deletion_scheduler import start_deletion_scheduler
from utils.digest_scheduler import start_digest_scheduler from utils.digest_scheduler import start_digest_scheduler
from utils.state_expiry_scheduler import start_state_expiry_scheduler
# Configure logging once at application startup # Configure logging once at application startup
logging.basicConfig( logging.basicConfig(
@@ -140,6 +141,7 @@ createDefaultRewards()
start_background_threads() start_background_threads()
start_deletion_scheduler() start_deletion_scheduler()
start_digest_scheduler(app) start_digest_scheduler(app)
start_state_expiry_scheduler(app)
if __name__ == '__main__': if __name__ == '__main__':
app.run(debug=False, host='0.0.0.0', port=5000, threaded=True) app.run(debug=False, host='0.0.0.0', port=5000, threaded=True)

View File

@@ -0,0 +1,250 @@
"""Tests for the state expiry scheduler."""
import os
import time
import pytest
from unittest.mock import patch, MagicMock
from flask import Flask
from tinydb import Query
from utils.state_expiry_scheduler import (
_get_today_start_timestamp,
expire_stale_pending_for_user,
run_state_expiry_check,
)
from db.db import users_db, pending_confirmations_db
from tests.conftest import TEST_SECRET_KEY
EXPIRY_USER_ID = "expiry_test_user"
EXPIRY_USER_ID_2 = "expiry_test_user_2"
EXPIRY_CHILD_ID = "expiry_child_id"
EXPIRY_CHILD_ID_2 = "expiry_child_id_2"
EXPIRY_TASK_ID = "expiry_task_id"
EXPIRY_REWARD_ID = "expiry_reward_id"
def _yesterday_timestamp() -> float:
"""Return a Unix timestamp from 25 hours ago (safely before today's midnight)."""
return time.time() - (25 * 3600)
def _seed_user(user_id: str = EXPIRY_USER_ID, timezone: str = "UTC", verified: bool = True):
users_db.remove(Query().id == user_id)
users_db.insert({
"id": user_id,
"first_name": "Expiry",
"last_name": "Tester",
"email": f"{user_id}@example.com",
"password": "hashed",
"verified": verified,
"role": "user",
"image_id": None,
"marked_for_deletion": False,
"timezone": timezone,
"email_digest_enabled": False,
})
def _seed_pending(
pending_id: str,
user_id: str = EXPIRY_USER_ID,
child_id: str = EXPIRY_CHILD_ID,
entity_id: str = EXPIRY_TASK_ID,
entity_type: str = "chore",
status: str = "pending",
created_at: float = None,
):
pending_confirmations_db.remove(Query().id == pending_id)
pending_confirmations_db.insert({
"id": pending_id,
"user_id": user_id,
"child_id": child_id,
"entity_id": entity_id,
"entity_type": entity_type,
"status": status,
"approved_at": None,
"created_at": created_at if created_at is not None else _yesterday_timestamp(),
"updated_at": time.time(),
})
def _cleanup():
for uid in (EXPIRY_USER_ID, EXPIRY_USER_ID_2):
users_db.remove(Query().id == uid)
pending_confirmations_db.remove(Query().user_id == uid)
@pytest.fixture
def app():
flask_app = Flask(__name__)
flask_app.config['TESTING'] = True
flask_app.config['SECRET_KEY'] = TEST_SECRET_KEY
return flask_app
class TestGetTodayStartTimestamp:
def test_returns_float(self):
ts = _get_today_start_timestamp("UTC")
assert isinstance(ts, float)
def test_today_start_is_before_now(self):
ts = _get_today_start_timestamp("UTC")
assert ts <= time.time()
def test_falls_back_to_utc_on_none(self):
ts_utc = _get_today_start_timestamp("UTC")
ts_none = _get_today_start_timestamp(None)
assert abs(ts_utc - ts_none) < 1 # within 1 second
def test_falls_back_to_utc_on_invalid_timezone(self):
ts_utc = _get_today_start_timestamp("UTC")
ts_bad = _get_today_start_timestamp("Invalid/Timezone")
assert abs(ts_utc - ts_bad) < 1
def test_timezone_shifts_midnight(self):
"""UTC-12 midnight is 12 hours later than UTC midnight in absolute terms."""
ts_utc = _get_today_start_timestamp("UTC")
ts_west = _get_today_start_timestamp("Etc/GMT+12")
# The western timezone's "today start" is at most 12 hours away from UTC's
assert abs(ts_utc - ts_west) <= 12 * 3600 + 1
class TestExpireStaleForUser:
def setup_method(self):
_cleanup()
def teardown_method(self):
_cleanup()
def test_pending_chore_from_yesterday_is_deleted(self, app):
_seed_pending("exp_chore_1", entity_type="chore", created_at=_yesterday_timestamp())
with app.app_context():
with patch('events.sse.send_event_to_user') as mock_send:
count = expire_stale_pending_for_user(EXPIRY_USER_ID, "UTC")
assert count == 1
assert pending_confirmations_db.get(Query().id == "exp_chore_1") is None
def test_pending_chore_fires_reset_sse(self, app):
_seed_pending("exp_chore_2", entity_type="chore", created_at=_yesterday_timestamp())
with app.app_context():
with patch('utils.state_expiry_scheduler.send_event_to_user') as mock_send:
expire_stale_pending_for_user(EXPIRY_USER_ID, "UTC")
mock_send.assert_called_once()
event = mock_send.call_args[0][1]
assert event.type == "child_chore_confirmation"
assert event.payload.data['operation'] == "RESET"
assert event.payload.data['task_id'] == EXPIRY_TASK_ID
def test_pending_reward_from_yesterday_is_deleted(self, app):
_seed_pending(
"exp_reward_1",
entity_id=EXPIRY_REWARD_ID,
entity_type="reward",
created_at=_yesterday_timestamp(),
)
with app.app_context():
with patch('utils.state_expiry_scheduler.send_event_to_user'):
count = expire_stale_pending_for_user(EXPIRY_USER_ID, "UTC")
assert count == 1
assert pending_confirmations_db.get(Query().id == "exp_reward_1") is None
def test_pending_reward_fires_cancelled_sse(self, app):
_seed_pending(
"exp_reward_2",
entity_id=EXPIRY_REWARD_ID,
entity_type="reward",
created_at=_yesterday_timestamp(),
)
with app.app_context():
with patch('utils.state_expiry_scheduler.send_event_to_user') as mock_send:
expire_stale_pending_for_user(EXPIRY_USER_ID, "UTC")
mock_send.assert_called_once()
event = mock_send.call_args[0][1]
assert event.type == "child_reward_request"
assert event.payload.data['operation'] == "CANCELLED"
assert event.payload.data['reward_id'] == EXPIRY_REWARD_ID
def test_pending_record_from_today_is_not_deleted(self, app):
_seed_pending("exp_today_1", created_at=time.time())
with app.app_context():
with patch('utils.state_expiry_scheduler.send_event_to_user') as mock_send:
count = expire_stale_pending_for_user(EXPIRY_USER_ID, "UTC")
assert count == 0
assert pending_confirmations_db.get(Query().id == "exp_today_1") is not None
mock_send.assert_not_called()
def test_approved_record_from_yesterday_is_not_deleted(self, app):
_seed_pending("exp_approved_1", status="approved", created_at=_yesterday_timestamp())
with app.app_context():
with patch('utils.state_expiry_scheduler.send_event_to_user') as mock_send:
count = expire_stale_pending_for_user(EXPIRY_USER_ID, "UTC")
assert count == 0
assert pending_confirmations_db.get(Query().id == "exp_approved_1") is not None
mock_send.assert_not_called()
def test_no_pending_records_returns_zero(self, app):
with app.app_context():
with patch('utils.state_expiry_scheduler.send_event_to_user') as mock_send:
count = expire_stale_pending_for_user(EXPIRY_USER_ID, "UTC")
assert count == 0
mock_send.assert_not_called()
def test_multiple_stale_records_all_expired(self, app):
_seed_pending("exp_multi_1", entity_id=EXPIRY_TASK_ID, entity_type="chore",
created_at=_yesterday_timestamp())
_seed_pending("exp_multi_2", entity_id=EXPIRY_REWARD_ID, entity_type="reward",
created_at=_yesterday_timestamp())
with app.app_context():
with patch('utils.state_expiry_scheduler.send_event_to_user') as mock_send:
count = expire_stale_pending_for_user(EXPIRY_USER_ID, "UTC")
assert count == 2
assert mock_send.call_count == 2
class TestRunStateExpiryCheck:
def setup_method(self):
_cleanup()
def teardown_method(self):
_cleanup()
def test_expires_stale_records_for_all_verified_users(self, app):
_seed_user(EXPIRY_USER_ID)
_seed_user(EXPIRY_USER_ID_2)
_seed_pending("exp_u1", user_id=EXPIRY_USER_ID, created_at=_yesterday_timestamp())
_seed_pending("exp_u2", user_id=EXPIRY_USER_ID_2, child_id=EXPIRY_CHILD_ID_2,
created_at=_yesterday_timestamp())
with patch('utils.state_expiry_scheduler.send_event_to_user'):
run_state_expiry_check(app)
assert pending_confirmations_db.get(Query().id == "exp_u1") is None
assert pending_confirmations_db.get(Query().id == "exp_u2") is None
def test_only_expires_stale_records_not_todays(self, app):
_seed_user(EXPIRY_USER_ID)
_seed_pending("exp_stale", created_at=_yesterday_timestamp())
_seed_pending("exp_fresh", created_at=time.time())
with patch('utils.state_expiry_scheduler.send_event_to_user'):
run_state_expiry_check(app)
assert pending_confirmations_db.get(Query().id == "exp_stale") is None
assert pending_confirmations_db.get(Query().id == "exp_fresh") is not None
def test_skips_when_db_env_is_e2e(self, app):
_seed_user(EXPIRY_USER_ID)
_seed_pending("exp_e2e", created_at=_yesterday_timestamp())
original = os.environ.get('DB_ENV')
try:
os.environ['DB_ENV'] = 'e2e'
with patch('utils.state_expiry_scheduler.send_event_to_user') as mock_send:
run_state_expiry_check(app)
mock_send.assert_not_called()
assert pending_confirmations_db.get(Query().id == "exp_e2e") is not None
finally:
if original is not None:
os.environ['DB_ENV'] = original
else:
os.environ.pop('DB_ENV', None)
def test_no_error_when_user_has_no_pending_records(self, app):
_seed_user(EXPIRY_USER_ID)
with patch('utils.state_expiry_scheduler.send_event_to_user') as mock_send:
run_state_expiry_check(app)
mock_send.assert_not_called()

View File

@@ -0,0 +1,131 @@
import logging
import os
from datetime import datetime, timezone
from apscheduler.schedulers.background import BackgroundScheduler
from tinydb import Query
from events.types.child_chore_confirmation import ChildChoreConfirmation
from events.types.child_reward_request import ChildRewardRequest
from events.types.event import Event
from events.types.event_types import EventType
from events.sse import send_event_to_user
logger = logging.getLogger(__name__)
def _get_today_start_timestamp(tz_str: str | None) -> float:
"""Return a Unix timestamp for the start of today (midnight) in the user's timezone."""
try:
if tz_str:
from zoneinfo import ZoneInfo
now_local = datetime.now(ZoneInfo(tz_str))
today_local = now_local.replace(hour=0, minute=0, second=0, microsecond=0)
return today_local.timestamp()
except Exception:
pass
now_utc = datetime.now(timezone.utc)
today_utc = now_utc.replace(hour=0, minute=0, second=0, microsecond=0)
return today_utc.timestamp()
def expire_stale_pending_for_user(user_id: str, tz_str: str | None) -> int:
"""Delete stale pending confirmations for a user and fire SSE events.
A record is stale if status='pending' and created_at is before the start of
today in the user's timezone. Returns the number of records expired.
Must be called within a Flask app context.
"""
from db.db import pending_confirmations_db
today_start = _get_today_start_timestamp(tz_str)
PendingQ = Query()
stale_items = pending_confirmations_db.search(
(PendingQ.user_id == user_id) &
(PendingQ.status == 'pending') &
(PendingQ.created_at < today_start)
)
if not stale_items:
return 0
for item in stale_items:
item_id = item.get('id')
child_id = item.get('child_id')
entity_id = item.get('entity_id')
entity_type = item.get('entity_type')
pending_confirmations_db.remove(PendingQ.id == item_id)
if entity_type == 'chore':
event = Event(
EventType.CHILD_CHORE_CONFIRMATION.value,
ChildChoreConfirmation(
child_id=child_id,
task_id=entity_id,
operation=ChildChoreConfirmation.OPERATION_RESET,
)
)
else:
event = Event(
EventType.CHILD_REWARD_REQUEST.value,
ChildRewardRequest(
child_id=child_id,
reward_id=entity_id,
operation=ChildRewardRequest.REQUEST_CANCELLED,
)
)
send_event_to_user(user_id, event)
logger.info(
f'State expiry: expired {entity_type} {entity_id} for child {child_id} (user {user_id})'
)
return len(stale_items)
def run_state_expiry_check(app) -> None:
"""Check all verified users and expire stale pending records."""
if os.environ.get('DB_ENV') == 'e2e':
return
with app.app_context():
try:
from db.db import users_db
UserQ = Query()
users = users_db.search(UserQ.verified == True)
for user_dict in users:
user_id = user_dict.get('id')
tz_str = user_dict.get('timezone')
try:
expired = expire_stale_pending_for_user(user_id, tz_str)
if expired:
logger.info(f'State expiry: expired {expired} record(s) for user {user_id}')
except Exception as e:
logger.error(f'State expiry: failed for user {user_id}: {e}')
except Exception as e:
logger.error(f'Error in run_state_expiry_check: {e}')
def start_state_expiry_scheduler(app) -> BackgroundScheduler:
"""Start the hourly state expiry scheduler. Returns the scheduler instance."""
scheduler = BackgroundScheduler()
scheduler.add_job(
run_state_expiry_check,
'cron',
minute=0,
args=[app],
id='state_expiry_scheduler',
replace_existing=True,
)
scheduler.start()
logger.info('State expiry scheduler started (hourly)')
# Run immediately on startup to handle any records that expired while server was down
run_state_expiry_check(app)
return scheduler

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 === 'CONFIRMED' ||
payload.operation === 'APPROVED' || payload.operation === 'APPROVED' ||
payload.operation === 'REJECTED' || payload.operation === 'REJECTED' ||
payload.operation === 'CANCELLED' payload.operation === 'CANCELLED' ||
payload.operation === 'RESET'
) { ) {
notificationListCountRef.value = -1 notificationListCountRef.value = -1
refreshKey.value++ refreshKey.value++

View File

@@ -7,6 +7,8 @@ import type {
Event, Event,
ChildRewardRequestEventPayload, ChildRewardRequestEventPayload,
ChildChoreConfirmationPayload, ChildChoreConfirmationPayload,
ChildTasksSetEventPayload,
ChildRewardsSetEventPayload,
} from '@/common/models' } from '@/common/models'
const router = useRouter() const router = useRouter()
@@ -60,11 +62,19 @@ function handleRewardRequestBadge(event: Event) {
function handleChoreConfirmationBadge(event: Event) { function handleChoreConfirmationBadge(event: Event) {
const payload = event.payload as ChildChoreConfirmationPayload const payload = event.payload as ChildChoreConfirmationPayload
if (['CONFIRMED', 'APPROVED', 'REJECTED', 'CANCELLED'].includes(payload.operation)) { if (['CONFIRMED', 'APPROVED', 'REJECTED', 'CANCELLED', 'RESET'].includes(payload.operation)) {
fetchNotificationCount() fetchNotificationCount()
} }
} }
function handleTasksSetBadge(_event: Event) {
fetchNotificationCount()
}
function handleRewardsSetBadge(_event: Event) {
fetchNotificationCount()
}
// Version fetching // Version fetching
const appVersion = ref('') const appVersion = ref('')
@@ -72,6 +82,8 @@ onMounted(async () => {
await fetchNotificationCount() await fetchNotificationCount()
eventBus.on('child_reward_request', handleRewardRequestBadge) eventBus.on('child_reward_request', handleRewardRequestBadge)
eventBus.on('child_chore_confirmation', handleChoreConfirmationBadge) eventBus.on('child_chore_confirmation', handleChoreConfirmationBadge)
eventBus.on('child_tasks_set', handleTasksSetBadge)
eventBus.on('child_rewards_set', handleRewardsSetBadge)
try { try {
const resp = await fetch('/api/version') const resp = await fetch('/api/version')
@@ -87,6 +99,8 @@ onMounted(async () => {
onUnmounted(() => { onUnmounted(() => {
eventBus.off('child_reward_request', handleRewardRequestBadge) eventBus.off('child_reward_request', handleRewardRequestBadge)
eventBus.off('child_chore_confirmation', handleChoreConfirmationBadge) eventBus.off('child_chore_confirmation', handleChoreConfirmationBadge)
eventBus.off('child_tasks_set', handleTasksSetBadge)
eventBus.off('child_rewards_set', handleRewardsSetBadge)
}) })
</script> </script>