diff --git a/.github/specs/feat-state-expire.md b/.github/specs/feat-state-expire.md new file mode 100644 index 0000000..0ad9d90 --- /dev/null +++ b/.github/specs/feat-state-expire.md @@ -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) diff --git a/backend/main.py b/backend/main.py index eaa4aaa..55343d3 100644 --- a/backend/main.py +++ b/backend/main.py @@ -27,6 +27,7 @@ from events.sse import sse_response_for_user, send_to_user from api.utils import get_current_user_id from utils.account_deletion_scheduler import start_deletion_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 logging.basicConfig( @@ -140,6 +141,7 @@ createDefaultRewards() start_background_threads() start_deletion_scheduler() start_digest_scheduler(app) +start_state_expiry_scheduler(app) if __name__ == '__main__': app.run(debug=False, host='0.0.0.0', port=5000, threaded=True) \ No newline at end of file diff --git a/backend/tests/test_state_expiry_scheduler.py b/backend/tests/test_state_expiry_scheduler.py new file mode 100644 index 0000000..51cc55a --- /dev/null +++ b/backend/tests/test_state_expiry_scheduler.py @@ -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() diff --git a/backend/utils/state_expiry_scheduler.py b/backend/utils/state_expiry_scheduler.py new file mode 100644 index 0000000..fcec63c --- /dev/null +++ b/backend/utils/state_expiry_scheduler.py @@ -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 diff --git a/frontend/vue-app/src/components/__tests__/NotificationView.spec.ts b/frontend/vue-app/src/components/__tests__/NotificationView.spec.ts new file mode 100644 index 0000000..30b5f92 --- /dev/null +++ b/frontend/vue-app/src/components/__tests__/NotificationView.spec.ts @@ -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: '
', props: ['fetchUrl', 'key'] }, +})) + +vi.mock('../shared/MessageBlock.vue', () => ({ + default: { template: '', props: ['message'] }, +})) + +// ── Router ─────────────────────────────────────────────────────────────────── + +const mockRouter = createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/', name: 'Landing', component: { template: '' } }, + { path: '/parent/:id', name: 'ParentView', component: { template: '' } }, + ], +}) + +// ── 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) + }) + }) +}) diff --git a/frontend/vue-app/src/components/__tests__/ParentLayout.spec.ts b/frontend/vue-app/src/components/__tests__/ParentLayout.spec.ts new file mode 100644 index 0000000..14df83f --- /dev/null +++ b/frontend/vue-app/src/components/__tests__/ParentLayout.spec.ts @@ -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: '' } }, + { path: '/notifications', name: 'NotificationView', component: { template: '' } }, + { path: '/tasks', name: 'TaskView', component: { template: '' } }, + { path: '/chores', name: 'ChoreView', component: { template: '' } }, + { path: '/rewards', name: 'RewardView', component: { template: '' } }, + ], +}) + +// ── 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) + }) + }) +}) diff --git a/frontend/vue-app/src/components/notification/NotificationView.vue b/frontend/vue-app/src/components/notification/NotificationView.vue index f34f3d5..5e354bf 100644 --- a/frontend/vue-app/src/components/notification/NotificationView.vue +++ b/frontend/vue-app/src/components/notification/NotificationView.vue @@ -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++ diff --git a/frontend/vue-app/src/layout/ParentLayout.vue b/frontend/vue-app/src/layout/ParentLayout.vue index 72a7bee..903c161 100644 --- a/frontend/vue-app/src/layout/ParentLayout.vue +++ b/frontend/vue-app/src/layout/ParentLayout.vue @@ -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) })