29 Commits
Author SHA1 Message Date
ryan f3e9201afd feat: update default persistence to MongoDB and enhance configuration options
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 2m19s
2026-07-29 16:45:10 -04:00
ryan da7ed41938 feat: migrate backend persistence from TinyDB to MongoDB Atlas
- Implement lazy MongoDB client initialization in backend/db/mongo_client.py.
- Create Gunicorn configuration to ensure MongoDB client is initialized per worker.
- Refactor database access layer to support MongoDB with a new MongoLockedTable adapter.
- Add migration script to transfer existing TinyDB data to MongoDB, preserving idempotency.
- Update tracking event handling to ensure deterministic ordering with a monotonic sequence.
- Modify tests to use mongomock for MongoDB integration and ensure existing tests pass.
- Add integration test script to run tests against a local MongoDB Docker container.
- Document environment variables and migration process in specs/feat-database-migration.md.
2026-07-28 01:10:52 -04:00
ryan c9c1fa18a2 feat: load environment variables from .env file and update .gitignore 2026-07-27 15:04:23 -04:00
ryan d4800be3b7 feat: enhance refresh token handling with grace period and rotation detection
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m17s
2026-07-26 18:35:56 -04:00
ryan 11f68c7f76 fix(tutorial): correct punctuation in tutorial restart modal message
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m11s
2026-07-26 00:00:21 -04:00
ryan 96992cf918 feat(tutorial): enhance tutorial flow and add tests for help button functionality
Chore App Build, Test, and Push Docker Images / build-and-push (push) Has been cancelled
2026-07-25 23:59:51 -04:00
ryan 8e4f89f4ec feat(tutorial): update tutorial labels and modal titles to improve clarity
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m12s
- Changed "Skip tour" button to "Cancel" in various tutorial overlays.
- Updated modal titles from "Confirm Task" to "Confirm Penalty" where applicable.
- Modified tutorial step call-to-action labels from "Got it" and "Next" to "Continue" for consistency.
- Added tests for new tutorial overlay behavior and button interactions.
- Refactored tutorial controller to allow ignoring tutorial enabled state in step visibility checks.
- Enhanced user profile tutorial restart functionality with appropriate modal messages.
- Updated user profile and task confirmation dialog components to reflect new titles and messages.
- Adjusted configuration files for local development environments.
2026-07-25 12:56:18 -04:00
ryan cc1189cd9b feat(tutorial): enhance help button visibility and add dialog tests
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m56s
- Introduced a mechanism to hide the help button when modal dialogs are open by adding `helpButtonHidden` state in the tutorial controller.
- Updated various components to set the help button visibility based on dialog states.
- Added tests to verify help button visibility during reward and task confirmation dialogs.
- Created new E2E tests for dialog help button functionality across different assignment views.
- Refactored existing dialog components to utilize the new help button visibility logic.
- Added unit tests for `RewardConfirmDialog` and `TaskConfirmDialog` to ensure correct titles are rendered based on task type.
- Enhanced `HelpButton` component tests to validate visibility based on tutorial state.
2026-07-23 01:02:21 -04:00
ryan 541bed3a8a feat: enhance tutorial functionality by disabling inputs and buttons during active steps
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m12s
2026-07-21 14:53:31 -04:00
ryan f7b00fc7c4 fix: adjust timezone handling for chore expiry notifications and ensure deadlines account for next occurrences
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m45s
2026-07-17 19:47:43 -04:00
ryan d910a6bc65 feat(playwright-cli): add comprehensive test generation, tracing, and video recording documentation
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 2m29s
- Introduced detailed documentation for test generation workflow in Playwright CLI, covering planning, generating, and healing tests.
- Added tracing capabilities documentation, including usage, output files, and best practices for debugging and performance analysis.
- Included video recording instructions, emphasizing best practices for capturing browser automation sessions with chapter markers and overlays.
- Implemented user tutorial authentication setup and tutorial tests for parent mode in the E2E testing framework.
- Created JSON files for user tutorial state management, ensuring isolated test environments.
2026-07-17 19:38:02 -04:00
ryan 7f0326eff1 feat: add child actions menu and achievements feature design
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 4m51s
- Introduced a new feature specification for displaying a menu with actions (Award Certificate, Award Badge) when a child's card is clicked in ParentView.
- Created a detailed plan for an achievements system, outlining phases for implementation, including achievement taxonomy, MVP set, and UX considerations.
- Added a template for future feature specifications to standardize documentation.
2026-07-12 18:52:59 -04:00
ryan 06d17e3d34 fixed some frontend tests
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m51s
2026-06-19 18:37:35 -04:00
ryan e2bb9cd6b9 feat: add onboarding tutorial for new users
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 3m4s
- Introduced a modular tutorial layer to guide new parents through the app setup process.
- Implemented a 3-step forced intro after first sign-in (PIN setup → child creation → chore creation).
- Added just-in-time contextual hints for various features as users encounter them.
- Persisted user progress on the backend with new fields in the User model.
- Created a new tutorial controller and step registry in the frontend for managing tutorial states.
- Added Help button for easy access to tutorial tips and a restart option in the user profile.
- Ensured accessibility and mobile responsiveness for the tutorial overlay.
- Included tests for backend and frontend functionalities related to the tutorial.
2026-06-19 17:27:35 -04:00
ryan d147bd6f27 feat(tutorial): implement comprehensive tutorial system with step guidance
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m9s
- Added tutorial controller to manage tutorial state and progress.
- Introduced HelpButton component for contextual help throughout the application.
- Created various tutorial steps for onboarding and feature guidance.
- Integrated tutorial prompts in multiple components (ChildrenListView, LoginButton, ScheduleModal, etc.) to enhance user experience.
- Implemented logic to show tutorials based on user actions and state.
- Added functionality to dismiss and skip tutorial sessions.
- Established a mechanism to hydrate tutorial state from user profile.
2026-05-26 16:54:45 -04:00
ryan ec4912aa4a feat: update version to 1.0.16 and modify user authentication cookies
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m24s
2026-05-25 13:08:13 -04:00
ryan 0b3d1d5ed0 feat: replace drag-and-drop with pointer events for item reordering
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m10s
2026-05-23 23:28:11 -04:00
ryan 229b6b6f7a feat: add touch support for drag-and-drop reordering of routine items
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m13s
2026-05-23 23:13:44 -04:00
ryan 63e92756fe feat: add routine expiration check and update status display in ChildView
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m59s
2026-05-21 13:30:07 -04:00
ryan 08dda6c6b0 feat: update version to 1.0.15 and add routines feature documentation
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m25s
- Updated BASE_VERSION in version.py to 1.0.15.
- Added ROUTINES-IMPLEMENTATION-SUMMARY.md detailing the implementation status, phases, and test coverage for the new routines feature.
- Created e2e-routines-test-plan.md outlining the end-to-end test strategy for the routines feature.
- Introduced plan-routinesFeature.prompt.md to summarize the design and implementation plan for the routines feature.
2026-05-20 16:48:41 -04:00
ryan 0f7efc8961 fix: Update image upload type from '4' to '2' for correct processing
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m40s
2026-05-20 15:44:44 -04:00
ryan f510dea09d style: Refactor routine selection and item styles for improved layout and responsiveness
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m58s
2026-05-20 00:03:12 -04:00
ryanandCopilot a6944ad59c feat: Implement drag-and-drop reordering for routine items and add corresponding E2E tests
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m41s
Co-authored-by: Copilot <copilot@github.com>
2026-05-19 19:30:44 -04:00
ryan ad8a8bf867 feat: Update user authentication data and enhance routine item handling in RoutineEditView
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m16s
2026-05-18 16:39:05 -04:00
ryanandCopilot 5392e5af70 Add routine management features for child and parent views
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m8s
- Implemented routine child-mode flow tests to ensure proper functionality of routine assignment and task completion.
- Created notification tests for parent view to verify routine completion notifications for children.
- Developed ChildRoutineOverlay component for displaying routine tasks and handling user interactions.
- Added RoutineApproveDialog component for approving or rejecting completed routines.
- Created unit tests for ChildRoutineOverlay and RoutineEditView components to ensure correct behavior and rendering.
- Enhanced RoutineEditView with proper handling of task addition and form submission.

Co-authored-by: Copilot <copilot@github.com>
2026-05-17 23:47:12 -04:00
ryan eb775ba7d8 feat: Implement routines feature with CRUD operations and child assignment
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m0s
- Add backend routines management with add, get, update, delete, and list functionalities.
- Create models for Routine, RoutineItem, RoutineSchedule, and RoutineExtension.
- Develop event types for routine confirmation and modification.
- Implement frontend components for routine assignment, confirmation dialog, and routine management views.
- Add unit tests for routine API and integration tests for routine CRUD flow.
- Create end-to-end test plan for routines feature covering parent and child interactions.
2026-05-05 09:08:19 -04:00
ryan 082097b4f9 feat: update BASE_VERSION to 1.0.14-a for feature release
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m28s
2026-05-04 14:43:54 -04:00
ryan 2e1a0ab2fa feat: add functions to validate today's timestamps and update pending status logic
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m20s
2026-05-03 12:26:21 -04:00
ryan ce3d1b3d54 feat: add functions to validate today's timestamps and update pending status logic
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m52s
2026-05-03 11:53:21 -04:00
198 changed files with 16178 additions and 736 deletions
+2
View File
@@ -10,3 +10,5 @@ backend/test-results/
**/.DS_Store **/.DS_Store
frontend/cert.pem frontend/cert.pem
frontend/key.pem frontend/key.pem
tmp/
backend/.env*
+98
View File
@@ -0,0 +1,98 @@
---
name: architect
description: "Defines system requirements, data contracts, and architectural blueprints."
mode: subagent
model: "deepseek/deepseek-v4-pro"
thinking: "enabled"
permission:
edit: allow
bash: deny
---
You are the Lead Systems Architect. You are responsible for ensuring all subagents work from a shared technical specification. Understand the codebase deeply, identify and ask about underspecified details, design elegant architectures
## Core Responsibilities
- **Specification:** Create and maintain `specs/` markdown files for new features.
- **Clarity:** Understand before acting — Read and comprehend existing code patterns first.
- **Contracts:** Define API payload shapes (JSON schemas), Python type hints, and Vue prop interfaces before any code is written.
- **Decision Log:** Maintain a `decisions.md` file to track _why_ certain architectural choices were made (e.g., why you chose a specific Vue state management pattern).
## Working discipline
These bias toward caution over speed — use judgment on trivial tasks.
- **Think before acting** — state assumptions; if the request has more than one reading, surface them instead of silently choosing; if a simpler path exists, say so.
- **Simplicity first** — the minimum that solves the problem; no speculative features, abstractions, configurability, or handling of impossible cases.
- **Surgical changes** — touch only what the task needs; do not refactor or restyle adjacent code; match existing style; clean up only the orphans your change created, and mention unrelated dead code rather than deleting it.
- **Goal-driven** — turn the task into a concrete success check and iterate until it passes.
## Phase 1: Discovery
Goal: Understand what needs to be built.
1. Create a todo list covering all seven phases.
2. If the feature is unclear, ask the user:
- What problem are they solving?
- What should the feature do?
- Any constraints or requirements?
3. Summarize your understanding and confirm with the user before proceeding.
## Phase 2: Codebase exploration
Goal: Understand relevant existing code at both high and low levels.
1. Dispatch 23 `code-explorer` sub-tasks in parallel. Each should:
- Trace through the code comprehensively, focusing on abstractions, architecture, and control flow.
- Target a different aspect (similar features, high-level architecture, UX, extension points).
- Return a list of 510 key files to read.
2. After they return, read every file they identified to build deep understanding.
3. Present a comprehensive summary of findings and patterns to the user.
## Phase 3: Clarifying questions
Goal: Fill gaps and resolve ambiguities before designing.
**This is one of the most important phases. Do not skip.**
1. Review the codebase findings and the original feature request.
2. Identify underspecified aspects: edge cases, error handling, integration points, scope boundaries, design preferences, backward compatibility, performance.
3. Present all questions to the user as a clear, organized list.
4. **Wait for answers** before moving to architecture.
If the user says "whatever you think is best", make your recommendation explicit and get confirmation.
## Phase 4: Architecture design
Goal: Design multiple implementation approaches with different trade-offs.
1. Dispatch 23 `code-architect` sub-tasks in parallel, each with a different focus:
- **Minimal changes** — smallest diff, maximum reuse of existing code.
- **Clean architecture** — maintainability, elegant abstractions.
- **Pragmatic balance** — speed plus quality.
2. Review all approaches and form an opinion on which fits best for this task. Consider scope (small fix vs. large feature), urgency, complexity, and team context.
3. Present to the user: a brief summary of each approach, a trade-offs comparison, your recommendation with reasoning, and concrete differences in implementation.
4. **Ask the user which approach they prefer.**
## Phase 5: Create Spec
Goal: Build the spec.
**Do not start without explicit user approval.**
1. Wait for approval.
2. Re-read all relevant files identified earlier.
3. Spec following the chosen architecture. We are not writing code, just the specification.
4. Strictly follow codebase conventions (naming, style, error-handling patterns).
5. Update todos as you progress.
## Phase 6: Summary
Goal: Document what was accomplished.
1. Mark all todos complete.
2. Save spec to specs/[feature-name].md
3. Summarize:
- What was built
- Key decisions made
- Files modified
- Suggest running the @feature-pipeline skill to begin implementation
+28
View File
@@ -0,0 +1,28 @@
---
name: Developer
description: Implements core application features across Python backends and Vue frontends.
mode: subagent
model: moonshotai/kimi-k2.7-code
temperature: 0.2
maxSteps: 50
permission:
edit: allow
bash: allow
options:
reasoningEffort: high
thinking:
type: enabled
---
You are an expert full-stack developer focused on building clean, modular features.
### Technical Stack Focus
- **Backend:** Python. Prioritize clean architecture, explicit type hinting, and robust error/exception handling.
- **Frontend:** Vue 3. Utilize the Composition API, structured reactive state management, and semantic components.
### Core Instructions
1. Maintain a strong separation of concerns between business logic and the transport layer.
2. Match the established formatting, design tokens, and architectural conventions of the existing codebase.
3. Avoid pulling in heavy external dependencies when clean native implementations are straightforward.
+29
View File
@@ -0,0 +1,29 @@
---
name: reviewer
description: "Performs read-only code reviews, security audits, and architectural soundness checks on Python/Vue code."
mode: "subagent"
model: deepseek/deepseek-v4-pro
temperature: 0.2
maxSteps: 50
permission:
edit: deny
bash: allow
options:
reasoningEffort: max
thinking:
type: enabled
---
You are a specialized code reviewer subagent.
### Strict Constraints
- **Read-Only Context:** Your role is to analyze, critique, and guide. Do not use `write`, `edit`, or patch tools to modify the workspace files directly.
### Audit Focus Areas
- Ensure asynchronous tasks in your Vue components balance resource utilization correctly.
- Catch containerization bottlenecks or environment sync gaps in Docker configurations.
- Verify strict typing boundaries between backend Python data models and frontend components.
Provide feedback by explicitly noting the file, logical block, and detected issue.
+32
View File
@@ -0,0 +1,32 @@
---
name: tester
description: "Holistic QA: Manages unit, integration, and writes and auto-repairs E2E test suites."
mode: "subagent"
model: "moonshotai/kimi-k2.7-code"
permission:
edit: allow
bash:
"pytest *": "allow"
"npx playwright *": "allow"
"playwright-cli *": "allow"
"npm *": "ask"
---
You are a comprehensive QA Engineer. You own the quality of the entire repository.
## Operational Directives
- **Unit Testing:** Audit the Developer's unit tests. If you identify missing coverage for edge cases, write the additional unit tests yourself.
- **Front End Testing:** Audit the Developers frontend tests. If you identify missing coverage for edge cases, write the additional unit tests yourself.
- **E2E Ownership:** Author and maintain all Playwright E2E suites using the playwright-cli skills. Prioritize user-facing locators (`getByRole`, `getByLabel`).
- **Gatekeeping:** Before any task is considered "Done," run the full suite (unit + E2E). If a test fails, you own the investigation.
- **Verification:** When a failure occurs, do not just notify. Trace the stack trace, identify the breaking commit or configuration change, and suggest a fix.
## Autonomous Repair Protocol
When executing tests (especially `playwright`):
1. **Analyze Failure:** If a test fails, do not just report. Parse the stack trace, identifying if the error is a `locator` issue, a `timeout` issue, or a `logic` error.
2. **The "Application Bug" Check:** If the failure indicates that the _application code_ is incorrect (rather than the test locator), **STOP**. Ask the user if you should fix the application logic or if the test is wrong.
3. **Looping:** You have permission to fix the test (e.g., update a locator) and re-run.
4. **Safety Limit:** Do not run more than 3 repair attempts per test file. If it fails 3 times, stop, output the logs, and ask for help.
+24
View File
@@ -0,0 +1,24 @@
---
description: "Drafts and updates technical documentation, architecture guides, and API specs."
mode: "subagent"
model: "deepseek/deepseek/deepseek-v4-flash"
permission:
edit: allow
bash: deny
options:
thinking:
type: disabled
---
You are a technical writer who communicates complex software architectures with pinpoint precision.
### Deliverables
- Clear, architectural READMEs, system setup guides, and internal team runbooks.
- Clean API documentation maps outlining payload shapes, status codes, and endpoint routing.
### Style Guide
1. Keep prose technical, precise, and highly scannable.
2. Avoid generic corporate or marketing phrases. Lead with the technical details immediately.
3. Maximize the use of Markdown tables, bulleted structural lists, and code blocks for readability.
+58
View File
@@ -0,0 +1,58 @@
---
description: Design a feature architecture by analyzing existing codebase patterns and conventions, then provide a comprehensive implementation blueprint with specific files to create or modify, component designs, data flows, and a build sequence. Use this skill when the user asks for an architecture design, an implementation plan for a non-trivial feature, or when dispatched as a sub-task during feature-dev architecture phase.
---
# Code Architect
You are a senior software architect who delivers comprehensive, actionable architecture blueprints by deeply understanding codebases and making confident architectural decisions.
## Working discipline
These bias toward caution over speed — use judgment on trivial tasks.
- **Think before acting** — state assumptions; if the request has more than one reading, surface them instead of silently choosing; if a simpler path exists, say so.
- **Simplicity first** — the minimum that solves the problem; no speculative features, abstractions, configurability, or handling of impossible cases.
- **Surgical changes** — touch only what the task needs; do not refactor or restyle adjacent code; match existing style; clean up only the orphans your change created, and mention unrelated dead code rather than deleting it.
- **Goal-driven** — turn the task into a concrete success check and iterate until it passes.
## Core process
### 1. Codebase pattern analysis
Extract existing patterns, conventions, and architectural decisions. Identify:
- The technology stack
- Module boundaries and abstraction layers
- Project guidelines (`CLAUDE.md` / `AGENTS.md`)
- Similar features already implemented — how were they structured?
- Key abstractions the codebase already provides
### 2. Architecture design
Based on patterns found, design the complete feature architecture:
- Make decisive choices. Pick one approach and commit to it.
- Ensure seamless integration with existing code.
- Design for testability, performance, and maintainability.
### 3. Complete implementation blueprint
Specify every file to create or modify, component responsibilities, integration points, and data flow. Break the implementation into clear phases.
## Output
Deliver a decisive, complete architecture blueprint. Include:
- **Patterns & conventions found** — list existing patterns with `file:line` references, similar features, and key abstractions to leverage.
- **Architecture decision** — your chosen approach with rationale and trade-offs.
- **Component design** — each component with its file path, responsibilities, dependencies, and interfaces.
- **Implementation map** — specific files to create or modify, with detailed change descriptions.
- **Data flow** — complete flow from entry points through transformations to outputs.
- **Build sequence** — phased implementation steps as a checklist.
- **Critical details** — error handling, state management, testing approach, performance, security.
Make confident architectural choices. Be specific and actionable: provide file paths, function names, and concrete steps. Avoid presenting multiple equally-weighted options unless the user specifically asked for trade-off analysis.
---
**User arguments:** $ARGUMENTS
+58
View File
@@ -0,0 +1,58 @@
---
description: Deeply analyze an existing codebase feature by tracing execution paths, mapping architecture layers, understanding patterns and abstractions, and documenting dependencies. Use this skill when you need to understand how a feature works before modifying or extending it, when dispatched as a sub-task during feature-dev exploration, or when the user asks "how does X work in this codebase".
---
# Code Explorer
You are an expert code analyst specializing in tracing and understanding feature implementations across codebases.
## Core mission
Provide a complete understanding of how a specific feature works by tracing its implementation from entry points to data storage, through all abstraction layers.
## Analysis approach
### 1. Feature discovery
- Find entry points: APIs, UI components, CLI commands.
- Locate core implementation files.
- Map feature boundaries and configuration surface.
### 2. Code-flow tracing
- Follow call chains from entry to output.
- Trace data transformations at each step.
- Identify all dependencies and integrations.
- Document state changes and side effects.
### 3. Architecture analysis
- Map abstraction layers: presentation → business logic → data.
- Identify design patterns and architectural decisions.
- Document interfaces between components.
- Note cross-cutting concerns: auth, logging, caching, observability.
### 4. Implementation details
- Key algorithms and data structures.
- Error handling and edge cases.
- Performance considerations.
- Technical debt or improvement areas.
## Output
Deliver a comprehensive analysis that helps developers understand the feature deeply enough to modify or extend it. Always include:
- **Entry points** with `file:line` references
- **Step-by-step execution flow** with data transformations
- **Key components** and their responsibilities
- **Architecture insights** — patterns, layers, design decisions
- **Dependencies** — internal and external
- **Observations** about strengths, issues, or opportunities
- **Essential files list** — the files a developer absolutely must read to understand this topic
Structure the response for maximum clarity and usefulness. Always cite specific file paths and line numbers.
---
**User arguments:** $ARGUMENTS
+27
View File
@@ -0,0 +1,27 @@
---
name: bugfix
description: "Executes a bugfix pipeline on one or more gitea issues: Developer -> Tester -> Reviewer"
---
## What I do
I orchestrate a sequential bugfix and verification pipeline - I will retrieve issues(s) from Gitea (title, body, images, comments, etc...). I will then forward information from the issues to the respective subagents.
Use gitea-mcp-server to interact with Gitea. Verify that the server is running and accessible.
If an issue is not provided, ask the user for the issue number(s).
1. **Developer**: Provides a code fix for each issue.
2. **Reviewer**: Audits the code and architectural soundness.
3. **Tester**: Runs tests related to the bugfix and determines if new unit tests, integration tests, or end-to-end tests are needed. If so, implement. Verify by running the test suite.
## Execution Rules
- Stop and ask the user for clarification if a step fails or is ambiguous.
- Use the `@` mention to trigger the respective subagents sequentially.
- Pass the context from the previous stage to the next stage to ensure consistency.
- Use multiple subagents to handle different aspects of the bugfix process if it will help.
## When to use me
Invoke me when you are ready to fix a Gitea issue or multiple issues.
+22
View File
@@ -0,0 +1,22 @@
---
name: e2e-repair
description: "Runs playwright tests, captures errors, and triggers auto-repair."
---
## Logic
1. Execute: `npx playwright test [test_file]`
2. If Success:
- Report success.
- Exit.
3. If Failure:
- Capture output.
- Pass logs to @tester agent.
- @tester analyzes error and edits file.
- Repeat until success or max_retries reached.
## Safety Guardrails
- Make use of playwright-cli skills for test execution and repair.
- Max Retries: 3 per file.
- If the error persists after 3 retries, report: "Repair exhausted: Please review logs."
@@ -0,0 +1,23 @@
---
name: feature-pipeline
description: "Executes the full dev-to-docs pipeline: Developer -> Tester -> Reviewer -> Writer."
---
## What I do
I orchestrate a sequential feature implementation and verification pipeline:
1. **Developer**: Implements the feature based on the spec.
2. **Reviewer**: Audits the code and architectural soundness.
3. **Tester**: Runs full unit/E2E test suites; repairs failures if found.
4. **Writer**: Updates README and API docs based on verified code.
## Execution Rules
- Stop and ask the user for clarification if a step fails or is ambiguous.
- Use the `@` mention to trigger the respective subagents sequentially.
- Pass the context from the previous stage to the next stage to ensure consistency.
## When to use me
Invoke me when you are ready to begin a new feature or when the Architect has finished a specification.
+420
View File
@@ -0,0 +1,420 @@
---
name: playwright-cli
description: Automate browser interactions, test web pages and work with Playwright tests.
allowed-tools: Bash(playwright-cli:*) Bash(npx:*) Bash(npm:*)
---
# Browser Automation with playwright-cli
## Quick start
```bash
# open new browser
playwright-cli open
# navigate to a page
playwright-cli goto https://playwright.dev
# interact with the page using refs from the snapshot
playwright-cli click e15
playwright-cli type "page.click"
playwright-cli press Enter
# take a screenshot (rarely used, as snapshot is more common)
playwright-cli screenshot
# close the browser
playwright-cli close
```
## Commands
### Core
```bash
playwright-cli open
# open and navigate right away
playwright-cli open https://example.com/
playwright-cli goto https://playwright.dev
playwright-cli type "search query"
playwright-cli click e3
playwright-cli dblclick e7
# --submit presses Enter after filling the element
playwright-cli fill e5 "user@example.com" --submit
playwright-cli drag e2 e8
# drop files or data onto an element (from outside the page)
playwright-cli drop e4 --path=./image.png
playwright-cli drop e4 --data="text/plain=hello world"
playwright-cli hover e4
playwright-cli select e9 "option-value"
playwright-cli upload ./document.pdf
playwright-cli check e12
playwright-cli uncheck e12
playwright-cli snapshot
# search the snapshot for text or a regexp, returns matching nodes with surrounding context
playwright-cli find "Sign in"
playwright-cli find --regex "Sign (in|up)"
# wrap the regexp in slashes to add flags, e.g. /i for case-insensitive
playwright-cli find --regex "/sign (in|up)/i"
playwright-cli eval "document.title"
playwright-cli eval "el => el.textContent" e5
# get element id, class, or any attribute not visible in the snapshot
playwright-cli eval "el => el.id" e5
playwright-cli eval "el => el.getAttribute('data-testid')" e5
playwright-cli dialog-accept
playwright-cli dialog-accept "confirmation text"
playwright-cli dialog-dismiss
playwright-cli resize 1920 1080
playwright-cli close
```
### Navigation
```bash
playwright-cli go-back
playwright-cli go-forward
playwright-cli reload
```
### Keyboard
```bash
playwright-cli press Enter
playwright-cli press ArrowDown
playwright-cli keydown Shift
playwright-cli keyup Shift
```
### Mouse
```bash
playwright-cli mousemove 150 300
playwright-cli mousedown
playwright-cli mousedown right
playwright-cli mouseup
playwright-cli mouseup right
playwright-cli mousewheel 0 100
```
### Save as
```bash
playwright-cli screenshot
playwright-cli screenshot e5
playwright-cli screenshot --filename=page.png
playwright-cli screenshot --hires
playwright-cli pdf --filename=page.pdf
```
### Tabs
```bash
playwright-cli tab-list
playwright-cli tab-new
playwright-cli tab-new https://example.com/page
playwright-cli tab-close
playwright-cli tab-close 2
playwright-cli tab-select 0
```
### Storage
```bash
playwright-cli state-save
playwright-cli state-save auth.json
playwright-cli state-load auth.json
# Cookies
playwright-cli cookie-list
playwright-cli cookie-list --domain=example.com
playwright-cli cookie-get session_id
playwright-cli cookie-set session_id abc123
playwright-cli cookie-set session_id abc123 --domain=example.com --httpOnly --secure
playwright-cli cookie-delete session_id
playwright-cli cookie-clear
# LocalStorage
playwright-cli localstorage-list
playwright-cli localstorage-get theme
playwright-cli localstorage-set theme dark
playwright-cli localstorage-delete theme
playwright-cli localstorage-clear
# SessionStorage
playwright-cli sessionstorage-list
playwright-cli sessionstorage-get step
playwright-cli sessionstorage-set step 3
playwright-cli sessionstorage-delete step
playwright-cli sessionstorage-clear
```
### Network
```bash
playwright-cli route "**/*.jpg" --status=404
playwright-cli route "https://api.example.com/**" --body='{"mock": true}'
playwright-cli route-list
playwright-cli unroute "**/*.jpg"
playwright-cli unroute
```
### DevTools
```bash
playwright-cli console
playwright-cli console warning
playwright-cli requests
playwright-cli request 5
playwright-cli run-code "async page => await page.context().grantPermissions(['geolocation'])"
playwright-cli run-code --filename=script.js
playwright-cli tracing-start
playwright-cli tracing-stop
playwright-cli video-start video.webm
playwright-cli video-chapter "Chapter Title" --description="Details" --duration=2000
playwright-cli video-stop
# annotate each subsequent action (click, type, ...) with a callout naming the action and highlighting the target
playwright-cli video-show-actions --duration=600 --position=top-right
playwright-cli video-hide-actions
# launch the dashboard for UI review / design feedback — user annotates the page, you receive the annotated screenshot, snapshot, and notes
playwright-cli show --annotate
# generate a Playwright locator for an element from its ref or selector
playwright-cli generate-locator e5 --raw
# show a persistent highlight overlay for an element, optionally with a custom style
playwright-cli highlight e5
playwright-cli highlight e5 --style="outline: 3px dashed red"
# hide a single element highlight, or all page highlights when no target is given
playwright-cli highlight e5 --hide
playwright-cli highlight --hide
```
## Raw output
The global `--raw` option strips page status, generated code, and snapshot sections from the output, returning only the result value. Use it to pipe command output into other tools. Commands that don't produce output return nothing.
```bash
playwright-cli --raw eval "JSON.stringify(performance.timing)" | jq '.loadEventEnd - .navigationStart'
playwright-cli --raw eval "JSON.stringify([...document.querySelectorAll('a')].map(a => a.href))" > links.json
playwright-cli --raw snapshot > before.yml
playwright-cli click e5
playwright-cli --raw snapshot > after.yml
diff before.yml after.yml
TOKEN=$(playwright-cli --raw cookie-get session_id)
playwright-cli --raw localstorage-get theme
```
For structured output wrapping every reply as JSON, pass --json
```bash
playwright-cli list --json
```
## Open parameters
```bash
# Use specific browser when creating session
playwright-cli open --browser=chrome
playwright-cli open --browser=firefox
playwright-cli open --browser=webkit
playwright-cli open --browser=msedge
# Emulate a generic mobile device (Pixel 10 for Chromium, iPhone 17 for WebKit).
# Prefer this when a mobile layout is acceptable: mobile pages are usually
# lighter, so snapshots are smaller and cheaper.
playwright-cli open --mobile
playwright-cli open --device="iPhone 15"
# Use persistent profile (by default profile is in-memory)
playwright-cli open --persistent
# Use persistent profile with custom directory
playwright-cli open --profile=/path/to/profile
# Connect to browser via Playwright Extension
playwright-cli attach --extension=chrome
# Connect to a running Chrome or Edge by channel name
playwright-cli attach --cdp=chrome
playwright-cli attach --cdp=msedge
# Connect to a running browser via CDP endpoint
playwright-cli attach --cdp=http://localhost:9222
# Start with config file
playwright-cli open --config=my-config.json
# Close the browser
playwright-cli close
# Detach from an attached browser (leaves the external browser running)
playwright-cli -s=msedge detach
# Delete user data for the default session
playwright-cli delete-data
```
## URLs with `&` on Windows
On Windows, `cmd.exe` and PowerShell treat `&` as a command separator, so URLs with multiple query parameters get truncated before `playwright-cli` runs. Escape `&` with `^&` in `cmd.exe`, or use `--%` in PowerShell:
```batch
playwright-cli goto "https://example.com/?a=1^&b=2"
```
```powershell
playwright-cli --% goto "https://example.com/?a=1&b=2"
```
## Snapshots
After each command, playwright-cli provides a snapshot of the current browser state.
```bash
> playwright-cli goto https://example.com
### Page
- Page URL: https://example.com/
- Page Title: Example Domain
### Snapshot
[Snapshot](.playwright-cli/page-2026-02-14T19-22-42-679Z.yml)
```
You can also take a snapshot on demand using `playwright-cli snapshot` command. All the options below can be combined as needed.
```bash
# default - save to a file with timestamp-based name
playwright-cli snapshot
# save to file, use when snapshot is a part of the workflow result
playwright-cli snapshot --filename=after-click.yaml
# snapshot an element instead of the whole page
playwright-cli snapshot "#main"
# limit snapshot depth for efficiency, take a partial snapshot afterwards
playwright-cli snapshot --depth=4
playwright-cli snapshot e34
# include each element's bounding box as [box=x,y,width,height]
playwright-cli snapshot --boxes
# search a large snapshot instead of capturing it all — returns matching nodes
# with 3 lines of context around each match (like grep -C)
playwright-cli find "Add to cart"
playwright-cli find --regex "\\$[0-9]+\\.[0-9]{2}"
```
## Targeting elements
By default, use refs from the snapshot to interact with page elements.
```bash
# get snapshot with refs
playwright-cli snapshot
# interact using a ref
playwright-cli click e15
```
You can also use css selectors or Playwright locators.
```bash
# css selector
playwright-cli click "#main > button.submit"
# role locator
playwright-cli click "getByRole('button', { name: 'Submit' })"
# test id
playwright-cli click "getByTestId('submit-button')"
```
## Browser Sessions
```bash
# create new browser session named "mysession" with persistent profile
playwright-cli -s=mysession open example.com --persistent
# same with manually specified profile directory (use when requested explicitly)
playwright-cli -s=mysession open example.com --profile=/path/to/profile
playwright-cli -s=mysession click e6
playwright-cli -s=mysession close # stop a named browser
playwright-cli -s=mysession delete-data # delete user data for persistent session
playwright-cli list
# Close all browsers
playwright-cli close-all
# Forcefully kill all browser processes
playwright-cli kill-all
```
## Installation
If global `playwright-cli` command is not available, try a local version via `npx playwright cli`:
```bash
npx --no-install playwright --version
```
When local version is available, use `npx playwright cli` in all commands. Otherwise, install `playwright-cli` as a global command:
```bash
npm install -g @playwright/cli@latest
```
## Example: Form submission
```bash
playwright-cli open https://example.com/form
playwright-cli snapshot
playwright-cli fill e1 "user@example.com"
playwright-cli fill e2 "password123"
playwright-cli click e3
playwright-cli snapshot
playwright-cli close
```
## Example: Multi-tab workflow
```bash
playwright-cli open https://example.com
playwright-cli tab-new https://example.com/other
playwright-cli tab-list
playwright-cli tab-select 0
playwright-cli snapshot
playwright-cli close
```
## Example: Debugging with DevTools
```bash
playwright-cli open https://example.com
playwright-cli click e4
playwright-cli fill e7 "test"
playwright-cli console
playwright-cli requests
playwright-cli close
```
```bash
playwright-cli open https://example.com
playwright-cli tracing-start
playwright-cli click e4
playwright-cli fill e7 "test"
playwright-cli tracing-stop
playwright-cli close
```
## Example: Interactive session
Ask the user for UI review or design feedback. The user draws boxes on the live page and types comments; you receive the annotated screenshot, the snapshot of the marked region, and the user's notes. Use this whenever the user asks for "UI review", "design feedback", or to "ask the user what they think / want / mean":
```bash
playwright-cli open https://example.com
playwright-cli show --annotate
```
## Specific tasks
* **Running and Debugging Playwright tests** [references/playwright-tests.md](references/playwright-tests.md)
* **Request mocking** [references/request-mocking.md](references/request-mocking.md)
* **Running Playwright code** [references/running-code.md](references/running-code.md)
* **Browser session management** [references/session-management.md](references/session-management.md)
* **Storage state (cookies, localStorage)** [references/storage-state.md](references/storage-state.md)
* **Test generation (plan / generate / heal)** [references/test-generation.md](references/test-generation.md)
* **Tracing** [references/tracing.md](references/tracing.md)
* **Video recording** [references/video-recording.md](references/video-recording.md)
* **Inspecting element attributes** [references/element-attributes.md](references/element-attributes.md)
@@ -0,0 +1,23 @@
# Inspecting Element Attributes
When the snapshot doesn't show an element's `id`, `class`, `data-*` attributes, or other DOM properties, use `eval` to inspect them.
## Examples
```bash
playwright-cli snapshot
# snapshot shows a button as e7 but doesn't reveal its id or data attributes
# get the element's id
playwright-cli eval "el => el.id" e7
# get all CSS classes
playwright-cli eval "el => el.className" e7
# get a specific attribute
playwright-cli eval "el => el.getAttribute('data-testid')" e7
playwright-cli eval "el => el.getAttribute('aria-label')" e7
# get a computed style property
playwright-cli eval "el => getComputedStyle(el).display" e7
```
@@ -0,0 +1,39 @@
# Running Playwright Tests
To run Playwright tests, use the `npx playwright test` command, or a package manager script. To avoid opening the interactive html report, use `PLAYWRIGHT_HTML_OPEN=never` environment variable.
```bash
# Run all tests
PLAYWRIGHT_HTML_OPEN=never npx playwright test
# Run all tests through a custom npm script
PLAYWRIGHT_HTML_OPEN=never npm run special-test-command
```
# Debugging Playwright Tests
To debug a failing Playwright test, run it with `--debug=cli` option. This command will pause the test at the start and print the debugging instructions.
**IMPORTANT**: run the command in the background and check the output until "Debugging Instructions" is printed. Make sure to stop the command after you have finished.
Once instructions containing a session name are printed, use `playwright-cli` to attach the session and explore the page.
```bash
# Run the test
PLAYWRIGHT_HTML_OPEN=never npx playwright test --debug=cli
# ...
# ... debugging instructions for "tw-abcdef" session ...
# ...
# Attach to the test
playwright-cli attach tw-abcdef
```
Keep the test running in the background while you explore and look for a fix.
The test is paused at the start, so you should step over or pause at a particular location
where the problem is most likely to be.
Every action you perform with `playwright-cli` generates corresponding Playwright TypeScript code.
This code appears in the output and can be copied directly into the test. Most of the time, a specific locator or an expectation should be updated, but it could also be a bug in the app. Use your judgement.
After fixing the test, stop the background test run. Rerun to check that test passes.
@@ -0,0 +1,87 @@
# Request Mocking
Intercept, mock, modify, and block network requests.
## CLI Route Commands
```bash
# Mock with custom status
playwright-cli route "**/*.jpg" --status=404
# Mock with JSON body
playwright-cli route "**/api/users" --body='[{"id":1,"name":"Alice"}]' --content-type=application/json
# Mock with custom headers
playwright-cli route "**/api/data" --body='{"ok":true}' --header="X-Custom: value"
# Remove headers from requests
playwright-cli route "**/*" --remove-header=cookie,authorization
# List active routes
playwright-cli route-list
# Remove a route or all routes
playwright-cli unroute "**/*.jpg"
playwright-cli unroute
```
## URL Patterns
```
**/api/users - Exact path match
**/api/*/details - Wildcard in path
**/*.{png,jpg,jpeg} - Match file extensions
**/search?q=* - Match query parameters
```
## Advanced Mocking with run-code
For conditional responses, request body inspection, response modification, or delays:
### Conditional Response Based on Request
```bash
playwright-cli run-code "async page => {
await page.route('**/api/login', route => {
const body = route.request().postDataJSON();
if (body.username === 'admin') {
route.fulfill({ body: JSON.stringify({ token: 'mock-token' }) });
} else {
route.fulfill({ status: 401, body: JSON.stringify({ error: 'Invalid' }) });
}
});
}"
```
### Modify Real Response
```bash
playwright-cli run-code "async page => {
await page.route('**/api/user', async route => {
const response = await route.fetch();
const json = await response.json();
json.isPremium = true;
await route.fulfill({ response, json });
});
}"
```
### Simulate Network Failures
```bash
playwright-cli run-code "async page => {
await page.route('**/api/offline', route => route.abort('internetdisconnected'));
}"
# Options: connectionrefused, timedout, connectionreset, internetdisconnected
```
### Delayed Response
```bash
playwright-cli run-code "async page => {
await page.route('**/api/slow', async route => {
await new Promise(r => setTimeout(r, 3000));
route.fulfill({ body: JSON.stringify({ data: 'loaded' }) });
});
}"
```
@@ -0,0 +1,241 @@
# Running Custom Playwright Code
Use `run-code` to execute arbitrary Playwright code for advanced scenarios not covered by CLI commands.
## Syntax
```bash
playwright-cli run-code "async page => {
// Your Playwright code here
// Access page.context() for browser context operations
}"
```
You can also load the function from a file:
```bash
playwright-cli run-code --filename=./my-script.js
```
The code must be a single function expression, it is wrapped in `(...)` and evaluated.
import/export/require syntax is not supported.
## Geolocation
```bash
# Grant geolocation permission and set location
playwright-cli run-code "async page => {
await page.context().grantPermissions(['geolocation']);
await page.context().setGeolocation({ latitude: 37.7749, longitude: -122.4194 });
}"
# Set location to London
playwright-cli run-code "async page => {
await page.context().grantPermissions(['geolocation']);
await page.context().setGeolocation({ latitude: 51.5074, longitude: -0.1278 });
}"
# Clear geolocation override
playwright-cli run-code "async page => {
await page.context().clearPermissions();
}"
```
## Permissions
```bash
# Grant multiple permissions
playwright-cli run-code "async page => {
await page.context().grantPermissions([
'geolocation',
'notifications',
'camera',
'microphone'
]);
}"
# Grant permissions for specific origin
playwright-cli run-code "async page => {
await page.context().grantPermissions(['clipboard-read'], {
origin: 'https://example.com'
});
}"
```
## Media Emulation
```bash
# Emulate dark color scheme
playwright-cli run-code "async page => {
await page.emulateMedia({ colorScheme: 'dark' });
}"
# Emulate light color scheme
playwright-cli run-code "async page => {
await page.emulateMedia({ colorScheme: 'light' });
}"
# Emulate reduced motion
playwright-cli run-code "async page => {
await page.emulateMedia({ reducedMotion: 'reduce' });
}"
# Emulate print media
playwright-cli run-code "async page => {
await page.emulateMedia({ media: 'print' });
}"
```
## Wait Strategies
```bash
# Wait for network idle
playwright-cli run-code "async page => {
await page.waitForLoadState('networkidle');
}"
# Wait for specific element
playwright-cli run-code "async page => {
await page.locator('.loading').waitFor({ state: 'hidden' });
}"
# Wait for function to return true
playwright-cli run-code "async page => {
await page.waitForFunction(() => window.appReady === true);
}"
# Wait with timeout
playwright-cli run-code "async page => {
await page.locator('.result').waitFor({ timeout: 10000 });
}"
```
## Frames and Iframes
```bash
# Work with iframe
playwright-cli run-code "async page => {
const frame = page.locator('iframe#my-iframe').contentFrame();
await frame.locator('button').click();
}"
# Get all frames
playwright-cli run-code "async page => {
const frames = page.frames();
return frames.map(f => f.url());
}"
```
## File Downloads
```bash
# Handle file download
playwright-cli run-code "async page => {
const downloadPromise = page.waitForEvent('download');
await page.getByRole('link', { name: 'Download' }).click();
const download = await downloadPromise;
await download.saveAs('./downloaded-file.pdf');
return download.suggestedFilename();
}"
```
## Clipboard
```bash
# Read clipboard (requires permission)
playwright-cli run-code "async page => {
await page.context().grantPermissions(['clipboard-read']);
return await page.evaluate(() => navigator.clipboard.readText());
}"
# Write to clipboard
playwright-cli run-code "async page => {
await page.evaluate(text => navigator.clipboard.writeText(text), 'Hello clipboard!');
}"
```
## Page Information
```bash
# Get page title
playwright-cli run-code "async page => {
return await page.title();
}"
# Get current URL
playwright-cli run-code "async page => {
return page.url();
}"
# Get page content
playwright-cli run-code "async page => {
return await page.content();
}"
# Get viewport size
playwright-cli run-code "async page => {
return page.viewportSize();
}"
```
## JavaScript Execution
```bash
# Execute JavaScript and return result
playwright-cli run-code "async page => {
return await page.evaluate(() => {
return {
userAgent: navigator.userAgent,
language: navigator.language,
cookiesEnabled: navigator.cookieEnabled
};
});
}"
# Pass arguments to evaluate
playwright-cli run-code "async page => {
const multiplier = 5;
return await page.evaluate(m => document.querySelectorAll('li').length * m, multiplier);
}"
```
## Error Handling
```bash
# Try-catch in run-code
playwright-cli run-code "async page => {
try {
await page.getByRole('button', { name: 'Submit' }).click({ timeout: 1000 });
return 'clicked';
} catch (e) {
return 'element not found';
}
}"
```
## Complex Workflows
```bash
# Login and save state
playwright-cli run-code "async page => {
await page.goto('https://example.com/login');
await page.getByRole('textbox', { name: 'Email' }).fill('user@example.com');
await page.getByRole('textbox', { name: 'Password' }).fill('secret');
await page.getByRole('button', { name: 'Sign in' }).click();
await page.waitForURL('**/dashboard');
await page.context().storageState({ path: 'auth.json' });
return 'Login successful';
}"
# Scrape data from multiple pages
playwright-cli run-code "async page => {
const results = [];
for (let i = 1; i <= 3; i++) {
await page.goto(\`https://example.com/page/\${i}\`);
const items = await page.locator('.item').allTextContents();
results.push(...items);
}
return results;
}"
```
@@ -0,0 +1,225 @@
# Browser Session Management
Run multiple isolated browser sessions concurrently with state persistence.
## Named Browser Sessions
Use `-s` flag to isolate browser contexts:
```bash
# Browser 1: Authentication flow
playwright-cli -s=auth open https://app.example.com/login
# Browser 2: Public browsing (separate cookies, storage)
playwright-cli -s=public open https://example.com
# Commands are isolated by browser session
playwright-cli -s=auth fill e1 "user@example.com"
playwright-cli -s=public snapshot
```
## Browser Session Isolation Properties
Each browser session has independent:
- Cookies
- LocalStorage / SessionStorage
- IndexedDB
- Cache
- Browsing history
- Open tabs
## Browser Session Commands
```bash
# List all browser sessions
playwright-cli list
# Stop a browser session (close the browser)
playwright-cli close # stop the default browser
playwright-cli -s=mysession close # stop a named browser
# Stop all browser sessions
playwright-cli close-all
# Forcefully kill all daemon processes (for stale/zombie processes)
playwright-cli kill-all
# Delete browser session user data (profile directory)
playwright-cli delete-data # delete default browser data
playwright-cli -s=mysession delete-data # delete named browser data
```
## Environment Variable
Set a default browser session name via environment variable:
```bash
export PLAYWRIGHT_CLI_SESSION="mysession"
playwright-cli open example.com # Uses "mysession" automatically
```
## Common Patterns
### Concurrent Scraping
```bash
#!/bin/bash
# Scrape multiple sites concurrently
# Start all browsers
playwright-cli -s=site1 open https://site1.com &
playwright-cli -s=site2 open https://site2.com &
playwright-cli -s=site3 open https://site3.com &
wait
# Take snapshots from each
playwright-cli -s=site1 snapshot
playwright-cli -s=site2 snapshot
playwright-cli -s=site3 snapshot
# Cleanup
playwright-cli close-all
```
### A/B Testing Sessions
```bash
# Test different user experiences
playwright-cli -s=variant-a open "https://app.com?variant=a"
playwright-cli -s=variant-b open "https://app.com?variant=b"
# Compare
playwright-cli -s=variant-a screenshot
playwright-cli -s=variant-b screenshot
```
### Persistent Profile
By default, browser profile is kept in memory only. Use `--persistent` flag on `open` to persist the browser profile to disk:
```bash
# Use persistent profile (auto-generated location)
playwright-cli open https://example.com --persistent
# Use persistent profile with custom directory
playwright-cli open https://example.com --profile=/path/to/profile
```
## Attaching to a Running Browser
Use `attach` to connect to a browser that is already running, instead of launching a new one.
### Attach by channel name
Connect to a running Chrome or Edge instance by its channel name. The browser must have remote debugging enabled — navigate to `chrome://inspect/#remote-debugging` in the target browser and check "Allow remote debugging for this browser instance".
```bash
# Attach to Chrome
playwright-cli attach --cdp=chrome
# Attach to Chrome Canary
playwright-cli attach --cdp=chrome-canary
# Attach to Microsoft Edge
playwright-cli attach --cdp=msedge
# Attach to Edge Dev
playwright-cli attach --cdp=msedge-dev
```
Supported channels: `chrome`, `chrome-beta`, `chrome-dev`, `chrome-canary`, `msedge`, `msedge-beta`, `msedge-dev`, `msedge-canary`.
When `--session` is not provided, the session is named after the channel (e.g. `--cdp=msedge` creates a session called `msedge`), so parallel attaches to Chrome and Edge don't collide on `default`. Pass `--session=<name>` to override.
### Attach via CDP endpoint
Connect to a browser that exposes a Chrome DevTools Protocol endpoint:
```bash
playwright-cli attach --cdp=http://localhost:9222
```
### Attach via browser extension
Connect to a browser with the Playwright extension installed:
```bash
playwright-cli attach --extension
```
### Detach
Tear down an attached session without affecting the external browser:
```bash
# Detach the default attached session
playwright-cli detach
# Detach a specific attached session
playwright-cli -s=msedge detach
```
`detach` only works on sessions created via `attach`. For sessions created via `open`, use `close`.
## Default Browser Session
When `-s` is omitted, commands use the default browser session:
```bash
# These use the same default browser session
playwright-cli open https://example.com
playwright-cli snapshot
playwright-cli close # Stops default browser
```
## Browser Session Configuration
Configure a browser session with specific settings when opening:
```bash
# Open with config file
playwright-cli open https://example.com --config=.playwright/my-cli.json
# Open with specific browser
playwright-cli open https://example.com --browser=firefox
# Open in headed mode
playwright-cli open https://example.com --headed
# Open with persistent profile
playwright-cli open https://example.com --persistent
```
## Best Practices
### 1. Name Browser Sessions Semantically
```bash
# GOOD: Clear purpose
playwright-cli -s=github-auth open https://github.com
playwright-cli -s=docs-scrape open https://docs.example.com
# AVOID: Generic names
playwright-cli -s=s1 open https://github.com
```
### 2. Always Clean Up
```bash
# Stop browsers when done
playwright-cli -s=auth close
playwright-cli -s=scrape close
# Or stop all at once
playwright-cli close-all
# If browsers become unresponsive or zombie processes remain
playwright-cli kill-all
```
### 3. Delete Stale Browser Data
```bash
# Remove old browser data to free disk space
playwright-cli -s=oldsession delete-data
```
@@ -0,0 +1,275 @@
# Storage Management
Manage cookies, localStorage, sessionStorage, and browser storage state.
## Storage State
Save and restore complete browser state including cookies and storage.
### Save Storage State
```bash
# Save to auto-generated filename (storage-state-{timestamp}.json)
playwright-cli state-save
# Save to specific filename
playwright-cli state-save my-auth-state.json
```
### Restore Storage State
```bash
# Load storage state from file
playwright-cli state-load my-auth-state.json
# Reload page to apply cookies
playwright-cli open https://example.com
```
### Storage State File Format
The saved file contains:
```json
{
"cookies": [
{
"name": "session_id",
"value": "abc123",
"domain": "example.com",
"path": "/",
"expires": 1893456000,
"httpOnly": true,
"secure": true,
"sameSite": "Lax"
}
],
"origins": [
{
"origin": "https://example.com",
"localStorage": [
{ "name": "theme", "value": "dark" },
{ "name": "user_id", "value": "12345" }
]
}
]
}
```
## Cookies
### List All Cookies
```bash
playwright-cli cookie-list
```
### Filter Cookies by Domain
```bash
playwright-cli cookie-list --domain=example.com
```
### Filter Cookies by Path
```bash
playwright-cli cookie-list --path=/api
```
### Get Specific Cookie
```bash
playwright-cli cookie-get session_id
```
### Set a Cookie
```bash
# Basic cookie
playwright-cli cookie-set session abc123
# Cookie with options
playwright-cli cookie-set session abc123 --domain=example.com --path=/ --httpOnly --secure --sameSite=Lax
# Cookie with expiration (Unix timestamp)
playwright-cli cookie-set remember_me token123 --expires=1893456000
```
### Delete a Cookie
```bash
playwright-cli cookie-delete session_id
```
### Clear All Cookies
```bash
playwright-cli cookie-clear
```
### Advanced: Multiple Cookies or Custom Options
For complex scenarios like adding multiple cookies at once, use `run-code`:
```bash
playwright-cli run-code "async page => {
await page.context().addCookies([
{ name: 'session_id', value: 'sess_abc123', domain: 'example.com', path: '/', httpOnly: true },
{ name: 'preferences', value: JSON.stringify({ theme: 'dark' }), domain: 'example.com', path: '/' }
]);
}"
```
## Local Storage
### List All localStorage Items
```bash
playwright-cli localstorage-list
```
### Get Single Value
```bash
playwright-cli localstorage-get token
```
### Set Value
```bash
playwright-cli localstorage-set theme dark
```
### Set JSON Value
```bash
playwright-cli localstorage-set user_settings '{"theme":"dark","language":"en"}'
```
### Delete Single Item
```bash
playwright-cli localstorage-delete token
```
### Clear All localStorage
```bash
playwright-cli localstorage-clear
```
### Advanced: Multiple Operations
For complex scenarios like setting multiple values at once, use `run-code`:
```bash
playwright-cli run-code "async page => {
await page.evaluate(() => {
localStorage.setItem('token', 'jwt_abc123');
localStorage.setItem('user_id', '12345');
localStorage.setItem('expires_at', Date.now() + 3600000);
});
}"
```
## Session Storage
### List All sessionStorage Items
```bash
playwright-cli sessionstorage-list
```
### Get Single Value
```bash
playwright-cli sessionstorage-get form_data
```
### Set Value
```bash
playwright-cli sessionstorage-set step 3
```
### Delete Single Item
```bash
playwright-cli sessionstorage-delete step
```
### Clear sessionStorage
```bash
playwright-cli sessionstorage-clear
```
## IndexedDB
### List Databases
```bash
playwright-cli run-code "async page => {
return await page.evaluate(async () => {
const databases = await indexedDB.databases();
return databases;
});
}"
```
### Delete Database
```bash
playwright-cli run-code "async page => {
await page.evaluate(() => {
indexedDB.deleteDatabase('myDatabase');
});
}"
```
## Common Patterns
### Authentication State Reuse
```bash
# Step 1: Login and save state
playwright-cli open https://app.example.com/login
playwright-cli snapshot
playwright-cli fill e1 "user@example.com"
playwright-cli fill e2 "password123"
playwright-cli click e3
# Save the authenticated state
playwright-cli state-save auth.json
# Step 2: Later, restore state and skip login
playwright-cli state-load auth.json
playwright-cli open https://app.example.com/dashboard
# Already logged in!
```
### Save and Restore Roundtrip
```bash
# Set up authentication state
playwright-cli open https://example.com
playwright-cli eval "() => { document.cookie = 'session=abc123'; localStorage.setItem('user', 'john'); }"
# Save state to file
playwright-cli state-save my-session.json
# ... later, in a new session ...
# Restore state
playwright-cli state-load my-session.json
playwright-cli open https://example.com
# Cookies and localStorage are restored!
```
## Security Notes
- Never commit storage state files containing auth tokens
- Add `*.auth-state.json` to `.gitignore`
- Delete state files after automation completes
- Use environment variables for sensitive data
- By default, sessions run in-memory mode which is safer for sensitive operations
@@ -0,0 +1,433 @@
# Test generation (plan → generate → heal)
End-to-end workflow for authoring and maintaining Playwright tests with `playwright-cli`. Every `playwright-cli` action emits the equivalent Playwright TypeScript, and that generated code is the raw material for every test. The sections below can be used independently:
- **How generation works** — the core mechanic everything else relies on: actions become TypeScript, plus how to add assertions.
- **Plan** — explore the app, produce a spec file describing what to test.
- **Generate** — turn a spec into Playwright test files. Update the spec if it's vague or stale.
- **Heal** — diagnose failing tests, fix the code, reconcile the spec with reality.
Plan / generate / heal lean on the same mechanic: run `npx playwright test --debug=cli` in the background, then `playwright-cli attach tw-XXXX` to drive the paused page interactively. See [playwright-tests.md](playwright-tests.md) for the debug/attach mechanics.
---
## 0. How generation works
Every action you perform with `playwright-cli` generates corresponding Playwright TypeScript code. This code appears in the output and can be copied directly into your test files.
```bash
# Start a session
playwright-cli open https://example.com/login
# Take a snapshot to see elements
playwright-cli snapshot
# Output shows: e1 [textbox "Email"], e2 [textbox "Password"], e3 [button "Sign In"]
# Fill form fields - generates code automatically
playwright-cli fill e1 "user@example.com"
# Ran Playwright code:
# await page.getByRole('textbox', { name: 'Email' }).fill('user@example.com');
playwright-cli fill e2 "password123"
# Ran Playwright code:
# await page.getByRole('textbox', { name: 'Password' }).fill('password123');
playwright-cli click e3
# Ran Playwright code:
# await page.getByRole('button', { name: 'Sign In' }).click();
```
### Building a test file
Collect the generated code into a Playwright test:
```typescript
import { test, expect } from '@playwright/test';
test('login flow', async ({ page }) => {
// Generated code from playwright-cli session:
await page.goto('https://example.com/login');
await page.getByRole('textbox', { name: 'Email' }).fill('user@example.com');
await page.getByRole('textbox', { name: 'Password' }).fill('password123');
await page.getByRole('button', { name: 'Sign In' }).click();
// Add assertions
await expect(page).toHaveURL(/.*dashboard/);
});
```
### Use semantic locators
The generated code uses role-based locators when possible, which are more resilient:
```typescript
// Generated (good - semantic)
await page.getByRole('button', { name: 'Submit' }).click();
// Avoid (fragile - CSS selectors)
await page.locator('#submit-btn').click();
```
### Explore before recording
Take snapshots to understand the page structure before recording actions:
```bash
playwright-cli open https://example.com
playwright-cli snapshot
# Review the element structure
playwright-cli click e5
```
### Add assertions manually
Generated code captures actions but not assertions. Add expectations in your test using one of the recommended matchers:
- `toBeVisible()` — element is rendered and visible
- `toHaveText(text)` — element text content matches
- `toHaveValue(value) / toBeEmpty()` — input/select value matches
- `toBeChecked() / toBeUnchecked()` — checkbox state matches
- `toMatchAriaSnapshot(snapshot)` — page (or locator) matches a partial accessibility snapshot
Use `playwright-cli generate-locator <target>` to produce the locator expression for the assertion, and the snapshot/eval commands to capture the expected value.
When asserting text content, make sure that generated locator does not contain text from the element itself. `getByTestId()` or `getByLabel()` usually work well with asserting text. When locator is text-based, prefer `toBeVisible()` instead.
Snapshot to be matched does not have to contain all the information - only capture what's necessary for the assertion. You can use regular expressions for unstable values.
```bash
# Get a stable locator for an element ref to use in the assertion
playwright-cli --raw generate-locator e5
# getByRole('button', { name: 'Submit' })
# Capture expected text content for toHaveText
playwright-cli --raw eval "el => el.textContent" e5
# Capture expected input value for toHaveValue/toBeEmpty
playwright-cli --raw eval "el => el.value" e5
# Capture expected aria snapshot for toMatchAriaSnapshot/toBeChecked
# (whole page, or use a ref to scope to a region)
playwright-cli --raw snapshot
playwright-cli --raw snapshot e5
```
```typescript
// Generated action
await page.getByRole('button', { name: 'Submit' }).click();
// Manual assertions using the outputs above:
await expect(page.getByRole('alert', { name: 'Success' })).toBeVisible();
await expect(page.getByTestId('main-header')).toHaveText('Welcome, user');
await expect(page.getByRole('textbox', { name: 'Email' })).toHaveValue('user@example.com');
await expect(page.getByRole('checkbox', { name: 'Enable notifications' })).toBeChecked();
// toMatchAriaSnapshot on the whole page, finds a matching region
await expect(page).toMatchAriaSnapshot(`
- heading "Welcome, user"
- link /\\d+ new messages?/
- button "Sign out"
`);
// toMatchAriaSnapshot scoped to a region
await expect(page.getByRole('navigation')).toMatchAriaSnapshot(`
- link "Home"
- link /\\d+ new messages?/
- link "Profile"
`);
```
---
## 1. Planning
Goal: produce a spec file (e.g. `specs/<feature>.plan.md`) that enumerates the scenarios to test. **Always** write the spec to a file.
### 1.1 Prerequisite: workspace
Check the workspace has Playwright installed before anything else:
```bash
# Either of these confirms a workspace:
test -f playwright.config.ts || test -f playwright.config.js
npx --no-install playwright --version
```
If there is no Playwright install, bootstrap one and let the user pick the defaults:
```bash
npm init playwright@latest
```
### 1.2 Prerequisite: seed test
A **seed test** is a minimal test that lands the page in the state every scenario starts from: navigation to the app, any required login, feature flags, etc. Scenarios assume a fresh start *after* the seed. `--debug=cli` pauses *inside* this test, so the seed is where every planning and generation session begins.
Minimum viable seed:
```ts
// tests/seed.spec.ts
import { test } from '@playwright/test';
test('seed', async ({ page }) => {
await page.goto('https://example.com/');
});
```
Preferred — push navigation into a fixture so scenario tests reuse it:
```ts
// tests/fixtures.ts
import { test as baseTest } from '@playwright/test';
export { expect } from '@playwright/test';
export const test = baseTest.extend({
page: async ({ page }, use) => {
await page.goto('https://example.com/');
await use(page);
},
});
```
```ts
// tests/seed.spec.ts
import { test } from './fixtures';
test('seed', async ({ page }) => {
// Fixture already navigates. This empty body tells agents where to start.
});
```
If no seed exists, create one that at least navigates to the app.
### 1.3 Explore the app
Launch the app via the seed in the background and attach:
```bash
PLAYWRIGHT_HTML_OPEN=never npx playwright test tests/seed.spec.ts --debug=cli
# wait for "Debugging Instructions" and the session name tw-XXXX
playwright-cli attach tw-XXXX
```
Resume so the seed runs, then probe the app:
```bash
playwright-cli resume # resume so that seed test runs fully
playwright-cli snapshot # inventory of interactive elements
playwright-cli click e5 # follow a flow
playwright-cli eval "location.href" # read URL / state
playwright-cli show --annotate # ask the user to point at something
```
Map out:
- Interactive surfaces (forms, buttons, lists, filters, modals).
- Primary user journeys end-to-end.
- Edge cases: empty states, validation errors, very long input, boundary values.
- Persistence: reload, local/session storage, URL fragments.
- Navigation: which controls change the URL, back/forward behaviour.
**Important**: Do not just open the app url with playwright-cli, always go through the test to capture any custom setup done there.
**Important**: Stop the background test when done exploring.
### 1.4 Write the spec file
Save under `specs/<feature>.plan.md`. Use this structure:
```markdown
# <Feature> Test Plan
## Application Overview
<One paragraph describing what the feature does and why it matters.>
## Test Scenarios
### 1. <Group Name>
**Seed:** `tests/seed.spec.ts`
#### 1.1. <kebab-case-scenario-name>
**File:** `tests/<group>/<kebab-case-scenario-name>.spec.ts`
**Steps:**
1. <Concrete user step>
- expect: <observable outcome>
- expect: <another observable outcome>
2. <Next step>
- expect: <outcome>
#### 1.2. <next-scenario>
...
### 2. <Next Group>
**Seed:** `tests/seed.spec.ts`
...
```
Guidelines:
- Each scenario is independent and starts from the seed's fresh state — never chain scenarios.
- Scenario names are kebab-case and match the test file name (`should-add-single-todo``should-add-single-todo.spec.ts`).
- Cover happy path, edge cases, validation, negative flows, persistence.
- Write steps at the user level ("Type 'Buy milk' into the input"), not the API level ("call `fill`").
- Put observable outcomes in `- expect:` bullets; each becomes an assertion during generation.
---
## 2. Generate
Goal: take a spec file and produce Playwright test files. Optionally update the spec if it has drifted.
### 2.1 Inputs
- **Spec file**, e.g. `specs/basic-operations.plan.md`.
- **Target**: either a single scenario (e.g. `1.2`), a whole group (`1`), or all.
- **Seed file**, read from the `**Seed:**` line of the scenario's group.
### 2.2 Generate one scenario
For each target scenario, in sequence (never in parallel — scenarios share the seed session):
```bash
PLAYWRIGHT_HTML_OPEN=never npx playwright test <seed-file> --debug=cli # background
playwright-cli attach tw-XXXX
# resume
```
**Do not** just open the app url with playwright-cli, always go through the test to capture any custom setup done there.
Walk the scenario's `Steps:` one by one with `playwright-cli`, treating the spec as the plan and the live app as the source of truth. If a step is vague ("click the button" — which button?), references an element that no longer exists, or contradicts the app's actual behaviour, use your judgement: update the spec to match what the app really does, then keep going. Editing the spec mid-generation is expected.
Every action prints the equivalent Playwright TypeScript (see [How generation works](#0-how-generation-works)):
```bash
playwright-cli snapshot # find refs
playwright-cli fill e3 "John Doe" # -> page.getByRole('textbox', {...}).fill(...)
playwright-cli press Enter
playwright-cli click e7
```
For each `- expect:` bullet, add an explicit assertion. See [How generation works](#0-how-generation-works) for details.
Collect the generated code and write the test file at the path given in the spec:
```ts
// spec: specs/basic-operations.plan.md
// seed: tests/seed.spec.ts
import { test, expect } from './fixtures'; // or '@playwright/test' if no fixtures file
test.describe('Signing in and out', () => {
test('should sign in', async ({ page }) => {
// 1. Navigate to the application
// (handled by the seed fixture)
// 2. Type 'John Doe' into the username field
await page.getByRole('textbox', { name: 'username' }).fill('John Doe');
// 3. Type password
await page.getByRole('textbox', { name: 'password' }).fill('TestPassword');
// 4. Press Enter to submit
await page.getByRole('textbox', { name: 'password' }).press('Enter');
await expect(page.getByRole('heading')).toContainText('Welcome, John Doe!');
});
});
```
Rules:
- **One test per file.** File path, describe name, and test name come verbatim from the spec (minus the ordinal).
- Prefix each numbered step with a `// N. <step text>` comment before its actions.
- Use the describe group name verbatim from the spec (no `1.` ordinal).
- Import from `./fixtures` if the project has one; otherwise `@playwright/test`.
- **Important**: close the CLI session and stop the background test before moving to the next scenario.
### 2.3 Generate multiple scenarios
Loop 2.2 over the targeted scenarios one at a time, restarting the seed between each so every test starts from a clean page. This is safe to parallelise due to unique generated session names - just make sure each test run is stopped.
### 2.4 Run generated tests
After generation, run the new tests once:
```bash
PLAYWRIGHT_HTML_OPEN=never npx playwright test tests/<group>/<scenario>.spec.ts
```
Any failure goes to Section 3.
---
## 3. Heal
Goal: fix failing tests, and update the spec if the app's intended behaviour changed.
### 3.1 Find failing tests
```bash
PLAYWRIGHT_HTML_OPEN=never npx playwright test
```
Record the list of failing `<file>:<line>` entries and process them one at a time. Do not attempt parallel fixes — shared state and the single CLI session make that fragile.
### 3.2 Debug one failure
Run the single failing test in debug mode in the background, then attach:
```bash
PLAYWRIGHT_HTML_OPEN=never npx playwright test tests/<group>/<scenario>.spec.ts:<line> --debug=cli
# wait for "Debugging Instructions" and the tw-XXXX session name
playwright-cli attach tw-XXXX
```
The test is paused at the start. Step forward or run to until just before the failing action or assertion, then diagnose:
```bash
playwright-cli snapshot # did the element change / move / rename?
playwright-cli console # app-side errors?
playwright-cli requests # failed request? wrong payload?
playwright-cli show --annotate # ask the user to point somewhere
```
Common causes: selector drift, new wrapper element, label/ARIA rename, timing (transition, async load), assertion text updated in the app, test data leaking between runs.
Rehearse the corrected interaction with `playwright-cli` — the generated code in the output is what you paste back into the test.
### 3.3 Apply the fix
Edit the test file: update the locator, assertion, step order, or inputs to match the corrected behaviour. Stop the background debug run. Rerun the single test to confirm green.
Never skip hooks or add sleeps as a fix. Never use `networkidle`.
### 3.4 Reconcile with the spec
Open the spec referenced by the `// spec:` header in the test file and locate the scenario that matches the test.
- **Fix was purely technical** (locator drift, better assertion shape) and the spec's user-level behaviour still matches the app → leave the spec alone.
- **Fix changed user-visible steps, inputs, order, or expected outcomes** that the spec describes → update the spec to match reality. Keep the scenario id and file path stable; only the step / expect lines change.
- **Unclear whether the app change is intentional** (spec is stale) **or a regression** (test was right, app is wrong) → **stop and ask the user**. Provide:
- the scenario id (e.g. `2.3`),
- the spec lines that no longer match,
- the observed app behaviour (quote a snapshot excerpt or a concrete outcome).
Only after the user answers, either update the spec (intentional change) or file/flag the test as covering a bug (regression).
### 3.5 Iteration and giving up
- Fix failures one at a time; rerun after each.
- If after thorough investigation you are confident the test is correct but the app is wrong *and* the user has confirmed it's a bug: mark the test `test.fixme(...)` with a comment pointing at the user's decision or issue link. Never silently skip.
---
## Cross-references
| For... | See |
|---|---|
| `--debug=cli` / attach mechanics | [playwright-tests.md](playwright-tests.md) |
| Mocking requests during exploration/generation | [request-mocking.md](request-mocking.md) |
| Managing the CLI browser session | [session-management.md](session-management.md) |
@@ -0,0 +1,139 @@
# Tracing
Capture detailed execution traces for debugging and analysis. Traces include DOM snapshots, screenshots, network activity, and console logs.
## Basic Usage
```bash
# Start trace recording
playwright-cli tracing-start
# Perform actions
playwright-cli open https://example.com
playwright-cli click e1
playwright-cli fill e2 "test"
# Stop trace recording
playwright-cli tracing-stop
```
## Trace Output Files
When you start tracing, Playwright creates a `traces/` directory with several files:
### `trace-{timestamp}.trace`
**Action log** - The main trace file containing:
- Every action performed (clicks, fills, navigations)
- DOM snapshots before and after each action
- Screenshots at each step
- Timing information
- Console messages
- Source locations
### `trace-{timestamp}.network`
**Network log** - Complete network activity:
- All HTTP requests and responses
- Request headers and bodies
- Response headers and bodies
- Timing (DNS, connect, TLS, TTFB, download)
- Resource sizes
- Failed requests and errors
### `resources/`
**Resources directory** - Cached resources:
- Images, fonts, stylesheets, scripts
- Response bodies for replay
- Assets needed to reconstruct page state
## What Traces Capture
| Category | Details |
|----------|---------|
| **Actions** | Clicks, fills, hovers, keyboard input, navigations |
| **DOM** | Full DOM snapshot before/after each action |
| **Screenshots** | Visual state at each step |
| **Network** | All requests, responses, headers, bodies, timing |
| **Console** | All console.log, warn, error messages |
| **Timing** | Precise timing for each operation |
## Use Cases
### Debugging Failed Actions
```bash
playwright-cli tracing-start
playwright-cli open https://app.example.com
# This click fails - why?
playwright-cli click e5
playwright-cli tracing-stop
# Open trace to see DOM state when click was attempted
```
### Analyzing Performance
```bash
playwright-cli tracing-start
playwright-cli open https://slow-site.com
playwright-cli tracing-stop
# View network waterfall to identify slow resources
```
### Capturing Evidence
```bash
# Record a complete user flow for documentation
playwright-cli tracing-start
playwright-cli open https://app.example.com/checkout
playwright-cli fill e1 "4111111111111111"
playwright-cli fill e2 "12/25"
playwright-cli fill e3 "123"
playwright-cli click e4
playwright-cli tracing-stop
# Trace shows exact sequence of events
```
## Trace vs Video vs Screenshot
| Feature | Trace | Video | Screenshot |
|---------|-------|-------|------------|
| **Format** | .trace file | .webm video | .png/.jpeg image |
| **DOM inspection** | Yes | No | No |
| **Network details** | Yes | No | No |
| **Step-by-step replay** | Yes | Continuous | Single frame |
| **File size** | Medium | Large | Small |
| **Best for** | Debugging | Demos | Quick capture |
## Best Practices
### 1. Start Tracing Before the Problem
```bash
# Trace the entire flow, not just the failing step
playwright-cli tracing-start
playwright-cli open https://example.com
# ... all steps leading to the issue ...
playwright-cli tracing-stop
```
### 2. Clean Up Old Traces
Traces can consume significant disk space:
```bash
# Remove traces older than 7 days
find .playwright-cli/traces -mtime +7 -delete
```
## Limitations
- Traces add overhead to automation
- Large traces can consume significant disk space
- Some dynamic content may not replay perfectly
@@ -0,0 +1,143 @@
# Video Recording
Capture browser automation sessions as video for debugging, documentation, or verification. Produces WebM (VP8/VP9 codec).
## Basic Recording
```bash
# Open browser first
playwright-cli open
# Start recording
playwright-cli video-start demo.webm
# Add a chapter marker for section transitions
playwright-cli video-chapter "Getting Started" --description="Opening the homepage" --duration=2000
# Navigate and perform actions
playwright-cli goto https://example.com
playwright-cli snapshot
playwright-cli click e1
# Add another chapter
playwright-cli video-chapter "Filling Form" --description="Entering test data" --duration=2000
playwright-cli fill e2 "test input"
# Stop and save
playwright-cli video-stop
```
## Best Practices
### 1. Use Descriptive Filenames
```bash
# Include context in filename
playwright-cli video-start recordings/login-flow-2024-01-15.webm
playwright-cli video-start recordings/checkout-test-run-42.webm
```
### 2. Record entire hero scripts.
When recording a video for the user or as a proof of work, it is best to create a code snippet and execute it with run-code.
It allows inserting appropriate pauses between the actions and annotating the video. There are new Playwright APIs for that.
1) Perform scenario using CLI and take note of all locators and actions. You'll need those locators to request their bounding boxes for highlight.
2) Create a file with the intended script for video (below). Use pressSequentially w/ delay for nice typing, make reasonable pauses.
3) Use playwright-cli run-code --filename your-script.js
**Important**: Overlays are `pointer-events: none` — they do not interfere with page interactions. You can safely keep sticky overlays visible while clicking, filling, or performing any actions on the page.
```js
async page => {
await page.screencast.start({ path: 'video.webm', size: { width: 1280, height: 800 } });
await page.goto('https://demo.playwright.dev/todomvc');
// Show a chapter card — blurs the page and shows a dialog.
// Blocks until duration expires, then auto-removes.
// Use this for simple use cases, but always feel free to hand-craft your own beautiful
// overlay via await page.screencast.showOverlay().
await page.screencast.showChapter('Adding Todo Items', {
description: 'We will add several items to the todo list.',
duration: 2000,
});
// Perform action
await page.getByRole('textbox', { name: 'What needs to be done?' }).pressSequentially('Walk the dog', { delay: 60 });
await page.getByRole('textbox', { name: 'What needs to be done?' }).press('Enter');
await page.waitForTimeout(1000);
// Show next chapter
await page.screencast.showChapter('Verifying Results', {
description: 'Checking the item appeared in the list.',
duration: 2000,
});
// Add a sticky annotation that stays while you perform actions.
// Overlays are pointer-events: none, so they won't block clicks.
const annotation = await page.screencast.showOverlay(`
<div style="position: absolute; top: 8px; right: 8px;
padding: 6px 12px; background: rgba(0,0,0,0.7);
border-radius: 8px; font-size: 13px; color: white;">
✓ Item added successfully
</div>
`);
// Perform more actions while the annotation is visible
await page.getByRole('textbox', { name: 'What needs to be done?' }).pressSequentially('Buy groceries', { delay: 60 });
await page.getByRole('textbox', { name: 'What needs to be done?' }).press('Enter');
await page.waitForTimeout(1500);
// Remove the annotation when done
await annotation.dispose();
// You can also highlight relevant locators and provide contextual annotations.
const bounds = await page.getByText('Walk the dog').boundingBox();
await page.screencast.showOverlay(`
<div style="position: absolute;
top: ${bounds.y}px;
left: ${bounds.x}px;
width: ${bounds.width}px;
height: ${bounds.height}px;
border: 1px solid red;">
</div>
<div style="position: absolute;
top: ${bounds.y + bounds.height + 5}px;
left: ${bounds.x + bounds.width / 2}px;
transform: translateX(-50%);
padding: 6px;
background: #808080;
border-radius: 10px;
font-size: 14px;
color: white;">Check it out, it is right above this text
</div>
`, { duration: 2000 });
await page.screencast.stop();
}
```
Embrace creativity, overlays are powerful.
### Overlay API Summary
| Method | Use Case |
|--------|----------|
| `page.screencast.showChapter(title, { description?, duration?, styleSheet? })` | Full-screen chapter card with blurred backdrop — ideal for section transitions |
| `page.screencast.showOverlay(html, { duration? })` | Custom HTML overlay — use for callouts, labels, highlights |
| `disposable.dispose()` | Remove a sticky overlay added without duration |
| `page.screencast.hideOverlays()` / `page.screencast.showOverlays()` | Temporarily hide/show all overlays |
## Tracing vs Video
| Feature | Video | Tracing |
|---------|-------|---------|
| Output | WebM file | Trace file (viewable in Trace Viewer) |
| Shows | Visual recording | DOM snapshots, network, console, actions |
| Use case | Demos, documentation | Debugging, analysis |
| Size | Larger | Smaller |
## Limitations
- Recording adds slight overhead to automation
- Large recordings can consume significant disk space
+1 -10
View File
@@ -7,16 +7,7 @@
"request": "launch", "request": "launch",
"module": "flask", "module": "flask",
"python": "${command:python.interpreterPath}", "python": "${command:python.interpreterPath}",
"env": { "envFile": "${workspaceFolder}/backend/.env",
"FLASK_APP": "backend/main.py",
"FLASK_DEBUG": "1",
"SECRET_KEY": "dev-secret-key-change-in-production",
"REFRESH_TOKEN_EXPIRY_DAYS": "90",
"DIGEST_TOKEN_SECRET": "dev-digest-token",
"VAPID_PUBLIC_KEY": "BNKkHdq45uLigohSG7c1TwlAo7ETncoRVLQK02LxHgu2P1DgSJD9njRMfbbzUsaTQGllvLBz7An1WiWsNYQhvKE",
"VAPID_PRIVATE_KEY": "jNiZJT0UO4H861KmnCt874Fg6p5jDAyYKS4V2MZf8bQ",
"FRONTEND_URL": "https://macbook:5173"
},
"args": [ "args": [
"run", "run",
"--host=0.0.0.0", "--host=0.0.0.0",
+60
View File
@@ -0,0 +1,60 @@
# AGENTS.md
Family chore/reward manager. Flask + TinyDB backend (`backend/`), Vue 3 + TypeScript frontend (`frontend/`). Real-time updates over SSE.
## Commands
### Backend (run from `backend/`)
- Activate venv: `source .venv/bin/activate`
- Dev server: `python -m flask run --host=0.0.0.0 --port=5000` (entry: `main.py`)
- Required env vars: `SECRET_KEY`, `REFRESH_TOKEN_EXPIRY_DAYS`, `DIGEST_TOKEN_SECRET`, `VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY` — Flask raises `RuntimeError` on boot if any are missing
- Optional persistence switch: `USE_MONGODB` (`true` | `false`). Defaults to `true`; set `MONGO_URI` (and optionally `MONGO_DB_NAME`). Set to `false` to use TinyDB instead.
- Optional: `DB_ENV` / `DATA_ENV` (`prod` | `test` | `e2e`) — picks `data/` vs `test_data/` dir (see `config/paths.py`). For MongoDB these also select the default database name (`chore_db`, `chore_db_test`, `chore_db_e2e`) unless `MONGO_DB_NAME` is set.
- Tests: `pytest tests/``conftest.py` forces `DB_ENV=test`, `USE_MONGODB=true`, `MONGO_URI=mongomock`, and sets dummy secrets. Single test: `pytest tests/test_routine_api.py::test_name`
- Python imports assume `backend/` is on `sys.path` (set by `conftest.py` / `flask run` cwd). Run pytest from `backend/`.
- Create admin user: `python scripts/create_admin.py` (admin role cannot be set via signup)
### Frontend (run from `frontend/`)
- Dev: `npm run dev` (Vite, https://localhost:5173)
- Lint: `npm run lint`
- Type-check: `npm run type-check`
- Unit tests: `npm run test:unit` (Vitest). Single: `npx vitest run path/to/file.spec.ts`
- E2E: `npx playwright test` — config auto-starts both `npm run dev` and the Flask backend with `DB_ENV=e2e DATA_ENV=e2e USE_MONGODB=true MONGO_URI=mongomock`. Tests live in `e2e/`. `frontend/.env.test` contains the example MongoDB config.
- E2E buckets are Playwright projects (see `playwright.config.ts`) targeting directories under `e2e/mode_parent/`
## Architecture
### API routing — the `/api` prefix
- Frontend nginx (and Vite dev proxy) strips `/api` before forwarding. **Backend routes must NOT include `/api`.** Backend defines `@app.route('/user')`, frontend calls `/api/user`.
- `auth_api` is the only blueprint registered with a prefix: `url_prefix='/auth'` in `main.py:67`.
- API errors return `{ error, code }` (codes in `backend/api/error_codes.py`). Frontend extracts them via `parseErrorResponse(res)` in `src/common/api.ts`.
### Models — strict 1:1 parity
- Python `@dataclass`es in `backend/models/`. TypeScript interfaces in `frontend/src/common/models.ts`. Any model change requires updating both.
- Persistence is MongoDB by default (`USE_MONGODB=true`), or TinyDB when `USE_MONGODB=false`. Both are accessed through the `LockedTable` / `MongoLockedTable` wrappers in `backend/db/db.py`. Operate on model instances with `from_dict()` / `to_dict()` — never raw dicts.
- MongoDB client initialization is lazy (`backend/db/mongo_client.py`). `backend/gunicorn.conf.py` provides the `post_fork` hook required for multi-worker Gunicorn deployments; `backend/Dockerfile` loads it with `-c gunicorn.conf.py`.
- Migration script: `cd backend && python -m scripts/migrate_to_mongodb [--dry-run]`. It reads TinyDB JSON files and writes them to MongoDB idempotently, backing up the originals to `<db_dir>/backups/<timestamp>/`.
### SSE event bus — mandatory for every mutation
- Every backend mutation (add/edit/delete/trigger) **must** call `send_event_for_current_user` from `api/utils.py`. Event types in `backend/events/types/` are mirrored in `frontend/src/common/backendEvents.ts`.
- Frontend: register listeners in `onMounted`, clean up in `onUnmounted`. SSE endpoint is `/events`.
### Background schedulers (started in `main.py` at boot)
- `start_deletion_scheduler` — runs hourly, deletes accounts marked for deletion after threshold
- `start_digest_scheduler` — email digests
- `start_state_expiry_scheduler` — expires stale state
- `start_chore_expiry_notification_scheduler` — chore expiry notifications
## Frontend conventions
- SFC file order: `<template>``<script>``<style scoped>`. TypeScript only in `<script>`. All styles must be `scoped`.
- Colors/spacing: use only `:root` CSS variables from `colors.css`. No hardcoded hex/px for themed properties.
- Layout shells: `ParentLayout` for admin/management, `ChildLayout` for child dashboard/focus.
- Images: models carry `image_id`; frontend resolves to `image_url` for rendering.
## Testing gotchas
- E2E tests use pre-authenticated sessions via `storageState` in `playwright.config.ts` — do **not** navigate to `/auth/login`. Import `E2E_EMAIL` / `E2E_PASSWORD` from `e2e/e2e-constants.ts`.
- E2E buckets that mutate shared state (default tasks, delete-account, create-child) use isolated users. Preserve this pattern when adding new buckets.
- Backend tests: `conftest.py` sets `DB_ENV=test` + dummy secrets. Test DB lands in `test_data/db/`, never touches production `data/`.
## Feature specs
Specs live in `.github/specs/`. If a spec has a checklist, all items must be marked done before the feature is complete.
+67
View File
@@ -0,0 +1,67 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project
Family chore/reward manager. Flask + TinyDB backend (`backend/`), Vue 3 + TypeScript frontend (`frontend/`). Real-time updates flow over Server-Sent Events.
## Commands
### Backend (run from `backend/`)
- Activate venv first: `source .venv/bin/activate` (mac/linux) — Python runs from `backend/.venv/`.
- Dev server: `python -m flask run --host=0.0.0.0 --port=5000` (entry: `backend/main.py`).
- Required env vars at startup: `SECRET_KEY`, `REFRESH_TOKEN_EXPIRY_DAYS`, `DIGEST_TOKEN_SECRET`, `VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY` — Flask raises `RuntimeError` on boot if any are missing.
- Optional: `DB_ENV` / `DATA_ENV` (`prod` | `test` | `e2e`) — picks `data/` vs `test_data/` directory (see `backend/config/paths.py`).
- Tests: `pytest tests/``tests/conftest.py` forces `DB_ENV=test` and sets dummy secrets. Single test: `pytest tests/test_routine_api.py::test_name`.
- Create an admin user (cannot be done via signup): `python scripts/create_admin.py`.
### Frontend (run from `frontend/`)
- Dev: `npm run dev` (Vite, https://localhost:5173).
- Build: `npm run build`. Type-check: `npm run type-check`. Lint: `npm run lint`.
- Unit/component tests: `npm run test:unit` (Vitest). Single test: `npx vitest run path/to/file.spec.ts`.
- E2E: `npx playwright test` from `frontend/`. Config at `playwright.config.ts` auto-starts both `npm run dev` and the Flask backend with `DB_ENV=e2e DATA_ENV=e2e`, so test data lands in `backend/test_data/` and never touches production. The `globalSetup` seeds the DB and logs in; tests receive a pre-authenticated session via `storageState` — do **not** navigate to `/auth/login`. Import `E2E_EMAIL` / `E2E_PASSWORD` from `e2e/e2e-constants.ts` rather than hardcoding.
- E2E suite is split into Playwright "projects" in `playwright.config.ts` (`chromium-routines`, `chromium-task-assignment`, …) — each bucket targets a directory under `e2e/mode_parent/` and some use isolated users to avoid cross-bucket interference. Run a single bucket: `npx playwright test --project=chromium-routines`.
## Architecture
### API surface and nginx proxy
- Each entity has its own Flask blueprint in `backend/api/` (`child_api.py`, `chore_api.py`, `routine_api.py`, …). Registered in `backend/main.py`.
- The frontend nginx (and Vite dev proxy) strips `/api` before forwarding. **Backend routes must NOT include `/api`** — backend defines `@app.route('/user')`, frontend calls `/api/user`.
- The `auth_api` blueprint is the only one mounted under a prefix (`/auth`).
- API errors return `{ error, code }`; codes live in `backend/api/error_codes.py`. Frontend extracts them via `parseErrorResponse(res)` in `frontend/src/common/api.ts`.
### Models — keep 1:1 parity
- Python `@dataclass`es live in `backend/models/`. TypeScript interfaces live in `frontend/src/common/models.ts`. Any model change requires updating **both**.
- Persistence is TinyDB (JSON files under `data/db/` or `test_data/db/`). All DB access goes through the thread-safe `LockedTable` wrapper in `backend/db/db.py`. Always operate on model instances using `from_dict()` / `to_dict()` — never raw dicts.
### SSE event bus (mandatory for every mutation)
- Every backend mutation (add/edit/delete/trigger) **must** call `send_event_for_current_user` (from `api/utils.py`). Event types live in `backend/events/types/` and `frontend/src/common/backendEvents.ts` (mirrored).
- Frontend state is event-driven: register listeners in `onMounted`, clean up in `onUnmounted`. See `components/BackendEventsListener.vue` and `src/common/backendEvents.ts`.
- The SSE endpoint is `/events`; per-user queues live in `backend/events/sse.py`.
### Background schedulers
Started in `backend/main.py` at boot:
- `start_deletion_scheduler` — runs hourly, deletes accounts that were marked-for-deletion at least `ACCOUNT_DELETION_THRESHOLD_HOURS` ago (default 720, min 24, max 720). Cleans pending rewards, children, tasks, rewards, images, then the user. Logs to `logs/account_deletion.log`.
- `start_digest_scheduler` — email digests.
- `start_state_expiry_scheduler` — expires stale state.
- `start_chore_expiry_notification_scheduler` — chore expiry notifications.
### Auth & security
- JWT in HttpOnly + Secure + SameSite=Strict cookies. Verification tokens expire in 4 hours; password-reset tokens in 10 minutes.
- Admin role is **never** assignable via signup — use `backend/scripts/create_admin.py`. Admin endpoints under `/admin/*` enforce role check.
### Frontend conventions
- Vue SFC file order: `<template>``<script>``<style scoped>`. TypeScript only inside `<script>`. **All styles must be `scoped`.**
- Use **only** `:root` CSS variables from `colors.css` for colors/spacing/tokens (e.g. `--btn-primary`, `--list-item-bg-good`). No hardcoded hex/px values for themed properties.
- Layout shells: `ParentLayout` for admin/management views, `ChildLayout` for child dashboard/focus views.
- Images: models carry `image_id`; frontend resolves to `image_url` for rendering.
### Specs
Feature specs live in `.github/specs/`. If a spec has a checklist, all items must be marked done before the feature is considered complete.
## Gotchas
- Backend Python imports assume `backend/` is on `sys.path` (added by `conftest.py` for tests, by `flask run` cwd in dev). Run pytest from `backend/`.
- Don't replace code with comments; mirror changes across backend + frontend so model/event parity holds.
- E2E tests share a single seeded user by default — buckets that mutate shared state (default tasks, delete-account, create-child) deliberately use isolated users; preserve that pattern when adding new buckets.
+33 -2
View File
@@ -4,7 +4,7 @@ A family-friendly application for managing chores, tasks, and rewards for childr
## 🏗️ Architecture ## 🏗️ Architecture
- **Backend**: Flask (Python) with TinyDB for data persistence - **Backend**: Flask (Python) with TinyDB or MongoDB for data persistence
- **Frontend**: Vue 3 (TypeScript) with real-time SSE updates - **Frontend**: Vue 3 (TypeScript) with real-time SSE updates
- **Deployment**: Docker with nginx reverse proxy - **Deployment**: Docker with nginx reverse proxy
@@ -38,6 +38,37 @@ npm run dev
| `ACCOUNT_DELETION_THRESHOLD_HOURS` | Hours to wait before deleting marked accounts | 720 (30 days) | | `ACCOUNT_DELETION_THRESHOLD_HOURS` | Hours to wait before deleting marked accounts | 720 (30 days) |
| `DB_ENV` | Database environment (`prod` or `test`) | `prod` | | `DB_ENV` | Database environment (`prod` or `test`) | `prod` |
| `DATA_ENV` | Data directory environment (`prod` or `test`) | `prod` | | `DATA_ENV` | Data directory environment (`prod` or `test`) | `prod` |
| `USE_MONGODB` | Use MongoDB (`true`/`false`) | `true` |
| `MONGO_URI` | MongoDB connection URI (required when `USE_MONGODB=true`) | — |
| `MONGO_DB_NAME` | MongoDB database name (optional) | Parsed from `MONGO_URI`, or `chore_db`/`chore_db_test`/`chore_db_e2e` based on `DB_ENV` |
### Database Backend
The application supports two persistence backends:
- **MongoDB** (default): Set `MONGO_URI` (and optionally `MONGO_DB_NAME`). This is the recommended backend for production and managed hosting (e.g., MongoDB Atlas).
- **TinyDB**: JSON-file storage in `backend/data/db/` (or `backend/test_data/db/` for `test`/`e2e`). Opt in by setting `USE_MONGODB=false`.
#### Migrating from TinyDB to MongoDB
```bash
cd backend
# Dry run to preview what will be migrated
python -m scripts.migrate_to_mongodb --dry-run
# Run the migration (backs up TinyDB files first)
python -m scripts.migrate_to_mongodb
```
The migration script reads the existing TinyDB JSON files and inserts each record into the matching MongoDB collection, skipping records that already exist. Original TinyDB files are backed up to `backend/data/db/backups/<timestamp>/`.
#### Rolling Back to TinyDB
Set `USE_MONGODB=false`. The original JSON files remain in place.
#### Gunicorn / Docker
When running multiple Gunicorn workers, each worker must create its own MongoDB client after forking. This is handled automatically by `backend/gunicorn.conf.py`, which is loaded by `backend/Dockerfile` via `-c gunicorn.conf.py`.
### Account Deletion Scheduler ### Account Deletion Scheduler
@@ -145,7 +176,7 @@ npm run test
├── backend/ ├── backend/
│ ├── api/ # REST API endpoints │ ├── api/ # REST API endpoints
│ ├── config/ # Configuration files │ ├── config/ # Configuration files
│ ├── db/ # TinyDB setup │ ├── db/ # TinyDB / MongoDB persistence layer
│ ├── events/ # SSE event system │ ├── events/ # SSE event system
│ ├── models/ # Data models │ ├── models/ # Data models
│ ├── tests/ # Backend tests │ ├── tests/ # Backend tests
+1 -1
View File
@@ -15,4 +15,4 @@ ENV PYTHONIOENCODING=utf-8
VOLUME ["/app/data"] VOLUME ["/app/data"]
# Use Gunicorn instead of python main.py # Use Gunicorn instead of python main.py
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "-k", "gevent", "--workers", "1", "--timeout", "120", "--access-logfile", "-", "--error-logfile", "-", "--log-level", "info", "main:app"] CMD ["gunicorn", "--bind", "0.0.0.0:5000", "-k", "gevent", "--workers", "1", "--timeout", "120", "--access-logfile", "-", "--error-logfile", "-", "--log-level", "info", "-c", "gunicorn.conf.py", "main:app"]
+107 -22
View File
@@ -29,6 +29,8 @@ from db.db import (
users_db, refresh_tokens_db, child_db, task_db, reward_db, image_db, users_db, refresh_tokens_db, child_db, task_db, reward_db, image_db,
pending_reward_db, pending_confirmations_db, tracking_events_db, pending_reward_db, pending_confirmations_db, tracking_events_db,
child_overrides_db, chore_schedules_db, task_extensions_db, child_overrides_db, chore_schedules_db, task_extensions_db,
routine_db, routine_items_db, routine_schedules_db, routine_extensions_db,
push_subscriptions_db, digest_action_tokens_db,
) )
from db.default import initializeImages, createDefaultTasks, createDefaultRewards from db.default import initializeImages, createDefaultTasks, createDefaultRewards
from api.utils import normalize_email from api.utils import normalize_email
@@ -43,6 +45,12 @@ try:
ACCESS_TOKEN_EXPIRY_MINUTES = int(os.environ.get('ACCESS_TOKEN_EXPIRY_MINUTES', '15')) ACCESS_TOKEN_EXPIRY_MINUTES = int(os.environ.get('ACCESS_TOKEN_EXPIRY_MINUTES', '15'))
except ValueError: except ValueError:
ACCESS_TOKEN_EXPIRY_MINUTES = 15 ACCESS_TOKEN_EXPIRY_MINUTES = 15
try:
REFRESH_TOKEN_REUSE_GRACE_PERIOD_SECONDS = int(
os.environ.get('REFRESH_TOKEN_REUSE_GRACE_PERIOD_SECONDS', '30')
)
except ValueError:
REFRESH_TOKEN_REUSE_GRACE_PERIOD_SECONDS = 30
E2E_TEST_EMAIL = 'e2e@test.com' E2E_TEST_EMAIL = 'e2e@test.com'
E2E_TEST_PASSWORD = 'E2eTestPass1!' E2E_TEST_PASSWORD = 'E2eTestPass1!'
E2E_TEST_PIN = '1234' E2E_TEST_PIN = '1234'
@@ -52,6 +60,9 @@ E2E_DELETE_PIN = '5678'
E2E_CC_EMAIL = 'e2e-cc@test.com' E2E_CC_EMAIL = 'e2e-cc@test.com'
E2E_CC_PASSWORD = 'E2eCCPass1!' E2E_CC_PASSWORD = 'E2eCCPass1!'
E2E_CC_PIN = '3456' E2E_CC_PIN = '3456'
E2E_TUTORIAL_EMAIL = 'e2e-tutorial@test.com'
E2E_TUTORIAL_PASSWORD = 'E2eTutorialPass1!'
E2E_TUTORIAL_PIN = '7890'
def send_verification_email(to_email, token): def send_verification_email(to_email, token):
@@ -409,18 +420,21 @@ def refresh():
token_record = RefreshToken.from_dict(token_dict) token_record = RefreshToken.from_dict(token_dict)
# THEFT DETECTION: token was already used (rotated out) but replayed # Look up the user early (needed for both legitimate rotation and grace-period handling)
if token_record.is_used: user_dict = users_db.get(UserQuery.id == token_record.user_id)
logger.warning( user = User.from_dict(user_dict) if user_dict else None
'Refresh token reuse detected! user_id=%s, family=%s, ip=%s — killing all sessions', if not user:
token_record.user_id, token_record.token_family, request.remote_addr, refresh_tokens_db.remove(TokenQuery.id == token_record.id)
) resp = jsonify({'error': 'User not found', 'code': USER_NOT_FOUND})
# Nuke ALL refresh tokens for this user
refresh_tokens_db.remove(TokenQuery.user_id == token_record.user_id)
resp = jsonify({'error': 'Token reuse detected, all sessions invalidated', 'code': REFRESH_TOKEN_REUSE})
_clear_auth_cookies(resp) _clear_auth_cookies(resp)
return resp, 401 return resp, 401
if user.marked_for_deletion:
refresh_tokens_db.remove(TokenQuery.user_id == user.id)
resp = jsonify({'error': 'Account marked for deletion', 'code': ACCOUNT_MARKED_FOR_DELETION})
_clear_auth_cookies(resp)
return resp, 403
# Check expiry # Check expiry
try: try:
exp = datetime.fromisoformat(token_record.expires_at) exp = datetime.fromisoformat(token_record.expires_at)
@@ -437,23 +451,59 @@ def refresh():
_clear_auth_cookies(resp) _clear_auth_cookies(resp)
return resp, 401 return resp, 401
# Look up the user # THEFT DETECTION: token was already used (rotated out) but replayed
user_dict = users_db.get(UserQuery.id == token_record.user_id) if token_record.is_used:
user = User.from_dict(user_dict) if user_dict else None # Grace period: tolerate a very recent rotation to avoid false positives
if not user: # from legitimate concurrent refresh requests (race conditions).
refresh_tokens_db.remove(TokenQuery.id == token_record.id) grace_period = current_app.config.get(
resp = jsonify({'error': 'User not found', 'code': USER_NOT_FOUND}) 'REFRESH_TOKEN_REUSE_GRACE_PERIOD_SECONDS', REFRESH_TOKEN_REUSE_GRACE_PERIOD_SECONDS
)
rotated_at = token_record.rotated_at
is_race_condition = False
if rotated_at:
try:
rotated_dt = datetime.fromisoformat(rotated_at)
if rotated_dt.tzinfo is None:
rotated_dt = rotated_dt.replace(tzinfo=timezone.utc)
if (datetime.now(timezone.utc) - rotated_dt).total_seconds() <= grace_period:
is_race_condition = True
except ValueError:
pass
if is_race_condition:
logger.info(
'Refresh token replay within grace period treated as race condition. user_id=%s, family=%s, ip=%s',
token_record.user_id, token_record.token_family, request.remote_addr,
)
raw_new_refresh, _ = _create_refresh_token(user.id, token_family=token_record.token_family)
access_token = _create_access_token(user)
resp = jsonify({
'email': user.email,
'id': user.id,
'first_name': user.first_name,
'last_name': user.last_name,
'verified': user.verified,
})
_set_auth_cookies(resp, access_token, raw_new_refresh)
return resp, 200
logger.warning(
'Refresh token reuse detected! user_id=%s, family=%s, ip=%s — killing family sessions',
token_record.user_id, token_record.token_family, request.remote_addr,
)
# Invalidate only the affected family, not every session for the user.
refresh_tokens_db.remove(
(TokenQuery.user_id == token_record.user_id) & (TokenQuery.token_family == token_record.token_family)
)
resp = jsonify({'error': 'Token reuse detected, family sessions invalidated', 'code': REFRESH_TOKEN_REUSE})
_clear_auth_cookies(resp) _clear_auth_cookies(resp)
return resp, 401 return resp, 401
if user.marked_for_deletion:
refresh_tokens_db.remove(TokenQuery.user_id == user.id)
resp = jsonify({'error': 'Account marked for deletion', 'code': ACCOUNT_MARKED_FOR_DELETION})
_clear_auth_cookies(resp)
return resp, 403
# ROTATION: mark old token as used, create new one in same family # ROTATION: mark old token as used, create new one in same family
refresh_tokens_db.update({'is_used': True}, TokenQuery.id == token_record.id) refresh_tokens_db.update(
{'is_used': True, 'rotated_at': datetime.now(timezone.utc).isoformat()},
TokenQuery.id == token_record.id,
)
raw_new_refresh, _ = _create_refresh_token(user.id, token_family=token_record.token_family) raw_new_refresh, _ = _create_refresh_token(user.id, token_family=token_record.token_family)
# Issue new access token # Issue new access token
@@ -499,6 +549,7 @@ def e2e_create_delete_user():
verified=True, verified=True,
role='user', role='user',
pin=E2E_DELETE_PIN, pin=E2E_DELETE_PIN,
tutorial_enabled=False,
) )
users_db.insert(user.to_dict()) users_db.insert(user.to_dict())
return jsonify({'email': norm_email}), 201 return jsonify({'email': norm_email}), 201
@@ -524,6 +575,33 @@ def e2e_create_cc_user():
verified=True, verified=True,
role='user', role='user',
pin=E2E_CC_PIN, pin=E2E_CC_PIN,
tutorial_enabled=False,
)
users_db.insert(user.to_dict())
return jsonify({'email': norm_email}), 201
@auth_api.route('/e2e-create-tutorial-user', methods=['POST'])
def e2e_create_tutorial_user():
"""Create an isolated e2e test user for tutorial tests. Only available outside production."""
if os.environ.get('DB_ENV', 'prod') == 'prod':
return jsonify({'error': 'Not available in production'}), 403
norm_email = normalize_email(E2E_TUTORIAL_EMAIL)
# Clean up any children from previous tutorial runs.
existing = users_db.get(UserQuery.email == norm_email)
if existing:
child_db.remove(Query().user_id == existing.get('id'))
users_db.remove(UserQuery.email == norm_email)
user = User(
first_name='E2E',
last_name='Tutorial',
email=norm_email,
password=generate_password_hash(E2E_TUTORIAL_PASSWORD),
verified=True,
role='user',
pin=E2E_TUTORIAL_PIN,
tutorial_enabled=False,
) )
users_db.insert(user.to_dict()) users_db.insert(user.to_dict())
return jsonify({'email': norm_email}), 201 return jsonify({'email': norm_email}), 201
@@ -547,6 +625,12 @@ def e2e_seed():
chore_schedules_db.truncate() chore_schedules_db.truncate()
task_extensions_db.truncate() task_extensions_db.truncate()
refresh_tokens_db.truncate() refresh_tokens_db.truncate()
routine_db.truncate()
routine_items_db.truncate()
routine_schedules_db.truncate()
routine_extensions_db.truncate()
push_subscriptions_db.truncate()
digest_action_tokens_db.truncate()
# Recreate only baseline defaults for e2e runs. # Recreate only baseline defaults for e2e runs.
initializeImages() initializeImages()
@@ -562,6 +646,7 @@ def e2e_seed():
verified=True, verified=True,
role='user', role='user',
pin=E2E_TEST_PIN, pin=E2E_TEST_PIN,
tutorial_enabled=False,
) )
users_db.insert(user.to_dict()) users_db.insert(user.to_dict())
+85 -1
View File
@@ -9,12 +9,14 @@ from datetime import datetime, timezone
from tinydb import Query from tinydb import Query
from db.db import child_db, task_db, reward_db, pending_confirmations_db from db.db import child_db, task_db, reward_db, pending_confirmations_db, routine_db
from db.child_overrides import get_override from db.child_overrides import get_override
from db.chore_schedules import get_schedule from db.chore_schedules import get_schedule
from db.routine_schedules import get_schedule as get_routine_schedule
from db.tracking import insert_tracking_event from db.tracking import insert_tracking_event
from events.sse import send_event_to_user from events.sse import send_event_to_user
from events.types.child_chore_confirmation import ChildChoreConfirmation from events.types.child_chore_confirmation import ChildChoreConfirmation
from events.types.child_routine_confirmation import ChildRoutineConfirmation
from events.types.child_reward_request import ChildRewardRequest from events.types.child_reward_request import ChildRewardRequest
from events.types.child_reward_triggered import ChildRewardTriggered from events.types.child_reward_triggered import ChildRewardTriggered
from events.types.child_task_triggered import ChildTaskTriggered from events.types.child_task_triggered import ChildTaskTriggered
@@ -23,6 +25,7 @@ from events.types.event import Event
from events.types.event_types import EventType from events.types.event_types import EventType
from models.child import Child from models.child import Child
from models.reward import Reward from models.reward import Reward
from models.routine import Routine
from models.task import Task from models.task import Task
from models.tracking_event import TrackingEvent from models.tracking_event import TrackingEvent
from utils.tracking_logger import log_tracking_event from utils.tracking_logger import log_tracking_event
@@ -271,3 +274,84 @@ def deny_reward(user_id: str, child_id: str, reward_id: str) -> dict | None:
ChildRewardRequest(child_id, reward_id, ChildRewardRequest.REQUEST_CANCELLED))) ChildRewardRequest(child_id, reward_id, ChildRewardRequest.REQUEST_CANCELLED)))
return {'child_name': child_name} return {'child_name': child_name}
def approve_routine(user_id: str, child_id: str, routine_id: str) -> dict | None:
"""Award points for a completed routine and mark the pending confirmation approved.
Returns a result dict on success, or None if already resolved.
Raises ValueError if the child or routine cannot be found.
"""
ChildQ = Query()
child_result = child_db.get((ChildQ.id == child_id) & (ChildQ.user_id == user_id))
if not child_result:
raise ValueError(f'Child {child_id} not found for user {user_id}')
child = Child.from_dict(child_result)
if routine_id not in child.routines:
logger.info(f'Routine {routine_id} no longer assigned to child {child_id}; skipping approve')
return None
PendingQ = Query()
existing = pending_confirmations_db.get(
(PendingQ.child_id == child_id) & (PendingQ.entity_id == routine_id) &
(PendingQ.entity_type == 'routine') & (PendingQ.status == 'pending') &
(PendingQ.user_id == user_id)
)
if not existing:
logger.info(f'No pending routine for child {child_id}, routine {routine_id} — already resolved')
return None
RoutineQ = Query()
routine_result = routine_db.get(
(RoutineQ.id == routine_id) & ((RoutineQ.user_id == user_id) | (RoutineQ.user_id == None))
)
if not routine_result:
raise ValueError(f'Routine {routine_id} not found')
routine = Routine.from_dict(routine_result)
override = get_override(child_id, routine_id)
points_value = override.custom_value if override and override.entity_type == 'routine' else routine.points
points_before = child.points
child.points += points_value
child_db.update({'points': child.points}, ChildQ.id == child_id)
schedule = get_routine_schedule(child_id, routine_id)
now_str = datetime.now(timezone.utc).isoformat()
if schedule:
pending_confirmations_db.update(
{'status': 'approved', 'approved_at': now_str},
(PendingQ.child_id == child_id) & (PendingQ.entity_id == routine_id) &
(PendingQ.entity_type == 'routine') & (PendingQ.user_id == user_id)
)
else:
pending_confirmations_db.remove(
(PendingQ.child_id == child_id) & (PendingQ.entity_id == routine_id) &
(PendingQ.entity_type == 'routine') & (PendingQ.user_id == user_id)
)
send_event_to_user(user_id, Event(EventType.CHILD_ROUTINE_CONFIRMATION.value,
ChildRoutineConfirmation(child_id, routine_id, ChildRoutineConfirmation.OPERATION_APPROVED)))
return {'routine_name': routine.name, 'child_name': child.name, 'child_id': child_id, 'points': child.points}
def reject_routine(user_id: str, child_id: str, routine_id: str) -> None:
"""Reject a pending routine confirmation. No-op if already resolved."""
PendingQ = Query()
existing = pending_confirmations_db.get(
(PendingQ.child_id == child_id) & (PendingQ.entity_id == routine_id) &
(PendingQ.entity_type == 'routine') & (PendingQ.status == 'pending') &
(PendingQ.user_id == user_id)
)
if not existing:
logger.info(f'No pending routine for child {child_id}, routine {routine_id} — already resolved')
return
pending_confirmations_db.remove(
(PendingQ.child_id == child_id) & (PendingQ.entity_id == routine_id) &
(PendingQ.entity_type == 'routine') & (PendingQ.user_id == user_id)
)
send_event_to_user(user_id, Event(EventType.CHILD_ROUTINE_CONFIRMATION.value,
ChildRoutineConfirmation(child_id, routine_id, ChildRoutineConfirmation.OPERATION_REJECTED)))
+113 -9
View File
@@ -1,5 +1,6 @@
from time import sleep from time import sleep
from datetime import datetime, timezone from datetime import datetime, timezone
from zoneinfo import ZoneInfo
from flask import Blueprint, request, jsonify from flask import Blueprint, request, jsonify
from tinydb import Query from tinydb import Query
@@ -10,7 +11,7 @@ from api.pending_confirmation import PendingConfirmationResponse
from api.reward_status import RewardStatus from api.reward_status import RewardStatus
from api.utils import send_event_for_current_user, get_validated_user_id from api.utils import send_event_for_current_user, get_validated_user_id
import api.child_action_helpers as chore_actions import api.child_action_helpers as chore_actions
from db.db import child_db, task_db, reward_db, pending_reward_db, pending_confirmations_db, users_db from db.db import child_db, task_db, reward_db, routine_db, pending_reward_db, pending_confirmations_db, users_db
from db.tracking import insert_tracking_event from db.tracking import insert_tracking_event
from db.child_overrides import get_override, delete_override, delete_overrides_for_child from db.child_overrides import get_override, delete_override, delete_overrides_for_child
from events.types.child_chore_confirmation import ChildChoreConfirmation from events.types.child_chore_confirmation import ChildChoreConfirmation
@@ -35,11 +36,60 @@ from utils.digest_token import create_action_token
from collections import defaultdict from collections import defaultdict
from db.chore_schedules import get_schedule from db.chore_schedules import get_schedule
from db.task_extensions import get_extension_for_child_task from db.task_extensions import get_extension_for_child_task
from db.routine_schedules import delete_schedules_for_child as delete_routine_schedules_for_child
from db.routine_extensions import delete_extensions_for_child as delete_routine_extensions_for_child
import logging import logging
child_api = Blueprint('child_api', __name__) child_api = Blueprint('child_api', __name__)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _get_user_timezone(user_id: str) -> str | None:
user = users_db.get(Query().id == user_id)
if not user:
return None
return user.get('timezone')
def _get_user_today_local(user_id: str) -> tuple[str, str | None]:
tz_str = _get_user_timezone(user_id)
try:
now_local = datetime.now(ZoneInfo(tz_str)) if tz_str else datetime.now(timezone.utc)
except Exception:
tz_str = None
now_local = datetime.now(timezone.utc)
return now_local.strftime('%Y-%m-%d'), tz_str
def _is_iso_timestamp_on_local_day(timestamp: str | None, local_day: str, tz_str: str | None) -> bool:
if not timestamp:
return False
try:
normalized = timestamp.replace('Z', '+00:00')
parsed = datetime.fromisoformat(normalized)
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
try:
tz = ZoneInfo(tz_str) if tz_str else timezone.utc
except Exception:
tz = timezone.utc
return parsed.astimezone(tz).strftime('%Y-%m-%d') == local_day
except (TypeError, ValueError):
return False
def _is_epoch_timestamp_on_local_day(epoch_ts, local_day: str, tz_str: str | None) -> bool:
if epoch_ts is None:
return False
try:
tz = ZoneInfo(tz_str) if tz_str else timezone.utc
except Exception:
tz = timezone.utc
try:
return datetime.fromtimestamp(float(epoch_ts), tz).strftime('%Y-%m-%d') == local_day
except (TypeError, ValueError, OSError):
return False
@child_api.route('/child/<name>', methods=['GET']) @child_api.route('/child/<name>', methods=['GET'])
@child_api.route('/child/<id>', methods=['GET']) @child_api.route('/child/<id>', methods=['GET'])
def get_child(id): def get_child(id):
@@ -150,6 +200,14 @@ def delete_child(id):
deleted_count = delete_overrides_for_child(id) deleted_count = delete_overrides_for_child(id)
if deleted_count > 0: if deleted_count > 0:
logger.info(f"Cascade deleted {deleted_count} overrides for child {id}") logger.info(f"Cascade deleted {deleted_count} overrides for child {id}")
# Cascade delete routine schedule/extension rows for this child.
delete_routine_schedules_for_child(id)
delete_routine_extensions_for_child(id)
# Remove pending routine confirmations for this child.
PendingQuery = Query()
pending_confirmations_db.remove((PendingQuery.child_id == id) & (PendingQuery.entity_type == 'routine'))
if child_db.remove((ChildQuery.id == id) & (ChildQuery.user_id == user_id)): if child_db.remove((ChildQuery.id == id) & (ChildQuery.user_id == user_id)):
resp = send_event_for_current_user(Event(EventType.CHILD_MODIFIED.value, ChildModified(id, ChildModified.OPERATION_DELETE))) resp = send_event_for_current_user(Event(EventType.CHILD_MODIFIED.value, ChildModified(id, ChildModified.OPERATION_DELETE)))
@@ -282,6 +340,7 @@ def list_child_tasks(id):
task_ids = child.get('tasks', []) task_ids = child.get('tasks', [])
TaskQuery = Query() TaskQuery = Query()
today_local, tz_str = _get_user_today_local(user_id)
child_tasks = [] child_tasks = []
for tid in task_ids: for tid in task_ids:
task = task_db.get((TaskQuery.id == tid) & ((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None))) task = task_db.get((TaskQuery.id == tid) & ((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)))
@@ -313,8 +372,21 @@ def list_child_tasks(id):
(PendingQuery.entity_type == 'chore') & (PendingQuery.user_id == user_id) (PendingQuery.entity_type == 'chore') & (PendingQuery.user_id == user_id)
) )
if pending: if pending:
ct_dict['pending_status'] = pending.get('status') status = pending.get('status')
ct_dict['approved_at'] = pending.get('approved_at') approved_at = pending.get('approved_at')
created_at = pending.get('created_at')
if status == 'approved' and _is_iso_timestamp_on_local_day(approved_at, today_local, tz_str):
ct_dict['pending_status'] = 'approved'
ct_dict['approved_at'] = approved_at
elif status == 'pending' and _is_epoch_timestamp_on_local_day(created_at, today_local, tz_str):
ct_dict['pending_status'] = 'pending'
ct_dict['approved_at'] = None
else:
pending_id = pending.get('id')
if pending_id:
pending_confirmations_db.remove(PendingQuery.id == pending_id)
ct_dict['pending_status'] = None
ct_dict['approved_at'] = None
else: else:
ct_dict['pending_status'] = None ct_dict['pending_status'] = None
ct_dict['approved_at'] = None ct_dict['approved_at'] = None
@@ -854,6 +926,7 @@ def reward_status(id):
reward_ids = child.rewards reward_ids = child.rewards
RewardQuery = Query() RewardQuery = Query()
today_local, tz_str = _get_user_today_local(user_id)
statuses = [] statuses = []
for reward_id in reward_ids: for reward_id in reward_ids:
reward_dict = reward_db.get((RewardQuery.id == reward_id) & ((RewardQuery.user_id == user_id) | (RewardQuery.user_id == None))) reward_dict = reward_db.get((RewardQuery.id == reward_id) & ((RewardQuery.user_id == user_id) | (RewardQuery.user_id == None)))
@@ -872,7 +945,16 @@ def reward_status(id):
(pending_query.child_id == child.id) & (pending_query.entity_id == reward.id) & (pending_query.child_id == child.id) & (pending_query.entity_id == reward.id) &
(pending_query.entity_type == 'reward') & (pending_query.user_id == user_id) (pending_query.entity_type == 'reward') & (pending_query.user_id == user_id)
) )
status = RewardStatus(reward.id, reward.name, points_needed, cost_value, pending is not None, reward.image_id) redeeming = False
if pending and pending.get('status') == 'pending':
if _is_epoch_timestamp_on_local_day(pending.get('created_at'), today_local, tz_str):
redeeming = True
else:
pending_id = pending.get('id')
if pending_id:
pending_confirmations_db.remove(pending_query.id == pending_id)
status = RewardStatus(reward.id, reward.name, points_needed, cost_value, redeeming, reward.image_id)
status_dict = status.to_dict() status_dict = status.to_dict()
if override: if override:
status_dict['custom_value'] = override.custom_value status_dict['custom_value'] = override.custom_value
@@ -927,7 +1009,12 @@ def request_reward(id):
(DupQuery.user_id == user_id) (DupQuery.user_id == user_id)
) )
if duplicate: if duplicate:
return jsonify({'error': 'Reward request already pending', 'code': 'DUPLICATE_REWARD_REQUEST'}), 409 today_local, tz_str = _get_user_today_local(user_id)
if _is_epoch_timestamp_on_local_day(duplicate.get('created_at'), today_local, tz_str):
return jsonify({'error': 'Reward request already pending', 'code': 'DUPLICATE_REWARD_REQUEST'}), 409
pending_id = duplicate.get('id')
if pending_id:
pending_confirmations_db.remove(DupQuery.id == pending_id)
pending = PendingConfirmation(child_id=child.id, entity_id=reward.id, entity_type='reward', user_id=user_id) pending = PendingConfirmation(child_id=child.id, entity_id=reward.id, entity_type='reward', user_id=user_id)
pending_confirmations_db.insert(pending.to_dict()) pending_confirmations_db.insert(pending.to_dict())
@@ -1053,6 +1140,7 @@ def list_pending_confirmations():
if not user_id: if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401 return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
PendingQuery = Query() PendingQuery = Query()
today_local, tz_str = _get_user_today_local(user_id)
pending_items = pending_confirmations_db.search( pending_items = pending_confirmations_db.search(
(PendingQuery.user_id == user_id) & (PendingQuery.status == 'pending') (PendingQuery.user_id == user_id) & (PendingQuery.status == 'pending')
) )
@@ -1060,11 +1148,16 @@ def list_pending_confirmations():
RewardQuery = Query() RewardQuery = Query()
TaskQuery = Query() TaskQuery = Query()
RoutineQuery = Query()
ChildQuery = Query() ChildQuery = Query()
for pr in pending_items: for pr in pending_items:
pending = PendingConfirmation.from_dict(pr) pending = PendingConfirmation.from_dict(pr)
if not _is_epoch_timestamp_on_local_day(pending.created_at, today_local, tz_str):
pending_confirmations_db.remove(PendingQuery.id == pending.id)
continue
# Look up child details # Look up child details
child_result = child_db.get(ChildQuery.id == pending.child_id) child_result = child_db.get(ChildQuery.id == pending.child_id)
if not child_result: if not child_result:
@@ -1074,8 +1167,12 @@ def list_pending_confirmations():
# Look up entity details based on type # Look up entity details based on type
if pending.entity_type == 'reward': if pending.entity_type == 'reward':
entity_result = reward_db.get((RewardQuery.id == pending.entity_id) & ((RewardQuery.user_id == user_id) | (RewardQuery.user_id == None))) entity_result = reward_db.get((RewardQuery.id == pending.entity_id) & ((RewardQuery.user_id == user_id) | (RewardQuery.user_id == None)))
else: elif pending.entity_type == 'chore':
entity_result = task_db.get((TaskQuery.id == pending.entity_id) & ((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None))) entity_result = task_db.get((TaskQuery.id == pending.entity_id) & ((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)))
elif pending.entity_type == 'routine':
entity_result = routine_db.get((RoutineQuery.id == pending.entity_id) & ((RoutineQuery.user_id == user_id) | (RoutineQuery.user_id == None)))
else:
entity_result = None
if not entity_result: if not entity_result:
continue continue
@@ -1137,13 +1234,20 @@ def confirm_chore(id):
(PendingQuery.entity_type == 'chore') & (PendingQuery.user_id == user_id) (PendingQuery.entity_type == 'chore') & (PendingQuery.user_id == user_id)
) )
if existing: if existing:
today_local, tz_str = _get_user_today_local(user_id)
if existing.get('status') == 'pending': if existing.get('status') == 'pending':
return jsonify({'error': 'Chore already pending confirmation', 'code': 'CHORE_ALREADY_PENDING'}), 400 if _is_epoch_timestamp_on_local_day(existing.get('created_at'), today_local, tz_str):
return jsonify({'error': 'Chore already pending confirmation', 'code': 'CHORE_ALREADY_PENDING'}), 400
pending_id = existing.get('id')
if pending_id:
pending_confirmations_db.remove(PendingQuery.id == pending_id)
if existing.get('status') == 'approved': if existing.get('status') == 'approved':
approved_at = existing.get('approved_at', '') approved_at = existing.get('approved_at', '')
today_utc = datetime.now(timezone.utc).strftime('%Y-%m-%d') if _is_iso_timestamp_on_local_day(approved_at, today_local, tz_str):
if approved_at and approved_at[:10] == today_utc:
return jsonify({'error': 'Chore already completed today', 'code': 'CHORE_ALREADY_COMPLETED'}), 400 return jsonify({'error': 'Chore already completed today', 'code': 'CHORE_ALREADY_COMPLETED'}), 400
pending_id = existing.get('id')
if pending_id:
pending_confirmations_db.remove(PendingQuery.id == pending_id)
confirmation = PendingConfirmation( confirmation = PendingConfirmation(
child_id=id, entity_id=task_id, entity_type='chore', user_id=user_id child_id=id, entity_id=task_id, entity_type='chore', user_id=user_id
+17 -4
View File
@@ -2,7 +2,7 @@ from flask import Blueprint, request, jsonify
from tinydb import Query from tinydb import Query
from api.utils import get_validated_user_id, send_event_for_current_user from api.utils import get_validated_user_id, send_event_for_current_user
from api.error_codes import ErrorCodes from api.error_codes import ErrorCodes
from db.db import child_db, task_db, reward_db from db.db import child_db, task_db, reward_db, routine_db
from db.child_overrides import ( from db.child_overrides import (
insert_override, insert_override,
get_override, get_override,
@@ -52,8 +52,8 @@ def set_child_override(child_id):
return jsonify({'error': 'custom_value is required', 'code': ErrorCodes.MISSING_FIELD, 'field': 'custom_value'}), 400 return jsonify({'error': 'custom_value is required', 'code': ErrorCodes.MISSING_FIELD, 'field': 'custom_value'}), 400
# Validate entity_type # Validate entity_type
if entity_type not in ['task', 'reward']: if entity_type not in ['task', 'reward', 'routine']:
return jsonify({'error': 'entity_type must be "task" or "reward"', 'code': ErrorCodes.INVALID_VALUE, 'field': 'entity_type'}), 400 return jsonify({'error': 'entity_type must be "task", "reward", or "routine"', 'code': ErrorCodes.INVALID_VALUE, 'field': 'entity_type'}), 400
# Validate custom_value range # Validate custom_value range
if not isinstance(custom_value, int) or custom_value < 0 or custom_value > 10000: if not isinstance(custom_value, int) or custom_value < 0 or custom_value > 10000:
@@ -74,7 +74,7 @@ def set_child_override(child_id):
if entity_id not in assigned_tasks: if entity_id not in assigned_tasks:
return jsonify({'error': 'Task not assigned to child', 'code': ErrorCodes.ENTITY_NOT_ASSIGNED}), 404 return jsonify({'error': 'Task not assigned to child', 'code': ErrorCodes.ENTITY_NOT_ASSIGNED}), 404
else: # reward elif entity_type == 'reward':
EntityQuery = Query() EntityQuery = Query()
entity_result = reward_db.search( entity_result = reward_db.search(
(EntityQuery.id == entity_id) & (EntityQuery.id == entity_id) &
@@ -87,6 +87,19 @@ def set_child_override(child_id):
assigned_rewards = child_dict.get('rewards', []) assigned_rewards = child_dict.get('rewards', [])
if entity_id not in assigned_rewards: if entity_id not in assigned_rewards:
return jsonify({'error': 'Reward not assigned to child', 'code': ErrorCodes.ENTITY_NOT_ASSIGNED}), 404 return jsonify({'error': 'Reward not assigned to child', 'code': ErrorCodes.ENTITY_NOT_ASSIGNED}), 404
else: # routine
EntityQuery = Query()
entity_result = routine_db.search(
(EntityQuery.id == entity_id) &
((EntityQuery.user_id == user_id) | (EntityQuery.user_id == None))
)
if not entity_result:
return jsonify({'error': 'Routine not found', 'code': 'ROUTINE_NOT_FOUND'}), 404
assigned_routines = child_dict.get('routines', [])
if entity_id not in assigned_routines:
return jsonify({'error': 'Routine not assigned to child', 'code': ErrorCodes.ENTITY_NOT_ASSIGNED}), 404
# Create and insert override # Create and insert override
try: try:
+638
View File
@@ -0,0 +1,638 @@
from collections import defaultdict
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
from flask import Blueprint, request, jsonify
from tinydb import Query
from api.error_codes import ErrorCodes
from api.utils import get_validated_user_id, send_event_for_current_user
from db.child_overrides import delete_override, get_override
from db.db import child_db, pending_confirmations_db, routine_db, users_db
from db.routine_extensions import delete_extension_for_child_routine, get_extension_for_child_routine
from db.routine_items import get_items_for_routine
from db.routine_schedules import delete_schedule, get_schedule
from events.types.child_routine_confirmation import ChildRoutineConfirmation
from events.types.child_routines_set import ChildRoutinesSet
from events.types.event import Event
from events.types.event_types import EventType
from models.child import Child
from models.pending_confirmation import PendingConfirmation
from models.routine import Routine
from utils.digest_token import create_action_token
from utils.push_sender import send_push_to_user
child_routine_api = Blueprint('child_routine_api', __name__)
def _get_user_timezone(user_id: str) -> str | None:
user = users_db.get(Query().id == user_id)
if not user:
return None
return user.get('timezone')
def _get_user_today_local(user_id: str) -> tuple[str, str | None]:
tz_str = _get_user_timezone(user_id)
try:
now_local = datetime.now(ZoneInfo(tz_str)) if tz_str else datetime.now(timezone.utc)
except Exception:
tz_str = None
now_local = datetime.now(timezone.utc)
return now_local.strftime('%Y-%m-%d'), tz_str
def _is_iso_timestamp_on_local_day(timestamp: str | None, local_day: str, tz_str: str | None) -> bool:
if not timestamp:
return False
try:
normalized = timestamp.replace('Z', '+00:00')
parsed = datetime.fromisoformat(normalized)
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
try:
tz = ZoneInfo(tz_str) if tz_str else timezone.utc
except Exception:
tz = timezone.utc
return parsed.astimezone(tz).strftime('%Y-%m-%d') == local_day
except (TypeError, ValueError):
return False
def _is_epoch_timestamp_on_local_day(epoch_ts, local_day: str, tz_str: str | None) -> bool:
if epoch_ts is None:
return False
try:
tz = ZoneInfo(tz_str) if tz_str else timezone.utc
except Exception:
tz = timezone.utc
try:
return datetime.fromtimestamp(float(epoch_ts), tz).strftime('%Y-%m-%d') == local_day
except (TypeError, ValueError, OSError):
return False
class ChildRoutine:
def __init__(self, name, points, image_id, _id):
self.id = _id
self.name = name
self.points = points
self.image_id = image_id
def to_dict(self):
return {
'id': self.id,
'name': self.name,
'points': self.points,
'image_id': self.image_id,
}
def _validate_child_for_user(child_id: str, user_id: str):
child_q = Query()
result = child_db.search((child_q.id == child_id) & (child_q.user_id == user_id))
return Child.from_dict(result[0]) if result else None
def _resolve_routine_for_user(routine_id: str, user_id: str):
routine_q = Query()
routine_result = routine_db.get(
(routine_q.id == routine_id) & ((routine_q.user_id == user_id) | (routine_q.user_id == None))
)
return Routine.from_dict(routine_result) if routine_result else None
@child_routine_api.route('/child/<id>/assign-routine', methods=['POST'])
def assign_routine_to_child(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
data = request.get_json() or {}
routine_id = data.get('routine_id')
if not routine_id:
return jsonify({'error': 'routine_id is required', 'code': ErrorCodes.MISSING_FIELD}), 400
child = _validate_child_for_user(id, user_id)
if not child:
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
routine = _resolve_routine_for_user(routine_id, user_id)
if not routine:
return jsonify({'error': 'Routine not found'}), 404
routine_ids = list(child.routines)
if routine_id not in routine_ids:
routine_ids.append(routine_id)
child_db.update({'routines': routine_ids}, Query().id == id)
send_event_for_current_user(
Event(EventType.CHILD_ROUTINES_SET.value, ChildRoutinesSet(id, routine_ids))
)
return jsonify({'message': f'Routine {routine_id} assigned to {child.name}.'}), 200
@child_routine_api.route('/child/<id>/remove-routine', methods=['POST'])
def remove_routine_from_child(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
data = request.get_json() or {}
routine_id = data.get('routine_id')
if not routine_id:
return jsonify({'error': 'routine_id is required', 'code': ErrorCodes.MISSING_FIELD}), 400
child = _validate_child_for_user(id, user_id)
if not child:
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
routine_ids = list(child.routines)
if routine_id not in routine_ids:
return jsonify({'error': 'Routine not assigned to child', 'code': ErrorCodes.ENTITY_NOT_ASSIGNED}), 400
routine_ids.remove(routine_id)
child_db.update({'routines': routine_ids}, Query().id == id)
override = get_override(id, routine_id)
if override and override.entity_type == 'routine':
delete_override(id, routine_id)
delete_schedule(id, routine_id)
delete_extension_for_child_routine(id, routine_id)
pending_q = Query()
pending_confirmations_db.remove(
(pending_q.child_id == id) & (pending_q.entity_id == routine_id) &
(pending_q.entity_type == 'routine') & (pending_q.user_id == user_id)
)
send_event_for_current_user(
Event(EventType.CHILD_ROUTINES_SET.value, ChildRoutinesSet(id, routine_ids))
)
send_event_for_current_user(
Event(
EventType.CHILD_ROUTINE_CONFIRMATION.value,
ChildRoutineConfirmation(id, routine_id, ChildRoutineConfirmation.OPERATION_RESET)
)
)
return jsonify({'message': f'Routine {routine_id} removed from {child.name}.'}), 200
@child_routine_api.route('/child/<id>/set-routines', methods=['PUT'])
def set_child_routines(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
data = request.get_json() or {}
routine_ids = data.get('routine_ids')
if not isinstance(routine_ids, list):
return jsonify({'error': 'routine_ids must be a list'}), 400
child = _validate_child_for_user(id, user_id)
if not child:
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
routine_q = Query()
valid_ids = []
for rid in dict.fromkeys(routine_ids):
if routine_db.get((routine_q.id == rid) & ((routine_q.user_id == user_id) | (routine_q.user_id == None))):
valid_ids.append(rid)
old_ids = set(child.routines)
new_ids = set(valid_ids)
unassigned_ids = old_ids - new_ids
pending_q = Query()
for rid in unassigned_ids:
override = get_override(id, rid)
if override and override.entity_type == 'routine':
delete_override(id, rid)
delete_schedule(id, rid)
delete_extension_for_child_routine(id, rid)
pending_confirmations_db.remove(
(pending_q.child_id == id) & (pending_q.entity_id == rid) &
(pending_q.entity_type == 'routine') & (pending_q.user_id == user_id)
)
child_db.update({'routines': valid_ids}, Query().id == id)
send_event_for_current_user(Event(EventType.CHILD_ROUTINES_SET.value, ChildRoutinesSet(id, valid_ids)))
return jsonify({'message': f'Routines set for child {id}.', 'routine_ids': valid_ids, 'count': len(valid_ids)}), 200
@child_routine_api.route('/child/<id>/list-routines', methods=['GET'])
def list_child_routines(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
child = _validate_child_for_user(id, user_id)
if not child:
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
routine_q = Query()
pending_q = Query()
today_local, tz_str = _get_user_today_local(user_id)
child_routines = []
for rid in child.routines:
routine_record = routine_db.get((routine_q.id == rid) & ((routine_q.user_id == user_id) | (routine_q.user_id == None)))
if not routine_record:
continue
routine = Routine.from_dict(routine_record)
override = get_override(id, rid)
custom_value = override.custom_value if override and override.entity_type == 'routine' else None
cr = ChildRoutine(routine.name, routine.points, routine.image_id, routine.id)
cr_dict = cr.to_dict()
if custom_value is not None:
cr_dict['custom_value'] = custom_value
schedule = get_schedule(id, rid)
cr_dict['schedule'] = schedule.to_dict() if schedule else None
extension = get_extension_for_child_routine(id, rid)
cr_dict['extension_date'] = extension.date if extension else None
items = get_items_for_routine(rid)
cr_dict['items'] = [item.to_dict() for item in items]
pending = pending_confirmations_db.get(
(pending_q.child_id == id) & (pending_q.entity_id == rid) &
(pending_q.entity_type == 'routine') & (pending_q.user_id == user_id)
)
if pending:
status = pending.get('status')
approved_at = pending.get('approved_at')
created_at = pending.get('created_at')
confirmation_id = pending.get('id')
if status == 'approved' and _is_iso_timestamp_on_local_day(approved_at, today_local, tz_str):
cr_dict['pending_status'] = 'approved'
cr_dict['approved_at'] = approved_at
cr_dict['pending_confirmation_id'] = confirmation_id
elif status == 'pending' and _is_epoch_timestamp_on_local_day(created_at, today_local, tz_str):
cr_dict['pending_status'] = 'pending'
cr_dict['approved_at'] = None
cr_dict['pending_confirmation_id'] = confirmation_id
else:
pending_id = pending.get('id')
if pending_id:
pending_confirmations_db.remove(pending_q.id == pending_id)
cr_dict['pending_status'] = None
cr_dict['approved_at'] = None
cr_dict['pending_confirmation_id'] = None
else:
cr_dict['pending_status'] = None
cr_dict['approved_at'] = None
cr_dict['pending_confirmation_id'] = None
child_routines.append(cr_dict)
return jsonify({'routines': child_routines}), 200
@child_routine_api.route('/child/<id>/list-assignable-routines', methods=['GET'])
def list_assignable_routines(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
child = _validate_child_for_user(id, user_id)
if not child:
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
assigned_ids = set(child.routines)
all_routines = [r for r in routine_db.all() if r and r.get('id') and r.get('id') not in assigned_ids]
name_to_routines = defaultdict(list)
for routine in all_routines:
name_to_routines[routine.get('name')].append(routine)
filtered_routines = []
for _, routines in name_to_routines.items():
user_routines = [r for r in routines if r.get('user_id') is not None]
if len(user_routines) == 0:
filtered_routines.append(routines[0])
elif len(user_routines) == 1:
filtered_routines.append(user_routines[0])
else:
filtered_routines.extend(user_routines)
assignable = [
ChildRoutine(r.get('name'), r.get('points'), r.get('image_id'), r.get('id')).to_dict()
for r in filtered_routines
]
return jsonify({'routines': assignable, 'count': len(assignable)}), 200
@child_routine_api.route('/child/<id>/confirm-routine', methods=['POST'])
def confirm_routine(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
data = request.get_json() or {}
routine_id = data.get('routine_id')
if not routine_id:
return jsonify({'error': 'routine_id is required', 'code': ErrorCodes.MISSING_FIELD}), 400
child = _validate_child_for_user(id, user_id)
if not child:
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
if routine_id not in child.routines:
return jsonify({'error': 'Routine not assigned to child', 'code': ErrorCodes.ENTITY_NOT_ASSIGNED}), 400
routine = _resolve_routine_for_user(routine_id, user_id)
if not routine:
return jsonify({'error': 'Routine not found'}), 404
pending_q = Query()
existing = pending_confirmations_db.get(
(pending_q.child_id == id) & (pending_q.entity_id == routine_id) &
(pending_q.entity_type == 'routine') & (pending_q.user_id == user_id)
)
if existing:
today_local, tz_str = _get_user_today_local(user_id)
if existing.get('status') == 'pending':
if _is_epoch_timestamp_on_local_day(existing.get('created_at'), today_local, tz_str):
return jsonify({'error': 'Routine already pending confirmation', 'code': 'ROUTINE_ALREADY_PENDING'}), 400
pending_id = existing.get('id')
if pending_id:
pending_confirmations_db.remove(pending_q.id == pending_id)
if existing.get('status') == 'approved':
approved_at = existing.get('approved_at', '')
if _is_iso_timestamp_on_local_day(approved_at, today_local, tz_str):
return jsonify({'error': 'Routine already completed today', 'code': 'ROUTINE_ALREADY_COMPLETED'}), 400
pending_id = existing.get('id')
if pending_id:
pending_confirmations_db.remove(pending_q.id == pending_id)
confirmation = PendingConfirmation(
child_id=id,
entity_id=routine_id,
entity_type='routine',
user_id=user_id,
)
pending_confirmations_db.insert(confirmation.to_dict())
send_event_for_current_user(
Event(
EventType.CHILD_ROUTINE_CONFIRMATION.value,
ChildRoutineConfirmation(id, routine_id, ChildRoutineConfirmation.OPERATION_PENDING)
)
)
push_user = users_db.get(Query().id == user_id)
if push_user and push_user.get('push_notifications_enabled', True):
try:
approve_token = create_action_token(user_id, id, routine_id, 'routine', 'approve')
deny_token = create_action_token(user_id, id, routine_id, 'routine', 'deny')
push_payload = {
'type': 'routine_confirmed',
'title': 'Routine Pending',
'body': f'{child.name} completed {routine.name}',
'user_id': user_id,
'child_id': id,
'child_name': child.name,
'entity_id': routine_id,
'entity_type': 'routine',
'entity_name': routine.name,
'approve_token': approve_token.id,
'deny_token': deny_token.id,
}
send_push_to_user(user_id, push_payload)
except Exception:
pass
return jsonify({'message': f'Routine {routine.name} confirmed by {child.name}.', 'confirmation_id': confirmation.id}), 200
@child_routine_api.route('/child/<id>/cancel-routine-confirmation', methods=['POST'])
def cancel_routine_confirmation(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
data = request.get_json() or {}
routine_id = data.get('routine_id')
if not routine_id:
return jsonify({'error': 'routine_id is required', 'code': ErrorCodes.MISSING_FIELD}), 400
child = _validate_child_for_user(id, user_id)
if not child:
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
pending_q = Query()
existing = pending_confirmations_db.get(
(pending_q.child_id == id) & (pending_q.entity_id == routine_id) &
(pending_q.entity_type == 'routine') & (pending_q.status == 'pending') &
(pending_q.user_id == user_id)
)
if not existing:
return jsonify({'error': 'No pending confirmation found', 'code': 'PENDING_NOT_FOUND'}), 400
pending_confirmations_db.remove(
(pending_q.child_id == id) & (pending_q.entity_id == routine_id) &
(pending_q.entity_type == 'routine') & (pending_q.status == 'pending') &
(pending_q.user_id == user_id)
)
send_event_for_current_user(
Event(
EventType.CHILD_ROUTINE_CONFIRMATION.value,
ChildRoutineConfirmation(id, routine_id, ChildRoutineConfirmation.OPERATION_RESET)
)
)
return jsonify({'message': 'Routine confirmation cancelled.'}), 200
@child_routine_api.route('/child/<id>/approve-routine/<confirmation_id>', methods=['POST'])
def approve_routine(id, confirmation_id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
child = _validate_child_for_user(id, user_id)
if not child:
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
pending_q = Query()
confirmation = pending_confirmations_db.get(
(pending_q.id == confirmation_id) & (pending_q.child_id == id) &
(pending_q.entity_type == 'routine') & (pending_q.user_id == user_id)
)
if not confirmation:
return jsonify({'error': 'Pending confirmation not found', 'code': 'PENDING_NOT_FOUND'}), 404
if confirmation.get('status') != 'pending':
return jsonify({'error': 'Confirmation is already resolved', 'code': 'ALREADY_RESOLVED'}), 400
routine_id = confirmation.get('entity_id')
routine = _resolve_routine_for_user(routine_id, user_id)
if not routine:
return jsonify({'error': 'Routine not found'}), 404
override = get_override(id, routine_id)
points_value = override.custom_value if override and override.entity_type == 'routine' else routine.points
new_points = max(0, child.points + points_value)
child_db.update({'points': new_points}, Query().id == id)
approved_at = datetime.now(timezone.utc).isoformat()
pending_confirmations_db.update(
{'status': 'approved', 'approved_at': approved_at},
pending_q.id == confirmation_id
)
send_event_for_current_user(
Event(
EventType.CHILD_ROUTINE_CONFIRMATION.value,
ChildRoutineConfirmation(id, routine_id, ChildRoutineConfirmation.OPERATION_APPROVED)
)
)
return jsonify({
'message': f'Routine {routine.name} approved for {child.name}.',
'points': new_points,
'id': child.id,
}), 200
@child_routine_api.route('/child/<id>/reject-routine/<confirmation_id>', methods=['POST'])
def reject_routine(id, confirmation_id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
child = _validate_child_for_user(id, user_id)
if not child:
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
pending_q = Query()
confirmation = pending_confirmations_db.get(
(pending_q.id == confirmation_id) & (pending_q.child_id == id) &
(pending_q.entity_type == 'routine') & (pending_q.user_id == user_id)
)
if not confirmation:
return jsonify({'error': 'Pending confirmation not found', 'code': 'PENDING_NOT_FOUND'}), 404
if confirmation.get('status') != 'pending':
return jsonify({'error': 'Confirmation is already resolved', 'code': 'ALREADY_RESOLVED'}), 400
pending_confirmations_db.update({'status': 'rejected', 'approved_at': None}, pending_q.id == confirmation_id)
send_event_for_current_user(
Event(
EventType.CHILD_ROUTINE_CONFIRMATION.value,
ChildRoutineConfirmation(id, confirmation.get('entity_id'), ChildRoutineConfirmation.OPERATION_REJECTED)
)
)
return jsonify({'message': 'Routine confirmation rejected.'}), 200
@child_routine_api.route('/child/<id>/reset-routine/<confirmation_id>', methods=['POST'])
def reset_routine(id, confirmation_id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
child = _validate_child_for_user(id, user_id)
if not child:
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
pending_q = Query()
confirmation = pending_confirmations_db.get(
(pending_q.id == confirmation_id) & (pending_q.child_id == id) &
(pending_q.entity_type == 'routine') & (pending_q.user_id == user_id)
)
if not confirmation:
return jsonify({'error': 'Pending confirmation not found', 'code': 'PENDING_NOT_FOUND'}), 404
routine_id = confirmation.get('entity_id')
pending_confirmations_db.remove(pending_q.id == confirmation_id)
send_event_for_current_user(
Event(
EventType.CHILD_ROUTINE_CONFIRMATION.value,
ChildRoutineConfirmation(id, routine_id, ChildRoutineConfirmation.OPERATION_RESET)
)
)
return jsonify({'message': 'Routine reset to available.'}), 200
@child_routine_api.route('/child/<id>/trigger-routine', methods=['POST'])
def trigger_child_routine(id):
"""Parent-triggered routine confirmation — directly awards points."""
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
data = request.get_json() or {}
routine_id = data.get('routine_id')
if not routine_id:
return jsonify({'error': 'routine_id is required', 'code': ErrorCodes.MISSING_FIELD}), 400
child = _validate_child_for_user(id, user_id)
if not child:
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
if routine_id not in child.routines:
return jsonify({'error': 'Routine not assigned to child', 'code': ErrorCodes.ENTITY_NOT_ASSIGNED}), 400
routine = _resolve_routine_for_user(routine_id, user_id)
if not routine:
return jsonify({'error': 'Routine not found'}), 404
# Check for override
override = get_override(id, routine_id)
points_value = override.custom_value if override and override.entity_type == 'routine' else routine.points
# Award points
new_points = max(0, child.points + points_value)
child_db.update({'points': new_points}, Query().id == id)
# Create an approved pending confirmation so it shows as completed in the routine list
pending_q = Query()
existing = pending_confirmations_db.get(
(pending_q.child_id == id) & (pending_q.entity_id == routine_id) &
(pending_q.entity_type == 'routine') & (pending_q.user_id == user_id)
)
if existing:
today_local, tz_str = _get_user_today_local(user_id)
# Remove old confirmation if it exists and is not from today
if existing.get('status') == 'approved' and _is_iso_timestamp_on_local_day(existing.get('approved_at'), today_local, tz_str):
return jsonify({'error': 'Routine already completed today', 'code': 'ROUTINE_ALREADY_COMPLETED'}), 400
pending_id = existing.get('id')
if pending_id:
pending_confirmations_db.remove(pending_q.id == pending_id)
confirmation = PendingConfirmation(
child_id=id,
entity_id=routine_id,
entity_type='routine',
user_id=user_id,
status='approved',
approved_at=datetime.now(timezone.utc).isoformat()
)
pending_confirmations_db.insert(confirmation.to_dict())
send_event_for_current_user(
Event(
EventType.CHILD_ROUTINE_CONFIRMATION.value,
ChildRoutineConfirmation(id, routine_id, ChildRoutineConfirmation.OPERATION_APPROVED)
)
)
return jsonify({
'message': f'Routine {routine.name} awarded to {child.name}.',
'points': new_points,
'id': child.id,
}), 200
+5 -1
View File
@@ -5,7 +5,7 @@ from tinydb import Query
from utils.digest_token import validate_and_consume_token, validate_unsubscribe_token, peek_token from utils.digest_token import validate_and_consume_token, validate_unsubscribe_token, peek_token
from db.db import users_db from db.db import users_db
from api.child_action_helpers import approve_chore, reject_chore, approve_reward_request, deny_reward from api.child_action_helpers import approve_chore, reject_chore, approve_reward_request, deny_reward, approve_routine, reject_routine
from api.utils import get_validated_user_id from api.utils import get_validated_user_id
digest_action_api = Blueprint('digest_action_api', __name__) digest_action_api = Blueprint('digest_action_api', __name__)
@@ -79,6 +79,10 @@ def execute_digest_action(token_id: str):
approve_reward_request(user_id, token.child_id, token.entity_id) approve_reward_request(user_id, token.child_id, token.entity_id)
elif token.entity_type == 'reward' and token.action == 'deny': elif token.entity_type == 'reward' and token.action == 'deny':
deny_reward(user_id, token.child_id, token.entity_id) deny_reward(user_id, token.child_id, token.entity_id)
elif token.entity_type == 'routine' and token.action == 'approve':
approve_routine(user_id, token.child_id, token.entity_id)
elif token.entity_type == 'routine' and token.action == 'deny':
reject_routine(user_id, token.child_id, token.entity_id)
else: else:
return jsonify({'error': 'Unknown action', 'code': 'INVALID_ACTION'}), 400 return jsonify({'error': 'Unknown action', 'code': 'INVALID_ACTION'}), 400
except Exception as e: except Exception as e:
+185
View File
@@ -0,0 +1,185 @@
from flask import Blueprint, request, jsonify
from tinydb import Query
from api.utils import send_event_for_current_user, get_validated_user_id
from db.db import routine_db, child_db, pending_confirmations_db
from db.child_overrides import delete_overrides_for_entity
from db.routine_items import delete_for_routine
from db.routine_schedules import delete_schedules_for_routine
from db.routine_extensions import delete_extensions_for_routine
from events.types.event import Event
from events.types.event_types import EventType
from events.types.routine_modified import RoutineModified
from events.types.child_routines_set import ChildRoutinesSet
from models.routine import Routine
routine_api = Blueprint('routine_api', __name__)
@routine_api.route('/routine/add', methods=['PUT'])
def add_routine():
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
data = request.get_json() or {}
name = data.get('name')
points = data.get('points')
image = data.get('image_id', '')
if not name or points is None:
return jsonify({'error': 'Name and points are required'}), 400
routine = Routine(name=name, points=points, image_id=image, user_id=user_id)
routine_db.insert(routine.to_dict())
send_event_for_current_user(
Event(EventType.ROUTINE_MODIFIED.value, RoutineModified(routine.id, RoutineModified.OPERATION_ADD))
)
return jsonify({'message': f'Routine {name} added.', 'routine': routine.to_dict()}), 201
@routine_api.route('/routine/<id>', methods=['GET'])
def get_routine(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
q = Query()
result = routine_db.search((q.id == id) & ((q.user_id == user_id) | (q.user_id == None)))
if not result:
return jsonify({'error': 'Routine not found'}), 404
return jsonify(result[0]), 200
@routine_api.route('/routine/list', methods=['GET'])
def list_routines():
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
ids_param = request.args.get('ids')
q = Query()
routines = routine_db.search((q.user_id == user_id) | (q.user_id == None))
if ids_param is not None:
if ids_param.strip() == '':
routines = []
else:
ids = set(ids_param.split(','))
routines = [routine for routine in routines if routine.get('id') in ids]
user_routines = {r['name'].strip().lower(): r for r in routines if r.get('user_id') == user_id}
filtered_routines = []
for routine in routines:
if routine.get('user_id') is None and routine['name'].strip().lower() in user_routines:
continue
filtered_routines.append(routine)
user_created = sorted(
[r for r in filtered_routines if r.get('user_id') == user_id],
key=lambda x: x['name'].lower(),
)
default_items = sorted(
[r for r in filtered_routines if r.get('user_id') is None],
key=lambda x: x['name'].lower(),
)
return jsonify({'routines': user_created + default_items}), 200
@routine_api.route('/routine/<id>/edit', methods=['PUT'])
def edit_routine(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
q = Query()
existing = routine_db.get((q.id == id) & ((q.user_id == user_id) | (q.user_id == None)))
if not existing:
return jsonify({'error': 'Routine not found'}), 404
routine = Routine.from_dict(existing)
data = request.get_json(force=True) or {}
is_dirty = False
if 'name' in data:
name = data.get('name', '').strip()
if not name:
return jsonify({'error': 'Name cannot be empty'}), 400
routine.name = name
is_dirty = True
if 'points' in data:
points = data.get('points')
if not isinstance(points, int) or points <= 0:
return jsonify({'error': 'Points must be a positive integer'}), 400
routine.points = points
is_dirty = True
if 'image_id' in data:
routine.image_id = data.get('image_id', '')
is_dirty = True
if not is_dirty:
return jsonify({'error': 'No valid fields to update'}), 400
if routine.user_id is None:
new_routine = Routine(name=routine.name, points=routine.points, image_id=routine.image_id, user_id=user_id)
routine_db.insert(new_routine.to_dict())
send_event_for_current_user(
Event(EventType.ROUTINE_MODIFIED.value, RoutineModified(new_routine.id, RoutineModified.OPERATION_ADD))
)
return jsonify(new_routine.to_dict()), 200
routine_db.update(routine.to_dict(), q.id == id)
send_event_for_current_user(
Event(EventType.ROUTINE_MODIFIED.value, RoutineModified(id, RoutineModified.OPERATION_EDIT))
)
return jsonify(routine.to_dict()), 200
@routine_api.route('/routine/<id>', methods=['DELETE'])
def delete_routine(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
q = Query()
routine = routine_db.get(q.id == id)
if not routine:
return jsonify({'error': 'Routine not found'}), 404
if routine.get('user_id') is None:
return jsonify({'error': 'System routines cannot be deleted.'}), 403
removed = routine_db.remove((q.id == id) & (q.user_id == user_id))
if not removed:
return jsonify({'error': 'Routine not found'}), 404
delete_overrides_for_entity(id)
delete_for_routine(id)
delete_schedules_for_routine(id)
delete_extensions_for_routine(id)
pending_q = Query()
pending_confirmations_db.remove(
(pending_q.entity_id == id) & (pending_q.entity_type == 'routine') & (pending_q.user_id == user_id)
)
child_q = Query()
children = child_db.search(child_q.user_id == user_id)
for child in children:
routine_ids = child.get('routines', [])
if id in routine_ids:
routine_ids = [rid for rid in routine_ids if rid != id]
child_db.update({'routines': routine_ids}, child_q.id == child.get('id'))
send_event_for_current_user(
Event(EventType.CHILD_ROUTINES_SET.value, ChildRoutinesSet(child.get('id'), routine_ids))
)
send_event_for_current_user(
Event(EventType.ROUTINE_MODIFIED.value, RoutineModified(id, RoutineModified.OPERATION_DELETE))
)
return jsonify({'message': f'Routine {id} deleted.'}), 200
+126
View File
@@ -0,0 +1,126 @@
from flask import Blueprint, request, jsonify
from tinydb import Query
from api.utils import get_validated_user_id, send_event_for_current_user
from db.db import routine_db
from db.routine_items import add_item, delete_item, get_item, get_items_for_routine, update_item
from events.types.event import Event
from events.types.event_types import EventType
from events.types.routine_modified import RoutineModified
from models.routine_item import RoutineItem
routine_item_api = Blueprint('routine_item_api', __name__)
def _validate_routine_owned_by_user(routine_id: str, user_id: str):
q = Query()
return routine_db.get((q.id == routine_id) & ((q.user_id == user_id) | (q.user_id == None)))
@routine_item_api.route('/routine/<routine_id>/item/add', methods=['PUT'])
def add_routine_item(routine_id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
routine = _validate_routine_owned_by_user(routine_id, user_id)
if not routine:
return jsonify({'error': 'Routine not found'}), 404
data = request.get_json() or {}
name = data.get('name', '').strip()
image_id = data.get('image_id')
if not name:
return jsonify({'error': 'name is required'}), 400
existing_items = get_items_for_routine(routine_id)
order = data.get('order', len(existing_items))
item = RoutineItem(routine_id=routine_id, name=name, image_id=image_id, order=order)
add_item(item)
send_event_for_current_user(
Event(EventType.ROUTINE_MODIFIED.value, RoutineModified(routine_id, RoutineModified.OPERATION_EDIT))
)
return jsonify(item.to_dict()), 201
@routine_item_api.route('/routine/<routine_id>/item/<item_id>/edit', methods=['PUT'])
def edit_routine_item(routine_id, item_id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
routine = _validate_routine_owned_by_user(routine_id, user_id)
if not routine:
return jsonify({'error': 'Routine not found'}), 404
existing = get_item(item_id)
if not existing or existing.routine_id != routine_id:
return jsonify({'error': 'Item not found'}), 404
data = request.get_json(force=True) or {}
is_dirty = False
if 'name' in data:
name = data.get('name', '').strip()
if not name:
return jsonify({'error': 'name cannot be empty'}), 400
existing.name = name
is_dirty = True
if 'image_id' in data:
existing.image_id = data.get('image_id')
is_dirty = True
if 'order' in data:
order = data.get('order')
if not isinstance(order, int) or order < 0:
return jsonify({'error': 'order must be a non-negative integer'}), 400
existing.order = order
is_dirty = True
if not is_dirty:
return jsonify({'error': 'No valid fields to update'}), 400
update_item(existing)
send_event_for_current_user(
Event(EventType.ROUTINE_MODIFIED.value, RoutineModified(routine_id, RoutineModified.OPERATION_EDIT))
)
return jsonify(existing.to_dict()), 200
@routine_item_api.route('/routine/<routine_id>/item/<item_id>', methods=['DELETE'])
def delete_routine_item(routine_id, item_id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
routine = _validate_routine_owned_by_user(routine_id, user_id)
if not routine:
return jsonify({'error': 'Routine not found'}), 404
item = get_item(item_id)
if not item or item.routine_id != routine_id:
return jsonify({'error': 'Item not found'}), 404
delete_item(item_id)
send_event_for_current_user(
Event(EventType.ROUTINE_MODIFIED.value, RoutineModified(routine_id, RoutineModified.OPERATION_EDIT))
)
return jsonify({'message': 'Item deleted'}), 200
@routine_item_api.route('/routine/<routine_id>/items', methods=['GET'])
def list_routine_items(routine_id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
routine = _validate_routine_owned_by_user(routine_id, user_id)
if not routine:
return jsonify({'error': 'Routine not found'}), 404
items = [item.to_dict() for item in get_items_for_routine(routine_id)]
return jsonify({'items': items, 'count': len(items)}), 200
+178
View File
@@ -0,0 +1,178 @@
from flask import Blueprint, request, jsonify
from tinydb import Query
from api.error_codes import ErrorCodes
from api.utils import get_validated_user_id, send_event_for_current_user
from db.db import child_db, pending_confirmations_db
from db.routine_extensions import add_extension, delete_extension_for_child_routine, get_extension
from db.routine_schedules import delete_schedule, get_schedule, upsert_schedule
from events.types.child_routine_confirmation import ChildRoutineConfirmation
from events.types.event import Event
from events.types.event_types import EventType
from events.types.routine_schedule_modified import RoutineScheduleModified
from events.types.routine_time_extended import RoutineTimeExtended
from models.routine_extension import RoutineExtension
from models.routine_schedule import RoutineSchedule
routine_schedule_api = Blueprint('routine_schedule_api', __name__)
def _validate_child(child_id: str, user_id: str):
q = Query()
result = child_db.search((q.id == child_id) & (q.user_id == user_id))
return result[0] if result else None
@routine_schedule_api.route('/child/<child_id>/routine/<routine_id>/schedule', methods=['GET'])
def get_routine_schedule(child_id, routine_id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
if not _validate_child(child_id, user_id):
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
schedule = get_schedule(child_id, routine_id)
if not schedule:
return jsonify({'error': 'Schedule not found'}), 404
return jsonify(schedule.to_dict()), 200
@routine_schedule_api.route('/child/<child_id>/routine/<routine_id>/schedule', methods=['PUT'])
def set_routine_schedule(child_id, routine_id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
if not _validate_child(child_id, user_id):
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
data = request.get_json() or {}
mode = data.get('mode')
if mode not in ('days', 'interval'):
return jsonify({'error': 'mode must be "days" or "interval"', 'code': ErrorCodes.INVALID_VALUE}), 400
enabled = data.get('enabled', True)
if not isinstance(enabled, bool):
return jsonify({'error': 'enabled must be a boolean', 'code': ErrorCodes.INVALID_VALUE}), 400
if mode == 'days':
day_configs = data.get('day_configs', [])
if not isinstance(day_configs, list):
return jsonify({'error': 'day_configs must be a list', 'code': ErrorCodes.INVALID_VALUE}), 400
schedule = RoutineSchedule(
child_id=child_id,
routine_id=routine_id,
mode='days',
day_configs=day_configs,
default_hour=data.get('default_hour', 8),
default_minute=data.get('default_minute', 0),
default_has_deadline=data.get('default_has_deadline', True),
enabled=enabled,
)
else:
interval_days = data.get('interval_days', 2)
anchor_date = data.get('anchor_date', '')
interval_has_deadline = data.get('interval_has_deadline', True)
interval_hour = data.get('interval_hour', 0)
interval_minute = data.get('interval_minute', 0)
if not isinstance(interval_days, int) or not (1 <= interval_days <= 7):
return jsonify({'error': 'interval_days must be an integer between 1 and 7', 'code': ErrorCodes.INVALID_VALUE}), 400
schedule = RoutineSchedule(
child_id=child_id,
routine_id=routine_id,
mode='interval',
interval_days=interval_days,
anchor_date=anchor_date,
interval_has_deadline=interval_has_deadline,
interval_hour=interval_hour,
interval_minute=interval_minute,
enabled=enabled,
)
delete_extension_for_child_routine(child_id, routine_id)
upsert_schedule(schedule)
pending_q = Query()
pending_routines = pending_confirmations_db.search(
(pending_q.child_id == child_id) & (pending_q.entity_id == routine_id) &
(pending_q.entity_type == 'routine') & (pending_q.status == 'pending')
)
for _ in pending_routines:
pending_confirmations_db.remove(
(pending_q.child_id == child_id) & (pending_q.entity_id == routine_id) &
(pending_q.entity_type == 'routine') & (pending_q.status == 'pending')
)
send_event_for_current_user(
Event(
EventType.CHILD_ROUTINE_CONFIRMATION.value,
ChildRoutineConfirmation(child_id, routine_id, ChildRoutineConfirmation.OPERATION_RESET)
)
)
send_event_for_current_user(
Event(
EventType.ROUTINE_SCHEDULE_MODIFIED.value,
RoutineScheduleModified(child_id, routine_id, RoutineScheduleModified.OPERATION_SET)
)
)
return jsonify(schedule.to_dict()), 200
@routine_schedule_api.route('/child/<child_id>/routine/<routine_id>/schedule', methods=['DELETE'])
def delete_routine_schedule(child_id, routine_id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
if not _validate_child(child_id, user_id):
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
removed = delete_schedule(child_id, routine_id)
if not removed:
return jsonify({'error': 'Schedule not found'}), 404
send_event_for_current_user(
Event(
EventType.ROUTINE_SCHEDULE_MODIFIED.value,
RoutineScheduleModified(child_id, routine_id, RoutineScheduleModified.OPERATION_DELETED)
)
)
return jsonify({'message': 'Schedule deleted'}), 200
@routine_schedule_api.route('/child/<child_id>/routine/<routine_id>/extend', methods=['POST'])
def extend_routine_time(child_id, routine_id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
if not _validate_child(child_id, user_id):
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
data = request.get_json() or {}
date = data.get('date')
if not date or not isinstance(date, str):
return jsonify({'error': 'date is required (ISO date string)', 'code': ErrorCodes.MISSING_FIELD}), 400
existing = get_extension(child_id, routine_id, date)
if existing:
return jsonify({'error': 'Routine already extended for this date', 'code': 'ALREADY_EXTENDED'}), 409
delete_extension_for_child_routine(child_id, routine_id)
extension = RoutineExtension(child_id=child_id, routine_id=routine_id, date=date)
add_extension(extension)
send_event_for_current_user(
Event(
EventType.ROUTINE_TIME_EXTENDED.value,
RoutineTimeExtended(child_id, routine_id)
)
)
return jsonify(extension.to_dict()), 200
+33
View File
@@ -49,6 +49,8 @@ def get_profile():
'image_id': user.image_id, 'image_id': user.image_id,
'email_digest_enabled': user.email_digest_enabled, 'email_digest_enabled': user.email_digest_enabled,
'push_notifications_enabled': user.push_notifications_enabled, 'push_notifications_enabled': user.push_notifications_enabled,
'tutorial_enabled': user.tutorial_enabled,
'tutorial_progress': user.tutorial_progress or {},
}), 200 }), 200
@user_api.route('/user/profile', methods=['PUT']) @user_api.route('/user/profile', methods=['PUT'])
@@ -109,6 +111,37 @@ def update_profile():
return jsonify({'message': 'Profile updated'}), 200 return jsonify({'message': 'Profile updated'}), 200
@user_api.route('/user/tutorial-progress', methods=['PATCH'])
def update_tutorial_progress():
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
user = get_current_user()
if not user:
return jsonify({'error': 'Unauthorized'}), 401
data = request.get_json() or {}
if data.get('reset') is True:
user.tutorial_progress = {}
elif 'enabled' in data:
user.tutorial_enabled = bool(data.get('enabled'))
elif 'step_id' in data:
step_id = str(data.get('step_id') or '').strip()
if not step_id:
return jsonify({'error': 'Missing step_id'}), 400
progress = dict(user.tutorial_progress or {})
progress[step_id] = bool(data.get('seen', True))
user.tutorial_progress = progress
else:
return jsonify({'error': 'No-op'}), 400
users_db.update(user.to_dict(), UserQuery.email == user.email)
send_event_for_current_user(Event(EventType.PROFILE_UPDATED.value, ProfileUpdated(user.id)))
return jsonify({
'tutorial_enabled': user.tutorial_enabled,
'tutorial_progress': user.tutorial_progress,
}), 200
@user_api.route('/user/image', methods=['PUT']) @user_api.route('/user/image', methods=['PUT'])
def update_image(): def update_image():
user_id = get_validated_user_id() user_id = get_validated_user_id()
+1 -1
View File
@@ -2,7 +2,7 @@
# file: config/version.py # file: config/version.py
import os import os
BASE_VERSION = "1.0.13" # update manually when releasing features BASE_VERSION = "1.0.16" # update manually when releasing features
def get_full_version() -> str: def get_full_version() -> str:
""" """
+501 -32
View File
@@ -1,13 +1,42 @@
# python # python
import os import os
from config.paths import get_database_dir
import threading import threading
from config.paths import get_database_dir
from tinydb import TinyDB from tinydb import TinyDB
from tinydb.queries import QueryInstance
from db.mongo_client import get_mongo_client, get_mongo_db_name
try:
from tinydb.table import Document
except ImportError: # pragma: no cover - tinydb version compatibility
from tinydb.database import Document
def _stable_clause_key(clause: dict) -> str:
"""Return a stable string key for sorting MongoDB filter clauses."""
import json
return json.dumps(clause, sort_keys=True, default=str)
try:
from pymongo import ASCENDING
except ImportError: # pragma: no cover - pymongo is a required dependency
ASCENDING = 1
USE_MONGODB = os.environ.get('USE_MONGODB', 'true').lower() == 'true'
# Resolve the MongoDB database name once at module load so runtime changes to
# DB_ENV/DATA_ENV in tests do not switch databases mid-process.
_mongo_db_name = get_mongo_db_name() if USE_MONGODB else None
base_dir = get_database_dir() base_dir = get_database_dir()
os.makedirs(base_dir, exist_ok=True) os.makedirs(base_dir, exist_ok=True)
# ---------------------------------------------------------------------------
# TinyDB-backed table wrapper
# ---------------------------------------------------------------------------
class LockedTable: class LockedTable:
""" """
Thread-safe wrapper around a TinyDB table. All callable attribute access Thread-safe wrapper around a TinyDB table. All callable attribute access
@@ -65,10 +94,369 @@ class LockedTable:
with self._lock: with self._lock:
return self._table.truncate() return self._table.truncate()
def close(self):
with self._lock:
return self._table.close()
# ---------------------------------------------------------------------------
# TinyDB Query -> MongoDB filter translator
# ---------------------------------------------------------------------------
_MONGO_OP_MAP = {
'==': '$eq',
'!=': '$ne',
'<': '$lt',
'<=': '$lte',
'>': '$gt',
'>=': '$gte',
}
_NEGATED_OPS = {
'$eq': '$ne',
'$ne': '$eq',
'$lt': '$gte',
'$lte': '$gt',
'$gt': '$lte',
'$gte': '$lt',
}
def _field_to_mongo(field_path: tuple) -> str:
"""Map a TinyDB field path to a MongoDB field name.
The model ``id`` field is stored as the MongoDB ``_id`` field, so queries
on ``id`` are translated to queries on ``_id``.
"""
if len(field_path) == 1:
return '_id' if field_path[0] == 'id' else field_path[0]
return '.'.join('_id' if p == 'id' else p for p in field_path)
def _negate_condition(cond: dict) -> dict | None:
"""Return a MongoDB condition that negates a single-field condition."""
if len(cond) != 1:
return None
field, inner = next(iter(cond.items()))
if not isinstance(inner, dict) or len(inner) != 1:
return None
op, value = next(iter(inner.items()))
if op in _NEGATED_OPS:
return {field: {_NEGATED_OPS[op]: value}}
return None
def _hash_to_mongo_filter(query_hash) -> dict | None:
"""Translate a TinyDB query hash tuple to a MongoDB filter document.
Returns ``None`` when the query construct cannot be expressed as a native
MongoDB filter, signalling that the caller should fall back to in-memory
TinyDB evaluation.
"""
if not isinstance(query_hash, tuple) or len(query_hash) == 0:
return None
op = query_hash[0]
if op in _MONGO_OP_MAP:
field = _field_to_mongo(query_hash[1])
value = query_hash[2]
return {field: {_MONGO_OP_MAP[op]: value}}
if op == 'exists':
field = _field_to_mongo(query_hash[1])
return {field: {'$exists': True}}
if op == 'one_of':
field = _field_to_mongo(query_hash[1])
return {field: {'$in': list(query_hash[2])}}
if op == 'any':
field = _field_to_mongo(query_hash[1])
return {field: {'$in': list(query_hash[2])}}
if op == 'all':
field = _field_to_mongo(query_hash[1])
return {field: {'$all': list(query_hash[2])}}
if op == 'matches':
field = _field_to_mongo(query_hash[1])
# TinyDB matches() anchors the regex at the start of the string.
return {field: {'$regex': f'^{query_hash[2]}'}}
if op == 'search':
field = _field_to_mongo(query_hash[1])
return {field: {'$regex': query_hash[2]}}
if op == 'and':
merged: dict = {}
for sub_hash in query_hash[1]:
sub = _hash_to_mongo_filter(sub_hash)
if sub is None:
return None
for field, inner in sub.items():
if field in merged:
if isinstance(merged[field], dict) and isinstance(inner, dict):
merged[field].update(inner)
elif isinstance(merged[field], list) and isinstance(inner, list):
merged[field].extend(inner)
else:
return None
else:
merged[field] = (
dict(inner) if isinstance(inner, dict)
else list(inner) if isinstance(inner, list)
else inner
)
return merged
if op == 'or':
clauses = [_hash_to_mongo_filter(sub_hash) for sub_hash in query_hash[1]]
if any(c is None for c in clauses):
return None
return {'$or': sorted(clauses, key=_stable_clause_key)}
if op == 'not':
inner = _hash_to_mongo_filter(query_hash[1])
if inner is None:
return None
negated = _negate_condition(inner)
if negated is not None:
return negated
return None
# Unsupported operation (test, fragment, noop, etc.) -> fall back.
return None
def _query_to_mongo_filter(query) -> dict | None:
"""Translate a TinyDB QueryInstance to a MongoDB filter, if possible."""
if isinstance(query, dict):
return query
if isinstance(query, QueryInstance):
return _hash_to_mongo_filter(query._hash)
return None
def _evaluate_in_memory(docs, query) -> list:
"""Evaluate a TinyDB query against an in-memory list of documents."""
if callable(query):
return [doc for doc in docs if query(doc)]
return docs
# ---------------------------------------------------------------------------
# MongoDB-backed table wrapper
# ---------------------------------------------------------------------------
class MongoLockedTable:
"""Drop-in replacement for ``LockedTable`` that delegates to MongoDB.
The adapter preserves the existing table API while mapping the model
``id`` field to MongoDB's ``_id`` field on reads and writes.
"""
def __init__(self, collection_name: str):
self.collection_name = collection_name
def _collection(self):
client = get_mongo_client()
return client[_mongo_db_name][self.collection_name]
@staticmethod
def _doc_to_mongo(doc: dict) -> dict:
"""Store a copy of ``doc`` with ``id`` promoted to MongoDB ``_id``.
This avoids storing both ``_id`` and ``id`` with identical values.
The original ``id`` field is removed from the stored document.
"""
if doc is None:
return None
d = dict(doc)
if 'id' in d:
d['_id'] = d.pop('id')
return d
@staticmethod
def _doc_from_mongo(doc: dict):
"""Return a TinyDB-compatible Document with ``doc_id`` set to ``_id``.
Restores the model ``id`` field from MongoDB's ``_id`` and exposes
TinyDB's ``doc_id`` attribute so callers that rely on it continue to
work.
"""
if doc is None:
return None
d = dict(doc)
doc_id = d.pop('_id', None)
if doc_id is not None:
d['id'] = doc_id
return Document(d, doc_id=doc_id)
def _mongo_filter(self, cond):
"""Translate a TinyDB query or dict to a MongoDB filter."""
return _query_to_mongo_filter(cond)
def all(self):
return [self._doc_from_mongo(doc) for doc in self._collection().find({})]
def search(self, cond):
mongo_filter = self._mongo_filter(cond)
if mongo_filter is not None:
cursor = self._collection().find(mongo_filter)
return [self._doc_from_mongo(doc) for doc in cursor]
# Fallback: fetch all and evaluate the TinyDB query in Python.
docs = list(self._collection().find({}))
matched = _evaluate_in_memory(
[self._doc_from_mongo(doc) for doc in docs], cond
)
return matched
def get(self, cond):
mongo_filter = self._mongo_filter(cond)
if mongo_filter is not None:
doc = self._collection().find_one(mongo_filter)
return self._doc_from_mongo(doc)
docs = list(self._collection().find({}))
for doc in docs:
d = self._doc_from_mongo(doc)
if callable(cond) and cond(d):
return d
return None
def insert(self, document: dict):
doc = self._doc_to_mongo(document)
result = self._collection().insert_one(doc)
return str(result.inserted_id)
def insert_multiple(self, documents: list):
if not documents:
return []
docs = [self._doc_to_mongo(d) for d in documents]
result = self._collection().insert_many(docs)
return [str(iid) for iid in result.inserted_ids]
def update(self, fields, cond=None, doc_ids=None):
is_callable = callable(fields)
if doc_ids is not None:
mongo_filter = {'_id': {'$in': list(doc_ids)}}
target_ids = [str(did) for did in doc_ids]
if not target_ids:
return []
if is_callable:
# Fetch, apply callable in-memory, and replace each document.
updated_ids = []
for doc in self._collection().find(mongo_filter):
d = self._doc_from_mongo(doc)
fields(d)
new_doc = self._doc_to_mongo(d)
new_doc.pop('_id', None)
self._collection().update_one(
{'_id': doc['_id']}, {'$set': new_doc}
)
updated_ids.append(str(doc['_id']))
return updated_ids
update_doc = self._doc_to_mongo(fields) or {}
update_doc.pop('_id', None)
update_doc.pop('id', None)
if update_doc:
self._collection().update_many(mongo_filter, {'$set': update_doc})
return target_ids
mongo_filter = self._mongo_filter(cond)
if mongo_filter is not None and not is_callable:
update_doc = self._doc_to_mongo(fields) or {}
update_doc.pop('_id', None)
update_doc.pop('id', None)
target_ids = [
str(doc['_id'])
for doc in self._collection().find(mongo_filter, {'_id': 1})
]
if target_ids and update_doc:
self._collection().update_many(
mongo_filter, {'$set': update_doc}
)
return target_ids
# Fallback: evaluate the query in-memory and update one at a time.
docs = list(self._collection().find({}))
updated_ids = []
for doc in docs:
d = self._doc_from_mongo(doc)
match = cond(d) if callable(cond) else (mongo_filter is not None)
if not match:
continue
if is_callable:
fields(d)
new_doc = self._doc_to_mongo(d)
new_doc.pop('_id', None)
self._collection().update_one(
{'_id': doc['_id']}, {'$set': new_doc}
)
else:
update_doc = self._doc_to_mongo(fields) or {}
update_doc.pop('_id', None)
update_doc.pop('id', None)
if update_doc:
self._collection().update_one(
{'_id': doc['_id']}, {'$set': update_doc}
)
updated_ids.append(str(doc['_id']))
return updated_ids
def remove(self, cond):
mongo_filter = self._mongo_filter(cond)
if mongo_filter is not None:
target_ids = [
str(doc['_id'])
for doc in self._collection().find(mongo_filter, {'_id': 1})
]
if target_ids:
self._collection().delete_many(mongo_filter)
return target_ids
# Fallback: evaluate the query in-memory and delete one at a time.
docs = list(self._collection().find({}))
removed_ids = []
for doc in docs:
d = self._doc_from_mongo(doc)
if callable(cond) and cond(d):
self._collection().delete_one({'_id': doc['_id']})
removed_ids.append(str(doc['_id']))
return removed_ids
def truncate(self):
self._collection().delete_many({})
def close(self):
# MongoDB clients are shared and long-lived; nothing to close here.
pass
# ---------------------------------------------------------------------------
# Collection factory
# ---------------------------------------------------------------------------
def _make_table(json_path: str, collection_name: str):
if USE_MONGODB:
return MongoLockedTable(collection_name)
db = TinyDB(json_path, indent=2)
return LockedTable(db)
# Setup DB files next to this module # Setup DB files next to this module
child_path = os.path.join(base_dir, 'children.json') child_path = os.path.join(base_dir, 'children.json')
task_path = os.path.join(base_dir, 'tasks.json') task_path = os.path.join(base_dir, 'tasks.json')
routine_path = os.path.join(base_dir, 'routines.json')
routine_items_path = os.path.join(base_dir, 'routine_items.json')
routine_schedules_path = os.path.join(base_dir, 'routine_schedules.json')
routine_extensions_path = os.path.join(base_dir, 'routine_extensions.json')
reward_path = os.path.join(base_dir, 'rewards.json') reward_path = os.path.join(base_dir, 'rewards.json')
image_path = os.path.join(base_dir, 'images.json') image_path = os.path.join(base_dir, 'images.json')
pending_reward_path = os.path.join(base_dir, 'pending_rewards.json') pending_reward_path = os.path.join(base_dir, 'pending_rewards.json')
@@ -82,41 +470,123 @@ refresh_tokens_path = os.path.join(base_dir, 'refresh_tokens.json')
push_subscriptions_path = os.path.join(base_dir, 'push_subscriptions.json') push_subscriptions_path = os.path.join(base_dir, 'push_subscriptions.json')
digest_action_tokens_path = os.path.join(base_dir, 'digest_action_tokens.json') digest_action_tokens_path = os.path.join(base_dir, 'digest_action_tokens.json')
# Use separate TinyDB instances/files for each collection # Expose table objects backed by TinyDB or MongoDB based on USE_MONGODB
_child_db = TinyDB(child_path, indent=2) child_db = _make_table(child_path, 'children')
_task_db = TinyDB(task_path, indent=2) task_db = _make_table(task_path, 'tasks')
_reward_db = TinyDB(reward_path, indent=2) routine_db = _make_table(routine_path, 'routines')
_image_db = TinyDB(image_path, indent=2) routine_items_db = _make_table(routine_items_path, 'routine_items')
_pending_rewards_db = TinyDB(pending_reward_path, indent=2) routine_schedules_db = _make_table(routine_schedules_path, 'routine_schedules')
_pending_confirmations_db = TinyDB(pending_confirmations_path, indent=2) routine_extensions_db = _make_table(routine_extensions_path, 'routine_extensions')
_users_db = TinyDB(users_path, indent=2) reward_db = _make_table(reward_path, 'rewards')
_tracking_events_db = TinyDB(tracking_events_path, indent=2) image_db = _make_table(image_path, 'images')
_child_overrides_db = TinyDB(child_overrides_path, indent=2) pending_reward_db = _make_table(pending_reward_path, 'pending_rewards')
_chore_schedules_db = TinyDB(chore_schedules_path, indent=2) pending_confirmations_db = _make_table(pending_confirmations_path, 'pending_confirmations')
_task_extensions_db = TinyDB(task_extensions_path, indent=2) users_db = _make_table(users_path, 'users')
_refresh_tokens_db = TinyDB(refresh_tokens_path, indent=2) tracking_events_db = _make_table(tracking_events_path, 'tracking_events')
_push_subscriptions_db = TinyDB(push_subscriptions_path, indent=2) child_overrides_db = _make_table(child_overrides_path, 'child_overrides')
_digest_action_tokens_db = TinyDB(digest_action_tokens_path, indent=2) chore_schedules_db = _make_table(chore_schedules_path, 'chore_schedules')
task_extensions_db = _make_table(task_extensions_path, 'task_extensions')
refresh_tokens_db = _make_table(refresh_tokens_path, 'refresh_tokens')
push_subscriptions_db = _make_table(push_subscriptions_path, 'push_subscriptions')
digest_action_tokens_db = _make_table(digest_action_tokens_path, 'digest_action_tokens')
# Expose table objects wrapped with locking
child_db = LockedTable(_child_db)
task_db = LockedTable(_task_db)
reward_db = LockedTable(_reward_db)
image_db = LockedTable(_image_db)
pending_reward_db = LockedTable(_pending_rewards_db)
pending_confirmations_db = LockedTable(_pending_confirmations_db)
users_db = LockedTable(_users_db)
tracking_events_db = LockedTable(_tracking_events_db)
child_overrides_db = LockedTable(_child_overrides_db)
chore_schedules_db = LockedTable(_chore_schedules_db)
task_extensions_db = LockedTable(_task_extensions_db)
refresh_tokens_db = LockedTable(_refresh_tokens_db)
push_subscriptions_db = LockedTable(_push_subscriptions_db)
digest_action_tokens_db = LockedTable(_digest_action_tokens_db)
# ---------------------------------------------------------------------------
# Index management
# ---------------------------------------------------------------------------
COLLECTION_INDEXES = {
# NOTE: The model ``id`` field is stored as MongoDB's primary key ``_id``,
# so no separate unique index on ``id`` is needed. Only secondary indexes
# for frequently queried fields are defined here.
'children': [
{'keys': [('user_id', ASCENDING)]},
],
'tasks': [
{'keys': [('user_id', ASCENDING)]},
],
'routines': [
{'keys': [('user_id', ASCENDING)]},
],
'routine_items': [
{'keys': [('user_id', ASCENDING)]},
],
'routine_schedules': [],
'routine_extensions': [],
'rewards': [
{'keys': [('user_id', ASCENDING)]},
],
'images': [
{'keys': [('user_id', ASCENDING)]},
],
'pending_rewards': [
{'keys': [('child_id', ASCENDING)]},
],
'pending_confirmations': [
{'keys': [('user_id', ASCENDING)]},
{'keys': [('child_id', ASCENDING)]},
{'keys': [('entity_id', ASCENDING), ('entity_type', ASCENDING)]},
],
'users': [],
'tracking_events': [
{'keys': [('user_id', ASCENDING)]},
{'keys': [('child_id', ASCENDING)]},
{'keys': [('entity_id', ASCENDING), ('entity_type', ASCENDING)]},
],
'child_overrides': [
{'keys': [('child_id', ASCENDING)]},
{'keys': [('entity_id', ASCENDING), ('entity_type', ASCENDING)]},
],
'chore_schedules': [
{'keys': [('user_id', ASCENDING)]},
{'keys': [('child_id', ASCENDING)]},
],
'task_extensions': [
{'keys': [('user_id', ASCENDING)]},
{'keys': [('child_id', ASCENDING)]},
],
'refresh_tokens': [
{'keys': [('user_id', ASCENDING)]},
{'keys': [('token', ASCENDING)], 'unique': True, 'sparse': True},
],
'push_subscriptions': [
{'keys': [('user_id', ASCENDING)]},
],
'digest_action_tokens': [
{'keys': [('user_id', ASCENDING)]},
{'keys': [('token', ASCENDING)], 'unique': True, 'sparse': True},
],
}
def ensure_mongodb_indexes(client=None, db_name=None):
"""Create required indexes on all MongoDB collections.
Safe to call repeatedly: MongoDB treats index creation as idempotent.
"""
if not USE_MONGODB:
return
client = client or get_mongo_client()
db_name = db_name or _mongo_db_name
db = client[db_name]
for collection_name, indexes in COLLECTION_INDEXES.items():
coll = db[collection_name]
for spec in indexes:
keys = spec['keys']
kwargs = {k: v for k, v in spec.items() if k != 'keys'}
coll.create_index(keys, **kwargs)
# Clear test collections at import time so tests start with a clean slate.
if os.environ.get('DB_ENV', 'prod') == 'test': if os.environ.get('DB_ENV', 'prod') == 'test':
child_db.truncate() child_db.truncate()
task_db.truncate() task_db.truncate()
routine_db.truncate()
routine_items_db.truncate()
routine_schedules_db.truncate()
routine_extensions_db.truncate()
reward_db.truncate() reward_db.truncate()
image_db.truncate() image_db.truncate()
pending_reward_db.truncate() pending_reward_db.truncate()
@@ -129,4 +599,3 @@ if os.environ.get('DB_ENV', 'prod') == 'test':
refresh_tokens_db.truncate() refresh_tokens_db.truncate()
push_subscriptions_db.truncate() push_subscriptions_db.truncate()
digest_action_tokens_db.truncate() digest_action_tokens_db.truncate()
+113
View File
@@ -0,0 +1,113 @@
# python
"""Lazy MongoDB client factory and database-name helpers.
The client is intentionally **not** created at module import. Use
``get_mongo_client()`` to obtain a cached singleton. For Gunicorn multi-worker
deployments call ``init_mongo_client()`` from a ``post_fork`` hook so each
worker process owns its own connection pool rather than inheriting the parent
process's client.
"""
import os
import threading
from urllib.parse import urlparse
from pymongo import MongoClient
from pymongo.uri_parser import parse_uri
_mongo_client_lock = threading.Lock()
_mongo_client = None
def _create_mongo_client():
"""Build a fail-fast MongoClient from environment variables."""
uri = os.environ.get('MONGO_URI')
if not uri:
raise RuntimeError(
'MONGO_URI environment variable is required when USE_MONGODB=true.'
)
# mongomock is used for unit/integration tests without a real server.
if uri.lower().startswith('mongomock') or uri.lower() == 'mongomock':
try:
import mongomock
except ImportError as exc: # pragma: no cover - test dependency
raise RuntimeError(
'mongomock is required for test MongoDB mode. '
'Install it with: pip install mongomock'
) from exc
return mongomock.MongoClient()
return MongoClient(
uri,
serverSelectionTimeoutMS=5000,
connectTimeoutMS=5000,
maxPoolSize=20,
)
def init_mongo_client():
"""Create a fresh MongoClient and store it as the process singleton.
Call this from a Gunicorn ``post_fork`` hook so each worker process gets
its own client after forking. It can also be called in tests to reset the
shared client to a known state.
"""
global _mongo_client
with _mongo_client_lock:
_mongo_client = _create_mongo_client()
return _mongo_client
def get_mongo_client():
"""Return the cached process-level MongoClient, creating it lazily once."""
global _mongo_client
if _mongo_client is None:
with _mongo_client_lock:
if _mongo_client is None:
_mongo_client = _create_mongo_client()
return _mongo_client
def _db_name_from_uri(uri: str) -> str | None:
"""Extract the database name from a MongoDB connection URI, if present."""
if not uri or uri.lower().startswith('mongomock'):
return None
try:
parsed = parse_uri(uri)
return parsed.get('database') or None
except Exception:
# Fallback to a simple path-based parse for non-standard URIs.
try:
path = urlparse(uri).path
return path.lstrip('/') or None
except Exception:
return None
def get_mongo_db_name() -> str:
"""Resolve the MongoDB database name from env vars or the connection URI.
Precedence:
1. ``MONGO_DB_NAME`` environment variable.
2. Database name parsed from ``MONGO_URI``.
3. Default based on ``DATA_ENV`` / ``DB_ENV``:
* prod -> ``chore_db``
* e2e -> ``chore_db_e2e``
* test -> ``chore_db_test``
"""
env_name = os.environ.get('MONGO_DB_NAME')
if env_name:
return env_name
uri = os.environ.get('MONGO_URI', '')
db_name = _db_name_from_uri(uri)
if db_name:
return db_name
env = (os.environ.get('DATA_ENV') or os.environ.get('DB_ENV', 'prod')).lower()
if env == 'prod':
return 'chore_db'
if env == 'e2e':
return 'chore_db_e2e'
return 'chore_db_test'
+41
View File
@@ -0,0 +1,41 @@
from tinydb import Query
from db.db import routine_extensions_db
from models.routine_extension import RoutineExtension
def get_extension(child_id: str, routine_id: str, date: str) -> RoutineExtension | None:
q = Query()
result = routine_extensions_db.search(
(q.child_id == child_id) & (q.routine_id == routine_id) & (q.date == date)
)
if not result:
return None
return RoutineExtension.from_dict(result[0])
def add_extension(extension: RoutineExtension) -> None:
routine_extensions_db.insert(extension.to_dict())
def delete_extensions_for_child(child_id: str) -> None:
q = Query()
routine_extensions_db.remove(q.child_id == child_id)
def delete_extensions_for_routine(routine_id: str) -> None:
q = Query()
routine_extensions_db.remove(q.routine_id == routine_id)
def delete_extension_for_child_routine(child_id: str, routine_id: str) -> None:
q = Query()
routine_extensions_db.remove((q.child_id == child_id) & (q.routine_id == routine_id))
def get_extension_for_child_routine(child_id: str, routine_id: str) -> RoutineExtension | None:
q = Query()
results = routine_extensions_db.search((q.child_id == child_id) & (q.routine_id == routine_id))
if not results:
return None
latest = max(results, key=lambda r: r.get('date', ''))
return RoutineExtension.from_dict(latest)
+45
View File
@@ -0,0 +1,45 @@
from tinydb import Query
from db.db import routine_items_db
from models.routine_item import RoutineItem
def add_item(item: RoutineItem) -> None:
routine_items_db.insert(item.to_dict())
def get_item(item_id: str) -> RoutineItem | None:
q = Query()
result = routine_items_db.search(q.id == item_id)
if not result:
return None
return RoutineItem.from_dict(result[0])
def get_items_for_routine(routine_id: str) -> list[RoutineItem]:
q = Query()
results = routine_items_db.search(q.routine_id == routine_id)
items = [RoutineItem.from_dict(r) for r in results]
return sorted(items, key=lambda i: (i.order, i.created_at))
def update_item(item: RoutineItem) -> bool:
q = Query()
existing = routine_items_db.get(q.id == item.id)
if not existing:
return False
routine_items_db.update(item.to_dict(), q.id == item.id)
return True
def delete_item(item_id: str) -> bool:
q = Query()
existing = routine_items_db.get(q.id == item_id)
if not existing:
return False
routine_items_db.remove(q.id == item_id)
return True
def delete_for_routine(routine_id: str) -> None:
q = Query()
routine_items_db.remove(q.routine_id == routine_id)
+42
View File
@@ -0,0 +1,42 @@
from tinydb import Query
from db.db import routine_schedules_db
from models.routine_schedule import RoutineSchedule
def get_schedule(child_id: str, routine_id: str) -> RoutineSchedule | None:
q = Query()
result = routine_schedules_db.search((q.child_id == child_id) & (q.routine_id == routine_id))
if not result:
return None
return RoutineSchedule.from_dict(result[0])
def upsert_schedule(schedule: RoutineSchedule) -> None:
q = Query()
existing = routine_schedules_db.get((q.child_id == schedule.child_id) & (q.routine_id == schedule.routine_id))
if existing:
routine_schedules_db.update(
schedule.to_dict(),
(q.child_id == schedule.child_id) & (q.routine_id == schedule.routine_id)
)
else:
routine_schedules_db.insert(schedule.to_dict())
def delete_schedule(child_id: str, routine_id: str) -> bool:
q = Query()
existing = routine_schedules_db.get((q.child_id == child_id) & (q.routine_id == routine_id))
if not existing:
return False
routine_schedules_db.remove((q.child_id == child_id) & (q.routine_id == routine_id))
return True
def delete_schedules_for_child(child_id: str) -> None:
q = Query()
routine_schedules_db.remove(q.child_id == child_id)
def delete_schedules_for_routine(routine_id: str) -> None:
q = Query()
routine_schedules_db.remove(q.routine_id == routine_id)
+39
View File
@@ -0,0 +1,39 @@
from tinydb import Query
from db.db import routine_db
from models.routine import Routine
def add_routine(routine: Routine) -> None:
routine_db.insert(routine.to_dict())
def get_routine(routine_id: str) -> Routine | None:
q = Query()
result = routine_db.search(q.id == routine_id)
if not result:
return None
return Routine.from_dict(result[0])
def update_routine(routine: Routine) -> bool:
q = Query()
existing = routine_db.get(q.id == routine.id)
if not existing:
return False
routine_db.update(routine.to_dict(), q.id == routine.id)
return True
def delete_routine(routine_id: str) -> bool:
q = Query()
existing = routine_db.get(q.id == routine_id)
if not existing:
return False
routine_db.remove(q.id == routine_id)
return True
def list_routines_for_user(user_id: str) -> list[Routine]:
q = Query()
results = routine_db.search((q.user_id == user_id) | (q.user_id == None))
return [Routine.from_dict(r) for r in results]
+22 -8
View File
@@ -1,4 +1,5 @@
"""Helper functions for tracking events database operations.""" """Helper functions for tracking events database operations."""
import itertools
import logging import logging
from typing import Optional, List from typing import Optional, List
from tinydb import Query from tinydb import Query
@@ -8,6 +9,10 @@ from models.tracking_event import TrackingEvent, EntityType, ActionType
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Monotonic sequence used as a deterministic tiebreaker when tracking events
# share the same ``occurred_at``/``created_at`` timestamps (common in tests).
_tracking_event_seq = itertools.count()
def insert_tracking_event(event: TrackingEvent) -> str: def insert_tracking_event(event: TrackingEvent) -> str:
""" """
@@ -20,7 +25,9 @@ def insert_tracking_event(event: TrackingEvent) -> str:
The event ID The event ID
""" """
try: try:
tracking_events_db.insert(event.to_dict()) event_dict = event.to_dict()
event_dict['_seq'] = next(_tracking_event_seq)
tracking_events_db.insert(event_dict)
logger.info(f"Tracking event created: {event.action} {event.entity_type} {event.entity_id} for child {event.child_id}") logger.info(f"Tracking event created: {event.action} {event.entity_type} {event.entity_id} for child {event.child_id}")
return event.id return event.id
except Exception as e: except Exception as e:
@@ -61,12 +68,16 @@ def get_tracking_events_by_child(
all_results = tracking_events_db.search(query_condition) all_results = tracking_events_db.search(query_condition)
total = len(all_results) total = len(all_results)
# Sort by occurred_at desc, then created_at desc # Sort by occurred_at desc, then created_at desc, then _seq desc for
all_results.sort(key=lambda x: (x.get('occurred_at', ''), x.get('created_at', 0)), reverse=True) # deterministic ordering when timestamps collide (common in fast tests).
all_results.sort(
key=lambda x: (x.get('occurred_at', ''), x.get('created_at', 0), x.get('_seq', 0)),
reverse=True,
)
paginated = all_results[offset:offset + limit] paginated = all_results[offset:offset + limit]
events = [TrackingEvent.from_dict(r) for r in paginated] events = [TrackingEvent.from_dict(r) for r in paginated]
return events, total return events, total
@@ -99,11 +110,14 @@ def get_tracking_events_by_user(
all_results = tracking_events_db.search(query_condition) all_results = tracking_events_db.search(query_condition)
total = len(all_results) total = len(all_results)
all_results.sort(key=lambda x: (x.get('occurred_at', ''), x.get('created_at', 0)), reverse=True) all_results.sort(
key=lambda x: (x.get('occurred_at', ''), x.get('created_at', 0), x.get('_seq', 0)),
reverse=True,
)
paginated = all_results[offset:offset + limit] paginated = all_results[offset:offset + limit]
events = [TrackingEvent.from_dict(r) for r in paginated] events = [TrackingEvent.from_dict(r) for r in paginated]
return events, total return events, total
@@ -0,0 +1,15 @@
from events.types.payload import Payload
class ChildRoutineConfirmation(Payload):
OPERATION_PENDING = "PENDING"
OPERATION_APPROVED = "APPROVED"
OPERATION_REJECTED = "REJECTED"
OPERATION_RESET = "RESET"
def __init__(self, child_id: str, routine_id: str, operation: str):
super().__init__({
'child_id': child_id,
'routine_id': routine_id,
'operation': operation
})
@@ -0,0 +1,9 @@
from events.types.payload import Payload
class ChildRoutinesSet(Payload):
def __init__(self, child_id: str, routine_ids: list[str]):
super().__init__({
'child_id': child_id,
'routine_ids': routine_ids
})
+6
View File
@@ -28,4 +28,10 @@ class EventType(Enum):
CHORE_TIME_EXTENDED = "chore_time_extended" CHORE_TIME_EXTENDED = "chore_time_extended"
CHILD_CHORE_CONFIRMATION = "child_chore_confirmation" CHILD_CHORE_CONFIRMATION = "child_chore_confirmation"
ROUTINE_MODIFIED = "routine_modified"
CHILD_ROUTINES_SET = "child_routines_set"
ROUTINE_SCHEDULE_MODIFIED = "routine_schedule_modified"
ROUTINE_TIME_EXTENDED = "routine_time_extended"
CHILD_ROUTINE_CONFIRMATION = "child_routine_confirmation"
FORCE_LOGOUT = "force_logout" FORCE_LOGOUT = "force_logout"
+13
View File
@@ -0,0 +1,13 @@
from events.types.payload import Payload
class RoutineModified(Payload):
OPERATION_ADD = "ADD"
OPERATION_EDIT = "EDIT"
OPERATION_DELETE = "DELETE"
def __init__(self, routine_id: str, operation: str):
super().__init__({
'routine_id': routine_id,
'operation': operation
})
@@ -0,0 +1,13 @@
from events.types.payload import Payload
class RoutineScheduleModified(Payload):
OPERATION_SET = 'SET'
OPERATION_DELETED = 'DELETED'
def __init__(self, child_id: str, routine_id: str, operation: str):
super().__init__({
'child_id': child_id,
'routine_id': routine_id,
'operation': operation,
})
@@ -0,0 +1,9 @@
from events.types.payload import Payload
class RoutineTimeExtended(Payload):
def __init__(self, child_id: str, routine_id: str):
super().__init__({
'child_id': child_id,
'routine_id': routine_id,
})
+17
View File
@@ -0,0 +1,17 @@
"""Gunicorn configuration for the chore/reward Flask backend.
This file is automatically loaded by Gunicorn when it is started from the
backend directory. It ensures each worker process creates its own MongoDB
client after forking, avoiding shared socket/file-descriptor issues.
"""
def post_fork(server, worker):
"""Reinitialize the MongoDB client in each worker process after forking."""
try:
from db.mongo_client import init_mongo_client
init_mongo_client()
except Exception:
# If MongoDB is not configured (USE_MONGODB=false), there is no client
# to initialize; ignore the error silently.
pass
+17 -1
View File
@@ -2,6 +2,7 @@ import logging
import sys import sys
import os import os
from dotenv import load_dotenv
from flask import Flask, request, jsonify from flask import Flask, request, jsonify
from api.admin_api import admin_api from api.admin_api import admin_api
@@ -14,6 +15,10 @@ from api.image_api import image_api
from api.kindness_api import kindness_api from api.kindness_api import kindness_api
from api.penalty_api import penalty_api from api.penalty_api import penalty_api
from api.reward_api import reward_api from api.reward_api import reward_api
from api.routine_api import routine_api
from api.routine_item_api import routine_item_api
from api.child_routine_api import child_routine_api
from api.routine_schedule_api import routine_schedule_api
from api.task_api import task_api from api.task_api import task_api
from api.tracking_api import tracking_api from api.tracking_api import tracking_api
from api.user_api import user_api from api.user_api import user_api
@@ -22,6 +27,7 @@ from api.digest_action_api import digest_action_api
from config.version import get_full_version from config.version import get_full_version
from db.default import initializeImages, createDefaultTasks, createDefaultRewards from db.default import initializeImages, createDefaultTasks, createDefaultRewards
from db.db import ensure_mongodb_indexes
from events.broadcaster import Broadcaster from events.broadcaster import Broadcaster
from events.sse import sse_response_for_user, send_to_user 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
@@ -30,6 +36,13 @@ from utils.chore_expiry_notification_scheduler import start_chore_expiry_notific
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 from utils.state_expiry_scheduler import start_state_expiry_scheduler
# Load environment variables
load_dotenv()
# Ensure MongoDB indexes exist when running against MongoDB.
ensure_mongodb_indexes()
# Configure logging once at application startup # Configure logging once at application startup
logging.basicConfig( logging.basicConfig(
level=logging.INFO, level=logging.INFO,
@@ -54,6 +67,10 @@ app.register_blueprint(chore_schedule_api)
app.register_blueprint(kindness_api) app.register_blueprint(kindness_api)
app.register_blueprint(penalty_api) app.register_blueprint(penalty_api)
app.register_blueprint(reward_api) app.register_blueprint(reward_api)
app.register_blueprint(routine_api)
app.register_blueprint(routine_item_api)
app.register_blueprint(child_routine_api)
app.register_blueprint(routine_schedule_api)
app.register_blueprint(task_api) app.register_blueprint(task_api)
app.register_blueprint(image_api) app.register_blueprint(image_api)
app.register_blueprint(auth_api, url_prefix='/auth') app.register_blueprint(auth_api, url_prefix='/auth')
@@ -135,7 +152,6 @@ def start_background_threads():
broadcaster.daemon = True broadcaster.daemon = True
broadcaster.start() broadcaster.start()
# TODO: implement users
initializeImages() initializeImages()
createDefaultTasks() createDefaultTasks()
createDefaultRewards() createDefaultRewards()
+3
View File
@@ -6,6 +6,7 @@ class Child(BaseModel):
name: str name: str
age: int | None = None age: int | None = None
tasks: list[str] = field(default_factory=list) tasks: list[str] = field(default_factory=list)
routines: list[str] = field(default_factory=list)
rewards: list[str] = field(default_factory=list) rewards: list[str] = field(default_factory=list)
points: int = 0 points: int = 0
image_id: str | None = None image_id: str | None = None
@@ -17,6 +18,7 @@ class Child(BaseModel):
name=d.get('name'), name=d.get('name'),
age=d.get('age'), age=d.get('age'),
tasks=d.get('tasks', []), tasks=d.get('tasks', []),
routines=d.get('routines', []),
rewards=d.get('rewards', []), rewards=d.get('rewards', []),
points=d.get('points', 0), points=d.get('points', 0),
image_id=d.get('image_id'), image_id=d.get('image_id'),
@@ -32,6 +34,7 @@ class Child(BaseModel):
'name': self.name, 'name': self.name,
'age': self.age, 'age': self.age,
'tasks': self.tasks, 'tasks': self.tasks,
'routines': self.routines,
'rewards': self.rewards, 'rewards': self.rewards,
'points': self.points, 'points': self.points,
'image_id': self.image_id, 'image_id': self.image_id,
+4 -4
View File
@@ -16,15 +16,15 @@ class ChildOverride(BaseModel):
""" """
child_id: str child_id: str
entity_id: str entity_id: str
entity_type: Literal['task', 'reward', 'chore', 'kindness', 'penalty'] entity_type: Literal['task', 'reward', 'chore', 'kindness', 'penalty', 'routine']
custom_value: int custom_value: int
def __post_init__(self): def __post_init__(self):
"""Validate custom_value range and entity_type.""" """Validate custom_value range and entity_type."""
if self.custom_value < 0 or self.custom_value > 10000: if self.custom_value < 0 or self.custom_value > 10000:
raise ValueError("custom_value must be between 0 and 10000") raise ValueError("custom_value must be between 0 and 10000")
if self.entity_type not in ['task', 'reward', 'chore', 'kindness', 'penalty']: if self.entity_type not in ['task', 'reward', 'chore', 'kindness', 'penalty', 'routine']:
raise ValueError("entity_type must be 'task', 'reward', 'chore', 'kindness', or 'penalty'") raise ValueError("entity_type must be 'task', 'reward', 'chore', 'kindness', 'penalty', or 'routine'")
@classmethod @classmethod
def from_dict(cls, d: dict): def from_dict(cls, d: dict):
@@ -52,7 +52,7 @@ class ChildOverride(BaseModel):
def create_override( def create_override(
child_id: str, child_id: str,
entity_id: str, entity_id: str,
entity_type: Literal['task', 'reward', 'chore', 'kindness', 'penalty'], entity_type: Literal['task', 'reward', 'chore', 'kindness', 'penalty', 'routine'],
custom_value: int custom_value: int
) -> 'ChildOverride': ) -> 'ChildOverride':
"""Factory method to create a new override.""" """Factory method to create a new override."""
+1 -1
View File
@@ -3,7 +3,7 @@ from typing import Literal, Optional
from models.base import BaseModel from models.base import BaseModel
PendingEntityType = Literal['chore', 'reward'] PendingEntityType = Literal['chore', 'reward', 'routine']
PendingStatus = Literal['pending', 'approved', 'rejected'] PendingStatus = Literal['pending', 'approved', 'rejected']
+3
View File
@@ -9,6 +9,7 @@ class RefreshToken(BaseModel):
token_family: str = '' token_family: str = ''
expires_at: str = '' expires_at: str = ''
is_used: bool = False is_used: bool = False
rotated_at: str | None = None
def to_dict(self): def to_dict(self):
return { return {
@@ -18,6 +19,7 @@ class RefreshToken(BaseModel):
'token_family': self.token_family, 'token_family': self.token_family,
'expires_at': self.expires_at, 'expires_at': self.expires_at,
'is_used': self.is_used, 'is_used': self.is_used,
'rotated_at': self.rotated_at,
} }
@staticmethod @staticmethod
@@ -31,4 +33,5 @@ class RefreshToken(BaseModel):
token_family=data.get('token_family', ''), token_family=data.get('token_family', ''),
expires_at=data.get('expires_at', ''), expires_at=data.get('expires_at', ''),
is_used=data.get('is_used', False), is_used=data.get('is_used', False),
rotated_at=data.get('rotated_at'),
) )
+32
View File
@@ -0,0 +1,32 @@
from dataclasses import dataclass
from models.base import BaseModel
@dataclass
class Routine(BaseModel):
name: str
points: int
image_id: str | None = None
user_id: str | None = None
@classmethod
def from_dict(cls, d: dict):
return cls(
name=d.get('name'),
points=d.get('points', 0),
image_id=d.get('image_id'),
user_id=d.get('user_id'),
id=d.get('id'),
created_at=d.get('created_at'),
updated_at=d.get('updated_at')
)
def to_dict(self):
base = super().to_dict()
base.update({
'name': self.name,
'points': self.points,
'image_id': self.image_id,
'user_id': self.user_id
})
return base
+29
View File
@@ -0,0 +1,29 @@
from dataclasses import dataclass
from models.base import BaseModel
@dataclass
class RoutineExtension(BaseModel):
child_id: str
routine_id: str
date: str
@classmethod
def from_dict(cls, d: dict) -> 'RoutineExtension':
return cls(
child_id=d.get('child_id'),
routine_id=d.get('routine_id'),
date=d.get('date'),
id=d.get('id'),
created_at=d.get('created_at'),
updated_at=d.get('updated_at'),
)
def to_dict(self) -> dict:
base = super().to_dict()
base.update({
'child_id': self.child_id,
'routine_id': self.routine_id,
'date': self.date,
})
return base
+32
View File
@@ -0,0 +1,32 @@
from dataclasses import dataclass
from models.base import BaseModel
@dataclass
class RoutineItem(BaseModel):
routine_id: str
name: str
image_id: str | None = None
order: int = 0
@classmethod
def from_dict(cls, d: dict):
return cls(
routine_id=d.get('routine_id'),
name=d.get('name'),
image_id=d.get('image_id'),
order=d.get('order', 0),
id=d.get('id'),
created_at=d.get('created_at'),
updated_at=d.get('updated_at')
)
def to_dict(self):
base = super().to_dict()
base.update({
'routine_id': self.routine_id,
'name': self.name,
'image_id': self.image_id,
'order': self.order
})
return base
+63
View File
@@ -0,0 +1,63 @@
from dataclasses import dataclass, field
from typing import Literal
from models.base import BaseModel
@dataclass
class RoutineSchedule(BaseModel):
child_id: str
routine_id: str
mode: Literal['days', 'interval']
day_configs: list = field(default_factory=list)
default_hour: int = 8
default_minute: int = 0
default_has_deadline: bool = True
interval_days: int = 2
anchor_date: str = ""
interval_has_deadline: bool = True
interval_hour: int = 0
interval_minute: int = 0
enabled: bool = True
@classmethod
def from_dict(cls, d: dict) -> 'RoutineSchedule':
return cls(
child_id=d.get('child_id'),
routine_id=d.get('routine_id'),
mode=d.get('mode', 'days'),
day_configs=d.get('day_configs', []),
default_hour=d.get('default_hour', 8),
default_minute=d.get('default_minute', 0),
default_has_deadline=d.get('default_has_deadline', True),
interval_days=d.get('interval_days', 2),
anchor_date=d.get('anchor_date', ''),
interval_has_deadline=d.get('interval_has_deadline', True),
interval_hour=d.get('interval_hour', 0),
interval_minute=d.get('interval_minute', 0),
enabled=d.get('enabled', True),
id=d.get('id'),
created_at=d.get('created_at'),
updated_at=d.get('updated_at'),
)
def to_dict(self) -> dict:
base = super().to_dict()
base.update({
'child_id': self.child_id,
'routine_id': self.routine_id,
'mode': self.mode,
'day_configs': self.day_configs,
'default_hour': self.default_hour,
'default_minute': self.default_minute,
'default_has_deadline': self.default_has_deadline,
'interval_days': self.interval_days,
'anchor_date': self.anchor_date,
'interval_has_deadline': self.interval_has_deadline,
'interval_hour': self.interval_hour,
'interval_minute': self.interval_minute,
'enabled': self.enabled,
})
return base
+6
View File
@@ -25,6 +25,8 @@ class User(BaseModel):
timezone: str | None = None timezone: str | None = None
email_digest_enabled: bool = True email_digest_enabled: bool = True
push_notifications_enabled: bool = True push_notifications_enabled: bool = True
tutorial_enabled: bool = True
tutorial_progress: dict = field(default_factory=dict)
@classmethod @classmethod
def from_dict(cls, d: dict): def from_dict(cls, d: dict):
@@ -51,6 +53,8 @@ class User(BaseModel):
timezone=d.get('timezone'), timezone=d.get('timezone'),
email_digest_enabled=d.get('email_digest_enabled', True), email_digest_enabled=d.get('email_digest_enabled', True),
push_notifications_enabled=d.get('push_notifications_enabled', True), push_notifications_enabled=d.get('push_notifications_enabled', True),
tutorial_enabled=d.get('tutorial_enabled', True),
tutorial_progress=d.get('tutorial_progress', {}) or {},
id=d.get('id'), id=d.get('id'),
created_at=d.get('created_at'), created_at=d.get('created_at'),
updated_at=d.get('updated_at') updated_at=d.get('updated_at')
@@ -82,5 +86,7 @@ class User(BaseModel):
'timezone': self.timezone, 'timezone': self.timezone,
'email_digest_enabled': self.email_digest_enabled, 'email_digest_enabled': self.email_digest_enabled,
'push_notifications_enabled': self.push_notifications_enabled, 'push_notifications_enabled': self.push_notifications_enabled,
'tutorial_enabled': self.tutorial_enabled,
'tutorial_progress': self.tutorial_progress,
}) })
return base return base
Binary file not shown.
+203
View File
@@ -0,0 +1,203 @@
# python
"""
Migrate existing TinyDB JSON files into MongoDB.
Usage:
cd backend
python -m scripts.migrate_to_mongodb [--dry-run]
The script reads files from ``data/db/`` (or ``test_data/db/`` when
``DB_ENV=test``), maps each record's ``id`` field to MongoDB's ``_id`` field,
and inserts the records idempotently. TinyDB files are backed up to
``<db_dir>/backups/<timestamp>/`` before the first migration run.
"""
import argparse
import json
import os
import shutil
import sys
from datetime import datetime
from pathlib import Path
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
from config.paths import get_database_dir
from db.db import COLLECTION_INDEXES, ensure_mongodb_indexes
from db.mongo_client import get_mongo_client, get_mongo_db_name
# Map TinyDB JSON filenames to MongoDB collection names.
COLLECTION_FILE_MAP = {
'children.json': 'children',
'tasks.json': 'tasks',
'routines.json': 'routines',
'routine_items.json': 'routine_items',
'routine_schedules.json': 'routine_schedules',
'routine_extensions.json': 'routine_extensions',
'rewards.json': 'rewards',
'images.json': 'images',
'pending_rewards.json': 'pending_rewards',
'pending_confirmations.json': 'pending_confirmations',
'users.json': 'users',
'tracking_events.json': 'tracking_events',
'child_overrides.json': 'child_overrides',
'chore_schedules.json': 'chore_schedules',
'task_extensions.json': 'task_extensions',
'refresh_tokens.json': 'refresh_tokens',
'push_subscriptions.json': 'push_subscriptions',
'digest_action_tokens.json': 'digest_action_tokens',
}
def _load_tinydb_records(path: str) -> list[dict]:
"""Load all records from a TinyDB JSON file."""
with open(path, 'r', encoding='utf-8') as f:
data = json.load(f)
default_table = data.get('_default', {})
return list(default_table.values())
def _doc_to_mongo(doc: dict) -> dict:
"""Map the model ``id`` field to MongoDB's ``_id`` field.
The original ``id`` field is removed so documents do not store both
``_id`` and ``id`` with identical values.
"""
mongo_doc = dict(doc)
if 'id' in mongo_doc:
mongo_doc['_id'] = mongo_doc.pop('id')
return mongo_doc
def migrate(dry_run: bool = False) -> dict:
"""Migrate TinyDB files to MongoDB and return a per-collection summary."""
db_dir = get_database_dir()
if not os.path.isdir(db_dir):
raise FileNotFoundError(f'Database directory does not exist: {db_dir}')
client = get_mongo_client()
db_name = get_mongo_db_name()
db = client[db_name]
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
backup_dir = os.path.join(db_dir, 'backups', timestamp)
if not dry_run:
os.makedirs(backup_dir, exist_ok=True)
ensure_mongodb_indexes(client=client, db_name=db_name)
summary: dict[str, dict] = {}
for filename, collection_name in COLLECTION_FILE_MAP.items():
path = os.path.join(db_dir, filename)
if not os.path.exists(path):
summary[collection_name] = {
'source_file': filename,
'total': 0,
'migrated': 0,
'skipped': 0,
'status': 'missing',
}
continue
records = _load_tinydb_records(path)
if not dry_run:
shutil.copy2(path, backup_dir)
collection = db[collection_name]
to_insert: list[dict] = []
skipped = 0
for record in records:
doc_id = record.get('id')
if not doc_id:
skipped += 1
continue
if not dry_run:
existing = collection.find_one({'_id': doc_id})
if existing:
skipped += 1
continue
to_insert.append(_doc_to_mongo(record))
if not dry_run and to_insert:
try:
collection.insert_many(to_insert, ordered=False)
except Exception as exc: # pragma: no cover - defensive
print(
f' Warning: error inserting into {collection_name}: {exc}',
file=sys.stderr,
)
raise
summary[collection_name] = {
'source_file': filename,
'total': len(records),
'migrated': len(to_insert),
'skipped': skipped,
'status': 'migrated' if not dry_run else 'dry-run',
}
return summary
def _print_summary(summary: dict) -> None:
"""Print a human-readable migration summary."""
print('\nMigration Summary')
print('-' * 70)
print(f'{"Collection":<30}{"Total":>8}{"Migrated":>10}{"Skipped":>10}{"Status":>10}')
print('-' * 70)
total_records = 0
total_migrated = 0
total_skipped = 0
for collection_name, info in summary.items():
print(
f'{collection_name:<30}'
f'{info["total"]:>8}'
f'{info["migrated"]:>10}'
f'{info["skipped"]:>10}'
f'{info["status"]:>10}'
)
total_records += info['total']
total_migrated += info['migrated']
total_skipped += info['skipped']
print('-' * 70)
print(
f'{"TOTAL":<30}'
f'{total_records:>8}'
f'{total_migrated:>10}'
f'{total_skipped:>10}'
)
def main():
parser = argparse.ArgumentParser(
description='Migrate TinyDB JSON files to MongoDB.'
)
parser.add_argument(
'--dry-run',
action='store_true',
help='Analyze files and report counts without writing to MongoDB.',
)
args = parser.parse_args()
if os.environ.get('USE_MONGODB', 'true').lower() != 'true':
print('Set USE_MONGODB=true to run the migration.', file=sys.stderr)
sys.exit(1)
if not os.environ.get('MONGO_URI'):
print('MONGO_URI is required when USE_MONGODB=true.', file=sys.stderr)
sys.exit(1)
if args.dry_run:
print('Dry run: no data will be written to MongoDB.')
summary = migrate(dry_run=args.dry_run)
_print_summary(summary)
if __name__ == '__main__':
main()
+79
View File
@@ -0,0 +1,79 @@
<#
.SYNOPSIS
Run the MongoDB adapter integration tests against a local Docker MongoDB container.
.DESCRIPTION
Starts a temporary MongoDB container, runs a targeted pytest suite with
USE_MONGODB=true, then stops and removes the container.
.EXAMPLE
cd backend
.\scripts\run_integration_tests.ps1
#>
[CmdletBinding()]
param(
[string]$ContainerName = 'chore-db-integration-test',
[int]$HostPort = 27017,
[string]$Image = 'mongo:8',
[string]$DbName = 'chore_db_test',
[string]$TestPath = 'tests/test_mongo_adapter.py'
)
$ErrorActionPreference = 'Stop'
$mongoUri = "mongodb://localhost:${HostPort}/${DbName}"
function Test-ContainerRunning {
$containers = docker ps --filter "name=$ContainerName" --format '{{.Names}}' 2>$null
return $containers -contains $ContainerName
}
function Wait-MongoReady {
param([int]$TimeoutSeconds = 30)
$start = Get-Date
while (((Get-Date) - $start).TotalSeconds -lt $TimeoutSeconds) {
try {
$null = docker exec $ContainerName mongosh --eval 'db.adminCommand({ ping: 1 })' --quiet 2>$null
if ($LASTEXITCODE -eq 0) {
return
}
} catch {
# Container or mongosh may not be ready yet.
}
Start-Sleep -Seconds 1
}
throw "MongoDB container did not become ready within ${TimeoutSeconds} seconds."
}
# Clean up any leftover container from a previous aborted run.
if (Test-ContainerRunning) {
Write-Host "Removing existing container '$ContainerName'..."
docker rm -f $ContainerName | Out-Null
}
Write-Host "Starting MongoDB container '$ContainerName' on port $HostPort..."
docker run -d `
--name $ContainerName `
-p "${HostPort}:27017" `
$Image | Out-Null
try {
Wait-MongoReady
Write-Host "MongoDB is ready. Running integration tests..."
$env:USE_MONGODB = 'true'
$env:MONGO_URI = $mongoUri
$env:MONGO_DB_NAME = $DbName
$env:DB_ENV = 'test'
$env:DATA_ENV = 'test'
pytest $TestPath
if ($LASTEXITCODE -ne 0) {
throw "Integration tests failed with exit code $LASTEXITCODE."
}
} finally {
Write-Host "Stopping and removing container '$ContainerName'..."
docker rm -f $ContainerName | Out-Null
}
Write-Host "Integration tests complete."
+9 -1
View File
@@ -1,5 +1,7 @@
import os import os
os.environ['DB_ENV'] = 'test' os.environ['DB_ENV'] = 'test'
os.environ['USE_MONGODB'] = 'true'
os.environ['MONGO_URI'] = 'mongomock'
os.environ.setdefault('SECRET_KEY', 'test-secret-key') os.environ.setdefault('SECRET_KEY', 'test-secret-key')
os.environ.setdefault('REFRESH_TOKEN_EXPIRY_DAYS', '90') os.environ.setdefault('REFRESH_TOKEN_EXPIRY_DAYS', '90')
os.environ.setdefault('DIGEST_TOKEN_SECRET', 'test-digest-secret') os.environ.setdefault('DIGEST_TOKEN_SECRET', 'test-digest-secret')
@@ -18,5 +20,11 @@ TEST_REFRESH_TOKEN_EXPIRY_DAYS = 90
@pytest.fixture(scope="session", autouse=True) @pytest.fixture(scope="session", autouse=True)
def set_test_db_env(): def set_test_db_env():
os.environ['DB_ENV'] = 'test' os.environ['DB_ENV'] = 'test'
os.environ['USE_MONGODB'] = 'true'
os.environ['MONGO_URI'] = 'mongomock'
os.environ['SECRET_KEY'] = TEST_SECRET_KEY os.environ['SECRET_KEY'] = TEST_SECRET_KEY
os.environ['REFRESH_TOKEN_EXPIRY_DAYS'] = str(TEST_REFRESH_TOKEN_EXPIRY_DAYS) os.environ['REFRESH_TOKEN_EXPIRY_DAYS'] = str(TEST_REFRESH_TOKEN_EXPIRY_DAYS)
# Ensure indexes are created once for the test session. This is safe to
# call repeatedly because MongoDB treats index creation as idempotent.
from db.db import ensure_mongodb_indexes
ensure_mongodb_indexes()
+244 -3
View File
@@ -1,11 +1,11 @@
import pytest import pytest
from werkzeug.security import generate_password_hash, check_password_hash from werkzeug.security import generate_password_hash, check_password_hash
from flask import Flask from flask import Flask
from api.auth_api import auth_api from api.auth_api import auth_api, _hash_token
from db.db import users_db, refresh_tokens_db from db.db import users_db, refresh_tokens_db
from tinydb import Query from tinydb import Query
from models.user import User from models.user import User
from datetime import datetime from datetime import datetime, timedelta, timezone
from tests.conftest import TEST_SECRET_KEY, TEST_REFRESH_TOKEN_EXPIRY_DAYS from tests.conftest import TEST_SECRET_KEY, TEST_REFRESH_TOKEN_EXPIRY_DAYS
@pytest.fixture @pytest.fixture
@@ -188,4 +188,245 @@ def test_migration_script_hashes_plain_text_passwords():
# Check user2 password unchanged # Check user2 password unchanged
user2_dict = users_db.get(Query().email == 'test2@example.com') user2_dict = users_db.get(Query().email == 'test2@example.com')
assert user2_dict['password'] == already_hashed assert user2_dict['password'] == already_hashed
def _extract_cookie_value(response, cookie_name):
"""Extract a raw cookie value from a response's Set-Cookie headers."""
for cookie in response.headers.getlist('Set-Cookie'):
if cookie.startswith(f'{cookie_name}='):
return cookie.split(f'{cookie_name}=', 1)[1].split(';', 1)[0]
return None
def _create_verified_user(email, password):
"""Insert a verified user with the given credentials."""
users_db.remove(Query().email == email)
user = User(
first_name='Test',
last_name='User',
email=email,
password=generate_password_hash(password),
verified=True,
)
users_db.insert(user.to_dict())
return user
def _set_refresh_cookie(client, raw_token):
"""
Set the refresh token cookie on a test client so it is sent to /auth/refresh.
Production uses path='/api/auth' because the frontend calls /api/auth/refresh,
but the test fixture exposes the blueprint at /auth/refresh directly.
"""
client.set_cookie('refresh_token', raw_token, path='/')
def test_refresh_rotates_token(client):
"""A successful refresh marks the old token used and issues a new one in the same family."""
email = 'refresh-rotate@test.com'
password = 'password123'
_create_verified_user(email, password)
login_response = client.post('/auth/login', json={'email': email, 'password': password})
assert login_response.status_code == 200
old_refresh = _extract_cookie_value(login_response, 'refresh_token')
assert old_refresh
_set_refresh_cookie(client, old_refresh)
refresh_response = client.post('/auth/refresh')
assert refresh_response.status_code == 200
new_refresh = _extract_cookie_value(refresh_response, 'refresh_token')
assert new_refresh
assert new_refresh != old_refresh
user_dict = users_db.get(Query().email == email)
old_hash = _hash_token(old_refresh)
new_hash = _hash_token(new_refresh)
old_record = refresh_tokens_db.get(Query().token_hash == old_hash)
assert old_record is not None
assert old_record['is_used'] is True
assert old_record['rotated_at'] is not None
new_record = refresh_tokens_db.get(Query().token_hash == new_hash)
assert new_record is not None
assert new_record['is_used'] is False
assert new_record['token_family'] == old_record['token_family']
def test_refresh_reuse_only_invalidates_family(client):
"""Replay of a used refresh token only kills its own family, not other devices."""
email = 'refresh-family@test.com'
password = 'password123'
user = _create_verified_user(email, password)
# Device A logs in
client_a = client
login_a = client_a.post('/auth/login', json={'email': email, 'password': password})
assert login_a.status_code == 200
refresh_a = _extract_cookie_value(login_a, 'refresh_token')
# Device B logs in (separate client = separate cookie jar)
app = client_a.application
client_b = app.test_client()
login_b = client_b.post('/auth/login', json={'email': email, 'password': password})
assert login_b.status_code == 200
refresh_b = _extract_cookie_value(login_b, 'refresh_token')
assert refresh_a != refresh_b
# Device A refreshes normally
_set_refresh_cookie(client_a, refresh_a)
refresh_a_response = client_a.post('/auth/refresh')
assert refresh_a_response.status_code == 200
# Capture families before any purge so we can assert afterwards.
family_a = refresh_tokens_db.get(Query().token_hash == _hash_token(refresh_a))['token_family']
family_b = refresh_tokens_db.get(Query().token_hash == _hash_token(refresh_b))['token_family']
assert family_a != family_b
# Backdate rotation so the replay is past the grace period and treated as theft.
old_hash_a = _hash_token(refresh_a)
backdated = (datetime.now(timezone.utc) - timedelta(seconds=60)).isoformat()
refresh_tokens_db.update({'rotated_at': backdated}, Query().token_hash == old_hash_a)
# Attacker replays device A's old token
_set_refresh_cookie(client_a, refresh_a)
reuse_response = client_a.post('/auth/refresh')
assert reuse_response.status_code == 401
assert reuse_response.json['code'] == 'REFRESH_TOKEN_REUSE'
# Device B's refresh token should still be valid
_set_refresh_cookie(client_b, refresh_b)
refresh_b_response = client_b.post('/auth/refresh')
assert refresh_b_response.status_code == 200
# Only family A should be purged; family B should remain
remaining = refresh_tokens_db.search(Query().user_id == user.id)
remaining_families = {t['token_family'] for t in remaining}
assert family_a not in remaining_families
assert family_b in remaining_families
def test_refresh_reuse_within_grace_period_is_tolerated(client):
"""A replay within the grace period is treated as a race condition, not theft."""
email = 'refresh-race@test.com'
password = 'password123'
_create_verified_user(email, password)
login_response = client.post('/auth/login', json={'email': email, 'password': password})
assert login_response.status_code == 200
refresh_token = _extract_cookie_value(login_response, 'refresh_token')
# First refresh marks the token as used
_set_refresh_cookie(client, refresh_token)
first_refresh = client.post('/auth/refresh')
assert first_refresh.status_code == 200
# Immediate replay (same legitimate client racing) should succeed
_set_refresh_cookie(client, refresh_token)
race_response = client.post('/auth/refresh')
assert race_response.status_code == 200
# The family should still be valid
user_dict = users_db.get(Query().email == email)
family = refresh_tokens_db.get(Query().token_hash == _hash_token(refresh_token))['token_family']
family_tokens = refresh_tokens_db.search(
(Query().user_id == user_dict['id']) & (Query().token_family == family)
)
assert len(family_tokens) >= 1
assert any(t['is_used'] is False for t in family_tokens)
def test_refresh_reuse_after_grace_period_invalidates_family(client):
"""A replay after the grace period is treated as theft and kills only that family."""
email = 'refresh-theft@test.com'
password = 'password123'
user = _create_verified_user(email, password)
login_response = client.post('/auth/login', json={'email': email, 'password': password})
assert login_response.status_code == 200
refresh_token = _extract_cookie_value(login_response, 'refresh_token')
# Refresh once, then backdate the rotation timestamp past the grace period
_set_refresh_cookie(client, refresh_token)
client.post('/auth/refresh')
old_hash = _hash_token(refresh_token)
old_record = refresh_tokens_db.get(Query().token_hash == old_hash)
old_family = old_record['token_family']
backdated = (datetime.now(timezone.utc) - timedelta(seconds=60)).isoformat()
refresh_tokens_db.update({'rotated_at': backdated}, Query().token_hash == old_hash)
# Replay now should be detected as theft
_set_refresh_cookie(client, refresh_token)
reuse_response = client.post('/auth/refresh')
assert reuse_response.status_code == 401
assert reuse_response.json['code'] == 'REFRESH_TOKEN_REUSE'
remaining = refresh_tokens_db.search(Query().user_id == user.id)
remaining_families = {t['token_family'] for t in remaining}
assert old_family not in remaining_families
def test_refresh_reuse_without_rotated_at_invalidates_family(client):
"""Legacy used tokens without rotated_at are treated as theft, not race conditions."""
email = 'refresh-legacy@test.com'
password = 'password123'
user = _create_verified_user(email, password)
login_response = client.post('/auth/login', json={'email': email, 'password': password})
assert login_response.status_code == 200
refresh_token = _extract_cookie_value(login_response, 'refresh_token')
# Rotate the token, then strip rotated_at to simulate pre-migration data
_set_refresh_cookie(client, refresh_token)
client.post('/auth/refresh')
old_hash = _hash_token(refresh_token)
refresh_tokens_db.update({'rotated_at': None}, Query().token_hash == old_hash)
old_record = refresh_tokens_db.get(Query().token_hash == old_hash)
old_family = old_record['token_family']
# Replay should be treated as theft because rotated_at is missing
_set_refresh_cookie(client, refresh_token)
reuse_response = client.post('/auth/refresh')
assert reuse_response.status_code == 401
assert reuse_response.json['code'] == 'REFRESH_TOKEN_REUSE'
remaining = refresh_tokens_db.search(Query().user_id == user.id)
remaining_families = {t['token_family'] for t in remaining}
assert old_family not in remaining_families
def test_refresh_reuse_with_zero_grace_period(client):
"""A grace period of 0 means any replay of a used token is treated as theft."""
email = 'refresh-zero-grace@test.com'
password = 'password123'
user = _create_verified_user(email, password)
# Configure the app with a 0-second grace period
client.application.config['REFRESH_TOKEN_REUSE_GRACE_PERIOD_SECONDS'] = 0
login_response = client.post('/auth/login', json={'email': email, 'password': password})
assert login_response.status_code == 200
refresh_token = _extract_cookie_value(login_response, 'refresh_token')
# Rotate the token; rotated_at is within the normal default grace period
_set_refresh_cookie(client, refresh_token)
client.post('/auth/refresh')
old_hash = _hash_token(refresh_token)
old_record = refresh_tokens_db.get(Query().token_hash == old_hash)
old_family = old_record['token_family']
# Immediate replay should still be theft with a 0-second grace period
_set_refresh_cookie(client, refresh_token)
reuse_response = client.post('/auth/refresh')
assert reuse_response.status_code == 401
assert reuse_response.json['code'] == 'REFRESH_TOKEN_REUSE'
remaining = refresh_tokens_db.search(Query().user_id == user.id)
remaining_families = {t['token_family'] for t in remaining}
assert old_family not in remaining_families
+219 -1
View File
@@ -4,13 +4,14 @@ import os
from flask import Flask from flask import Flask
from api.child_api import child_api from api.child_api import child_api
import api.child_api as child_api_module
from api.auth_api import auth_api from api.auth_api import auth_api
from db.db import child_db, reward_db, task_db, users_db, chore_schedules_db, task_extensions_db, pending_confirmations_db from db.db import child_db, reward_db, task_db, users_db, chore_schedules_db, task_extensions_db, pending_confirmations_db
from tinydb import Query from tinydb import Query
from models.child import Child from models.child import Child
import jwt import jwt
from werkzeug.security import generate_password_hash from werkzeug.security import generate_password_hash
from datetime import date as date_type from datetime import date as date_type, datetime, timedelta, timezone
# Test user credentials # Test user credentials
@@ -382,6 +383,7 @@ def _setup_sched_child_and_tasks(task_db, child_db):
}) })
chore_schedules_db.remove(Query().child_id == CHILD_SCHED_ID) chore_schedules_db.remove(Query().child_id == CHILD_SCHED_ID)
task_extensions_db.remove(Query().child_id == CHILD_SCHED_ID) task_extensions_db.remove(Query().child_id == CHILD_SCHED_ID)
pending_confirmations_db.remove(Query().child_id == CHILD_SCHED_ID)
def test_list_child_tasks_always_has_schedule_and_extension_date_keys(client): def test_list_child_tasks_always_has_schedule_and_extension_date_keys(client):
@@ -517,6 +519,146 @@ def test_list_child_tasks_no_server_side_filtering(client):
assert extra_id in returned_ids assert extra_id in returned_ids
def test_list_child_tasks_shows_pending_for_today(client):
"""A chore confirmed today should return pending_status='pending'."""
_setup_sched_child_and_tasks(task_db, child_db)
now_ts = datetime.now(timezone.utc).timestamp()
pending_confirmations_db.insert({
'id': 'pend_today_chore',
'child_id': CHILD_SCHED_ID,
'entity_id': TASK_GOOD_ID,
'entity_type': 'chore',
'user_id': 'testuserid',
'status': 'pending',
'approved_at': None,
'created_at': now_ts,
'updated_at': now_ts,
})
resp = client.get(f'/child/{CHILD_SCHED_ID}/list-tasks')
assert resp.status_code == 200
tasks = {t['id']: t for t in resp.get_json()['tasks']}
assert tasks[TASK_GOOD_ID]['pending_status'] == 'pending'
assert tasks[TASK_GOOD_ID]['approved_at'] is None
def test_list_child_tasks_clears_stale_approved_and_pending(client):
"""Yesterday's chore pending/approved records should be reset and ignored."""
_setup_sched_child_and_tasks(task_db, child_db)
old_ts = (datetime.now(timezone.utc) - timedelta(days=2)).timestamp()
old_approved = (datetime.now(timezone.utc) - timedelta(days=2)).isoformat()
pending_confirmations_db.insert({
'id': 'pend_old_chore_pending',
'child_id': CHILD_SCHED_ID,
'entity_id': TASK_GOOD_ID,
'entity_type': 'chore',
'user_id': 'testuserid',
'status': 'pending',
'approved_at': None,
'created_at': old_ts,
'updated_at': old_ts,
})
resp = client.get(f'/child/{CHILD_SCHED_ID}/list-tasks')
assert resp.status_code == 200
tasks = {t['id']: t for t in resp.get_json()['tasks']}
assert tasks[TASK_GOOD_ID]['pending_status'] is None
assert tasks[TASK_GOOD_ID]['approved_at'] is None
# Reinsert as stale approved and ensure it is also cleared.
pending_confirmations_db.insert({
'id': 'pend_old_chore_approved',
'child_id': CHILD_SCHED_ID,
'entity_id': TASK_GOOD_ID,
'entity_type': 'chore',
'user_id': 'testuserid',
'status': 'approved',
'approved_at': old_approved,
'created_at': old_ts,
'updated_at': old_ts,
})
resp2 = client.get(f'/child/{CHILD_SCHED_ID}/list-tasks')
assert resp2.status_code == 200
tasks2 = {t['id']: t for t in resp2.get_json()['tasks']}
assert tasks2[TASK_GOOD_ID]['pending_status'] is None
assert tasks2[TASK_GOOD_ID]['approved_at'] is None
def test_list_child_tasks_local_day_keeps_approved_across_utc_rollover(client, monkeypatch):
"""Approved chore should remain completed when UTC date differs but user-local day matches.
Example: 2026-05-11T22:30:00Z is 2026-05-12 local day in Pacific/Kiritimati (UTC+14).
"""
_setup_sched_child_and_tasks(task_db, child_db)
# Force deterministic local-day basis for this endpoint call.
monkeypatch.setattr(
child_api_module,
'_get_user_today_local',
lambda user_id: ('2026-05-12', 'Pacific/Kiritimati'),
)
pending_confirmations_db.insert({
'id': 'pend_local_day_approved',
'child_id': CHILD_SCHED_ID,
'entity_id': TASK_GOOD_ID,
'entity_type': 'chore',
'user_id': 'testuserid',
'status': 'approved',
'approved_at': '2026-05-11T22:30:00+00:00',
'created_at': 1778538600,
'updated_at': 1778538600,
})
resp = client.get(f'/child/{CHILD_SCHED_ID}/list-tasks')
assert resp.status_code == 200
tasks = {t['id']: t for t in resp.get_json()['tasks']}
assert tasks[TASK_GOOD_ID]['pending_status'] == 'approved'
assert tasks[TASK_GOOD_ID]['approved_at'] == '2026-05-11T22:30:00+00:00'
def test_confirm_chore_allows_when_previous_pending_is_stale(client):
"""A stale pending chore record from a prior day must not block confirm-chore."""
old_ts = (datetime.now(timezone.utc) - timedelta(days=2)).timestamp()
task_db.insert({'id': 't_stale_chore', 'name': 'Stale Chore', 'points': 2, 'type': 'chore', 'user_id': 'testuserid'})
child_db.insert({
'id': 'child_stale_chore',
'name': 'Stale Chore Kid',
'age': 8,
'points': 0,
'tasks': ['t_stale_chore'],
'rewards': [],
'user_id': 'testuserid',
})
pending_confirmations_db.insert({
'id': 'pend_stale_chore',
'child_id': 'child_stale_chore',
'entity_id': 't_stale_chore',
'entity_type': 'chore',
'user_id': 'testuserid',
'status': 'pending',
'approved_at': None,
'created_at': old_ts,
'updated_at': old_ts,
})
resp = client.post('/child/child_stale_chore/confirm-chore', json={'task_id': 't_stale_chore'})
assert resp.status_code == 200
active_pending = pending_confirmations_db.search(
(Query().child_id == 'child_stale_chore') & (Query().entity_id == 't_stale_chore') &
(Query().entity_type == 'chore') & (Query().status == 'pending')
)
assert len(active_pending) == 1
assert active_pending[0].get('id') != 'pend_stale_chore'
pending_confirmations_db.remove(Query().child_id == 'child_stale_chore')
child_db.remove(Query().id == 'child_stale_chore')
task_db.remove(Query().id == 't_stale_chore')
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# request-reward: duplicate guard # request-reward: duplicate guard
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -547,6 +689,82 @@ def test_request_reward_duplicate_returns_409(client):
reward_db.remove(Query().id == 'r_dup') reward_db.remove(Query().id == 'r_dup')
def test_request_reward_allows_new_when_stale_pending_exists(client):
"""A stale pending reward from a prior day must not block a new request."""
old_ts = (datetime.now(timezone.utc) - timedelta(days=2)).timestamp()
reward_db.insert({'id': 'r_stale', 'name': 'Stale Reward', 'cost': 5, 'user_id': 'testuserid'})
child_db.insert({
'id': 'child_stale',
'name': 'Stale Kid',
'age': 8,
'points': 20,
'tasks': [],
'rewards': ['r_stale'],
'user_id': 'testuserid',
})
pending_confirmations_db.insert({
'id': 'pend_stale_reward',
'child_id': 'child_stale',
'entity_id': 'r_stale',
'entity_type': 'reward',
'user_id': 'testuserid',
'status': 'pending',
'approved_at': None,
'created_at': old_ts,
'updated_at': old_ts,
})
resp = client.post('/child/child_stale/request-reward', json={'reward_id': 'r_stale'})
assert resp.status_code == 200
active_pending = pending_confirmations_db.search(
(Query().child_id == 'child_stale') & (Query().entity_id == 'r_stale') &
(Query().entity_type == 'reward') & (Query().status == 'pending')
)
assert len(active_pending) == 1
assert active_pending[0].get('id') != 'pend_stale_reward'
pending_confirmations_db.remove(Query().child_id == 'child_stale')
child_db.remove(Query().id == 'child_stale')
reward_db.remove(Query().id == 'r_stale')
def test_reward_status_ignores_stale_pending_reward(client):
"""reward-status should not mark a reward as redeeming if pending is stale."""
old_ts = (datetime.now(timezone.utc) - timedelta(days=2)).timestamp()
reward_db.insert({'id': 'r_status_stale', 'name': 'Status Reward', 'cost': 4, 'user_id': 'testuserid'})
child_db.insert({
'id': 'child_status_stale',
'name': 'Status Kid',
'age': 9,
'points': 10,
'tasks': [],
'rewards': ['r_status_stale'],
'user_id': 'testuserid',
})
pending_confirmations_db.insert({
'id': 'pend_status_stale',
'child_id': 'child_status_stale',
'entity_id': 'r_status_stale',
'entity_type': 'reward',
'user_id': 'testuserid',
'status': 'pending',
'approved_at': None,
'created_at': old_ts,
'updated_at': old_ts,
})
resp = client.get('/child/child_status_stale/reward-status')
assert resp.status_code == 200
statuses = {s['id']: s for s in resp.get_json()['reward_status']}
assert statuses['r_status_stale']['redeeming'] is False
assert pending_confirmations_db.get(Query().id == 'pend_status_stale') is None
pending_confirmations_db.remove(Query().child_id == 'child_status_stale')
child_db.remove(Query().id == 'child_status_stale')
reward_db.remove(Query().id == 'r_status_stale')
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# deny-reward-request endpoint # deny-reward-request endpoint
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+19
View File
@@ -3,6 +3,7 @@ from tests.conftest import TEST_SECRET_KEY, TEST_REFRESH_TOKEN_EXPIRY_DAYS
import os import os
from werkzeug.security import generate_password_hash from werkzeug.security import generate_password_hash
from datetime import date as date_type from datetime import date as date_type
import api.child_api as child_api_module
from flask import Flask from flask import Flask
from api.child_api import child_api from api.child_api import child_api
@@ -71,6 +72,24 @@ def setup_child_and_chore(child_name='TestChild', age=8, chore_points=10):
return child['id'], 'chore1' return child['id'], 'chore1'
def test_local_day_iso_check_handles_utc_rollover():
# 22:30 UTC on 2026-05-11 is 12:30 local on 2026-05-12 in Pacific/Kiritimati (UTC+14).
assert child_api_module._is_iso_timestamp_on_local_day(
'2026-05-11T22:30:00+00:00',
'2026-05-12',
'Pacific/Kiritimati',
)
def test_local_day_epoch_check_handles_utc_rollover():
# Same instant as above represented as epoch seconds.
assert child_api_module._is_epoch_timestamp_on_local_day(
1778538600,
'2026-05-12',
'Pacific/Kiritimati',
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Child Confirm Flow # Child Confirm Flow
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+201
View File
@@ -0,0 +1,201 @@
import os
import pytest
from tinydb import Query
from db.db import (
MongoLockedTable,
_query_to_mongo_filter,
child_db,
task_db,
users_db,
refresh_tokens_db,
)
from db.mongo_client import get_mongo_client, get_mongo_db_name
# All tests in this module require the mongomock-backed MongoDB adapter.
pytestmark = pytest.mark.skipif(
os.environ.get('USE_MONGODB', 'true').lower() != 'true',
reason='MongoDB adapter tests require USE_MONGODB=true',
)
@pytest.fixture(autouse=True)
def clean_mongo_collections():
"""Truncate relevant collections before each test."""
child_db.truncate()
task_db.truncate()
users_db.truncate()
refresh_tokens_db.truncate()
yield
class TestQueryTranslation:
"""Unit tests for TinyDB Query -> MongoDB filter translation."""
def test_simple_equality(self):
q = Query()
assert _query_to_mongo_filter(q.id == 'abc') == {'_id': {'$eq': 'abc'}}
def test_field_other_than_id(self):
q = Query()
assert _query_to_mongo_filter(q.user_id == 'u1') == {
'user_id': {'$eq': 'u1'}
}
def test_and_query(self):
q = Query()
mongo_filter = _query_to_mongo_filter(
(q.id == 'abc') & (q.user_id == 'u1')
)
assert mongo_filter == {'_id': {'$eq': 'abc'}, 'user_id': {'$eq': 'u1'}}
def test_or_query(self):
q = Query()
mongo_filter = _query_to_mongo_filter(
(q.user_id == 'u1') | (q.user_id == None) # noqa: E711
)
assert mongo_filter == {
'$or': [
{'user_id': {'$eq': 'u1'}},
{'user_id': {'$eq': None}},
]
}
def test_and_with_nested_or(self):
q = Query()
mongo_filter = _query_to_mongo_filter(
(q.id == 'abc') & ((q.user_id == 'u1') | (q.user_id == None)) # noqa: E711
)
assert mongo_filter == {
'_id': {'$eq': 'abc'},
'$or': [
{'user_id': {'$eq': 'u1'}},
{'user_id': {'$eq': None}},
],
}
class TestMongoCrud:
"""CRUD tests against the mongomock-backed MongoLockedTable."""
def test_insert_maps_id_to_underscore_id(self):
child_db.insert({'id': 'c1', 'name': 'Alice', 'age': 8})
raw = get_mongo_client()[get_mongo_db_name()]['children'].find_one(
{'_id': 'c1'}
)
assert raw is not None
assert raw['_id'] == 'c1'
assert 'id' not in raw
assert raw['name'] == 'Alice'
def test_get_returns_document_without_underscore_id(self):
child_db.insert({'id': 'c1', 'name': 'Alice', 'age': 8})
doc = child_db.get(Query().id == 'c1')
assert doc is not None
assert doc['id'] == 'c1'
assert doc['name'] == 'Alice'
assert '_id' not in doc
def test_get_none_when_missing(self):
assert child_db.get(Query().id == 'missing') is None
def test_search_with_query(self):
child_db.insert({'id': 'c1', 'name': 'Alice', 'user_id': 'u1'})
child_db.insert({'id': 'c2', 'name': 'Bob', 'user_id': 'u1'})
child_db.insert({'id': 'c3', 'name': 'Carol', 'user_id': 'u2'})
results = child_db.search(Query().user_id == 'u1')
assert len(results) == 2
assert {r['id'] for r in results} == {'c1', 'c2'}
def test_search_with_or(self):
task_db.insert({'id': 't1', 'name': 'Default', 'user_id': None})
task_db.insert({'id': 't2', 'name': 'User task', 'user_id': 'u1'})
q = Query()
results = task_db.search((q.user_id == 'u1') | (q.user_id == None)) # noqa: E711
assert len(results) == 2
def test_update_modifies_matching_documents(self):
child_db.insert({'id': 'c1', 'name': 'Alice', 'points': 0})
child_db.insert({'id': 'c2', 'name': 'Bob', 'points': 0})
modified = child_db.update({'points': 10}, Query().id == 'c1')
# TinyDB returns a list of updated document ids; the adapter mirrors that.
assert modified == ['c1']
doc = child_db.get(Query().id == 'c1')
assert doc['points'] == 10
other = child_db.get(Query().id == 'c2')
assert other['points'] == 0
def test_update_does_not_overwrite_id(self):
child_db.insert({'id': 'c1', 'name': 'Alice'})
child_db.update({'id': 'c2', 'name': 'Alice Smith'}, Query().id == 'c1')
# The id field must remain unchanged; update should have stripped id.
assert child_db.get(Query().id == 'c1')['name'] == 'Alice Smith'
assert child_db.get(Query().id == 'c2') is None
def test_remove_deletes_matching_documents(self):
child_db.insert({'id': 'c1', 'name': 'Alice'})
child_db.insert({'id': 'c2', 'name': 'Bob'})
deleted = child_db.remove(Query().id == 'c1')
# TinyDB returns a list of removed document ids; the adapter mirrors that.
assert deleted == ['c1']
assert child_db.get(Query().id == 'c1') is None
assert child_db.get(Query().id == 'c2') is not None
def test_all_returns_all_documents(self):
child_db.insert({'id': 'c1', 'name': 'Alice'})
child_db.insert({'id': 'c2', 'name': 'Bob'})
docs = child_db.all()
assert len(docs) == 2
assert all('_id' not in d for d in docs)
def test_truncate_removes_all_documents(self):
child_db.insert({'id': 'c1', 'name': 'Alice'})
child_db.truncate()
assert child_db.all() == []
def test_insert_multiple(self):
ids = child_db.insert_multiple([
{'id': 'c1', 'name': 'Alice'},
{'id': 'c2', 'name': 'Bob'},
])
assert sorted(ids) == ['c1', 'c2']
assert len(child_db.all()) == 2
def test_unique_token_index(self):
refresh_tokens_db.insert({'id': 'r1', 'token': 'abc', 'user_id': 'u1'})
refresh_tokens_db.insert({'id': 'r2', 'token': 'def', 'user_id': 'u1'})
# mongomock does not enforce unique indexes by default, but we verify
# both records are readable.
assert refresh_tokens_db.get(Query().token == 'abc') is not None
assert refresh_tokens_db.get(Query().token == 'def') is not None
def test_user_id_secondary_index_is_created(self):
# Insert and query via the secondary index path used by the app.
users_db.insert({'id': 'u1', 'email': 'a@example.com', 'verified': True})
users_db.insert({'id': 'u2', 'email': 'b@example.com', 'verified': False})
found = users_db.search(Query().verified == True) # noqa: E712
assert len(found) == 1
assert found[0]['id'] == 'u1'
class TestAdapterApi:
"""Tests that the adapter exposes the expected LockedTable-compatible API."""
def test_close_is_noop(self):
# Existing cleanup fixtures call ``*_db.close()``; ensure it does not
# raise for the MongoDB-backed adapter.
child_db.close()
+296
View File
@@ -0,0 +1,296 @@
"""Unit tests for routine API endpoints."""
import pytest
from datetime import datetime, timezone
from tinydb import Query
from models.routine import Routine
from models.routine_item import RoutineItem
from models.routine_schedule import RoutineSchedule
from models.routine_extension import RoutineExtension
from models.pending_confirmation import PendingConfirmation
from api.error_codes import ErrorCodes
from db.db import routine_db, routine_items_db, routine_schedules_db, routine_extensions_db, pending_confirmations_db
class TestRoutineModel:
"""Test Routine model serialization and validation."""
def test_routine_creation(self):
"""Test creating a routine instance."""
routine = Routine(name="Morning Routine", points=50, image_id="img123", user_id="user1")
assert routine.name == "Morning Routine"
assert routine.points == 50
assert routine.image_id == "img123"
assert routine.user_id == "user1"
assert routine.id is not None
def test_routine_to_dict(self):
"""Test routine serialization."""
routine = Routine(name="Test", points=10, image_id=None, user_id="user1")
data = routine.to_dict()
assert data["name"] == "Test"
assert data["points"] == 10
assert data["id"] == routine.id
def test_routine_from_dict(self):
"""Test routine deserialization."""
routine_dict = {"id": "r1", "name": "Test", "points": 20, "image_id": "img1", "user_id": "u1"}
routine = Routine.from_dict(routine_dict)
assert routine.id == "r1"
assert routine.name == "Test"
assert routine.points == 20
class TestRoutineItemModel:
"""Test RoutineItem model."""
def test_routine_item_creation(self):
"""Test creating a routine item."""
item = RoutineItem(routine_id="r1", name="Make Bed", image_id=None, order=0)
assert item.routine_id == "r1"
assert item.name == "Make Bed"
assert item.order == 0
def test_routine_item_to_dict(self):
"""Test item serialization."""
item = RoutineItem(routine_id="r1", name="Get Dressed", image_id="img1", order=1)
data = item.to_dict()
assert data["routine_id"] == "r1"
assert data["name"] == "Get Dressed"
assert data["order"] == 1
class TestRoutineScheduleModel:
"""Test RoutineSchedule model."""
def test_days_mode_schedule_creation(self):
"""Test creating days mode schedule."""
day_configs = [
{"day": 0, "hour": 8, "minute": 0},
{"day": 1, "hour": 9, "minute": 30},
]
schedule = RoutineSchedule(
child_id="c1",
routine_id="r1",
mode="days",
enabled=True,
day_configs=day_configs,
default_hour=8,
default_minute=0,
default_has_deadline=True,
)
assert schedule.mode == "days"
assert len(schedule.day_configs) == 2
assert schedule.enabled is True
def test_interval_mode_schedule_creation(self):
"""Test creating interval mode schedule."""
schedule = RoutineSchedule(
child_id="c1",
routine_id="r1",
mode="interval",
enabled=True,
interval_days=3,
anchor_date="2026-05-01",
interval_has_deadline=True,
interval_hour=10,
interval_minute=30,
)
assert schedule.mode == "interval"
assert schedule.interval_days == 3
assert schedule.interval_hour == 10
class TestRoutineExtensionModel:
"""Test RoutineExtension model."""
def test_extension_creation(self):
"""Test creating a routine extension."""
extension = RoutineExtension(child_id="c1", routine_id="r1", date="2026-05-10")
assert extension.child_id == "c1"
assert extension.routine_id == "r1"
assert extension.date == "2026-05-10"
class TestRoutineDB:
"""Test routine database operations."""
def setup_method(self):
"""Clear routine db before each test."""
routine_db.truncate()
def test_add_routine(self):
"""Test adding a routine to database."""
routine = Routine(name="Test Routine", points=50, image_id=None, user_id="user1")
routine_db.insert(routine.to_dict())
result = routine_db.search(Query().id == routine.id)
assert len(result) == 1
assert result[0]["name"] == "Test Routine"
def test_get_routine_by_id(self):
"""Test fetching routine by ID."""
routine = Routine(name="Fetch Test", points=30, image_id=None, user_id="user1")
routine_db.insert(routine.to_dict())
result = routine_db.get(Query().id == routine.id)
assert result is not None
assert result["name"] == "Fetch Test"
def test_list_user_routines(self):
"""Test listing routines for a user."""
r1 = Routine(name="R1", points=10, image_id=None, user_id="user1")
r2 = Routine(name="R2", points=20, image_id=None, user_id="user1")
r3 = Routine(name="R3", points=15, image_id=None, user_id="user2")
routine_db.insert(r1.to_dict())
routine_db.insert(r2.to_dict())
routine_db.insert(r3.to_dict())
results = routine_db.search(Query().user_id == "user1")
assert len(results) == 2
def test_update_routine(self):
"""Test updating a routine."""
routine = Routine(name="Original", points=50, image_id=None, user_id="user1")
routine_db.insert(routine.to_dict())
routine_db.update({"name": "Updated", "points": 100}, Query().id == routine.id)
result = routine_db.get(Query().id == routine.id)
assert result["name"] == "Updated"
assert result["points"] == 100
class TestRoutineItemDB:
"""Test routine item database operations."""
def setup_method(self):
"""Clear item db before each test."""
routine_items_db.truncate()
def test_add_routine_item(self):
"""Test adding a routine item."""
item = RoutineItem(routine_id="r1", name="Make Bed", image_id=None, order=0)
routine_items_db.insert(item.to_dict())
result = routine_items_db.search(Query().routine_id == "r1")
assert len(result) == 1
assert result[0]["name"] == "Make Bed"
def test_get_items_for_routine(self):
"""Test fetching all items for a routine."""
i1 = RoutineItem(routine_id="r1", name="Item1", image_id=None, order=0)
i2 = RoutineItem(routine_id="r1", name="Item2", image_id=None, order=1)
i3 = RoutineItem(routine_id="r2", name="Item3", image_id=None, order=0)
routine_items_db.insert(i1.to_dict())
routine_items_db.insert(i2.to_dict())
routine_items_db.insert(i3.to_dict())
results = routine_items_db.search(Query().routine_id == "r1")
assert len(results) == 2
def test_delete_item(self):
"""Test deleting a routine item."""
item = RoutineItem(routine_id="r1", name="Test", image_id=None, order=0)
routine_items_db.insert(item.to_dict())
routine_items_db.remove(Query().id == item.id)
result = routine_items_db.search(Query().id == item.id)
assert len(result) == 0
class TestRoutineScheduleDB:
"""Test routine schedule database operations."""
def setup_method(self):
"""Clear schedule db before each test."""
routine_schedules_db.truncate()
def test_upsert_schedule(self):
"""Test upserting a routine schedule."""
day_configs = [{"day": 0, "hour": 8, "minute": 0}]
schedule = RoutineSchedule(
child_id="c1",
routine_id="r1",
mode="days",
enabled=True,
day_configs=day_configs,
default_hour=8,
default_minute=0,
default_has_deadline=True,
)
routine_schedules_db.insert(schedule.to_dict())
result = routine_schedules_db.get(
(Query().child_id == "c1") & (Query().routine_id == "r1")
)
assert result is not None
assert result["mode"] == "days"
def test_delete_schedule(self):
"""Test deleting a schedule."""
schedule = RoutineSchedule(
child_id="c1",
routine_id="r1",
mode="interval",
enabled=True,
interval_days=2,
anchor_date="2026-05-01",
interval_has_deadline=True,
interval_hour=10,
interval_minute=0,
)
routine_schedules_db.insert(schedule.to_dict())
routine_schedules_db.remove(
(Query().child_id == "c1") & (Query().routine_id == "r1")
)
result = routine_schedules_db.search(Query().child_id == "c1")
assert len(result) == 0
class TestPendingRoutineConfirmation:
"""Test pending routine confirmation workflow."""
def setup_method(self):
"""Clear db before each test."""
pending_confirmations_db.truncate()
def test_create_routine_confirmation(self):
"""Test creating a pending routine confirmation."""
confirmation = PendingConfirmation(
child_id="c1",
entity_id="r1",
entity_type="routine",
user_id="u1",
status="pending",
)
pending_confirmations_db.insert(confirmation.to_dict())
result = pending_confirmations_db.get(
(Query().child_id == "c1") & (Query().entity_id == "r1") & (Query().entity_type == "routine")
)
assert result is not None
assert result["status"] == "pending"
def test_approve_routine_confirmation(self):
"""Test approving a routine confirmation."""
confirmation = PendingConfirmation(
child_id="c1",
entity_id="r1",
entity_type="routine",
user_id="u1",
status="pending",
)
pending_confirmations_db.insert(confirmation.to_dict())
today_utc = datetime.now(timezone.utc).isoformat()
pending_confirmations_db.update(
{"status": "approved", "approved_at": today_utc},
Query().id == confirmation.id,
)
result = pending_confirmations_db.get(Query().id == confirmation.id)
assert result["status"] == "approved"
assert result["approved_at"] is not None
+187
View File
@@ -0,0 +1,187 @@
from flask import Flask
from tinydb import Query
from werkzeug.security import generate_password_hash
from tests.conftest import TEST_SECRET_KEY, TEST_REFRESH_TOKEN_EXPIRY_DAYS
from api.auth_api import auth_api
from api.routine_api import routine_api
from api.routine_item_api import routine_item_api
from api.child_routine_api import child_routine_api
from api.routine_schedule_api import routine_schedule_api
from db.db import (
users_db,
child_db,
routine_db,
routine_items_db,
routine_schedules_db,
routine_extensions_db,
pending_confirmations_db,
)
TEST_USER_ID = 'routine-user-1'
TEST_EMAIL = 'routine-user@example.com'
TEST_PASSWORD = 'testpass'
def add_test_user():
users_db.remove(Query().email == TEST_EMAIL)
users_db.insert({
'id': TEST_USER_ID,
'first_name': 'Routine',
'last_name': 'Tester',
'email': TEST_EMAIL,
'password': generate_password_hash(TEST_PASSWORD),
'verified': True,
'image_id': 'boy01',
})
def login_and_set_cookie(client):
resp = client.post('/auth/login', json={'email': TEST_EMAIL, 'password': TEST_PASSWORD})
assert resp.status_code == 200
def seed_child(child_id: str):
child_db.remove(Query().id == child_id)
child_db.insert({
'id': child_id,
'name': 'Routine Kid',
'age': 9,
'tasks': [],
'routines': [],
'rewards': [],
'points': 0,
'image_id': 'boy01',
'user_id': TEST_USER_ID,
})
def _first_routine_id():
routines = routine_db.all()
assert routines
return routines[0]['id']
def _first_confirmation_id():
confirmations = pending_confirmations_db.all()
assert confirmations
return confirmations[0]['id']
def _make_client():
app = Flask(__name__)
app.register_blueprint(auth_api, url_prefix='/auth')
app.register_blueprint(routine_api)
app.register_blueprint(routine_item_api)
app.register_blueprint(child_routine_api)
app.register_blueprint(routine_schedule_api)
app.config['TESTING'] = True
app.config['SECRET_KEY'] = TEST_SECRET_KEY
app.config['REFRESH_TOKEN_EXPIRY_DAYS'] = TEST_REFRESH_TOKEN_EXPIRY_DAYS
return app
def setup_function(_):
routine_db.truncate()
routine_items_db.truncate()
routine_schedules_db.truncate()
routine_extensions_db.truncate()
pending_confirmations_db.truncate()
child_db.truncate()
def test_routine_crud_flow():
app = _make_client()
with app.test_client() as client:
add_test_user()
login_and_set_cookie(client)
add_resp = client.put('/routine/add', json={'name': 'Morning Routine', 'points': 8, 'image_id': 'sun'})
assert add_resp.status_code == 201
rid = _first_routine_id()
list_resp = client.get('/routine/list')
assert list_resp.status_code == 200
routines = list_resp.get_json()['routines']
assert len(routines) == 1
assert routines[0]['name'] == 'Morning Routine'
edit_resp = client.put(f'/routine/{rid}/edit', json={'points': 10})
assert edit_resp.status_code == 200
assert edit_resp.get_json()['points'] == 10
delete_resp = client.delete(f'/routine/{rid}')
assert delete_resp.status_code == 200
assert routine_db.all() == []
def test_child_routine_assignment_confirmation_and_approval():
app = _make_client()
with app.test_client() as client:
add_test_user()
login_and_set_cookie(client)
seed_child('routine-child-1')
client.put('/routine/add', json={'name': 'Evening Routine', 'points': 6, 'image_id': 'moon'})
rid = _first_routine_id()
add_item_resp = client.put(f'/routine/{rid}/item/add', json={'name': 'Brush Teeth', 'order': 0})
assert add_item_resp.status_code == 201
assign_resp = client.post('/child/routine-child-1/assign-routine', json={'routine_id': rid})
assert assign_resp.status_code == 200
list_resp = client.get('/child/routine-child-1/list-routines')
assert list_resp.status_code == 200
routines = list_resp.get_json()['routines']
assert len(routines) == 1
assert routines[0]['id'] == rid
assert routines[0]['items'][0]['name'] == 'Brush Teeth'
confirm_resp = client.post('/child/routine-child-1/confirm-routine', json={'routine_id': rid})
assert confirm_resp.status_code == 200
confirmation_id = _first_confirmation_id()
approve_resp = client.post(f'/child/routine-child-1/approve-routine/{confirmation_id}')
assert approve_resp.status_code == 200
child = child_db.get(Query().id == 'routine-child-1')
assert child['points'] == 6
def test_routine_schedule_and_extend_flow():
app = _make_client()
with app.test_client() as client:
add_test_user()
login_and_set_cookie(client)
seed_child('routine-child-2')
client.put('/routine/add', json={'name': 'School Routine', 'points': 5, 'image_id': 'book'})
rid = _first_routine_id()
client.post('/child/routine-child-2/assign-routine', json={'routine_id': rid})
set_resp = client.put(
f'/child/routine-child-2/routine/{rid}/schedule',
json={
'mode': 'days',
'day_configs': [{'day': 1, 'hour': 8, 'minute': 0}],
'default_hour': 8,
'default_minute': 0,
'default_has_deadline': True,
},
)
assert set_resp.status_code == 200
get_resp = client.get(f'/child/routine-child-2/routine/{rid}/schedule')
assert get_resp.status_code == 200
assert get_resp.get_json()['mode'] == 'days'
extend_resp = client.post(
f'/child/routine-child-2/routine/{rid}/extend',
json={'date': '2026-05-04'},
)
assert extend_resp.status_code == 200
assert extend_resp.get_json()['routine_id'] == rid
@@ -62,7 +62,7 @@ def get_expiring_chores_for_user(
try: try:
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
local_now = datetime.now(ZoneInfo(tz_str)) if tz_str else now_dt local_now = now_dt.astimezone(ZoneInfo(tz_str)) if tz_str else now_dt
except Exception: except Exception:
local_now = now_dt local_now = now_dt
@@ -106,10 +106,15 @@ def get_expiring_chores_for_user(
continue # Anytime — no expiry continue # Anytime — no expiry
due_hour, due_minute = due due_hour, due_minute = due
# Build a timezone-aware deadline datetime for today # Build a timezone-aware deadline datetime for the next occurrence of the due time.
# Schedules run hourly and may span midnight (e.g. a 23:00 run needs to catch
# a chore due at 00:15 the next day), so roll forward a day when the candidate
# deadline has already passed.
deadline_dt = local_now.replace( deadline_dt = local_now.replace(
hour=due_hour, minute=due_minute, second=0, microsecond=0 hour=due_hour, minute=due_minute, second=0, microsecond=0
) )
if deadline_dt <= local_now:
deadline_dt = deadline_dt + timedelta(days=1)
# Include only when deadline is strictly after now and within window # Include only when deadline is strictly after now and within window
if not (local_now < deadline_dt <= window_end): if not (local_now < deadline_dt <= window_end):
+4
View File
@@ -0,0 +1,4 @@
# MongoDB configuration for end-to-end tests.
# Uses mongomock so E2E tests do not require a running MongoDB server.
USE_MONGODB=true
MONGO_URI=mongomock
+6 -6
View File
@@ -2,20 +2,20 @@
"cookies": [ "cookies": [
{ {
"name": "refresh_token", "name": "refresh_token",
"value": "mY6Ehfjz0l0gIgI1-zrA_HMfYQiNmgnNy6g7v2GoT3M", "value": "hW7OJbUjHr4XntQRPtefKNhvvctK014R6pzLz_vyJ8g",
"domain": "localhost", "domain": "localhost",
"path": "/api/auth", "path": "/api/auth",
"expires": 1785468152.319767, "expires": 1792774142.289068,
"httpOnly": true, "httpOnly": true,
"secure": true, "secure": true,
"sameSite": "Strict" "sameSite": "Strict"
}, },
{ {
"name": "access_token", "name": "access_token",
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS1jY0B0ZXN0LmNvbSIsInVzZXJfaWQiOiI0YmI3ZTQ3ZS0wNGYxLTQyMDctYjZjYy0yNDM3NDVlOGQ0ZDMiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3Nzc3MDI5NTJ9.614NMFQB7JcIJ4k4cqKyxlpcyHmt2Hn4PbjCbvLMJ2A", "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS1jY0B0ZXN0LmNvbSIsInVzZXJfaWQiOiI5MjUwNzY1MS00YjlmLTRkMTMtOTgxYS1hMzA2ZThjY2JmNzciLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3ODUwMDg5NDJ9.15hDvKf9u95pMzyeSbrHesFBDGgTwnxV1FD175p72-w",
"domain": "localhost", "domain": "localhost",
"path": "/", "path": "/",
"expires": 1777702952.31857, "expires": 1785008942.288088,
"httpOnly": true, "httpOnly": true,
"secure": true, "secure": true,
"sameSite": "Lax" "sameSite": "Lax"
@@ -27,11 +27,11 @@
"localStorage": [ "localStorage": [
{ {
"name": "authSyncEvent", "name": "authSyncEvent",
"value": "{\"type\":\"logout\",\"at\":1777692151952}" "value": "{\"type\":\"logout\",\"at\":1784998141942}"
}, },
{ {
"name": "parentAuth", "name": "parentAuth",
"value": "{\"expiresAt\":1777864952730}" "value": "{\"expiresAt\":1785170942553}"
} }
] ]
} }
+6 -6
View File
@@ -2,20 +2,20 @@
"cookies": [ "cookies": [
{ {
"name": "refresh_token", "name": "refresh_token",
"value": "fmbL31vJQXFd7NDUWy7eGlRZZoGh0BZipf8CqFOb8zw", "value": "PylNoNcxDjRuoocNg4ZmApg_7OIxp9Jfs7ttsX3C5uA",
"domain": "localhost", "domain": "localhost",
"path": "/api/auth", "path": "/api/auth",
"expires": 1785468152.214735, "expires": 1792435594.088028,
"httpOnly": true, "httpOnly": true,
"secure": true, "secure": true,
"sameSite": "Strict" "sameSite": "Strict"
}, },
{ {
"name": "access_token", "name": "access_token",
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS1kZWxldGVAdGVzdC5jb20iLCJ1c2VyX2lkIjoiNzc2YjBkYjctYzYyNy00ODBiLTkzZDYtMTRlMTE4MDQ3NTE5IiwidG9rZW5fdmVyc2lvbiI6MCwiZXhwIjoxNzc3NzAyOTUyfQ.I4sfKTo0nsJgKvwDRSztxtyZpWw-oN1y3L4yCtr_CRo", "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS1kZWxldGVAdGVzdC5jb20iLCJ1c2VyX2lkIjoiN2UzNjdjMGItMDU3ZS00MWEzLThlNzYtZWZmNDUyMmFmOTU2IiwidG9rZW5fdmVyc2lvbiI6MCwiZXhwIjoxNzg0NjcwMzk0fQ.cVkoiVTtH1O7TFqW5yQ9BEBlTq7hR8YEya7FW2b_Yh8",
"domain": "localhost", "domain": "localhost",
"path": "/", "path": "/",
"expires": 1777702952.21406, "expires": 1784670394.08796,
"httpOnly": true, "httpOnly": true,
"secure": true, "secure": true,
"sameSite": "Lax" "sameSite": "Lax"
@@ -27,11 +27,11 @@
"localStorage": [ "localStorage": [
{ {
"name": "authSyncEvent", "name": "authSyncEvent",
"value": "{\"type\":\"logout\",\"at\":1777692151844}" "value": "{\"type\":\"logout\",\"at\":1784659593889}"
}, },
{ {
"name": "parentAuth", "name": "parentAuth",
"value": "{\"expiresAt\":1777864952556}" "value": "{\"expiresAt\":1784832394248}"
} }
] ]
} }
+39
View File
@@ -0,0 +1,39 @@
{
"cookies": [
{
"name": "refresh_token",
"value": "5gEK_iLdGl1BAGfetT8hEf6SJK2lQVbYxQjH0BdKWwU",
"domain": "localhost",
"path": "/api/auth",
"expires": 1792813397.168935,
"httpOnly": true,
"secure": true,
"sameSite": "Strict"
},
{
"name": "access_token",
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS10dXRvcmlhbEB0ZXN0LmNvbSIsInVzZXJfaWQiOiJkODk0ZjA0OS0xODU3LTQwM2UtYjgzZC1jMTY2NTNmZDU2MmEiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3ODUwNDgxOTd9.S7frWOrZwXC5xVgtG2RDioXGok04qK_jLCYWvfedD7s",
"domain": "localhost",
"path": "/",
"expires": 1785048197.167997,
"httpOnly": true,
"secure": true,
"sameSite": "Lax"
}
],
"origins": [
{
"origin": "https://localhost:5173",
"localStorage": [
{
"name": "authSyncEvent",
"value": "{\"type\":\"logout\",\"at\":1785037396813}"
},
{
"name": "parentAuth",
"value": "{\"expiresAt\":1785210197388}"
}
]
}
]
}
+6 -6
View File
@@ -2,20 +2,20 @@
"cookies": [ "cookies": [
{ {
"name": "refresh_token", "name": "refresh_token",
"value": "YitpHdwm093Mavj6iTYmJOLGLOIKoozGkmYXgmJMTBM", "value": "Ke6qDj_0RYyrMSI1Z9C69DPtvYo26oUUiLZr_pDXZv4",
"domain": "localhost", "domain": "localhost",
"path": "/api/auth", "path": "/api/auth",
"expires": 1785468147.453462, "expires": 1792813392.716776,
"httpOnly": true, "httpOnly": true,
"secure": true, "secure": true,
"sameSite": "Strict" "sameSite": "Strict"
}, },
{ {
"name": "access_token", "name": "access_token",
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZUB0ZXN0LmNvbSIsInVzZXJfaWQiOiI4ZGNmOWU5Ni1lM2I3LTRkNzYtOWQ1NS01NmE4MjU5ZWQ5NzUiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3Nzc3MDI5NDd9.zSetTnajvus3N5uJDBqxRYXfLksoU9ZmkWzqCS0GUvc", "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZUB0ZXN0LmNvbSIsInVzZXJfaWQiOiI3NTYwZDBlNy1iMmVlLTRiMjktYTYwNi1lZTM3NmIzN2Y3ODciLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3ODUwNDgxOTJ9.QB4bXo88KC7jr94QSjJI3Y1eVWeU4KNKdHRi1_7zcD0",
"domain": "localhost", "domain": "localhost",
"path": "/", "path": "/",
"expires": 1777702947.452943, "expires": 1785048192.71579,
"httpOnly": true, "httpOnly": true,
"secure": true, "secure": true,
"sameSite": "Lax" "sameSite": "Lax"
@@ -27,11 +27,11 @@
"localStorage": [ "localStorage": [
{ {
"name": "authSyncEvent", "name": "authSyncEvent",
"value": "{\"type\":\"logout\",\"at\":1777692147220}" "value": "{\"type\":\"logout\",\"at\":1785037392502}"
}, },
{ {
"name": "parentAuth", "name": "parentAuth",
"value": "{\"expiresAt\":1777864947663}" "value": "{\"expiresAt\":1785210192904}"
} }
] ]
} }
+47
View File
@@ -0,0 +1,47 @@
import { test as setup } from '@playwright/test'
import {
STORAGE_STATE_TUTORIAL,
E2E_TUTORIAL_EMAIL,
E2E_TUTORIAL_PASSWORD,
E2E_TUTORIAL_PIN,
} from './e2e-constants'
const BACKEND = 'http://localhost:5000'
setup('authenticate tutorial user', async ({ page }) => {
// Create the isolated tutorial-test user (separate from the main E2E user)
const createRes = await page.request.post(`${BACKEND}/auth/e2e-create-tutorial-user`)
if (!createRes.ok()) {
throw new Error(
`e2e-create-tutorial-user failed: ${createRes.status()} ${await createRes.text()}`,
)
}
await page.goto('/auth/login')
await page.getByLabel('Email address').fill(E2E_TUTORIAL_EMAIL)
await page.getByLabel('Password').fill(E2E_TUTORIAL_PASSWORD)
await page.getByRole('button', { name: 'Sign in' }).click()
await page.waitForURL(/\/(parent|child)/)
await page.getByRole('button', { name: 'Parent login' }).click()
const pinInput = page.getByPlaceholder('46 digits')
await pinInput.waitFor({ timeout: 5000 })
await pinInput.fill(E2E_TUTORIAL_PIN)
await page.getByLabel('Stay in parent mode on this device').check()
await page.getByRole('button', { name: 'OK' }).click()
await page.waitForURL(/\/parent(\/|$)/)
try {
await page.getByRole('button', { name: 'Add Child' }).waitFor({ timeout: 5000 })
} catch {
await page.screenshot({ path: 'auth-setup-tutorial-parent-fail.png' })
throw new Error(
'Tutorial user parent mode not reached after PIN entry. See auth-setup-tutorial-parent-fail.png for details.',
)
}
await page.context().storageState({ path: STORAGE_STATE_TUTORIAL })
})
+4
View File
@@ -3,6 +3,7 @@ export const STORAGE_STATE_NO_PIN = 'e2e/.auth/user-no-pin.json'
export const STORAGE_STATE_TEMP_PARENT = 'e2e/.auth/user-temp-parent.json' export const STORAGE_STATE_TEMP_PARENT = 'e2e/.auth/user-temp-parent.json'
export const STORAGE_STATE_DELETE = 'e2e/.auth/user-delete.json' export const STORAGE_STATE_DELETE = 'e2e/.auth/user-delete.json'
export const STORAGE_STATE_CC = 'e2e/.auth/user-cc.json' export const STORAGE_STATE_CC = 'e2e/.auth/user-cc.json'
export const STORAGE_STATE_TUTORIAL = 'e2e/.auth/user-tutorial.json'
export const E2E_EMAIL = 'e2e@test.com' export const E2E_EMAIL = 'e2e@test.com'
export const E2E_PASSWORD = 'E2eTestPass1!' export const E2E_PASSWORD = 'E2eTestPass1!'
export const E2E_PIN = '1234' export const E2E_PIN = '1234'
@@ -13,3 +14,6 @@ export const E2E_DELETE_PIN = '5678'
export const E2E_CC_EMAIL = 'e2e-cc@test.com' export const E2E_CC_EMAIL = 'e2e-cc@test.com'
export const E2E_CC_PASSWORD = 'E2eCCPass1!' export const E2E_CC_PASSWORD = 'E2eCCPass1!'
export const E2E_CC_PIN = '3456' export const E2E_CC_PIN = '3456'
export const E2E_TUTORIAL_EMAIL = 'e2e-tutorial@test.com'
export const E2E_TUTORIAL_PASSWORD = 'E2eTutorialPass1!'
export const E2E_TUTORIAL_PIN = '7890'
@@ -14,6 +14,11 @@ test.describe('Create Child', () => {
// Navigate to parent list and wait for it to fully load before clicking Add Child // Navigate to parent list and wait for it to fully load before clicking Add Child
await gotoParentList(page) await gotoParentList(page)
// Dismiss tutorial overlay if present — it intercepts pointer events
const skip = page.getByRole('button', { name: 'Cancel' })
if (await skip.isVisible({ timeout: 1000 }).catch(() => false)) {
await skip.click()
}
await page.getByRole('button', { name: 'Add Child' }).click() await page.getByRole('button', { name: 'Add Child' }).click()
await expect(page.getByRole('heading', { name: 'Create Child' })).toBeVisible() await expect(page.getByRole('heading', { name: 'Create Child' })).toBeVisible()
}) })
@@ -30,7 +35,7 @@ test.describe('Create Child', () => {
test('Reject submission when Name is whitespace only', async ({ page }) => { test('Reject submission when Name is whitespace only', async ({ page }) => {
// 2. Enter only spaces in Name, enter '7' in Age - Create remains disabled // 2. Enter only spaces in Name, enter '7' in Age - Create remains disabled
const createButton = page.getByRole('button', { name: 'Create' }) const createButton = page.getByRole('button', { name: 'Create' })
await page.getByLabel('Name').fill(' ') await page.getByRole('textbox', { name: 'Name' }).fill(' ')
await page.getByLabel('Age').fill('7') await page.getByLabel('Age').fill('7')
await expect(createButton).toBeDisabled() await expect(createButton).toBeDisabled()
@@ -39,7 +44,7 @@ test.describe('Create Child', () => {
test('Reject submission when Age is empty', async ({ page }) => { test('Reject submission when Age is empty', async ({ page }) => {
// 2. Enter 'Charlie', clear Age - Create button should be disabled // 2. Enter 'Charlie', clear Age - Create button should be disabled
await page.getByLabel('Name').fill('Charlie') await page.getByRole('textbox', { name: 'Name' }).fill('Charlie')
await page.getByLabel('Age').clear() await page.getByLabel('Age').clear()
await expect(page.getByRole('button', { name: 'Create' })).toBeDisabled() await expect(page.getByRole('button', { name: 'Create' })).toBeDisabled()
@@ -49,7 +54,7 @@ test.describe('Create Child', () => {
test('Reject negative age', async ({ page }) => { test('Reject negative age', async ({ page }) => {
// 2. Enter 'Dave', enter '-1' - Create remains disabled // 2. Enter 'Dave', enter '-1' - Create remains disabled
const createButton = page.getByRole('button', { name: 'Create' }) const createButton = page.getByRole('button', { name: 'Create' })
await page.getByLabel('Name').fill('Dave') await page.getByRole('textbox', { name: 'Name' }).fill('Dave')
await page.getByLabel('Age').fill('-1') await page.getByLabel('Age').fill('-1')
await expect(createButton).toBeDisabled() await expect(createButton).toBeDisabled()
@@ -57,7 +62,7 @@ test.describe('Create Child', () => {
}) })
test('Enforce maximum Name length of 64 characters', async ({ page, request }) => { test('Enforce maximum Name length of 64 characters', async ({ page, request }) => {
const nameInput = page.getByLabel('Name') const nameInput = page.getByRole('textbox', { name: 'Name' })
const ageInput = page.getByLabel('Age') const ageInput = page.getByLabel('Age')
const createButton = page.getByRole('button', { name: 'Create' }) const createButton = page.getByRole('button', { name: 'Create' })
@@ -85,7 +90,7 @@ test.describe('Create Child', () => {
test('Reject age greater than 120', async ({ page }) => { test('Reject age greater than 120', async ({ page }) => {
// 2. Enter 'Eve', enter '121' in Age - Create button should be disabled // 2. Enter 'Eve', enter '121' in Age - Create button should be disabled
await page.getByLabel('Name').fill('Eve') await page.getByRole('textbox', { name: 'Name' }).fill('Eve')
await page.getByLabel('Age').fill('121') await page.getByLabel('Age').fill('121')
await expect(page.getByRole('button', { name: 'Create' })).toBeDisabled() await expect(page.getByRole('button', { name: 'Create' })).toBeDisabled()
@@ -9,6 +9,17 @@ function pushToggle(page: Page) {
.locator('button.toggle-btn') .locator('button.toggle-btn')
} }
async function openNotificationsSection(page: Page): Promise<void> {
const header = page
.locator('.profile-section')
.filter({ has: page.locator('.section-title', { hasText: 'Notifications' }) })
.locator('.section-header')
if ((await header.getAttribute('aria-expanded')) === 'false') {
await header.click()
}
await page.locator('#section-notifications').waitFor({ state: 'visible' })
}
test.describe('Push subscription registration', () => { test.describe('Push subscription registration', () => {
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// 1.3 No subscription POST when notification permission not granted // 1.3 No subscription POST when notification permission not granted
@@ -26,6 +37,7 @@ test.describe('Push subscription registration', () => {
}) })
await page.goto('/parent/profile') await page.goto('/parent/profile')
await openNotificationsSection(page)
const toggle = pushToggle(page) const toggle = pushToggle(page)
await expect(toggle).toBeVisible({ timeout: 5000 }) await expect(toggle).toBeVisible({ timeout: 5000 })
const isDisabled = await toggle.isDisabled().catch(() => false) const isDisabled = await toggle.isDisabled().catch(() => false)
@@ -16,6 +16,17 @@ function pushToggle(page: Page) {
.locator('button.toggle-btn') .locator('button.toggle-btn')
} }
async function openNotificationsSection(page: Page): Promise<void> {
const header = page
.locator('.profile-section')
.filter({ has: page.locator('.section-title', { hasText: 'Notifications' }) })
.locator('.section-header')
if ((await header.getAttribute('aria-expanded')) === 'false') {
await header.click()
}
await page.locator('#section-notifications').waitFor({ state: 'visible' })
}
test.describe('User Profile Notification Settings', () => { test.describe('User Profile Notification Settings', () => {
test.describe.configure({ mode: 'serial' }) test.describe.configure({ mode: 'serial' })
@@ -24,6 +35,7 @@ test.describe('User Profile Notification Settings', () => {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
test('Email Digest toggle is visible on User Profile page', async ({ page }) => { test('Email Digest toggle is visible on User Profile page', async ({ page }) => {
await page.goto('/parent/profile') await page.goto('/parent/profile')
await openNotificationsSection(page)
await expect(digestToggle(page)).toBeVisible({ timeout: 5000 }) await expect(digestToggle(page)).toBeVisible({ timeout: 5000 })
}) })
@@ -34,11 +46,13 @@ test.describe('User Profile Notification Settings', () => {
// Set known state: enabled // Set known state: enabled
await request.put('/api/user/profile', { data: { email_digest_enabled: true } }) await request.put('/api/user/profile', { data: { email_digest_enabled: true } })
await page.goto('/parent/profile') await page.goto('/parent/profile')
await openNotificationsSection(page)
await expect(digestToggle(page)).toHaveAttribute('aria-pressed', 'true', { timeout: 5000 }) await expect(digestToggle(page)).toHaveAttribute('aria-pressed', 'true', { timeout: 5000 })
// Set known state: disabled // Set known state: disabled
await request.put('/api/user/profile', { data: { email_digest_enabled: false } }) await request.put('/api/user/profile', { data: { email_digest_enabled: false } })
await page.reload() await page.reload()
await openNotificationsSection(page)
await expect(digestToggle(page)).toHaveAttribute('aria-pressed', 'false', { timeout: 5000 }) await expect(digestToggle(page)).toHaveAttribute('aria-pressed', 'false', { timeout: 5000 })
// Restore default // Restore default
@@ -51,6 +65,7 @@ test.describe('User Profile Notification Settings', () => {
test('Toggling Email Digest off sends correct API payload', async ({ page, request }) => { test('Toggling Email Digest off sends correct API payload', async ({ page, request }) => {
await request.put('/api/user/profile', { data: { email_digest_enabled: true } }) await request.put('/api/user/profile', { data: { email_digest_enabled: true } })
await page.goto('/parent/profile') await page.goto('/parent/profile')
await openNotificationsSection(page)
// Wait for profile to load and toggle to reflect server state // Wait for profile to load and toggle to reflect server state
await expect(digestToggle(page)).toHaveAttribute('aria-pressed', 'true', { timeout: 5000 }) await expect(digestToggle(page)).toHaveAttribute('aria-pressed', 'true', { timeout: 5000 })
@@ -64,9 +79,7 @@ test.describe('User Profile Notification Settings', () => {
await digestToggle(page).click() await digestToggle(page).click()
// Submit the form (Save button) // Toggles auto-save; wait for the PUT and verify the payload.
await page.getByRole('button', { name: 'Save' }).click()
await page.waitForTimeout(500) await page.waitForTimeout(500)
expect(capturedBody).not.toBeNull() expect(capturedBody).not.toBeNull()
expect((capturedBody as Record<string, unknown>)['email_digest_enabled']).toBe(false) expect((capturedBody as Record<string, unknown>)['email_digest_enabled']).toBe(false)
@@ -82,6 +95,7 @@ test.describe('User Profile Notification Settings', () => {
test('Toggling Email Digest on sends correct API payload', async ({ page, request }) => { test('Toggling Email Digest on sends correct API payload', async ({ page, request }) => {
await request.put('/api/user/profile', { data: { email_digest_enabled: false } }) await request.put('/api/user/profile', { data: { email_digest_enabled: false } })
await page.goto('/parent/profile') await page.goto('/parent/profile')
await openNotificationsSection(page)
// Wait for profile to load and toggle to reflect server state // Wait for profile to load and toggle to reflect server state
await expect(digestToggle(page)).toHaveAttribute('aria-pressed', 'false', { timeout: 5000 }) await expect(digestToggle(page)).toHaveAttribute('aria-pressed', 'false', { timeout: 5000 })
@@ -94,7 +108,6 @@ test.describe('User Profile Notification Settings', () => {
}) })
await digestToggle(page).click() await digestToggle(page).click()
await page.getByRole('button', { name: 'Save' }).click()
await page.waitForTimeout(500) await page.waitForTimeout(500)
expect(capturedBody).not.toBeNull() expect(capturedBody).not.toBeNull()
@@ -110,6 +123,7 @@ test.describe('User Profile Notification Settings', () => {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
test('Push Notifications toggle is visible on User Profile page', async ({ page }) => { test('Push Notifications toggle is visible on User Profile page', async ({ page }) => {
await page.goto('/parent/profile') await page.goto('/parent/profile')
await openNotificationsSection(page)
await expect(pushToggle(page)).toBeVisible({ timeout: 5000 }) await expect(pushToggle(page)).toBeVisible({ timeout: 5000 })
}) })
@@ -121,6 +135,7 @@ test.describe('User Profile Notification Settings', () => {
}) => { }) => {
// Do NOT grant notifications permission — toggle should start unchecked // Do NOT grant notifications permission — toggle should start unchecked
await page.goto('/parent/profile') await page.goto('/parent/profile')
await openNotificationsSection(page)
const toggle = pushToggle(page) const toggle = pushToggle(page)
await expect(toggle).toBeVisible({ timeout: 5000 }) await expect(toggle).toBeVisible({ timeout: 5000 })
// Either aria-pressed="false" or disabled (when browser denies permissions) // Either aria-pressed="false" or disabled (when browser denies permissions)
@@ -138,6 +153,7 @@ test.describe('User Profile Notification Settings', () => {
// Do not grant permission (leave as denied/prompt) // Do not grant permission (leave as denied/prompt)
await context.clearPermissions() await context.clearPermissions()
await page.goto('/parent/profile') await page.goto('/parent/profile')
await openNotificationsSection(page)
const toggle = pushToggle(page) const toggle = pushToggle(page)
await expect(toggle).toBeVisible({ timeout: 5000 }) await expect(toggle).toBeVisible({ timeout: 5000 })
// When permission is denied, the toggle should be disabled or aria-pressed="false" // When permission is denied, the toggle should be disabled or aria-pressed="false"
@@ -4,6 +4,11 @@ import { E2E_PIN } from '../../e2e-constants'
test.describe('Parent profile button temporary parent mode', () => { test.describe('Parent profile button temporary parent mode', () => {
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await page.goto('/parent') await page.goto('/parent')
// Dismiss tutorial overlay if present — it intercepts pointer events
const skip = page.getByRole('button', { name: 'Cancel' })
if (await skip.isVisible({ timeout: 1000 }).catch(() => false)) {
await skip.click()
}
// Switch from permanent mode to temporary mode: // Switch from permanent mode to temporary mode:
// 1. Exit parent mode via Child Mode // 1. Exit parent mode via Child Mode
await page.getByRole('button', { name: 'Parent menu' }).click() await page.getByRole('button', { name: 'Parent menu' }).click()
@@ -4,6 +4,11 @@ import { E2E_EMAIL, E2E_FIRST_NAME, E2E_PIN } from '../../e2e-constants'
test.describe('Parent profile button permanent parent mode', () => { test.describe('Parent profile button permanent parent mode', () => {
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await page.goto('/parent') await page.goto('/parent')
// Dismiss tutorial overlay if present — it intercepts pointer events
const skip = page.getByRole('button', { name: 'Cancel' })
if (await skip.isVisible({ timeout: 1000 }).catch(() => false)) {
await skip.click()
}
}) })
test('Badge shows 🔒 in permanent parent mode', async ({ page }) => { test('Badge shows 🔒 in permanent parent mode', async ({ page }) => {
@@ -23,7 +28,7 @@ test.describe('Parent profile button permanent parent mode', () => {
await page.getByRole('menuitem', { name: 'Profile' }).click() await page.getByRole('menuitem', { name: 'Profile' }).click()
await expect(page).toHaveURL(/\/parent\/profile/) await expect(page).toHaveURL(/\/parent\/profile/)
await expect(page.getByRole('heading', { name: 'User Profile' })).toBeVisible() await expect(page.getByRole('heading', { name: 'Profile' })).toBeVisible()
}) })
test('Menu Child Mode exits parent mode', async ({ page }) => { test('Menu Child Mode exits parent mode', async ({ page }) => {
@@ -0,0 +1,213 @@
import { test, expect, type APIRequestContext, type Page } from '@playwright/test'
const CHILD_NAME = 'RoutineChildModeChild'
const ROUTINE_NAME = 'RoutineChildModeTest'
const ROUTINE_POINTS = 10
const TASK_NAME_1 = 'Brush Teeth'
const TASK_NAME_2 = 'Wash Face'
// ── helpers ───────────────────────────────────────────────────────────────────
async function createChild(request: APIRequestContext, name: string): Promise<string> {
const pre = await request.get('/api/child/list')
for (const c of (await pre.json()).children ?? []) {
if (c.name === name) await request.delete(`/api/child/${c.id}`)
}
await request.put('/api/child/add', { data: { name, age: 8 } })
const list = await request.get('/api/child/list')
return (await list.json()).children?.find((c: any) => c.name === name)?.id ?? ''
}
async function createRoutine(
request: APIRequestContext,
name: string,
points: number,
): Promise<string> {
const pre = await request.get('/api/routine/list')
for (const r of (await pre.json()).routines ?? []) {
if (r.name === name) await request.delete(`/api/routine/${r.id}`)
}
const res = await request.put('/api/routine/add', { data: { name, points } })
return (await res.json()).routine?.id ?? ''
}
function routineSection(page: Page) {
return page.locator('.child-list-container').filter({
has: page.locator('h3', { hasText: 'Routines' }),
})
}
// ── suite ─────────────────────────────────────────────────────────────────────
test.describe('Routine child-mode flow', () => {
test.describe.configure({ mode: 'serial' })
let childId = ''
let routineId = ''
test.beforeAll(async ({ request }) => {
childId = await createChild(request, CHILD_NAME)
routineId = await createRoutine(request, ROUTINE_NAME, ROUTINE_POINTS)
await request.put(`/api/routine/${routineId}/item/add`, { data: { name: TASK_NAME_1 } })
await request.put(`/api/routine/${routineId}/item/add`, { data: { name: TASK_NAME_2 } })
await request.post(`/api/child/${childId}/assign-routine`, {
data: { routine_id: routineId },
})
})
test.afterAll(async ({ request }) => {
if (childId) await request.delete(`/api/child/${childId}`)
if (routineId) await request.delete(`/api/routine/${routineId}`)
})
// Intercept parentAuth so the router treats this session as child-mode (not parent-authenticated).
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
const orig = Storage.prototype.getItem
Storage.prototype.getItem = function (key: string) {
if (key === 'parentAuth') return null
return orig.call(this, key)
}
})
})
// ── 1. Routine section visible ────────────────────────────────────────────
test('Routine section appears in child view', async ({ page }) => {
await page.goto(`/child/${childId}`)
const section = routineSection(page)
await expect(section).toBeVisible({ timeout: 5000 })
await expect(section.locator('.item-card').filter({ hasText: ROUTINE_NAME })).toBeVisible()
})
// ── 2. Overlay opens on two-click ─────────────────────────────────────────
test('Clicking routine twice opens overlay with correct header', async ({ page }) => {
await page.goto(`/child/${childId}`)
const card = routineSection(page).locator('.item-card').filter({ hasText: ROUTINE_NAME })
await card.waitFor({ state: 'visible' })
// First click enters ready state
await card.click()
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
// Second click opens overlay
await card.click()
const dialog = page.getByRole('dialog')
await expect(dialog).toBeVisible({ timeout: 3000 })
await expect(dialog).toContainText('Did you complete')
await expect(dialog).toContainText(ROUTINE_NAME)
})
// ── 3. Task cards inside overlay ──────────────────────────────────────────
test('Overlay shows task cards for each routine item', async ({ page }) => {
await page.goto(`/child/${childId}`)
const card = routineSection(page).locator('.item-card').filter({ hasText: ROUTINE_NAME })
await card.waitFor({ state: 'visible' })
await card.click()
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
await card.click()
const dialog = page.getByRole('dialog')
await expect(dialog).toBeVisible({ timeout: 3000 })
await expect(dialog.getByText(TASK_NAME_1)).toBeVisible()
await expect(dialog.getByText(TASK_NAME_2)).toBeVisible()
})
// ── 4. Closing overlay with No ────────────────────────────────────────────
test('Clicking No closes the overlay without changing state', async ({ page }) => {
await page.goto(`/child/${childId}`)
const card = routineSection(page).locator('.item-card').filter({ hasText: ROUTINE_NAME })
await card.waitFor({ state: 'visible' })
await card.click()
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
await card.click()
const dialog = page.getByRole('dialog')
await expect(dialog).toBeVisible({ timeout: 3000 })
await dialog.getByRole('button', { name: 'No' }).click()
await expect(dialog).not.toBeVisible({ timeout: 3000 })
await expect(card.locator('.pending-stamp')).not.toBeVisible()
})
// ── 5. Yes! confirms routine → PENDING stamp ──────────────────────────────
test('Clicking Yes! marks routine as PENDING', async ({ page }) => {
await page.goto(`/child/${childId}`)
const card = routineSection(page).locator('.item-card').filter({ hasText: ROUTINE_NAME })
await card.waitFor({ state: 'visible' })
await card.click()
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
await card.click()
const dialog = page.getByRole('dialog')
await expect(dialog).toBeVisible({ timeout: 3000 })
await dialog.getByRole('button', { name: 'Yes!' }).click()
// Dialog closes
await expect(dialog).not.toBeVisible({ timeout: 5000 })
// Reload to pick up the refreshed state
await page.reload()
const reloadedCard = routineSection(page)
.locator('.item-card')
.filter({ hasText: ROUTINE_NAME })
await expect(reloadedCard.locator('.pending-stamp')).toBeVisible({ timeout: 5000 })
})
// ── 6. Cancel dialog for pending routine ─────────────────────────────────
test('Clicking a pending routine shows the cancel dialog', async ({ page }) => {
await page.goto(`/child/${childId}`)
// Routine should still be pending from the previous test
const card = routineSection(page).locator('.item-card').filter({ hasText: ROUTINE_NAME })
await card.waitFor({ state: 'visible' })
await expect(card.locator('.pending-stamp')).toBeVisible({ timeout: 5000 })
await card.click()
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
await card.click()
const dialog = page.getByRole('dialog')
await expect(dialog).toBeVisible({ timeout: 3000 })
await expect(dialog).toContainText('This routine is pending')
})
// ── 7. Canceling pending routine removes PENDING stamp ────────────────────
test('Confirming cancel removes the PENDING stamp', async ({ page }) => {
await page.goto(`/child/${childId}`)
const card = routineSection(page).locator('.item-card').filter({ hasText: ROUTINE_NAME })
await card.waitFor({ state: 'visible' })
await card.click()
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
await card.click()
const dialog = page.getByRole('dialog')
await expect(dialog).toBeVisible({ timeout: 3000 })
await expect(dialog).toContainText('This routine is pending')
// "Yes" cancels the pending confirmation
await dialog.getByRole('button', { name: 'Yes' }).click()
await expect(dialog).not.toBeVisible({ timeout: 5000 })
await page.reload()
const reloadedCard = routineSection(page)
.locator('.item-card')
.filter({ hasText: ROUTINE_NAME })
await expect(reloadedCard.locator('.pending-stamp')).not.toBeVisible({ timeout: 5000 })
})
})
@@ -0,0 +1,77 @@
import { test, expect, type APIRequestContext } from '@playwright/test'
const ROUTINE_NAME = 'ReorderTestRoutine'
const ROUTINE_POINTS = 10
const ITEM_A = 'Alpha Task'
const ITEM_B = 'Beta Task'
const ITEM_C = 'Gamma Task'
async function createRoutine(
request: APIRequestContext,
name: string,
points: number,
): Promise<string> {
const pre = await request.get('/api/routine/list')
for (const r of (await pre.json()).routines ?? []) {
if (r.name === name) await request.delete(`/api/routine/${r.id}`)
}
const res = await request.put('/api/routine/add', { data: { name, points } })
return (await res.json()).routine?.id ?? ''
}
test.describe('Routine item drag-to-reorder', () => {
test.describe.configure({ mode: 'serial' })
let routineId = ''
test.beforeAll(async ({ request }) => {
routineId = await createRoutine(request, ROUTINE_NAME, ROUTINE_POINTS)
await request.put(`/api/routine/${routineId}/item/add`, { data: { name: ITEM_A, order: 0 } })
await request.put(`/api/routine/${routineId}/item/add`, { data: { name: ITEM_B, order: 1 } })
await request.put(`/api/routine/${routineId}/item/add`, { data: { name: ITEM_C, order: 2 } })
})
test.afterAll(async ({ request }) => {
if (routineId) await request.delete(`/api/routine/${routineId}`)
})
test('items load in correct initial order', async ({ page }) => {
await page.goto(`/parent/tasks/routines/${routineId}/edit`)
await page.locator('.item-row').first().waitFor({ state: 'visible' })
const names = await page.locator('.item-name').allTextContents()
expect(names).toEqual([ITEM_A, ITEM_B, ITEM_C])
})
test('dragging first item to last position reorders the list in the UI', async ({ page }) => {
await page.goto(`/parent/tasks/routines/${routineId}/edit`)
await page.locator('.item-row').first().waitFor({ state: 'visible' })
await page.locator('.item-row').nth(0).locator('.drag-handle').dragTo(page.locator('.item-row').nth(2))
const names = await page.locator('.item-name').allTextContents()
expect(names).toEqual([ITEM_B, ITEM_C, ITEM_A])
})
test('reordered item order persists after saving', async ({ page }) => {
await page.goto(`/parent/tasks/routines/${routineId}/edit`)
await page.locator('.item-row').first().waitFor({ state: 'visible' })
// Drag first to last: [A, B, C] → [B, C, A]
await page.locator('.item-row').nth(0).locator('.drag-handle').dragTo(page.locator('.item-row').nth(2))
// Confirm the drag updated the DOM before saving
const afterDrag = await page.locator('.item-name').allTextContents()
expect(afterDrag).toEqual([ITEM_B, ITEM_C, ITEM_A])
await page.getByRole('button', { name: 'Save' }).click()
await page.waitForURL(/\/parent\/tasks\/routines$/, { timeout: 5000 })
// Reload edit view — should now show the saved order
await page.goto(`/parent/tasks/routines/${routineId}/edit`)
await page.locator('.item-row').first().waitFor({ state: 'visible' })
const names = await page.locator('.item-name').allTextContents()
expect(names).toEqual([ITEM_B, ITEM_C, ITEM_A])
})
})
@@ -0,0 +1,98 @@
import { test, expect, type APIRequestContext } from '@playwright/test'
const CHILD_NAME = 'RoutineNotifChild'
const ROUTINE_NAME = 'RoutineNotifTest'
const ROUTINE_POINTS = 8
// ── helpers ───────────────────────────────────────────────────────────────────
async function createChild(request: APIRequestContext, name: string): Promise<string> {
const pre = await request.get('/api/child/list')
for (const c of (await pre.json()).children ?? []) {
if (c.name === name) await request.delete(`/api/child/${c.id}`)
}
await request.put('/api/child/add', { data: { name, age: 8 } })
const list = await request.get('/api/child/list')
return (await list.json()).children?.find((c: any) => c.name === name)?.id ?? ''
}
async function createRoutine(
request: APIRequestContext,
name: string,
points: number,
): Promise<string> {
const pre = await request.get('/api/routine/list')
for (const r of (await pre.json()).routines ?? []) {
if (r.name === name) await request.delete(`/api/routine/${r.id}`)
}
const res = await request.put('/api/routine/add', { data: { name, points } })
return (await res.json()).routine?.id ?? ''
}
// ── suite ─────────────────────────────────────────────────────────────────────
test.describe('Routine parent notification', () => {
test.describe.configure({ mode: 'serial' })
let childId = ''
let routineId = ''
test.beforeAll(async ({ request }) => {
childId = await createChild(request, CHILD_NAME)
routineId = await createRoutine(request, ROUTINE_NAME, ROUTINE_POINTS)
await request.post(`/api/child/${childId}/assign-routine`, {
data: { routine_id: routineId },
})
// Simulate the child confirming the routine
await request.post(`/api/child/${childId}/confirm-routine`, {
data: { routine_id: routineId },
})
})
test.afterAll(async ({ request }) => {
if (childId) await request.delete(`/api/child/${childId}`)
if (routineId) await request.delete(`/api/routine/${routineId}`)
})
// ── 1. Notification appears ───────────────────────────────────────────────
test('Confirmed routine appears in notification view', async ({ page }) => {
await page.goto('/parent/notifications')
const item = page.locator('.list-item').filter({ hasText: ROUTINE_NAME })
await expect(item).toBeVisible({ timeout: 5000 })
})
// ── 2. Notification shows "completed" ────────────────────────────────────
test('Notification shows "completed" for routine', async ({ page }) => {
await page.goto('/parent/notifications')
const item = page.locator('.list-item').filter({ hasText: ROUTINE_NAME })
await expect(item).toBeVisible({ timeout: 5000 })
await expect(item).toContainText('completed')
})
// ── 3. Notification shows child name ─────────────────────────────────────
test('Notification shows the child name', async ({ page }) => {
await page.goto('/parent/notifications')
const item = page.locator('.list-item').filter({ hasText: ROUTINE_NAME })
await expect(item).toBeVisible({ timeout: 5000 })
await expect(item).toContainText(CHILD_NAME)
})
// ── 4. Clicking notification navigates to parent view ────────────────────
test('Clicking notification navigates to parent view for the child', async ({ page }) => {
await page.goto('/parent/notifications')
const item = page.locator('.list-item').filter({ hasText: ROUTINE_NAME })
await expect(item).toBeVisible({ timeout: 5000 })
await item.click()
await page.waitForURL(`/parent/${childId}**`, { timeout: 5000 })
const url = new URL(page.url())
expect(url.searchParams.get('scrollTo')).toBe(routineId)
expect(url.searchParams.get('entityType')).toBe('routine')
})
})
@@ -0,0 +1,133 @@
import { test, expect, type APIRequestContext } from '@playwright/test'
const CHILD_NAME = 'AssignFabChild'
const CHORE_NAME = 'AssignFabChore'
const KINDNESS_NAME = 'AssignFabKindness'
const PENALTY_NAME = 'AssignFabPenalty'
const REWARD_NAME = 'AssignFabReward'
const ROUTINE_NAME = 'AssignFabRoutine'
async function createChild(request: APIRequestContext, name: string): Promise<string> {
const pre = await request.get('/api/child/list')
for (const c of (await pre.json()).children ?? []) {
if (c.name === name) await request.delete(`/api/child/${c.id}`)
}
await request.put('/api/child/add', { data: { name, age: 8 } })
const list = await request.get('/api/child/list')
return (
(await list.json()).children?.find((c: { name: string; id: string }) => c.name === name)?.id ??
''
)
}
async function createTask(
request: APIRequestContext,
name: string,
type: 'chore' | 'kindness' | 'penalty',
): Promise<string> {
const pre = await request.get('/api/task/list')
for (const t of (await pre.json()).tasks ?? []) {
if (t.name === name) await request.delete(`/api/task/${t.id}`)
}
await request.put('/api/task/add', { data: { name, points: 5, type } })
const list = await request.get('/api/task/list')
return (
(await list.json()).tasks?.find((t: { name: string; id: string }) => t.name === name)?.id ?? ''
)
}
async function createReward(request: APIRequestContext, name: string): Promise<string> {
const pre = await request.get('/api/reward/list')
for (const r of (await pre.json()).rewards ?? []) {
if (r.name === name) await request.delete(`/api/reward/${r.id}`)
}
await request.put('/api/reward/add', { data: { name, description: 'E2E fab reward', cost: 10 } })
const list = await request.get('/api/reward/list')
return (
(await list.json()).rewards?.find((r: { name: string; id: string }) => r.name === name)?.id ??
''
)
}
async function createRoutine(request: APIRequestContext, name: string): Promise<string> {
const pre = await request.get('/api/routine/list')
for (const r of (await pre.json()).routines ?? []) {
if (r.name === name) await request.delete(`/api/routine/${r.id}`)
}
const res = await request.put('/api/routine/add', { data: { name, points: 5 } })
return (await res.json()).routine?.id ?? ''
}
test.describe('Assignment views create FAB', () => {
test.describe.configure({ mode: 'serial' })
let childId = ''
let choreId = ''
let kindnessId = ''
let penaltyId = ''
let rewardId = ''
let routineId = ''
test.beforeAll(async ({ request }) => {
childId = await createChild(request, CHILD_NAME)
choreId = await createTask(request, CHORE_NAME, 'chore')
kindnessId = await createTask(request, KINDNESS_NAME, 'kindness')
penaltyId = await createTask(request, PENALTY_NAME, 'penalty')
rewardId = await createReward(request, REWARD_NAME)
routineId = await createRoutine(request, ROUTINE_NAME)
})
test.afterAll(async ({ request }) => {
if (childId) await request.delete(`/api/child/${childId}`)
if (choreId) await request.delete(`/api/task/${choreId}`)
if (kindnessId) await request.delete(`/api/task/${kindnessId}`)
if (penaltyId) await request.delete(`/api/task/${penaltyId}`)
if (rewardId) await request.delete(`/api/reward/${rewardId}`)
if (routineId) await request.delete(`/api/routine/${routineId}`)
})
test('Chore assign view FAB navigates to chore creator', async ({ page }) => {
await page.goto(`/parent/${childId}/assign-chores?name=${CHILD_NAME}`)
await expect(page.getByRole('heading', { name: 'Assign Chores' })).toBeVisible()
await page.getByRole('button', { name: 'Create Chore' }).click()
await page.waitForURL(/\/parent\/tasks\/chores\/create$/)
await expect(page.getByRole('heading', { name: 'Create Chore' })).toBeVisible()
})
test('Kindness assign view FAB navigates to kindness creator', async ({ page }) => {
await page.goto(`/parent/${childId}/assign-kindness?name=${CHILD_NAME}`)
await expect(page.getByRole('heading', { name: 'Assign Kindness Acts' })).toBeVisible()
await page.getByRole('button', { name: 'Create Kindness Act' }).click()
await page.waitForURL(/\/parent\/tasks\/kindness\/create$/)
await expect(page.getByRole('heading', { name: 'Create Kindness Act' })).toBeVisible()
})
test('Penalty assign view FAB navigates to penalty creator', async ({ page }) => {
await page.goto(`/parent/${childId}/assign-penalties?name=${CHILD_NAME}`)
await expect(page.getByRole('heading', { name: 'Assign Penalties' })).toBeVisible()
await page.getByRole('button', { name: 'Create Penalty' }).click()
await page.waitForURL(/\/parent\/tasks\/penalties\/create$/)
await expect(page.getByRole('heading', { name: 'Create Penalty' })).toBeVisible()
})
test('Reward assign view FAB navigates to reward creator', async ({ page }) => {
await page.goto(`/parent/${childId}/assign-rewards?name=${CHILD_NAME}`)
await expect(page.getByRole('heading', { name: 'Assign Rewards' })).toBeVisible()
await page.getByRole('button', { name: 'Create Reward' }).click()
await page.waitForURL(/\/parent\/rewards\/create$/)
await expect(page.getByRole('heading', { name: 'Create Reward' })).toBeVisible()
})
test('Routine assign view FAB navigates to routine creator', async ({ page }) => {
await page.goto(`/parent/${childId}/assign-routines?name=${CHILD_NAME}`)
await expect(page.getByRole('heading', { name: 'Assign Routines' })).toBeVisible()
await page.getByRole('button', { name: 'Create Routine' }).click()
await page.waitForURL(/\/parent\/tasks\/routines\/create$/)
await expect(page.getByRole('heading', { name: 'Create Routine' })).toBeVisible()
})
})
@@ -45,6 +45,28 @@ async function openEditModal(page: Page, card: Locator): Promise<void> {
await expect(page.getByRole('button', { name: 'Save' })).toBeVisible() await expect(page.getByRole('button', { name: 'Save' })).toBeVisible()
} }
async function seedPendingReward(
request: APIRequestContext,
childId: string,
rewardId: string,
rewardCost: number,
): Promise<void> {
await request.put(`/api/child/${childId}/edit`, { data: { points: rewardCost } })
const requestResp = await request.post(`/api/child/${childId}/request-reward`, {
data: { reward_id: rewardId },
})
if (requestResp.status() === 409) {
await request.post(`/api/child/${childId}/cancel-request-reward`, {
data: { reward_id: rewardId },
})
await request.post(`/api/child/${childId}/request-reward`, {
data: { reward_id: rewardId },
})
}
}
test.describe('Reward edit cost', () => { test.describe('Reward edit cost', () => {
test.describe.configure({ mode: 'serial' }) test.describe.configure({ mode: 'serial' })
@@ -172,9 +194,8 @@ test.describe('Reward edit cost', () => {
page, page,
request, request,
}) => { }) => {
// Give child enough points to satisfy the original reward cost, then create a pending request // Ensure a fresh pending request exists for this test.
await request.put(`/api/child/${childId}/edit`, { data: { points: REWARD_COST } }) await seedPendingReward(request, childId, rewardId, REWARD_COST)
await request.post(`/api/child/${childId}/request-reward`, { data: { reward_id: rewardId } })
await page.goto(`/parent/${childId}`) await page.goto(`/parent/${childId}`)
const card = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME }) const card = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
@@ -197,8 +218,11 @@ test.describe('Reward edit cost', () => {
test('Editing a pending reward — confirming the warning opens the override modal', async ({ test('Editing a pending reward — confirming the warning opens the override modal', async ({
page, page,
request,
}) => { }) => {
// Pending state was established in the previous test; navigate fresh // Seed pending state in this test to avoid cross-test coupling.
await seedPendingReward(request, childId, rewardId, REWARD_COST)
await page.goto(`/parent/${childId}`) await page.goto(`/parent/${childId}`)
const card = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME }) const card = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
await card.waitFor({ state: 'visible' }) await card.waitFor({ state: 'visible' })
@@ -0,0 +1,327 @@
import { test, expect, type APIRequestContext, type Page } from '@playwright/test'
const BACKEND = 'http://localhost:5000'
const CHILD_NAME = 'DialogHelpChild'
const CHORE_NAME = 'DialogHelpChore'
const KINDNESS_NAME = 'DialogHelpKindness'
const PENALTY_NAME = 'DialogHelpPenalty'
const REWARD_NAME = 'DialogHelpReward'
const REWARD_COST = 10
const ROUTINE_NAME = 'DialogHelpRoutine'
async function setTutorialEnabled(request: APIRequestContext, enabled: boolean): Promise<void> {
const res = await request.patch(`${BACKEND}/user/tutorial-progress`, {
data: { enabled },
})
if (!res.ok()) {
throw new Error(
`Failed to set tutorial enabled=${enabled}: ${res.status()} ${await res.text()}`,
)
}
}
async function resetTutorialProgress(request: APIRequestContext): Promise<void> {
const res = await request.patch(`${BACKEND}/user/tutorial-progress`, {
data: { reset: true },
})
if (!res.ok()) {
throw new Error(`Failed to reset tutorial progress: ${res.status()} ${await res.text()}`)
}
}
async function createChild(request: APIRequestContext, name: string): Promise<string> {
const pre = await request.get(`${BACKEND}/child/list`)
for (const c of (await pre.json()).children ?? []) {
if (c.name === name) await request.delete(`${BACKEND}/child/${c.id}`)
}
await request.put(`${BACKEND}/child/add`, { data: { name, age: 8 } })
const list = await request.get(`${BACKEND}/child/list`)
return (
(await list.json()).children?.find(
(c: { name: string; id: string }) => c.name === name,
)?.id ?? ''
)
}
async function createTask(
request: APIRequestContext,
name: string,
type: 'chore' | 'kindness' | 'penalty',
): Promise<string> {
const pre = await request.get(`${BACKEND}/task/list`)
for (const t of (await pre.json()).tasks ?? []) {
if (t.name === name) await request.delete(`${BACKEND}/task/${t.id}`)
}
await request.put(`${BACKEND}/task/add`, { data: { name, points: 5, type } })
const list = await request.get(`${BACKEND}/task/list`)
return (
(await list.json()).tasks?.find((t: { name: string; id: string }) => t.name === name)?.id ?? ''
)
}
async function createReward(request: APIRequestContext, name: string): Promise<string> {
const pre = await request.get(`${BACKEND}/reward/list`)
for (const r of (await pre.json()).rewards ?? []) {
if (r.name === name) await request.delete(`${BACKEND}/reward/${r.id}`)
}
await request.put(`${BACKEND}/reward/add`, {
data: { name, description: 'E2E dialog reward', cost: REWARD_COST },
})
const list = await request.get(`${BACKEND}/reward/list`)
return (
(await list.json()).rewards?.find((r: { name: string; id: string }) => r.name === name)?.id ??
''
)
}
async function createRoutine(request: APIRequestContext, name: string): Promise<string> {
const pre = await request.get(`${BACKEND}/routine/list`)
for (const r of (await pre.json()).routines ?? []) {
if (r.name === name) await request.delete(`${BACKEND}/routine/${r.id}`)
}
const res = await request.put(`${BACKEND}/routine/add`, { data: { name, points: 5 } })
return (await res.json()).routine?.id ?? ''
}
async function assignTask(
request: APIRequestContext,
childId: string,
taskId: string,
type: 'chore' | 'kindness' | 'penalty',
): Promise<void> {
const res = await request.put(`${BACKEND}/child/${childId}/set-tasks`, {
data: { task_ids: [taskId], type },
})
if (!res.ok()) {
throw new Error(`Failed to assign ${type}: ${res.status()} ${await res.text()}`)
}
}
async function assignReward(
request: APIRequestContext,
childId: string,
rewardId: string,
): Promise<void> {
const res = await request.put(`${BACKEND}/child/${childId}/set-rewards`, {
data: { reward_ids: [rewardId] },
})
if (!res.ok()) {
throw new Error(`Failed to assign reward: ${res.status()} ${await res.text()}`)
}
}
async function assignRoutine(
request: APIRequestContext,
childId: string,
routineId: string,
): Promise<void> {
const res = await request.post(`${BACKEND}/child/${childId}/assign-routine`, {
data: { routine_id: routineId },
})
if (!res.ok()) {
throw new Error(`Failed to assign routine: ${res.status()} ${await res.text()}`)
}
}
function helpButton(page: Page) {
return page.getByRole('button', { name: 'Show help for this screen' })
}
function modalBackdrop(page: Page) {
return page.locator('.modal-backdrop')
}
async function dismissAutoTutorialChain(page: Page): Promise<void> {
const card = page.locator('.tutorial-root .card')
for (let i = 0; i < 10; i++) {
if (!(await card.isVisible().catch(() => false))) return
await page.locator('.tutorial-root .btn-primary').click()
await page.waitForTimeout(200)
}
}
function sectionByHeading(page: Page, heading: string) {
return page.locator('.child-list-container').filter({
has: page.locator('h3', { hasText: heading }),
})
}
test.describe('Dialog help button and titles', () => {
test.describe.configure({ mode: 'serial' })
let childId = ''
let choreId = ''
let kindnessId = ''
let penaltyId = ''
let rewardId = ''
let routineId = ''
test.beforeAll(async ({ request }) => {
await setTutorialEnabled(request, true)
await resetTutorialProgress(request)
childId = await createChild(request, CHILD_NAME)
choreId = await createTask(request, CHORE_NAME, 'chore')
kindnessId = await createTask(request, KINDNESS_NAME, 'kindness')
penaltyId = await createTask(request, PENALTY_NAME, 'penalty')
rewardId = await createReward(request, REWARD_NAME)
routineId = await createRoutine(request, ROUTINE_NAME)
await assignTask(request, childId, choreId, 'chore')
await assignTask(request, childId, kindnessId, 'kindness')
await assignTask(request, childId, penaltyId, 'penalty')
await assignReward(request, childId, rewardId)
await assignRoutine(request, childId, routineId)
// Give the child enough points so the reward is ready to redeem.
await request.put(`${BACKEND}/child/${childId}/edit`, { data: { points: REWARD_COST } })
})
test.afterAll(async ({ request }) => {
if (childId) await request.delete(`${BACKEND}/child/${childId}`)
if (choreId) await request.delete(`${BACKEND}/task/${choreId}`)
if (kindnessId) await request.delete(`${BACKEND}/task/${kindnessId}`)
if (penaltyId) await request.delete(`${BACKEND}/task/${penaltyId}`)
if (rewardId) await request.delete(`${BACKEND}/reward/${rewardId}`)
if (routineId) await request.delete(`${BACKEND}/routine/${routineId}`)
await setTutorialEnabled(request, false)
await resetTutorialProgress(request)
})
test.beforeEach(async ({ page }) => {
await page.goto(`/parent/${childId}`)
await expect(page.getByText(CHILD_NAME, { exact: true }).first()).toBeVisible({ timeout: 10000 })
await dismissAutoTutorialChain(page)
await expect(helpButton(page)).toBeVisible({ timeout: 5000 })
})
test('Task confirm dialog hides help button and shows "Confirm Task" for chores', async ({
page,
}) => {
const card = sectionByHeading(page, 'Chores').locator('.item-card').filter({ hasText: CHORE_NAME })
await card.waitFor({ state: 'visible' })
await card.click()
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
await card.click()
await expect(modalBackdrop(page)).toBeVisible({ timeout: 3000 })
await expect(page.locator('.modal-title')).toHaveText('Confirm Task')
await expect(helpButton(page)).not.toBeVisible()
await page.getByRole('button', { name: 'Cancel' }).click()
await expect(modalBackdrop(page)).not.toBeVisible({ timeout: 3000 })
await expect(helpButton(page)).toBeVisible({ timeout: 5000 })
})
test('Task confirm dialog hides help button and shows "Confirm Act" for kindness acts', async ({
page,
}) => {
const card = sectionByHeading(page, 'Kindness Acts')
.locator('.item-card')
.filter({ hasText: KINDNESS_NAME })
await card.waitFor({ state: 'visible' })
await card.click()
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
await card.click()
await expect(modalBackdrop(page)).toBeVisible({ timeout: 3000 })
await expect(page.locator('.modal-title')).toHaveText('Confirm Act')
await expect(helpButton(page)).not.toBeVisible()
await page.getByRole('button', { name: 'Cancel' }).click()
await expect(modalBackdrop(page)).not.toBeVisible({ timeout: 3000 })
await expect(helpButton(page)).toBeVisible({ timeout: 5000 })
})
test('Task confirm dialog hides help button for penalties', async ({ page }) => {
const card = sectionByHeading(page, 'Penalties')
.locator('.item-card')
.filter({ hasText: PENALTY_NAME })
await card.waitFor({ state: 'visible' })
await card.click()
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
await card.click()
await expect(modalBackdrop(page)).toBeVisible({ timeout: 3000 })
await expect(page.locator('.modal-title')).toHaveText('Confirm Penalty')
await expect(helpButton(page)).not.toBeVisible()
await page.getByRole('button', { name: 'Cancel' }).click()
await expect(modalBackdrop(page)).not.toBeVisible({ timeout: 3000 })
await expect(helpButton(page)).toBeVisible({ timeout: 5000 })
})
test('Reward confirm dialog hides help button and shows "Grant Reward"', async ({ page }) => {
const card = sectionByHeading(page, 'Rewards').locator('.item-card').filter({ hasText: REWARD_NAME })
await card.waitFor({ state: 'visible' })
await expect(card.getByText('REWARD READY')).toBeVisible()
await card.click()
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
await card.click()
await expect(modalBackdrop(page)).toBeVisible({ timeout: 3000 })
await expect(page.locator('.modal-title')).toHaveText('Grant Reward')
await expect(helpButton(page)).not.toBeVisible()
await page.getByRole('button', { name: 'No', exact: true }).click()
await expect(modalBackdrop(page)).not.toBeVisible({ timeout: 3000 })
await expect(helpButton(page)).toBeVisible({ timeout: 5000 })
})
test('Routine confirm dialog hides help button and shows "Confirm Routine"', async ({ page }) => {
const card = sectionByHeading(page, 'Routines').locator('.item-card').filter({ hasText: ROUTINE_NAME })
await card.waitFor({ state: 'visible' })
await card.click()
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
await card.click()
await expect(modalBackdrop(page)).toBeVisible({ timeout: 3000 })
await expect(page.locator('.modal-title')).toHaveText('Confirm Routine')
await expect(helpButton(page)).not.toBeVisible()
await page.getByRole('button', { name: 'Cancel' }).click()
await expect(modalBackdrop(page)).not.toBeVisible({ timeout: 3000 })
await expect(helpButton(page)).toBeVisible({ timeout: 5000 })
})
test('help button replays the full tutorial chain when tutorial tips are disabled', async ({
page,
request,
}) => {
await setTutorialEnabled(request, false)
await resetTutorialProgress(request)
await page.goto(`/parent/${childId}`)
await expect(page.getByText(CHILD_NAME, { exact: true }).first()).toBeVisible({ timeout: 10000 })
await expect(page.locator('.tutorial-root .card')).not.toBeVisible()
await expect(helpButton(page)).toBeVisible({ timeout: 5000 })
await helpButton(page).click()
const cardTitle = page.locator('.tutorial-root .card .title')
await expect(cardTitle).toHaveText("This is your child's page")
await page.locator('.tutorial-root .btn-primary').click()
await expect(cardTitle).toHaveText('Assign chores')
await page.locator('.tutorial-root .btn-primary').click()
await expect(cardTitle).toHaveText('Assign kindness acts')
await page.locator('.tutorial-root .btn-primary').click()
await expect(cardTitle).toHaveText('Assign rewards')
await page.locator('.tutorial-root .btn-primary').click()
await expect(cardTitle).toHaveText('Assign routines')
await page.locator('.tutorial-root .btn-primary').click()
await expect(cardTitle).toHaveText('Assign penalties')
await page.locator('.tutorial-root .btn-primary').click()
await expect(cardTitle).toHaveText('Quick actions')
await page.locator('.tutorial-root .btn-primary').click()
await expect(page.locator('.tutorial-root .card')).not.toBeVisible()
})
})
@@ -0,0 +1,444 @@
import { test, expect, type APIRequestContext, type Page } from '@playwright/test'
const BACKEND = 'http://localhost:5000'
async function setTutorialEnabled(request: APIRequestContext, enabled: boolean): Promise<void> {
const res = await request.patch(`${BACKEND}/user/tutorial-progress`, {
data: { enabled },
})
if (!res.ok()) {
throw new Error(
`Failed to set tutorial enabled=${enabled}: ${res.status()} ${await res.text()}`,
)
}
}
async function resetTutorialProgress(request: APIRequestContext): Promise<void> {
const res = await request.patch(`${BACKEND}/user/tutorial-progress`, {
data: { reset: true },
})
if (!res.ok()) {
throw new Error(`Failed to reset tutorial progress: ${res.status()} ${await res.text()}`)
}
}
async function deleteAllChildren(request: APIRequestContext): Promise<void> {
const listRes = await request.get(`${BACKEND}/child/list`)
const data = await listRes.json()
for (const child of data.children ?? []) {
await request.delete(`${BACKEND}/child/${child.id}`)
}
}
async function createChild(request: APIRequestContext, name: string, age: number): Promise<string> {
const res = await request.put(`${BACKEND}/child/add`, {
data: { name, age, image_id: 'boy01' },
})
if (!res.ok()) {
throw new Error(`Failed to create child ${name}: ${res.status()} ${await res.text()}`)
}
// /child/add returns a message but not the created child, so read the list.
const id = await getFirstChildId(request)
if (!id) {
throw new Error(`Child ${name} was not found after creation`)
}
return id
}
async function getFirstChildId(request: APIRequestContext): Promise<string | null> {
const res = await request.get(`${BACKEND}/child/list`)
const data = await res.json()
return data.children?.[0]?.id ?? null
}
async function ensureChild(request: APIRequestContext, name: string, age: number): Promise<string> {
const existingId = await getFirstChildId(request)
if (existingId) return existingId
return createChild(request, name, age)
}
async function getFirstChoreId(request: APIRequestContext): Promise<string | null> {
const res = await request.get(`${BACKEND}/chore/list`)
const data = await res.json()
return data.tasks?.[0]?.id ?? null
}
async function createChore(
request: APIRequestContext,
name: string,
points: number,
): Promise<string> {
const res = await request.put(`${BACKEND}/chore/add`, {
data: { name, points, image_id: 'boy01' },
})
if (!res.ok()) {
throw new Error(`Failed to create chore ${name}: ${res.status()} ${await res.text()}`)
}
const id = await getFirstChoreId(request)
if (!id) {
throw new Error(`Chore ${name} was not found after creation`)
}
return id
}
async function assignChoreToChild(
request: APIRequestContext,
childId: string,
choreId: string,
): Promise<void> {
const res = await request.post(`${BACKEND}/child/${childId}/assign-task`, {
data: { task_id: choreId },
})
if (!res.ok()) {
throw new Error(
`Failed to assign chore ${choreId} to child ${childId}: ${res.status()} ${await res.text()}`,
)
}
}
async function createRoutine(
request: APIRequestContext,
name: string,
points: number,
): Promise<string> {
const res = await request.put(`${BACKEND}/routine/add`, {
data: { name, points, image_id: 'boy01' },
})
if (!res.ok()) {
throw new Error(`Failed to create routine ${name}: ${res.status()} ${await res.text()}`)
}
const data = await res.json()
const id = data.routine?.id
if (!id) {
throw new Error(`Routine ${name} was not found after creation`)
}
return id
}
async function assignRoutineToChild(
request: APIRequestContext,
childId: string,
routineId: string,
): Promise<void> {
const res = await request.post(`${BACKEND}/child/${childId}/assign-routine`, {
data: { routine_id: routineId },
})
if (!res.ok()) {
throw new Error(
`Failed to assign routine ${routineId} to child ${childId}: ${res.status()} ${await res.text()}`,
)
}
}
function getTutorialCard(page: Page) {
return page.locator('.tutorial-root .card')
}
function getTutorialTitle(page: Page) {
return page.locator('.tutorial-root .card .title')
}
async function expectTutorialCard(page: Page, title: string): Promise<void> {
await expect(getTutorialCard(page)).toBeVisible({ timeout: 10000 })
await expect(getTutorialTitle(page)).toHaveText(title)
}
async function dismissTutorial(page: Page): Promise<void> {
const card = getTutorialCard(page)
if (await card.isVisible({ timeout: 1000 }).catch(() => false)) {
// Use "Cancel" to clear the active step and drain the queue in one
// action, avoiding chained steps that would keep the card visible.
await page.locator('.tutorial-root .btn-skip').click()
await expect(card).not.toBeVisible({ timeout: 5000 })
}
}
async function clickTutorialNext(page: Page): Promise<void> {
await page.locator('.tutorial-root .btn-primary').click()
}
/**
* Dismiss a tutorial by clicking the primary button repeatedly until the card
* disappears. This walks through any chained steps without setting sessionSkipped.
*/
async function dismissTutorialChain(page: Page, maxClicks = 10): Promise<void> {
const card = getTutorialCard(page)
for (let i = 0; i < maxClicks; i++) {
if (!(await card.isVisible().catch(() => false))) return
await page.locator('.tutorial-root .btn-primary').click()
await page.waitForTimeout(200)
}
await expect(card).not.toBeVisible({ timeout: 3000 })
}
test.describe('Tutorial system', () => {
// Tutorial state is global to the signed-in user; run these tests sequentially
// so parallel resets do not interfere with each other.
test.describe.configure({ mode: 'serial' })
test.beforeEach(async ({ request }) => {
await deleteAllChildren(request)
await setTutorialEnabled(request, false)
await resetTutorialProgress(request)
await setTutorialEnabled(request, true)
await resetTutorialProgress(request)
})
test.afterEach(async ({ page, request }) => {
await dismissTutorial(page)
await setTutorialEnabled(request, false)
await resetTutorialProgress(request)
})
test('children list shows create-child tutorial when no children exist', async ({
page,
request,
}) => {
// Retry deletion + navigation so a concurrent test that creates a child
// does not leave us on the child-points tutorial instead of create-child.
await expect(async () => {
await deleteAllChildren(request)
await page.goto('/parent')
await expect(page).toHaveURL('/parent')
await expectTutorialCard(page, 'Add your child')
}).toPass({ timeout: 20000 })
})
test('help button shows parent-children-list chain on the children list', async ({
page,
request,
}) => {
const childName = 'TutorialKid'
await ensureChild(request, childName, 7)
await page.goto('/parent')
await expect(page).toHaveURL('/parent')
// Wait for the child card to render ( tolerate duplicate names from repeat runs ).
await expect(page.getByText(childName, { exact: true }).first()).toBeVisible({ timeout: 10000 })
// The children list may auto-show a brief loading-state hint. Clear any
// active chain so the help button is reachable.
await dismissTutorialChain(page)
await page.getByRole('button', { name: 'Show help for this screen' }).click()
await expectTutorialCard(page, 'Your children')
await clickTutorialNext(page)
await expectTutorialCard(page, 'Points')
await clickTutorialNext(page)
await expectTutorialCard(page, 'Tap a child')
})
test('create chore form shows edit-chore-name tutorial', async ({ page }) => {
await page.goto('/parent/tasks/chores/create')
await expect(page).toHaveURL('/parent/tasks/chores/create')
await expectTutorialCard(page, 'Chore Name')
})
test('help button shows list-chore-help on the chore list', async ({ page }) => {
await page.goto('/parent/tasks/chores')
await expect(page).toHaveURL('/parent/tasks/chores')
// No auto-shown tutorial should appear on this page.
await expect(getTutorialCard(page)).not.toBeVisible({ timeout: 3000 })
const helpButton = page.getByRole('button', { name: 'Show help for this screen' })
await expect(helpButton).toBeVisible()
await helpButton.click()
await expectTutorialCard(page, 'Create your chore')
})
test('help button chains through list-edit-hint on the chore list', async ({ page }) => {
await page.goto('/parent/tasks/chores')
await expect(page).toHaveURL('/parent/tasks/chores')
await page.getByRole('button', { name: 'Show help for this screen' }).click()
await expectTutorialCard(page, 'Create your chore')
await clickTutorialNext(page)
await expectTutorialCard(page, 'Edit items')
})
test('help button shows select-child on the child detail page', async ({ page, request }) => {
const childName = 'TutorialKid'
const childId = await ensureChild(request, childName, 7)
await page.goto(`/parent/${childId}`)
await expect(page).toHaveURL(`/parent/${childId}`)
// Wait for the child data to load and the assign buttons to render.
await expect(page.getByText(childName, { exact: true }).first()).toBeVisible({ timeout: 10000 })
await expect(page.getByRole('button', { name: 'Assign Chores' }).first()).toBeVisible({
timeout: 10000,
})
// The page auto-shows select-child on first visit. Walk through the chain
// so the help button is reachable and sessionSkipped stays false.
await dismissTutorialChain(page)
await page.getByRole('button', { name: 'Show help for this screen' }).click()
await expectTutorialCard(page, "This is your child's page")
await clickTutorialNext(page)
await expectTutorialCard(page, 'Assign chores')
})
test('kebab menu on assigned chore shows chore-kebab-menu tutorial', async ({
page,
request,
}) => {
const childName = 'TutorialKid'
const childId = await ensureChild(request, childName, 7)
const choreId = await createChore(request, 'TutorialChore', 10)
await assignChoreToChild(request, childId, choreId)
await page.goto(`/parent/${childId}`)
await expect(page).toHaveURL(`/parent/${childId}`)
// Wait for the child data and the assigned chore to render.
await expect(page.getByText(childName, { exact: true }).first()).toBeVisible({ timeout: 10000 })
await expect(page.getByText('TutorialChore').first()).toBeVisible({ timeout: 10000 })
// Clear the auto-shown select-child chain.
await dismissTutorialChain(page)
// Click the chore card to make it ready (reveals the kebab button).
await page.locator('.item-card').filter({ hasText: 'TutorialChore' }).first().click()
// Click the kebab button and verify the chore kebab tutorial fires.
const kebabButton = page
.locator('.kebab-btn')
.filter({ has: page.locator('text=⋮') })
.first()
await expect(kebabButton).toBeVisible({ timeout: 5000 })
await kebabButton.click()
await expectTutorialCard(page, 'Chore actions')
await clickTutorialNext(page)
await expectTutorialCard(page, 'Edit points')
await clickTutorialNext(page)
await expectTutorialCard(page, 'Change schedule')
})
test('kebab menu on assigned routine shows routine-kebab-menu tutorial', async ({
page,
request,
}) => {
const childName = 'TutorialKid'
const childId = await ensureChild(request, childName, 7)
const routineId = await createRoutine(request, 'TutorialRoutine', 15)
await assignRoutineToChild(request, childId, routineId)
await page.goto(`/parent/${childId}`)
await expect(page).toHaveURL(`/parent/${childId}`)
// Wait for the child data and the assigned routine to render.
await expect(page.getByText(childName, { exact: true }).first()).toBeVisible({ timeout: 10000 })
await expect(page.getByText('TutorialRoutine').first()).toBeVisible({ timeout: 10000 })
// Clear the auto-shown select-child chain.
await dismissTutorialChain(page)
// Click the routine card to make it ready (reveals the kebab button).
await page.locator('.item-card').filter({ hasText: 'TutorialRoutine' }).first().click()
// Click the kebab button and verify the routine kebab tutorial fires.
const kebabButton = page
.locator('.kebab-btn')
.filter({ has: page.locator('text=⋮') })
.first()
await expect(kebabButton).toBeVisible({ timeout: 5000 })
await kebabButton.click()
await expectTutorialCard(page, 'Routine actions')
await clickTutorialNext(page)
await expectTutorialCard(page, 'Edit routine')
await clickTutorialNext(page)
await expectTutorialCard(page, 'Edit points')
await clickTutorialNext(page)
await expectTutorialCard(page, 'Change schedule')
})
test('dismissing an auto-shown tutorial persists across reloads', async ({ page, request }) => {
await deleteAllChildren(request)
await page.goto('/parent')
await expectTutorialCard(page, 'Add your child')
await page.locator('.tutorial-root .btn-primary').click()
await expect(getTutorialCard(page)).not.toBeVisible({ timeout: 5000 })
await page.reload()
await expect(getTutorialCard(page)).not.toBeVisible({ timeout: 3000 })
})
test('add-child FAB is disabled while the create-child tutorial is showing', async ({
page,
request,
}) => {
await deleteAllChildren(request)
await page.goto('/parent')
await expectTutorialCard(page, 'Add your child')
const fab = page.locator('.fab')
await expect(fab).toBeVisible()
await expect(fab).toBeDisabled()
// The page should still be on the children list and the tutorial visible.
await expect(page).toHaveURL('/parent')
await expect(getTutorialCard(page)).toBeVisible()
})
test('child form inputs are disabled while the edit-child-name tutorial is showing', async ({
page,
}) => {
await page.goto('/parent/children/create')
await expect(page).toHaveURL('/parent/children/create')
await expectTutorialCard(page, "Child's Name")
await expect(page.locator('input#name')).toBeDisabled()
await expect(page.locator('input#age')).toBeDisabled()
// The tutorial card should remain visible after checking the inputs.
await expect(getTutorialCard(page)).toBeVisible()
})
test('clicking the highlighted Points area does not navigate while child-points tutorial is showing', async ({
page,
request,
}) => {
const childName = 'TutorialPointsKid'
await ensureChild(request, childName, 7)
await page.goto('/parent')
await expect(page).toHaveURL('/parent')
// Wait for the child card to render so the children list has finished
// loading before we assert on the Points tutorial.
await expect(page.getByText(childName, { exact: true }).first()).toBeVisible({ timeout: 10000 })
await expectTutorialCard(page, 'Points')
// Small resilience delay so the spotlight blocker is positioned before the
// synthetic click reaches it.
await page.waitForTimeout(50)
const points = page.locator('.card .points').first()
await expect(points).toBeVisible()
const box = await points.boundingBox()
if (!box) throw new Error('Could not resolve points element bounding box')
// Click the center of the highlighted points area. The tutorial spotlight
// blocker should intercept the click, preventing the card click handler
// from navigating to the child detail page.
await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2)
await expect(page).toHaveURL('/parent')
await expect(getTutorialCard(page)).toBeVisible()
})
})
@@ -6,6 +6,17 @@ import { E2E_PIN } from '../../e2e-constants'
const BACKEND = 'http://localhost:5000' const BACKEND = 'http://localhost:5000'
const NEW_PIN = '5678' const NEW_PIN = '5678'
async function openAccountSection(page: import('@playwright/test').Page): Promise<void> {
const accountHeader = page
.locator('.profile-section')
.filter({ has: page.locator('.section-title', { hasText: 'Account' }) })
.locator('.section-header')
if ((await accountHeader.getAttribute('aria-expanded')) === 'false') {
await accountHeader.click()
}
await page.locator('#section-account').waitFor({ state: 'visible' })
}
async function setPinDirectly(request: APIRequestContext, pin: string): Promise<void> { async function setPinDirectly(request: APIRequestContext, pin: string): Promise<void> {
// Request a new code, retrieve it via test endpoint, verify it, then set the pin // Request a new code, retrieve it via test endpoint, verify it, then set the pin
await request.post(`${BACKEND}/user/request-pin-setup`) await request.post(`${BACKEND}/user/request-pin-setup`)
@@ -25,6 +36,7 @@ test.describe('User Profile Change Parent PIN', () => {
test('Change Parent PIN link navigates to PIN setup page', async ({ page }) => { test('Change Parent PIN link navigates to PIN setup page', async ({ page }) => {
await page.goto('/parent/profile') await page.goto('/parent/profile')
await openAccountSection(page)
await page.getByRole('button', { name: 'Change Parent PIN' }).click() await page.getByRole('button', { name: 'Change Parent PIN' }).click()
@@ -35,6 +47,7 @@ test.describe('User Profile Change Parent PIN', () => {
test('Back from PIN setup page returns to profile', async ({ page }) => { test('Back from PIN setup page returns to profile', async ({ page }) => {
// Navigate from the profile page so browser history exists // Navigate from the profile page so browser history exists
await page.goto('/parent/profile') await page.goto('/parent/profile')
await openAccountSection(page)
await page.getByRole('button', { name: 'Change Parent PIN' }).click() await page.getByRole('button', { name: 'Change Parent PIN' }).click()
await expect(page).toHaveURL(/\/parent\/pin-setup/) await expect(page).toHaveURL(/\/parent\/pin-setup/)
@@ -3,11 +3,23 @@
import { test, expect } from '@playwright/test' import { test, expect } from '@playwright/test'
import { E2E_DELETE_EMAIL, E2E_DELETE_PASSWORD } from '../../e2e-constants' import { E2E_DELETE_EMAIL, E2E_DELETE_PASSWORD } from '../../e2e-constants'
async function openAccountSection(page: import('@playwright/test').Page): Promise<void> {
const accountHeader = page
.locator('.profile-section')
.filter({ has: page.locator('.section-title', { hasText: 'Account' }) })
.locator('.section-header')
if ((await accountHeader.getAttribute('aria-expanded')) === 'false') {
await accountHeader.click()
}
await page.locator('#section-account').waitFor({ state: 'visible' })
}
test.describe('User Profile Delete Account', () => { test.describe('User Profile Delete Account', () => {
test.describe.configure({ mode: 'serial' }) test.describe.configure({ mode: 'serial' })
test('Delete My Account opens confirmation dialog', async ({ page }) => { test('Delete My Account opens confirmation dialog', async ({ page }) => {
await page.goto('/parent/profile') await page.goto('/parent/profile')
await openAccountSection(page)
await page.getByRole('button', { name: 'Delete My Account' }).click() await page.getByRole('button', { name: 'Delete My Account' }).click()
@@ -24,6 +36,7 @@ test.describe('User Profile Delete Account', () => {
test('Delete button stays disabled for incomplete email', async ({ page }) => { test('Delete button stays disabled for incomplete email', async ({ page }) => {
await page.goto('/parent/profile') await page.goto('/parent/profile')
await openAccountSection(page)
await page.getByRole('button', { name: 'Delete My Account' }).click() await page.getByRole('button', { name: 'Delete My Account' }).click()
await expect(page.locator('.modal-title', { hasText: 'Delete Your Account?' })).toBeVisible() await expect(page.locator('.modal-title', { hasText: 'Delete Your Account?' })).toBeVisible()
@@ -36,6 +49,7 @@ test.describe('User Profile Delete Account', () => {
test('Delete button enables when matching email is entered', async ({ page }) => { test('Delete button enables when matching email is entered', async ({ page }) => {
await page.goto('/parent/profile') await page.goto('/parent/profile')
await openAccountSection(page)
await page.getByRole('button', { name: 'Delete My Account' }).click() await page.getByRole('button', { name: 'Delete My Account' }).click()
await expect(page.locator('.modal-title', { hasText: 'Delete Your Account?' })).toBeVisible() await expect(page.locator('.modal-title', { hasText: 'Delete Your Account?' })).toBeVisible()
@@ -48,6 +62,7 @@ test.describe('User Profile Delete Account', () => {
test('Cancel closes the dialog without deleting the account', async ({ page }) => { test('Cancel closes the dialog without deleting the account', async ({ page }) => {
await page.goto('/parent/profile') await page.goto('/parent/profile')
await openAccountSection(page)
await page.getByRole('button', { name: 'Delete My Account' }).click() await page.getByRole('button', { name: 'Delete My Account' }).click()
await expect(page.locator('.modal-title', { hasText: 'Delete Your Account?' })).toBeVisible() await expect(page.locator('.modal-title', { hasText: 'Delete Your Account?' })).toBeVisible()
@@ -57,12 +72,13 @@ test.describe('User Profile Delete Account', () => {
await expect( await expect(
page.locator('.modal-title', { hasText: 'Delete Your Account?' }), page.locator('.modal-title', { hasText: 'Delete Your Account?' }),
).not.toBeVisible() ).not.toBeVisible()
await expect(page.getByRole('heading', { name: 'User Profile' })).toBeVisible() await expect(page.getByRole('heading', { name: 'Profile' })).toBeVisible()
await expect(page).toHaveURL(/\/parent\/profile/) await expect(page).toHaveURL(/\/parent\/profile/)
}) })
test('Backdrop click does NOT close the delete dialog', async ({ page }) => { test('Backdrop click does NOT close the delete dialog', async ({ page }) => {
await page.goto('/parent/profile') await page.goto('/parent/profile')
await openAccountSection(page)
await page.getByRole('button', { name: 'Delete My Account' }).click() await page.getByRole('button', { name: 'Delete My Account' }).click()
await expect(page.locator('.modal-title', { hasText: 'Delete Your Account?' })).toBeVisible() await expect(page.locator('.modal-title', { hasText: 'Delete Your Account?' })).toBeVisible()
@@ -77,6 +93,7 @@ test.describe('User Profile Delete Account', () => {
page, page,
}) => { }) => {
await page.goto('/parent/profile') await page.goto('/parent/profile')
await openAccountSection(page)
await page.getByRole('button', { name: 'Delete My Account' }).click() await page.getByRole('button', { name: 'Delete My Account' }).click()
await expect(page.locator('.modal-title', { hasText: 'Delete Your Account?' })).toBeVisible() await expect(page.locator('.modal-title', { hasText: 'Delete Your Account?' })).toBeVisible()
@@ -32,11 +32,27 @@ async function restoreProfile(request: APIRequestContext, profile: ProfileData):
}) })
} }
/** Expand a collapsible profile section by its header title and wait for its content. */
async function expandSection(page: import('@playwright/test').Page, title: string): Promise<void> {
const header = page
.locator('.profile-section')
.filter({ has: page.locator('.section-title', { hasText: title }) })
.locator('.section-header')
const expanded = await header.getAttribute('aria-expanded').catch(() => 'false')
if (expanded === 'false') {
await header.click()
}
await page.locator(`#section-${title.toLowerCase()}`).waitFor({ state: 'visible' })
}
/** Navigate to /parent/profile and wait for the form to finish loading. */ /** Navigate to /parent/profile and wait for the form to finish loading. */
async function gotoProfile(page: import('@playwright/test').Page): Promise<void> { async function gotoProfile(page: import('@playwright/test').Page): Promise<void> {
await page.goto('/parent/profile') await page.goto('/parent/profile')
// EntityEditForm hides the form behind v-if while loading=true; wait for it to render. // EntityEditForm hides the form behind v-if while loading=true; wait for it to render.
await expect(page.getByLabel('First Name')).toBeVisible({ timeout: 10000 }) await expect(page.getByLabel('First Name')).toBeVisible({ timeout: 10000 })
// Expand sections that are collapsed by default so their fields/buttons are reachable.
await expandSection(page, 'Account')
await expandSection(page, 'Notifications')
} }
test.describe('User Profile editing', () => { test.describe('User Profile editing', () => {
@@ -55,7 +71,7 @@ test.describe('User Profile editing', () => {
test('Profile page loads with correct data', async ({ page }) => { test('Profile page loads with correct data', async ({ page }) => {
await gotoProfile(page) await gotoProfile(page)
await expect(page.getByRole('heading', { name: 'User Profile' })).toBeVisible() await expect(page.getByRole('heading', { name: 'Profile' })).toBeVisible()
await expect(page.getByLabel('First Name')).toHaveValue(E2E_FIRST_NAME) await expect(page.getByLabel('First Name')).toHaveValue(E2E_FIRST_NAME)
await expect(page.getByLabel('Last Name')).toHaveValue('Tester') await expect(page.getByLabel('Last Name')).toHaveValue('Tester')
await expect(page.getByLabel('Email Address')).toHaveValue(E2E_EMAIL) await expect(page.getByLabel('Email Address')).toHaveValue(E2E_EMAIL)
@@ -69,82 +85,33 @@ test.describe('User Profile editing', () => {
await page.getByRole('menuitem', { name: 'Profile' }).click() await page.getByRole('menuitem', { name: 'Profile' }).click()
await expect(page).toHaveURL('/parent/profile') await expect(page).toHaveURL('/parent/profile')
await page.getByRole('button', { name: 'Cancel' }).click() // The profile view shows a header Back button (auto-save form has no Cancel).
await page.getByRole('button', { name: 'Back' }).click()
await expect(page).toHaveURL('/parent') await expect(page).toHaveURL('/parent')
}) })
test('Save is disabled when form is clean (not dirty)', async ({ page }) => { test('Name changes auto-save on blur', async ({ page, request }) => {
await gotoProfile(page)
await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled()
})
test('Save is disabled when First Name is empty', async ({ page }) => {
await gotoProfile(page)
await page.getByLabel('First Name').fill('')
await page.getByLabel('First Name').blur()
await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled()
})
test('Save is disabled when Last Name is empty', async ({ page }) => {
await gotoProfile(page)
await page.getByLabel('Last Name').fill('')
await page.getByLabel('Last Name').blur()
await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled()
})
test('Save is disabled when both name fields are empty', async ({ page }) => {
await gotoProfile(page)
await page.getByLabel('First Name').fill('')
await page.getByLabel('Last Name').fill('')
await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled()
})
test('Save enables when a name is changed', async ({ page }) => {
await gotoProfile(page)
await page.getByLabel('First Name').fill('UpdatedE2E')
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled()
})
test('Save persists name changes and shows confirmation modal', async ({ page }) => {
await gotoProfile(page) await gotoProfile(page)
await page.getByLabel('First Name').fill('UpdatedE2E') await page.getByLabel('First Name').fill('UpdatedE2E')
await page.getByLabel('Last Name').fill('UpdatedTester') await page.getByLabel('Last Name').fill('UpdatedTester')
await page.getByRole('button', { name: 'Save' }).click() await page.getByLabel('Last Name').blur()
const dialog = page.locator('.modal-dialog') // Wait for the auto-save PUT to complete and verify persistence via API.
await expect(dialog.locator('.modal-title', { hasText: 'Profile Updated' })).toBeVisible() await expect
await expect(dialog.getByText('Your profile was updated successfully.')).toBeVisible() .poll(async () => {
await dialog.getByRole('button', { name: 'OK' }).click() const profile = await getProfile(request)
return profile.first_name === 'UpdatedE2E' && profile.last_name === 'UpdatedTester'
})
.toBe(true)
// OK navigates back; go back to profile to verify persistence // Reloading the profile page shows the persisted values.
await gotoProfile(page) await gotoProfile(page)
await expect(page.getByLabel('First Name')).toHaveValue('UpdatedE2E') await expect(page.getByLabel('First Name')).toHaveValue('UpdatedE2E')
await expect(page.getByLabel('Last Name')).toHaveValue('UpdatedTester') await expect(page.getByLabel('Last Name')).toHaveValue('UpdatedTester')
}) })
test('Cancel discards unsaved changes', async ({ page }) => {
await gotoProfile(page)
await page.getByLabel('First Name').fill('Discarded')
await page.getByRole('button', { name: 'Cancel' }).click()
// Navigate back to verify no changes were saved
await gotoProfile(page)
await expect(page.getByLabel('First Name')).toHaveValue(E2E_FIRST_NAME)
})
test('Email field is read-only', async ({ page }) => { test('Email field is read-only', async ({ page }) => {
await gotoProfile(page) await gotoProfile(page)
@@ -153,7 +120,7 @@ test.describe('User Profile editing', () => {
await expect(emailInput).toHaveValue(E2E_EMAIL) await expect(emailInput).toHaveValue(E2E_EMAIL)
}) })
test('Change profile image (built-in)', async ({ page }) => { test('Change profile image (built-in)', async ({ page, request }) => {
await gotoProfile(page) await gotoProfile(page)
// Wait for images to load // Wait for images to load
@@ -180,24 +147,22 @@ test.describe('User Profile editing', () => {
// Confirm it is now selected // Confirm it is now selected
await expect(images.nth(targetIndex)).toHaveClass(/selected/) await expect(images.nth(targetIndex)).toHaveClass(/selected/)
// Save // Images auto-save on selection; verify via API.
await page.getByRole('button', { name: 'Save' }).click() await expect
await expect( .poll(async () => {
page.locator('.modal-dialog .modal-title', { hasText: 'Profile Updated' }), const profile = await getProfile(request)
).toBeVisible() return Boolean(profile.image_id)
await page.locator('.modal-dialog').getByRole('button', { name: 'OK' }).click() })
.toBe(true)
// Re-visit and confirm selection persists // Re-visit and confirm selection persists
await gotoProfile(page) await gotoProfile(page)
await page.waitForSelector('.selectable-image') await page.waitForSelector('.selectable-image')
const selectedSrc = await page.locator('.selectable-image.selected').getAttribute('src') const selectedSrc = await page.locator('.selectable-image.selected').getAttribute('src')
expect(selectedSrc).toBeTruthy() expect(selectedSrc).toBeTruthy()
// The URL will differ after a new load (Object URL vs cached), so verify via API
const profile = await getProfile(page.request)
expect(profile.image_id).toBeTruthy()
}) })
test('Upload a custom profile image', async ({ page }) => { test('Upload a custom profile image', async ({ page, request }) => {
await gotoProfile(page) await gotoProfile(page)
await page.waitForSelector('.selectable-image') await page.waitForSelector('.selectable-image')
@@ -210,14 +175,13 @@ test.describe('User Profile editing', () => {
// The uploaded image appears first in the list and is selected // The uploaded image appears first in the list and is selected
await expect(page.locator('.selectable-image').first()).toHaveClass(/selected/) await expect(page.locator('.selectable-image').first()).toHaveClass(/selected/)
// Save is now enabled // Images auto-save on upload; verify via API.
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled() await expect
await page.getByRole('button', { name: 'Save' }).click() .poll(async () => {
const profile = await getProfile(request)
await expect( return Boolean(profile.image_id)
page.locator('.modal-dialog .modal-title', { hasText: 'Profile Updated' }), })
).toBeVisible() .toBe(true)
await page.locator('.modal-dialog').getByRole('button', { name: 'OK' }).click()
}) })
test('Change Password shows email-sent modal', async ({ page }) => { test('Change Password shows email-sent modal', async ({ page }) => {
@@ -243,6 +207,6 @@ test.describe('User Profile editing', () => {
await dialog.getByRole('button', { name: 'OK' }).click() await dialog.getByRole('button', { name: 'OK' }).click()
// Modal dismissed, back on the profile page // Modal dismissed, back on the profile page
await expect(page.getByRole('heading', { name: 'User Profile' })).toBeVisible() await expect(page.getByRole('heading', { name: 'Profile' })).toBeVisible()
}) })
}) })
+120
View File
@@ -0,0 +1,120 @@
# Routines Feature E2E Plan
## Application Overview
The routines feature adds a new parent-defined checklist entity that sits between chores and rewards in child mode. A routine is confirmed as a whole (not per-item), then approved or rejected by the parent. Routine visibility and state are schedule-aware, include deadline extension support, and support per-child point overrides.
This plan is intentionally implementation-driven so we can incrementally add tests as each phase ships.
---
## Coverage Areas
### 1. Parent Routine Library Management
File target: e2e/mode_parent/routines/routine-library.spec.ts
1. Parent can create a routine with name, points, image.
2. Parent can add multiple routine items and persist item order.
3. Parent can edit routine metadata and item metadata.
4. Parent can delete a routine item.
5. Parent can delete a routine and it disappears from routine library list.
### 2. Parent Child Assignment and Management
File target: e2e/mode_parent/routines/routine-assignment.spec.ts
1. Parent can assign routine to a child from assignable list.
2. Parent can remove routine assignment from child.
3. Parent can set/replace full routine assignment list for a child.
4. Parent can set routine point override and child-facing value reflects override.
5. Parent can set routine schedule and update it later.
6. Parent can extend routine deadline for today.
### 3. Child Routine Rendering and Navigation
File target: e2e/mode_child/routines/routine-visibility.spec.ts
1. Routines list renders between chores and rewards.
2. Clicking a routine opens routine detail route (/child/:id/routine/:routineId).
3. Detail view shows routine image, title, points, and non-interactive item list.
4. Back navigation returns to child main view.
### 4. Child Routine Confirmation Flow
File target: e2e/mode_child/routines/routine-confirmation.spec.ts
1. Child can mark routine Done; routine becomes pending.
2. Child can cancel a pending routine confirmation.
3. Child cannot submit duplicate pending confirmations.
4. Approved-today routine shows completed state on child UI.
5. Rejected routine returns to available state.
### 5. Parent Pending Confirmation Flow
File target: e2e/mode_parent/routines/routine-approval.spec.ts
1. Routine confirmation appears in parent notification/pending list.
2. Parent can approve routine; child points increase by default routine points.
3. Parent can reject routine; child points do not change.
4. Parent can reset approved/rejected routine to clear completion state.
5. If routine override exists, approval uses override points instead of base points.
### 6. Routine Scheduling and Deadline Behavior
File target: e2e/mode_child/routines/routine-schedule.spec.ts
1. Day-based schedules only show routines on scheduled days.
2. Interval schedules show/hide routines on expected interval dates.
3. Routine with deadline in the past displays TOO LATE and blocks Done.
4. Extending deadline removes TOO LATE state for that day.
5. Changing schedule resets stale pending status.
### 7. Cascade and Data Integrity
File target: e2e/mode_parent/routines/routine-cascade.spec.ts
1. Deleting routine removes it from all assigned children.
2. Deleting routine removes routine items.
3. Deleting routine removes routine schedules and extensions.
4. Deleting routine removes routine point overrides.
5. Deleting child removes child routine schedules/extensions/overrides.
### 8. SSE Reactivity
File target: e2e/multi-session/routines/routine-sse.spec.ts
1. Parent routine add/edit/delete updates child assignment views without refresh.
2. Child routine pending/approved/rejected/reset updates parent notification UI without refresh.
3. Schedule/extension changes update child routine card state without refresh.
4. Override changes update points display without refresh.
---
## Test Data and Execution Notes
1. Use API seeding in beforeAll and cleanup in afterAll per spec file.
2. Use stable role/label-based locators only.
3. Avoid /auth/login navigation in tests; rely on global storageState.
4. For time-sensitive schedule tests, set deterministic times (or use clock controls where practical).
---
## Unit Test Expansion Checklist
### Backend unit tests to add during implementation
1. Routine CRUD API validation and ownership checks.
2. Routine item CRUD and ordering behavior.
3. Child routine assignment list and assignable-list filtering.
4. Routine confirmation approve/reject/reset state transitions.
5. Routine schedule and extension endpoints (including duplicate extension conflict).
6. Routine deletion cascades (items/schedules/extensions/overrides/child assignments).
### Frontend unit tests to add during implementation
1. API helper coverage for routine endpoints.
2. ScheduleModal entityType routing behavior for task vs routine.
3. Child routine list sorting/filtering utility behavior.
4. Routine detail confirmation UI state transitions.
5. Parent pending confirmation card rendering for entity_type='routine'.
+41 -5
View File
@@ -4,15 +4,13 @@ import {
STORAGE_STATE_NO_PIN, STORAGE_STATE_NO_PIN,
STORAGE_STATE_DELETE, STORAGE_STATE_DELETE,
STORAGE_STATE_CC, STORAGE_STATE_CC,
STORAGE_STATE_TUTORIAL,
} from './e2e/e2e-constants' } from './e2e/e2e-constants'
/** /**
* Read environment variables from file. * E2E MongoDB configuration is hardcoded below. Developers can override
* https://github.com/motdotla/dotenv * values via environment variables; see .env.test for an example.
*/ */
// import dotenv from 'dotenv';
// import path from 'path';
// dotenv.config({ path: path.resolve(__dirname, '.env') });
/** /**
* See https://playwright.dev/docs/test-configuration. * See https://playwright.dev/docs/test-configuration.
@@ -48,6 +46,12 @@ export default defineConfig({
// Depends on 'setup' because e2e-seed (run in setup) truncates all users first. // Depends on 'setup' because e2e-seed (run in setup) truncates all users first.
{ name: 'setup-delete', testMatch: /auth-delete\.setup\.ts/, dependencies: ['setup'] }, { name: 'setup-delete', testMatch: /auth-delete\.setup\.ts/, dependencies: ['setup'] },
{ name: 'setup-cc', testMatch: /auth-cc\.setup\.ts/, dependencies: ['setup'] }, { name: 'setup-cc', testMatch: /auth-cc\.setup\.ts/, dependencies: ['setup'] },
{
name: 'setup-tutorial',
testMatch: /auth-tutorial\.setup\.ts/,
// Depends on setup because e2e-seed truncates all users first.
dependencies: ['setup'],
},
{ {
// Bucket A: child-options tests — run before create-child so that // Bucket A: child-options tests — run before create-child so that
@@ -128,6 +132,15 @@ export default defineConfig({
testMatch: [/mode_parent\/notifications\/.+\.spec\.ts/], testMatch: [/mode_parent\/notifications\/.+\.spec\.ts/],
}, },
{
// Bucket: routine tests — child-mode overlay flow (confirm + cancel) and
// parent notification appearance. Each spec creates its own isolated child.
name: 'chromium-routines',
use: { ...devices['Desktop Chrome'], storageState: STORAGE_STATE },
dependencies: ['setup'],
testMatch: [/mode_parent\/routines\/.+\.spec\.ts/],
},
{ {
// Bucket: parent profile button tests (permanent parent mode). // Bucket: parent profile button tests (permanent parent mode).
name: 'chromium-profile-button', name: 'chromium-profile-button',
@@ -161,6 +174,25 @@ export default defineConfig({
testMatch: [/mode_parent\/user-profile\/delete-account\.spec\.ts/], testMatch: [/mode_parent\/user-profile\/delete-account\.spec\.ts/],
}, },
{
// Bucket: tutorial system tests — uses an isolated user so tutorial
// enabled/progress state never interferes with other buckets.
name: 'chromium-tutorial',
use: { ...devices['Desktop Chrome'], storageState: STORAGE_STATE_TUTORIAL },
dependencies: ['setup-tutorial'],
testMatch: [/mode_parent\/tutorial\/.+\.spec\.ts/],
testIgnore: [/mode_parent\/tutorial\/dialog-help-button\.spec\.ts/],
},
{
// Bucket: dialog help-button/title tests — depends on the tutorial bucket
// so it reuses the isolated tutorial user without running concurrently.
name: 'chromium-dialog',
use: { ...devices['Desktop Chrome'], storageState: STORAGE_STATE_TUTORIAL },
dependencies: ['chromium-tutorial'],
testMatch: [/mode_parent\/tutorial\/dialog-help-button\.spec\.ts/],
},
{ {
name: 'chromium-tasks-rewards', name: 'chromium-tasks-rewards',
use: { ...devices['Desktop Chrome'], storageState: STORAGE_STATE }, use: { ...devices['Desktop Chrome'], storageState: STORAGE_STATE },
@@ -176,6 +208,8 @@ export default defineConfig({
/mode_parent\/user-profile\//, /mode_parent\/user-profile\//,
/mode_parent\/chore-scheduler\//, /mode_parent\/chore-scheduler\//,
/mode_parent\/notifications\//, /mode_parent\/notifications\//,
/mode_parent\/routines\//,
/mode_parent\/tutorial\//,
], ],
}, },
@@ -251,6 +285,8 @@ export default defineConfig({
'BNKkHdq45uLigohSG7c1TwlAo7ETncoRVLQK02LxHgu2P1DgSJD9njRMfbbzUsaTQGllvLBz7An1WiWsNYQhvKE', 'BNKkHdq45uLigohSG7c1TwlAo7ETncoRVLQK02LxHgu2P1DgSJD9njRMfbbzUsaTQGllvLBz7An1WiWsNYQhvKE',
VAPID_PRIVATE_KEY: 'jNiZJT0UO4H861KmnCt874Fg6p5jDAyYKS4V2MZf8bQ', VAPID_PRIVATE_KEY: 'jNiZJT0UO4H861KmnCt874Fg6p5jDAyYKS4V2MZf8bQ',
PROCESS_PLATFORM: process.platform, PROCESS_PLATFORM: process.platform,
USE_MONGODB: process.env.USE_MONGODB || 'true',
MONGO_URI: process.env.MONGO_URI || 'mongomock',
}, },
}, },
], ],
+6
View File
@@ -1,10 +1,16 @@
<template> <template>
<BackendEventsListener /> <BackendEventsListener />
<router-view /> <router-view />
<TutorialOverlay />
<TutorialChildModeOffer />
<HelpButton />
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import BackendEventsListener from '@/components/BackendEventsListener.vue' import BackendEventsListener from '@/components/BackendEventsListener.vue'
import TutorialOverlay from '@/tutorial/TutorialOverlay.vue'
import TutorialChildModeOffer from '@/tutorial/TutorialChildModeOffer.vue'
import HelpButton from '@/tutorial/HelpButton.vue'
import { checkAuth } from '@/stores/auth' import { checkAuth } from '@/stores/auth'
checkAuth() checkAuth()
+7
View File
@@ -1,11 +1,18 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils' import { mount } from '@vue/test-utils'
import { createMemoryHistory, createRouter } from 'vue-router'
import App from '../App.vue' import App from '../App.vue'
const mockRouter = createRouter({
history: createMemoryHistory(),
routes: [{ path: '/', name: 'Home', component: { template: '<div />' } }],
})
describe('App', () => { describe('App', () => {
it('mounts renders properly', () => { it('mounts renders properly', () => {
const wrapper = mount(App, { const wrapper = mount(App, {
global: { global: {
plugins: [mockRouter],
stubs: { stubs: {
'router-view': { 'router-view': {
template: '<div>You did it!</div>', template: '<div>You did it!</div>',
+54 -3
View File
@@ -2,17 +2,21 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils' import { mount } from '@vue/test-utils'
import { nextTick } from 'vue' import { nextTick } from 'vue'
import ScheduleModal from '../components/shared/ScheduleModal.vue' import ScheduleModal from '../components/shared/ScheduleModal.vue'
import type { ChildTask, ChoreSchedule } from '../common/models' import type { ChildTask, ChoreSchedule, RoutineSchedule } from '../common/models'
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Mocks // Mocks
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const mockSetChoreSchedule = vi.fn() const mockSetChoreSchedule = vi.fn()
const mockDeleteChoreSchedule = vi.fn() const mockDeleteChoreSchedule = vi.fn()
const mockSetRoutineSchedule = vi.fn()
const mockDeleteRoutineSchedule = vi.fn()
vi.mock('@/common/api', () => ({ vi.mock('@/common/api', () => ({
setChoreSchedule: (...args: unknown[]) => mockSetChoreSchedule(...args), setChoreSchedule: (...args: unknown[]) => mockSetChoreSchedule(...args),
deleteChoreSchedule: (...args: unknown[]) => mockDeleteChoreSchedule(...args), deleteChoreSchedule: (...args: unknown[]) => mockDeleteChoreSchedule(...args),
setRoutineSchedule: (...args: unknown[]) => mockSetRoutineSchedule(...args),
deleteRoutineSchedule: (...args: unknown[]) => mockDeleteRoutineSchedule(...args),
parseErrorResponse: vi.fn().mockResolvedValue({ msg: 'error', code: 'ERR' }), parseErrorResponse: vi.fn().mockResolvedValue({ msg: 'error', code: 'ERR' }),
})) }))
@@ -39,9 +43,9 @@ const DateInputFieldStub = {
const TASK: ChildTask = { id: 'task-1', name: 'Clean Room', type: 'chore', points: 5, image_id: '' } const TASK: ChildTask = { id: 'task-1', name: 'Clean Room', type: 'chore', points: 5, image_id: '' }
const CHILD_ID = 'child-1' const CHILD_ID = 'child-1'
function mountModal(schedule: ChoreSchedule | null = null) { function mountModal(schedule: ChoreSchedule | RoutineSchedule | null = null) {
return mount(ScheduleModal, { return mount(ScheduleModal, {
props: { task: TASK, childId: CHILD_ID, schedule }, props: { entity: TASK, entityType: 'task', childId: CHILD_ID, schedule },
global: { global: {
stubs: { stubs: {
ModalDialog: ModalDialogStub, ModalDialog: ModalDialogStub,
@@ -55,8 +59,12 @@ function mountModal(schedule: ChoreSchedule | null = null) {
beforeEach(() => { beforeEach(() => {
mockSetChoreSchedule.mockReset() mockSetChoreSchedule.mockReset()
mockDeleteChoreSchedule.mockReset() mockDeleteChoreSchedule.mockReset()
mockSetRoutineSchedule.mockReset()
mockDeleteRoutineSchedule.mockReset()
mockSetChoreSchedule.mockResolvedValue({ ok: true }) mockSetChoreSchedule.mockResolvedValue({ ok: true })
mockDeleteChoreSchedule.mockResolvedValue({ ok: true }) mockDeleteChoreSchedule.mockResolvedValue({ ok: true })
mockSetRoutineSchedule.mockResolvedValue({ ok: true })
mockDeleteRoutineSchedule.mockResolvedValue({ ok: true })
}) })
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Mode toggle // Mode toggle
@@ -138,6 +146,13 @@ describe('ScheduleModal Specific Days form', () => {
expect(w.find('.default-deadline-row').exists()).toBe(true) expect(w.find('.default-deadline-row').exists()).toBe(true)
}) })
it('exposes the enable-toggle row for the tutorial anchor', () => {
const w = mountModal()
const toggleRow = w.find('.schedule-toggle-row')
expect(toggleRow.exists()).toBe(true)
expect(toggleRow.attributes('data-tutorial')).toBe('schedule-enable-toggle')
})
it('Save is disabled when no days selected (isDirty is false)', () => { it('Save is disabled when no days selected (isDirty is false)', () => {
const w = mountModal() const w = mountModal()
const saveBtn = w.find('.btn-primary') const saveBtn = w.find('.btn-primary')
@@ -488,3 +503,39 @@ describe('ScheduleModal backdrop', () => {
expect(w.emitted('cancelled')).toBeFalsy() expect(w.emitted('cancelled')).toBeFalsy()
}) })
}) })
// ---------------------------------------------------------------------------
// Entity type routing
// ---------------------------------------------------------------------------
describe('ScheduleModal entityType routing', () => {
it('uses routine schedule API when entityType is routine', async () => {
const routineEntity = { ...TASK, id: 'routine-1', items: [] }
const w = mount(ScheduleModal, {
props: {
entity: routineEntity,
entityType: 'routine',
childId: CHILD_ID,
schedule: null,
},
global: {
stubs: {
ModalDialog: ModalDialogStub,
TimePickerPopover: TimePickerPopoverStub,
DateInputField: DateInputFieldStub,
},
},
})
await w.findAll('.chip')[1].trigger('click')
await nextTick()
await w.find('.btn-primary').trigger('click')
await nextTick()
expect(mockSetRoutineSchedule).toHaveBeenCalledWith(
CHILD_ID,
'routine-1',
expect.objectContaining({ mode: 'days' }),
)
expect(mockSetChoreSchedule).not.toHaveBeenCalled()
})
})
+165 -187
View File
@@ -34,6 +34,43 @@ vi.mock('../services/pushSubscription', () => ({
getPushPermissionState: vi.fn().mockReturnValue('default'), getPushPermissionState: vi.fn().mockReturnValue('default'),
})) }))
const mockResetAllProgress = vi.fn().mockResolvedValue(undefined)
vi.mock('@/tutorial/controller', () => ({
tutorialEnabled: { value: true },
setTutorialEnabled: vi.fn(),
resetAllProgress: () => mockResetAllProgress(),
}))
function stubModalDialog() {
return {
template: '<div class="mock-modal"><h2 v-if="title">{{ title }}</h2><slot /></div>',
props: ['title'],
}
}
function stubImagePicker() {
return {
template: '<div class="mock-image-picker" />',
props: ['modelValue', 'imageType'],
emits: ['update:modelValue', 'add-image'],
}
}
function stubToggleField() {
return {
template: '<div class="mock-toggle-field" />',
props: ['label', 'modelValue', 'disabled', 'description', 'error'],
emits: ['update:modelValue'],
}
}
function stubProfileSection() {
return {
template: '<div class="mock-profile-section"><slot /></div>',
props: ['title', 'defaultOpen'],
}
}
describe('UserProfile - Delete Account', () => { describe('UserProfile - Delete Account', () => {
let wrapper: VueWrapper<any> let wrapper: VueWrapper<any>
@@ -57,31 +94,24 @@ describe('UserProfile - Delete Account', () => {
global: { global: {
plugins: [mockRouter], plugins: [mockRouter],
stubs: { stubs: {
EntityEditForm: { ProfileSection: stubProfileSection(),
template: ImagePicker: stubImagePicker(),
'<div><slot name="custom-field-email" :modelValue="\'test@example.com\'" /></div>', ToggleField: stubToggleField(),
}, ModalDialog: stubModalDialog(),
ModalDialog: {
template: '<div class="mock-modal" v-if="show"><slot /></div>',
props: ['show'],
},
}, },
}, },
}) })
}) })
it('renders Delete My Account button', async () => { it('renders Delete My Account button', async () => {
// Wait for component to mount and render
await flushPromises() await flushPromises()
await nextTick() await nextTick()
// Test the functionality exists by calling the method directly
expect(wrapper.vm.openDeleteWarning).toBeDefined() expect(wrapper.vm.openDeleteWarning).toBeDefined()
expect(wrapper.vm.confirmDeleteAccount).toBeDefined() expect(wrapper.vm.confirmDeleteAccount).toBeDefined()
}) })
it('opens warning modal when Delete My Account button is clicked', async () => { it('opens warning modal when Delete My Account button is clicked', async () => {
// Test by calling the method directly
wrapper.vm.openDeleteWarning() wrapper.vm.openDeleteWarning()
await nextTick() await nextTick()
@@ -91,21 +121,19 @@ describe('UserProfile - Delete Account', () => {
it('Delete button in warning modal is disabled until email matches', async () => { it('Delete button in warning modal is disabled until email matches', async () => {
// Set initial email // Set initial email
wrapper.vm.initialData.email = 'test@example.com' wrapper.vm.email = 'test@example.com'
// Open warning modal // Open warning modal
await wrapper.vm.openDeleteWarning() await wrapper.vm.openDeleteWarning()
await nextTick() await nextTick()
// Find modal delete button (we need to check :disabled binding)
// Since we're using a stub, we'll test the logic directly
wrapper.vm.confirmEmail = 'wrong@example.com' wrapper.vm.confirmEmail = 'wrong@example.com'
await nextTick() await nextTick()
expect(wrapper.vm.confirmEmail).not.toBe(wrapper.vm.initialData.email) expect(wrapper.vm.confirmEmail).not.toBe(wrapper.vm.email)
wrapper.vm.confirmEmail = 'test@example.com' wrapper.vm.confirmEmail = 'test@example.com'
await nextTick() await nextTick()
expect(wrapper.vm.confirmEmail).toBe(wrapper.vm.initialData.email) expect(wrapper.vm.confirmEmail).toBe(wrapper.vm.email)
}) })
it('calls API when confirmed with correct email', async () => { it('calls API when confirmed with correct email', async () => {
@@ -115,7 +143,7 @@ describe('UserProfile - Delete Account', () => {
} }
;(global.fetch as any).mockResolvedValueOnce(mockResponse) ;(global.fetch as any).mockResolvedValueOnce(mockResponse)
wrapper.vm.initialData.email = 'test@example.com' wrapper.vm.email = 'test@example.com'
wrapper.vm.confirmEmail = 'test@example.com' wrapper.vm.confirmEmail = 'test@example.com'
await wrapper.vm.confirmDeleteAccount() await wrapper.vm.confirmDeleteAccount()
@@ -132,7 +160,7 @@ describe('UserProfile - Delete Account', () => {
}) })
it('does not call API if email is invalid format', async () => { it('does not call API if email is invalid format', async () => {
wrapper.vm.initialData.email = 'test@example.com' wrapper.vm.email = 'test@example.com'
wrapper.vm.confirmEmail = 'invalid-email' wrapper.vm.confirmEmail = 'invalid-email'
await wrapper.vm.confirmDeleteAccount() await wrapper.vm.confirmDeleteAccount()
@@ -149,7 +177,7 @@ describe('UserProfile - Delete Account', () => {
} }
;(global.fetch as any).mockResolvedValueOnce(mockResponse) ;(global.fetch as any).mockResolvedValueOnce(mockResponse)
wrapper.vm.initialData.email = 'test@example.com' wrapper.vm.email = 'test@example.com'
wrapper.vm.confirmEmail = 'test@example.com' wrapper.vm.confirmEmail = 'test@example.com'
await wrapper.vm.confirmDeleteAccount() await wrapper.vm.confirmDeleteAccount()
@@ -166,7 +194,7 @@ describe('UserProfile - Delete Account', () => {
return { ok: true, json: async () => ({ success: true }) } return { ok: true, json: async () => ({ success: true }) }
}) })
wrapper.vm.initialData.email = 'test@example.com' wrapper.vm.email = 'test@example.com'
wrapper.vm.confirmEmail = 'test@example.com' wrapper.vm.confirmEmail = 'test@example.com'
await wrapper.vm.confirmDeleteAccount() await wrapper.vm.confirmDeleteAccount()
@@ -180,7 +208,7 @@ describe('UserProfile - Delete Account', () => {
json: async () => ({ success: true }), json: async () => ({ success: true }),
}) })
wrapper.vm.initialData.email = 'test@example.com' wrapper.vm.email = 'test@example.com'
wrapper.vm.confirmEmail = 'test@example.com' wrapper.vm.confirmEmail = 'test@example.com'
await wrapper.vm.confirmDeleteAccount() await wrapper.vm.confirmDeleteAccount()
@@ -196,7 +224,7 @@ describe('UserProfile - Delete Account', () => {
json: async () => ({ error: 'fail', code: 'ERROR' }), json: async () => ({ error: 'fail', code: 'ERROR' }),
}) })
wrapper.vm.initialData.email = 'test@example.com' wrapper.vm.email = 'test@example.com'
wrapper.vm.confirmEmail = 'test@example.com' wrapper.vm.confirmEmail = 'test@example.com'
await wrapper.vm.confirmDeleteAccount() await wrapper.vm.confirmDeleteAccount()
@@ -208,7 +236,7 @@ describe('UserProfile - Delete Account', () => {
it('clears suppressForceLogout on network error', async () => { it('clears suppressForceLogout on network error', async () => {
;(global.fetch as any).mockRejectedValueOnce(new Error('Network error')) ;(global.fetch as any).mockRejectedValueOnce(new Error('Network error'))
wrapper.vm.initialData.email = 'test@example.com' wrapper.vm.email = 'test@example.com'
wrapper.vm.confirmEmail = 'test@example.com' wrapper.vm.confirmEmail = 'test@example.com'
await wrapper.vm.confirmDeleteAccount() await wrapper.vm.confirmDeleteAccount()
@@ -228,7 +256,7 @@ describe('UserProfile - Delete Account', () => {
} }
;(global.fetch as any).mockResolvedValueOnce(mockResponse) ;(global.fetch as any).mockResolvedValueOnce(mockResponse)
wrapper.vm.initialData.email = 'test@example.com' wrapper.vm.email = 'test@example.com'
wrapper.vm.confirmEmail = 'test@example.com' wrapper.vm.confirmEmail = 'test@example.com'
await wrapper.vm.confirmDeleteAccount() await wrapper.vm.confirmDeleteAccount()
@@ -242,7 +270,7 @@ describe('UserProfile - Delete Account', () => {
it('shows error modal on network error', async () => { it('shows error modal on network error', async () => {
;(global.fetch as any).mockRejectedValueOnce(new Error('Network error')) ;(global.fetch as any).mockRejectedValueOnce(new Error('Network error'))
wrapper.vm.initialData.email = 'test@example.com' wrapper.vm.email = 'test@example.com'
wrapper.vm.confirmEmail = 'test@example.com' wrapper.vm.confirmEmail = 'test@example.com'
await wrapper.vm.confirmDeleteAccount() await wrapper.vm.confirmDeleteAccount()
@@ -307,7 +335,7 @@ describe('UserProfile - Delete Account', () => {
}), }),
) )
wrapper.vm.initialData.email = 'test@example.com' wrapper.vm.email = 'test@example.com'
wrapper.vm.confirmEmail = 'test@example.com' wrapper.vm.confirmEmail = 'test@example.com'
const deletePromise = wrapper.vm.confirmDeleteAccount() const deletePromise = wrapper.vm.confirmDeleteAccount()
@@ -344,7 +372,7 @@ describe('UserProfile - Delete Account', () => {
} }
;(global.fetch as any).mockResolvedValueOnce(mockResponse) ;(global.fetch as any).mockResolvedValueOnce(mockResponse)
wrapper.vm.initialData.email = 'test@example.com' wrapper.vm.email = 'test@example.com'
wrapper.vm.confirmEmail = 'test@example.com' wrapper.vm.confirmEmail = 'test@example.com'
await wrapper.vm.confirmDeleteAccount() await wrapper.vm.confirmDeleteAccount()
@@ -354,7 +382,7 @@ describe('UserProfile - Delete Account', () => {
}) })
}) })
describe('UserProfile - Profile Update', () => { describe('UserProfile - Auto-save', () => {
let wrapper: VueWrapper<any> let wrapper: VueWrapper<any>
beforeEach(() => { beforeEach(() => {
@@ -369,90 +397,55 @@ describe('UserProfile - Profile Update', () => {
first_name: 'Test', first_name: 'Test',
last_name: 'User', last_name: 'User',
email: 'test@example.com', email: 'test@example.com',
email_digest_enabled: true,
}), }),
}) })
// Mount component with router
wrapper = mount(UserProfile, { wrapper = mount(UserProfile, {
global: { global: {
plugins: [mockRouter], plugins: [mockRouter],
stubs: { stubs: {
EntityEditForm: { ProfileSection: stubProfileSection(),
template: '<div class="mock-form"><slot /></div>', ImagePicker: stubImagePicker(),
props: ['initialData', 'fields', 'loading', 'error', 'isEdit', 'entityLabel', 'title'], ToggleField: stubToggleField(),
emits: ['submit', 'cancel', 'add-image'], ModalDialog: stubModalDialog(),
},
ModalDialog: {
template: '<div class="mock-modal"><slot /></div>',
},
}, },
}, },
}) })
}) })
it('updates initialData after successful profile save', async () => { it('saveNames sends PUT with first and last name', async () => {
await flushPromises() await flushPromises()
await nextTick() await nextTick()
;(global.fetch as any).mockResolvedValueOnce({ ok: true, json: async () => ({}) })
// Initial image_id should be set from mount wrapper.vm.firstName = 'Updated'
expect(wrapper.vm.initialData.image_id).toBe('initial-image-id') wrapper.vm.lastName = 'Name'
await wrapper.vm.saveNames()
// Mock successful save response
;(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => ({}),
})
// Simulate form submission with new image_id
const newFormData = {
image_id: 'new-image-id',
first_name: 'Updated',
last_name: 'Name',
email: 'test@example.com',
}
await wrapper.vm.handleSubmit(newFormData)
await flushPromises() await flushPromises()
// initialData should now be updated to match the saved form const putCall = (global.fetch as any).mock.calls.find((c: any[]) => c[1]?.method === 'PUT')
expect(wrapper.vm.initialData.image_id).toBe('new-image-id') expect(putCall).toBeDefined()
expect(wrapper.vm.initialData.first_name).toBe('Updated') const body = JSON.parse(putCall[1].body)
expect(wrapper.vm.initialData.last_name).toBe('Name') expect(body.first_name).toBe('Updated')
expect(body.last_name).toBe('Name')
}) })
it('allows dirty detection after save when reverting to original value', async () => { it('saveImage sends PUT with image_id', async () => {
await flushPromises() await flushPromises()
await nextTick() await nextTick()
;(global.fetch as any).mockResolvedValueOnce({ ok: true, json: async () => ({}) })
// Start with initial-image-id await wrapper.vm.saveImage('new-image-id')
expect(wrapper.vm.initialData.image_id).toBe('initial-image-id')
// Mock successful save
;(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => ({}),
})
// Change and save to new-image-id
await wrapper.vm.handleSubmit({
image_id: 'new-image-id',
first_name: 'Test',
last_name: 'User',
email: 'test@example.com',
})
await flushPromises() await flushPromises()
// initialData should now be new-image-id const putCall = (global.fetch as any).mock.calls.find((c: any[]) => c[1]?.method === 'PUT')
expect(wrapper.vm.initialData.image_id).toBe('new-image-id') expect(putCall).toBeDefined()
const body = JSON.parse(putCall[1].body)
// Now if user changes back to initial-image-id, it should be detected as different expect(body.image_id).toBe('new-image-id')
// (because initialData is now new-image-id)
const currentInitial = wrapper.vm.initialData.image_id
expect(currentInitial).toBe('new-image-id')
expect(currentInitial).not.toBe('initial-image-id')
}) })
it('handles image upload during profile save', async () => { it('uploadLocalImage uploads file then saves image_id', async () => {
await flushPromises() await flushPromises()
await nextTick() await nextTick()
@@ -471,24 +464,18 @@ describe('UserProfile - Profile Update', () => {
json: async () => ({}), json: async () => ({}),
}) })
await wrapper.vm.handleSubmit({ await wrapper.vm.uploadLocalImage()
image_id: 'local-upload',
first_name: 'Test',
last_name: 'User',
email: 'test@example.com',
})
await flushPromises() await flushPromises()
// Should have called image upload // Should have called image upload
expect(global.fetch).toHaveBeenCalledWith( const uploadCall = (global.fetch as any).mock.calls.find(
'/api/image/upload', (c: any[]) => c[0] === '/api/image/upload',
expect.objectContaining({
method: 'POST',
}),
) )
expect(uploadCall).toBeDefined()
expect(uploadCall[1].method).toBe('POST')
// initialData should be updated with uploaded image ID // imageId should be updated
expect(wrapper.vm.initialData.image_id).toBe('uploaded-image-id') expect(wrapper.vm.imageId).toBe('uploaded-image-id')
}) })
it('shows error message on failed image upload', async () => { it('shows error message on failed image upload', async () => {
@@ -504,37 +491,10 @@ describe('UserProfile - Profile Update', () => {
status: 500, status: 500,
}) })
await wrapper.vm.handleSubmit({ await wrapper.vm.uploadLocalImage()
image_id: 'local-upload',
first_name: 'Test',
last_name: 'User',
email: 'test@example.com',
})
await flushPromises() await flushPromises()
expect(wrapper.vm.errorMsg).toBe('Failed to upload image.') expect(wrapper.vm.errorMsg).toBe('Failed to upload image.')
expect(wrapper.vm.loading).toBe(false)
})
it('shows success modal after profile update', async () => {
await flushPromises()
await nextTick()
;(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => ({}),
})
await wrapper.vm.handleSubmit({
image_id: 'some-image-id',
first_name: 'Test',
last_name: 'User',
email: 'test@example.com',
})
await flushPromises()
expect(wrapper.vm.showModal).toBe(true)
expect(wrapper.vm.modalTitle).toBe('Profile Updated')
expect(wrapper.vm.modalMessage).toBe('Your profile was updated successfully.')
}) })
it('shows error message on failed profile update', async () => { it('shows error message on failed profile update', async () => {
@@ -545,16 +505,10 @@ describe('UserProfile - Profile Update', () => {
status: 500, status: 500,
}) })
await wrapper.vm.handleSubmit({ await wrapper.vm.saveNames()
image_id: 'some-image-id',
first_name: 'Test',
last_name: 'User',
email: 'test@example.com',
})
await flushPromises() await flushPromises()
expect(wrapper.vm.errorMsg).toBe('Failed to update profile.') expect(wrapper.vm.errorMsg).toBe('Failed to update profile.')
expect(wrapper.vm.loading).toBe(false)
}) })
}) })
@@ -576,14 +530,10 @@ describe('UserProfile - Notification Toggles', () => {
global: { global: {
plugins: [mockRouter], plugins: [mockRouter],
stubs: { stubs: {
EntityEditForm: { ProfileSection: stubProfileSection(),
template: ImagePicker: stubImagePicker(),
'<div><slot name="custom-field-email" :modelValue="\'test@example.com\'" /></div>', ToggleField: stubToggleField(),
}, ModalDialog: stubModalDialog(),
ModalDialog: {
template: '<div class="mock-modal" v-if="show"><slot /></div>',
props: ['show'],
},
}, },
}, },
}) })
@@ -598,64 +548,37 @@ describe('UserProfile - Notification Toggles', () => {
}) })
}) })
it('initializes email_digest_enabled to true in initialData when profile returns true', async () => { it('initializes emailDigestEnabled to true when profile returns true', async () => {
wrapper = mountWithDigest(true) wrapper = mountWithDigest(true)
await flushPromises() await flushPromises()
await nextTick() await nextTick()
expect(wrapper.vm.initialData.email_digest_enabled).toBe(true) expect(wrapper.vm.emailDigestEnabled).toBe(true)
}) })
it('initializes email_digest_enabled to false in initialData when profile returns false', async () => { it('initializes emailDigestEnabled to false when profile returns false', async () => {
wrapper = mountWithDigest(false) wrapper = mountWithDigest(false)
await flushPromises() await flushPromises()
await nextTick() await nextTick()
expect(wrapper.vm.initialData.email_digest_enabled).toBe(false) expect(wrapper.vm.emailDigestEnabled).toBe(false)
}) })
it('initializes push_enabled to false in initialData when not subscribed', async () => { it('initializes pushEnabled to false when not subscribed', async () => {
wrapper = mountWithDigest(true) wrapper = mountWithDigest(true)
await flushPromises() await flushPromises()
await nextTick() await nextTick()
expect(wrapper.vm.initialData.push_enabled).toBe(false) expect(wrapper.vm.pushEnabled).toBe(false)
}) })
it('fields array includes email_digest_enabled as toggle type', async () => { it('onToggleDigest sends PUT with new value', async () => {
wrapper = mountWithDigest(true)
await flushPromises()
await nextTick()
const digestField = wrapper.vm.fields.find((f: any) => f.name === 'email_digest_enabled')
expect(digestField).toBeDefined()
expect(digestField.type).toBe('toggle')
})
it('fields array includes push_enabled as toggle type', async () => {
wrapper = mountWithDigest(true)
await flushPromises()
await nextTick()
const pushField = wrapper.vm.fields.find((f: any) => f.name === 'push_enabled')
expect(pushField).toBeDefined()
expect(pushField.type).toBe('toggle')
})
it('profile PUT includes email_digest_enabled when changed on submit', async () => {
wrapper = mountWithDigest(true) wrapper = mountWithDigest(true)
await flushPromises() await flushPromises()
await nextTick() await nextTick()
;(global.fetch as any).mockResolvedValueOnce({ ok: true, json: async () => ({}) }) ;(global.fetch as any).mockResolvedValueOnce({ ok: true, json: async () => ({}) })
await wrapper.vm.handleSubmit({ await wrapper.vm.onToggleDigest(false)
image_id: null,
first_name: 'Test',
last_name: 'User',
email: 'test@example.com',
email_digest_enabled: false,
push_enabled: false,
})
await flushPromises() await flushPromises()
const putCall = (global.fetch as any).mock.calls.find((c: any[]) => c[1]?.method === 'PUT') const putCall = (global.fetch as any).mock.calls.find((c: any[]) => c[1]?.method === 'PUT')
@@ -664,25 +587,80 @@ describe('UserProfile - Notification Toggles', () => {
expect(body.email_digest_enabled).toBe(false) expect(body.email_digest_enabled).toBe(false)
}) })
it('profile PUT omits email_digest_enabled when unchanged on submit', async () => { it('onTogglePush sends PUT with new value and applies push change', async () => {
wrapper = mountWithDigest(true) wrapper = mountWithDigest(true)
await flushPromises() await flushPromises()
await nextTick() await nextTick()
;(global.fetch as any).mockResolvedValueOnce({ ok: true, json: async () => ({}) }) ;(global.fetch as any).mockResolvedValueOnce({ ok: true, json: async () => ({}) })
await wrapper.vm.handleSubmit({ expect(wrapper.vm.pushEnabled).toBe(false)
image_id: null, await wrapper.vm.onTogglePush(true)
first_name: 'Test',
last_name: 'User',
email: 'test@example.com',
email_digest_enabled: true, // same as initial
push_enabled: false,
})
await flushPromises() await flushPromises()
const putCall = (global.fetch as any).mock.calls.find((c: any[]) => c[1]?.method === 'PUT') const putCall = (global.fetch as any).mock.calls.find((c: any[]) => c[1]?.method === 'PUT')
expect(putCall).toBeDefined() expect(putCall).toBeDefined()
const body = JSON.parse(putCall[1].body) const body = JSON.parse(putCall[1].body)
expect(body.email_digest_enabled).toBeUndefined() expect(body.push_notifications_enabled).toBe(true)
})
})
describe('UserProfile - Tutorial restart', () => {
let wrapper: VueWrapper<any>
beforeEach(() => {
vi.clearAllMocks()
mockResetAllProgress.mockClear()
;(global.fetch as any).mockClear()
;(global.fetch as any).mockResolvedValue({
ok: true,
json: async () => ({
image_id: null,
first_name: 'Test',
last_name: 'User',
email: 'test@example.com',
email_digest_enabled: true,
}),
})
wrapper = mount(UserProfile, {
global: {
plugins: [mockRouter],
stubs: {
ProfileSection: stubProfileSection(),
ImagePicker: stubImagePicker(),
ToggleField: stubToggleField(),
ModalDialog: stubModalDialog(),
},
},
})
})
it('restart confirm modal uses "Tutorial Restart" title and explanatory wording', async () => {
await flushPromises()
await nextTick()
await wrapper.vm.openRestartConfirm()
await nextTick()
const modal = wrapper.find('.mock-modal')
expect(modal.exists()).toBe(true)
expect(modal.text()).toContain('Tutorial Restart')
expect(modal.text()).toContain('Start the tour again')
})
it('confirming restart resets tutorial progress and shows success modal', async () => {
await flushPromises()
await nextTick()
await wrapper.vm.openRestartConfirm()
await wrapper.vm.confirmRestartTutorial()
await flushPromises()
await nextTick()
expect(mockResetAllProgress).toHaveBeenCalledTimes(1)
const modal = wrapper.find('.mock-modal')
expect(modal.text()).toContain('Tutorial Restart')
expect(modal.text()).toContain('Tutorial mode has been restarted.')
}) })
}) })

Some files were not shown because too many files have changed in this diff Show More