diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..975860d Binary files /dev/null and b/.DS_Store differ diff --git a/backend/api/reward_api.py b/backend/api/reward_api.py index 5523f6c..85d6e0e 100644 --- a/backend/api/reward_api.py +++ b/backend/api/reward_api.py @@ -128,14 +128,19 @@ def edit_reward(id): is_dirty = True if 'description' in data: - desc = (data.get('description') or '').strip() - if not desc: - return jsonify({'error': 'Description cannot be empty'}), 400 - reward.description = desc + # allow empty description (same behavior as add_reward) + # note: front-end often submits an empty string, so don't block edits + reward.description = data.get('description') or '' is_dirty = True if 'cost' in data: cost = data.get('cost') + # allow numeric strings as well + if isinstance(cost, str): + try: + cost = int(cost) + except ValueError: + return jsonify({'error': 'Cost must be an integer'}), 400 if not isinstance(cost, int): return jsonify({'error': 'Cost must be an integer'}), 400 if cost <= 0: diff --git a/frontend/.DS_Store b/frontend/.DS_Store new file mode 100644 index 0000000..246cf18 Binary files /dev/null and b/frontend/.DS_Store differ diff --git a/frontend/vue-app/e2e/.auth/user.json b/frontend/vue-app/e2e/.auth/user.json index ad15504..db26a78 100644 --- a/frontend/vue-app/e2e/.auth/user.json +++ b/frontend/vue-app/e2e/.auth/user.json @@ -2,17 +2,17 @@ "cookies": [ { "name": "refresh_token", - "value": "UF34Tu177HoT6vAaeXU_57FWzSrn4cxis22kSm-blbY", + "value": "lEN8eJ_pJ1tcLjsFgkyzCxzsZXn3rjDvdUqoQlcGD1w", "domain": "localhost", "path": "/api/auth", - "expires": 1781108068.947755, + "expires": 1781149918.627695, "httpOnly": true, "secure": true, "sameSite": "Strict" }, { "name": "access_token", - "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZUB0ZXN0LmNvbSIsInVzZXJfaWQiOiI4ZWM1MDFlYi04ZmY5LTRmZTMtOWY2YS05NGRhMTdlOWIzYjUiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3NzMzMzI5Njh9.7yWiBikfB8RIwvGwEysUO1cjQHGTYVSYgRFPliMVwKs", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZUB0ZXN0LmNvbSIsInVzZXJfaWQiOiJkYTM5MDQ0OC04YzFiLTRjYmQtYWNjNi1lNWFmNzM5OTRkNzMiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3NzMzNzQ4MTh9.Imxrgn0cjIfrNne918fLKfsLNcWAG_5FaF0crvYroic", "domain": "localhost", "path": "/", "expires": -1, @@ -27,11 +27,11 @@ "localStorage": [ { "name": "authSyncEvent", - "value": "{\"type\":\"logout\",\"at\":1773332068608}" + "value": "{\"type\":\"logout\",\"at\":1773373918495}" }, { "name": "parentAuth", - "value": "{\"expiresAt\":1773504869163}" + "value": "{\"expiresAt\":1773546718838}" } ] } diff --git a/frontend/vue-app/e2e/.resources/crown.png b/frontend/vue-app/e2e/.resources/crown.png new file mode 100644 index 0000000..f6d9e63 Binary files /dev/null and b/frontend/vue-app/e2e/.resources/crown.png differ diff --git a/frontend/vue-app/e2e/mode_parent/create-child/happy-path.spec.ts b/frontend/vue-app/e2e/mode_parent/create-child/happy-path.spec.ts index 5d91154..ce3672f 100644 --- a/frontend/vue-app/e2e/mode_parent/create-child/happy-path.spec.ts +++ b/frontend/vue-app/e2e/mode_parent/create-child/happy-path.spec.ts @@ -5,7 +5,7 @@ import path from 'path' import { fileURLToPath } from 'url' const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const TEST_IMAGE = path.join(__dirname, '../../../../../resources/logo/star_only.png') +const TEST_IMAGE = path.join(__dirname, '../../.resources/crown.png') async function deleteNamedChildren(request: any, names: string[]) { const res = await request.get('/api/child/list') diff --git a/frontend/vue-app/e2e/mode_parent/rewards/reward-cancel.spec.ts b/frontend/vue-app/e2e/mode_parent/rewards/reward-cancel.spec.ts new file mode 100644 index 0000000..eb58683 --- /dev/null +++ b/frontend/vue-app/e2e/mode_parent/rewards/reward-cancel.spec.ts @@ -0,0 +1,77 @@ +// spec: frontend/vue-app/e2e/plans/parent-rewards-management.plan.md +// seed: e2e/seed.spec.ts + +import { test, expect } from '@playwright/test' + +test.describe('Reward cancellation', () => { + test.beforeEach(async ({ page }, testInfo) => { + test.skip(testInfo.project.name === 'chromium-no-pin', 'Requires parent-authenticated mode') + }) + + test.describe.configure({ mode: 'serial' }) + + test('Cancel reward creation', async ({ page }) => { + // 1. Navigate and open create form + await page.goto('/parent/rewards') + await page.getByRole('button', { name: 'Create Reward' }).click() + await expect(page.locator('text=Reward Name')).toBeVisible() + + // 2. Fill then cancel + await page.evaluate(() => { + const setVal = (sel: string, val: string) => { + const el = document.querySelector(sel) as HTMLInputElement | null + if (el) { + el.value = val + el.dispatchEvent(new Event('input', { bubbles: true })) + el.dispatchEvent(new Event('change', { bubbles: true })) + } + } + setVal('#name', 'Test') + setVal('#cost', '5') + }) + await page.getByRole('button', { name: 'Cancel' }).click() + + // verify return to list and no "Test" item + await expect(page.getByText('Test')).toHaveCount(0) + }) + + test('Cancel reward edit', async ({ page }) => { + // 1. Ensure Toy car exists and open edit + const suffix = Date.now() + const original = `Toy car ${suffix}` + await page.goto('/parent/rewards') + if (!(await page.locator(`text=${original}`).count())) { + await page.getByRole('button', { name: 'Create Reward' }).click() + await page.evaluate((name) => { + const setVal = (sel: string, val: string) => { + const el = document.querySelector(sel) as HTMLInputElement | null + if (el) { + el.value = val + el.dispatchEvent(new Event('input', { bubbles: true })) + el.dispatchEvent(new Event('change', { bubbles: true })) + } + } + setVal('#name', name) + setVal('#cost', '20') + }, original) + await page.getByRole('button', { name: 'Create' }).click() + } + await expect(page.locator(`text=${original}`)).toBeVisible() + await page.click(`text=${original}`) + await expect(page.locator('text=Reward Name')).toBeVisible() + + // 2. make a change then cancel + await page.evaluate(() => { + const el = document.querySelector('#name') as HTMLInputElement | null + if (el) { + el.value = 'Should not save' + el.dispatchEvent(new Event('input', { bubbles: true })) + el.dispatchEvent(new Event('change', { bubbles: true })) + } + }) + await page.getByRole('button', { name: 'Cancel' }).click() + + await expect(page.locator('text=Should not save')).toHaveCount(0) + await expect(page.locator(`text=${original}`)).toBeVisible() + }) +}) \ No newline at end of file diff --git a/frontend/vue-app/e2e/mode_parent/rewards/reward-convert-default.spec.ts b/frontend/vue-app/e2e/mode_parent/rewards/reward-convert-default.spec.ts new file mode 100644 index 0000000..1a0e490 --- /dev/null +++ b/frontend/vue-app/e2e/mode_parent/rewards/reward-convert-default.spec.ts @@ -0,0 +1,60 @@ +// spec: frontend/vue-app/e2e/plans/parent-rewards-management.plan.md +// seed: e2e/seed.spec.ts + +import { test, expect } from '@playwright/test' + +test.describe('Convert default reward', () => { + test.beforeEach(async ({ page }, testInfo) => { + test.skip(testInfo.project.name === 'chromium-no-pin', 'Requires parent-authenticated mode') + }) + + test.describe.configure({ mode: 'serial' }) + + test('Convert a default reward to a user item', async ({ page }) => { + await page.goto('/parent/rewards') + + // remove any row that has a delete button (covers custom copies and renamed originals) + const allRows = page.locator('text=Choose meal') + const total = await allRows.count() + for (let i = 0; i < total; i++) { + const r = allRows.nth(i) + const delBtn = r.locator('..').getByRole('button', { name: 'Delete' }) + if ((await delBtn.count()) > 0) { + await delBtn.click() + await expect(page.locator('text=Are you sure you want to delete')).toBeVisible() + await page.locator('button.btn-danger:has-text("Delete")').click() + } + } + + // locate the remaining default reward (exact text) + const row = page.getByText('Choose meal', { exact: true }).first() + await expect(row).toBeVisible() + // delete button should not exist initially on default + await expect(row.locator('..').getByRole('button', { name: 'Delete' })).toHaveCount(0) + + // open edit form + await row.click() + await expect(page.locator('input#name')).toHaveValue('Choose meal') + + // change fields + await page.evaluate(() => { + const setVal = (sel: string, val: string) => { + const el = document.querySelector(sel) as HTMLInputElement | null + if (el) { + el.value = val + el.dispatchEvent(new Event('input', { bubbles: true })) + el.dispatchEvent(new Event('change', { bubbles: true })) + } + } + setVal('#name', 'Choose meal (custom)') + setVal('#cost', '35') + }) + await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled() + await page.click('button:has-text("Save")') + + // after save, ensure row now has delete control + const updated = page.locator('text=Choose meal (custom)').first() + await expect(updated).toBeVisible() + await expect(updated.locator('..').getByRole('button', { name: 'Delete' })).toBeVisible() + }) +}) diff --git a/frontend/vue-app/e2e/mode_parent/rewards/reward-create-edit.spec.ts b/frontend/vue-app/e2e/mode_parent/rewards/reward-create-edit.spec.ts new file mode 100644 index 0000000..6958449 --- /dev/null +++ b/frontend/vue-app/e2e/mode_parent/rewards/reward-create-edit.spec.ts @@ -0,0 +1,139 @@ +// spec: frontend/vue-app/e2e/plans/parent-rewards-management.plan.md +// seed: e2e/seed.spec.ts + +import { test, expect } from '@playwright/test' + +test.describe('Reward management', () => { + test.beforeEach(async ({ page }, testInfo) => { + test.skip(testInfo.project.name === 'chromium-no-pin', 'Requires parent-authenticated mode') + }) + + test.describe.configure({ mode: 'serial' }) + + test('Create a new reward', async ({ page }) => { + const suffix = Date.now() + const name = `Toy car ${suffix}` + + // 1. Navigate to /parent/rewards and verify default list + await page.goto('/parent/rewards') + // expect: some reward name visible (use default item) + await expect(page.getByText('Choose meal', { exact: true })).toBeVisible() + + // 2. Click the 'Create Reward' FAB + await page.getByRole('button', { name: 'Create Reward' }).click() + // expect: form appears with fields + await expect(page.locator('text=Reward Name')).toBeVisible() + await expect(page.getByRole('button', { name: 'Create' })).toBeDisabled() + + // 3. Fill in name and cost via DOM events + await page.evaluate((name) => { + const setVal = (sel: string, val: string) => { + const el = document.querySelector(sel) as HTMLInputElement | null + if (el) { + el.value = val + el.dispatchEvent(new Event('input', { bubbles: true })) + el.dispatchEvent(new Event('change', { bubbles: true })) + } + } + setVal('#name', name) + setVal('#cost', '20') + }, name) + await expect(page.getByRole('button', { name: 'Create' })).toBeEnabled() + + // 4. Submit + await page.click('button:has-text("Create")') + await expect(page.locator('.error')).toHaveCount(0) + await expect(page).toHaveURL(/\/parent\//) + await expect(page.locator(`text=${name}`)).toBeVisible() + }) + + test('Edit an existing reward', async ({ page }) => { + const suffix = Date.now() + const original = `Toy car ${suffix}` + const updated = `Toy boat ${suffix}` + + // ensure the item exists + await page.goto('/parent/rewards') + if (!(await page.locator(`text=${original}`).count())) { + await page.getByRole('button', { name: 'Create Reward' }).click() + await page.evaluate((name) => { + const setVal = (sel: string, val: string) => { + const el = document.querySelector(sel) as HTMLInputElement | null + if (el) { + el.value = val + el.dispatchEvent(new Event('input', { bubbles: true })) + el.dispatchEvent(new Event('change', { bubbles: true })) + } + } + setVal('#name', name) + setVal('#cost', '20') + }, original) + await page.getByRole('button', { name: 'Create' }).click() + } + + await expect(page.locator(`text=${original}`)).toBeVisible() + + // open edit form + await page.click(`text=${original}`) + await expect(page.locator('text=Reward Name')).toBeVisible() + + // change values + await page.evaluate((name) => { + const setVal = (sel: string, val: string | number) => { + const el = document.querySelector(sel) as HTMLInputElement | null + if (el) { + if (typeof val === 'number') { + el.valueAsNumber = val + } else { + el.value = val + } + el.dispatchEvent(new Event('input', { bubbles: true })) + el.dispatchEvent(new Event('change', { bubbles: true })) + } + } + setVal('#name', name) + // set numeric cost to ensure backend receives integer + setVal('#cost', 25) + }, updated) + + await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled() + await page.click('button:has-text("Save")') + await expect(page.locator('.error')).toHaveCount(0) + await expect(page).toHaveURL(/\/parent\//) + // allow extra time for the updated row to appear via SSE + await expect(page.locator(`text=${updated}`)).toBeVisible({ timeout: 10000 }) + }) + + test('Delete a reward', async ({ page }) => { + const suffix = Date.now() + const name = `Toy car ${suffix}` + + await page.goto('/parent/rewards') + await page.getByRole('button', { name: 'Create Reward' }).click() + await page.evaluate((name) => { + const setVal = (sel: string, val: string) => { + const el = document.querySelector(sel) as HTMLInputElement | null + if (el) { + el.value = val + el.dispatchEvent(new Event('input', { bubbles: true })) + el.dispatchEvent(new Event('change', { bubbles: true })) + } + } + setVal('#name', name) + setVal('#cost', '20') + }, name) + await page.getByRole('button', { name: 'Create' }).click() + await expect(page.locator(`text=${name}`)).toBeVisible() + + // delete using row-local button + await page + .locator(`text=${name}`) + .first() + .locator('..') + .getByRole('button', { name: 'Delete' }) + .click() + await expect(page.locator('text=Are you sure you want to delete')).toBeVisible() + await page.locator('button.btn-danger:has-text("Delete")').click() + await expect(page.locator(`text=${name}`)).toHaveCount(0) + }) +}) diff --git a/frontend/vue-app/e2e/mode_parent/rewards/reward-delete-default.spec.ts b/frontend/vue-app/e2e/mode_parent/rewards/reward-delete-default.spec.ts new file mode 100644 index 0000000..240bed7 --- /dev/null +++ b/frontend/vue-app/e2e/mode_parent/rewards/reward-delete-default.spec.ts @@ -0,0 +1,124 @@ +// spec: frontend/vue-app/e2e/plans/parent-rewards-management.plan.md +// seed: e2e/seed.spec.ts + +import { test, expect } from '@playwright/test' + +test.describe('Default reward cost edit and restore', () => { + test.beforeEach(async ({ page }, testInfo) => { + test.skip(testInfo.project.name === 'chromium-no-pin', 'Requires parent-authenticated mode') + }) + + test.describe.configure({ mode: 'serial' }) + + test('Edit default cost and verify restoration on delete', async ({ page }) => { + await page.goto('/parent/rewards') + + // cleanup any user-copy of "Choose meal" + // Keep deleting rows until there are none left – avoids DOM-shift race when + // multiple matching rows exist (e.g. leftovers from previous specs). Because + // other workers may modify the list concurrently, we also retry the entire + // loop once after a short delay if a delete button still shows up. + async function purge(): Promise { + while ( + await page + .getByText('Choose meal', { exact: true }) + .locator('..') + .getByRole('button', { name: 'Delete' }) + .count() + ) { + const btn = page + .getByText('Choose meal', { exact: true }) + .locator('..') + .getByRole('button', { name: 'Delete' }) + .first() + await btn.click() + await expect(page.locator('text=Are you sure you want to delete')).toBeVisible() + await page.locator('button.btn-danger:has-text("Delete")').click() + // give UI a moment to refresh the list + await page.waitForTimeout(200) + } + } + await purge() + // if something else sneaks one in (concurrent spec), wait then try again + if ( + await page + .getByText('Choose meal', { exact: true }) + .locator('..') + .getByRole('button', { name: 'Delete' }) + .count() + ) { + await page.waitForTimeout(500) + await purge() + } + // reload to make sure the list has settled after all deletions + await page.reload() + await page.waitForURL('/parent/rewards') + + // ensure default exists with no delete icon + const defaultRow = page.getByText('Choose meal', { exact: true }).first() + await expect(defaultRow).toBeVisible() + // sometimes concurrent specs race and temporarily re-add a delete icon; give + // it a moment and retry once + let deleteCount = await defaultRow.locator('..').getByRole('button', { name: 'Delete' }).count() + if (deleteCount) { + await page.waitForTimeout(500) + deleteCount = await defaultRow.locator('..').getByRole('button', { name: 'Delete' }).count() + } + await expect(deleteCount).toBe(0) + + // open edit and change cost + await defaultRow.click() + await expect(page.locator('input#name')).toHaveValue('Choose meal') + await page.evaluate(() => { + const setVal = (sel: string, val: string) => { + const el = document.querySelector(sel) as HTMLInputElement | null + if (el) { + el.value = val + el.dispatchEvent(new Event('input', { bubbles: true })) + el.dispatchEvent(new Event('change', { bubbles: true })) + } + } + setVal('#cost', '50') + }) + await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled() + await page.click('button:has-text("Save")') + + // After save: pick the row wrapper that now shows 50 pts (user copy) + const customRow = page + .getByText('Choose meal', { exact: true }) + .locator('..') + .filter({ has: page.locator('text=50 pts') }) + .first() + await expect(customRow).toBeVisible({ timeout: 10000 }) + await expect(customRow.getByRole('button', { name: /Delete/ })).toBeVisible() + + // delete and confirm restoration using the same customRow + await customRow.getByRole('button', { name: /Delete/ }).click() + await expect(page.locator('text=Are you sure you want to delete')).toBeVisible() + await page.locator('button.btn-danger:has-text("Delete")').click() + + // wait until the exact 'Choose meal' copy showing the edited cost is gone + await expect( + page + .getByText('Choose meal', { exact: true }) + .locator('..') + .filter({ has: page.locator('text=50 pts') }), + ).toHaveCount(0, { timeout: 10000 }) + + // reload the list to clear any lingering delete icon artifacts + await page.reload() + await page.waitForURL('/parent/rewards') + + // ensure no delete button is visible on the exact default 'Choose meal' row + await expect( + page + .getByText('Choose meal', { exact: true }) + .locator('..') + .getByRole('button', { name: 'Delete' }), + ).toHaveCount(0, { timeout: 10000 }) + + const finalRow = page.getByText('Choose meal', { exact: true }).first() + await expect(finalRow).toBeVisible() + await expect(finalRow).not.toContainText('50 pts') + }) +}) diff --git a/frontend/vue-app/e2e/plans/parent-rewards-management.plan.md b/frontend/vue-app/e2e/plans/parent-rewards-management.plan.md new file mode 100644 index 0000000..7fe0bfe --- /dev/null +++ b/frontend/vue-app/e2e/plans/parent-rewards-management.plan.md @@ -0,0 +1,113 @@ +# Parent Rewards Management + +## Application Overview + +Focus on parent-mode flows interacting with the Rewards section. A parent must be authenticated via PIN and can create, cancel, edit, delete, and manage default rewards. The scenarios mirror the chore/kindness/penalty plans but target `/parent/rewards` and the reward-specific fields (name, cost, description). All tests run under parent mode in the `mode_parent/rewards` directory. Unique names use a `${Date.now()}` suffix to avoid collisions. + +## Test Scenarios + +### 1. Create and manage rewards + +**Seed:** `e2e/seed.spec.ts` + +#### 1.1. Create a new reward + +**File:** `e2e/mode_parent/rewards/reward-create-edit.spec.ts` + +**Steps:** + 1. - + - expect: Navigate to `/parent/rewards` and verify the default reward list loads (some reward name visible) + 2. Click the 'Create Reward' FAB + - expect: The create form appears with 'Reward Name' and 'Cost' fields + - expect: Create button is disabled initially + 3. Enter a unique name (e.g. `Toy car ${suffix}`) and `20` in Cost via DOM events + - expect: Create button becomes enabled + 4. Click the Create button + - expect: No `.error` elements are shown + - expect: The new reward appears in the list + +#### 1.2. Cancel reward creation + +**File:** `e2e/mode_parent/rewards/reward-cancel.spec.ts` + +**Steps:** + 1. - + - expect: Navigate to `/parent/rewards` and click 'Create Reward' + - expect: Create form is displayed with name label visible + 2. Fill 'Test' for name and '5' for cost then click 'Cancel' + - expect: User is returned to the rewards list + - expect: No element with text 'Test' exists + +#### 1.3. Edit an existing reward + +**File:** `e2e/mode_parent/rewards/reward-create-edit.spec.ts` + +**Steps:** + 1. - + - expect: Navigate to `/parent/rewards`; create `Toy car ${suffix}` if absent + - expect: The reward appears in the list + 2. Click the reward row to open edit form + - expect: Edit form appears with 'Reward Name' label visible + 3. Change the name to `Toy boat ${suffix}` and cost to '25' via DOM events + - expect: Save button becomes enabled + 4. Click Save + - expect: No errors are displayed + - expect: Updated reward name appears in the list + +#### 1.4. Cancel reward edit + +**File:** `e2e/mode_parent/rewards/reward-cancel.spec.ts` + +**Steps:** + 1. - + - expect: Navigate to `/parent/rewards`; ensure `Toy car` exists; click its row + - expect: Edit form is displayed + 2. Change the name to 'Should not save' via DOM events then click 'Cancel' + - expect: Rewards list visible again + - expect: `Toy car` is still present + - expect: `Should not save` does not appear + +#### 1.5. Convert a default reward to a user item by editing + +**File:** `e2e/mode_parent/rewards/reward-convert-default.spec.ts` + +**Steps:** + 1. - + - expect: Navigate to `/parent/rewards` and locate a default reward such as 'Choose meal' + - expect: Item is visible with no delete button + 2. Click the row to open edit form + - expect: Edit form opens with `input#name` value matching the default + 3. Change the name to 'Choose meal (custom)' and cost to '35' + - expect: Save button becomes enabled + 4. Click Save + - expect: Row now shows a visible delete button + +#### 1.6. Delete a reward + +**File:** `e2e/mode_parent/rewards/reward-create-edit.spec.ts` + +**Steps:** + 1. - + - expect: Navigate to `/parent/rewards`; create a unique reward if necessary + - expect: The reward appears in the list with a delete control + 2. Click the delete icon and confirm the dialog + - expect: Item is removed from the list + +#### 1.7. Edit default reward cost and verify system restoration on delete + +**File:** `e2e/mode_parent/rewards/reward-delete-default.spec.ts` + +**Steps:** + 1. - + - expect: Navigate to `/parent/rewards`; clean up any user-copy of 'Choose meal' from prior runs + - expect: Verify default 'Choose meal' is present with no delete icon + 2. Click 'Choose meal' to open edit form; confirm input#name reads 'Choose meal'; change cost to '50' via DOM events + - expect: Save button becomes enabled + 3. Click Save + - expect: Exactly one 'Choose meal' row exists + - expect: Delete icon now visible on that row + - expect: Cost display reads '50 pts' + 4. Click delete icon and confirm + - expect: 'Choose meal' remains in list (default restored) + - expect: No delete icon present + - expect: Cost display returns to original value