mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
Merge pull request #8 from jjbrunton/staging
chore(release): promote staging for v1.0.6
This commit is contained in:
127 files changed
+5431
-455
No files matched your search
@@ -0,0 +1,11 @@
|
|||||||
|
name = "architecture_reviewer"
|
||||||
|
description = "Read-only reviewer for cross-cutting or hard-to-reverse Sauci architecture decisions."
|
||||||
|
model = "gpt-5.6-sol"
|
||||||
|
model_reasoning_effort = "high"
|
||||||
|
sandbox_mode = "read-only"
|
||||||
|
developer_instructions = """
|
||||||
|
Evaluate the delegated decision against current system boundaries, data flow,
|
||||||
|
migration path, operability, reversibility, and verification. Prefer the smallest
|
||||||
|
coherent change. Return explicit trade-offs, risks, and a recommendation with
|
||||||
|
file-backed evidence. Do not edit files or review unrelated local refactors.
|
||||||
|
"""
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
name = "database_reviewer"
|
||||||
|
description = "Read-only reviewer for Sauci migrations, RLS, authorization, and query boundaries."
|
||||||
|
model = "gpt-5.6-terra"
|
||||||
|
model_reasoning_effort = "high"
|
||||||
|
sandbox_mode = "read-only"
|
||||||
|
developer_instructions = """
|
||||||
|
Read the applicable AGENTS.md, database migration skill, and migration safety
|
||||||
|
documentation. Review only the delegated diff for migration tracking,
|
||||||
|
idempotency, authorization and tenant isolation, portability, rollback impact,
|
||||||
|
types, and integration coverage. Never mutate a database or edit files. Return
|
||||||
|
prioritized line-specific findings with concrete evidence and counterevidence.
|
||||||
|
"""
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
name = "implementation"
|
||||||
|
description = "Default executor for a bounded Sauci product or harness change after scope and acceptance criteria are known."
|
||||||
|
model = "gpt-5.6-terra"
|
||||||
|
developer_instructions = """
|
||||||
|
Read the root and nearest scoped AGENTS.md files plus the applicable repository
|
||||||
|
skill. Implement only the delegated objective and preserve unrelated work. Update
|
||||||
|
maintained contract documentation and add focused tests when the contract changes.
|
||||||
|
Run the required focused checks and return changed files, exact results,
|
||||||
|
assumptions, and blockers. Stop for missing authority or a materially ambiguous
|
||||||
|
product choice. Do not expand scope or perform external, destructive, production,
|
||||||
|
release, or provider writes unless the task explicitly authorizes that action.
|
||||||
|
"""
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
name = "repo_scout"
|
||||||
|
description = "Read-only Sauci codebase scout for locating ownership, callers, contracts, tests, and existing patterns."
|
||||||
|
model = "gpt-5.6-luna"
|
||||||
|
model_reasoning_effort = "low"
|
||||||
|
sandbox_mode = "read-only"
|
||||||
|
developer_instructions = """
|
||||||
|
Read the root and nearest scoped AGENTS.md files. Map only the requested surface.
|
||||||
|
Return evidence-backed file paths, symbols, current behavior, dependencies, tests,
|
||||||
|
and uncertainties. Do not edit files, make design decisions, or propose broad
|
||||||
|
refactors. Do not claim runtime behavior you did not observe.
|
||||||
|
"""
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
name = "security_reviewer"
|
||||||
|
description = "Read-only reviewer for auth, RLS, cryptography, secrets, payments, privileged MCP, and destructive operations."
|
||||||
|
model = "gpt-5.6-sol"
|
||||||
|
model_reasoning_effort = "high"
|
||||||
|
sandbox_mode = "read-only"
|
||||||
|
developer_instructions = """
|
||||||
|
Perform a scoped threat-boundary review of the delegated change. Trace callers
|
||||||
|
through authentication and authorization to the data sink. Prioritize exploitable
|
||||||
|
correctness and security risks, include counterevidence, and cite exact files and
|
||||||
|
lines. Do not edit files or expand into a repository-wide audit unless explicitly
|
||||||
|
requested.
|
||||||
|
"""
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
name = "test_writer"
|
||||||
|
description = "Focused Sauci regression and contract-test author for already-defined behavior."
|
||||||
|
model = "gpt-5.6-terra"
|
||||||
|
model_reasoning_effort = "medium"
|
||||||
|
developer_instructions = """
|
||||||
|
Read the applicable AGENTS.md and testing documentation. Test the intended
|
||||||
|
observable contract at the lowest useful layer while preserving real auth,
|
||||||
|
authorization, tenant, and couple boundaries. Do not weaken assertions to
|
||||||
|
accommodate a failure. Run the focused test and return files changed, exact
|
||||||
|
commands, results, and any product defect exposed by the test.
|
||||||
|
"""
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
name = "verifier"
|
||||||
|
description = "Independent Sauci behavior verifier that drives completed work and returns evidence, not fixes."
|
||||||
|
model = "gpt-5.6-terra"
|
||||||
|
model_reasoning_effort = "medium"
|
||||||
|
developer_instructions = """
|
||||||
|
Read the applicable AGENTS.md and .codex/skills/verify/SKILL.md. Independently
|
||||||
|
exercise the delegated acceptance criteria against the running non-production
|
||||||
|
product. You may write generated proof only under evidence/. Do not edit source,
|
||||||
|
tests, configuration, or acceptance criteria. Return expected behavior, observed
|
||||||
|
behavior, evidence paths, environment limitations, and a works or broken verdict.
|
||||||
|
"""
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
[agents]
|
||||||
|
enabled = true
|
||||||
|
max_concurrent_threads_per_session = 3
|
||||||
|
default_subagent_model = "gpt-5.6-terra"
|
||||||
|
default_subagent_reasoning_effort = "medium"
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
---
|
||||||
|
name: delegate-to-claude
|
||||||
|
description: Delegate a bounded coding, investigation, or review task from Codex to Claude Code, then inspect and independently assess Claude's result. Use when the user asks Codex to delegate to Claude or when a second-model execution or review pass is explicitly desired. Do not use for ordinary Codex subagents or as a substitute for Codex's final review.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Delegate to Claude Code
|
||||||
|
|
||||||
|
Use Claude Code as an executor or independent reviewer while Codex remains the coordinator responsible for scope, evidence, and acceptance.
|
||||||
|
|
||||||
|
## Preconditions
|
||||||
|
|
||||||
|
1. Confirm `claude` is installed and authenticated by running `scripts/claude_delegate.sh check` from this skill directory.
|
||||||
|
2. Read the repository's applicable `AGENTS.md`, `CLAUDE.md`, and task-specific instructions before writing the work order.
|
||||||
|
3. Do not delegate secrets, provider writes, production mutations, releases, or destructive operations unless the user explicitly authorized that exact action. Claude receives no broader authority than Codex has.
|
||||||
|
|
||||||
|
## Work order
|
||||||
|
|
||||||
|
Write the task into a temporary Markdown file outside the repository unless the user requested a durable task artifact. Make it self-contained and include:
|
||||||
|
|
||||||
|
- role: executor or read-only reviewer;
|
||||||
|
- exact objective and boundaries;
|
||||||
|
- working directory and relevant files;
|
||||||
|
- repository instructions Claude must read;
|
||||||
|
- allowed side effects and forbidden actions;
|
||||||
|
- required checks and expected evidence;
|
||||||
|
- requested return format: findings or changed files, commands run, results, and blockers.
|
||||||
|
|
||||||
|
The helper adds an executor marker and tells Claude not to spawn further agents. Do not pass the user's whole conversation when a concise work order is sufficient.
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
From the target repository directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/absolute/path/to/this-skill/scripts/claude_delegate.sh run /absolute/path/to/work-order.md /absolute/path/to/result.json
|
||||||
|
```
|
||||||
|
|
||||||
|
The default permission mode is `auto`, which lets Claude decide whether tools are safe to use. Override it only when the task requires a stricter mode:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
CLAUDE_DELEGATE_PERMISSION_MODE=plan scripts/claude_delegate.sh run work-order.md result.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional environment variables:
|
||||||
|
|
||||||
|
- `CLAUDE_DELEGATE_MODEL`: Claude model alias or full model name; default `sonnet`.
|
||||||
|
- `CLAUDE_DELEGATE_EFFORT`: `low`, `medium`, `high`, `xhigh`, or `max`; default `medium`.
|
||||||
|
- `CLAUDE_DELEGATE_MAX_BUDGET_USD`: API budget ceiling when supported by the current authentication mode.
|
||||||
|
- `CLAUDE_DELEGATE_PERMISSION_MODE`: default `auto`; prefer `plan` for read-only planning/review when tools are unnecessary.
|
||||||
|
|
||||||
|
The helper loads project and local Claude settings while excluding user-level integrations by default, and disables browser/MCP integrations. This keeps delegation focused on the checked-out repository. If a task genuinely requires an external connector, handle that action in Codex under the original authorization rather than silently widening Claude's access.
|
||||||
|
|
||||||
|
Never choose `bypassPermissions` merely to avoid a permission failure. Use it only when the user explicitly requests it and the execution is inside an appropriately isolated sandbox.
|
||||||
|
|
||||||
|
For a follow-up in the same Claude session, obtain `session_id` from the JSON result and run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scripts/claude_delegate.sh resume SESSION_ID follow-up.md follow-up-result.json
|
||||||
|
```
|
||||||
|
|
||||||
|
If a call outlives the command yield, continue waiting on that same process/session rather than launching a duplicate.
|
||||||
|
|
||||||
|
## Accept or iterate
|
||||||
|
|
||||||
|
Claude's summary is evidence to inspect, not proof. Codex must:
|
||||||
|
|
||||||
|
1. inspect the actual diff or artifacts;
|
||||||
|
2. run proportionate independent checks;
|
||||||
|
3. compare the result with the original scope and repository rules;
|
||||||
|
4. send a focused follow-up with concrete failures when iteration is needed;
|
||||||
|
5. report Claude's contribution separately from Codex's verification.
|
||||||
|
|
||||||
|
Do not claim success from Claude's self-report alone. Codex retains the decision to accept, commit, publish, deploy, or make any external change.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
interface:
|
||||||
|
display_name: "Delegate to Claude Code"
|
||||||
|
short_description: "Delegate scoped work to Claude Code"
|
||||||
|
default_prompt: "Use $delegate-to-claude to delegate this scoped task to Claude Code and review the result."
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
die() {
|
||||||
|
printf 'claude_delegate: %s\n' "$*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat >&2 <<'EOF'
|
||||||
|
usage:
|
||||||
|
claude_delegate.sh check
|
||||||
|
claude_delegate.sh run <prompt-file> [result-json]
|
||||||
|
claude_delegate.sh resume <session-id> <prompt-file> [result-json]
|
||||||
|
EOF
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
|
||||||
|
command -v claude >/dev/null 2>&1 || die "claude CLI is not installed or not on PATH"
|
||||||
|
|
||||||
|
if [[ "${1:-}" == "check" ]]; then
|
||||||
|
claude --version
|
||||||
|
auth_status="$(claude auth status 2>/dev/null)" || die "unable to read Claude authentication status"
|
||||||
|
[[ "$auth_status" == *'"loggedIn": true'* ]] || die "Claude CLI is not authenticated"
|
||||||
|
printf 'authentication: logged in\n'
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
mode="${1:-}"
|
||||||
|
case "$mode" in
|
||||||
|
run)
|
||||||
|
[[ $# -ge 2 && $# -le 3 ]] || usage
|
||||||
|
prompt_file="$2"
|
||||||
|
result_file="${3:-}"
|
||||||
|
resume_id=""
|
||||||
|
;;
|
||||||
|
resume)
|
||||||
|
[[ $# -ge 3 && $# -le 4 ]] || usage
|
||||||
|
resume_id="$2"
|
||||||
|
prompt_file="$3"
|
||||||
|
result_file="${4:-}"
|
||||||
|
[[ "$resume_id" =~ ^[0-9a-fA-F-]{36}$ ]] || die "session id must be a UUID"
|
||||||
|
;;
|
||||||
|
*) usage ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
[[ -f "$prompt_file" ]] || die "prompt file does not exist: $prompt_file"
|
||||||
|
[[ -r "$prompt_file" ]] || die "prompt file is not readable: $prompt_file"
|
||||||
|
|
||||||
|
prompt="ROLE: EXECUTOR — do the work yourself; do not spawn subagents. Stay within the work order's authorization and scope. At the end, report changed files or findings, commands and checks run with results, and any blockers. WORK ORDER: $(<"$prompt_file")"
|
||||||
|
|
||||||
|
effort="${CLAUDE_DELEGATE_EFFORT:-medium}"
|
||||||
|
permission_mode="${CLAUDE_DELEGATE_PERMISSION_MODE:-auto}"
|
||||||
|
case "$effort" in low|medium|high|xhigh|max) ;; *) die "invalid effort: $effort" ;; esac
|
||||||
|
case "$permission_mode" in acceptEdits|auto|manual|dontAsk|plan|bypassPermissions) ;;
|
||||||
|
*) die "invalid permission mode: $permission_mode" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
model="${CLAUDE_DELEGATE_MODEL:-sonnet}"
|
||||||
|
args=(--print --output-format json --model "$model" --effort "$effort" --permission-mode "$permission_mode" --setting-sources project,local --strict-mcp-config --no-chrome)
|
||||||
|
[[ -n "${CLAUDE_DELEGATE_MAX_BUDGET_USD:-}" ]] && args+=(--max-budget-usd "$CLAUDE_DELEGATE_MAX_BUDGET_USD")
|
||||||
|
[[ -n "$resume_id" ]] && args+=(--resume "$resume_id")
|
||||||
|
|
||||||
|
if [[ -n "$result_file" ]]; then
|
||||||
|
result_dir="$(dirname "$result_file")"
|
||||||
|
[[ -d "$result_dir" ]] || die "result directory does not exist: $result_dir"
|
||||||
|
claude "${args[@]}" "$prompt" | tee "$result_file"
|
||||||
|
else
|
||||||
|
claude "${args[@]}" "$prompt"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
---
|
||||||
|
name: generate-release
|
||||||
|
description: Generate a signed Sauci mobile release by bumping the public version, writing on-brand release notes, building and verifying iOS and Android binaries, committing release metadata, and tagging the release commit. Use for an actual production mobile release; use release-mobile for preparation, one-platform builds, uploads, or submission work that does not require the complete release lifecycle.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Generate release
|
||||||
|
|
||||||
|
Create a reproducible release whose tag identifies the reviewed release source
|
||||||
|
and final platform build metadata. Read `apps/mobile/AGENTS.md`,
|
||||||
|
`docs/releasing.md`, and the `release-mobile` and `commit` skills before acting. Read
|
||||||
|
[references/release-notes-voice.md](references/release-notes-voice.md) before
|
||||||
|
drafting notes and [references/artifact-verification.md](references/artifact-verification.md)
|
||||||
|
before accepting a build.
|
||||||
|
|
||||||
|
A request to generate or create a release authorizes the scoped version edits,
|
||||||
|
release notes, local builds, release commits, and one local annotated tag. It
|
||||||
|
does not authorize pushing commits or tags, uploading binaries, submitting a
|
||||||
|
store release, changing signing credentials, or regenerating native projects.
|
||||||
|
|
||||||
|
## Defaults
|
||||||
|
|
||||||
|
- Build both iOS and Android production binaries unless the user narrows the
|
||||||
|
platforms.
|
||||||
|
- Increment the patch version unless the user specifies `minor`, `major`, or an
|
||||||
|
exact SemVer version.
|
||||||
|
- Use `v<version>` for the annotated Git tag.
|
||||||
|
- Write artifacts beneath ignored `dist/releases/v<version>/`; never commit
|
||||||
|
binaries, credentials, logs, or build directories.
|
||||||
|
|
||||||
|
## Preflight
|
||||||
|
|
||||||
|
Fetch the remote and inspect the worktree, branch, upstream, existing tags,
|
||||||
|
toolchains, EAS authentication, and signing availability without changing them.
|
||||||
|
Require all of the following:
|
||||||
|
|
||||||
|
- a named branch, normally `main` for a production release;
|
||||||
|
- a clean starting worktree with `HEAD` equal to its fetched upstream;
|
||||||
|
- the previous reachable `v[0-9]*` release tag and no collision for the next tag;
|
||||||
|
- consistent public versions across `app.json`, Android `versionName`, and every
|
||||||
|
iOS `MARKETING_VERSION` (`scripts/set_version.mjs --check`);
|
||||||
|
- Node 20, JDK 17 for Android, and Xcode/CocoaPods for iOS;
|
||||||
|
- the configured production API/Auth/RevenueCat environment, without printing
|
||||||
|
secret values.
|
||||||
|
|
||||||
|
Do not switch branches, discard work, repair credentials, push, or weaken branch
|
||||||
|
protection merely to pass preflight. Stop with the exact blocker. Never run
|
||||||
|
`expo prebuild` as part of this workflow.
|
||||||
|
|
||||||
|
## Prepare the release
|
||||||
|
|
||||||
|
1. Derive the next version with `scripts/set_version.mjs --bump <kind>` or set an
|
||||||
|
explicit version with `--version <x.y.z>`. The script updates the checked-in
|
||||||
|
public-version sources but deliberately leaves platform build numbers to EAS.
|
||||||
|
2. Review the complete first-parent and ordinary commit diff from the previous
|
||||||
|
reachable release tag to the starting commit. Inspect the actual product diff
|
||||||
|
for every user-facing claim; commit subjects alone are not evidence.
|
||||||
|
3. Add the new entry immediately below the title in `docs/release-notes.md`.
|
||||||
|
Describe user-visible value, meaningful fixes, privacy or reliability changes,
|
||||||
|
and only claims supported by the diff. Omit internal migrations, refactors,
|
||||||
|
CI, infrastructure, dependency churn, and implementation jargon unless the
|
||||||
|
user experiences a direct result. Use a temporary `Build numbers pending
|
||||||
|
artifact verification` line until the binaries have been inspected; never
|
||||||
|
guess or predict build numbers.
|
||||||
|
4. Run `npm run verify:full` and the native acceptance required by the
|
||||||
|
`release-mobile` skill. A missing prerequisite is a failure/limitation, not a
|
||||||
|
pass.
|
||||||
|
5. Review the scoped diff and create `chore(release): prepare v<version>`. Do not
|
||||||
|
stage unrelated paths. This clean release-preparation commit must descend
|
||||||
|
directly from the fetched upstream commit.
|
||||||
|
|
||||||
|
## Build and capture platform metadata
|
||||||
|
|
||||||
|
Build from the clean prepared source using the package scripts in
|
||||||
|
`apps/mobile/package.json`, with `EAS_LOCAL_BUILD_ARTIFACTS_DIR` set to the
|
||||||
|
versioned artifact directory. When building both platforms, build Android first,
|
||||||
|
then iOS.
|
||||||
|
|
||||||
|
After each build:
|
||||||
|
|
||||||
|
1. Inspect the worktree before doing anything else. Accept only the documented
|
||||||
|
EAS build-number changes in `app.json`, that platform's native version source,
|
||||||
|
and the iOS widget plist when applicable. Treat any implementation, resource,
|
||||||
|
dependency, or native-project regeneration as a failed build.
|
||||||
|
2. Independently inspect the binary for package/bundle identifier, public
|
||||||
|
version, platform build number, production endpoint configuration where
|
||||||
|
inspectable, and signing identity/fingerprint. Require the embedded public
|
||||||
|
version to equal the requested version.
|
||||||
|
3. Record the verified platform build number in the new release-note entry.
|
||||||
|
4. Commit only the expected metadata and release-note update as
|
||||||
|
`chore(<platform>): record v<version> build <number>`. The worktree must be
|
||||||
|
clean before starting the next platform build.
|
||||||
|
|
||||||
|
Do not accept a successful EAS exit code as proof of a valid artifact. If either
|
||||||
|
requested platform fails, do not tag a partial release.
|
||||||
|
|
||||||
|
## Tag and prove the result
|
||||||
|
|
||||||
|
Re-run focused version checks and `npm run verify:fast`. Confirm every requested
|
||||||
|
artifact exists, has a SHA-256 digest, passed independent version/signature
|
||||||
|
inspection, and was produced from the release commit chain. Require a clean
|
||||||
|
worktree, then create the annotated local tag `v<version>` at `HEAD`.
|
||||||
|
|
||||||
|
Read back the tag object and prove:
|
||||||
|
|
||||||
|
- the tag resolves to `HEAD`;
|
||||||
|
- the tag version equals all public-version sources and embedded artifacts;
|
||||||
|
- recorded build numbers equal the embedded platform build numbers;
|
||||||
|
- no binary or ignored build output entered the commit;
|
||||||
|
- the release commits descend from the fetched starting branch commit;
|
||||||
|
- for a two-platform release, the Android artifact's source commit differs from
|
||||||
|
the final tag only by the later verified iOS build metadata.
|
||||||
|
|
||||||
|
Report the branch, tag, commit, release-note summary, artifact paths, versions,
|
||||||
|
build numbers, signatures, SHA-256 digests, checks run, and any environmental
|
||||||
|
limitations. State explicitly that the tag and commits remain local unless a
|
||||||
|
separate authorized push was completed.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
interface:
|
||||||
|
display_name: "Generate Release"
|
||||||
|
short_description: "Bump, build, note, and tag Sauci releases"
|
||||||
|
default_prompt: "Use $generate-release to create the next signed Sauci mobile release for iOS and Android."
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Release artifact verification
|
||||||
|
|
||||||
|
Use the strongest locally available platform tools and record the commands and
|
||||||
|
results. Never print provisioning profiles, keys, tokens, or environment values.
|
||||||
|
|
||||||
|
## Android AAB
|
||||||
|
|
||||||
|
Require a production `.aab`. Inspect its manifest with `bundletool dump manifest`
|
||||||
|
or Android SDK `apkanalyzer`, and verify:
|
||||||
|
|
||||||
|
- package `com.sauci.app`;
|
||||||
|
- `versionName` equals the release version;
|
||||||
|
- `versionCode` equals the recorded Android build number.
|
||||||
|
|
||||||
|
Verify the archive signature with `jarsigner -verify -verbose -certs`. Compare the
|
||||||
|
signing certificate SHA-1 to the production fingerprint documented in
|
||||||
|
`docs/releasing.md`; do not copy credentials into commands or reports. Calculate
|
||||||
|
the artifact SHA-256 with `shasum -a 256`.
|
||||||
|
|
||||||
|
## iOS IPA
|
||||||
|
|
||||||
|
Extract the `.ipa` into a new temporary directory created with `mktemp -d`. Read
|
||||||
|
the application `Info.plist` with `/usr/libexec/PlistBuddy` or `plutil` and verify:
|
||||||
|
|
||||||
|
- `CFBundleIdentifier` is `com.sauci.app`;
|
||||||
|
- `CFBundleShortVersionString` equals the release version;
|
||||||
|
- `CFBundleVersion` equals the recorded iOS build number.
|
||||||
|
|
||||||
|
Use `codesign -dvvv` and `codesign --verify --deep --strict` on the extracted app
|
||||||
|
to inspect and verify signing without exposing the embedded profile. Calculate the
|
||||||
|
original `.ipa` SHA-256 with `shasum -a 256`. Remove only the explicit temporary
|
||||||
|
extraction directory after inspection.
|
||||||
|
|
||||||
|
## Source correspondence
|
||||||
|
|
||||||
|
For both platforms, retain the build command, artifact path, build completion
|
||||||
|
time, and digest in the final report. Compare the post-build tracked diff with the
|
||||||
|
documented allowed version files. A binary with the right version but unexpected
|
||||||
|
source regeneration, wrong signing, or an unverifiable manifest is not accepted.
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# Sauci release-note voice
|
||||||
|
|
||||||
|
Sauci sounds warm, confident, inviting, and quietly playful. It is a premium
|
||||||
|
couples app about private connection and shared curiosity: supportive without
|
||||||
|
being clinical, polished without sounding corporate, and intimate without being
|
||||||
|
graphic or presumptuous.
|
||||||
|
|
||||||
|
## Shape
|
||||||
|
|
||||||
|
Use a short opening sentence followed by three to six compact bullets. Lead with
|
||||||
|
the change that most improves a couple's experience. Combine related work into
|
||||||
|
one benefit rather than mirroring the commit list. Include a brief closing line
|
||||||
|
only when it adds warmth or useful context.
|
||||||
|
|
||||||
|
In `docs/release-notes.md`, use:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## v1.2.3 — 29 August 2026
|
||||||
|
_iOS build 44 · Android build 48_
|
||||||
|
|
||||||
|
A little more ease, clarity, and connection in every check-in.
|
||||||
|
|
||||||
|
- **Closer conversations** — Find the prompts that feel right for both of you.
|
||||||
|
- **Smoother moments** — Chats and matches now feel quicker and more reliable.
|
||||||
|
- **More control** — Clearer choices help keep your shared space comfortable.
|
||||||
|
```
|
||||||
|
|
||||||
|
Adapt the words to the evidence; do not reuse this copy when those claims are not
|
||||||
|
present in the release. If only one platform was requested, record only its build.
|
||||||
|
|
||||||
|
## Language
|
||||||
|
|
||||||
|
- Address the couple's experience with `you`, `your partner`, `together`, or
|
||||||
|
`your shared space` where natural.
|
||||||
|
- Prefer concrete benefits: `pick up where you left off`, `find the right prompt`,
|
||||||
|
`keep conversations flowing`, `stay in control`.
|
||||||
|
- Use restrained warmth. One gentle phrase is enough; avoid forced innuendo,
|
||||||
|
breathless hype, exclamation marks, emoji, and claims that Sauci will improve or
|
||||||
|
fix a relationship.
|
||||||
|
- Prefer `new`, `smoother`, `clearer`, `more private`, and `more reliable` to
|
||||||
|
`revolutionary`, `game-changing`, `massive`, or `best ever`.
|
||||||
|
- Explain a fix as the experience restored, not the defect mechanics. Name a
|
||||||
|
security or privacy change plainly when users need to understand it.
|
||||||
|
|
||||||
|
## Evidence boundary
|
||||||
|
|
||||||
|
Release notes describe shipped behavior only. Do not claim a feature from a plan,
|
||||||
|
flagged-off path, migration, test, or commit title without confirming the runtime
|
||||||
|
path in the diff and release configuration. Do not expose internal service names,
|
||||||
|
database details, moderation logic, security-sensitive implementation, or content
|
||||||
|
that the product intentionally keeps private between partners.
|
||||||
+105
@@ -0,0 +1,105 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const repoRoot = path.resolve(scriptDir, "../../../..");
|
||||||
|
const files = {
|
||||||
|
app: path.join(repoRoot, "apps/mobile/app.json"),
|
||||||
|
android: path.join(repoRoot, "apps/mobile/android/app/build.gradle"),
|
||||||
|
ios: path.join(repoRoot, "apps/mobile/ios/Sauci.xcodeproj/project.pbxproj"),
|
||||||
|
};
|
||||||
|
|
||||||
|
const semverPattern = /^([0-9]+)\.([0-9]+)\.([0-9]+)$/;
|
||||||
|
|
||||||
|
function fail(message) {
|
||||||
|
console.error(`set_version: ${message}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseVersion(value, label) {
|
||||||
|
const match = semverPattern.exec(value);
|
||||||
|
if (!match) fail(`${label} must be an exact numeric SemVer version (x.y.z)`);
|
||||||
|
return match.slice(1).map(Number);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readState() {
|
||||||
|
const appRaw = fs.readFileSync(files.app, "utf8");
|
||||||
|
const androidRaw = fs.readFileSync(files.android, "utf8");
|
||||||
|
const iosRaw = fs.readFileSync(files.ios, "utf8");
|
||||||
|
const appVersion = JSON.parse(appRaw).expo?.version;
|
||||||
|
const androidVersions = [...androidRaw.matchAll(/\bversionName\s+["']([^"']+)["']/g)].map(
|
||||||
|
(match) => match[1],
|
||||||
|
);
|
||||||
|
const iosVersions = [...iosRaw.matchAll(/\bMARKETING_VERSION\s*=\s*([^;]+);/g)].map(
|
||||||
|
(match) => match[1].trim(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (typeof appVersion !== "string") fail("apps/mobile/app.json has no expo.version");
|
||||||
|
if (androidVersions.length !== 1) fail("expected exactly one Android versionName");
|
||||||
|
if (iosVersions.length === 0) fail("found no iOS MARKETING_VERSION values");
|
||||||
|
|
||||||
|
const versions = [appVersion, ...androidVersions, ...iosVersions];
|
||||||
|
versions.forEach((version) => parseVersion(version, `configured version ${version}`));
|
||||||
|
if (new Set(versions).size !== 1) {
|
||||||
|
fail(`public versions disagree: ${versions.join(", ")}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { appRaw, androidRaw, iosRaw, current: appVersion, iosCount: iosVersions.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextVersion(current, bump) {
|
||||||
|
const [major, minor, patch] = parseVersion(current, "current version");
|
||||||
|
if (bump === "major") return `${major + 1}.0.0`;
|
||||||
|
if (bump === "minor") return `${major}.${minor + 1}.0`;
|
||||||
|
if (bump === "patch") return `${major}.${minor}.${patch + 1}`;
|
||||||
|
fail("--bump must be patch, minor, or major");
|
||||||
|
}
|
||||||
|
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const check = args.length === 1 && args[0] === "--check";
|
||||||
|
const bumpIndex = args.indexOf("--bump");
|
||||||
|
const versionIndex = args.indexOf("--version");
|
||||||
|
|
||||||
|
if (!check && (bumpIndex === -1) === (versionIndex === -1)) {
|
||||||
|
fail("use --check, --bump patch|minor|major, or --version x.y.z");
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = readState();
|
||||||
|
if (check) {
|
||||||
|
console.log(`public version ${state.current} is consistent across app.json, Android, and ${state.iosCount} iOS settings`);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const desired =
|
||||||
|
bumpIndex !== -1
|
||||||
|
? nextVersion(state.current, args[bumpIndex + 1])
|
||||||
|
: (parseVersion(args[versionIndex + 1], "--version"), args[versionIndex + 1]);
|
||||||
|
|
||||||
|
if (desired === state.current) fail(`requested version is already ${state.current}`);
|
||||||
|
const currentParts = parseVersion(state.current, "current version");
|
||||||
|
const desiredParts = parseVersion(desired, "requested version");
|
||||||
|
const increases = desiredParts.some(
|
||||||
|
(part, index) => part > currentParts[index] && desiredParts.slice(0, index).every((value, prior) => value === currentParts[prior]),
|
||||||
|
);
|
||||||
|
if (!increases) fail(`requested version ${desired} must be greater than ${state.current}`);
|
||||||
|
|
||||||
|
const appRaw = state.appRaw.replace(
|
||||||
|
/(\"version\"\s*:\s*\")([^\"]+)(\")/,
|
||||||
|
`$1${desired}$3`,
|
||||||
|
);
|
||||||
|
const androidRaw = state.androidRaw.replace(
|
||||||
|
/(\bversionName\s+["'])([^"']+)(["'])/,
|
||||||
|
`$1${desired}$3`,
|
||||||
|
);
|
||||||
|
const iosRaw = state.iosRaw.replace(
|
||||||
|
/(\bMARKETING_VERSION\s*=\s*)([^;]+)(;)/g,
|
||||||
|
`$1${desired}$3`,
|
||||||
|
);
|
||||||
|
|
||||||
|
fs.writeFileSync(files.app, appRaw);
|
||||||
|
fs.writeFileSync(files.android, androidRaw);
|
||||||
|
fs.writeFileSync(files.ios, iosRaw);
|
||||||
|
console.log(`updated public version ${state.current} -> ${desired}`);
|
||||||
@@ -10,9 +10,12 @@ scoped instructions. Confirm target platform and release profile. Run repository
|
|||||||
verification plus a native acceptance pass before starting a release build.
|
verification plus a native acceptance pass before starting a release build.
|
||||||
|
|
||||||
Before a build from the current upstream branch, fetch, require a clean worktree,
|
Before a build from the current upstream branch, fetch, require a clean worktree,
|
||||||
and prove `HEAD` matches its upstream. Sauci tracks its native iOS and Android
|
and prove `HEAD` matches its upstream. The `generate-release` skill may instead
|
||||||
projects, so routine release builds must package those checked-in sources. Do not
|
build a clean, scoped release-preparation commit that descends directly from that
|
||||||
run `expo prebuild` first; native regeneration is a separate reviewed change.
|
fetched upstream; between its multi-platform builds it commits only independently
|
||||||
|
verified EAS version metadata. Sauci tracks its native iOS and Android projects,
|
||||||
|
so routine release builds must package those checked-in sources. Do not run
|
||||||
|
`expo prebuild` first; native regeneration is a separate reviewed change.
|
||||||
|
|
||||||
Use the package scripts in `apps/mobile/package.json`; they create artifacts only.
|
Use the package scripts in `apps/mobile/package.json`; they create artifacts only.
|
||||||
Never infer upload or submission authority from build authorization. Store upload
|
Never infer upload or submission authority from build authorization. Store upload
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ jobs:
|
|||||||
- run: npm ci
|
- run: npm ci
|
||||||
- run: npm run learn:check
|
- run: npm run learn:check
|
||||||
- run: npm run verify:fast
|
- run: npm run verify:fast
|
||||||
|
- name: Enforce API and mobile coverage floors
|
||||||
|
run: npm run test:coverage
|
||||||
- run: npm run build
|
- run: npm run build
|
||||||
|
|
||||||
backend-integration:
|
backend-integration:
|
||||||
@@ -76,7 +78,7 @@ jobs:
|
|||||||
- name: Run standalone backend integration tests
|
- name: Run standalone backend integration tests
|
||||||
env:
|
env:
|
||||||
DATABASE_URL: postgresql://sauci:sauci_ci_only@127.0.0.1:5432/sauci_test
|
DATABASE_URL: postgresql://sauci:sauci_ci_only@127.0.0.1:5432/sauci_test
|
||||||
run: npm run test:api:integration
|
run: npm run test:integration:coverage -w @sauci/api
|
||||||
|
|
||||||
container-contract:
|
container-contract:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@@ -17,7 +17,18 @@ MCP service in one npm/Turborepo workspace.
|
|||||||
5. Read `apps/mobile/DESIGN.md` before mobile UI work.
|
5. Read `apps/mobile/DESIGN.md` before mobile UI work.
|
||||||
6. Keep shared package exports type-only and route them through `src/index.ts`.
|
6. Keep shared package exports type-only and route them through `src/index.ts`.
|
||||||
7. Do not commit secrets, generated evidence, local logs, or runtime state.
|
7. Do not commit secrets, generated evidence, local logs, or runtime state.
|
||||||
8. Scope work to the task. Use high-effort reviewers only for the risk triggers in
|
8. Implement in a task-specific git worktree created from current `staging`,
|
||||||
|
not in the shared `staging` checkout. After the work is verified, commit the
|
||||||
|
scoped changes, merge the completed task branch back into local `staging`,
|
||||||
|
push `staging`, and prove local and remote commit parity.
|
||||||
|
9. Before merging, identify every deployed app or service the change can affect
|
||||||
|
and prove the relevant compatibility contracts still hold. Preserve deployed
|
||||||
|
interfaces and supported client behavior; repository checks alone do not
|
||||||
|
prove a live change is safe.
|
||||||
|
10. Scope work to the task. Route bounded implementation through the project
|
||||||
|
`implementation` agent when delegation is useful; the primary agent retains
|
||||||
|
acceptance, review, and final verification. Do not delegate trivial edits.
|
||||||
|
11. Use high-effort reviewers only for the risk triggers in
|
||||||
`docs/agents/routing.md`.
|
`docs/agents/routing.md`.
|
||||||
|
|
||||||
## Repository map
|
## Repository map
|
||||||
@@ -36,6 +47,8 @@ MCP service in one npm/Turborepo workspace.
|
|||||||
|
|
||||||
## Workflows
|
## Workflows
|
||||||
|
|
||||||
|
- Implementation: create a task worktree from current `staging`; commit verified
|
||||||
|
work, merge it back to local `staging`, push `staging`, and verify remote parity.
|
||||||
- Bootstrap: `npm ci`
|
- Bootstrap: `npm ci`
|
||||||
- Full local stack: `scripts/dev-local.sh up`
|
- Full local stack: `scripts/dev-local.sh up`
|
||||||
- Fast checks: `npm run verify:fast`
|
- Fast checks: `npm run verify:fast`
|
||||||
|
|||||||
@@ -3,6 +3,15 @@ DATABASE_URL=postgresql://sauci:sauci_local_only@127.0.0.1:54320/sauci
|
|||||||
SUPABASE_AUTH_URL=https://your-project.supabase.co
|
SUPABASE_AUTH_URL=https://your-project.supabase.co
|
||||||
SUPABASE_AUTH_AUDIENCE=authenticated
|
SUPABASE_AUTH_AUDIENCE=authenticated
|
||||||
|
|
||||||
|
# Optional connection-pool bounds. The API process opens one pool and shares it
|
||||||
|
# across every repository, so these are the process-wide limits, not per-domain
|
||||||
|
# ones. The worker has its own separately bounded process pool. Raising either
|
||||||
|
# maximum raises the server connections this instance can hold; keep the total
|
||||||
|
# across instances inside the database's own limit.
|
||||||
|
# DATABASE_POOL_MAX=10
|
||||||
|
# DATABASE_POOL_IDLE_TIMEOUT_MS=30000
|
||||||
|
# DATABASE_POOL_CONNECTION_TIMEOUT_MS=10000
|
||||||
|
|
||||||
# Server-only hosted Auth Admin credential. Used for account deletion and for
|
# Server-only hosted Auth Admin credential. Used for account deletion and for
|
||||||
# enriching admin user lists; it is never exposed to the browser or used as a
|
# enriching admin user lists; it is never exposed to the browser or used as a
|
||||||
# Supabase database client.
|
# Supabase database client.
|
||||||
|
|||||||
@@ -17,8 +17,11 @@
|
|||||||
"db:migrate:prod": "node dist/db/migrate.js",
|
"db:migrate:prod": "node dist/db/migrate.js",
|
||||||
"db:seed": "tsx src/db/seed.ts",
|
"db:seed": "tsx src/db/seed.ts",
|
||||||
"migration:cutover": "tsx src/migration/run.ts",
|
"migration:cutover": "tsx src/migration/run.ts",
|
||||||
|
"load:representative": "tsx src/load/representative.ts",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
|
"test:coverage": "vitest run --coverage",
|
||||||
"test:integration": "vitest run --config vitest.integration.config.ts",
|
"test:integration": "vitest run --config vitest.integration.config.ts",
|
||||||
|
"test:integration:coverage": "vitest run --config vitest.integration.config.ts --coverage",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"lint": "tsc --noEmit"
|
"lint": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
@@ -32,6 +35,7 @@
|
|||||||
"zod": "^3.23.0"
|
"zod": "^3.23.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@vitest/coverage-v8": "^2.1.9",
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
"@types/pg": "^8.15.5",
|
"@types/pg": "^8.15.5",
|
||||||
"drizzle-kit": "^0.31.4",
|
"drizzle-kit": "^0.31.4",
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import type { FilesystemMediaStorage } from './domains/media/storage.js';
|
|||||||
import type { AdminRequestAuth } from './domains/admin/auth.js';
|
import type { AdminRequestAuth } from './domains/admin/auth.js';
|
||||||
import type { AdminRepository } from './domains/admin/repository.js';
|
import type { AdminRepository } from './domains/admin/repository.js';
|
||||||
import { registerAdminRoutes } from './domains/admin/routes.js';
|
import { registerAdminRoutes } from './domains/admin/routes.js';
|
||||||
|
import { recordRequest } from './telemetry.js';
|
||||||
|
|
||||||
type Variables = { identity: AuthIdentity };
|
type Variables = { identity: AuthIdentity };
|
||||||
|
|
||||||
@@ -57,6 +58,18 @@ function error(code: string, message: string): ApiErrorResponse {
|
|||||||
export function createApp(deps: AppDependencies): Hono<{ Variables: Variables }> {
|
export function createApp(deps: AppDependencies): Hono<{ Variables: Variables }> {
|
||||||
const app = new Hono<{ Variables: Variables }>();
|
const app = new Hono<{ Variables: Variables }>();
|
||||||
|
|
||||||
|
app.use('*', async (c, next) => {
|
||||||
|
const startedAt = performance.now();
|
||||||
|
try {
|
||||||
|
await next();
|
||||||
|
} finally {
|
||||||
|
// Hono returns the matched template after dispatch, never the raw path.
|
||||||
|
const candidate = c.req.routePath;
|
||||||
|
const route = candidate && !/[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}/i.test(candidate) && !candidate.includes('?') ? candidate : 'unmatched';
|
||||||
|
recordRequest(c.req.method, route, c.res.status, startedAt);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.get('/health/live', (c) => c.json({ status: 'ok' }));
|
app.get('/health/live', (c) => c.json({ status: 'ok' }));
|
||||||
app.get('/health/ready', async (c) => {
|
app.get('/health/ready', async (c) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ const baseSchema = z.object({
|
|||||||
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
|
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
|
||||||
PORT: z.coerce.number().int().min(1).max(65535).default(3003),
|
PORT: z.coerce.number().int().min(1).max(65535).default(3003),
|
||||||
DATABASE_URL: z.string().url(),
|
DATABASE_URL: z.string().url(),
|
||||||
|
DATABASE_POOL_MAX: z.coerce.number().int().min(1).max(200).default(10),
|
||||||
|
DATABASE_POOL_IDLE_TIMEOUT_MS: z.coerce.number().int().min(0).max(3_600_000).default(30_000),
|
||||||
|
DATABASE_POOL_CONNECTION_TIMEOUT_MS: z.coerce.number().int().min(0).max(60_000).default(10_000),
|
||||||
SUPABASE_AUTH_URL: z.string().url().optional(),
|
SUPABASE_AUTH_URL: z.string().url().optional(),
|
||||||
SUPABASE_AUTH_ISSUER: z.string().url().optional(),
|
SUPABASE_AUTH_ISSUER: z.string().url().optional(),
|
||||||
SUPABASE_AUTH_JWKS_URL: z.string().url().optional(),
|
SUPABASE_AUTH_JWKS_URL: z.string().url().optional(),
|
||||||
@@ -28,6 +31,7 @@ export interface AppConfig {
|
|||||||
nodeEnv: 'development' | 'test' | 'production';
|
nodeEnv: 'development' | 'test' | 'production';
|
||||||
port: number;
|
port: number;
|
||||||
databaseUrl: string;
|
databaseUrl: string;
|
||||||
|
databasePool: { max: number; idleTimeoutMillis: number; connectionTimeoutMillis: number };
|
||||||
authIssuer: string;
|
authIssuer: string;
|
||||||
authJwksUrl?: string;
|
authJwksUrl?: string;
|
||||||
authAudience: string;
|
authAudience: string;
|
||||||
@@ -81,6 +85,11 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
|
|||||||
nodeEnv: parsed.NODE_ENV,
|
nodeEnv: parsed.NODE_ENV,
|
||||||
port: parsed.PORT,
|
port: parsed.PORT,
|
||||||
databaseUrl: parsed.DATABASE_URL,
|
databaseUrl: parsed.DATABASE_URL,
|
||||||
|
databasePool: {
|
||||||
|
max: parsed.DATABASE_POOL_MAX,
|
||||||
|
idleTimeoutMillis: parsed.DATABASE_POOL_IDLE_TIMEOUT_MS,
|
||||||
|
connectionTimeoutMillis: parsed.DATABASE_POOL_CONNECTION_TIMEOUT_MS,
|
||||||
|
},
|
||||||
authIssuer,
|
authIssuer,
|
||||||
authJwksUrl,
|
authJwksUrl,
|
||||||
authAudience: parsed.SUPABASE_AUTH_AUDIENCE,
|
authAudience: parsed.SUPABASE_AUTH_AUDIENCE,
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { Pool } from 'pg';
|
||||||
|
import { instrumentPool, type TelemetryProcess } from '../telemetry.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A repository is constructed either from a connection string, in which case it
|
||||||
|
* owns the pool it creates and must end it, or from a pool the caller owns. The
|
||||||
|
* API process takes the second path so every domain shares one bounded pool
|
||||||
|
* instead of opening a default-sized pool per repository; tests keep taking the
|
||||||
|
* first so each suite stays independently closable.
|
||||||
|
*/
|
||||||
|
export type DatabaseConnection = string | Pool;
|
||||||
|
|
||||||
|
export interface DatabasePoolOptions {
|
||||||
|
/** Server-wide ceiling on concurrent PostgreSQL connections. */
|
||||||
|
max?: number;
|
||||||
|
idleTimeoutMillis?: number;
|
||||||
|
connectionTimeoutMillis?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `pg` defaults to ten connections per pool, which multiplied by one pool per
|
||||||
|
* repository. One shared pool of the same size keeps the same headroom for a
|
||||||
|
* single API process while bounding what a self-hosted PostgreSQL must accept.
|
||||||
|
*/
|
||||||
|
export const DEFAULT_POOL_MAX = 10;
|
||||||
|
|
||||||
|
export function createDatabasePool(databaseUrl: string, options: DatabasePoolOptions = {}, telemetryProcess?: TelemetryProcess): Pool {
|
||||||
|
const pool = new Pool({
|
||||||
|
connectionString: databaseUrl,
|
||||||
|
max: options.max ?? DEFAULT_POOL_MAX,
|
||||||
|
...(options.idleTimeoutMillis === undefined ? {} : { idleTimeoutMillis: options.idleTimeoutMillis }),
|
||||||
|
...(options.connectionTimeoutMillis === undefined ? {} : { connectionTimeoutMillis: options.connectionTimeoutMillis }),
|
||||||
|
});
|
||||||
|
return telemetryProcess ? instrumentPool(telemetryProcess, pool) : pool;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolvedPool {
|
||||||
|
pool: Pool;
|
||||||
|
/** True when this repository created the pool and is responsible for ending it. */
|
||||||
|
owned: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolvePool(connection: DatabaseConnection): ResolvedPool {
|
||||||
|
return typeof connection === 'string'
|
||||||
|
? { pool: new Pool({ connectionString: connection }), owned: true }
|
||||||
|
: { pool: connection, owned: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ends the pool only when this repository created it, so shared pools survive. */
|
||||||
|
export async function closeResolvedPool(pool: Pool, owned: boolean): Promise<void> {
|
||||||
|
if (owned) await pool.end();
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import type { FeatureInterestResponse, Profile } from '@sauci/shared';
|
|||||||
import { and, eq, sql } from 'drizzle-orm';
|
import { and, eq, sql } from 'drizzle-orm';
|
||||||
import { drizzle, type NodePgDatabase } from 'drizzle-orm/node-postgres';
|
import { drizzle, type NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||||
import { Pool } from 'pg';
|
import { Pool } from 'pg';
|
||||||
|
import { closeResolvedPool, resolvePool, type DatabaseConnection } from './pool.js';
|
||||||
import type { AuthIdentity } from '../auth.js';
|
import type { AuthIdentity } from '../auth.js';
|
||||||
import { featureInterests, profiles, type ProfileRow } from './schema.js';
|
import { featureInterests, profiles, type ProfileRow } from './schema.js';
|
||||||
|
|
||||||
@@ -44,10 +45,13 @@ function toProfile(row: ProfileRow): MobileCompatibleProfile {
|
|||||||
|
|
||||||
export class PostgresRepository implements ApiRepository {
|
export class PostgresRepository implements ApiRepository {
|
||||||
private readonly pool: Pool;
|
private readonly pool: Pool;
|
||||||
|
private readonly ownsPool: boolean;
|
||||||
private readonly db: NodePgDatabase;
|
private readonly db: NodePgDatabase;
|
||||||
|
|
||||||
constructor(databaseUrl: string) {
|
constructor(connection: DatabaseConnection) {
|
||||||
this.pool = new Pool({ connectionString: databaseUrl });
|
const resolved = resolvePool(connection);
|
||||||
|
this.pool = resolved.pool;
|
||||||
|
this.ownsPool = resolved.owned;
|
||||||
this.db = drizzle(this.pool);
|
this.db = drizzle(this.pool);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,7 +59,21 @@ export class PostgresRepository implements ApiRepository {
|
|||||||
await this.db.select({ id: profiles.id }).from(profiles).limit(1);
|
await this.db.select({ id: profiles.id }).from(profiles).limit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Idempotent for the common case. `GET /v1/me` runs on every app start and on
|
||||||
|
* every session refresh, and the previous unconditional upsert made each of
|
||||||
|
* those a write that advanced `auth_synced_at` and produced dead row versions
|
||||||
|
* for autovacuum. The only fields this sync can actually change are the ones
|
||||||
|
* the conflict clause sets, so a profile that already carries the identity's
|
||||||
|
* email is returned as read and nothing is written. First-use creation and
|
||||||
|
* email enrichment keep their existing behaviour.
|
||||||
|
*/
|
||||||
async upsertProfile(identity: AuthIdentity): Promise<MobileCompatibleProfile> {
|
async upsertProfile(identity: AuthIdentity): Promise<MobileCompatibleProfile> {
|
||||||
|
const [existing] = await this.db.select().from(profiles).where(eq(profiles.id, identity.id)).limit(1);
|
||||||
|
if (existing && (identity.email === null || identity.email === undefined || existing.email === identity.email)) {
|
||||||
|
return toProfile(existing);
|
||||||
|
}
|
||||||
|
|
||||||
const [row] = await this.db.insert(profiles).values({
|
const [row] = await this.db.insert(profiles).values({
|
||||||
id: identity.id,
|
id: identity.id,
|
||||||
name: identity.name,
|
name: identity.name,
|
||||||
@@ -93,6 +111,6 @@ export class PostgresRepository implements ApiRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async close(): Promise<void> {
|
async close(): Promise<void> {
|
||||||
await this.pool.end();
|
await closeResolvedPool(this.pool, this.ownsPool);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Pool, type PoolClient, type QueryResultRow } from 'pg';
|
import { Pool, type PoolClient, type QueryResultRow } from 'pg';
|
||||||
|
import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js';
|
||||||
import { AccountOperationError } from './types.js';
|
import { AccountOperationError } from './types.js';
|
||||||
|
|
||||||
interface ProfileOperationRecord extends QueryResultRow {
|
interface ProfileOperationRecord extends QueryResultRow {
|
||||||
@@ -63,9 +64,12 @@ async function lockCoupleAndPartner(
|
|||||||
|
|
||||||
export class PostgresAccountOperationsRepository implements AccountOperationsRepository {
|
export class PostgresAccountOperationsRepository implements AccountOperationsRepository {
|
||||||
private readonly pool: Pool;
|
private readonly pool: Pool;
|
||||||
|
private readonly ownsPool: boolean;
|
||||||
|
|
||||||
constructor(databaseUrl: string) {
|
constructor(connection: DatabaseConnection) {
|
||||||
this.pool = new Pool({ connectionString: databaseUrl });
|
const resolved = resolvePool(connection);
|
||||||
|
this.pool = resolved.pool;
|
||||||
|
this.ownsPool = resolved.owned;
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteRelationship(userId: string): Promise<DestructiveOperationResult> {
|
async deleteRelationship(userId: string): Promise<DestructiveOperationResult> {
|
||||||
@@ -135,7 +139,7 @@ export class PostgresAccountOperationsRepository implements AccountOperationsRep
|
|||||||
}
|
}
|
||||||
|
|
||||||
async close(): Promise<void> {
|
async close(): Promise<void> {
|
||||||
await this.pool.end();
|
await closeResolvedPool(this.pool, this.ownsPool);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Pool, type PoolClient, type QueryResultRow } from 'pg';
|
import { Pool, type PoolClient, type QueryResultRow } from 'pg';
|
||||||
|
import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js';
|
||||||
import { readFile } from 'node:fs/promises';
|
import { readFile } from 'node:fs/promises';
|
||||||
import { resolve, sep } from 'node:path';
|
import { resolve, sep } from 'node:path';
|
||||||
import { AdminError, type AdminPrincipal, type AdminQuery } from './types.js';
|
import { AdminError, type AdminPrincipal, type AdminQuery } from './types.js';
|
||||||
@@ -139,9 +140,10 @@ export interface AdminRepository {
|
|||||||
|
|
||||||
export class PostgresAdminRepository implements AdminRepository {
|
export class PostgresAdminRepository implements AdminRepository {
|
||||||
private readonly pool: Pool;
|
private readonly pool: Pool;
|
||||||
constructor(databaseUrl: string, private readonly options: {
|
private readonly ownsPool: boolean;
|
||||||
|
constructor(connection: DatabaseConnection, private readonly options: {
|
||||||
adminPrivateKeyJwk?: string; mediaRoot?: string; authDirectory?: AdminAuthDirectory;
|
adminPrivateKeyJwk?: string; mediaRoot?: string; authDirectory?: AdminAuthDirectory;
|
||||||
} = {}) { this.pool = new Pool({ connectionString: databaseUrl }); }
|
} = {}) { const resolved = resolvePool(connection); this.pool = resolved.pool; this.ownsPool = resolved.owned; }
|
||||||
|
|
||||||
async principal(userId: string): Promise<AdminPrincipal> {
|
async principal(userId: string): Promise<AdminPrincipal> {
|
||||||
const result = await this.pool.query<{ id: string; user_id: string; role: AdminPrincipal['role']; permissions: string[] }>(
|
const result = await this.pool.query<{ id: string; user_id: string; role: AdminPrincipal['role']; permissions: string[] }>(
|
||||||
@@ -356,5 +358,5 @@ export class PostgresAdminRepository implements AdminRepository {
|
|||||||
await client.query('insert into audit_logs(admin_user_id,actor_user_id,table_name,action,record_id,old_values,new_values) values($1,$2,$3,$4,$5,$6,$7)', [principal.adminId, principal.userId, table, action, recordId || null, redactAudit(oldValues), redactAudit(newValues)]);
|
await client.query('insert into audit_logs(admin_user_id,actor_user_id,table_name,action,record_id,old_values,new_values) values($1,$2,$3,$4,$5,$6,$7)', [principal.adminId, principal.userId, table, action, recordId || null, redactAudit(oldValues), redactAudit(newValues)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async close(): Promise<void> { await this.pool.end(); }
|
async close(): Promise<void> { await closeResolvedPool(this.pool, this.ownsPool); }
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Pool, type PoolClient } from 'pg';
|
import { Pool, type PoolClient } from 'pg';
|
||||||
|
import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js';
|
||||||
import { AnswersError, calculateMatchType, type Answer, type MatchType, type QuestionType, type ResponseData } from './types.js';
|
import { AnswersError, calculateMatchType, type Answer, type MatchType, type QuestionType, type ResponseData } from './types.js';
|
||||||
|
|
||||||
interface ContextRow { couple_id: string | null; gender: string | null; max_intensity: number; is_premium: boolean }
|
interface ContextRow { couple_id: string | null; gender: string | null; max_intensity: number; is_premium: boolean }
|
||||||
@@ -44,7 +45,7 @@ export interface AnswersRepository {
|
|||||||
submit(userId: string, input: SubmitInput): Promise<{ response: ResponseRow; match: (MatchRow & { question: QuestionRow }) | null }>;
|
submit(userId: string, input: SubmitInput): Promise<{ response: ResponseRow; match: (MatchRow & { question: QuestionRow }) | null }>;
|
||||||
update(userId: string, input: UpdateInput): Promise<Record<string, unknown>>;
|
update(userId: string, input: UpdateInput): Promise<Record<string, unknown>>;
|
||||||
responses(userId: string, page: number, limit: number): Promise<{ responses: unknown[]; totalCount: number }>;
|
responses(userId: string, page: number, limit: number): Promise<{ responses: unknown[]; totalCount: number }>;
|
||||||
matches(userId: string, page: number, limit: number, archived: boolean): Promise<{ matches: unknown[]; totalCount: number }>;
|
matches(userId: string, page: number, limit: number, archived: boolean): Promise<{ matches: unknown[]; totalCount: number | null; hasMore: boolean }>;
|
||||||
markSeen(userId: string, ids: string[]): Promise<void>;
|
markSeen(userId: string, ids: string[]): Promise<void>;
|
||||||
archive(userId: string, matchId: string, archived: boolean): Promise<void>;
|
archive(userId: string, matchId: string, archived: boolean): Promise<void>;
|
||||||
streak(userId: string): Promise<CoupleStreakView | null>;
|
streak(userId: string): Promise<CoupleStreakView | null>;
|
||||||
@@ -53,8 +54,9 @@ export interface AnswersRepository {
|
|||||||
|
|
||||||
export class PostgresAnswersRepository implements AnswersRepository {
|
export class PostgresAnswersRepository implements AnswersRepository {
|
||||||
private readonly pool: Pool;
|
private readonly pool: Pool;
|
||||||
|
private readonly ownsPool: boolean;
|
||||||
private zones: Promise<Set<string>> | null = null;
|
private zones: Promise<Set<string>> | null = null;
|
||||||
constructor(databaseUrl: string) { this.pool = new Pool({ connectionString: databaseUrl }); }
|
constructor(connection: DatabaseConnection) { const resolved = resolvePool(connection); this.pool = resolved.pool; this.ownsPool = resolved.owned; }
|
||||||
|
|
||||||
private async context(client: Pool | PoolClient, userId: string): Promise<ContextRow> {
|
private async context(client: Pool | PoolClient, userId: string): Promise<ContextRow> {
|
||||||
const result = await client.query<ContextRow>('select couple_id, gender, max_intensity, is_premium from profiles where id=$1', [userId]);
|
const result = await client.query<ContextRow>('select couple_id, gender, max_intensity, is_premium from profiles where id=$1', [userId]);
|
||||||
@@ -230,12 +232,23 @@ export class PostgresAnswersRepository implements AnswersRepository {
|
|||||||
return {responses:rows.rows.map(this.dates),totalCount:count.rows[0]?.count??0};
|
return {responses:rows.rows.map(this.dates),totalCount:count.rows[0]?.count??0};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reading one row past the page decides `hasMore` exactly, and the total — which
|
||||||
|
* the client only records when it refreshes — is counted on the first page only
|
||||||
|
* instead of on every scroll. Unread is aggregated once per couple rather than as
|
||||||
|
* a correlated count per row: the sort key depends on it, so the old subquery
|
||||||
|
* already ran for every candidate match, not just the twenty that were returned.
|
||||||
|
*/
|
||||||
async matches(userId:string,page:number,limit:number,archived:boolean){
|
async matches(userId:string,page:number,limit:number,archived:boolean){
|
||||||
const ctx=await this.context(this.pool,userId); const offset=page*limit;
|
const ctx=await this.context(this.pool,userId); const offset=page*limit;
|
||||||
const params=[userId,ctx.couple_id,archived,limit,offset];
|
const params=[userId,ctx.couple_id,archived,limit+1,offset];
|
||||||
const base=`from matches m join questions q on q.id=m.question_id left join match_archives a on a.match_id=m.id and a.user_id=$1 where m.couple_id=$2 and (($3 and a.id is not null) or (not $3 and a.id is null))`;
|
const from=`from matches m join questions q on q.id=m.question_id left join match_archives a on a.match_id=m.id and a.user_id=$1`;
|
||||||
const [rows,count]=await Promise.all([this.pool.query(`select m.*,json_build_object('id',q.id,'pack_id',q.pack_id,'text',q.text,'partner_text',q.partner_text,'intensity',q.intensity,'question_type',q.question_type,'config',q.config,'created_at',q.created_at) question,(select count(*)::int from messages msg where msg.match_id=m.id and msg.user_id<>$1 and msg.read_at is null) "unreadCount" ${base} order by "unreadCount" desc,m.created_at desc limit $4 offset $5`,params),this.pool.query<{count:number}>(`select count(*)::int count ${base}`,params.slice(0,3))]);
|
const where=`where m.couple_id=$2 and (($3 and a.id is not null) or (not $3 and a.id is null))`;
|
||||||
return {matches:rows.rows.map(this.dates),totalCount:count.rows[0]?.count??0};
|
const unread=`unread as (select msg.match_id,count(*)::int c from messages msg join matches um on um.id=msg.match_id and um.couple_id=$2 where msg.user_id<>$1 and msg.read_at is null group by msg.match_id)`;
|
||||||
|
const [rows,count]=await Promise.all([
|
||||||
|
this.pool.query(`with ${unread} select m.*,json_build_object('id',q.id,'pack_id',q.pack_id,'text',q.text,'partner_text',q.partner_text,'intensity',q.intensity,'question_type',q.question_type,'config',q.config,'created_at',q.created_at) question,coalesce(u.c,0) "unreadCount" ${from} left join unread u on u.match_id=m.id ${where} order by "unreadCount" desc,m.created_at desc limit $4 offset $5`,params),
|
||||||
|
page===0?this.pool.query<{count:number}>(`select count(*)::int count ${from} ${where}`,params.slice(0,3)):null]);
|
||||||
|
return {matches:rows.rows.slice(0,limit).map(this.dates),totalCount:count?count.rows[0]?.count??0:null,hasMore:rows.rows.length>limit};
|
||||||
}
|
}
|
||||||
async markSeen(userId:string,ids:string[]){const ctx=await this.context(this.pool,userId);await this.pool.query('update matches set is_new=false where couple_id=$1 and id=any($2::uuid[])',[ctx.couple_id,ids]);}
|
async markSeen(userId:string,ids:string[]){const ctx=await this.context(this.pool,userId);await this.pool.query('update matches set is_new=false where couple_id=$1 and id=any($2::uuid[])',[ctx.couple_id,ids]);}
|
||||||
async archive(userId:string,matchId:string,archived:boolean){const ctx=await this.context(this.pool,userId);const found=await this.pool.query('select 1 from matches where id=$1 and couple_id=$2',[matchId,ctx.couple_id]);if(!found.rowCount)throw new AnswersError('match_not_found',404,'Match was not found');if(archived)await this.pool.query('insert into match_archives(match_id,user_id) values($1,$2) on conflict do nothing',[matchId,userId]);else await this.pool.query('delete from match_archives where match_id=$1 and user_id=$2',[matchId,userId]);}
|
async archive(userId:string,matchId:string,archived:boolean){const ctx=await this.context(this.pool,userId);const found=await this.pool.query('select 1 from matches where id=$1 and couple_id=$2',[matchId,ctx.couple_id]);if(!found.rowCount)throw new AnswersError('match_not_found',404,'Match was not found');if(archived)await this.pool.query('insert into match_archives(match_id,user_id) values($1,$2) on conflict do nothing',[matchId,userId]);else await this.pool.query('delete from match_archives where match_id=$1 and user_id=$2',[matchId,userId]);}
|
||||||
@@ -304,5 +317,5 @@ export class PostgresAnswersRepository implements AnswersRepository {
|
|||||||
}
|
}
|
||||||
private async tx<T>(fn:(client:PoolClient)=>Promise<T>):Promise<T>{const c=await this.pool.connect();try{await c.query('begin');const value=await fn(c);await c.query('commit');return value;}catch(e){await c.query('rollback');throw e;}finally{c.release();}}
|
private async tx<T>(fn:(client:PoolClient)=>Promise<T>):Promise<T>{const c=await this.pool.connect();try{await c.query('begin');const value=await fn(c);await c.query('commit');return value;}catch(e){await c.query('rollback');throw e;}finally{c.release();}}
|
||||||
private dates<T extends Record<string,any>>(row:T):T{return Object.fromEntries(Object.entries(row).map(([k,v])=>[k,v instanceof Date?v.toISOString():v])) as T;}
|
private dates<T extends Record<string,any>>(row:T):T{return Object.fromEntries(Object.entries(row).map(([k,v])=>[k,v instanceof Date?v.toISOString():v])) as T;}
|
||||||
async close(){await this.pool.end();}
|
async close(){await closeResolvedPool(this.pool,this.ownsPool);}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Pool, type QueryResultRow } from 'pg';
|
import { Pool, type QueryResultRow } from 'pg';
|
||||||
|
import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js';
|
||||||
|
|
||||||
export interface PackContext { id: string; name: string; icon: string | null }
|
export interface PackContext { id: string; name: string; icon: string | null }
|
||||||
export interface AppQuestion {
|
export interface AppQuestion {
|
||||||
@@ -19,6 +20,50 @@ export interface StrokeSegment {
|
|||||||
}
|
}
|
||||||
export interface LiveDrawState { strokes: StrokeSegment[]; revision: number; updated_at: string | null; updated_by: string | null }
|
export interface LiveDrawState { strokes: StrokeSegment[]; revision: number; updated_at: string | null; updated_by: string | null }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A change summary rather than a payload. The mobile app used to re-fetch profile,
|
||||||
|
* couple, matches, both pending directions and enabled packs every five seconds to
|
||||||
|
* notice partner activity; it now compares this summary with the last one it saw and
|
||||||
|
* refreshes only the domains whose marker actually moved. Every field is a marker,
|
||||||
|
* not display data, so the response stays a few hundred bytes whatever the couple's
|
||||||
|
* history looks like.
|
||||||
|
*/
|
||||||
|
export interface SyncSummary {
|
||||||
|
server_time: string;
|
||||||
|
couple_id: string | null;
|
||||||
|
profile_updated_at: string | null;
|
||||||
|
partner_id: string | null;
|
||||||
|
partner_updated_at: string | null;
|
||||||
|
/** Matches visible to this member: archiving one of them moves the count. */
|
||||||
|
match_count: number;
|
||||||
|
new_match_count: number;
|
||||||
|
latest_match_at: string | null;
|
||||||
|
/** Partner answered, this member has not. */
|
||||||
|
pending_yours: number;
|
||||||
|
/** This member answered, partner has not. */
|
||||||
|
pending_theirs: number;
|
||||||
|
unread_total: number;
|
||||||
|
/** Digest of the enabled pack ids, so an equal-and-opposite toggle is still a change. */
|
||||||
|
enabled_packs_fingerprint: string;
|
||||||
|
/**
|
||||||
|
* Digest of each visible match's type and response summary. `match_count` /
|
||||||
|
* `new_match_count` / `latest_match_at` only move when a match is created,
|
||||||
|
* archived or unarchived — not when a partner edits an existing response and
|
||||||
|
* changes its `match_type` or `response_summary` — so this fingerprint is
|
||||||
|
* what actually notices that edit.
|
||||||
|
*/
|
||||||
|
match_state_fingerprint: string;
|
||||||
|
/**
|
||||||
|
* Digest of each visible match's unread count. `unread_total` can stay equal
|
||||||
|
* while unread messages redistribute between matches (one read, another
|
||||||
|
* receives a message), which reorders the unread-first list without moving
|
||||||
|
* the total; this fingerprint is what notices that.
|
||||||
|
*/
|
||||||
|
match_unread_fingerprint: string;
|
||||||
|
/** couple_streaks.updated_at, so a partner's answer can silently refresh an already-loaded streak. */
|
||||||
|
streak_updated_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export class AppDataError extends Error {
|
export class AppDataError extends Error {
|
||||||
constructor(public readonly code: 'not_found' | 'no_couple' | 'not_recipient' | 'rate_limited' | 'revision_conflict', message: string, public readonly status: 403 | 404 | 409 | 429, public readonly details?: Record<string, unknown>) { super(message); }
|
constructor(public readonly code: 'not_found' | 'no_couple' | 'not_recipient' | 'rate_limited' | 'revision_conflict', message: string, public readonly status: 403 | 404 | 409 | 429, public readonly details?: Record<string, unknown>) { super(message); }
|
||||||
}
|
}
|
||||||
@@ -31,6 +76,7 @@ export interface AppDataRepository {
|
|||||||
markMediaViewed(userId: string, messageId: string, expiresAt: string | null): Promise<{ media_viewed_at: string; media_expires_at: string | null }>;
|
markMediaViewed(userId: string, messageId: string, expiresAt: string | null): Promise<{ media_viewed_at: string; media_expires_at: string | null }>;
|
||||||
nudgeStatus(userId: string): Promise<{ last_nudge_sent_at: string | null }>;
|
nudgeStatus(userId: string): Promise<{ last_nudge_sent_at: string | null }>;
|
||||||
sendNudge(userId: string): Promise<{ success: true; notification_sent: boolean; reason?: string; next_nudge_available_at: string }>;
|
sendNudge(userId: string): Promise<{ success: true; notification_sent: boolean; reason?: string; next_nudge_available_at: string }>;
|
||||||
|
syncSummary(userId: string): Promise<SyncSummary>;
|
||||||
getLiveDraw(userId: string): Promise<LiveDrawState>;
|
getLiveDraw(userId: string): Promise<LiveDrawState>;
|
||||||
putLiveDraw(userId: string, strokes: StrokeSegment[], baseRevision: number): Promise<LiveDrawState>;
|
putLiveDraw(userId: string, strokes: StrokeSegment[], baseRevision: number): Promise<LiveDrawState>;
|
||||||
close(): Promise<void>;
|
close(): Promise<void>;
|
||||||
@@ -38,11 +84,22 @@ export interface AppDataRepository {
|
|||||||
|
|
||||||
interface QuestionRow extends QueryResultRow, Omit<AppQuestion, 'created_at'> { created_at: Date }
|
interface QuestionRow extends QueryResultRow, Omit<AppQuestion, 'created_at'> { created_at: Date }
|
||||||
|
|
||||||
|
interface SyncRow extends QueryResultRow {
|
||||||
|
server_time: Date; couple_id: string | null; profile_updated_at: Date | null;
|
||||||
|
partner_id: string | null; partner_updated_at: Date | null;
|
||||||
|
match_count: number | null; new_match_count: number | null; latest_match_at: Date | null;
|
||||||
|
pending_yours: number | null; pending_theirs: number | null; unread_total: number | null;
|
||||||
|
enabled_packs_fingerprint: string | null;
|
||||||
|
match_state_fingerprint: string | null; match_unread_fingerprint: string | null;
|
||||||
|
streak_updated_at: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
function question(row: QuestionRow): AppQuestion { return { ...row, created_at: row.created_at.toISOString() }; }
|
function question(row: QuestionRow): AppQuestion { return { ...row, created_at: row.created_at.toISOString() }; }
|
||||||
|
|
||||||
export class PostgresAppDataRepository implements AppDataRepository {
|
export class PostgresAppDataRepository implements AppDataRepository {
|
||||||
private readonly pool: Pool;
|
private readonly pool: Pool;
|
||||||
constructor(databaseUrl: string) { this.pool = new Pool({ connectionString: databaseUrl }); }
|
private readonly ownsPool: boolean;
|
||||||
|
constructor(connection: DatabaseConnection) { const resolved = resolvePool(connection); this.pool = resolved.pool; this.ownsPool = resolved.owned; }
|
||||||
|
|
||||||
private async visiblePack(userId: string, packId: string): Promise<PackContext> {
|
private async visiblePack(userId: string, packId: string): Promise<PackContext> {
|
||||||
const result = await this.pool.query<PackContext>(`
|
const result = await this.pool.query<PackContext>(`
|
||||||
@@ -189,5 +246,75 @@ export class PostgresAppDataRepository implements AppDataRepository {
|
|||||||
}
|
}
|
||||||
return { strokes: row.strokes, revision: Number(row.revision), updated_at: row.updated_at.toISOString(), updated_by: row.updated_by };
|
return { strokes: row.strokes, revision: Number(row.revision), updated_at: row.updated_at.toISOString(), updated_by: row.updated_by };
|
||||||
}
|
}
|
||||||
async close(): Promise<void> { await this.pool.end(); }
|
/**
|
||||||
|
* One round trip, all scalar subqueries, nothing that grows with the couple's
|
||||||
|
* history. An unpaired member's couple-scoped markers all collapse to zero or null
|
||||||
|
* because `me.couple_id` is null, so the endpoint stays valid before pairing.
|
||||||
|
*/
|
||||||
|
async syncSummary(userId: string): Promise<SyncSummary> {
|
||||||
|
const result = await this.pool.query<SyncRow>(`
|
||||||
|
with me as (select id, couple_id, updated_at from profiles where id = $1),
|
||||||
|
visible as (
|
||||||
|
select m.id, m.is_new, m.created_at, m.match_type, m.response_summary from matches m, me
|
||||||
|
where m.couple_id = me.couple_id
|
||||||
|
and not exists (select 1 from match_archives a where a.match_id = m.id and a.user_id = me.id)
|
||||||
|
),
|
||||||
|
visible_unread as (
|
||||||
|
select v.id as match_id, count(msg.id)::int as c
|
||||||
|
from visible v
|
||||||
|
left join messages msg on msg.match_id = v.id and msg.user_id <> (select id from me)
|
||||||
|
and msg.read_at is null and msg.deleted_at is null
|
||||||
|
and not exists (select 1 from message_deletions d where d.message_id = msg.id and d.user_id = (select id from me))
|
||||||
|
group by v.id
|
||||||
|
)
|
||||||
|
select
|
||||||
|
now() as server_time,
|
||||||
|
me.couple_id,
|
||||||
|
me.updated_at as profile_updated_at,
|
||||||
|
(select p.id from profiles p where p.couple_id = me.couple_id and p.id <> me.id order by p.id limit 1) as partner_id,
|
||||||
|
(select max(p.updated_at) from profiles p where p.couple_id = me.couple_id and p.id <> me.id) as partner_updated_at,
|
||||||
|
(select count(*)::int from visible) as match_count,
|
||||||
|
(select count(*)::int from visible where is_new) as new_match_count,
|
||||||
|
(select max(created_at) from visible) as latest_match_at,
|
||||||
|
(select count(*)::int from responses r join questions q on q.id = r.question_id
|
||||||
|
where r.couple_id = me.couple_id and q.deleted_at is null and r.user_id <> me.id
|
||||||
|
and not exists (select 1 from responses o
|
||||||
|
where o.question_id = r.question_id and o.couple_id = me.couple_id and o.user_id = me.id)) as pending_yours,
|
||||||
|
(select count(*)::int from responses r join questions q on q.id = r.question_id
|
||||||
|
where r.couple_id = me.couple_id and q.deleted_at is null and r.user_id = me.id
|
||||||
|
and not exists (select 1 from responses o
|
||||||
|
where o.question_id = r.question_id and o.couple_id = me.couple_id and o.user_id <> me.id)) as pending_theirs,
|
||||||
|
(select count(*)::int from messages msg join matches ma on ma.id = msg.match_id
|
||||||
|
where ma.couple_id = me.couple_id and msg.user_id <> me.id and msg.read_at is null and msg.deleted_at is null
|
||||||
|
and not exists (select 1 from message_deletions d where d.message_id = msg.id and d.user_id = me.id)) as unread_total,
|
||||||
|
(select md5(coalesce(string_agg(cp.pack_id::text, ',' order by cp.pack_id), ''))
|
||||||
|
from couple_packs cp where cp.couple_id = me.couple_id and cp.enabled) as enabled_packs_fingerprint,
|
||||||
|
(select md5(coalesce(string_agg(v.id::text || ':' || v.match_type || ':' || coalesce(v.response_summary::text, ''), ',' order by v.id), ''))
|
||||||
|
from visible v) as match_state_fingerprint,
|
||||||
|
(select md5(coalesce(string_agg(vu.match_id::text || ':' || vu.c, ',' order by vu.match_id), ''))
|
||||||
|
from visible_unread vu) as match_unread_fingerprint,
|
||||||
|
(select cs.updated_at from couple_streaks cs where cs.couple_id = me.couple_id) as streak_updated_at
|
||||||
|
from me`, [userId]);
|
||||||
|
const row = result.rows[0];
|
||||||
|
if (!row) throw new AppDataError('not_found', 'Profile not found', 404);
|
||||||
|
return {
|
||||||
|
server_time: row.server_time.toISOString(),
|
||||||
|
couple_id: row.couple_id,
|
||||||
|
profile_updated_at: row.profile_updated_at?.toISOString() ?? null,
|
||||||
|
partner_id: row.partner_id,
|
||||||
|
partner_updated_at: row.partner_updated_at?.toISOString() ?? null,
|
||||||
|
match_count: row.match_count ?? 0,
|
||||||
|
new_match_count: row.new_match_count ?? 0,
|
||||||
|
latest_match_at: row.latest_match_at?.toISOString() ?? null,
|
||||||
|
pending_yours: row.pending_yours ?? 0,
|
||||||
|
pending_theirs: row.pending_theirs ?? 0,
|
||||||
|
unread_total: row.unread_total ?? 0,
|
||||||
|
enabled_packs_fingerprint: row.enabled_packs_fingerprint ?? '',
|
||||||
|
match_state_fingerprint: row.match_state_fingerprint ?? '',
|
||||||
|
match_unread_fingerprint: row.match_unread_fingerprint ?? '',
|
||||||
|
streak_updated_at: row.streak_updated_at?.toISOString() ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async close(): Promise<void> { await closeResolvedPool(this.pool, this.ownsPool); }
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,7 @@ import type { Hono } from 'hono';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import type { AuthIdentity } from '../../auth.js';
|
import type { AuthIdentity } from '../../auth.js';
|
||||||
import { AppDataError, type AppDataRepository } from './repository.js';
|
import { AppDataError, type AppDataRepository } from './repository.js';
|
||||||
|
import { recordSync } from '../../telemetry.js';
|
||||||
|
|
||||||
type App = Hono<{ Variables: { identity: AuthIdentity } }>;
|
type App = Hono<{ Variables: { identity: AuthIdentity } }>;
|
||||||
const uuid = z.string().uuid();
|
const uuid = z.string().uuid();
|
||||||
@@ -44,6 +45,13 @@ export function registerAppDataRoutes(app: App, repository: AppDataRepository):
|
|||||||
const result = await run(() => repository.markMediaViewed(c.get('identity').id, id.data, body.data.expires_at));
|
const result = await run(() => repository.markMediaViewed(c.get('identity').id, id.data, body.data.expires_at));
|
||||||
return result.ok ? c.json(result.value) : c.json(error(result.cause.code, result.cause.message), result.cause.status);
|
return result.ok ? c.json(result.value) : c.json(error(result.cause.code, result.cause.message), result.cause.status);
|
||||||
});
|
});
|
||||||
|
// The client polls this instead of re-fetching every domain; see SyncSummary.
|
||||||
|
app.get('/v1/me/sync', async c => {
|
||||||
|
const startedAt = performance.now();
|
||||||
|
const result = await run(() => repository.syncSummary(c.get('identity').id));
|
||||||
|
recordSync('api',result.ok?'ok':'error',startedAt);
|
||||||
|
return result.ok ? c.json(result.value) : c.json(error(result.cause.code, result.cause.message), result.cause.status);
|
||||||
|
});
|
||||||
app.get('/v1/me/nudge-status', async c => c.json(await repository.nudgeStatus(c.get('identity').id)));
|
app.get('/v1/me/nudge-status', async c => c.json(await repository.nudgeStatus(c.get('identity').id)));
|
||||||
app.post('/v1/me/nudge', async c => {
|
app.post('/v1/me/nudge', async c => {
|
||||||
const result = await run(() => repository.sendNudge(c.get('identity').id));
|
const result = await run(() => repository.sendNudge(c.get('identity').id));
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Pool, type PoolClient, type QueryResultRow } from 'pg';
|
import { Pool, type PoolClient, type QueryResultRow } from 'pg';
|
||||||
|
import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js';
|
||||||
import { BillingError, type RedemptionResult, type RevenueCatWebhook, type SubscriptionStatus } from './types.js';
|
import { BillingError, type RedemptionResult, type RevenueCatWebhook, type SubscriptionStatus } from './types.js';
|
||||||
|
|
||||||
interface ProfileRecord extends QueryResultRow {
|
interface ProfileRecord extends QueryResultRow {
|
||||||
@@ -43,9 +44,12 @@ async function transaction<T>(pool: Pool, operation: (client: PoolClient) => Pro
|
|||||||
|
|
||||||
export class PostgresBillingRepository implements BillingRepository {
|
export class PostgresBillingRepository implements BillingRepository {
|
||||||
private readonly pool: Pool;
|
private readonly pool: Pool;
|
||||||
|
private readonly ownsPool: boolean;
|
||||||
|
|
||||||
constructor(databaseUrl: string) {
|
constructor(connection: DatabaseConnection) {
|
||||||
this.pool = new Pool({ connectionString: databaseUrl });
|
const resolved = resolvePool(connection);
|
||||||
|
this.pool = resolved.pool;
|
||||||
|
this.ownsPool = resolved.owned;
|
||||||
}
|
}
|
||||||
|
|
||||||
async processRevenueCatEvent(
|
async processRevenueCatEvent(
|
||||||
@@ -207,6 +211,6 @@ export class PostgresBillingRepository implements BillingRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async close(): Promise<void> {
|
async close(): Promise<void> {
|
||||||
await this.pool.end();
|
await closeResolvedPool(this.pool, this.ownsPool);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Pool, type QueryResultRow } from 'pg';
|
import { Pool, type QueryResultRow } from 'pg';
|
||||||
|
import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js';
|
||||||
|
|
||||||
export type ChatMediaType = 'image' | 'video' | null;
|
export type ChatMediaType = 'image' | 'video' | null;
|
||||||
export type ReportReason = 'harassment' | 'spam' | 'inappropriate_content' | 'other';
|
export type ReportReason = 'harassment' | 'spam' | 'inappropriate_content' | 'other';
|
||||||
@@ -45,8 +46,37 @@ export class ChatError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The reply to a message poll. A request without a cursor is a full snapshot; a
|
||||||
|
* request with one carries only what changed, so a quiet chat costs an empty
|
||||||
|
* array rather than the whole backlog.
|
||||||
|
*/
|
||||||
|
export interface MessagesPage {
|
||||||
|
/** Newest first. Messages created or changed since the cursor, or the snapshot. */
|
||||||
|
messages: ChatMessage[];
|
||||||
|
/** Messages the caller deleted for themselves since the cursor. */
|
||||||
|
removed_ids: string[];
|
||||||
|
/** Cursor to send as `since` on the next poll. */
|
||||||
|
server_time: string;
|
||||||
|
/** True when `messages` is a complete snapshot the client may replace its cache with. */
|
||||||
|
complete: boolean;
|
||||||
|
/** Present only when the caller asked for it, folding the typing poll into this one. */
|
||||||
|
typing?: PartnerTyping;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PartnerTyping {
|
||||||
|
typing: boolean;
|
||||||
|
expires_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListMessagesOptions {
|
||||||
|
/** ISO timestamp from a previous `server_time`. Omit for a full snapshot. */
|
||||||
|
since?: string;
|
||||||
|
includeTyping?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ChatRepository {
|
export interface ChatRepository {
|
||||||
listMessages(userId: string, matchId: string): Promise<ChatMessage[]>;
|
listMessages(userId: string, matchId: string, options?: ListMessagesOptions): Promise<MessagesPage>;
|
||||||
sendText(userId: string, matchId: string, content: string): Promise<ChatMessage>;
|
sendText(userId: string, matchId: string, content: string): Promise<ChatMessage>;
|
||||||
unreadCounts(userId: string): Promise<{ total: number; by_match: Record<string, number> }>;
|
unreadCounts(userId: string): Promise<{ total: number; by_match: Record<string, number> }>;
|
||||||
markMatchRead(userId: string, matchId: string): Promise<{ updated: number; read_at: string }>;
|
markMatchRead(userId: string, matchId: string): Promise<{ updated: number; read_at: string }>;
|
||||||
@@ -55,13 +85,21 @@ export interface ChatRepository {
|
|||||||
deleteForEveryone(userId: string, messageId: string): Promise<ChatMessage>;
|
deleteForEveryone(userId: string, messageId: string): Promise<ChatMessage>;
|
||||||
report(userId: string, messageId: string, reason: ReportReason): Promise<void>;
|
report(userId: string, messageId: string, reason: ReportReason): Promise<void>;
|
||||||
setTyping(userId: string, matchId: string, ttlMs: number): Promise<void>;
|
setTyping(userId: string, matchId: string, ttlMs: number): Promise<void>;
|
||||||
getPartnerTyping(userId: string, matchId: string): Promise<{ typing: boolean; expires_at: string | null }>;
|
getPartnerTyping(userId: string, matchId: string): Promise<PartnerTyping>;
|
||||||
close(): Promise<void>;
|
close(): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// `media_expired` is derived rather than read. Nothing ever writes the stored
|
||||||
|
// column — it is inserted `false` and never updated — so a video that reached
|
||||||
|
// `media_expires_at` still arrived flagged as playable, while the media itself
|
||||||
|
// was already refused (see `accessible` in the media repository, which treats
|
||||||
|
// `media_expired or media_expires_at <= now()` as gone). Deriving it here is the
|
||||||
|
// same rule the media side already applies, and it is what makes the client's
|
||||||
|
// "Video expired" state reachable at all.
|
||||||
const columns = `m.id, m.match_id, m.user_id, m.content, m.created_at, m.read_at,
|
const columns = `m.id, m.match_id, m.user_id, m.content, m.created_at, m.read_at,
|
||||||
m.delivered_at, m.deleted_at, m.media_path, m.media_type, m.media_expires_at,
|
m.delivered_at, m.deleted_at, m.media_path, m.media_type, m.media_expires_at,
|
||||||
m.media_expired, m.media_viewed_at, m.version, m.encrypted_content,
|
(m.media_expired or (m.media_expires_at is not null and m.media_expires_at <= now())) as media_expired,
|
||||||
|
m.media_viewed_at, m.version, m.encrypted_content,
|
||||||
m.encryption_iv, m.keys_metadata, m.moderation_status, m.flag_reason, m.category`;
|
m.encryption_iv, m.keys_metadata, m.moderation_status, m.flag_reason, m.category`;
|
||||||
|
|
||||||
function iso(value: Date | null): string | null {
|
function iso(value: Date | null): string | null {
|
||||||
@@ -82,9 +120,12 @@ function toMessage(row: MessageRecord): ChatMessage {
|
|||||||
|
|
||||||
export class PostgresChatRepository implements ChatRepository {
|
export class PostgresChatRepository implements ChatRepository {
|
||||||
private readonly pool: Pool;
|
private readonly pool: Pool;
|
||||||
|
private readonly ownsPool: boolean;
|
||||||
|
|
||||||
constructor(databaseUrl: string) {
|
constructor(connection: DatabaseConnection) {
|
||||||
this.pool = new Pool({ connectionString: databaseUrl });
|
const resolved = resolvePool(connection);
|
||||||
|
this.pool = resolved.pool;
|
||||||
|
this.ownsPool = resolved.owned;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async assertMatchAccess(userId: string, matchId: string): Promise<void> {
|
private async assertMatchAccess(userId: string, matchId: string): Promise<void> {
|
||||||
@@ -112,20 +153,68 @@ export class PostgresChatRepository implements ChatRepository {
|
|||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
async listMessages(userId: string, matchId: string): Promise<ChatMessage[]> {
|
/**
|
||||||
|
* Without `since` this is the previous behaviour: the newest 500 visible messages.
|
||||||
|
* With `since` it returns only rows whose visible state moved after that instant —
|
||||||
|
* new messages, existing ones whose delivery, read, deletion or media-view
|
||||||
|
* timestamp advanced, and videos whose media expired inside the window — plus the
|
||||||
|
* ids the caller has since deleted for themselves.
|
||||||
|
*
|
||||||
|
* Moderation metadata (`moderation_status`, `flag_reason`, `category`) is not
|
||||||
|
* covered: the classifier and the admin tool rewrite those columns without
|
||||||
|
* advancing any timestamp, so a delta cannot see them move. No client renders
|
||||||
|
* them today, so nothing user-visible goes stale; giving them a cursor would
|
||||||
|
* mean adding a column to `messages`.
|
||||||
|
* Rows are returned whole rather than as patches so the client merges by id and
|
||||||
|
* needs no second shape to interpret.
|
||||||
|
*
|
||||||
|
* The returned cursor is deliberately a couple of seconds behind the server clock:
|
||||||
|
* a transaction that started before this read but committed after it would
|
||||||
|
* otherwise fall into the gap between two polls. The overlap re-delivers a handful
|
||||||
|
* of rows the client already has, which its merge-by-id absorbs.
|
||||||
|
*/
|
||||||
|
async listMessages(userId: string, matchId: string, options: ListMessagesOptions = {}): Promise<MessagesPage> {
|
||||||
await this.assertMatchAccess(userId, matchId);
|
await this.assertMatchAccess(userId, matchId);
|
||||||
const result = await this.pool.query<MessageRecord>(
|
const since = options.since ?? null;
|
||||||
`select ${columns}
|
const visible = `not exists (select 1 from message_deletions d where d.message_id = m.id and d.user_id = $2)`;
|
||||||
from messages m
|
// Expiry is the one visible change no row write announces: the clock crosses
|
||||||
where m.match_id = $1
|
// `media_expires_at` and nothing is updated, so no timestamp moves past the
|
||||||
and not exists (
|
// cursor. Comparing the deadline against the window instead emits the row in
|
||||||
select 1 from message_deletions d where d.message_id = m.id and d.user_id = $2
|
// the single poll whose `(since, now()]` contains it, which is exactly when
|
||||||
)
|
// the derived `media_expired` above flips.
|
||||||
order by m.created_at desc, m.id desc
|
const changed = `(m.created_at > $3 or m.read_at > $3 or m.delivered_at > $3
|
||||||
limit 500`,
|
or m.deleted_at > $3 or m.media_viewed_at > $3
|
||||||
[matchId, userId],
|
or (m.media_expires_at > $3 and m.media_expires_at <= now()))`;
|
||||||
);
|
|
||||||
return result.rows.map(toMessage);
|
const [clock, messages, removed, typing] = await Promise.all([
|
||||||
|
this.pool.query<{ cursor: Date }>(`select now() - interval '2 seconds' as cursor`),
|
||||||
|
this.pool.query<MessageRecord>(
|
||||||
|
`select ${columns}
|
||||||
|
from messages m
|
||||||
|
where m.match_id = $1 and ${visible}${since === null ? '' : ` and ${changed}`}
|
||||||
|
order by m.created_at desc, m.id desc
|
||||||
|
limit 500`,
|
||||||
|
since === null ? [matchId, userId] : [matchId, userId, since],
|
||||||
|
),
|
||||||
|
since === null
|
||||||
|
? null
|
||||||
|
: this.pool.query<{ message_id: string }>(
|
||||||
|
`select d.message_id
|
||||||
|
from message_deletions d
|
||||||
|
join messages m on m.id = d.message_id
|
||||||
|
where m.match_id = $1 and d.user_id = $2 and d.deleted_at > $3`,
|
||||||
|
[matchId, userId, since],
|
||||||
|
),
|
||||||
|
options.includeTyping ? this.partnerTyping(userId, matchId) : null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
messages: messages.rows.map(toMessage),
|
||||||
|
removed_ids: removed?.rows.map((row) => row.message_id) ?? [],
|
||||||
|
server_time: clock.rows[0]!.cursor.toISOString(),
|
||||||
|
complete: since === null,
|
||||||
|
...(typing ? { typing } : {}),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendText(userId: string, matchId: string, content: string): Promise<ChatMessage> {
|
async sendText(userId: string, matchId: string, content: string): Promise<ChatMessage> {
|
||||||
@@ -226,8 +315,8 @@ export class PostgresChatRepository implements ChatRepository {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getPartnerTyping(userId: string, matchId: string): Promise<{ typing: boolean; expires_at: string | null }> {
|
/** Access already asserted by the caller, so the message poll can fold this in for free. */
|
||||||
await this.assertMatchAccess(userId, matchId);
|
private async partnerTyping(userId: string, matchId: string): Promise<PartnerTyping> {
|
||||||
const result = await this.pool.query<{ expires_at: Date }>(
|
const result = await this.pool.query<{ expires_at: Date }>(
|
||||||
`select expires_at from chat_typing_states
|
`select expires_at from chat_typing_states
|
||||||
where match_id = $1 and user_id <> $2 and expires_at > now()
|
where match_id = $1 and user_id <> $2 and expires_at > now()
|
||||||
@@ -238,8 +327,13 @@ export class PostgresChatRepository implements ChatRepository {
|
|||||||
return { typing: Boolean(expiresAt), expires_at: iso(expiresAt) };
|
return { typing: Boolean(expiresAt), expires_at: iso(expiresAt) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getPartnerTyping(userId: string, matchId: string): Promise<PartnerTyping> {
|
||||||
|
await this.assertMatchAccess(userId, matchId);
|
||||||
|
return this.partnerTyping(userId, matchId);
|
||||||
|
}
|
||||||
|
|
||||||
async close(): Promise<void> {
|
async close(): Promise<void> {
|
||||||
await this.pool.end();
|
await closeResolvedPool(this.pool, this.ownsPool);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -8,6 +8,7 @@ type AuthenticatedApp = Hono<{ Variables: { identity: AuthIdentity } }>;
|
|||||||
const id = z.string().uuid();
|
const id = z.string().uuid();
|
||||||
const sendBody = z.object({ content: z.string().trim().min(1).max(10_000) }).strict();
|
const sendBody = z.object({ content: z.string().trim().min(1).max(10_000) }).strict();
|
||||||
const reportBody = z.object({ reason: z.enum(['harassment', 'spam', 'inappropriate_content', 'other']) }).strict();
|
const reportBody = z.object({ reason: z.enum(['harassment', 'spam', 'inappropriate_content', 'other']) }).strict();
|
||||||
|
const since = z.string().datetime({ offset: true });
|
||||||
|
|
||||||
function error(code: string, message: string): ApiErrorResponse {
|
function error(code: string, message: string): ApiErrorResponse {
|
||||||
return { error: { code, message } };
|
return { error: { code, message } };
|
||||||
@@ -25,11 +26,20 @@ async function result<T>(operation: () => Promise<T>) {
|
|||||||
export function registerChatRoutes(app: AuthenticatedApp, repository: ChatRepository): void {
|
export function registerChatRoutes(app: AuthenticatedApp, repository: ChatRepository): void {
|
||||||
app.get('/v1/chat/unread', async (c) => c.json(await repository.unreadCounts(c.get('identity').id)));
|
app.get('/v1/chat/unread', async (c) => c.json(await repository.unreadCounts(c.get('identity').id)));
|
||||||
|
|
||||||
|
// `since` turns this into a delta poll and `typing=true` folds the typing check
|
||||||
|
// into the same request. Both are optional: a call with neither still answers with
|
||||||
|
// the full snapshot under the `messages` key it always used.
|
||||||
app.get('/v1/matches/:matchId/messages', async (c) => {
|
app.get('/v1/matches/:matchId/messages', async (c) => {
|
||||||
const parsedId = id.safeParse(c.req.param('matchId'));
|
const parsedId = id.safeParse(c.req.param('matchId'));
|
||||||
if (!parsedId.success) return c.json(error('invalid_match_id', 'A valid match ID is required'), 400);
|
if (!parsedId.success) return c.json(error('invalid_match_id', 'A valid match ID is required'), 400);
|
||||||
const response = await result(() => repository.listMessages(c.get('identity').id, parsedId.data));
|
const rawSince = c.req.query('since');
|
||||||
return response.ok ? c.json({ messages: response.value }) : c.json(error(response.cause.code, response.cause.message), response.cause.status);
|
const parsedSince = rawSince === undefined ? undefined : since.safeParse(rawSince);
|
||||||
|
if (parsedSince && !parsedSince.success) return c.json(error('invalid_since', 'since must be an ISO-8601 timestamp'), 400);
|
||||||
|
const response = await result(() => repository.listMessages(c.get('identity').id, parsedId.data, {
|
||||||
|
...(parsedSince?.success ? { since: parsedSince.data } : {}),
|
||||||
|
includeTyping: c.req.query('typing') === 'true',
|
||||||
|
}));
|
||||||
|
return response.ok ? c.json(response.value) : c.json(error(response.cause.code, response.cause.message), response.cause.status);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/v1/matches/:matchId/messages', async (c) => {
|
app.post('/v1/matches/:matchId/messages', async (c) => {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Couple, Profile } from '@sauci/shared';
|
import type { Couple, Profile } from '@sauci/shared';
|
||||||
import { Pool, type PoolClient, type QueryResultRow } from 'pg';
|
import { Pool, type PoolClient, type QueryResultRow } from 'pg';
|
||||||
|
import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js';
|
||||||
import { CoupleError, type CoupleStateResponse } from './types.js';
|
import { CoupleError, type CoupleStateResponse } from './types.js';
|
||||||
|
|
||||||
interface ProfileRecord extends QueryResultRow {
|
interface ProfileRecord extends QueryResultRow {
|
||||||
@@ -93,9 +94,12 @@ async function transaction<T>(pool: Pool, work: (client: PoolClient) => Promise<
|
|||||||
|
|
||||||
export class PostgresCoupleRepository implements CoupleRepository {
|
export class PostgresCoupleRepository implements CoupleRepository {
|
||||||
private readonly pool: Pool;
|
private readonly pool: Pool;
|
||||||
|
private readonly ownsPool: boolean;
|
||||||
|
|
||||||
constructor(databaseUrl: string) {
|
constructor(connection: DatabaseConnection) {
|
||||||
this.pool = new Pool({ connectionString: databaseUrl });
|
const resolved = resolvePool(connection);
|
||||||
|
this.pool = resolved.pool;
|
||||||
|
this.ownsPool = resolved.owned;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getState(userId: string): Promise<CoupleStateResponse> {
|
async getState(userId: string): Promise<CoupleStateResponse> {
|
||||||
@@ -199,6 +203,6 @@ export class PostgresCoupleRepository implements CoupleRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async close(): Promise<void> {
|
async close(): Promise<void> {
|
||||||
await this.pool.end();
|
await closeResolvedPool(this.pool, this.ownsPool);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Pool, type PoolClient, type QueryResultRow } from 'pg';
|
import { Pool, type PoolClient, type QueryResultRow } from 'pg';
|
||||||
|
import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js';
|
||||||
|
|
||||||
export type DareStatus =
|
export type DareStatus =
|
||||||
| 'pending' | 'active' | 'submitted' | 'completed' | 'expired' | 'declined' | 'cancelled';
|
| 'pending' | 'active' | 'submitted' | 'completed' | 'expired' | 'declined' | 'cancelled';
|
||||||
@@ -181,9 +182,12 @@ function toSentDare(row: SentDareRecord, viewerId: string): SentDare {
|
|||||||
|
|
||||||
export class PostgresDaresRepository implements DaresRepository {
|
export class PostgresDaresRepository implements DaresRepository {
|
||||||
private readonly pool: Pool;
|
private readonly pool: Pool;
|
||||||
|
private readonly ownsPool: boolean;
|
||||||
|
|
||||||
constructor(databaseUrl: string) {
|
constructor(connection: DatabaseConnection) {
|
||||||
this.pool = new Pool({ connectionString: databaseUrl });
|
const resolved = resolvePool(connection);
|
||||||
|
this.pool = resolved.pool;
|
||||||
|
this.ownsPool = resolved.owned;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -475,6 +479,6 @@ export class PostgresDaresRepository implements DaresRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async close(): Promise<void> {
|
async close(): Promise<void> {
|
||||||
await this.pool.end();
|
await closeResolvedPool(this.pool, this.ownsPool);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { Pool, type PoolClient, type QueryResultRow } from 'pg';
|
import { Pool, type PoolClient, type QueryResultRow } from 'pg';
|
||||||
|
import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js';
|
||||||
import type { MediaKind, MediaObject, MediaUploadContext } from './types.js';
|
import type { MediaKind, MediaObject, MediaUploadContext } from './types.js';
|
||||||
|
|
||||||
interface MediaRow extends QueryResultRow {
|
interface MediaRow extends QueryResultRow {
|
||||||
@@ -28,7 +29,8 @@ function object(row: MediaRow): MediaObject {
|
|||||||
|
|
||||||
export class PostgresMediaRepository implements MediaRepository {
|
export class PostgresMediaRepository implements MediaRepository {
|
||||||
private readonly pool: Pool;
|
private readonly pool: Pool;
|
||||||
constructor(databaseUrl: string) { this.pool = new Pool({ connectionString: databaseUrl }); }
|
private readonly ownsPool: boolean;
|
||||||
|
constructor(connection: DatabaseConnection) { const resolved = resolvePool(connection); this.pool = resolved.pool; this.ownsPool = resolved.owned; }
|
||||||
|
|
||||||
async create(userId: string, kind: MediaKind, storageKey: string, mimeType: string, byteSize: number, context: MediaUploadContext) {
|
async create(userId: string, kind: MediaKind, storageKey: string, mimeType: string, byteSize: number, context: MediaUploadContext) {
|
||||||
const client = await this.pool.connect();
|
const client = await this.pool.connect();
|
||||||
@@ -102,5 +104,5 @@ export class PostgresMediaRepository implements MediaRepository {
|
|||||||
async acknowledgeDeletion(storageKey: string): Promise<void> {
|
async acknowledgeDeletion(storageKey: string): Promise<void> {
|
||||||
await this.pool.query('delete from media_deletion_queue where storage_key=$1',[storageKey]);
|
await this.pool.query('delete from media_deletion_queue where storage_key=$1',[storageKey]);
|
||||||
}
|
}
|
||||||
async close() { await this.pool.end(); }
|
async close(): Promise<void> { await closeResolvedPool(this.pool, this.ownsPool); }
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Pool, type PoolClient, type QueryResultRow } from 'pg';
|
import { Pool, type PoolClient, type QueryResultRow } from 'pg';
|
||||||
|
import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js';
|
||||||
import type { ClassifierRuntimeConfig, MessageForClassification, OperationItem, ProducerSummary } from './types.js';
|
import type { ClassifierRuntimeConfig, MessageForClassification, OperationItem, ProducerSummary } from './types.js';
|
||||||
|
|
||||||
interface OutboxRow extends QueryResultRow {
|
interface OutboxRow extends QueryResultRow {
|
||||||
@@ -14,9 +15,19 @@ export interface OperationsRepository {
|
|||||||
message(id: string): Promise<MessageForClassification | null>;
|
message(id: string): Promise<MessageForClassification | null>;
|
||||||
classifierConfig(): Promise<ClassifierRuntimeConfig | null>;
|
classifierConfig(): Promise<ClassifierRuntimeConfig | null>;
|
||||||
classify(id: string, status: 'safe' | 'flagged', reason: string | null, category: string): Promise<void>;
|
classify(id: string, status: 'safe' | 'flagged', reason: string | null, category: string): Promise<void>;
|
||||||
|
outboxState(): Promise<{ due: number; oldestDueAgeSeconds: number }>;
|
||||||
close(): Promise<void>;
|
close(): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Matches `claim`: only currently due, retryable, and unlocked/expired leases. */
|
||||||
|
export const OUTBOX_STATE_SQL = `select count(*)::int due,
|
||||||
|
coalesce(floor(extract(epoch from now()-min(available_at))),0)::int oldest_due_age_seconds
|
||||||
|
from operations_outbox
|
||||||
|
where sent_at is null
|
||||||
|
and attempts<5
|
||||||
|
and available_at<=now()
|
||||||
|
and (locked_at is null or locked_at<now()-interval '5 minutes')`;
|
||||||
|
|
||||||
function item(row: OutboxRow): OperationItem {
|
function item(row: OutboxRow): OperationItem {
|
||||||
return { id: row.id, kind: row.kind, dedupeKey: row.dedupe_key, recipientId: row.recipient_id,
|
return { id: row.id, kind: row.kind, dedupeKey: row.dedupe_key, recipientId: row.recipient_id,
|
||||||
pushToken: row.push_token, payload: row.payload, attempts: row.attempts };
|
pushToken: row.push_token, payload: row.payload, attempts: row.attempts };
|
||||||
@@ -31,7 +42,8 @@ async function transaction<T>(pool: Pool, work: (client: PoolClient) => Promise<
|
|||||||
|
|
||||||
export class PostgresOperationsRepository implements OperationsRepository {
|
export class PostgresOperationsRepository implements OperationsRepository {
|
||||||
private readonly pool: Pool;
|
private readonly pool: Pool;
|
||||||
constructor(databaseUrl: string) { this.pool = new Pool({ connectionString: databaseUrl }); }
|
private readonly ownsPool: boolean;
|
||||||
|
constructor(connection: DatabaseConnection) { const resolved = resolvePool(connection); this.pool = resolved.pool; this.ownsPool = resolved.owned; }
|
||||||
|
|
||||||
async produce(now: Date, limit=100): Promise<ProducerSummary> {
|
async produce(now: Date, limit=100): Promise<ProducerSummary> {
|
||||||
return transaction(this.pool, async (client) => {
|
return transaction(this.pool, async (client) => {
|
||||||
@@ -278,6 +290,30 @@ export class PostgresOperationsRepository implements OperationsRepository {
|
|||||||
return result.rows.map(item);
|
return result.rows.map(item);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
async outboxState(): Promise<{ due: number; oldestDueAgeSeconds: number }> {
|
||||||
|
const client = await this.pool.connect();
|
||||||
|
let released = false;
|
||||||
|
try {
|
||||||
|
await client.query('begin');
|
||||||
|
// A server-side timeout cancels the query and transaction; unlike a
|
||||||
|
// Promise race it cannot leave an unobserved query or borrowed client.
|
||||||
|
await client.query("set local statement_timeout = '250ms'");
|
||||||
|
const result = await client.query<{ due: number; oldest_due_age_seconds: number }>(OUTBOX_STATE_SQL);
|
||||||
|
await client.query('commit');
|
||||||
|
const row = result.rows[0];
|
||||||
|
return { due: row?.due ?? 0, oldestDueAgeSeconds: row?.oldest_due_age_seconds ?? 0 };
|
||||||
|
} catch (cause) {
|
||||||
|
try { await client.query('rollback'); }
|
||||||
|
catch (rollbackCause) {
|
||||||
|
// A failed rollback leaves transaction state uncertain. Evict this
|
||||||
|
// client instead of returning it to the shared pool, but retain the
|
||||||
|
// original observation failure for callers and logs.
|
||||||
|
client.release(rollbackCause instanceof Error ? rollbackCause : new Error('outbox observation rollback failed'));
|
||||||
|
released = true;
|
||||||
|
}
|
||||||
|
throw cause;
|
||||||
|
} finally { if (!released) client.release(); }
|
||||||
|
}
|
||||||
async complete(id:string) { await this.pool.query('update operations_outbox set sent_at=now(),locked_at=null,last_error=null where id=$1',[id]); }
|
async complete(id:string) { await this.pool.query('update operations_outbox set sent_at=now(),locked_at=null,last_error=null where id=$1',[id]); }
|
||||||
async fail(id:string,message:string) { await this.pool.query(
|
async fail(id:string,message:string) { await this.pool.query(
|
||||||
`update operations_outbox set locked_at=null,last_error=$2,available_at=now()+make_interval(secs=>least(3600,power(2,attempts)::int*30)) where id=$1`,[id,message.slice(0,500)]); }
|
`update operations_outbox set locked_at=null,last_error=$2,available_at=now()+make_interval(secs=>least(3600,power(2,attempts)::int*30)) where id=$1`,[id,message.slice(0,500)]); }
|
||||||
@@ -312,7 +348,7 @@ export class PostgresOperationsRepository implements OperationsRepository {
|
|||||||
async classify(id:string,status:'safe'|'flagged',reason:string|null,category:string) {
|
async classify(id:string,status:'safe'|'flagged',reason:string|null,category:string) {
|
||||||
await this.pool.query('update messages set moderation_status=$2,flag_reason=$3,category=$4 where id=$1',[id,status,reason,category]);
|
await this.pool.query('update messages set moderation_status=$2,flag_reason=$3,category=$4 where id=$1',[id,status,reason,category]);
|
||||||
}
|
}
|
||||||
async close() { await this.pool.end(); }
|
async close(): Promise<void> { await closeResolvedPool(this.pool, this.ownsPool); }
|
||||||
}
|
}
|
||||||
|
|
||||||
function streakCopy(days:number) {
|
function streakCopy(days:number) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { MediaJanitor } from '../media/janitor.js';
|
import type { MediaJanitor } from '../media/janitor.js';
|
||||||
import type { OperationsRepository } from './repository.js';
|
import type { OperationsRepository } from './repository.js';
|
||||||
import type { DiscordProvider, ExpoPushProvider, MessageClassifier } from './providers.js';
|
import type { DiscordProvider, ExpoPushProvider, MessageClassifier } from './providers.js';
|
||||||
|
import { emitTelemetry } from '../../telemetry.js';
|
||||||
|
|
||||||
export class OperationsRunner {
|
export class OperationsRunner {
|
||||||
constructor(private readonly repository:OperationsRepository,private readonly expo:ExpoPushProvider,private readonly discord:DiscordProvider,private readonly classifier:MessageClassifier,private readonly mediaJanitor?:MediaJanitor) {}
|
constructor(private readonly repository:OperationsRepository,private readonly expo:ExpoPushProvider,private readonly discord:DiscordProvider,private readonly classifier:MessageClassifier,private readonly mediaJanitor?:MediaJanitor) {}
|
||||||
@@ -13,7 +14,11 @@ export class OperationsRunner {
|
|||||||
await this.repository.complete(item.id);completed++;
|
await this.repository.complete(item.id);completed++;
|
||||||
} catch(cause) { await this.repository.fail(item.id,cause instanceof Error?cause.message:'unknown error');failed++; }
|
} catch(cause) { await this.repository.fail(item.id,cause instanceof Error?cause.message:'unknown error');failed++; }
|
||||||
}
|
}
|
||||||
|
// Observation never blocks useful work. It runs after claiming/completing and
|
||||||
|
// has a short caller-side deadline; the claim lock predicate remains the
|
||||||
|
// source of truth when several worker instances race.
|
||||||
|
const after=await this.repository.outboxState().catch(()=>null);
|
||||||
|
if(after)emitTelemetry({event:'outbox',process:'worker',due_after:after.due,oldest_due_age_seconds:after.oldestDueAgeSeconds,claimed:items.length,completed,failed,count:1});
|
||||||
return {produced,claimed:items.length,completed,failed};
|
return {produced,claimed:items.length,completed,failed};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Pool } from 'pg';
|
import { Pool } from 'pg';
|
||||||
|
import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js';
|
||||||
|
|
||||||
export interface PackCategory {
|
export interface PackCategory {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -69,9 +70,12 @@ interface ProfilePreferencesRow {
|
|||||||
|
|
||||||
export class PostgresPacksRepository implements PacksRepository {
|
export class PostgresPacksRepository implements PacksRepository {
|
||||||
private readonly pool: Pool;
|
private readonly pool: Pool;
|
||||||
|
private readonly ownsPool: boolean;
|
||||||
|
|
||||||
constructor(databaseUrl: string) {
|
constructor(connection: DatabaseConnection) {
|
||||||
this.pool = new Pool({ connectionString: databaseUrl });
|
const resolved = resolvePool(connection);
|
||||||
|
this.pool = resolved.pool;
|
||||||
|
this.ownsPool = resolved.owned;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async profile(userId: string): Promise<ProfilePreferencesRow> {
|
private async profile(userId: string): Promise<ProfilePreferencesRow> {
|
||||||
@@ -215,6 +219,6 @@ export class PostgresPacksRepository implements PacksRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async close(): Promise<void> {
|
async close(): Promise<void> {
|
||||||
await this.pool.end();
|
await closeResolvedPool(this.pool, this.ownsPool);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { Pool, type QueryResultRow } from 'pg';
|
import { Pool, type QueryResultRow } from 'pg';
|
||||||
|
import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js';
|
||||||
import type {
|
import type {
|
||||||
FeedbackSubmission,
|
FeedbackSubmission,
|
||||||
NotificationPreferenceKey,
|
NotificationPreferenceKey,
|
||||||
@@ -80,9 +81,12 @@ function assertChanged(rowCount: number | null): void {
|
|||||||
|
|
||||||
export class PostgresProfileSettingsRepository implements ProfileSettingsRepository {
|
export class PostgresProfileSettingsRepository implements ProfileSettingsRepository {
|
||||||
private readonly pool: Pool;
|
private readonly pool: Pool;
|
||||||
|
private readonly ownsPool: boolean;
|
||||||
|
|
||||||
constructor(databaseUrl: string) {
|
constructor(connection: DatabaseConnection) {
|
||||||
this.pool = new Pool({ connectionString: databaseUrl });
|
const resolved = resolvePool(connection);
|
||||||
|
this.pool = resolved.pool;
|
||||||
|
this.ownsPool = resolved.owned;
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateProfile(userId: string, update: ProfileUpdate): Promise<void> {
|
async updateProfile(userId: string, update: ProfileUpdate): Promise<void> {
|
||||||
@@ -146,6 +150,6 @@ export class PostgresProfileSettingsRepository implements ProfileSettingsReposit
|
|||||||
}
|
}
|
||||||
|
|
||||||
async close(): Promise<void> {
|
async close(): Promise<void> {
|
||||||
await this.pool.end();
|
await closeResolvedPool(this.pool, this.ownsPool);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+26
-26
@@ -2,6 +2,8 @@ import { serve } from '@hono/node-server';
|
|||||||
import { createApp } from './app.js';
|
import { createApp } from './app.js';
|
||||||
import { createAuthVerifier } from './auth.js';
|
import { createAuthVerifier } from './auth.js';
|
||||||
import { loadConfig } from './config.js';
|
import { loadConfig } from './config.js';
|
||||||
|
import { createDatabasePool } from './db/pool.js';
|
||||||
|
import { recordPool } from './telemetry.js';
|
||||||
import { PostgresRepository } from './db/repository.js';
|
import { PostgresRepository } from './db/repository.js';
|
||||||
import { PostgresAppDataRepository } from './domains/app-data/repository.js';
|
import { PostgresAppDataRepository } from './domains/app-data/repository.js';
|
||||||
import { AdminRequestAuth } from './domains/admin/auth.js';
|
import { AdminRequestAuth } from './domains/admin/auth.js';
|
||||||
@@ -26,30 +28,34 @@ import { FilesystemMediaStorage, PostgresMediaRepository } from './domains/media
|
|||||||
|
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
const authVerifier = createAuthVerifier(config);
|
const authVerifier = createAuthVerifier(config);
|
||||||
const repository = new PostgresRepository(config.databaseUrl);
|
// One deliberately bounded pool for the whole process. Repositories used to open
|
||||||
const coupleRepository = new PostgresCoupleRepository(config.databaseUrl);
|
// a default-sized pool each, so a single API instance could hold twelve times the
|
||||||
const packsRepository = new PostgresPacksRepository(config.databaseUrl);
|
// connections a self-hosted PostgreSQL was sized for.
|
||||||
const answersRepository = new PostgresAnswersRepository(config.databaseUrl);
|
const pool = createDatabasePool(config.databaseUrl, config.databasePool, 'api');
|
||||||
const chatRepository = new PostgresChatRepository(config.databaseUrl);
|
const repository = new PostgresRepository(pool);
|
||||||
const daresRepository = new PostgresDaresRepository(config.databaseUrl);
|
const coupleRepository = new PostgresCoupleRepository(pool);
|
||||||
const profileSettingsRepository = new PostgresProfileSettingsRepository(config.databaseUrl);
|
const packsRepository = new PostgresPacksRepository(pool);
|
||||||
const accountOperationsRepository = new PostgresAccountOperationsRepository(config.databaseUrl);
|
const answersRepository = new PostgresAnswersRepository(pool);
|
||||||
|
const chatRepository = new PostgresChatRepository(pool);
|
||||||
|
const daresRepository = new PostgresDaresRepository(pool);
|
||||||
|
const profileSettingsRepository = new PostgresProfileSettingsRepository(pool);
|
||||||
|
const accountOperationsRepository = new PostgresAccountOperationsRepository(pool);
|
||||||
const accountOperationsService = new AccountOperationsService(
|
const accountOperationsService = new AccountOperationsService(
|
||||||
accountOperationsRepository,
|
accountOperationsRepository,
|
||||||
new SupabaseAuthAdminClient(config.authIssuer, config.supabaseAuthServiceRoleKey),
|
new SupabaseAuthAdminClient(config.authIssuer, config.supabaseAuthServiceRoleKey),
|
||||||
new HttpRevenueCatClient(config.revenueCatApiKey, config.revenueCatEntitlementId),
|
new HttpRevenueCatClient(config.revenueCatApiKey, config.revenueCatEntitlementId),
|
||||||
new ExpoPartnerNotifier(),
|
new ExpoPartnerNotifier(),
|
||||||
);
|
);
|
||||||
const mediaRepository = new PostgresMediaRepository(config.databaseUrl);
|
const mediaRepository = new PostgresMediaRepository(pool);
|
||||||
const mediaStorage = new FilesystemMediaStorage(
|
const mediaStorage = new FilesystemMediaStorage(
|
||||||
config.mediaRoot,
|
config.mediaRoot,
|
||||||
config.mediaSigningSecret,
|
config.mediaSigningSecret,
|
||||||
config.mediaPublicBaseUrl,
|
config.mediaPublicBaseUrl,
|
||||||
);
|
);
|
||||||
const appDataRepository = new PostgresAppDataRepository(config.databaseUrl);
|
const appDataRepository = new PostgresAppDataRepository(pool);
|
||||||
const billingRepository = new PostgresBillingRepository(config.databaseUrl);
|
const billingRepository = new PostgresBillingRepository(pool);
|
||||||
const billingService = new BillingService(billingRepository, config.revenueCatWebhookSecret);
|
const billingService = new BillingService(billingRepository, config.revenueCatWebhookSecret);
|
||||||
const adminRepository = new PostgresAdminRepository(config.databaseUrl, {
|
const adminRepository = new PostgresAdminRepository(pool, {
|
||||||
adminPrivateKeyJwk: config.adminPrivateKeyJwk,
|
adminPrivateKeyJwk: config.adminPrivateKeyJwk,
|
||||||
mediaRoot: config.mediaRoot,
|
mediaRoot: config.mediaRoot,
|
||||||
authDirectory: new SupabaseAdminAuthDirectory(config.authIssuer, config.supabaseAuthServiceRoleKey),
|
authDirectory: new SupabaseAdminAuthDirectory(config.authIssuer, config.supabaseAuthServiceRoleKey),
|
||||||
@@ -76,6 +82,10 @@ const app = createApp({
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Sample pool state on the telemetry cadence rather than once per request.
|
||||||
|
const poolTelemetryTimer = setInterval(() => recordPool('api', pool), 60_000);
|
||||||
|
poolTelemetryTimer.unref();
|
||||||
|
|
||||||
const server = serve({ fetch: app.fetch, port: config.port }, (info) => {
|
const server = serve({ fetch: app.fetch, port: config.port }, (info) => {
|
||||||
console.log(`Sauci API listening on http://127.0.0.1:${info.port}`);
|
console.log(`Sauci API listening on http://127.0.0.1:${info.port}`);
|
||||||
});
|
});
|
||||||
@@ -85,6 +95,7 @@ let shuttingDown = false;
|
|||||||
async function shutdown(signal: string): Promise<void> {
|
async function shutdown(signal: string): Promise<void> {
|
||||||
if (shuttingDown) return;
|
if (shuttingDown) return;
|
||||||
shuttingDown = true;
|
shuttingDown = true;
|
||||||
|
clearInterval(poolTelemetryTimer);
|
||||||
console.log(`Received ${signal}; shutting down`);
|
console.log(`Received ${signal}; shutting down`);
|
||||||
const forcedExit = setTimeout(() => {
|
const forcedExit = setTimeout(() => {
|
||||||
console.error('Graceful shutdown timed out');
|
console.error('Graceful shutdown timed out');
|
||||||
@@ -94,20 +105,9 @@ async function shutdown(signal: string): Promise<void> {
|
|||||||
|
|
||||||
server.close(async () => {
|
server.close(async () => {
|
||||||
try {
|
try {
|
||||||
await Promise.all([
|
// Repositories share the process pool and no longer own a connection each,
|
||||||
repository.close(),
|
// so ending the pool once is the whole of the database shutdown.
|
||||||
coupleRepository.close(),
|
await pool.end();
|
||||||
packsRepository.close(),
|
|
||||||
answersRepository.close(),
|
|
||||||
chatRepository.close(),
|
|
||||||
daresRepository.close(),
|
|
||||||
profileSettingsRepository.close(),
|
|
||||||
accountOperationsRepository.close(),
|
|
||||||
mediaRepository.close(),
|
|
||||||
appDataRepository.close(),
|
|
||||||
billingRepository.close(),
|
|
||||||
adminRepository.close(),
|
|
||||||
]);
|
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to close database connections', error);
|
console.error('Failed to close database connections', error);
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/** Refuse fixture targets outside the local development boundary. */
|
||||||
|
export function requireLoopback(url: string, name: string): URL {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
if (!['127.0.0.1', 'localhost', '::1'].includes(parsed.hostname)) {
|
||||||
|
throw new Error(`${name} must resolve to loopback; refusing non-local load`);
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireLocalApi(url: string, expectedPort = 3003): URL {
|
||||||
|
const parsed = requireLoopback(url, 'SAUCI_LOAD_API_URL');
|
||||||
|
if (parsed.protocol !== 'http:' || Number(parsed.port || 80) !== expectedPort) {
|
||||||
|
throw new Error(`SAUCI_LOAD_API_URL must be http loopback on port ${expectedPort}`);
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { createDatabasePool } from '../db/pool.js';
|
||||||
|
import { PostgresOperationsRepository } from '../domains/operations/repository.js';
|
||||||
|
import { emitTelemetry, recordPool } from '../telemetry.js';
|
||||||
|
import { requireLocalApi, requireLoopback } from './loopback.js';
|
||||||
|
|
||||||
|
function percentile(samples: number[], fraction: number): number {
|
||||||
|
const sorted = [...samples].sort((a, b) => a - b);
|
||||||
|
return Math.round((sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * fraction) - 1)] ?? 0) * 100) / 100;
|
||||||
|
}
|
||||||
|
async function read(url: URL, headers: HeadersInit): Promise<number> {
|
||||||
|
const startedAt = performance.now();
|
||||||
|
const response = await fetch(url, { headers });
|
||||||
|
if (!response.ok) throw new Error(`representative API read failed with ${response.status}`);
|
||||||
|
return performance.now() - startedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const expectedPort = Number(process.env.SAUCI_LOAD_API_PORT ?? '3003');
|
||||||
|
const apiUrl = requireLocalApi(process.env.SAUCI_LOAD_API_URL ?? `http://127.0.0.1:${expectedPort}`, expectedPort);
|
||||||
|
const databaseUrl = process.env.DATABASE_URL;
|
||||||
|
const authorization = process.env.SAUCI_LOAD_AUTHORIZATION;
|
||||||
|
const matchId = process.env.SAUCI_LOAD_MATCH_ID;
|
||||||
|
const expectedUserId = process.env.SAUCI_LOAD_USER_ID;
|
||||||
|
if (!databaseUrl || !authorization || !matchId || !expectedUserId) throw new Error('DATABASE_URL, SAUCI_LOAD_AUTHORIZATION, SAUCI_LOAD_MATCH_ID, and SAUCI_LOAD_USER_ID are required');
|
||||||
|
requireLoopback(databaseUrl, 'DATABASE_URL');
|
||||||
|
if (!/^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i.test(matchId) || !/^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i.test(expectedUserId)) throw new Error('SAUCI_LOAD_MATCH_ID and SAUCI_LOAD_USER_ID must be UUIDs');
|
||||||
|
|
||||||
|
const health = await fetch(new URL('/health/live', apiUrl));
|
||||||
|
if (!health.ok || (await health.json() as { status?: string }).status !== 'ok') throw new Error('local Sauci API health preflight failed');
|
||||||
|
const headers = { authorization };
|
||||||
|
const me = await fetch(new URL('/v1/me', apiUrl), { headers });
|
||||||
|
const profile = me.ok ? await me.json() as { profile?: { id?: string } } : undefined;
|
||||||
|
if (profile?.profile?.id !== expectedUserId) throw new Error('local fixture identity preflight failed');
|
||||||
|
|
||||||
|
const sync = await Promise.all(Array.from({ length: 5 }, () => read(new URL('/v1/me/sync', apiUrl), headers)));
|
||||||
|
const chat = await Promise.all(Array.from({ length: 5 }, () => read(new URL(`/v1/matches/${matchId}/messages?typing=true`, apiUrl), headers)));
|
||||||
|
for (const [operation, samples] of [['sync', sync], ['chat', chat]] as const) emitTelemetry({ event: 'load', process: 'load-fixture', operation, count: samples.length, p50_ms: percentile(samples, .5), p95_ms: percentile(samples, .95), p99_ms: percentile(samples, .99) });
|
||||||
|
|
||||||
|
const pool = createDatabasePool(databaseUrl, { max: 1 }, 'load-fixture');
|
||||||
|
try { await new PostgresOperationsRepository(pool).outboxState(); recordPool('load-fixture', pool); }
|
||||||
|
finally { await pool.end(); }
|
||||||
|
}
|
||||||
|
void main();
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import type { Pool, PoolClient } from 'pg';
|
||||||
|
|
||||||
|
/** Fixed, privacy-safe JSON log vocabulary. Do not add free-form fields. */
|
||||||
|
export type TelemetryProcess = 'api' | 'worker' | 'load-fixture';
|
||||||
|
type Outcome = 'ok' | 'error';
|
||||||
|
type StatusClass = '2xx' | '3xx' | '4xx' | '5xx';
|
||||||
|
|
||||||
|
export type TelemetryRecord =
|
||||||
|
| { event: 'request'; process: 'api'; method: string; route: string; status_class: StatusClass; count: number; duration_ms: number }
|
||||||
|
| { event: 'query'; process: TelemetryProcess; operation: 'pool_query'; outcome: Outcome; count: number; duration_ms: number }
|
||||||
|
| { event: 'pool'; process: TelemetryProcess; total: number; idle: number; waiting: number }
|
||||||
|
| { event: 'sync'; process: 'api' | 'worker'; outcome: Outcome; count: number; duration_ms: number }
|
||||||
|
| { event: 'outbox'; process: 'worker'; due_after: number; oldest_due_age_seconds: number; claimed: number; completed: number; failed: number; count: number }
|
||||||
|
| { event: 'load'; process: 'load-fixture'; operation: 'sync' | 'chat'; count: number; p50_ms: number; p95_ms: number; p99_ms: number };
|
||||||
|
|
||||||
|
type TelemetrySink = (record: TelemetryRecord) => void;
|
||||||
|
let sink: TelemetrySink = (record) => console.log(JSON.stringify(record));
|
||||||
|
let pending = new Map<string, TelemetryRecord>();
|
||||||
|
|
||||||
|
function aggregate(record: Extract<TelemetryRecord, { event: 'request' | 'query' | 'sync' }>): void {
|
||||||
|
const key = record.event === 'request' ? `${record.event}:${record.method}:${record.route}:${record.status_class}` : `${record.event}:${record.process}:${record.outcome}`;
|
||||||
|
const prior = pending.get(key) as typeof record | undefined;
|
||||||
|
if (!prior) { pending.set(key, { ...record }); return; }
|
||||||
|
prior.count += record.count;
|
||||||
|
prior.duration_ms += record.duration_ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Emits bounded aggregates each minute; workers also flush after a tick. */
|
||||||
|
export function flushTelemetry(): void { for (const record of pending.values()) sink(record); pending = new Map(); }
|
||||||
|
const flushTimer = setInterval(flushTelemetry, 60_000);
|
||||||
|
flushTimer.unref();
|
||||||
|
|
||||||
|
export function emitTelemetry(record: Exclude<TelemetryRecord, { event: 'request' | 'query' | 'sync' }>): void { sink(record); }
|
||||||
|
export function recordRequest(method: string, route: string, status: number, startedAt: number): void {
|
||||||
|
const statusClass: StatusClass = status >= 500 ? '5xx' : status >= 400 ? '4xx' : status >= 300 ? '3xx' : '2xx';
|
||||||
|
aggregate({ event: 'request', process: 'api', method, route, status_class: statusClass, duration_ms: durationMs(startedAt), count: 1 });
|
||||||
|
}
|
||||||
|
export function recordSync(process: 'api' | 'worker', outcome: Outcome, startedAt: number): void { aggregate({ event: 'sync', process, outcome, duration_ms: durationMs(startedAt), count: 1 }); }
|
||||||
|
/** Test-only hook; production code always uses the JSON console sink. */
|
||||||
|
export function setTelemetrySinkForTests(next: TelemetrySink): () => void { const previous = sink; sink = next; return () => { sink = previous; pending = new Map(); }; }
|
||||||
|
export function durationMs(startedAt: number): number { return Math.max(0, Math.round((performance.now() - startedAt) * 100) / 100); }
|
||||||
|
export function recordPool(process: TelemetryProcess, pool: Pool): void { emitTelemetry({ event: 'pool', process, total: pool.totalCount, idle: pool.idleCount, waiting: pool.waitingCount }); }
|
||||||
|
|
||||||
|
export function instrumentPool(process: TelemetryProcess, pool: Pool): Pool {
|
||||||
|
const clients = new WeakSet<PoolClient>();
|
||||||
|
const instrumentQuery = <T extends { query: Pool['query'] }>(target: T): T => {
|
||||||
|
const query = target.query.bind(target) as (...args: unknown[]) => unknown;
|
||||||
|
target.query = ((...args: unknown[]) => {
|
||||||
|
const startedAt = performance.now();
|
||||||
|
const finish = (outcome: Outcome) => aggregate({ event: 'query', process, operation: 'pool_query', outcome, duration_ms: durationMs(startedAt), count: 1 });
|
||||||
|
try { const result = query(...args); if (result && typeof (result as Promise<unknown>).then === 'function') return (result as Promise<unknown>).then((value) => { finish('ok'); return value; }, (error) => { finish('error'); throw error; }); finish('ok'); return result; }
|
||||||
|
catch (error) { finish('error'); throw error; }
|
||||||
|
}) as Pool['query'];
|
||||||
|
return target;
|
||||||
|
};
|
||||||
|
instrumentQuery(pool);
|
||||||
|
const connect = pool.connect.bind(pool) as (...args: unknown[]) => unknown;
|
||||||
|
pool.connect = ((...args: unknown[]) => {
|
||||||
|
const result = connect(...args);
|
||||||
|
if (result && typeof (result as Promise<PoolClient>).then === 'function') return (result as Promise<PoolClient>).then((client) => { if (!clients.has(client)) { clients.add(client); instrumentQuery(client); } return client; });
|
||||||
|
return result;
|
||||||
|
}) as Pool['connect'];
|
||||||
|
return pool;
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ const optionalString=z.preprocess(value=>value===''?undefined:value,z.string().m
|
|||||||
const optionalUrl=z.preprocess(value=>value===''?undefined:value,z.string().url().optional());
|
const optionalUrl=z.preprocess(value=>value===''?undefined:value,z.string().url().optional());
|
||||||
const schema=z.object({
|
const schema=z.object({
|
||||||
NODE_ENV:z.enum(['development','test','production']).default('development'), DATABASE_URL:z.string().url(),
|
NODE_ENV:z.enum(['development','test','production']).default('development'), DATABASE_URL:z.string().url(),
|
||||||
|
DATABASE_POOL_MAX:z.coerce.number().int().min(1).max(200).default(4), DATABASE_POOL_IDLE_TIMEOUT_MS:z.coerce.number().int().min(0).max(3_600_000).default(30_000), DATABASE_POOL_CONNECTION_TIMEOUT_MS:z.coerce.number().int().min(0).max(60_000).default(10_000),
|
||||||
WORKER_POLL_INTERVAL_MS:z.coerce.number().int().min(1000).max(300000).default(30000), WORKER_BATCH_SIZE:z.coerce.number().int().min(1).max(100).default(25),
|
WORKER_POLL_INTERVAL_MS:z.coerce.number().int().min(1000).max(300000).default(30000), WORKER_BATCH_SIZE:z.coerce.number().int().min(1).max(100).default(25),
|
||||||
MEDIA_ROOT:z.string().min(1).default('/data/media'), MEDIA_SIGNING_SECRET:z.string().min(32).default('local-media-signing-secret-change-me-123456'),
|
MEDIA_ROOT:z.string().min(1).default('/data/media'), MEDIA_SIGNING_SECRET:z.string().min(32).default('local-media-signing-secret-change-me-123456'),
|
||||||
MEDIA_PUBLIC_BASE_URL:z.string().url().default('http://127.0.0.1:3003'), CLASSIFIER_ENABLED:z.enum(['true','false']).default('true').transform(v=>v==='true'),
|
MEDIA_PUBLIC_BASE_URL:z.string().url().default('http://127.0.0.1:3003'), CLASSIFIER_ENABLED:z.enum(['true','false']).default('true').transform(v=>v==='true'),
|
||||||
@@ -15,7 +16,7 @@ export function loadWorkerConfig(env:NodeJS.ProcessEnv=process.env) {
|
|||||||
const value=schema.parse(env);
|
const value=schema.parse(env);
|
||||||
if(value.NODE_ENV==='production'&&value.CLASSIFIER_ENABLED&&!value.OPENROUTER_API_KEY)throw new Error('OPENROUTER_API_KEY is required when classification is enabled');
|
if(value.NODE_ENV==='production'&&value.CLASSIFIER_ENABLED&&!value.OPENROUTER_API_KEY)throw new Error('OPENROUTER_API_KEY is required when classification is enabled');
|
||||||
if(value.ADMIN_PRIVATE_KEY_JWK){try{JSON.parse(value.ADMIN_PRIVATE_KEY_JWK);}catch{throw new Error('ADMIN_PRIVATE_KEY_JWK must be valid JSON');}}
|
if(value.ADMIN_PRIVATE_KEY_JWK){try{JSON.parse(value.ADMIN_PRIVATE_KEY_JWK);}catch{throw new Error('ADMIN_PRIVATE_KEY_JWK must be valid JSON');}}
|
||||||
return {nodeEnv:value.NODE_ENV,databaseUrl:value.DATABASE_URL,pollIntervalMs:value.WORKER_POLL_INTERVAL_MS,batchSize:value.WORKER_BATCH_SIZE,
|
return {nodeEnv:value.NODE_ENV,databaseUrl:value.DATABASE_URL,databasePool:{max:value.DATABASE_POOL_MAX,idleTimeoutMillis:value.DATABASE_POOL_IDLE_TIMEOUT_MS,connectionTimeoutMillis:value.DATABASE_POOL_CONNECTION_TIMEOUT_MS},pollIntervalMs:value.WORKER_POLL_INTERVAL_MS,batchSize:value.WORKER_BATCH_SIZE,
|
||||||
mediaRoot:value.MEDIA_ROOT,mediaSigningSecret:value.MEDIA_SIGNING_SECRET,mediaPublicBaseUrl:value.MEDIA_PUBLIC_BASE_URL,classifierEnabled:value.CLASSIFIER_ENABLED,
|
mediaRoot:value.MEDIA_ROOT,mediaSigningSecret:value.MEDIA_SIGNING_SECRET,mediaPublicBaseUrl:value.MEDIA_PUBLIC_BASE_URL,classifierEnabled:value.CLASSIFIER_ENABLED,
|
||||||
openRouterApiKey:value.OPENROUTER_API_KEY,classifierModel:value.CLASSIFIER_MODEL,classifierPrompt:env.CLASSIFIER_PROMPT||defaultPrompt,
|
openRouterApiKey:value.OPENROUTER_API_KEY,classifierModel:value.CLASSIFIER_MODEL,classifierPrompt:env.CLASSIFIER_PROMPT||defaultPrompt,
|
||||||
adminPrivateKeyJwk:value.ADMIN_PRIVATE_KEY_JWK,discordWebhookUrl:value.DISCORD_WEBHOOK_URL};
|
adminPrivateKeyJwk:value.ADMIN_PRIVATE_KEY_JWK,discordWebhookUrl:value.DISCORD_WEBHOOK_URL};
|
||||||
|
|||||||
@@ -3,14 +3,16 @@ import { PostgresMediaRepository } from '../domains/media/repository.js';
|
|||||||
import { FilesystemMediaStorage } from '../domains/media/storage.js';
|
import { FilesystemMediaStorage } from '../domains/media/storage.js';
|
||||||
import { DiscordProvider, ExpoPushProvider, MessageClassifier, OperationsRunner, PostgresOperationsRepository } from '../domains/operations/index.js';
|
import { DiscordProvider, ExpoPushProvider, MessageClassifier, OperationsRunner, PostgresOperationsRepository } from '../domains/operations/index.js';
|
||||||
import { loadWorkerConfig } from './config.js';
|
import { loadWorkerConfig } from './config.js';
|
||||||
|
import { createDatabasePool } from '../db/pool.js';
|
||||||
|
import { flushTelemetry, recordPool, recordSync } from '../telemetry.js';
|
||||||
|
import { WorkerLifecycle } from './lifecycle.js';
|
||||||
|
|
||||||
const config=loadWorkerConfig(); const operations=new PostgresOperationsRepository(config.databaseUrl); const mediaRepository=new PostgresMediaRepository(config.databaseUrl);
|
const config=loadWorkerConfig(); const pool=createDatabasePool(config.databaseUrl,config.databasePool,'worker'); const operations=new PostgresOperationsRepository(pool); const mediaRepository=new PostgresMediaRepository(pool);
|
||||||
const mediaStorage=new FilesystemMediaStorage(config.mediaRoot,config.mediaSigningSecret,config.mediaPublicBaseUrl);
|
const mediaStorage=new FilesystemMediaStorage(config.mediaRoot,config.mediaSigningSecret,config.mediaPublicBaseUrl);
|
||||||
const runner=new OperationsRunner(operations,new ExpoPushProvider(),new DiscordProvider(config.discordWebhookUrl),new MessageClassifier(operations,
|
const runner=new OperationsRunner(operations,new ExpoPushProvider(),new DiscordProvider(config.discordWebhookUrl),new MessageClassifier(operations,
|
||||||
{enabled:config.classifierEnabled,apiKey:config.openRouterApiKey,model:config.classifierModel,prompt:config.classifierPrompt,adminPrivateKeyJwk:config.adminPrivateKeyJwk,mediaRoot:config.mediaRoot}),
|
{enabled:config.classifierEnabled,apiKey:config.openRouterApiKey,model:config.classifierModel,prompt:config.classifierPrompt,adminPrivateKeyJwk:config.adminPrivateKeyJwk,mediaRoot:config.mediaRoot}),
|
||||||
new MediaJanitor(mediaRepository,mediaStorage));
|
new MediaJanitor(mediaRepository,mediaStorage));
|
||||||
let stopped=false;let timer:NodeJS.Timeout|undefined;
|
const lifecycle=new WorkerLifecycle({pollIntervalMs:config.pollIntervalMs,closePool:()=>pool.end(),onTickFailure:cause=>console.error('Operations worker tick failed',cause),runTick:async()=>{const startedAt=performance.now();try{const result=await runner.runOnce(new Date(),config.batchSize);recordSync('worker','ok',startedAt);recordPool('worker',pool);flushTelemetry();if(result.claimed||result.failed)console.log('Operations worker tick',result);}catch(cause){recordSync('worker','error',startedAt);recordPool('worker',pool);flushTelemetry();throw cause;}}});
|
||||||
async function tick(){if(stopped)return;try{const result=await runner.runOnce(new Date(),config.batchSize);if(result.claimed||result.failed)console.log('Operations worker tick',result);}catch(cause){console.error('Operations worker tick failed',cause);}finally{if(!stopped){timer=setTimeout(()=>void tick(),config.pollIntervalMs);}}}
|
function shutdown(signal:string){console.log(`Received ${signal}; stopping operations worker`);return lifecycle.shutdown();}
|
||||||
async function shutdown(signal:string){if(stopped)return;stopped=true;if(timer)clearTimeout(timer);console.log(`Received ${signal}; stopping operations worker`);await Promise.all([operations.close(),mediaRepository.close()]);}
|
process.on('SIGINT',()=>void shutdown('SIGINT').then(code=>process.exit(code))); process.on('SIGTERM',()=>void shutdown('SIGTERM').then(code=>process.exit(code)));
|
||||||
process.on('SIGINT',()=>void shutdown('SIGINT').then(()=>process.exit(0))); process.on('SIGTERM',()=>void shutdown('SIGTERM').then(()=>process.exit(0)));
|
console.log('Sauci operations worker started');lifecycle.start();
|
||||||
console.log('Sauci operations worker started');void tick();
|
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
export interface WorkerLifecycleDependencies {
|
||||||
|
pollIntervalMs: number;
|
||||||
|
runTick: () => Promise<void>;
|
||||||
|
closePool: () => Promise<void>;
|
||||||
|
onTickFailure: (cause: unknown) => void;
|
||||||
|
graceMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Owns worker tick scheduling and makes shutdown single-flight and bounded. */
|
||||||
|
export class WorkerLifecycle {
|
||||||
|
private stopped = false;
|
||||||
|
private timer: NodeJS.Timeout | undefined;
|
||||||
|
private activeTick: Promise<void> | undefined;
|
||||||
|
private shutdownPromise: Promise<number> | undefined;
|
||||||
|
private readonly graceMs: number;
|
||||||
|
|
||||||
|
constructor(private readonly deps: WorkerLifecycleDependencies) { this.graceMs = deps.graceMs ?? 25_000; }
|
||||||
|
|
||||||
|
start(): void { if (!this.activeTick && !this.stopped) this.activeTick = this.tick(); }
|
||||||
|
|
||||||
|
shutdown(): Promise<number> {
|
||||||
|
if (!this.shutdownPromise) this.shutdownPromise = this.stop();
|
||||||
|
return this.shutdownPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async tick(): Promise<void> {
|
||||||
|
try { await this.deps.runTick(); }
|
||||||
|
catch (cause) { this.deps.onTickFailure(cause); }
|
||||||
|
finally {
|
||||||
|
if (!this.stopped) this.timer = setTimeout(() => { this.activeTick = this.tick(); }, this.deps.pollIntervalMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async stop(): Promise<number> {
|
||||||
|
this.stopped = true;
|
||||||
|
if (this.timer) clearTimeout(this.timer);
|
||||||
|
const work = (async () => {
|
||||||
|
try { if (this.activeTick) await this.activeTick; await this.deps.closePool(); return 0; }
|
||||||
|
catch { return 1; }
|
||||||
|
})();
|
||||||
|
let timeout: NodeJS.Timeout | undefined;
|
||||||
|
const timedOut = new Promise<number>((resolve) => { timeout = setTimeout(() => resolve(1), this.graceMs); });
|
||||||
|
const result = await Promise.race([work, timedOut]);
|
||||||
|
if (timeout) clearTimeout(timeout);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { AccountOperationsService } from '../src/domains/account-operations/service.js';
|
||||||
|
import type { AuthAdminClient, PartnerNotifier, RevenueCatClient } from '../src/domains/account-operations/clients.js';
|
||||||
|
import type { AccountOperationsRepository } from '../src/domains/account-operations/repository.js';
|
||||||
|
|
||||||
|
function setup(partnerPushToken: string | null = 'ExponentPushToken[partner]') {
|
||||||
|
const repository = {
|
||||||
|
deleteRelationship: vi.fn(async () => ({ partnerPushToken })),
|
||||||
|
resetProgress: vi.fn(async () => ({ partnerPushToken })),
|
||||||
|
deleteAccount: vi.fn(async (_userId: string, deleteAuthUser: () => Promise<void>) => {
|
||||||
|
await deleteAuthUser();
|
||||||
|
return { partnerPushToken };
|
||||||
|
}),
|
||||||
|
setPremium: vi.fn(async () => undefined),
|
||||||
|
close: vi.fn(async () => undefined),
|
||||||
|
} satisfies AccountOperationsRepository;
|
||||||
|
const authAdmin = { deleteUser: vi.fn(async () => undefined) } satisfies AuthAdminClient;
|
||||||
|
const revenueCat = { isEntitled: vi.fn(async () => true) } satisfies RevenueCatClient;
|
||||||
|
const notifier = {
|
||||||
|
relationshipDeleted: vi.fn(async () => undefined),
|
||||||
|
progressReset: vi.fn(async () => undefined),
|
||||||
|
partnerAccountDeleted: vi.fn(async () => undefined),
|
||||||
|
} satisfies PartnerNotifier;
|
||||||
|
return {
|
||||||
|
repository,
|
||||||
|
authAdmin,
|
||||||
|
revenueCat,
|
||||||
|
notifier,
|
||||||
|
service: new AccountOperationsService(repository, authAdmin, revenueCat, notifier),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AccountOperationsService', () => {
|
||||||
|
it('deletes relationship data and notifies the partner', async () => {
|
||||||
|
const { service, repository, notifier } = setup();
|
||||||
|
await expect(service.deleteRelationship('user-1')).resolves.toEqual({
|
||||||
|
success: true,
|
||||||
|
message: 'Relationship data deleted successfully',
|
||||||
|
});
|
||||||
|
expect(repository.deleteRelationship).toHaveBeenCalledWith('user-1');
|
||||||
|
expect(notifier.relationshipDeleted).toHaveBeenCalledWith('ExponentPushToken[partner]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets progress and skips notification without a partner token', async () => {
|
||||||
|
const { service, notifier } = setup(null);
|
||||||
|
await expect(service.resetProgress('user-1')).resolves.toEqual({
|
||||||
|
success: true,
|
||||||
|
message: 'Progress reset successfully',
|
||||||
|
});
|
||||||
|
expect(notifier.progressReset).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes hosted auth inside the repository operation before notifying', async () => {
|
||||||
|
const { service, repository, authAdmin, notifier } = setup();
|
||||||
|
await expect(service.deleteAccount('user-1')).resolves.toEqual({
|
||||||
|
success: true,
|
||||||
|
message: 'Account deleted successfully',
|
||||||
|
});
|
||||||
|
expect(repository.deleteAccount).toHaveBeenCalledOnce();
|
||||||
|
expect(authAdmin.deleteUser).toHaveBeenCalledWith('user-1');
|
||||||
|
expect(notifier.partnerAccountDeleted).toHaveBeenCalledWith('ExponentPushToken[partner]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists the entitlement returned by RevenueCat', async () => {
|
||||||
|
const { service, repository, revenueCat } = setup();
|
||||||
|
revenueCat.isEntitled.mockResolvedValueOnce(false);
|
||||||
|
await expect(service.syncSubscription('user-1')).resolves.toEqual({
|
||||||
|
success: true,
|
||||||
|
is_premium: false,
|
||||||
|
});
|
||||||
|
expect(repository.setPremium).toHaveBeenCalledWith('user-1', false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a committed destructive operation successful when notification fails', async () => {
|
||||||
|
const { service, notifier } = setup();
|
||||||
|
notifier.relationshipDeleted.mockRejectedValueOnce(new Error('push unavailable'));
|
||||||
|
const error = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||||
|
|
||||||
|
await expect(service.deleteRelationship('user-1')).resolves.toMatchObject({ success: true });
|
||||||
|
expect(error).toHaveBeenCalledWith('Partner notification failed', expect.any(Error));
|
||||||
|
error.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,7 +6,7 @@ import { PostgresAnswersRepository } from '../src/domains/answers/repository.js'
|
|||||||
const url=process.env.DATABASE_URL;const local=url?['127.0.0.1','localhost','::1'].includes(new URL(url).hostname):false;if(url&&!local)throw new Error('Answer integration tests only permit localhost');
|
const url=process.env.DATABASE_URL;const local=url?['127.0.0.1','localhost','::1'].includes(new URL(url).hostname):false;if(url&&!local)throw new Error('Answer integration tests only permit localhost');
|
||||||
describe.skipIf(!url||!local)('answers + PostgreSQL',()=>{const admin=new Pool({connectionString:url});const schema=`answers_${randomUUID().replaceAll('-','')}`;let pool:Pool;let repo:PostgresAnswersRepository;
|
describe.skipIf(!url||!local)('answers + PostgreSQL',()=>{const admin=new Pool({connectionString:url});const schema=`answers_${randomUUID().replaceAll('-','')}`;let pool:Pool;let repo:PostgresAnswersRepository;
|
||||||
const u1='11111111-1111-4111-8111-111111111111',u2='22222222-2222-4222-8222-222222222222',outsider='33333333-3333-4333-8333-333333333333',couple='44444444-4444-4444-8444-444444444444',other='55555555-5555-4555-8555-555555555555',pack='66666666-6666-4666-8666-666666666666',q='77777777-7777-4777-8777-777777777777',concurrentQ='88888888-8888-4888-8888-888888888888',femaleOnlyQ='99999999-9999-4999-8999-999999999999',limitQ1='aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',limitQ2='bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb',catchUpQ='cccccccc-cccc-4ccc-8ccc-cccccccccccc';
|
const u1='11111111-1111-4111-8111-111111111111',u2='22222222-2222-4222-8222-222222222222',outsider='33333333-3333-4333-8333-333333333333',couple='44444444-4444-4444-8444-444444444444',other='55555555-5555-4555-8555-555555555555',pack='66666666-6666-4666-8666-666666666666',q='77777777-7777-4777-8777-777777777777',concurrentQ='88888888-8888-4888-8888-888888888888',femaleOnlyQ='99999999-9999-4999-8999-999999999999',limitQ1='aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',limitQ2='bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb',catchUpQ='cccccccc-cccc-4ccc-8ccc-cccccccccccc';
|
||||||
beforeAll(async()=>{await admin.query(`create schema "${schema}"`);const isolated=new URL(url!);isolated.searchParams.set('options',`-c search_path=${schema}`);pool=new Pool({connectionString:isolated.toString()});repo=new PostgresAnswersRepository(isolated.toString());for(const name of ['0000_identity_and_feature_interests.sql','0001_couples.sql','0002_packs_catalog_progress.sql','0003_answers_matches.sql','0014_daily_limit_local_reset.sql','0015_daily_limit_response_index.sql','0016_couple_streak_locality.sql']){const sql=await readFile(new URL(`../drizzle/${name}`,import.meta.url),'utf8');for(const s of sql.split('--> statement-breakpoint'))if(s.trim())await pool.query(s);}await pool.query('insert into couples(id,invite_code) values($1,$2),($3,$4)',[couple,'ANSWERS1',other,'ANSWERS2']);await pool.query("insert into profiles(id,couple_id,gender,max_intensity) values($1,$4,'female',5),($2,$4,'male',3),($3,$5,'male',5)",[u1,u2,outsider,couple,other]);await pool.query("insert into question_packs(id,name) values($1,'Core')",[pack]);await pool.query("insert into questions(id,pack_id,text,intensity,target_user_genders) values($1,$2,'Question',3,array['female']),($3,$2,'Concurrent',2,null),($4,$2,'Female first',2,array['female']),($5,$2,'Limit one',2,null),($6,$2,'Limit two',2,null),($7,$2,'Catch up',2,null)",[q,pack,concurrentQ,femaleOnlyQ,limitQ1,limitQ2,catchUpQ]);await pool.query('insert into couple_packs(couple_id,pack_id,enabled) values($1,$2,true)',[couple,pack]);});afterAll(async()=>{await repo.close();await pool.end();await admin.query(`drop schema "${schema}" cascade`);await admin.end();});
|
beforeAll(async()=>{await admin.query(`create schema "${schema}"`);const isolated=new URL(url!);isolated.searchParams.set('options',`-c search_path=${schema}`);pool=new Pool({connectionString:isolated.toString()});repo=new PostgresAnswersRepository(isolated.toString());for(const name of ['0000_identity_and_feature_interests.sql','0001_couples.sql','0002_packs_catalog_progress.sql','0003_answers_matches.sql','0004_chat.sql','0014_daily_limit_local_reset.sql','0015_daily_limit_response_index.sql','0016_couple_streak_locality.sql']){const sql=await readFile(new URL(`../drizzle/${name}`,import.meta.url),'utf8');for(const s of sql.split('--> statement-breakpoint'))if(s.trim())await pool.query(s);}await pool.query('insert into couples(id,invite_code) values($1,$2),($3,$4)',[couple,'ANSWERS1',other,'ANSWERS2']);await pool.query("insert into profiles(id,couple_id,gender,max_intensity) values($1,$4,'female',5),($2,$4,'male',3),($3,$5,'male',5)",[u1,u2,outsider,couple,other]);await pool.query("insert into question_packs(id,name) values($1,'Core')",[pack]);await pool.query("insert into questions(id,pack_id,text,intensity,target_user_genders) values($1,$2,'Question',3,array['female']),($3,$2,'Concurrent',2,null),($4,$2,'Female first',2,array['female']),($5,$2,'Limit one',2,null),($6,$2,'Limit two',2,null),($7,$2,'Catch up',2,null)",[q,pack,concurrentQ,femaleOnlyQ,limitQ1,limitQ2,catchUpQ]);await pool.query('insert into couple_packs(couple_id,pack_id,enabled) values($1,$2,true)',[couple,pack]);});afterAll(async()=>{await repo.close();await pool.end();await admin.query(`drop schema "${schema}" cascade`);await admin.end();});
|
||||||
it('targets recommendation by gender and excludes completed responses',async()=>{expect((await repo.recommended(u1)).map(x=>x.id)).toContain(q);expect((await repo.recommended(u2)).map(x=>x.id)).not.toContain(q);await repo.submit(u1,{questionId:q,answer:'yes'});expect((await repo.recommended(u1)).map(x=>x.id)).not.toContain(q);});
|
it('targets recommendation by gender and excludes completed responses',async()=>{expect((await repo.recommended(u1)).map(x=>x.id)).toContain(q);expect((await repo.recommended(u2)).map(x=>x.id)).not.toContain(q);await repo.submit(u1,{questionId:q,answer:'yes'});expect((await repo.recommended(u1)).map(x=>x.id)).not.toContain(q);});
|
||||||
it('rejects direct answers that bypass target-gender eligibility',async()=>{await expect(repo.submit(u2,{questionId:femaleOnlyQ,answer:'yes'})).rejects.toMatchObject({code:'question_not_eligible'});});
|
it('rejects direct answers that bypass target-gender eligibility',async()=>{await expect(repo.submit(u2,{questionId:femaleOnlyQ,answer:'yes'})).rejects.toMatchObject({code:'question_not_eligible'});});
|
||||||
it('serializes concurrent partner submissions and still creates one match',async()=>{await Promise.all([repo.submit(u1,{questionId:concurrentQ,answer:'yes'}),repo.submit(u2,{questionId:concurrentQ,answer:'yes'})]);const counts=await pool.query('select count(*)::int count from matches where question_id=$1',[concurrentQ]);expect(counts.rows[0].count).toBe(1);});
|
it('serializes concurrent partner submissions and still creates one match',async()=>{await Promise.all([repo.submit(u1,{questionId:concurrentQ,answer:'yes'}),repo.submit(u2,{questionId:concurrentQ,answer:'yes'})]);const counts=await pool.query('select count(*)::int count from matches where question_id=$1',[concurrentQ]);expect(counts.rows[0].count).toBe(1);});
|
||||||
@@ -18,6 +18,71 @@ it('answers the streak from the caller side rather than by stored position',asyn
|
|||||||
expect(await repo.streak(u1)).toMatchObject({you_answered_today:true,partner_answered_today:false,partner_name:'Alex',current_streak:0});
|
expect(await repo.streak(u1)).toMatchObject({you_answered_today:true,partner_answered_today:false,partner_name:'Alex',current_streak:0});
|
||||||
expect(await repo.streak(u2)).toMatchObject({you_answered_today:false,partner_answered_today:true,partner_name:'Sam',current_streak:0});});
|
expect(await repo.streak(u2)).toMatchObject({you_answered_today:false,partner_answered_today:true,partner_name:'Sam',current_streak:0});});
|
||||||
|
|
||||||
|
describe('match pagination',()=>{
|
||||||
|
const paged=Array.from({length:5},(_,index)=>`d0000000-0000-4000-8000-00000000000${index}`);
|
||||||
|
const pagedQuestions=Array.from({length:5},(_,index)=>`f0000000-0000-4000-8000-00000000000${index}`);
|
||||||
|
const otherMatch='e0000000-0000-4000-8000-000000000000';
|
||||||
|
beforeAll(async()=>{
|
||||||
|
await pool.query('delete from match_archives');await pool.query('delete from messages');await pool.query('delete from matches');
|
||||||
|
// One question per match: a couple may only match a given question once.
|
||||||
|
for(const[index,id]of pagedQuestions.entries())await pool.query("insert into questions(id,pack_id,text,intensity) values($1,$2,$3,2)",[id,pack,`Paged ${index}`]);
|
||||||
|
// Distinct creation times so the ordering assertion is about the query, not tie-breaking.
|
||||||
|
for(const[index,id]of paged.entries())await pool.query("insert into matches(id,couple_id,question_id,match_type,created_at) values($1,$2,$3,'yes_yes',now()-($4::text||' minutes')::interval)",[id,couple,pagedQuestions[index],String(index)]);
|
||||||
|
// A match belonging to another couple must never appear, whatever the page.
|
||||||
|
await pool.query("insert into matches(id,couple_id,question_id,match_type) values($1,$2,$3,'yes_yes')",[otherMatch,other,q]);
|
||||||
|
// Two unread from the partner on the oldest match; one message of our own, which is never unread.
|
||||||
|
await pool.query("insert into messages(match_id,user_id,content) values($1,$2,'a'),($1,$2,'b'),($1,$3,'mine')",[paged[4],u2,u1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts once on the first page, never again, and still reports hasMore',async()=>{
|
||||||
|
const first=await repo.matches(u1,0,2,false);
|
||||||
|
expect(first.totalCount).toBe(5);
|
||||||
|
expect(first.hasMore).toBe(true);
|
||||||
|
expect(first.matches).toHaveLength(2);
|
||||||
|
|
||||||
|
// Later pages skip the count entirely: the client already has it and paging
|
||||||
|
// is decided by hasMore, which comes from reading limit + 1 rows.
|
||||||
|
const second=await repo.matches(u1,1,2,false);
|
||||||
|
expect(second.totalCount).toBeNull();
|
||||||
|
expect(second.hasMore).toBe(true);
|
||||||
|
const last=await repo.matches(u1,2,2,false);
|
||||||
|
expect(last.totalCount).toBeNull();
|
||||||
|
expect(last.hasMore).toBe(false);
|
||||||
|
expect(last.matches).toHaveLength(1);
|
||||||
|
|
||||||
|
// An exactly-full final page must not claim another page exists.
|
||||||
|
expect(await repo.matches(u1,0,5,false)).toMatchObject({hasMore:false,totalCount:5});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps unread-first ordering, aggregates unread once, and isolates the other couple',async()=>{
|
||||||
|
const page=await repo.matches(u1,0,5,false);
|
||||||
|
// The unread match sorts ahead of newer ones; the rest stay newest-first.
|
||||||
|
expect(page.matches.map(item=>item.id)).toEqual([paged[4],paged[0],paged[1],paged[2],paged[3]]);
|
||||||
|
expect(page.matches[0]).toMatchObject({unreadCount:2});
|
||||||
|
// Our own message is not unread to us, and everything else is zero.
|
||||||
|
expect(page.matches.slice(1).every(item=>item.unreadCount===0)).toBe(true);
|
||||||
|
// Unread is per viewer: the partner's own two messages are not unread to them,
|
||||||
|
// but ours is, so the same thread reports a different count on each side.
|
||||||
|
expect((await repo.matches(u2,0,5,false)).matches.find(item=>item.id===paged[4])?.unreadCount).toBe(1);
|
||||||
|
// The other couple's match is invisible here, and their member sees only theirs.
|
||||||
|
expect(page.matches.some(item=>item.id===otherMatch)).toBe(false);
|
||||||
|
expect((await repo.matches(outsider,0,5,false)).matches.map(item=>item.id)).toEqual([otherMatch]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pages the archived view separately and per user',async()=>{
|
||||||
|
await repo.archive(u1,paged[0],true);
|
||||||
|
const active=await repo.matches(u1,0,5,false);
|
||||||
|
expect(active.matches.some(item=>item.id===paged[0])).toBe(false);
|
||||||
|
expect(active.totalCount).toBe(4);
|
||||||
|
const archived=await repo.matches(u1,0,5,true);
|
||||||
|
expect(archived.matches.map(item=>item.id)).toEqual([paged[0]]);
|
||||||
|
expect(archived).toMatchObject({totalCount:1,hasMore:false});
|
||||||
|
// Archiving is per user, so the partner still sees it in their active list.
|
||||||
|
expect((await repo.matches(u2,0,5,false)).matches.some(item=>item.id===paged[0])).toBe(true);
|
||||||
|
await repo.archive(u1,paged[0],false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('counts the day in the couple shared zone and prefers the first reported member zone',async()=>{await pool.query('update profiles set timezone=$2 where id=$1',[u1,'Pacific/Auckland']);await pool.query('update profiles set timezone=$2 where id=$1',[u2,'America/Los_Angeles']);
|
it('counts the day in the couple shared zone and prefers the first reported member zone',async()=>{await pool.query('update profiles set timezone=$2 where id=$1',[u1,'Pacific/Auckland']);await pool.query('update profiles set timezone=$2 where id=$1',[u2,'America/Los_Angeles']);
|
||||||
await pool.query('delete from responses where couple_id=$1',[couple]);await pool.query('delete from matches where couple_id=$1',[couple]);
|
await pool.query('delete from responses where couple_id=$1',[couple]);await pool.query('delete from matches where couple_id=$1',[couple]);
|
||||||
await repo.submit(u1,{questionId:q,answer:'yes'});
|
await repo.submit(u1,{questionId:q,answer:'yes'});
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const questionId='22222222-2222-4222-8222-222222222222';
|
|||||||
function repo():AnswersRepository{return {
|
function repo():AnswersRepository{return {
|
||||||
recommended:vi.fn(async()=>[]),pending:vi.fn(async()=>[]),answerGap:vi.fn(async()=>({unanswered_by_partner:0,threshold:10,is_blocked:false})),
|
recommended:vi.fn(async()=>[]),pending:vi.fn(async()=>[]),answerGap:vi.fn(async()=>({unanswered_by_partner:0,threshold:10,is_blocked:false})),
|
||||||
dailyLimit:vi.fn(async()=>({responses_today:0,limit_value:0,remaining:0,reset_at:null,is_blocked:false})),
|
dailyLimit:vi.fn(async()=>({responses_today:0,limit_value:0,remaining:0,reset_at:null,is_blocked:false})),
|
||||||
submit:vi.fn(async()=>({response:{} as never,match:null})),update:vi.fn(async()=>({success:true})),responses:vi.fn(async()=>({responses:[],totalCount:0})),matches:vi.fn(async()=>({matches:[],totalCount:0})),markSeen:vi.fn(async()=>undefined),archive:vi.fn(async()=>undefined),streak:vi.fn(async()=>null),close:vi.fn(async()=>undefined)};}
|
submit:vi.fn(async()=>({response:{} as never,match:null})),update:vi.fn(async()=>({success:true})),responses:vi.fn(async()=>({responses:[],totalCount:0})),matches:vi.fn(async()=>({matches:[],totalCount:0,hasMore:false})),markSeen:vi.fn(async()=>undefined),archive:vi.fn(async()=>undefined),streak:vi.fn(async()=>null),close:vi.fn(async()=>undefined)};}
|
||||||
function app(r:AnswersRepository){const app=new Hono<{Variables:{identity:AuthIdentity}}>();app.use('/v1/*',async(c,n)=>{c.set('identity',identity);await n();});registerAnswerRoutes(app,r);return app;}
|
function app(r:AnswersRepository){const app=new Hono<{Variables:{identity:AuthIdentity}}>();app.use('/v1/*',async(c,n)=>{c.set('identity',identity);await n();});registerAnswerRoutes(app,r);return app;}
|
||||||
|
|
||||||
describe('answer routes',()=>{
|
describe('answer routes',()=>{
|
||||||
@@ -18,6 +18,16 @@ describe('answer routes',()=>{
|
|||||||
it('maps confirmation and domain failures',async()=>{const r=repo();vi.mocked(r.update).mockResolvedValueOnce({success:false,requires_confirmation:true,message_count:2});const a=app(r);expect(await (await a.request(`/v1/responses/${questionId}`,{method:'PATCH',headers:{'content-type':'application/json'},body:'{"new_answer":"no"}'})).json()).toMatchObject({requires_confirmation:true,message_count:2});vi.mocked(r.submit).mockRejectedValueOnce(new AnswersError('daily_limit',429,'Daily response limit reached',{daily_limit:10,responses_today:10,remaining:0,reset_at:'2026-08-29T00:00:00.000Z'}));const blocked=await a.request('/v1/responses',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({question_id:questionId,answer:'yes'})});expect(blocked.status).toBe(429);
|
it('maps confirmation and domain failures',async()=>{const r=repo();vi.mocked(r.update).mockResolvedValueOnce({success:false,requires_confirmation:true,message_count:2});const a=app(r);expect(await (await a.request(`/v1/responses/${questionId}`,{method:'PATCH',headers:{'content-type':'application/json'},body:'{"new_answer":"no"}'})).json()).toMatchObject({requires_confirmation:true,message_count:2});vi.mocked(r.submit).mockRejectedValueOnce(new AnswersError('daily_limit',429,'Daily response limit reached',{daily_limit:10,responses_today:10,remaining:0,reset_at:'2026-08-29T00:00:00.000Z'}));const blocked=await a.request('/v1/responses',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({question_id:questionId,answer:'yes'})});expect(blocked.status).toBe(429);
|
||||||
// The client renders the limit screen straight from this body, so its shape is a contract.
|
// The client renders the limit screen straight from this body, so its shape is a contract.
|
||||||
expect(await blocked.json()).toMatchObject({error:{code:'daily_limit'},details:{daily_limit:10,responses_today:10,remaining:0,reset_at:'2026-08-29T00:00:00.000Z'}});});
|
expect(await blocked.json()).toMatchObject({error:{code:'daily_limit'},details:{daily_limit:10,responses_today:10,remaining:0,reset_at:'2026-08-29T00:00:00.000Z'}});});
|
||||||
|
it('reports pagination from the repository, including a page with no total',async()=>{const r=repo();const a=app(r);
|
||||||
|
// Later pages skip the count entirely, so `hasMore` — not a client-side
|
||||||
|
// comparison against totalCount — is what decides whether to keep paging.
|
||||||
|
vi.mocked(r.matches).mockResolvedValueOnce({matches:[],totalCount:null,hasMore:true});
|
||||||
|
const later=await a.request('/v1/matches?page=2&limit=20');
|
||||||
|
expect(later.status).toBe(200);
|
||||||
|
expect(await later.json()).toMatchObject({totalCount:null,hasMore:true});
|
||||||
|
expect(r.matches).toHaveBeenCalledWith(identity.id,2,20,false);
|
||||||
|
await a.request('/v1/matches?page=0&limit=20&archived=true');
|
||||||
|
expect(r.matches).toHaveBeenLastCalledWith(identity.id,0,20,true);});
|
||||||
it('scopes match mutations to identity',async()=>{const r=repo();const a=app(r);await a.request('/v1/matches/seen',{method:'PATCH',headers:{'content-type':'application/json'},body:JSON.stringify({ids:[questionId]})});expect(r.markSeen).toHaveBeenCalledWith(identity.id,[questionId]);await a.request(`/v1/matches/${questionId}/archive`,{method:'PUT',headers:{'content-type':'application/json'},body:'{"archived":true}'});expect(r.archive).toHaveBeenCalledWith(identity.id,questionId,true);});
|
it('scopes match mutations to identity',async()=>{const r=repo();const a=app(r);await a.request('/v1/matches/seen',{method:'PATCH',headers:{'content-type':'application/json'},body:JSON.stringify({ids:[questionId]})});expect(r.markSeen).toHaveBeenCalledWith(identity.id,[questionId]);await a.request(`/v1/matches/${questionId}/archive`,{method:'PUT',headers:{'content-type':'application/json'},body:'{"archived":true}'});expect(r.archive).toHaveBeenCalledWith(identity.id,questionId,true);});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -75,6 +75,31 @@ describe.skipIf(!databaseUrl || !localDatabase)('API + PostgreSQL integration',
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Reading the profile again must not rewrite it. Every authenticated request the
|
||||||
|
// app makes starts here, so an unconditional upsert turned a read into a write
|
||||||
|
// on the busiest path in the API.
|
||||||
|
const stamps = async () => (await pool.query<{ auth_synced_at: Date | null; updated_at: Date }>(
|
||||||
|
'select auth_synced_at, updated_at from profiles where id = $1',
|
||||||
|
['33333333-3333-4333-8333-333333333333'],
|
||||||
|
)).rows[0];
|
||||||
|
const created = await stamps();
|
||||||
|
expect(created.auth_synced_at).not.toBeNull();
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||||
|
expect((await app.request('/v1/me', { headers })).status).toBe(200);
|
||||||
|
}
|
||||||
|
const unchanged = await stamps();
|
||||||
|
expect(unchanged.auth_synced_at?.getTime()).toBe(created.auth_synced_at?.getTime());
|
||||||
|
expect(unchanged.updated_at.getTime()).toBe(created.updated_at.getTime());
|
||||||
|
|
||||||
|
// A genuine identity change still writes through, so first use and a changed
|
||||||
|
// email are both recorded rather than skipped.
|
||||||
|
await pool.query('update profiles set email = $2 where id = $1', ['33333333-3333-4333-8333-333333333333', 'stale@sauci.test']);
|
||||||
|
expect((await app.request('/v1/me', { headers })).status).toBe(200);
|
||||||
|
const resynced = await stamps();
|
||||||
|
expect(resynced.auth_synced_at!.getTime()).toBeGreaterThan(created.auth_synced_at!.getTime());
|
||||||
|
expect(await (await app.request('/v1/me', { headers })).json()).toMatchObject({ profile: { email: 'integration@sauci.test' } });
|
||||||
|
|
||||||
expect((await app.request('/v1/me/feature-interests/better-chat', { method: 'PUT', headers })).status).toBe(200);
|
expect((await app.request('/v1/me/feature-interests/better-chat', { method: 'PUT', headers })).status).toBe(200);
|
||||||
expect(await (await app.request('/v1/me/feature-interests/better-chat', { headers })).json())
|
expect(await (await app.request('/v1/me/feature-interests/better-chat', { headers })).json())
|
||||||
.toEqual({ feature: 'better-chat', interested: true });
|
.toEqual({ feature: 'better-chat', interested: true });
|
||||||
|
|||||||
@@ -30,6 +30,99 @@ describe.skipIf(!databaseUrl || !local)('app-data repository + PostgreSQL', () =
|
|||||||
await pool.query("insert into messages(id,match_id,user_id,media_path,media_type) values($1,$2,$3,'x.jpg','image')",[message,match,alice]);
|
await pool.query("insert into messages(id,match_id,user_id,media_path,media_type) values($1,$2,$3,'x.jpg','image')",[message,match,alice]);
|
||||||
});
|
});
|
||||||
afterAll(async()=>{await repo.close();await pool.end();await admin.query(`drop schema "${schema}" cascade`);await admin.end();});
|
afterAll(async()=>{await repo.close();await pool.end();await admin.query(`drop schema "${schema}" cascade`);await admin.end();});
|
||||||
|
it('summarises change markers per couple without leaking another couple state',async()=>{
|
||||||
|
// This one small read replaced a five-second refresh of profile, couple, matches,
|
||||||
|
// pending questions, packs and unread counts, so its markers have to move for
|
||||||
|
// exactly the changes those refreshes existed to notice.
|
||||||
|
const before=await repo.syncSummary(alice);
|
||||||
|
expect(before).toMatchObject({couple_id:couple,partner_id:bob,match_count:1,new_match_count:1,unread_total:0});
|
||||||
|
expect(before.profile_updated_at).not.toBeNull();
|
||||||
|
expect(Date.parse(before.server_time)).not.toBeNaN();
|
||||||
|
|
||||||
|
// The partner's own message is unread to them and not to us.
|
||||||
|
expect((await repo.syncSummary(bob)).unread_total).toBe(1);
|
||||||
|
|
||||||
|
// A partner profile edit moves the partner marker, which is what tells the
|
||||||
|
// client to re-read the couple rather than everything.
|
||||||
|
await pool.query("update profiles set name='Bobby', updated_at=now() where id=$1",[bob]);
|
||||||
|
const afterPartner=await repo.syncSummary(alice);
|
||||||
|
expect(Date.parse(afterPartner.partner_updated_at!)).toBeGreaterThan(Date.parse(before.partner_updated_at!));
|
||||||
|
expect(afterPartner.profile_updated_at).toBe(before.profile_updated_at);
|
||||||
|
|
||||||
|
// couple_packs carries no timestamp, so equal-and-opposite toggles have to be
|
||||||
|
// caught by a digest rather than a max(updated_at).
|
||||||
|
const emptyDigest=before.enabled_packs_fingerprint;
|
||||||
|
expect(emptyDigest).toEqual(expect.any(String));
|
||||||
|
await pool.query('insert into couple_packs(couple_id,pack_id,enabled) values($1,$2,true)',[couple,pack]);
|
||||||
|
const enabled=await repo.syncSummary(alice);
|
||||||
|
expect(enabled.enabled_packs_fingerprint).not.toBe(emptyDigest);
|
||||||
|
await pool.query('update couple_packs set enabled=false where couple_id=$1 and pack_id=$2',[couple,pack]);
|
||||||
|
expect((await repo.syncSummary(alice)).enabled_packs_fingerprint).toBe(emptyDigest);
|
||||||
|
// Toggling back must land on the same digest, so the client stops refetching.
|
||||||
|
await pool.query('update couple_packs set enabled=true where couple_id=$1 and pack_id=$2',[couple,pack]);
|
||||||
|
expect((await repo.syncSummary(alice)).enabled_packs_fingerprint).toBe(enabled.enabled_packs_fingerprint);
|
||||||
|
|
||||||
|
// An outsider's summary is about their own couple and never ours.
|
||||||
|
const other=await repo.syncSummary(outsider);
|
||||||
|
expect(other).toMatchObject({couple_id:otherCouple,partner_id:null,match_count:0,unread_total:0});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves the match-state fingerprint on an in-place edit that the count and latest-at markers miss',async()=>{
|
||||||
|
// Editing an existing match's type/response summary (response editing) creates
|
||||||
|
// no new match and touches no created_at, so match_count/new_match_count/
|
||||||
|
// latest_match_at all hold steady; only the state fingerprint must move.
|
||||||
|
const before=await repo.syncSummary(alice);
|
||||||
|
await pool.query("update matches set match_type='yes_maybe', response_summary=$2 where id=$1",[match,JSON.stringify({edited:true})]);
|
||||||
|
const after=await repo.syncSummary(alice);
|
||||||
|
expect(after.match_count).toBe(before.match_count);
|
||||||
|
expect(after.new_match_count).toBe(before.new_match_count);
|
||||||
|
expect(after.latest_match_at).toBe(before.latest_match_at);
|
||||||
|
expect(after.match_state_fingerprint).not.toBe(before.match_state_fingerprint);
|
||||||
|
// Reverting lands back on the same digest, so the client stops refetching.
|
||||||
|
await pool.query("update matches set match_type='yes_yes', response_summary=null where id=$1",[match]);
|
||||||
|
expect((await repo.syncSummary(alice)).match_state_fingerprint).toBe(before.match_state_fingerprint);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves the match-unread fingerprint when unread redistributes without changing the total',async()=>{
|
||||||
|
const secondMatch='cccccccc-cccc-4ccc-8ccc-cccccccccccc';
|
||||||
|
const secondQuestion='dddddddd-dddd-4ddd-8ddd-dddddddddddd';
|
||||||
|
const originalUnread='eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee';
|
||||||
|
const secondUnread='11111111-2222-4333-8444-555555555555';
|
||||||
|
await pool.query("insert into questions(id,pack_id,text) values($1,$2,'Question 2')",[secondQuestion,pack]);
|
||||||
|
await pool.query("insert into matches(id,couple_id,question_id,match_type) values($1,$2,$3,'yes_yes')",[secondMatch,couple,secondQuestion]);
|
||||||
|
// Alice's own message ('message', from beforeAll) is never unread to her, so
|
||||||
|
// give the original match a message from her partner to actually redistribute.
|
||||||
|
await pool.query("insert into messages(id,match_id,user_id,media_path,media_type) values($1,$2,$3,'y.jpg','image')",[originalUnread,match,bob]);
|
||||||
|
await pool.query("insert into messages(id,match_id,user_id,media_path,media_type) values($1,$2,$3,'y2.jpg','image')",[secondUnread,secondMatch,bob]);
|
||||||
|
|
||||||
|
const before=await repo.syncSummary(alice);
|
||||||
|
expect(before.unread_total).toBe(2);
|
||||||
|
|
||||||
|
// Read the original match's unread message while a new one lands in the
|
||||||
|
// second match: unread_total holds steady at 2, but which match owns it moves.
|
||||||
|
await pool.query('update messages set read_at=now() where id=$1',[originalUnread]);
|
||||||
|
const thirdMessage='ffffffff-ffff-4fff-8fff-ffffffffffff';
|
||||||
|
await pool.query("insert into messages(id,match_id,user_id,media_path,media_type) values($1,$2,$3,'z.jpg','image')",[thirdMessage,secondMatch,bob]);
|
||||||
|
|
||||||
|
const after=await repo.syncSummary(alice);
|
||||||
|
expect(after.unread_total).toBe(before.unread_total);
|
||||||
|
expect(after.match_unread_fingerprint).not.toBe(before.match_unread_fingerprint);
|
||||||
|
|
||||||
|
// Clean up so later assertions in this suite see the original single-match state.
|
||||||
|
await pool.query('delete from messages where id=any($1::uuid[])',[[originalUnread,secondUnread,thirdMessage]]);
|
||||||
|
await pool.query('delete from matches where id=$1',[secondMatch]);
|
||||||
|
await pool.query('delete from questions where id=$1',[secondQuestion]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves the streak marker from couple_streaks.updated_at',async()=>{
|
||||||
|
await pool.query('insert into couple_streaks(couple_id) values($1) on conflict do nothing',[couple]);
|
||||||
|
const before=await repo.syncSummary(alice);
|
||||||
|
expect(before.streak_updated_at).toEqual(expect.any(String));
|
||||||
|
await pool.query('update couple_streaks set current_streak=current_streak+1, updated_at=now() where couple_id=$1',[couple]);
|
||||||
|
const after=await repo.syncSummary(alice);
|
||||||
|
expect(Date.parse(after.streak_updated_at!)).toBeGreaterThan(Date.parse(before.streak_updated_at!));
|
||||||
|
});
|
||||||
|
|
||||||
it('returns only public catalog data and couple-owned match context',async()=>{
|
it('returns only public catalog data and couple-owned match context',async()=>{
|
||||||
expect((await repo.packQuestions(alice,pack))[0]?.text).toBe('Question');
|
expect((await repo.packQuestions(alice,pack))[0]?.text).toBe('Question');
|
||||||
expect((await repo.matchContext(bob,match)).responses).toHaveLength(2);
|
expect((await repo.matchContext(bob,match)).responses).toHaveLength(2);
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ function repository(): AppDataRepository {
|
|||||||
markMediaViewed: vi.fn(async () => ({ media_viewed_at: 'now', media_expires_at: null })),
|
markMediaViewed: vi.fn(async () => ({ media_viewed_at: 'now', media_expires_at: null })),
|
||||||
nudgeStatus: vi.fn(async () => ({ last_nudge_sent_at: null })),
|
nudgeStatus: vi.fn(async () => ({ last_nudge_sent_at: null })),
|
||||||
sendNudge: vi.fn(async () => ({ success: true as const, notification_sent: false, reason: 'no_push_token', next_nudge_available_at: 'later' })),
|
sendNudge: vi.fn(async () => ({ success: true as const, notification_sent: false, reason: 'no_push_token', next_nudge_available_at: 'later' })),
|
||||||
|
syncSummary: vi.fn(async () => ({
|
||||||
|
server_time: 'now', couple_id: null, profile_updated_at: null, partner_id: null, partner_updated_at: null,
|
||||||
|
match_count: 0, new_match_count: 0, latest_match_at: null, pending_yours: 0, pending_theirs: 0,
|
||||||
|
unread_total: 0, enabled_packs_fingerprint: '',
|
||||||
|
match_state_fingerprint: '', match_unread_fingerprint: '', streak_updated_at: null,
|
||||||
|
})),
|
||||||
getLiveDraw: vi.fn(async () => ({ strokes: [], revision: 0, updated_at: null, updated_by: null })),
|
getLiveDraw: vi.fn(async () => ({ strokes: [], revision: 0, updated_at: null, updated_by: null })),
|
||||||
putLiveDraw: vi.fn(async () => ({ strokes: [], revision: 1, updated_at: 'now', updated_by: identity.id })),
|
putLiveDraw: vi.fn(async () => ({ strokes: [], revision: 1, updated_at: 'now', updated_by: identity.id })),
|
||||||
close: vi.fn(async () => undefined),
|
close: vi.fn(async () => undefined),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
ApiRepository,
|
ApiRepository,
|
||||||
MobileCompatibleProfile,
|
MobileCompatibleProfile,
|
||||||
} from '../src/db/repository.js';
|
} from '../src/db/repository.js';
|
||||||
|
import { flushTelemetry, setTelemetrySinkForTests, type TelemetryRecord } from '../src/telemetry.js';
|
||||||
|
|
||||||
const alice: AuthIdentity = {
|
const alice: AuthIdentity = {
|
||||||
id: '11111111-1111-4111-8111-111111111111',
|
id: '11111111-1111-4111-8111-111111111111',
|
||||||
@@ -119,4 +120,15 @@ describe('Sauci API', () => {
|
|||||||
});
|
});
|
||||||
expect(response.status).toBe(400);
|
expect(response.status).toBe(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('logs only a route template and status class, never raw UUIDs or auth values', async () => {
|
||||||
|
const records: TelemetryRecord[] = []; const restore = setTelemetrySinkForTests((record) => records.push(record));
|
||||||
|
const app = createApp({ auth: verifier(), repository: new MemoryRepository() });
|
||||||
|
await app.request('/v1/me/11111111-1111-4111-8111-111111111111?token=private-token', { headers: { authorization: 'Bearer private-token' } });
|
||||||
|
flushTelemetry(); restore();
|
||||||
|
const serialized = JSON.stringify(records);
|
||||||
|
expect(records).toContainEqual(expect.objectContaining({ event: 'request', status_class: '4xx' }));
|
||||||
|
expect(serialized).not.toContain('11111111-1111-4111-8111-111111111111');
|
||||||
|
expect(serialized).not.toContain('private-token');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -48,7 +48,7 @@ describe.skipIf(!databaseUrl || !isLocal)('chat repository + PostgreSQL', () =>
|
|||||||
|
|
||||||
it('sends, lists, counts and reads text messages for couple members only', async () => {
|
it('sends, lists, counts and reads text messages for couple members only', async () => {
|
||||||
const sent = await repository.sendText(alice, match, 'Hello');
|
const sent = await repository.sendText(alice, match, 'Hello');
|
||||||
expect((await repository.listMessages(bob, match))[0]?.content).toBe('Hello');
|
expect((await repository.listMessages(bob, match)).messages[0]?.content).toBe('Hello');
|
||||||
expect(await repository.unreadCounts(bob)).toEqual({ total: 1, by_match: { [match]: 1 } });
|
expect(await repository.unreadCounts(bob)).toEqual({ total: 1, by_match: { [match]: 1 } });
|
||||||
expect((await repository.markDelivered(bob, sent.id)).delivered_at).not.toBeNull();
|
expect((await repository.markDelivered(bob, sent.id)).delivered_at).not.toBeNull();
|
||||||
expect((await repository.markMatchRead(bob, match)).updated).toBe(1);
|
expect((await repository.markMatchRead(bob, match)).updated).toBe(1);
|
||||||
@@ -58,14 +58,110 @@ describe.skipIf(!databaseUrl || !isLocal)('chat repository + PostgreSQL', () =>
|
|||||||
it('enforces per-user and author-only deletions plus reports', async () => {
|
it('enforces per-user and author-only deletions plus reports', async () => {
|
||||||
const sent = await repository.sendText(alice, match, 'Moderate me');
|
const sent = await repository.sendText(alice, match, 'Moderate me');
|
||||||
await repository.deleteForSelf(bob, sent.id);
|
await repository.deleteForSelf(bob, sent.id);
|
||||||
expect((await repository.listMessages(bob, match)).some(item => item.id === sent.id)).toBe(false);
|
expect((await repository.listMessages(bob, match)).messages.some(item => item.id === sent.id)).toBe(false);
|
||||||
expect((await repository.listMessages(alice, match)).some(item => item.id === sent.id)).toBe(true);
|
expect((await repository.listMessages(alice, match)).messages.some(item => item.id === sent.id)).toBe(true);
|
||||||
await expect(repository.deleteForEveryone(bob, sent.id)).rejects.toMatchObject({ code: 'not_message_author' });
|
await expect(repository.deleteForEveryone(bob, sent.id)).rejects.toMatchObject({ code: 'not_message_author' });
|
||||||
expect((await repository.deleteForEveryone(alice, sent.id)).deleted_at).not.toBeNull();
|
expect((await repository.deleteForEveryone(alice, sent.id)).deleted_at).not.toBeNull();
|
||||||
await repository.report(bob, sent.id, 'harassment');
|
await repository.report(bob, sent.id, 'harassment');
|
||||||
await expect(repository.report(bob, sent.id, 'spam')).rejects.toBeInstanceOf(ChatError);
|
await expect(repository.report(bob, sent.id, 'spam')).rejects.toBeInstanceOf(ChatError);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('answers a delta with only what changed, and the same rows a full read would give', async () => {
|
||||||
|
const before = await repository.listMessages(bob, match);
|
||||||
|
const cursor = before.server_time;
|
||||||
|
expect(before.complete).toBe(true);
|
||||||
|
|
||||||
|
// Nothing has happened since the cursor, so the delta carries no row the client
|
||||||
|
// has not already seen. The cursor is deliberately held a couple of seconds
|
||||||
|
// behind wall clock so a write racing the read is repeated rather than lost;
|
||||||
|
// that repetition is absorbed by merging on id, never by blanking the thread.
|
||||||
|
const known = new Set(before.messages.map(item => item.id));
|
||||||
|
const quiet = await repository.listMessages(bob, match, { since: cursor });
|
||||||
|
expect(quiet.complete).toBe(false);
|
||||||
|
expect(quiet.messages.every(item => known.has(item.id))).toBe(true);
|
||||||
|
expect(quiet.removed_ids?.every(id => !known.has(id))).toBe(true);
|
||||||
|
|
||||||
|
const fresh = await repository.sendText(alice, match, 'Delta arrival');
|
||||||
|
const delta = await repository.listMessages(bob, match, { since: cursor });
|
||||||
|
expect(delta.messages.map(item => item.id)).toContain(fresh.id);
|
||||||
|
// Media and moderation columns must survive the narrower query.
|
||||||
|
expect(delta.messages.find(item => item.id === fresh.id)).toMatchObject({
|
||||||
|
content: 'Delta arrival', media_path: null, media_type: null, media_expired: false, read_at: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// A status change with no new row still reaches the sender through the delta.
|
||||||
|
const afterSend = await repository.listMessages(alice, match);
|
||||||
|
await repository.markMatchRead(bob, match);
|
||||||
|
const readDelta = await repository.listMessages(alice, match, { since: afterSend.server_time });
|
||||||
|
expect(readDelta.messages.find(item => item.id === fresh.id)?.read_at).not.toBeNull();
|
||||||
|
|
||||||
|
// So does a deletion, which arrives as a removal rather than a silent gap.
|
||||||
|
const doomed = await repository.sendText(alice, match, 'Delete me');
|
||||||
|
const beforeDelete = await repository.listMessages(bob, match);
|
||||||
|
await repository.deleteForSelf(bob, doomed.id);
|
||||||
|
const removal = await repository.listMessages(bob, match, { since: beforeDelete.server_time });
|
||||||
|
expect(removal.removed_ids).toContain(doomed.id);
|
||||||
|
|
||||||
|
// Ordering is the contract the screen renders against: newest first, both ways.
|
||||||
|
const full = await repository.listMessages(bob, match);
|
||||||
|
const timestamps = full.messages.map(item => Date.parse(item.created_at));
|
||||||
|
expect([...timestamps].sort((a, b) => b - a)).toEqual(timestamps);
|
||||||
|
|
||||||
|
// Access control is unchanged on the delta path.
|
||||||
|
await expect(repository.listMessages(outsider, match, { since: cursor })).rejects.toMatchObject({ code: 'match_not_found' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces an expiring video to a chat that is already open, and only in the window it expires', async () => {
|
||||||
|
// Expiry is the one visible change no write announces: the deadline simply
|
||||||
|
// passes. Backdated so neither `created_at` nor `media_viewed_at` can be what
|
||||||
|
// puts the row in a delta — only the deadline crossing may.
|
||||||
|
const video = await repository.sendText(alice, match, 'Sent a video');
|
||||||
|
await pool.query(
|
||||||
|
`update messages
|
||||||
|
set media_path = 'media:expiring', media_type = 'video',
|
||||||
|
moderation_status = 'safe', encrypted_content = 'cipher', encryption_iv = 'iv',
|
||||||
|
created_at = now() - interval '1 hour',
|
||||||
|
media_viewed_at = now() - interval '1 hour',
|
||||||
|
media_expires_at = now() + interval '1 hour'
|
||||||
|
where id = $1`,
|
||||||
|
[video.id],
|
||||||
|
);
|
||||||
|
|
||||||
|
const before = await repository.listMessages(bob, match);
|
||||||
|
expect(before.messages.find(item => item.id === video.id)).toMatchObject({ media_expired: false });
|
||||||
|
|
||||||
|
// A deadline still in the future is not a change, so the row is not re-sent on
|
||||||
|
// every poll until it arrives.
|
||||||
|
const quiet = await repository.listMessages(bob, match, { since: before.server_time });
|
||||||
|
expect(quiet.messages.some(item => item.id === video.id)).toBe(false);
|
||||||
|
|
||||||
|
// The deadline now falls inside this poll's window.
|
||||||
|
await pool.query(`update messages set media_expires_at = now() - interval '1 second' where id = $1`, [video.id]);
|
||||||
|
const delta = await repository.listMessages(bob, match, { since: before.server_time });
|
||||||
|
const expired = delta.messages.find(item => item.id === video.id);
|
||||||
|
// Media, encryption and moderation columns still arrive whole on the delta path.
|
||||||
|
expect(expired).toMatchObject({
|
||||||
|
media_expired: true, media_type: 'video', media_path: 'media:expiring',
|
||||||
|
moderation_status: 'safe', encrypted_content: 'cipher', encryption_iv: 'iv',
|
||||||
|
});
|
||||||
|
expect(expired?.media_expires_at).not.toBeNull();
|
||||||
|
expect(expired?.media_viewed_at).not.toBeNull();
|
||||||
|
|
||||||
|
// A full read agrees, so leaving and re-entering the chat shows the same thing.
|
||||||
|
expect((await repository.listMessages(bob, match)).messages.find(item => item.id === video.id))
|
||||||
|
.toMatchObject({ media_expired: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('folds the partner typing check into the message read when asked', async () => {
|
||||||
|
await repository.setTyping(alice, match, 4_000);
|
||||||
|
const withTyping = await repository.listMessages(bob, match, { includeTyping: true });
|
||||||
|
expect(withTyping.typing?.typing).toBe(true);
|
||||||
|
// Own typing is never reflected back, so the sender does not see themselves.
|
||||||
|
expect((await repository.listMessages(alice, match, { includeTyping: true })).typing?.typing).toBe(false);
|
||||||
|
// And it stays absent unless requested, so the default read costs no extra work.
|
||||||
|
expect((await repository.listMessages(bob, match)).typing).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it('stores typing state with a short expiry and exposes only the partner state', async () => {
|
it('stores typing state with a short expiry and exposes only the partner state', async () => {
|
||||||
await repository.setTyping(alice, match, 4_000);
|
await repository.setTyping(alice, match, 4_000);
|
||||||
expect((await repository.getPartnerTyping(bob, match)).typing).toBe(true);
|
expect((await repository.getPartnerTyping(bob, match)).typing).toBe(true);
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ const message = { id: messageId, match_id: matchId, user_id: identity.id, conten
|
|||||||
|
|
||||||
function setup() {
|
function setup() {
|
||||||
const repository: ChatRepository = {
|
const repository: ChatRepository = {
|
||||||
listMessages: vi.fn(async () => [message]), sendText: vi.fn(async () => message),
|
listMessages: vi.fn(async () => ({ messages: [message], removed_ids: [], server_time: new Date().toISOString(), complete: true })),
|
||||||
|
sendText: vi.fn(async () => message),
|
||||||
unreadCounts: vi.fn(async () => ({ total: 1, by_match: { [matchId]: 1 } })),
|
unreadCounts: vi.fn(async () => ({ total: 1, by_match: { [matchId]: 1 } })),
|
||||||
markMatchRead: vi.fn(async () => ({ updated: 1, read_at: new Date().toISOString() })),
|
markMatchRead: vi.fn(async () => ({ updated: 1, read_at: new Date().toISOString() })),
|
||||||
markDelivered: vi.fn(async () => message), deleteForSelf: vi.fn(async () => undefined),
|
markDelivered: vi.fn(async () => message), deleteForSelf: vi.fn(async () => undefined),
|
||||||
@@ -43,6 +44,28 @@ describe('chat routes', () => {
|
|||||||
expect(repository.sendText).not.toHaveBeenCalled();
|
expect(repository.sendText).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('passes a delta cursor and folded typing check through to the repository', async () => {
|
||||||
|
const { app, repository } = setup();
|
||||||
|
const since = '2026-08-28T10:00:00.000Z';
|
||||||
|
const delta = await app.request(`/v1/matches/${matchId}/messages?since=${encodeURIComponent(since)}&typing=true`);
|
||||||
|
expect(delta.status).toBe(200);
|
||||||
|
expect(repository.listMessages).toHaveBeenCalledWith(identity.id, matchId, { since, includeTyping: true });
|
||||||
|
|
||||||
|
// Omitting both keeps the full-snapshot contract the client had before.
|
||||||
|
const snapshot = await app.request(`/v1/matches/${matchId}/messages`);
|
||||||
|
expect(snapshot.status).toBe(200);
|
||||||
|
expect(await snapshot.json()).toMatchObject({ messages: [{ id: messageId }] });
|
||||||
|
expect(repository.listMessages).toHaveBeenLastCalledWith(identity.id, matchId, { includeTyping: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a malformed cursor rather than silently reading everything', async () => {
|
||||||
|
const { app, repository } = setup();
|
||||||
|
const response = await app.request(`/v1/matches/${matchId}/messages?since=yesterday`);
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(await response.json()).toMatchObject({ error: { code: 'invalid_since' } });
|
||||||
|
expect(repository.listMessages).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('supports unread, typing, deletion and reporting actions', async () => {
|
it('supports unread, typing, deletion and reporting actions', async () => {
|
||||||
const { app, repository } = setup();
|
const { app, repository } = setup();
|
||||||
expect((await app.request('/v1/chat/unread')).status).toBe(200);
|
expect((await app.request('/v1/chat/unread')).status).toBe(200);
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { Pool } from 'pg';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
DEFAULT_POOL_MAX,
|
||||||
|
closeResolvedPool,
|
||||||
|
createDatabasePool,
|
||||||
|
resolvePool,
|
||||||
|
} from '../src/db/pool.js';
|
||||||
|
|
||||||
|
const url = 'postgres://user:pass@127.0.0.1:5432/sauci_test';
|
||||||
|
|
||||||
|
afterEach(() => vi.restoreAllMocks());
|
||||||
|
|
||||||
|
describe('database pool', () => {
|
||||||
|
it('bounds the shared pool and only forwards timeouts that were configured', () => {
|
||||||
|
// A process-wide pool is the whole point of the change: an unbounded default
|
||||||
|
// multiplied by one pool per repository is what the self-hosted database had
|
||||||
|
// to absorb before.
|
||||||
|
const bounded = createDatabasePool(url, { max: 4, idleTimeoutMillis: 1_000, connectionTimeoutMillis: 2_000 });
|
||||||
|
expect(bounded.options.max).toBe(4);
|
||||||
|
expect(bounded.options.idleTimeoutMillis).toBe(1_000);
|
||||||
|
expect(bounded.options.connectionTimeoutMillis).toBe(2_000);
|
||||||
|
|
||||||
|
const defaults = createDatabasePool(url);
|
||||||
|
expect(defaults.options.max).toBe(DEFAULT_POOL_MAX);
|
||||||
|
|
||||||
|
return Promise.all([bounded.end(), defaults.end()]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('owns a pool it built from a URL and never ends one it was handed', async () => {
|
||||||
|
// Repositories in the API process share one pool, so closing any single
|
||||||
|
// repository must not take the connections out from under the others. Tests
|
||||||
|
// still construct from a URL and must be able to close what they created.
|
||||||
|
const owned = resolvePool(url);
|
||||||
|
expect(owned.owned).toBe(true);
|
||||||
|
const ownedEnd = vi.spyOn(owned.pool, 'end');
|
||||||
|
await closeResolvedPool(owned.pool, owned.owned);
|
||||||
|
expect(ownedEnd).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
const shared = new Pool({ connectionString: url });
|
||||||
|
const borrowed = resolvePool(shared);
|
||||||
|
expect(borrowed.owned).toBe(false);
|
||||||
|
expect(borrowed.pool).toBe(shared);
|
||||||
|
const sharedEnd = vi.spyOn(shared, 'end');
|
||||||
|
await closeResolvedPool(borrowed.pool, borrowed.owned);
|
||||||
|
expect(sharedEnd).not.toHaveBeenCalled();
|
||||||
|
await shared.end();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { requireLocalApi, requireLoopback } from '../src/load/loopback.js';
|
||||||
|
|
||||||
|
describe('representative load fixture boundary', () => {
|
||||||
|
it('accepts only loopback API and PostgreSQL URLs', () => {
|
||||||
|
expect(requireLoopback('http://127.0.0.1:3003', 'API').hostname).toBe('127.0.0.1');
|
||||||
|
expect(requireLoopback('postgresql://sauci:test@localhost:5432/sauci', 'DATABASE').hostname).toBe('localhost');
|
||||||
|
expect(() => requireLoopback('https://api.sauci.example', 'API')).toThrow('must resolve to loopback');
|
||||||
|
expect(() => requireLoopback('postgresql://sauci:test@db.internal:5432/sauci', 'DATABASE')).toThrow('must resolve to loopback');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows only the controlled local HTTP API port', () => {
|
||||||
|
expect(requireLocalApi('http://127.0.0.1:3003').port).toBe('3003');
|
||||||
|
expect(() => requireLocalApi('https://127.0.0.1:3003')).toThrow('must be http loopback');
|
||||||
|
expect(() => requireLocalApi('http://127.0.0.1:3004')).toThrow('must be http loopback');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto';
|
|||||||
import { readFile } from 'node:fs/promises';
|
import { readFile } from 'node:fs/promises';
|
||||||
import { Pool } from 'pg';
|
import { Pool } from 'pg';
|
||||||
import { afterAll,beforeAll,describe,expect,it } from 'vitest';
|
import { afterAll,beforeAll,describe,expect,it } from 'vitest';
|
||||||
import { PostgresOperationsRepository } from '../src/domains/operations/repository.js';
|
import { OUTBOX_STATE_SQL, PostgresOperationsRepository } from '../src/domains/operations/repository.js';
|
||||||
|
|
||||||
const databaseUrl=process.env.DATABASE_URL;const local=databaseUrl?['127.0.0.1','localhost','::1'].includes(new URL(databaseUrl).hostname):false;
|
const databaseUrl=process.env.DATABASE_URL;const local=databaseUrl?['127.0.0.1','localhost','::1'].includes(new URL(databaseUrl).hostname):false;
|
||||||
if(databaseUrl&&!local)throw new Error('Operations integration tests only permit a localhost DATABASE_URL');
|
if(databaseUrl&&!local)throw new Error('Operations integration tests only permit a localhost DATABASE_URL');
|
||||||
@@ -111,4 +111,13 @@ describe.skipIf(!databaseUrl||!local)('PostgresOperationsRepository',()=>{
|
|||||||
const [a,b]=await Promise.all([repo.claim(100),repo2.claim(100)]);const ids=[...a,...b].map(item=>item.id);expect(new Set(ids).size).toBe(ids.length);expect(ids.length).toBeGreaterThan(0);
|
const [a,b]=await Promise.all([repo.claim(100),repo2.claim(100)]);const ids=[...a,...b].map(item=>item.id);expect(new Set(ids).size).toBe(ids.length);expect(ids.length).toBeGreaterThan(0);
|
||||||
expect([...a,...b].find(item=>item.dedupeKey.startsWith('message:'))?.pushToken).toBeNull();
|
expect([...a,...b].find(item=>item.dedupeKey.startsWith('message:'))?.pushToken).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps due/unlocked outbox observation eligible for the existing due index',async()=>{
|
||||||
|
const client=await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query('begin'); await client.query('set local enable_seqscan=off');
|
||||||
|
const plan=await client.query<{"QUERY PLAN":string}>(`explain (costs off) ${OUTBOX_STATE_SQL}`);
|
||||||
|
expect(plan.rows.map(row=>row['QUERY PLAN']).join('\n')).toContain('operations_outbox_due_idx');
|
||||||
|
} finally { await client.query('rollback').catch(()=>undefined); client.release(); }
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { OUTBOX_STATE_SQL, PostgresOperationsRepository } from '../src/domains/operations/repository.js';
|
||||||
|
|
||||||
|
describe('operations outbox observation', () => {
|
||||||
|
it('uses a transaction-scoped statement timeout and always releases on query failure', async () => {
|
||||||
|
const query = vi.fn(async (sql: string) => {
|
||||||
|
if (sql === OUTBOX_STATE_SQL) throw new Error('statement timeout');
|
||||||
|
return { rows: [], rowCount: 0 };
|
||||||
|
});
|
||||||
|
const release = vi.fn();
|
||||||
|
const pool = { connect: vi.fn(async () => ({ query, release })) };
|
||||||
|
const repository = new PostgresOperationsRepository(pool as never);
|
||||||
|
await expect(repository.outboxState()).rejects.toThrow('statement timeout');
|
||||||
|
expect(query).toHaveBeenNthCalledWith(1, 'begin');
|
||||||
|
expect(query).toHaveBeenNthCalledWith(2, "set local statement_timeout = '250ms'");
|
||||||
|
expect(query).toHaveBeenNthCalledWith(3, OUTBOX_STATE_SQL);
|
||||||
|
expect(query).toHaveBeenCalledWith('rollback');
|
||||||
|
expect(release).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('evicts the client when rollback itself fails while preserving the original error', async () => {
|
||||||
|
const original = new Error('statement timeout'); const rollback = new Error('rollback failed');
|
||||||
|
const query = vi.fn(async (sql: string) => { if (sql === OUTBOX_STATE_SQL) throw original; if (sql === 'rollback') throw rollback; return { rows: [], rowCount: 0 }; });
|
||||||
|
const release = vi.fn(); const repository = new PostgresOperationsRepository({ connect: vi.fn(async () => ({ query, release })) } as never);
|
||||||
|
await expect(repository.outboxState()).rejects.toBe(original);
|
||||||
|
expect(release).toHaveBeenCalledTimes(1);
|
||||||
|
expect(release).toHaveBeenCalledWith(rollback);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,7 +7,7 @@ import { loadWorkerConfig } from '../src/workers/config.js';
|
|||||||
|
|
||||||
const summary={releasedPacks:0,streakMilestones:0,digests:0,packChanges:0,weeklySummaries:0,unpairedReminders:0,catchupReminders:0,streakReminders:0,daresExpired:0};
|
const summary={releasedPacks:0,streakMilestones:0,digests:0,packChanges:0,weeklySummaries:0,unpairedReminders:0,catchupReminders:0,streakReminders:0,daresExpired:0};
|
||||||
function repository(items:OperationItem[]):OperationsRepository {
|
function repository(items:OperationItem[]):OperationsRepository {
|
||||||
return {produce:vi.fn(async()=>summary),claim:vi.fn(async()=>items),complete:vi.fn(async()=>undefined),fail:vi.fn(async()=>undefined),
|
return {produce:vi.fn(async()=>summary),claim:vi.fn(async()=>items),complete:vi.fn(async()=>undefined),fail:vi.fn(async()=>undefined),outboxState:vi.fn(async()=>({due:items.length,oldestDueAgeSeconds:0})),
|
||||||
message:vi.fn(async()=>null),classifierConfig:vi.fn(async()=>null),classify:vi.fn(async()=>undefined),close:vi.fn(async()=>undefined)};
|
message:vi.fn(async()=>null),classifierConfig:vi.fn(async()=>null),classify:vi.fn(async()=>undefined),close:vi.fn(async()=>undefined)};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,4 +71,11 @@ describe('worker configuration',()=>{
|
|||||||
discordWebhookUrl:undefined,
|
discordWebhookUrl:undefined,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses a distinct bounded pool configuration for the worker process',()=>{
|
||||||
|
expect(loadWorkerConfig({
|
||||||
|
DATABASE_URL:'postgresql://sauci:test@localhost:5432/sauci',
|
||||||
|
DATABASE_POOL_MAX:'3', DATABASE_POOL_IDLE_TIMEOUT_MS:'1200', DATABASE_POOL_CONNECTION_TIMEOUT_MS:'3400',
|
||||||
|
}).databasePool).toEqual({max:3,idleTimeoutMillis:1200,connectionTimeoutMillis:3400});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { Pool } from 'pg';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { createDatabasePool } from '../src/db/pool.js';
|
||||||
|
import { flushTelemetry, recordPool, setTelemetrySinkForTests, type TelemetryRecord } from '../src/telemetry.js';
|
||||||
|
|
||||||
|
const url = 'postgres://user:pass@127.0.0.1:5432/sauci_test';
|
||||||
|
|
||||||
|
describe('privacy-safe telemetry', () => {
|
||||||
|
let restore: () => void = () => {};
|
||||||
|
afterEach(() => { restore(); vi.restoreAllMocks(); });
|
||||||
|
|
||||||
|
it('records query duration without SQL or bound values', async () => {
|
||||||
|
const records: TelemetryRecord[] = [];
|
||||||
|
restore = setTelemetrySinkForTests((record) => records.push(record));
|
||||||
|
vi.spyOn(Pool.prototype, 'query').mockResolvedValueOnce({ rows: [], rowCount: 0 } as never);
|
||||||
|
const pool = createDatabasePool(url, {}, 'api');
|
||||||
|
await pool.query('select secret from profiles where id=$1', ['private-user-id']);
|
||||||
|
flushTelemetry();
|
||||||
|
const serialized = JSON.stringify(records);
|
||||||
|
expect(records).toContainEqual(expect.objectContaining({ event: 'query', process: 'api', operation: 'pool_query', outcome: 'ok', count: 1 }));
|
||||||
|
expect(serialized).not.toContain('secret');
|
||||||
|
expect(serialized).not.toContain('private-user-id');
|
||||||
|
await pool.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits only numeric pool state', async () => {
|
||||||
|
const records: TelemetryRecord[] = [];
|
||||||
|
restore = setTelemetrySinkForTests((record) => records.push(record));
|
||||||
|
const pool = new Pool({ connectionString: url });
|
||||||
|
recordPool('worker', pool);
|
||||||
|
expect(records).toEqual([{ event: 'pool', process: 'worker', total: 0, idle: 0, waiting: 0 }]);
|
||||||
|
await pool.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('also instruments transaction-client queries', async () => {
|
||||||
|
const records: TelemetryRecord[] = [];
|
||||||
|
restore = setTelemetrySinkForTests((record) => records.push(record));
|
||||||
|
const client = { query: vi.fn(async () => ({ rows: [], rowCount: 0 })), release: vi.fn() };
|
||||||
|
vi.spyOn(Pool.prototype, 'connect').mockResolvedValueOnce(client as never);
|
||||||
|
const pool = createDatabasePool(url, {}, 'worker');
|
||||||
|
const borrowed = await pool.connect();
|
||||||
|
await borrowed.query('select message_body from messages where id=$1', ['private-message-id']);
|
||||||
|
flushTelemetry();
|
||||||
|
const serialized = JSON.stringify(records);
|
||||||
|
expect(records).toContainEqual(expect.objectContaining({ event: 'query', process: 'worker', outcome: 'ok', count: 1 }));
|
||||||
|
expect(serialized).not.toContain('message_body');
|
||||||
|
expect(serialized).not.toContain('private-message-id');
|
||||||
|
await pool.end();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { WorkerLifecycle } from '../src/workers/lifecycle.js';
|
||||||
|
|
||||||
|
function deferred<T>() { let resolve!: (value: T) => void; const promise = new Promise<T>((next) => { resolve = next; }); return { promise, resolve }; }
|
||||||
|
|
||||||
|
describe('WorkerLifecycle', () => {
|
||||||
|
it('reuses one shutdown promise, awaits the active tick, and closes once', async () => {
|
||||||
|
const tick = deferred<void>(); const closePool = vi.fn(async () => undefined);
|
||||||
|
const lifecycle = new WorkerLifecycle({ pollIntervalMs: 60_000, runTick: () => tick.promise, closePool, onTickFailure: vi.fn(), graceMs: 50 });
|
||||||
|
lifecycle.start(); const first = lifecycle.shutdown(); const second = lifecycle.shutdown();
|
||||||
|
expect(second).toBe(first); expect(closePool).not.toHaveBeenCalled();
|
||||||
|
tick.resolve(); await expect(first).resolves.toBe(0); expect(closePool).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns nonzero when an active tick exceeds the one total deadline', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const lifecycle = new WorkerLifecycle({ pollIntervalMs: 60_000, runTick: () => new Promise<void>(() => undefined), closePool: vi.fn(async () => undefined), onTickFailure: vi.fn(), graceMs: 10 });
|
||||||
|
lifecycle.start(); const shutdown = lifecycle.shutdown(); await vi.advanceTimersByTimeAsync(10);
|
||||||
|
await expect(shutdown).resolves.toBe(1); vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns nonzero and closes once when pool shutdown rejects or hangs', async () => {
|
||||||
|
const rejected = new WorkerLifecycle({ pollIntervalMs: 60_000, runTick: async () => undefined, closePool: vi.fn(async () => { throw new Error('close failed'); }), onTickFailure: vi.fn(), graceMs: 50 });
|
||||||
|
rejected.start(); await expect(rejected.shutdown()).resolves.toBe(1);
|
||||||
|
vi.useFakeTimers(); const closePool = vi.fn(() => new Promise<void>(() => undefined));
|
||||||
|
const hung = new WorkerLifecycle({ pollIntervalMs: 60_000, runTick: async () => undefined, closePool, onTickFailure: vi.fn(), graceMs: 10 });
|
||||||
|
hung.start(); const shutdown = hung.shutdown(); await vi.advanceTimersByTimeAsync(10);
|
||||||
|
await expect(shutdown).resolves.toBe(1); expect(closePool).toHaveBeenCalledTimes(1); vi.useRealTimers();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,5 +5,16 @@ export default defineConfig({
|
|||||||
environment: 'node',
|
environment: 'node',
|
||||||
include: ['test/**/*.test.ts'],
|
include: ['test/**/*.test.ts'],
|
||||||
exclude: ['test/**/*.integration.test.ts'],
|
exclude: ['test/**/*.integration.test.ts'],
|
||||||
|
coverage: {
|
||||||
|
provider: 'v8',
|
||||||
|
include: ['src/**/*.ts'],
|
||||||
|
reporter: ['text', 'json-summary', 'lcov'],
|
||||||
|
thresholds: {
|
||||||
|
statements: 30,
|
||||||
|
branches: 60,
|
||||||
|
functions: 40,
|
||||||
|
lines: 30,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -5,5 +5,16 @@ export default defineConfig({
|
|||||||
environment: 'node',
|
environment: 'node',
|
||||||
include: ['test/**/*.integration.test.ts'],
|
include: ['test/**/*.integration.test.ts'],
|
||||||
fileParallelism: false,
|
fileParallelism: false,
|
||||||
|
coverage: {
|
||||||
|
provider: 'v8',
|
||||||
|
include: ['src/**/*.ts'],
|
||||||
|
reporter: ['text', 'json-summary', 'lcov'],
|
||||||
|
thresholds: {
|
||||||
|
statements: 55,
|
||||||
|
branches: 65,
|
||||||
|
functions: 65,
|
||||||
|
lines: 55,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -3,11 +3,12 @@ import { Ionicons } from "@expo/vector-icons";
|
|||||||
import { View, ActivityIndicator, StyleSheet, Platform, Text, Pressable, Animated, AppState } from "react-native";
|
import { View, ActivityIndicator, StyleSheet, Platform, Text, Pressable, Animated, AppState } from "react-native";
|
||||||
import { BlurView } from "expo-blur";
|
import { BlurView } from "expo-blur";
|
||||||
import { useEffect, useState, useCallback, useRef } from "react";
|
import { useEffect, useState, useCallback, useRef } from "react";
|
||||||
import { useAuthStore, useMatchStore, useMessageStore, useSubscriptionStore, usePacksStore, useStreakStore } from "../../src/store";
|
import { useAuthStore, useMatchStore, useMessageStore, useSubscriptionStore, useStreakStore } from "../../src/store";
|
||||||
import { colors, radius, spacing, typography, shadows } from "../../src/theme";
|
import { colors, radius, spacing, typography, shadows } from "../../src/theme";
|
||||||
import { authClient } from "../../src/lib/authClient";
|
import { authClient } from "../../src/lib/authClient";
|
||||||
import { isBiometricEnabled } from "../../src/lib/biometricAuth";
|
import { isBiometricEnabled } from "../../src/lib/biometricAuth";
|
||||||
import { checkAndRegisterPushToken } from "../../src/lib/notifications";
|
import { checkAndRegisterPushToken } from "../../src/lib/notifications";
|
||||||
|
import { useCoupleSync } from "../../src/hooks/useCoupleSync";
|
||||||
import { syncBadgeCount } from "../../src/lib/badge";
|
import { syncBadgeCount } from "../../src/lib/badge";
|
||||||
import { BiometricLockScreen } from "../../src/components/BiometricLockScreen";
|
import { BiometricLockScreen } from "../../src/components/BiometricLockScreen";
|
||||||
import { Events } from "../../src/lib/analytics";
|
import { Events } from "../../src/lib/analytics";
|
||||||
@@ -59,9 +60,8 @@ export default function AppLayout() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const segments = useSegments();
|
const segments = useSegments();
|
||||||
const { isAuthenticated, isLoading, user, signOut, updateLastActive } = useAuthStore();
|
const { isAuthenticated, isLoading, user, signOut, updateLastActive } = useAuthStore();
|
||||||
const { newMatchesCount, pendingQuestions, fetchPendingQuestions, fetchMatches } = useMatchStore();
|
const { newMatchesCount, pendingQuestions, matches, fetchPendingQuestions, fetchMatches } = useMatchStore();
|
||||||
const { unreadCount, lastMessage, fetchUnreadCount, clearLastMessage } = useMessageStore();
|
const { unreadCount, lastMessage, clearLastMessage } = useMessageStore();
|
||||||
const { fetchEnabledPacks } = usePacksStore();
|
|
||||||
const { initializeRevenueCat, refreshSubscriptionStatus } = useSubscriptionStore();
|
const { initializeRevenueCat, refreshSubscriptionStatus } = useSubscriptionStore();
|
||||||
const { fetchStreak } = useStreakStore();
|
const { fetchStreak } = useStreakStore();
|
||||||
const messageToastAnim = useRef(new Animated.Value(-100)).current;
|
const messageToastAnim = useRef(new Animated.Value(-100)).current;
|
||||||
@@ -173,12 +173,8 @@ export default function AppLayout() {
|
|||||||
setIsLocked(false);
|
setIsLocked(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Fetch unread message count on mount
|
// The unread badge arrives with the first sync summary, so it needs no request
|
||||||
useEffect(() => {
|
// of its own here.
|
||||||
if (user?.id) {
|
|
||||||
fetchUnreadCount();
|
|
||||||
}
|
|
||||||
}, [user?.id, fetchUnreadCount]);
|
|
||||||
|
|
||||||
// Fetch streak data when user is in a couple
|
// Fetch streak data when user is in a couple
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -187,12 +183,15 @@ export default function AppLayout() {
|
|||||||
}
|
}
|
||||||
}, [user?.couple_id, fetchStreak]);
|
}, [user?.couple_id, fetchStreak]);
|
||||||
|
|
||||||
// Fetch pending questions (Your Turn) when user is in a couple
|
// Fetch pending questions (Your Turn) and matches when user is in a couple.
|
||||||
|
// Both feed tab badges, so they load once here and are kept current by the
|
||||||
|
// sync summary rather than by re-fetching on a timer.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user?.couple_id) {
|
if (user?.couple_id) {
|
||||||
fetchPendingQuestions();
|
void fetchPendingQuestions();
|
||||||
|
void fetchMatches(true);
|
||||||
}
|
}
|
||||||
}, [user?.couple_id, fetchPendingQuestions]);
|
}, [user?.couple_id, fetchPendingQuestions, fetchMatches]);
|
||||||
|
|
||||||
// Sync app icon badge count whenever matches, messages, or pending questions change
|
// Sync app icon badge count whenever matches, messages, or pending questions change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -214,44 +213,31 @@ export default function AppLayout() {
|
|||||||
}
|
}
|
||||||
}, [user?.id, user?.onboarding_completed]);
|
}, [user?.id, user?.onboarding_completed]);
|
||||||
|
|
||||||
// Poll the self-hosted API for profile, match, pending-question and pack changes.
|
// One small change summary drives every domain refresh. It replaces a
|
||||||
// This replaces the former Supabase Postgres change subscriptions.
|
// five-second refetch of profile, matches, pending questions and packs plus a
|
||||||
|
// three-second unread poll, and only asks a store to reload when the server
|
||||||
|
// reports that its data actually moved.
|
||||||
|
useCoupleSync(user?.id, user?.couple_id);
|
||||||
|
|
||||||
|
// Match analytics are derived from the store rather than from a poll response,
|
||||||
|
// so they fire once per genuinely new match however it arrived.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user?.id) return;
|
|
||||||
let cancelled = false;
|
|
||||||
matchPollInitializedRef.current = false;
|
matchPollInitializedRef.current = false;
|
||||||
knownMatchIdsRef.current = new Set();
|
knownMatchIdsRef.current = new Set();
|
||||||
|
}, [user?.id, user?.couple_id]);
|
||||||
|
|
||||||
const poll = async () => {
|
|
||||||
await Promise.allSettled([
|
|
||||||
useAuthStore.getState().fetchUser(),
|
|
||||||
...(user.couple_id ? [fetchMatches(true), fetchPendingQuestions(), fetchEnabledPacks()] : []),
|
|
||||||
]);
|
|
||||||
if (cancelled || !user.couple_id) return;
|
|
||||||
const refreshed = useMatchStore.getState().matches;
|
|
||||||
if (matchPollInitializedRef.current) {
|
|
||||||
const added = refreshed.filter(item => !knownMatchIdsRef.current.has(item.id));
|
|
||||||
for (const item of added) Events.matchCreated(item.match_type || "unknown");
|
|
||||||
const previousCount = knownMatchIdsRef.current.size;
|
|
||||||
if (previousCount === 0 && added.length > 0) Events.firstMatch();
|
|
||||||
else if (added.length > 0 && refreshed.length % 10 === 0) Events.milestoneMatch(refreshed.length);
|
|
||||||
}
|
|
||||||
knownMatchIdsRef.current = new Set(refreshed.map(item => item.id));
|
|
||||||
matchPollInitializedRef.current = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
void poll();
|
|
||||||
const timer = setInterval(() => void poll(), 5_000);
|
|
||||||
return () => { cancelled = true; clearInterval(timer); };
|
|
||||||
}, [user?.id, user?.couple_id, fetchMatches, fetchPendingQuestions, fetchEnabledPacks]);
|
|
||||||
|
|
||||||
// Poll the self-hosted API for unread chat state. Chat screens perform their
|
|
||||||
// own message polling; this keeps the global badge current without Supabase Realtime.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user?.couple_id) return;
|
if (!user?.couple_id) return;
|
||||||
const timer = setInterval(() => void fetchUnreadCount(), 3_000);
|
const known = knownMatchIdsRef.current;
|
||||||
return () => clearInterval(timer);
|
const added = matches.filter(item => !known.has(item.id));
|
||||||
}, [user?.couple_id, fetchUnreadCount]);
|
if (matchPollInitializedRef.current && added.length > 0) {
|
||||||
|
for (const item of added) Events.matchCreated(item.match_type || "unknown");
|
||||||
|
if (known.size === 0) Events.firstMatch();
|
||||||
|
else if (matches.length % 10 === 0) Events.milestoneMatch(matches.length);
|
||||||
|
}
|
||||||
|
knownMatchIdsRef.current = new Set(matches.map(item => item.id));
|
||||||
|
matchPollInitializedRef.current = true;
|
||||||
|
}, [matches, user?.couple_id]);
|
||||||
|
|
||||||
// Animate message toast when lastMessage changes
|
// Animate message toast when lastMessage changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
import { useCallback, useMemo, useState } from 'react';
|
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||||
import { ScrollView, StyleSheet, Platform, View, Text, useWindowDimensions } from 'react-native';
|
import { ScrollView, StyleSheet, Platform, View, Text, useWindowDimensions } from 'react-native';
|
||||||
import { useFocusEffect } from 'expo-router';
|
import { useFocusEffect, useRouter } from 'expo-router';
|
||||||
import Animated, { FadeInDown } from 'react-native-reanimated';
|
import Animated, { FadeInDown } from 'react-native-reanimated';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { BlurView } from 'expo-blur';
|
import { BlurView } from 'expo-blur';
|
||||||
import { useAuthStore, usePacksStore, useStreakStore, useSubscriptionStore } from '../../src/store';
|
import * as Haptics from 'expo-haptics';
|
||||||
|
import { useAuthStore, useMatchStore, usePacksStore, useSubscriptionStore } from '../../src/store';
|
||||||
import { GradientBackground } from '../../src/components/ui';
|
import { GradientBackground } from '../../src/components/ui';
|
||||||
import { Paywall } from '../../src/components/paywall';
|
import { Paywall } from '../../src/components/paywall';
|
||||||
import { CompactHeader, ContentRow, DaresTile, LiveDrawTile } from '../../src/components/discovery';
|
import { CompactHeader, ContentRow, DaresTile, LiveDrawTile, PendingQuestionsCard } from '../../src/components/discovery';
|
||||||
import { StreakDisplay } from '../../src/components/StreakDisplay';
|
import { StreakDisplay } from '../../src/components/StreakDisplay';
|
||||||
import { colors, spacing, typography, radius } from '../../src/theme';
|
import { Events } from '../../src/lib/analytics';
|
||||||
|
import { colors, spacing, typography } from '../../src/theme';
|
||||||
import type { QuestionPack, Category } from '../../src/types';
|
import type { QuestionPack, Category } from '../../src/types';
|
||||||
|
|
||||||
const MAX_CONTENT_WIDTH = 600;
|
const MAX_CONTENT_WIDTH = 600;
|
||||||
@@ -68,23 +70,40 @@ function groupPacksByCategory(
|
|||||||
|
|
||||||
export default function DiscoveryScreen() {
|
export default function DiscoveryScreen() {
|
||||||
const { user, partner, couple } = useAuthStore();
|
const { user, partner, couple } = useAuthStore();
|
||||||
const { packs, categories, fetchPacks, getPackProgress } = usePacksStore();
|
const { packs, categories, fetchPacks, ensurePacksLoaded, getPackProgress } = usePacksStore();
|
||||||
const { fetchStreak } = useStreakStore();
|
const { pendingQuestions, fetchPendingQuestions } = useMatchStore();
|
||||||
const { subscription } = useSubscriptionStore();
|
const { subscription } = useSubscriptionStore();
|
||||||
|
const router = useRouter();
|
||||||
const { width } = useWindowDimensions();
|
const { width } = useWindowDimensions();
|
||||||
const [showPaywall, setShowPaywall] = useState(false);
|
const [showPaywall, setShowPaywall] = useState(false);
|
||||||
const [headerHeight, setHeaderHeight] = useState(130);
|
const [headerHeight, setHeaderHeight] = useState(130);
|
||||||
|
const lastPromptImpression = useRef<string | null>(null);
|
||||||
|
|
||||||
// Check all sources of premium access: user flag, partner flag, or active subscription
|
// Check all sources of premium access: user flag, partner flag, or active subscription
|
||||||
const isPremiumUser = user?.is_premium || partner?.is_premium || subscription.isProUser;
|
const isPremiumUser = user?.is_premium || partner?.is_premium || subscription.isProUser;
|
||||||
const isWideScreen = width > MAX_CONTENT_WIDTH;
|
const isWideScreen = width > MAX_CONTENT_WIDTH;
|
||||||
|
const pendingQuestionIds = useMemo(
|
||||||
|
() => pendingQuestions.map(question => question.id).join(','),
|
||||||
|
[pendingQuestions],
|
||||||
|
);
|
||||||
|
|
||||||
// Refresh data when screen gains focus
|
// Load the catalog once. Returning to this tab shows what is cached; answering a
|
||||||
|
// question invalidates it, and StreakDisplay owns the streak, so neither needs a
|
||||||
|
// request here.
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
fetchPacks();
|
void ensurePacksLoaded();
|
||||||
fetchStreak();
|
void fetchPendingQuestions();
|
||||||
}, [fetchStreak])
|
|
||||||
|
if (pendingQuestionIds && lastPromptImpression.current !== pendingQuestionIds) {
|
||||||
|
Events.pendingQuestionsPromptViewed(pendingQuestions.length);
|
||||||
|
lastPromptImpression.current = pendingQuestionIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
lastPromptImpression.current = null;
|
||||||
|
};
|
||||||
|
}, [ensurePacksLoaded, fetchPendingQuestions, pendingQuestionIds, pendingQuestions.length])
|
||||||
);
|
);
|
||||||
|
|
||||||
// Group packs by category
|
// Group packs by category
|
||||||
@@ -96,6 +115,18 @@ export default function DiscoveryScreen() {
|
|||||||
// Check if user has no packs enabled (empty state)
|
// Check if user has no packs enabled (empty state)
|
||||||
const hasNoPacks = packs.length === 0;
|
const hasNoPacks = packs.length === 0;
|
||||||
|
|
||||||
|
const handlePendingQuestionsPress = useCallback(() => {
|
||||||
|
const firstQuestion = pendingQuestions[0];
|
||||||
|
if (!firstQuestion) return;
|
||||||
|
|
||||||
|
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
|
Events.pendingQuestionsPromptTapped(pendingQuestions.length);
|
||||||
|
router.push({
|
||||||
|
pathname: '/(app)/swipe',
|
||||||
|
params: { mode: 'pending', startQuestionId: firstQuestion.question.id },
|
||||||
|
});
|
||||||
|
}, [pendingQuestions, router]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GradientBackground>
|
<GradientBackground>
|
||||||
<View
|
<View
|
||||||
@@ -117,16 +148,23 @@ export default function DiscoveryScreen() {
|
|||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
>
|
>
|
||||||
<View style={[styles.innerContainer, isWideScreen && styles.innerContainerWide]}>
|
<View style={[styles.innerContainer, isWideScreen && styles.innerContainerWide]}>
|
||||||
|
<PendingQuestionsCard
|
||||||
|
questions={pendingQuestions}
|
||||||
|
partnerName={partner?.name}
|
||||||
|
onPress={handlePendingQuestionsPress}
|
||||||
|
delay={50}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Shared streak - hidden entirely for couples with nothing running */}
|
{/* Shared streak - hidden entirely for couples with nothing running */}
|
||||||
<View style={styles.streakSlot}>
|
<View style={styles.streakSlot}>
|
||||||
<StreakDisplay delay={50} />
|
<StreakDisplay delay={100} />
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Live Draw Activity Tile */}
|
{/* Live Draw Activity Tile */}
|
||||||
<LiveDrawTile delay={100} />
|
<LiveDrawTile delay={150} />
|
||||||
|
|
||||||
{/* Dares */}
|
{/* Dares */}
|
||||||
<DaresTile delay={150} />
|
<DaresTile delay={200} />
|
||||||
|
|
||||||
{/* Content Rows by Category */}
|
{/* Content Rows by Category */}
|
||||||
{packsByCategory.map((group, index) => (
|
{packsByCategory.map((group, index) => (
|
||||||
@@ -136,7 +174,7 @@ export default function DiscoveryScreen() {
|
|||||||
packs={group.packs}
|
packs={group.packs}
|
||||||
isPremiumUser={isPremiumUser}
|
isPremiumUser={isPremiumUser}
|
||||||
categoryId={group.category.id}
|
categoryId={group.category.id}
|
||||||
delay={200 + index * 100}
|
delay={250 + index * 100}
|
||||||
getPackProgress={getPackProgress}
|
getPackProgress={getPackProgress}
|
||||||
onShowPaywall={() => setShowPaywall(true)}
|
onShowPaywall={() => setShowPaywall(true)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export default function MatchesScreen() {
|
|||||||
fetchMatches,
|
fetchMatches,
|
||||||
markAllAsSeen,
|
markAllAsSeen,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
isRefreshing,
|
||||||
hasMore,
|
hasMore,
|
||||||
isLoadingMore,
|
isLoadingMore,
|
||||||
totalCount,
|
totalCount,
|
||||||
@@ -58,7 +59,9 @@ export default function MatchesScreen() {
|
|||||||
fetchTheirTurnQuestions,
|
fetchTheirTurnQuestions,
|
||||||
currentView,
|
currentView,
|
||||||
setCurrentView,
|
setCurrentView,
|
||||||
|
ensureViewLoaded,
|
||||||
fetchPendingQuestions,
|
fetchPendingQuestions,
|
||||||
|
fetchArchivedMatches,
|
||||||
// Nudge state and methods
|
// Nudge state and methods
|
||||||
nudgeCooldownUntil,
|
nudgeCooldownUntil,
|
||||||
isNudging,
|
isNudging,
|
||||||
@@ -118,14 +121,12 @@ export default function MatchesScreen() {
|
|||||||
setShowTutorial(false);
|
setShowTutorial(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Load whichever view is showing, once. Returning to this tab reuses what is
|
||||||
|
// already cached; the sync poll refreshes it when the server reports a change,
|
||||||
|
// and pull-to-refresh remains the explicit way to force one.
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
fetchMatches(true);
|
ensureViewLoaded();
|
||||||
// Also refresh pending questions when focused
|
|
||||||
fetchPendingQuestions();
|
|
||||||
fetchTheirTurnQuestions();
|
|
||||||
// Check nudge cooldown
|
|
||||||
checkNudgeCooldown();
|
|
||||||
|
|
||||||
// Cleanup: mark matches as seen when leaving the screen (if on Complete tab)
|
// Cleanup: mark matches as seen when leaving the screen (if on Complete tab)
|
||||||
return () => {
|
return () => {
|
||||||
@@ -134,9 +135,15 @@ export default function MatchesScreen() {
|
|||||||
state.markAllAsSeen();
|
state.markAllAsSeen();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [])
|
}, [ensureViewLoaded])
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// The nudge cooldown only moves when this device sends a nudge, and sendNudge
|
||||||
|
// already records the new cooldown, so one read on mount is enough.
|
||||||
|
useEffect(() => {
|
||||||
|
void checkNudgeCooldown();
|
||||||
|
}, [checkNudgeCooldown]);
|
||||||
|
|
||||||
// Format cooldown remaining time
|
// Format cooldown remaining time
|
||||||
const formatCooldownTime = (cooldownUntil: Date): string => {
|
const formatCooldownTime = (cooldownUntil: Date): string => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -633,13 +640,16 @@ export default function MatchesScreen() {
|
|||||||
refreshing={
|
refreshing={
|
||||||
currentView === 'pending' ? isLoadingPending :
|
currentView === 'pending' ? isLoadingPending :
|
||||||
currentView === 'their_turn' ? isLoadingTheirTurn :
|
currentView === 'their_turn' ? isLoadingTheirTurn :
|
||||||
isLoading
|
currentView === 'archived' ? isLoadingArchived :
|
||||||
|
isRefreshing
|
||||||
}
|
}
|
||||||
onRefresh={() => {
|
onRefresh={() => {
|
||||||
if (currentView === 'pending') {
|
if (currentView === 'pending') {
|
||||||
fetchPendingQuestions();
|
fetchPendingQuestions();
|
||||||
} else if (currentView === 'their_turn') {
|
} else if (currentView === 'their_turn') {
|
||||||
fetchTheirTurnQuestions();
|
fetchTheirTurnQuestions();
|
||||||
|
} else if (currentView === 'archived') {
|
||||||
|
fetchArchivedMatches();
|
||||||
} else {
|
} else {
|
||||||
fetchMatches(true);
|
fetchMatches(true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ const AnimatedSectionList = Animated.createAnimatedComponent(SectionList);
|
|||||||
type GroupByOption = "pack" | "date" | "answer";
|
type GroupByOption = "pack" | "date" | "answer";
|
||||||
|
|
||||||
export default function MyAnswersScreen() {
|
export default function MyAnswersScreen() {
|
||||||
const { responses, isLoading, isLoadingMore, hasMore, groupBy, dateSortOrder, fetchResponses, setGroupBy, toggleDateSortOrder, totalCount } = useResponsesStore();
|
const { responses, isLoading, isRefreshing, isLoadingMore, hasMore, groupBy, dateSortOrder, fetchResponses, ensureResponsesLoaded, setGroupBy, toggleDateSortOrder, totalCount } = useResponsesStore();
|
||||||
const { user, partner } = useAuthStore();
|
const { user, partner } = useAuthStore();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const params = useLocalSearchParams();
|
const params = useLocalSearchParams();
|
||||||
@@ -110,11 +110,12 @@ export default function MyAnswersScreen() {
|
|||||||
// Ambient orb breathing animations
|
// Ambient orb breathing animations
|
||||||
const { orbStyle1, orbStyle2 } = useAmbientOrbAnimation();
|
const { orbStyle1, orbStyle2 } = useAmbientOrbAnimation();
|
||||||
|
|
||||||
// Fetch responses on focus
|
// Load once, then keep showing what is cached. Answering or editing a response
|
||||||
|
// invalidates it, and pull-to-refresh is always available.
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
fetchResponses(true);
|
void ensureResponsesLoaded();
|
||||||
}, [])
|
}, [ensureResponsesLoaded])
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleLoadMore = useCallback(() => {
|
const handleLoadMore = useCallback(() => {
|
||||||
@@ -271,7 +272,7 @@ export default function MyAnswersScreen() {
|
|||||||
scrollEventThrottle={16}
|
scrollEventThrottle={16}
|
||||||
refreshControl={
|
refreshControl={
|
||||||
<RefreshControl
|
<RefreshControl
|
||||||
refreshing={isLoading}
|
refreshing={isRefreshing}
|
||||||
onRefresh={() => fetchResponses(true)}
|
onRefresh={() => fetchResponses(true)}
|
||||||
tintColor={colors.primary}
|
tintColor={colors.primary}
|
||||||
colors={[colors.primary]}
|
colors={[colors.primary]}
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ const AnimatedFlatList = Animated.createAnimatedComponent(FlatList<Question>);
|
|||||||
export default function PackDetailsScreen() {
|
export default function PackDetailsScreen() {
|
||||||
const { id } = useLocalSearchParams();
|
const { id } = useLocalSearchParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { packs, fetchPacks } = usePacksStore();
|
const { packs, ensurePacksLoaded } = usePacksStore();
|
||||||
const { subscription } = useSubscriptionStore();
|
const { subscription } = useSubscriptionStore();
|
||||||
const { user, partner } = useAuthStore();
|
const { user, partner } = useAuthStore();
|
||||||
const [questions, setQuestions] = useState<Question[]>([]);
|
const [questions, setQuestions] = useState<Question[]>([]);
|
||||||
@@ -61,9 +61,7 @@ export default function PackDetailsScreen() {
|
|||||||
const category = categories.find(c => c.id === pack?.category_id);
|
const category = categories.find(c => c.id === pack?.category_id);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (packs.length === 0) {
|
void ensurePacksLoaded();
|
||||||
fetchPacks();
|
|
||||||
}
|
|
||||||
fetchPackDetails();
|
fetchPackDetails();
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,21 @@ module.exports = {
|
|||||||
'<rootDir>/src/**/__tests__/**/*.(spec|test).(ts|tsx)',
|
'<rootDir>/src/**/__tests__/**/*.(spec|test).(ts|tsx)',
|
||||||
'<rootDir>/src/**/*.(spec|test).(ts|tsx)',
|
'<rootDir>/src/**/*.(spec|test).(ts|tsx)',
|
||||||
],
|
],
|
||||||
|
collectCoverageFrom: [
|
||||||
|
'<rootDir>/app/**/*.{ts,tsx}',
|
||||||
|
'<rootDir>/src/**/*.{ts,tsx}',
|
||||||
|
'!<rootDir>/src/**/__tests__/**',
|
||||||
|
'!<rootDir>/src/test/**',
|
||||||
|
'!<rootDir>/**/*.d.ts',
|
||||||
|
],
|
||||||
|
coverageThreshold: {
|
||||||
|
global: {
|
||||||
|
statements: 23,
|
||||||
|
branches: 17,
|
||||||
|
functions: 21,
|
||||||
|
lines: 23,
|
||||||
|
},
|
||||||
|
},
|
||||||
transformIgnorePatterns: [
|
transformIgnorePatterns: [
|
||||||
'node_modules/(?!(jest-)?react-native|@react-native|@react-navigation|expo(nent)?|@expo(nent)?/.*|expo-router|react-native-reanimated|react-native-gesture-handler|react-native-safe-area-context|@sentry/react-native|@sentry/.*)',
|
'node_modules/(?!(jest-)?react-native|@react-native|@react-navigation|expo(nent)?|@expo(nent)?/.*|expo-router|react-native-reanimated|react-native-gesture-handler|react-native-safe-area-context|@sentry/react-native|@sentry/.*)',
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"test": "jest",
|
"test": "jest",
|
||||||
"test:watch": "jest --watch",
|
"test:watch": "jest --watch",
|
||||||
"test:ci": "jest --runInBand --ci",
|
"test:ci": "jest --runInBand --ci",
|
||||||
|
"test:coverage": "jest --runInBand --ci --coverage",
|
||||||
"release:ios": "dotenv -e .env -- eas build --platform ios --profile production --local",
|
"release:ios": "dotenv -e .env -- eas build --platform ios --profile production --local",
|
||||||
"release:ios:preview": "dotenv -e .env -- eas build --platform ios --profile preview --local",
|
"release:ios:preview": "dotenv -e .env -- eas build --platform ios --profile preview --local",
|
||||||
"release:android": "dotenv -e .env -- eas build --platform android --profile production --local",
|
"release:android": "dotenv -e .env -- eas build --platform android --profile production --local",
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { fireEvent, render } from '@/test/test-utils';
|
||||||
|
import { PendingQuestionsCard, formatWaitingSince } from '@/components/discovery/PendingQuestionsCard';
|
||||||
|
import type { PendingQuestion } from '@/store';
|
||||||
|
|
||||||
|
const pendingQuestion = (id: string, partnerAnsweredAt: string): PendingQuestion => ({
|
||||||
|
id: `response-${id}`,
|
||||||
|
partnerAnsweredAt,
|
||||||
|
question: {
|
||||||
|
id,
|
||||||
|
pack_id: 'pack-1',
|
||||||
|
text: `Question ${id}`,
|
||||||
|
intensity: 1,
|
||||||
|
created_at: '2026-08-01T00:00:00.000Z',
|
||||||
|
pack: { id: 'pack-1', name: 'Connection', icon: 'heart' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PendingQuestionsCard', () => {
|
||||||
|
it('stays out of Home when there is nothing to answer', () => {
|
||||||
|
const { queryByTestId } = render(
|
||||||
|
<PendingQuestionsCard questions={[]} partnerName="Sam" onPress={jest.fn()} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(queryByTestId('pending-questions-card')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names the partner, shows the count, and preserves answer privacy', () => {
|
||||||
|
const questions = [
|
||||||
|
pendingQuestion('q1', '2026-08-29T09:00:00.000Z'),
|
||||||
|
pendingQuestion('q2', '2026-08-29T10:00:00.000Z'),
|
||||||
|
];
|
||||||
|
const { getByText, getByLabelText } = render(
|
||||||
|
<PendingQuestionsCard questions={questions} partnerName="Sam" onPress={jest.fn()} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getByText('Sam has answered 2 questions')).toBeTruthy();
|
||||||
|
expect(getByText('Answer yours to see where you match. Their answers stay private until you respond.')).toBeTruthy();
|
||||||
|
expect(getByLabelText('2 questions waiting')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts the answer action from its accessible CTA', () => {
|
||||||
|
const onPress = jest.fn();
|
||||||
|
const { getByTestId } = render(
|
||||||
|
<PendingQuestionsCard
|
||||||
|
questions={[pendingQuestion('q1', '2026-08-29T09:00:00.000Z')]}
|
||||||
|
partnerName="Sam"
|
||||||
|
onPress={onPress}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.press(getByTestId('pending-questions-answer'));
|
||||||
|
expect(onPress).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatWaitingSince', () => {
|
||||||
|
const now = new Date('2026-08-29T12:00:00.000Z');
|
||||||
|
|
||||||
|
it('formats useful compact waiting ages', () => {
|
||||||
|
expect(formatWaitingSince('2026-08-29T11:59:30.000Z', now)).toBe('just now');
|
||||||
|
expect(formatWaitingSince('2026-08-29T11:42:00.000Z', now)).toBe('18m ago');
|
||||||
|
expect(formatWaitingSince('2026-08-29T09:00:00.000Z', now)).toBe('3h ago');
|
||||||
|
expect(formatWaitingSince('2026-08-27T12:00:00.000Z', now)).toBe('2d ago');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -17,13 +17,19 @@ jest.mock("expo-router", () => ({
|
|||||||
jest.mock("@/store", () => {
|
jest.mock("@/store", () => {
|
||||||
const auth = { user: { id: "user-1" }, partner: { id: "user-2" }, couple: { id: "couple-1", couple_id: "couple-1" } };
|
const auth = { user: { id: "user-1" }, partner: { id: "user-2" }, couple: { id: "couple-1", couple_id: "couple-1" } };
|
||||||
const packs = { enabledPackIds: ["pack-1"], ensureEnabledPacksLoaded: async () => undefined, packs: [] };
|
const packs = { enabledPackIds: ["pack-1"], ensureEnabledPacksLoaded: async () => undefined, packs: [] };
|
||||||
|
const packState = { ...packs, invalidatePacks: jest.fn() };
|
||||||
|
const responses = { invalidateResponses: jest.fn() };
|
||||||
const useAuthStore = () => auth;
|
const useAuthStore = () => auth;
|
||||||
useAuthStore.getState = () => auth.user;
|
useAuthStore.getState = () => auth.user;
|
||||||
|
const usePacksStore = () => packs;
|
||||||
|
usePacksStore.getState = () => packState;
|
||||||
|
const useResponsesStore = () => responses;
|
||||||
|
useResponsesStore.getState = () => responses;
|
||||||
// Read lazily: the factory runs before this module's own consts are initialised.
|
// Read lazily: the factory runs before this module's own consts are initialised.
|
||||||
const streak = { fetchStreak: () => mockFetchStreak() };
|
const streak = { fetchStreak: () => mockFetchStreak() };
|
||||||
const useStreakStore = () => streak;
|
const useStreakStore = () => streak;
|
||||||
useStreakStore.getState = () => streak;
|
useStreakStore.getState = () => streak;
|
||||||
return { useAuthStore, usePacksStore: () => packs, useStreakStore };
|
return { useAuthStore, usePacksStore, useResponsesStore, useStreakStore };
|
||||||
});
|
});
|
||||||
|
|
||||||
jest.mock("@/lib/skippedQuestions", () => ({
|
jest.mock("@/lib/skippedQuestions", () => ({
|
||||||
@@ -45,17 +51,19 @@ const questions = [
|
|||||||
{ id: "q2", pack_id: "pack-1", question_type: "swipe", text: "Two" },
|
{ id: "q2", pack_id: "pack-1", question_type: "swipe", text: "Two" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const futureResetAt = "2099-08-29T00:00:00.000Z";
|
||||||
|
|
||||||
const openLimit = {
|
const openLimit = {
|
||||||
responses_today: 4,
|
responses_today: 4,
|
||||||
limit_value: 10,
|
limit_value: 10,
|
||||||
remaining: 6,
|
remaining: 6,
|
||||||
reset_at: "2026-08-29T00:00:00.000Z",
|
reset_at: futureResetAt,
|
||||||
is_blocked: false,
|
is_blocked: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const dailyLimit429 = () => new ApiError("Daily response limit reached", 429, {
|
const dailyLimit429 = () => new ApiError("Daily response limit reached", 429, {
|
||||||
error: { code: "daily_limit", message: "Daily response limit reached" },
|
error: { code: "daily_limit", message: "Daily response limit reached" },
|
||||||
details: { daily_limit: 10, responses_today: 10, remaining: 0, reset_at: "2026-08-29T00:00:00.000Z" },
|
details: { daily_limit: 10, responses_today: 10, remaining: 0, reset_at: futureResetAt },
|
||||||
});
|
});
|
||||||
|
|
||||||
// clearMocks wipes implementations between tests, so re-arm the whole surface each time.
|
// clearMocks wipes implementations between tests, so re-arm the whole surface each time.
|
||||||
@@ -92,7 +100,7 @@ describe("useSwipeScreen daily limit", () => {
|
|||||||
responses_today: 10,
|
responses_today: 10,
|
||||||
limit_value: 10,
|
limit_value: 10,
|
||||||
remaining: 0,
|
remaining: 0,
|
||||||
reset_at: "2026-08-29T00:00:00.000Z",
|
reset_at: futureResetAt,
|
||||||
is_blocked: true,
|
is_blocked: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { act, renderHook } from '@testing-library/react-native';
|
||||||
|
import { Alert } from 'react-native';
|
||||||
|
import { Audio } from 'expo-av';
|
||||||
|
|
||||||
|
import { useAudioRecorder } from '@/hooks/useAudioRecorder';
|
||||||
|
|
||||||
|
const makeRecording = (overrides: Record<string, unknown> = {}) => ({
|
||||||
|
getStatusAsync: jest.fn(async () => ({ isRecording: true, durationMillis: 2400 })),
|
||||||
|
stopAndUnloadAsync: jest.fn(async () => undefined),
|
||||||
|
getURI: jest.fn(() => 'file://recording.m4a'),
|
||||||
|
setOnRecordingStatusUpdate: jest.fn(),
|
||||||
|
setProgressUpdateInterval: jest.fn(),
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useAudioRecorder', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
jest.spyOn(Alert, 'alert').mockImplementation(() => undefined);
|
||||||
|
jest.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||||
|
(Audio.requestPermissionsAsync as jest.Mock).mockResolvedValue({ status: 'granted' });
|
||||||
|
(Audio.setAudioModeAsync as jest.Mock).mockResolvedValue(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => jest.restoreAllMocks());
|
||||||
|
|
||||||
|
it('alerts and refuses to start without microphone permission', async () => {
|
||||||
|
(Audio.requestPermissionsAsync as jest.Mock).mockResolvedValueOnce({ status: 'denied' });
|
||||||
|
const { result } = renderHook(() => useAudioRecorder());
|
||||||
|
|
||||||
|
let started = true;
|
||||||
|
await act(async () => { started = await result.current.startRecording(); });
|
||||||
|
|
||||||
|
expect(started).toBe(false);
|
||||||
|
expect(Alert.alert).toHaveBeenCalledWith(
|
||||||
|
'Microphone Permission',
|
||||||
|
'Please allow microphone access to record audio.'
|
||||||
|
);
|
||||||
|
expect(Audio.Recording.createAsync).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts, reports duration, and returns the completed recording', async () => {
|
||||||
|
const recording = makeRecording();
|
||||||
|
(Audio.Recording.createAsync as jest.Mock).mockResolvedValue({ recording });
|
||||||
|
const onComplete = jest.fn();
|
||||||
|
const { result } = renderHook(() => useAudioRecorder({ onRecordingComplete: onComplete }));
|
||||||
|
|
||||||
|
await act(async () => { expect(await result.current.startRecording()).toBe(true); });
|
||||||
|
expect(result.current.state).toBe('recording');
|
||||||
|
expect(recording.setProgressUpdateInterval).toHaveBeenCalledWith(100);
|
||||||
|
|
||||||
|
const statusCallback = recording.setOnRecordingStatusUpdate.mock.calls[0][0];
|
||||||
|
act(() => statusCallback({ isRecording: true, durationMillis: 2400 }));
|
||||||
|
expect(result.current.durationSeconds).toBe(2);
|
||||||
|
|
||||||
|
let completed: unknown;
|
||||||
|
await act(async () => { completed = await result.current.stopRecording(); });
|
||||||
|
expect(completed).toEqual({ uri: 'file://recording.m4a', duration: 2 });
|
||||||
|
expect(result.current).toMatchObject({
|
||||||
|
state: 'stopped',
|
||||||
|
recordingUri: 'file://recording.m4a',
|
||||||
|
durationSeconds: 2,
|
||||||
|
});
|
||||||
|
expect(onComplete).toHaveBeenCalledWith('file://recording.m4a', 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('auto-stops when the configured duration is reached', async () => {
|
||||||
|
const recording = makeRecording({
|
||||||
|
getStatusAsync: jest.fn(async () => ({ isRecording: true, durationMillis: 1000 })),
|
||||||
|
});
|
||||||
|
(Audio.Recording.createAsync as jest.Mock).mockResolvedValue({ recording });
|
||||||
|
const onComplete = jest.fn();
|
||||||
|
const { result } = renderHook(() => useAudioRecorder({ maxDurationSeconds: 1, onRecordingComplete: onComplete }));
|
||||||
|
|
||||||
|
await act(async () => { await result.current.startRecording(); });
|
||||||
|
const statusCallback = recording.setOnRecordingStatusUpdate.mock.calls[0][0];
|
||||||
|
await act(async () => { statusCallback({ isRecording: true, durationMillis: 1000 }); });
|
||||||
|
|
||||||
|
expect(recording.stopAndUnloadAsync).toHaveBeenCalled();
|
||||||
|
expect(onComplete).toHaveBeenCalledWith('file://recording.m4a', 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets an active recording and cleans it up on unmount', async () => {
|
||||||
|
const first = makeRecording();
|
||||||
|
const second = makeRecording();
|
||||||
|
(Audio.Recording.createAsync as jest.Mock)
|
||||||
|
.mockResolvedValueOnce({ recording: first })
|
||||||
|
.mockResolvedValueOnce({ recording: second });
|
||||||
|
const view = renderHook(() => useAudioRecorder());
|
||||||
|
|
||||||
|
await act(async () => { await view.result.current.startRecording(); });
|
||||||
|
act(() => view.result.current.resetRecording());
|
||||||
|
expect(first.stopAndUnloadAsync).toHaveBeenCalled();
|
||||||
|
expect(view.result.current.state).toBe('idle');
|
||||||
|
|
||||||
|
await act(async () => { await view.result.current.startRecording(); });
|
||||||
|
view.unmount();
|
||||||
|
expect(second.stopAndUnloadAsync).toHaveBeenCalled();
|
||||||
|
expect(Audio.setAudioModeAsync).toHaveBeenLastCalledWith({
|
||||||
|
allowsRecordingIOS: false,
|
||||||
|
playsInSilentModeIOS: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recovers when starting or stopping throws', async () => {
|
||||||
|
(Audio.Recording.createAsync as jest.Mock).mockRejectedValueOnce(new Error('native unavailable'));
|
||||||
|
const first = renderHook(() => useAudioRecorder());
|
||||||
|
await act(async () => { expect(await first.result.current.startRecording()).toBe(false); });
|
||||||
|
expect(first.result.current.state).toBe('idle');
|
||||||
|
|
||||||
|
const recording = makeRecording({ getStatusAsync: jest.fn(async () => { throw new Error('lost'); }) });
|
||||||
|
(Audio.Recording.createAsync as jest.Mock).mockResolvedValueOnce({ recording });
|
||||||
|
const second = renderHook(() => useAudioRecorder());
|
||||||
|
await act(async () => { await second.result.current.startRecording(); });
|
||||||
|
await act(async () => { expect(await second.result.current.stopRecording()).toBeNull(); });
|
||||||
|
expect(second.result.current.state).toBe('idle');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
import { act, renderHook } from '@testing-library/react-native';
|
||||||
|
import { useCoupleSync, SYNC_INTERVAL_MS } from '@/hooks/useCoupleSync';
|
||||||
|
import { appDataApi, type SyncSummary } from '@/lib/appDataApi';
|
||||||
|
import { useAuthStore } from '@/store/authStore';
|
||||||
|
import { useMatchStore } from '@/store/matchStore';
|
||||||
|
import { useMessageStore } from '@/store/messageStore';
|
||||||
|
import { usePacksStore } from '@/store/packsStore';
|
||||||
|
import { useStreakStore } from '@/store/streakStore';
|
||||||
|
|
||||||
|
jest.mock('@/lib/appDataApi', () => ({ appDataApi: { syncSummary: jest.fn() } }));
|
||||||
|
|
||||||
|
const mockForeground = { value: true };
|
||||||
|
const mockListeners = new Set<(foreground: boolean) => void>();
|
||||||
|
jest.mock('@/lib/appForeground', () => ({
|
||||||
|
isForeground: () => mockForeground.value,
|
||||||
|
subscribeToForeground: (listener: (foreground: boolean) => void) => {
|
||||||
|
mockListeners.add(listener);
|
||||||
|
return () => { mockListeners.delete(listener); };
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const baseline: SyncSummary = {
|
||||||
|
server_time: '2026-08-27T12:00:00.000Z',
|
||||||
|
couple_id: 'couple',
|
||||||
|
profile_updated_at: '2026-08-01T00:00:00.000Z',
|
||||||
|
partner_id: 'partner',
|
||||||
|
partner_updated_at: '2026-08-01T00:00:00.000Z',
|
||||||
|
match_count: 3,
|
||||||
|
new_match_count: 0,
|
||||||
|
latest_match_at: '2026-08-20T00:00:00.000Z',
|
||||||
|
pending_yours: 1,
|
||||||
|
pending_theirs: 2,
|
||||||
|
unread_total: 0,
|
||||||
|
enabled_packs_fingerprint: 'digest-a',
|
||||||
|
match_state_fingerprint: 'state-a',
|
||||||
|
match_unread_fingerprint: 'unread-a',
|
||||||
|
streak_updated_at: '2026-08-01T00:00:00.000Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
const flush = async () => { await act(async () => { await Promise.resolve(); await Promise.resolve(); }); };
|
||||||
|
const tick = async () => {
|
||||||
|
await flush();
|
||||||
|
await act(async () => { jest.advanceTimersByTime(SYNC_INTERVAL_MS); });
|
||||||
|
await flush();
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Replaces every domain refresh the sync can dispatch with a counter. */
|
||||||
|
function stubRefreshers() {
|
||||||
|
const fetchUser = jest.fn(async () => undefined);
|
||||||
|
const fetchCouple = jest.fn(async () => undefined);
|
||||||
|
const fetchMatches = jest.fn(async () => undefined);
|
||||||
|
const fetchPendingQuestions = jest.fn(async () => undefined);
|
||||||
|
const fetchTheirTurnQuestions = jest.fn(async () => undefined);
|
||||||
|
const fetchEnabledPacks = jest.fn(async () => undefined);
|
||||||
|
const refreshStreak = jest.fn(async () => undefined);
|
||||||
|
useAuthStore.setState({ fetchUser, fetchCouple } as never);
|
||||||
|
useMatchStore.setState({ fetchMatches, fetchPendingQuestions, fetchTheirTurnQuestions } as never);
|
||||||
|
usePacksStore.setState({ fetchEnabledPacks } as never);
|
||||||
|
useStreakStore.setState({ refreshStreak } as never);
|
||||||
|
return { fetchUser, fetchCouple, fetchMatches, fetchPendingQuestions, fetchTheirTurnQuestions, fetchEnabledPacks, refreshStreak };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useCoupleSync', () => {
|
||||||
|
let refreshers: ReturnType<typeof stubRefreshers>;
|
||||||
|
const originals = {
|
||||||
|
auth: { fetchUser: useAuthStore.getState().fetchUser, fetchCouple: useAuthStore.getState().fetchCouple },
|
||||||
|
match: {
|
||||||
|
fetchMatches: useMatchStore.getState().fetchMatches,
|
||||||
|
fetchPendingQuestions: useMatchStore.getState().fetchPendingQuestions,
|
||||||
|
fetchTheirTurnQuestions: useMatchStore.getState().fetchTheirTurnQuestions,
|
||||||
|
},
|
||||||
|
packs: { fetchEnabledPacks: usePacksStore.getState().fetchEnabledPacks },
|
||||||
|
streak: { refreshStreak: useStreakStore.getState().refreshStreak },
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
jest.useFakeTimers();
|
||||||
|
mockForeground.value = true;
|
||||||
|
mockListeners.clear();
|
||||||
|
useMessageStore.setState({ unreadCount: 0 });
|
||||||
|
// Every view starts loaded: this hook must refresh only what is on screen,
|
||||||
|
// and the "not loaded" case is asserted separately below.
|
||||||
|
useMatchStore.setState({ loadedAt: { active: 1, pending: 1, their_turn: 1 } });
|
||||||
|
usePacksStore.setState({ enabledPacksLoadedAt: Date.now() });
|
||||||
|
refreshers = stubRefreshers();
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
jest.useRealTimers();
|
||||||
|
useAuthStore.setState(originals.auth as never);
|
||||||
|
useMatchStore.setState({ ...originals.match, loadedAt: {} } as never);
|
||||||
|
usePacksStore.setState({ ...originals.packs, enabledPacksLoadedAt: null } as never);
|
||||||
|
useStreakStore.setState(originals.streak as never);
|
||||||
|
});
|
||||||
|
|
||||||
|
const answer = (...summaries: SyncSummary[]) => {
|
||||||
|
const mock = appDataApi.syncSummary as jest.Mock;
|
||||||
|
mock.mockReset();
|
||||||
|
for (const summary of summaries) mock.mockResolvedValueOnce(summary);
|
||||||
|
mock.mockResolvedValue(summaries[summaries.length - 1]);
|
||||||
|
};
|
||||||
|
|
||||||
|
it('spends one request per interval and refreshes nothing while the markers hold still', async () => {
|
||||||
|
answer(baseline);
|
||||||
|
renderHook(() => useCoupleSync('me', 'couple'));
|
||||||
|
await flush();
|
||||||
|
expect(appDataApi.syncSummary).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
await tick();
|
||||||
|
await tick();
|
||||||
|
expect(appDataApi.syncSummary).toHaveBeenCalledTimes(3);
|
||||||
|
// Three polls, three requests in total: the baseline poll must not trigger
|
||||||
|
// the refresh of five domains that the old five-second sweep performed.
|
||||||
|
for (const refresh of Object.values(refreshers)) expect(refresh).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies the unread total from the summary without a second request', async () => {
|
||||||
|
answer(baseline, { ...baseline, unread_total: 4 });
|
||||||
|
renderHook(() => useCoupleSync('me', 'couple'));
|
||||||
|
await flush();
|
||||||
|
await tick();
|
||||||
|
expect(useMessageStore.getState().unreadCount).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refreshes only the domain whose marker moved', async () => {
|
||||||
|
answer(baseline, { ...baseline, latest_match_at: '2026-08-28T00:00:00.000Z', match_count: 4 });
|
||||||
|
renderHook(() => useCoupleSync('me', 'couple'));
|
||||||
|
await flush();
|
||||||
|
await tick();
|
||||||
|
expect(refreshers.fetchMatches).toHaveBeenCalledWith(true, { silent: true });
|
||||||
|
expect(refreshers.fetchPendingQuestions).not.toHaveBeenCalled();
|
||||||
|
expect(refreshers.fetchTheirTurnQuestions).not.toHaveBeenCalled();
|
||||||
|
expect(refreshers.fetchEnabledPacks).not.toHaveBeenCalled();
|
||||||
|
expect(refreshers.fetchUser).not.toHaveBeenCalled();
|
||||||
|
expect(refreshers.fetchCouple).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes each remaining marker to its own domain', async () => {
|
||||||
|
answer(baseline, {
|
||||||
|
...baseline,
|
||||||
|
pending_yours: 2,
|
||||||
|
pending_theirs: 3,
|
||||||
|
enabled_packs_fingerprint: 'digest-b',
|
||||||
|
partner_updated_at: '2026-08-29T00:00:00.000Z',
|
||||||
|
streak_updated_at: '2026-08-29T00:00:00.000Z',
|
||||||
|
});
|
||||||
|
renderHook(() => useCoupleSync('me', 'couple'));
|
||||||
|
await flush();
|
||||||
|
await tick();
|
||||||
|
expect(refreshers.fetchPendingQuestions).toHaveBeenCalledWith({ silent: true });
|
||||||
|
expect(refreshers.fetchTheirTurnQuestions).toHaveBeenCalledWith({ silent: true });
|
||||||
|
expect(refreshers.fetchEnabledPacks).toHaveBeenCalledTimes(1);
|
||||||
|
expect(refreshers.refreshStreak).toHaveBeenCalledTimes(1);
|
||||||
|
// A partner profile change is a couple-level read; the whole user is only
|
||||||
|
// re-read when the user's own profile or couple membership moved.
|
||||||
|
expect(refreshers.fetchCouple).toHaveBeenCalledTimes(1);
|
||||||
|
expect(refreshers.fetchUser).not.toHaveBeenCalled();
|
||||||
|
expect(refreshers.fetchMatches).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refreshes a loaded Matches view when the match-state fingerprint moves in place', async () => {
|
||||||
|
// Counts and latest-created-at do not move for an in-place match_type/
|
||||||
|
// response_summary edit; only the state fingerprint does.
|
||||||
|
answer(baseline, { ...baseline, match_state_fingerprint: 'state-b' });
|
||||||
|
renderHook(() => useCoupleSync('me', 'couple'));
|
||||||
|
await flush();
|
||||||
|
await tick();
|
||||||
|
expect(refreshers.fetchMatches).toHaveBeenCalledWith(true, { silent: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refreshes a loaded Matches view when unread redistributes without changing the total', async () => {
|
||||||
|
// One match is read while another receives a message: unread_total holds
|
||||||
|
// steady but the per-match fingerprint (and unread-first ordering) moves.
|
||||||
|
answer(baseline, { ...baseline, unread_total: baseline.unread_total, match_unread_fingerprint: 'unread-b' });
|
||||||
|
renderHook(() => useCoupleSync('me', 'couple'));
|
||||||
|
await flush();
|
||||||
|
await tick();
|
||||||
|
expect(refreshers.fetchMatches).toHaveBeenCalledWith(true, { silent: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not refresh enabled packs when that domain has never been opened', async () => {
|
||||||
|
usePacksStore.setState({ enabledPacksLoadedAt: null });
|
||||||
|
answer(baseline, { ...baseline, enabled_packs_fingerprint: 'digest-b' });
|
||||||
|
renderHook(() => useCoupleSync('me', 'couple'));
|
||||||
|
await flush();
|
||||||
|
await tick();
|
||||||
|
expect(refreshers.fetchEnabledPacks).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets the baseline when the account identity changes, instead of diffing against the prior account', async () => {
|
||||||
|
answer({ ...baseline, match_count: 3 });
|
||||||
|
const { rerender } = renderHook(
|
||||||
|
({ userId, coupleId }: { userId: string; coupleId: string }) => useCoupleSync(userId, coupleId),
|
||||||
|
{ initialProps: { userId: 'alice', coupleId: 'couple-a' } },
|
||||||
|
);
|
||||||
|
await flush();
|
||||||
|
await tick(); // establishes alice's baseline (match_count: 3)
|
||||||
|
|
||||||
|
// Switch accounts: bob's couple has a completely different match_count.
|
||||||
|
// Without a baseline reset this would look like a 12-count jump and
|
||||||
|
// trigger a refresh purely from comparing across accounts.
|
||||||
|
answer({ ...baseline, couple_id: 'couple-b', match_count: 15 });
|
||||||
|
rerender({ userId: 'bob', coupleId: 'couple-b' });
|
||||||
|
await flush();
|
||||||
|
expect(refreshers.fetchMatches).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await tick();
|
||||||
|
expect(refreshers.fetchMatches).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-reads the user once when the profile moves, instead of the couple as well', async () => {
|
||||||
|
answer(baseline, {
|
||||||
|
...baseline,
|
||||||
|
profile_updated_at: '2026-08-29T00:00:00.000Z',
|
||||||
|
partner_updated_at: '2026-08-29T00:00:00.000Z',
|
||||||
|
});
|
||||||
|
renderHook(() => useCoupleSync('me', 'couple'));
|
||||||
|
await flush();
|
||||||
|
await tick();
|
||||||
|
expect(refreshers.fetchUser).toHaveBeenCalledTimes(1);
|
||||||
|
expect(refreshers.fetchCouple).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves views the user has never opened unloaded', async () => {
|
||||||
|
useMatchStore.setState({ loadedAt: {} });
|
||||||
|
answer(baseline, {
|
||||||
|
...baseline,
|
||||||
|
match_count: 9,
|
||||||
|
latest_match_at: '2026-08-29T00:00:00.000Z',
|
||||||
|
pending_yours: 7,
|
||||||
|
pending_theirs: 8,
|
||||||
|
});
|
||||||
|
renderHook(() => useCoupleSync('me', 'couple'));
|
||||||
|
await flush();
|
||||||
|
await tick();
|
||||||
|
expect(refreshers.fetchMatches).not.toHaveBeenCalled();
|
||||||
|
expect(refreshers.fetchPendingQuestions).not.toHaveBeenCalled();
|
||||||
|
expect(refreshers.fetchTheirTurnQuestions).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops entirely for a signed-out user and while the app is backgrounded', async () => {
|
||||||
|
answer(baseline);
|
||||||
|
const { unmount } = renderHook(() => useCoupleSync(undefined, null));
|
||||||
|
await flush();
|
||||||
|
await tick();
|
||||||
|
expect(appDataApi.syncSummary).not.toHaveBeenCalled();
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
renderHook(() => useCoupleSync('me', 'couple'));
|
||||||
|
await flush();
|
||||||
|
expect(appDataApi.syncSummary).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
mockForeground.value = false;
|
||||||
|
for (const listener of [...mockListeners]) listener(false);
|
||||||
|
});
|
||||||
|
await tick();
|
||||||
|
await tick();
|
||||||
|
expect(appDataApi.syncSummary).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('discards a summary that lands after the account it belongs to was switched away', async () => {
|
||||||
|
// Alice's poll is still open when Bob signs in. Its answer describes a
|
||||||
|
// couple Bob is not in, so none of it may land: not his badge, not the
|
||||||
|
// baseline his next poll is diffed against, not a refresh decision.
|
||||||
|
let answerAlice: (summary: SyncSummary) => void = () => undefined;
|
||||||
|
const mock = appDataApi.syncSummary as jest.Mock;
|
||||||
|
mock.mockReset();
|
||||||
|
mock.mockReturnValueOnce(new Promise<SyncSummary>(resolve => { answerAlice = resolve; }));
|
||||||
|
|
||||||
|
const { rerender } = renderHook(
|
||||||
|
({ userId, coupleId }: { userId: string; coupleId: string }) => useCoupleSync(userId, coupleId),
|
||||||
|
{ initialProps: { userId: 'alice', coupleId: 'couple-a' } },
|
||||||
|
);
|
||||||
|
await flush();
|
||||||
|
expect(mock).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
const bobBaseline: SyncSummary = { ...baseline, couple_id: 'couple-b', unread_total: 1, match_count: 2 };
|
||||||
|
mock.mockResolvedValue(bobBaseline);
|
||||||
|
rerender({ userId: 'bob', coupleId: 'couple-b' });
|
||||||
|
await flush();
|
||||||
|
expect(useMessageStore.getState().unreadCount).toBe(1);
|
||||||
|
|
||||||
|
// Alice's request finally answers, long after she stopped being the user.
|
||||||
|
await act(async () => {
|
||||||
|
answerAlice({ ...baseline, couple_id: 'couple-a', unread_total: 9, match_count: 42 });
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
expect(useMessageStore.getState().unreadCount).toBe(1);
|
||||||
|
|
||||||
|
// Bob's baseline is still Bob's: a poll identical to his own first one has
|
||||||
|
// nothing to refresh. Had Alice's answer been recorded as the baseline,
|
||||||
|
// this would read as a 40-match jump and refresh the Matches view.
|
||||||
|
await tick();
|
||||||
|
expect(useMessageStore.getState().unreadCount).toBe(1);
|
||||||
|
for (const refresh of Object.values(refreshers)) expect(refresh).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never runs two summaries at once', async () => {
|
||||||
|
let release: (summary: SyncSummary) => void = () => undefined;
|
||||||
|
(appDataApi.syncSummary as jest.Mock).mockReset();
|
||||||
|
(appDataApi.syncSummary as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
|
||||||
|
(appDataApi.syncSummary as jest.Mock).mockResolvedValue(baseline);
|
||||||
|
renderHook(() => useCoupleSync('me', 'couple'));
|
||||||
|
await flush();
|
||||||
|
await act(async () => { jest.advanceTimersByTime(SYNC_INTERVAL_MS * 4); });
|
||||||
|
expect(appDataApi.syncSummary).toHaveBeenCalledTimes(1);
|
||||||
|
await act(async () => { release(baseline); await Promise.resolve(); });
|
||||||
|
await tick();
|
||||||
|
expect(appDataApi.syncSummary).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { act, renderHook, waitFor } from "@testing-library/react-native";
|
import { act, renderHook, waitFor } from "@testing-library/react-native";
|
||||||
import { useDares } from "@/features/dares/hooks/useDares";
|
import { useDares } from "@/features/dares/hooks/useDares";
|
||||||
import { ApiError, apiClient } from "@/lib/apiClient";
|
import { ApiError, apiClient } from "@/lib/apiClient";
|
||||||
|
import type { UseDaresReturn } from "@/features/dares/hooks/useDares";
|
||||||
import type { SentDare } from "@/features/dares/types";
|
import type { SentDare } from "@/features/dares/types";
|
||||||
|
|
||||||
const incoming: SentDare = {
|
const incoming: SentDare = {
|
||||||
@@ -90,3 +91,80 @@ describe("useDares", () => {
|
|||||||
expect(result.current.error).toBe("offline");
|
expect(result.current.error).toBe("offline");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("useDares polling cost", () => {
|
||||||
|
/** Counts requests per endpoint and lets the active payload be swapped mid-test. */
|
||||||
|
function mockCounting(active: () => SentDare[]) {
|
||||||
|
const calls = { active: 0, history: 0, catalog: 0, stats: 0 };
|
||||||
|
jest.spyOn(apiClient, "get").mockImplementation(async (path: string) => {
|
||||||
|
if (path.startsWith("/v1/dares?filter=active")) { calls.active += 1; return { dares: active() } as never; }
|
||||||
|
if (path.startsWith("/v1/dares?filter=history")) { calls.history += 1; return { dares: [] } as never; }
|
||||||
|
if (path === "/v1/dares/packs") { calls.catalog += 1; return catalog as never; }
|
||||||
|
if (path === "/v1/dares/stats") { calls.stats += 1; return stats as never; }
|
||||||
|
throw new Error(`unexpected path ${path}`);
|
||||||
|
});
|
||||||
|
return calls;
|
||||||
|
}
|
||||||
|
|
||||||
|
const flush = async () => { await act(async () => { await Promise.resolve(); await Promise.resolve(); }); };
|
||||||
|
const tick = async () => {
|
||||||
|
await flush();
|
||||||
|
await act(async () => { jest.advanceTimersByTime(5_000); });
|
||||||
|
await flush();
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => { jest.restoreAllMocks(); jest.useFakeTimers(); });
|
||||||
|
afterEach(() => jest.useRealTimers());
|
||||||
|
|
||||||
|
it("polls the active list alone and re-reads the static endpoints only when it changes", async () => {
|
||||||
|
let current: SentDare[] = [incoming];
|
||||||
|
const calls = mockCounting(() => current);
|
||||||
|
const { result } = renderHook(() => useDares({ isFocused: true }));
|
||||||
|
await flush();
|
||||||
|
expect(result.current.loading).toBe(false);
|
||||||
|
// The first pass is a full load: nothing is cached yet.
|
||||||
|
expect(calls).toEqual({ active: 1, history: 1, catalog: 1, stats: 1 });
|
||||||
|
|
||||||
|
await tick();
|
||||||
|
await tick();
|
||||||
|
// Two quiet polls cost one request each, not four. The catalogue and the
|
||||||
|
// stats cannot move while the active set is byte-for-byte identical.
|
||||||
|
expect(calls).toEqual({ active: 3, history: 1, catalog: 1, stats: 1 });
|
||||||
|
|
||||||
|
current = [{ ...incoming, status: "active" }];
|
||||||
|
await tick();
|
||||||
|
expect(calls).toEqual({ active: 4, history: 2, catalog: 2, stats: 2 });
|
||||||
|
expect(result.current.active[0]?.status).toBe("active");
|
||||||
|
|
||||||
|
await tick();
|
||||||
|
expect(calls).toEqual({ active: 5, history: 2, catalog: 2, stats: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("issues nothing at all while the screen is not on top", async () => {
|
||||||
|
const calls = mockCounting(() => [incoming]);
|
||||||
|
const { rerender } = renderHook<UseDaresReturn, { isFocused: boolean }>(
|
||||||
|
({ isFocused }) => useDares({ isFocused }),
|
||||||
|
{ initialProps: { isFocused: false } },
|
||||||
|
);
|
||||||
|
await tick();
|
||||||
|
await tick();
|
||||||
|
expect(calls).toEqual({ active: 0, history: 0, catalog: 0, stats: 0 });
|
||||||
|
|
||||||
|
rerender({ isFocused: true });
|
||||||
|
await flush();
|
||||||
|
expect(calls).toEqual({ active: 1, history: 1, catalog: 1, stats: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never overlaps two polls", async () => {
|
||||||
|
let release: (payload: { dares: SentDare[] }) => void = () => undefined;
|
||||||
|
jest.spyOn(apiClient, "get").mockImplementation(async (path: string) => {
|
||||||
|
if (path.startsWith("/v1/dares?filter=active")) return new Promise(resolve => { release = resolve; }) as never;
|
||||||
|
return { dares: [], ...catalog, ...stats } as never;
|
||||||
|
});
|
||||||
|
renderHook(() => useDares({ isFocused: true }));
|
||||||
|
await flush();
|
||||||
|
await act(async () => { jest.advanceTimersByTime(20_000); });
|
||||||
|
expect((apiClient.get as jest.Mock).mock.calls.filter(([path]) => String(path).includes("filter=active"))).toHaveLength(1);
|
||||||
|
await act(async () => { release({ dares: [] }); await Promise.resolve(); });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -48,6 +48,60 @@ describe('useDrawingSync API polling', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('useDrawingSync focus gating', () => {
|
||||||
|
const handlers = {
|
||||||
|
onStrokeStart: jest.fn(), onStrokeContinue: jest.fn(), onStrokeEnd: jest.fn(),
|
||||||
|
onClearCanvas: jest.fn(), onUndo: jest.fn(), onRedo: jest.fn(),
|
||||||
|
};
|
||||||
|
beforeEach(() => { jest.clearAllMocks(); jest.useFakeTimers(); });
|
||||||
|
afterEach(() => jest.useRealTimers());
|
||||||
|
|
||||||
|
it('reads the canvas once on mount and then polls only while the screen is on top', async () => {
|
||||||
|
(appDataApi.getLiveDraw as jest.Mock).mockResolvedValue({ strokes: [], revision: 1, updated_at: 'one', updated_by: userId });
|
||||||
|
const onLoadStrokes = jest.fn();
|
||||||
|
const { rerender } = renderHook<unknown, { isFocused: boolean }>(({ isFocused }) => useDrawingSync({
|
||||||
|
coupleId: 'couple', userId, onLoadStrokes, isFocused, ...handlers,
|
||||||
|
}), { initialProps: { isFocused: false } });
|
||||||
|
|
||||||
|
await waitFor(() => expect(appDataApi.getLiveDraw).toHaveBeenCalledTimes(1));
|
||||||
|
// A canvas nobody is looking at costs nothing beyond the state it loaded with.
|
||||||
|
await act(async () => { jest.advanceTimersByTime(750 * 8); await Promise.resolve(); });
|
||||||
|
expect(appDataApi.getLiveDraw).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
rerender({ isFocused: true });
|
||||||
|
await act(async () => { jest.advanceTimersByTime(750); await Promise.resolve(); });
|
||||||
|
expect(appDataApi.getLiveDraw).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
rerender({ isFocused: false });
|
||||||
|
await act(async () => { jest.advanceTimersByTime(750 * 8); await Promise.resolve(); });
|
||||||
|
expect(appDataApi.getLiveDraw).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never polls over its own write in flight', async () => {
|
||||||
|
(appDataApi.getLiveDraw as jest.Mock).mockResolvedValue({ strokes: [], revision: 1, updated_at: 'one', updated_by: userId });
|
||||||
|
let release: (state: unknown) => void = () => undefined;
|
||||||
|
(appDataApi.putLiveDraw as jest.Mock).mockReturnValue(new Promise(resolve => { release = resolve; }));
|
||||||
|
const onLoadStrokes = jest.fn();
|
||||||
|
const { result } = renderHook(() => useDrawingSync({
|
||||||
|
coupleId: 'couple', userId, onLoadStrokes, isFocused: true, ...handlers,
|
||||||
|
}));
|
||||||
|
await waitFor(() => expect(appDataApi.getLiveDraw).toHaveBeenCalledTimes(1));
|
||||||
|
|
||||||
|
const pending = result.current.persistStrokes([stroke]);
|
||||||
|
await act(async () => { jest.advanceTimersByTime(750 * 4); await Promise.resolve(); });
|
||||||
|
// The write returns the authoritative state; reading over the top of it would
|
||||||
|
// only race a revision we are about to replace.
|
||||||
|
expect(appDataApi.getLiveDraw).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
release({ strokes: [stroke], revision: 2, updated_at: 'two', updated_by: userId });
|
||||||
|
await pending;
|
||||||
|
});
|
||||||
|
await act(async () => { jest.advanceTimersByTime(750); await Promise.resolve(); });
|
||||||
|
expect(appDataApi.getLiveDraw).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('useDrawingSync stroke deduplication', () => {
|
describe('useDrawingSync stroke deduplication', () => {
|
||||||
const at = (id: string, points: number, userId = 'u1') => ({
|
const at = (id: string, points: number, userId = 'u1') => ({
|
||||||
id, userId, points: Array.from({ length: points }, (_, i) => ({ x: i, y: i })),
|
id, userId, points: Array.from({ length: points }, (_, i) => ({ x: i, y: i })),
|
||||||
|
|||||||
@@ -4,85 +4,183 @@ import { chatApi } from '@/lib/chatApi';
|
|||||||
|
|
||||||
jest.mock('@/lib/chatApi', () => ({ chatApi: { listMessages: jest.fn(), markRead: jest.fn() } }));
|
jest.mock('@/lib/chatApi', () => ({ chatApi: { listMessages: jest.fn(), markRead: jest.fn() } }));
|
||||||
|
|
||||||
const message = (id: string, user_id = 'partner') => ({ id, match_id: 'match1', user_id, content: id } as any);
|
const message = (id: string, user_id = 'partner', overrides: Record<string, unknown> = {}) => ({
|
||||||
|
id, match_id: 'match1', user_id, content: id,
|
||||||
|
created_at: '2026-08-27T12:00:00Z', read_at: null, ...overrides,
|
||||||
|
} as any);
|
||||||
|
|
||||||
describe('useMessageSubscription API polling', () => {
|
const snapshot = (messages: any[], server_time = 'cursor-1') => ({ messages, removed_ids: [], server_time, complete: true });
|
||||||
|
const delta = (messages: any[], options: { removed?: string[]; server_time?: string; typing?: boolean } = {}) => ({
|
||||||
|
messages,
|
||||||
|
removed_ids: options.removed ?? [],
|
||||||
|
server_time: options.server_time ?? 'cursor-2',
|
||||||
|
complete: false,
|
||||||
|
...(options.typing === undefined ? {} : { typing: { typing: options.typing, expires_at: null } }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const listMessages = chatApi.listMessages as jest.Mock;
|
||||||
|
const markRead = chatApi.markRead as jest.Mock;
|
||||||
|
|
||||||
|
describe('useMessageSubscription incremental polling', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
jest.useFakeTimers();
|
jest.useFakeTimers();
|
||||||
(chatApi.listMessages as jest.Mock).mockResolvedValue({ messages: [] });
|
listMessages.mockResolvedValue(snapshot([]));
|
||||||
(chatApi.markRead as jest.Mock).mockResolvedValue({ updated: 0, read_at: 'now' });
|
markRead.mockResolvedValue({ updated: 0, read_at: 'now' });
|
||||||
});
|
});
|
||||||
afterEach(() => jest.useRealTimers());
|
afterEach(() => jest.useRealTimers());
|
||||||
|
|
||||||
it('fails closed without both a match and authenticated user', async () => {
|
const mount = (config: Partial<Parameters<typeof useMessageSubscription>[0]> = {}) =>
|
||||||
const { result, rerender } = renderHook<ReturnType<typeof useMessageSubscription>, { matchId: string | undefined; userId: string | undefined }>(
|
renderHook(() => useMessageSubscription({ matchId: 'match1', userId: 'me', pollInterval: 500, ...config }));
|
||||||
|
|
||||||
|
it('fails closed without both match and authenticated user', () => {
|
||||||
|
const { rerender } = renderHook<ReturnType<typeof useMessageSubscription>, { matchId: string | undefined; userId: string | undefined }>(
|
||||||
({ matchId, userId }) => useMessageSubscription({ matchId, userId }),
|
({ matchId, userId }) => useMessageSubscription({ matchId, userId }),
|
||||||
{ initialProps: { matchId: undefined as string | undefined, userId: 'me' as string | undefined } },
|
{ initialProps: { matchId: undefined as string | undefined, userId: 'me' as string | undefined } },
|
||||||
);
|
);
|
||||||
expect(result.current.loading).toBe(false);
|
expect(listMessages).not.toHaveBeenCalled();
|
||||||
expect(chatApi.listMessages).not.toHaveBeenCalled();
|
|
||||||
rerender({ matchId: 'match1', userId: undefined });
|
rerender({ matchId: 'match1', userId: undefined });
|
||||||
expect(chatApi.listMessages).not.toHaveBeenCalled();
|
expect(listMessages).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('loads initial messages without treating them as newly arrived', async () => {
|
it('loads a snapshot first, then requests only changes since the server cursor', async () => {
|
||||||
|
listMessages.mockResolvedValueOnce(snapshot([message('m1')], 'cursor-1'));
|
||||||
|
const { result } = mount();
|
||||||
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
|
expect(listMessages).toHaveBeenNthCalledWith(1, 'match1', { typing: true });
|
||||||
|
|
||||||
|
await act(async () => { jest.advanceTimersByTime(500); });
|
||||||
|
await waitFor(() => expect(listMessages).toHaveBeenCalledTimes(2));
|
||||||
|
expect(listMessages).toHaveBeenNthCalledWith(2, 'match1', { since: 'cursor-1', typing: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps cached messages, and their identity, when a delta carries nothing', async () => {
|
||||||
|
listMessages.mockResolvedValueOnce(snapshot([message('m1', 'partner', { read_at: 'seen' })]));
|
||||||
|
listMessages.mockResolvedValue(delta([]));
|
||||||
|
const { result } = mount();
|
||||||
|
await waitFor(() => expect(result.current.messages).toHaveLength(1));
|
||||||
|
const cached = result.current.messages;
|
||||||
|
|
||||||
|
await act(async () => { jest.advanceTimersByTime(500); });
|
||||||
|
await waitFor(() => expect(listMessages).toHaveBeenCalledTimes(2));
|
||||||
|
expect(result.current.messages).toBe(cached);
|
||||||
|
expect(result.current.loading).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('merges delta rows by id, applies self-deletions, and keeps newest first', async () => {
|
||||||
|
listMessages.mockResolvedValueOnce(snapshot([
|
||||||
|
message('m2', 'me', { created_at: '2026-08-27T12:00:02Z' }),
|
||||||
|
message('m1', 'partner', { created_at: '2026-08-27T12:00:01Z', read_at: 'seen' }),
|
||||||
|
]));
|
||||||
|
listMessages.mockResolvedValueOnce(delta([
|
||||||
|
message('m3', 'partner', { created_at: '2026-08-27T12:00:03Z', read_at: 'seen' }),
|
||||||
|
message('m1', 'partner', { created_at: '2026-08-27T12:00:01Z', read_at: 'seen', deleted_at: 'gone' }),
|
||||||
|
], { removed: ['m2'] }));
|
||||||
|
const { result } = mount();
|
||||||
|
await waitFor(() => expect(result.current.messages).toHaveLength(2));
|
||||||
|
|
||||||
|
await act(async () => { jest.advanceTimersByTime(500); });
|
||||||
|
await waitFor(() => expect(result.current.messages).toHaveLength(2));
|
||||||
|
expect(result.current.messages.map(m => m.id)).toEqual(['m3', 'm1']);
|
||||||
|
expect(result.current.messages[1]?.deleted_at).toBe('gone');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks read only when the response holds an unread partner message', async () => {
|
||||||
|
listMessages.mockResolvedValueOnce(snapshot([message('m1', 'partner')]));
|
||||||
|
listMessages.mockResolvedValueOnce(delta([message('m2', 'me')]));
|
||||||
|
listMessages.mockResolvedValueOnce(delta([message('m1', 'partner', { read_at: 'seen' })]));
|
||||||
|
const { result } = mount();
|
||||||
|
await waitFor(() => expect(markRead).toHaveBeenCalledTimes(1));
|
||||||
|
|
||||||
|
await act(async () => { jest.advanceTimersByTime(500); });
|
||||||
|
await waitFor(() => expect(listMessages).toHaveBeenCalledTimes(2));
|
||||||
|
await act(async () => { jest.advanceTimersByTime(500); });
|
||||||
|
await waitFor(() => expect(listMessages).toHaveBeenCalledTimes(3));
|
||||||
|
expect(markRead).toHaveBeenCalledTimes(1);
|
||||||
|
expect(result.current.messages).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('issues no request at all while the chat is not focused', async () => {
|
||||||
|
const { rerender } = renderHook<ReturnType<typeof useMessageSubscription>, { focused: boolean }>(
|
||||||
|
({ focused }) => useMessageSubscription({ matchId: 'match1', userId: 'me', pollInterval: 500, isFocused: focused }),
|
||||||
|
{ initialProps: { focused: false } },
|
||||||
|
);
|
||||||
|
await act(async () => { jest.advanceTimersByTime(2_000); });
|
||||||
|
expect(listMessages).not.toHaveBeenCalled();
|
||||||
|
expect(markRead).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
rerender({ focused: true });
|
||||||
|
await waitFor(() => expect(listMessages).toHaveBeenCalledTimes(1));
|
||||||
|
|
||||||
|
rerender({ focused: false });
|
||||||
|
const callsAtBlur = listMessages.mock.calls.length;
|
||||||
|
await act(async () => { jest.advanceTimersByTime(2_000); });
|
||||||
|
expect(listMessages).toHaveBeenCalledTimes(callsAtBlur);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports partner arrivals once, and never for the initial load or own messages', async () => {
|
||||||
const onNewMessage = jest.fn();
|
const onNewMessage = jest.fn();
|
||||||
(chatApi.listMessages as jest.Mock).mockResolvedValue({ messages: [message('m1')] });
|
listMessages.mockResolvedValueOnce(snapshot([message('m1', 'partner', { read_at: 'seen' })]));
|
||||||
const { result } = renderHook(() => useMessageSubscription({ matchId: 'match1', userId: 'me', onNewMessage }));
|
listMessages.mockResolvedValueOnce(delta([message('mine', 'me')]));
|
||||||
|
listMessages.mockResolvedValueOnce(delta([message('m2', 'partner')]));
|
||||||
|
listMessages.mockResolvedValueOnce(delta([message('m2', 'partner', { read_at: 'seen' })]));
|
||||||
|
const { result } = mount({ onNewMessage });
|
||||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
expect(result.current.messages).toEqual([message('m1')]);
|
|
||||||
expect(onNewMessage).not.toHaveBeenCalled();
|
expect(onNewMessage).not.toHaveBeenCalled();
|
||||||
expect(chatApi.markRead).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('refreshes on the polling interval, marks focused messages read, and reports new partner messages', async () => {
|
for (const expected of [2, 3, 4]) {
|
||||||
const onNewMessage = jest.fn();
|
await act(async () => { jest.advanceTimersByTime(500); });
|
||||||
(chatApi.listMessages as jest.Mock)
|
await waitFor(() => expect(listMessages).toHaveBeenCalledTimes(expected));
|
||||||
.mockResolvedValueOnce({ messages: [message('m1')] })
|
}
|
||||||
.mockResolvedValueOnce({ messages: [message('m2'), message('m1')] });
|
|
||||||
const { result } = renderHook(() => useMessageSubscription({ matchId: 'match1', userId: 'me', onNewMessage, pollInterval: 500 }));
|
|
||||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
|
||||||
act(() => { result.current.isFocusedRef.current = true; });
|
|
||||||
await act(async () => { jest.advanceTimersByTime(500); await Promise.resolve(); });
|
|
||||||
await waitFor(() => expect(result.current.messages[0]?.id).toBe('m2'));
|
|
||||||
expect(chatApi.markRead).toHaveBeenCalledWith('match1');
|
|
||||||
expect(onNewMessage).toHaveBeenCalledTimes(1);
|
expect(onNewMessage).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not notify for the current user own newly polled message', async () => {
|
it('reads partner typing state out of the message response', async () => {
|
||||||
const onNewMessage = jest.fn();
|
listMessages.mockResolvedValueOnce({ ...snapshot([]), typing: { typing: true, expires_at: 'later' } });
|
||||||
(chatApi.listMessages as jest.Mock)
|
const { result } = mount();
|
||||||
.mockResolvedValueOnce({ messages: [] })
|
await waitFor(() => expect(result.current.partnerTyping).toBe(true));
|
||||||
.mockResolvedValueOnce({ messages: [message('mine', 'me')] });
|
|
||||||
const { result } = renderHook(() => useMessageSubscription({ matchId: 'match1', userId: 'me', onNewMessage, pollInterval: 500 }));
|
listMessages.mockResolvedValueOnce(delta([], { typing: false }));
|
||||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
await act(async () => { jest.advanceTimersByTime(500); });
|
||||||
await act(async () => { jest.advanceTimersByTime(500); await Promise.resolve(); });
|
await waitFor(() => expect(result.current.partnerTyping).toBe(false));
|
||||||
expect(onNewMessage).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('surfaces loading completion and preserves a usable empty state after an API error', async () => {
|
it('surfaces loading completion and preserves a usable empty state after an API error', async () => {
|
||||||
(chatApi.listMessages as jest.Mock).mockRejectedValueOnce(new Error('forbidden'));
|
listMessages.mockRejectedValueOnce(new Error('forbidden'));
|
||||||
const { result } = renderHook(() => useMessageSubscription({ matchId: 'match1', userId: 'me' }));
|
const { result } = mount();
|
||||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
expect(result.current.messages).toEqual([]);
|
expect(result.current.messages).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('prevents overlapping polls and clears the interval on unmount', async () => {
|
it('prevents overlapping polls and stops on unmount', async () => {
|
||||||
let resolveFirst!: (value: { messages: any[] }) => void;
|
let resolveFirst!: (value: unknown) => void;
|
||||||
(chatApi.listMessages as jest.Mock).mockImplementationOnce(() => new Promise(resolve => { resolveFirst = resolve; }));
|
listMessages.mockImplementationOnce(() => new Promise(resolve => { resolveFirst = resolve; }));
|
||||||
const { unmount } = renderHook(() => useMessageSubscription({ matchId: 'match1', userId: 'me', pollInterval: 500 }));
|
const { unmount } = mount();
|
||||||
expect(chatApi.listMessages).toHaveBeenCalledTimes(1);
|
expect(listMessages).toHaveBeenCalledTimes(1);
|
||||||
act(() => { jest.advanceTimersByTime(1_500); });
|
await act(async () => { jest.advanceTimersByTime(1_500); });
|
||||||
expect(chatApi.listMessages).toHaveBeenCalledTimes(1);
|
expect(listMessages).toHaveBeenCalledTimes(1);
|
||||||
await act(async () => resolveFirst({ messages: [] }));
|
|
||||||
|
await act(async () => { resolveFirst(snapshot([])); });
|
||||||
unmount();
|
unmount();
|
||||||
act(() => { jest.advanceTimersByTime(2_000); });
|
await act(async () => { jest.advanceTimersByTime(2_000); });
|
||||||
expect(chatApi.listMessages).toHaveBeenCalledTimes(1);
|
expect(listMessages).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restarts immediately, and without a stale cursor, for a different match', async () => {
|
||||||
|
listMessages.mockResolvedValue(snapshot([message('m1')], 'cursor-1'));
|
||||||
|
const { result, rerender } = renderHook<ReturnType<typeof useMessageSubscription>, { matchId: string }>(
|
||||||
|
({ matchId }) => useMessageSubscription({ matchId, userId: 'me', pollInterval: 500 }),
|
||||||
|
{ initialProps: { matchId: 'match1' } },
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(result.current.messages).toHaveLength(1));
|
||||||
|
|
||||||
|
rerender({ matchId: 'match2' });
|
||||||
|
await waitFor(() => expect(listMessages).toHaveBeenCalledTimes(2));
|
||||||
|
expect(listMessages).toHaveBeenNthCalledWith(2, 'match2', { typing: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('supports optimistic external updates', async () => {
|
it('supports optimistic external updates', async () => {
|
||||||
const { result } = renderHook(() => useMessageSubscription({ matchId: 'match1', userId: 'me' }));
|
const { result } = mount();
|
||||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
act(() => result.current.setMessages([message('optimistic', 'me')]));
|
act(() => result.current.setMessages([message('optimistic', 'me')]));
|
||||||
expect(result.current.messages[0]?.id).toBe('optimistic');
|
expect(result.current.messages[0]?.id).toBe('optimistic');
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { act, renderHook } from '@testing-library/react-native';
|
||||||
|
import { usePolling } from '@/hooks/usePolling';
|
||||||
|
|
||||||
|
const mockForeground = { value: true };
|
||||||
|
const mockListeners = new Set<(foreground: boolean) => void>();
|
||||||
|
|
||||||
|
jest.mock('@/lib/appForeground', () => ({
|
||||||
|
isForeground: () => mockForeground.value,
|
||||||
|
subscribeToForeground: (listener: (foreground: boolean) => void) => {
|
||||||
|
mockListeners.add(listener);
|
||||||
|
return () => { mockListeners.delete(listener); };
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const setForeground = async (value: boolean) => {
|
||||||
|
await act(async () => {
|
||||||
|
mockForeground.value = value;
|
||||||
|
for (const listener of [...mockListeners]) listener(value);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
// The next run is scheduled once the previous one settles, so let pending
|
||||||
|
// promises land before and after moving the clock.
|
||||||
|
const flush = async () => { await act(async () => { await Promise.resolve(); await Promise.resolve(); }); };
|
||||||
|
const tick = async (ms: number) => {
|
||||||
|
await flush();
|
||||||
|
await act(async () => { jest.advanceTimersByTime(ms); });
|
||||||
|
await flush();
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('usePolling', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
jest.useFakeTimers();
|
||||||
|
mockForeground.value = true;
|
||||||
|
mockListeners.clear();
|
||||||
|
});
|
||||||
|
afterEach(() => jest.useRealTimers());
|
||||||
|
|
||||||
|
it('runs once immediately and then on the interval', async () => {
|
||||||
|
const task = jest.fn(async () => undefined);
|
||||||
|
renderHook(() => usePolling(task, { intervalMs: 100 }));
|
||||||
|
expect(task).toHaveBeenCalledTimes(1);
|
||||||
|
await tick(100);
|
||||||
|
expect(task).toHaveBeenCalledTimes(2);
|
||||||
|
await tick(100);
|
||||||
|
expect(task).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips the leading run when asked to', async () => {
|
||||||
|
const task = jest.fn(async () => undefined);
|
||||||
|
renderHook(() => usePolling(task, { intervalMs: 100, leading: false }));
|
||||||
|
expect(task).not.toHaveBeenCalled();
|
||||||
|
await tick(100);
|
||||||
|
expect(task).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('issues nothing while disabled and starts once enabled', async () => {
|
||||||
|
const task = jest.fn(async () => undefined);
|
||||||
|
const { rerender } = renderHook<void, { enabled: boolean }>(({ enabled }) => usePolling(task, { intervalMs: 100, enabled }), {
|
||||||
|
initialProps: { enabled: false },
|
||||||
|
});
|
||||||
|
await tick(1_000);
|
||||||
|
expect(task).not.toHaveBeenCalled();
|
||||||
|
rerender({ enabled: true });
|
||||||
|
expect(task).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops while the app is backgrounded and catches up on return', async () => {
|
||||||
|
const task = jest.fn(async () => undefined);
|
||||||
|
renderHook(() => usePolling(task, { intervalMs: 100 }));
|
||||||
|
expect(task).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
await setForeground(false);
|
||||||
|
await tick(1_000);
|
||||||
|
expect(task).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
await setForeground(true);
|
||||||
|
expect(task).toHaveBeenCalledTimes(2);
|
||||||
|
await tick(100);
|
||||||
|
expect(task).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never runs two tasks concurrently', async () => {
|
||||||
|
let release!: () => void;
|
||||||
|
const task = jest.fn(() => new Promise<void>(resolve => { release = resolve; }));
|
||||||
|
renderHook(() => usePolling(task, { intervalMs: 100 }));
|
||||||
|
expect(task).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
await tick(500);
|
||||||
|
expect(task).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
await act(async () => { release(); });
|
||||||
|
await tick(100);
|
||||||
|
expect(task).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('backs off after consecutive failures and recovers after a success', async () => {
|
||||||
|
const task = jest.fn(async (): Promise<void> => { throw new Error('offline'); });
|
||||||
|
renderHook(() => usePolling(task, { intervalMs: 100, maxIntervalMs: 400 }));
|
||||||
|
expect(task).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
await tick(100);
|
||||||
|
expect(task).toHaveBeenCalledTimes(1);
|
||||||
|
await tick(100);
|
||||||
|
expect(task).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
await tick(399);
|
||||||
|
expect(task).toHaveBeenCalledTimes(2);
|
||||||
|
await tick(1);
|
||||||
|
expect(task).toHaveBeenCalledTimes(3);
|
||||||
|
|
||||||
|
task.mockImplementation(async () => undefined);
|
||||||
|
await tick(400);
|
||||||
|
expect(task).toHaveBeenCalledTimes(4);
|
||||||
|
await tick(100);
|
||||||
|
expect(task).toHaveBeenCalledTimes(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restarts immediately when the reset key changes', async () => {
|
||||||
|
const task = jest.fn(async () => undefined);
|
||||||
|
const { rerender } = renderHook<void, { resetKey: string }>(({ resetKey }) => usePolling(task, { intervalMs: 100, resetKey }), {
|
||||||
|
initialProps: { resetKey: 'a' },
|
||||||
|
});
|
||||||
|
expect(task).toHaveBeenCalledTimes(1);
|
||||||
|
rerender({ resetKey: 'b' });
|
||||||
|
expect(task).toHaveBeenCalledTimes(2);
|
||||||
|
await tick(50);
|
||||||
|
expect(task).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops and unsubscribes on unmount', async () => {
|
||||||
|
const task = jest.fn(async () => undefined);
|
||||||
|
const { unmount } = renderHook(() => usePolling(task, { intervalMs: 100 }));
|
||||||
|
await tick(100);
|
||||||
|
expect(task).toHaveBeenCalledTimes(2);
|
||||||
|
unmount();
|
||||||
|
expect(mockListeners.size).toBe(0);
|
||||||
|
await tick(1_000);
|
||||||
|
expect(task).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { act, renderHook } from '@testing-library/react-native';
|
||||||
|
import { Platform } from 'react-native';
|
||||||
|
|
||||||
|
import { useResponseMediaUpload } from '@/hooks/useResponseMediaUpload';
|
||||||
|
import { uploadMedia } from '@/lib/mediaApi';
|
||||||
|
|
||||||
|
jest.mock('@/lib/mediaApi', () => ({ uploadMedia: jest.fn() }));
|
||||||
|
|
||||||
|
const setPlatform = (os: string) => Object.defineProperty(Platform, 'OS', { configurable: true, value: os });
|
||||||
|
|
||||||
|
describe('useResponseMediaUpload', () => {
|
||||||
|
const originalPlatform = Platform.OS;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
setPlatform('ios');
|
||||||
|
jest.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||||
|
(uploadMedia as jest.Mock).mockResolvedValue({ reference: 'media:uploaded' });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => jest.restoreAllMocks());
|
||||||
|
afterAll(() => setPlatform(originalPlatform));
|
||||||
|
|
||||||
|
it('fails closed when identity context is incomplete', async () => {
|
||||||
|
const { result } = renderHook(() => useResponseMediaUpload({ userId: '', questionId: 'q1' }));
|
||||||
|
await expect(result.current.uploadPhoto('file://photo.jpg')).resolves.toEqual({
|
||||||
|
success: false,
|
||||||
|
error: 'Missing userId or questionId',
|
||||||
|
});
|
||||||
|
expect(uploadMedia).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['file://photo.jpeg', 'image/jpeg'],
|
||||||
|
['file://photo.png', 'image/png'],
|
||||||
|
['file://photo.unknown', 'image/jpeg'],
|
||||||
|
])('uploads native photo %s with %s', async (uri, mimeType) => {
|
||||||
|
const { result } = renderHook(() => useResponseMediaUpload({ userId: 'u1', questionId: 'q1' }));
|
||||||
|
let response: unknown;
|
||||||
|
await act(async () => { response = await result.current.uploadPhoto(uri); });
|
||||||
|
expect(response).toEqual({ success: true, mediaPath: 'media:uploaded' });
|
||||||
|
expect(uploadMedia).toHaveBeenCalledWith(uri, { kind: 'response', mimeType, questionId: 'q1' });
|
||||||
|
expect(result.current.uploading).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['file://voice.mp3', 'audio/mpeg'],
|
||||||
|
['file://voice.wav', 'audio/wav'],
|
||||||
|
['file://voice.aac', 'audio/aac'],
|
||||||
|
['file://voice.caf', 'audio/x-caf'],
|
||||||
|
['file://voice.unknown', 'audio/m4a'],
|
||||||
|
])('uploads native audio %s with %s', async (uri, mimeType) => {
|
||||||
|
const { result } = renderHook(() => useResponseMediaUpload({ userId: 'u1', questionId: 'q1' }));
|
||||||
|
await act(async () => { await result.current.uploadAudio(uri, 12); });
|
||||||
|
expect(uploadMedia).toHaveBeenCalledWith(uri, { kind: 'response', mimeType, questionId: 'q1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects web photo and audio MIME types', async () => {
|
||||||
|
setPlatform('web');
|
||||||
|
const fetchSpy = jest.spyOn(global, 'fetch')
|
||||||
|
.mockResolvedValueOnce({ blob: async () => ({ type: 'image/webp' }) } as Response)
|
||||||
|
.mockResolvedValueOnce({ blob: async () => ({ type: 'audio/webm' }) } as Response);
|
||||||
|
const { result } = renderHook(() => useResponseMediaUpload({ userId: 'u1', questionId: 'q1' }));
|
||||||
|
|
||||||
|
await act(async () => { await result.current.uploadPhoto('blob:photo'); });
|
||||||
|
expect(uploadMedia).toHaveBeenLastCalledWith('blob:photo', {
|
||||||
|
kind: 'response', mimeType: 'image/webp', questionId: 'q1',
|
||||||
|
});
|
||||||
|
await act(async () => { await result.current.uploadAudio('blob:audio', 3); });
|
||||||
|
expect(uploadMedia).toHaveBeenLastCalledWith('blob:audio', {
|
||||||
|
kind: 'response', mimeType: 'audio/webm', questionId: 'q1',
|
||||||
|
});
|
||||||
|
fetchSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the provider error and clears uploading state', async () => {
|
||||||
|
(uploadMedia as jest.Mock).mockRejectedValueOnce(new Error('upload failed'));
|
||||||
|
const { result } = renderHook(() => useResponseMediaUpload({ userId: 'u1', questionId: 'q1' }));
|
||||||
|
let response: unknown;
|
||||||
|
await act(async () => { response = await result.current.uploadPhoto('file://photo.jpg'); });
|
||||||
|
expect(response).toEqual({ success: false, error: 'upload failed' });
|
||||||
|
expect(result.current.uploading).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -76,4 +76,22 @@ describe('useTypingIndicator API polling', () => {
|
|||||||
act(() => { jest.advanceTimersByTime(2_000); });
|
act(() => { jest.advanceTimersByTime(2_000); });
|
||||||
expect(chatApi.getTyping).toHaveBeenCalledTimes(calls);
|
expect(chatApi.getTyping).toHaveBeenCalledTimes(calls);
|
||||||
});
|
});
|
||||||
|
it('stops polling and mirrors typing state supplied by the message poll', async () => {
|
||||||
|
const { result, rerender } = renderHook<ReturnType<typeof useTypingIndicator>, { externalTyping: boolean | undefined }>(
|
||||||
|
({ externalTyping }) => useTypingIndicator({ channelName: 'chat:match1', userId: 'me', pollInterval: 500, externalTyping }),
|
||||||
|
{ initialProps: { externalTyping: undefined as boolean | undefined } },
|
||||||
|
);
|
||||||
|
await act(async () => { await Promise.resolve(); });
|
||||||
|
const callsBeforeFolding = (chatApi.getTyping as jest.Mock).mock.calls.length;
|
||||||
|
|
||||||
|
rerender({ externalTyping: true });
|
||||||
|
expect(result.current.partnerTyping).toBe(true);
|
||||||
|
await act(async () => { jest.advanceTimersByTime(5_000); });
|
||||||
|
expect(chatApi.getTyping).toHaveBeenCalledTimes(callsBeforeFolding);
|
||||||
|
|
||||||
|
act(() => result.current.clearTypingIndicator());
|
||||||
|
expect(result.current.partnerTyping).toBe(false);
|
||||||
|
rerender({ externalTyping: false });
|
||||||
|
expect(result.current.partnerTyping).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { AppState } from 'react-native';
|
||||||
|
import { isForeground, isForegroundState, subscribeToForeground } from '@/lib/appForeground';
|
||||||
|
|
||||||
|
describe('appForeground', () => {
|
||||||
|
afterEach(() => jest.restoreAllMocks());
|
||||||
|
|
||||||
|
it('treats only background and inactive as away', () => {
|
||||||
|
expect(isForegroundState('active')).toBe(true);
|
||||||
|
expect(isForegroundState('background')).toBe(false);
|
||||||
|
expect(isForegroundState('inactive')).toBe(false);
|
||||||
|
// Unknown states — including whatever a test harness leaves behind — must
|
||||||
|
// fail towards running, never towards disabling recurring work forever.
|
||||||
|
expect(isForegroundState(undefined)).toBe(true);
|
||||||
|
expect(isForegroundState('extension')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads the current state and reports transitions until unsubscribed', () => {
|
||||||
|
let emit: (state: string) => void = () => undefined;
|
||||||
|
const remove = jest.fn();
|
||||||
|
jest.spyOn(AppState, 'addEventListener').mockImplementation(((_: string, handler: (state: string) => void) => {
|
||||||
|
emit = handler;
|
||||||
|
return { remove } as never;
|
||||||
|
}) as never);
|
||||||
|
|
||||||
|
expect(isForeground()).toBe(isForegroundState(AppState.currentState));
|
||||||
|
|
||||||
|
const seen: boolean[] = [];
|
||||||
|
const unsubscribe = subscribeToForeground(value => seen.push(value));
|
||||||
|
emit('background');
|
||||||
|
emit('active');
|
||||||
|
expect(seen).toEqual([false, true]);
|
||||||
|
|
||||||
|
unsubscribe();
|
||||||
|
expect(remove).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -24,6 +24,18 @@ describe('chatApi', () => {
|
|||||||
expect(apiClient.get).toHaveBeenNthCalledWith(2, '/v1/matches/match%2Fid/typing');
|
expect(apiClient.get).toHaveBeenNthCalledWith(2, '/v1/matches/match%2Fid/typing');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('encodes the delta cursor and folded typing request into the message query', async () => {
|
||||||
|
(apiClient.get as jest.Mock).mockResolvedValue({ messages: [], removed_ids: [], server_time: 'now', complete: false });
|
||||||
|
|
||||||
|
await chatApi.listMessages('match1', { typing: true });
|
||||||
|
await chatApi.listMessages('match1', { since: '2026-08-27T12:00:00.000+01:00' });
|
||||||
|
await chatApi.listMessages('match1', { since: '2026-08-27T12:00:00.000Z', typing: true });
|
||||||
|
|
||||||
|
expect(apiClient.get).toHaveBeenNthCalledWith(1, '/v1/matches/match1/messages?typing=true');
|
||||||
|
expect(apiClient.get).toHaveBeenNthCalledWith(2, '/v1/matches/match1/messages?since=2026-08-27T12%3A00%3A00.000%2B01%3A00');
|
||||||
|
expect(apiClient.get).toHaveBeenNthCalledWith(3, '/v1/matches/match1/messages?since=2026-08-27T12%3A00%3A00.000Z&typing=true');
|
||||||
|
});
|
||||||
|
|
||||||
it('uses authenticated unread, receipt, deletion and reporting routes', async () => {
|
it('uses authenticated unread, receipt, deletion and reporting routes', async () => {
|
||||||
(apiClient.get as jest.Mock).mockResolvedValue({ total: 0, by_match: {} });
|
(apiClient.get as jest.Mock).mockResolvedValue({ total: 0, by_match: {} });
|
||||||
(apiClient.post as jest.Mock).mockResolvedValue({});
|
(apiClient.post as jest.Mock).mockResolvedValue({});
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { Platform } from 'react-native';
|
||||||
|
import * as FileSystem from 'expo-file-system/legacy';
|
||||||
|
|
||||||
|
import {
|
||||||
|
cacheVideo,
|
||||||
|
clearExpiredUrls,
|
||||||
|
clearUrlCache,
|
||||||
|
clearVideoCache,
|
||||||
|
getCachedSignedUrl,
|
||||||
|
getStoragePath,
|
||||||
|
getVideoCachedUri,
|
||||||
|
getVideoCachePath,
|
||||||
|
getVideoCacheSize,
|
||||||
|
prefetchSignedUrls,
|
||||||
|
} from '@/lib/imageCache';
|
||||||
|
import { getMediaUrl, mediaId } from '@/lib/mediaApi';
|
||||||
|
|
||||||
|
jest.mock('@/lib/mediaApi', () => ({ getMediaUrl: jest.fn(), mediaId: jest.fn() }));
|
||||||
|
jest.mock('expo-file-system/legacy', () => ({
|
||||||
|
cacheDirectory: 'file:///cache/',
|
||||||
|
getInfoAsync: jest.fn(),
|
||||||
|
makeDirectoryAsync: jest.fn(),
|
||||||
|
downloadAsync: jest.fn(),
|
||||||
|
deleteAsync: jest.fn(),
|
||||||
|
readDirectoryAsync: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const setPlatform = (os: string) => Object.defineProperty(Platform, 'OS', { configurable: true, value: os });
|
||||||
|
|
||||||
|
describe('imageCache', () => {
|
||||||
|
const originalPlatform = Platform.OS;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
clearUrlCache();
|
||||||
|
setPlatform('ios');
|
||||||
|
jest.spyOn(Date, 'now').mockReturnValue(1_000_000);
|
||||||
|
jest.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||||
|
jest.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||||
|
jest.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||||
|
(mediaId as jest.Mock).mockReturnValue('id-1');
|
||||||
|
(getMediaUrl as jest.Mock).mockResolvedValue({
|
||||||
|
url: 'https://signed.example/media',
|
||||||
|
expires_at: new Date(2_000_000).toISOString(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => jest.restoreAllMocks());
|
||||||
|
afterAll(() => setPlatform(originalPlatform));
|
||||||
|
|
||||||
|
it('caches signed URLs and refreshes expired entries', async () => {
|
||||||
|
await expect(getCachedSignedUrl('media:id-1')).resolves.toBe('https://signed.example/media');
|
||||||
|
await expect(getCachedSignedUrl('media:id-1')).resolves.toBe('https://signed.example/media');
|
||||||
|
expect(getMediaUrl).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
jest.spyOn(Date, 'now').mockReturnValue(3_000_000);
|
||||||
|
clearExpiredUrls();
|
||||||
|
await getCachedSignedUrl('media:id-1');
|
||||||
|
expect(getMediaUrl).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves legacy URLs and extracts their storage paths', async () => {
|
||||||
|
(mediaId as jest.Mock).mockReturnValue(null);
|
||||||
|
await expect(getCachedSignedUrl('https://legacy.example/chat-media/folder/file.jpg'))
|
||||||
|
.resolves.toBe('https://legacy.example/chat-media/folder/file.jpg');
|
||||||
|
expect(getStoragePath('https://legacy.example/response-media/a/b.jpg')).toBe('a/b.jpg');
|
||||||
|
expect(getStoragePath('media:id')).toBe('media:id');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when signing fails and prefetches every path', async () => {
|
||||||
|
(getMediaUrl as jest.Mock).mockRejectedValueOnce(new Error('offline'));
|
||||||
|
await expect(getCachedSignedUrl('media:bad')).resolves.toBeNull();
|
||||||
|
await prefetchSignedUrls(['media:a', 'media:b']);
|
||||||
|
expect(getMediaUrl).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses safe cache paths and returns an existing cached video', async () => {
|
||||||
|
expect(getVideoCachePath('folder/a:b?.mp4')).toBe('file:///cache/video-cache/folder_a_b_.mp4');
|
||||||
|
(FileSystem.getInfoAsync as jest.Mock).mockResolvedValue({ exists: true });
|
||||||
|
await expect(getVideoCachedUri('video.mp4')).resolves.toBe('file:///cache/video-cache/video.mp4');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates the cache directory and downloads a video once', async () => {
|
||||||
|
(FileSystem.getInfoAsync as jest.Mock)
|
||||||
|
.mockResolvedValueOnce({ exists: false })
|
||||||
|
.mockResolvedValueOnce({ exists: false });
|
||||||
|
(FileSystem.downloadAsync as jest.Mock).mockResolvedValue({ status: 200 });
|
||||||
|
|
||||||
|
await expect(cacheVideo('video.mp4', 'https://signed/video')).resolves
|
||||||
|
.toBe('file:///cache/video-cache/video.mp4');
|
||||||
|
expect(FileSystem.makeDirectoryAsync).toHaveBeenCalledWith(
|
||||||
|
'file:///cache/video-cache/', { intermediates: true }
|
||||||
|
);
|
||||||
|
expect(FileSystem.downloadAsync).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles failed downloads and web without writing', async () => {
|
||||||
|
(FileSystem.getInfoAsync as jest.Mock).mockResolvedValue({ exists: false });
|
||||||
|
(FileSystem.downloadAsync as jest.Mock).mockResolvedValue({ status: 503 });
|
||||||
|
await expect(cacheVideo('video.mp4', 'https://signed/video')).resolves.toBeNull();
|
||||||
|
|
||||||
|
setPlatform('web');
|
||||||
|
await expect(getVideoCachedUri('video.mp4')).resolves.toBeNull();
|
||||||
|
await expect(cacheVideo('video.mp4', 'https://signed/video')).resolves.toBeNull();
|
||||||
|
await expect(getVideoCacheSize()).resolves.toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears videos and sums cached file sizes', async () => {
|
||||||
|
(FileSystem.getInfoAsync as jest.Mock)
|
||||||
|
.mockResolvedValueOnce({ exists: true })
|
||||||
|
.mockResolvedValueOnce({ exists: true })
|
||||||
|
.mockResolvedValueOnce({ exists: true, size: 12 })
|
||||||
|
.mockResolvedValueOnce({ exists: true, size: 8 });
|
||||||
|
(FileSystem.readDirectoryAsync as jest.Mock).mockResolvedValue(['a', 'b']);
|
||||||
|
|
||||||
|
await clearVideoCache();
|
||||||
|
expect(FileSystem.deleteAsync).toHaveBeenCalledWith(
|
||||||
|
'file:///cache/video-cache/', { idempotent: true }
|
||||||
|
);
|
||||||
|
await expect(getVideoCacheSize()).resolves.toBe(20);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { router } from 'expo-router';
|
||||||
|
import { handleNotificationResponse } from '@/lib/notifications';
|
||||||
|
import { useMatchStore } from '@/store/matchStore';
|
||||||
|
|
||||||
|
jest.mock('expo-router', () => ({
|
||||||
|
router: { push: jest.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('expo-notifications', () => ({
|
||||||
|
setNotificationHandler: jest.fn(),
|
||||||
|
addNotificationResponseReceivedListener: jest.fn(),
|
||||||
|
getLastNotificationResponseAsync: jest.fn(),
|
||||||
|
getPermissionsAsync: jest.fn(),
|
||||||
|
requestPermissionsAsync: jest.fn(),
|
||||||
|
getExpoPushTokenAsync: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('@/lib/crashlytics', () => ({
|
||||||
|
captureError: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const notificationResponse = (type: string) => ({
|
||||||
|
notification: {
|
||||||
|
request: {
|
||||||
|
content: { data: { type } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}) as never;
|
||||||
|
|
||||||
|
describe('handleNotificationResponse', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useMatchStore.setState({ currentView: 'active' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens the answer queue for a catch-up reminder', () => {
|
||||||
|
handleNotificationResponse(notificationResponse('catchup_reminder'));
|
||||||
|
|
||||||
|
expect(useMatchStore.getState().currentView).toBe('pending');
|
||||||
|
expect(router.push).toHaveBeenCalledWith('/(app)/swipe?mode=pending');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores unknown notification types', () => {
|
||||||
|
handleNotificationResponse(notificationResponse('unknown'));
|
||||||
|
|
||||||
|
expect(router.push).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -45,9 +45,18 @@ describe('matchStore API behavior', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('guards concurrent loads and exhausted pagination', async () => {
|
it('guards concurrent loads and exhausted pagination', async () => {
|
||||||
useMatchStore.setState({ isLoading: true });
|
// Overlap protection cannot key off the loading flags any more: a silent
|
||||||
await useMatchStore.getState().fetchMatches(true);
|
// refresh raises none of them and still must not issue a second request.
|
||||||
useMatchStore.setState({ isLoading: false, hasMore: false });
|
let release: (page: { matches: unknown[]; totalCount: number }) => void = () => undefined;
|
||||||
|
(apiClient.get as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
|
||||||
|
const inFlight = useMatchStore.getState().fetchMatches(true);
|
||||||
|
await useMatchStore.getState().fetchMatches(true, { silent: true });
|
||||||
|
expect(apiClient.get).toHaveBeenCalledTimes(1);
|
||||||
|
release({ matches: [], totalCount: 0 });
|
||||||
|
await inFlight;
|
||||||
|
|
||||||
|
(apiClient.get as jest.Mock).mockClear();
|
||||||
|
useMatchStore.setState({ hasMore: false });
|
||||||
await useMatchStore.getState().fetchMatches(false);
|
await useMatchStore.getState().fetchMatches(false);
|
||||||
expect(apiClient.get).not.toHaveBeenCalled();
|
expect(apiClient.get).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -147,7 +156,10 @@ describe('matchStore API behavior', () => {
|
|||||||
it('toggles archived visibility and triggers an initial fetch only', async () => {
|
it('toggles archived visibility and triggers an initial fetch only', async () => {
|
||||||
(apiClient.get as jest.Mock).mockResolvedValue({ matches: [], totalCount: 0 });
|
(apiClient.get as jest.Mock).mockResolvedValue({ matches: [], totalCount: 0 });
|
||||||
useMatchStore.getState().toggleShowArchived();
|
useMatchStore.getState().toggleShowArchived();
|
||||||
await Promise.resolve();
|
// Let the floating fetch settle: its in-flight guard is module-scoped and
|
||||||
|
// is only released in a finally, so a half-run request would leak into
|
||||||
|
// the next test and silently suppress its archived fetch.
|
||||||
|
await jest.runOnlyPendingTimersAsync();
|
||||||
expect(useMatchStore.getState().showArchived).toBe(true);
|
expect(useMatchStore.getState().showArchived).toBe(true);
|
||||||
expect(apiClient.get).toHaveBeenCalledTimes(1);
|
expect(apiClient.get).toHaveBeenCalledTimes(1);
|
||||||
useMatchStore.getState().toggleShowArchived();
|
useMatchStore.getState().toggleShowArchived();
|
||||||
@@ -178,11 +190,19 @@ describe('matchStore API behavior', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('updates current view and delegates an empty view fetch', () => {
|
it('updates current view and delegates an empty view fetch', () => {
|
||||||
|
const real = useMatchStore.getState().fetchArchivedMatches;
|
||||||
const fetchArchivedMatches = jest.fn(async () => undefined);
|
const fetchArchivedMatches = jest.fn(async () => undefined);
|
||||||
useMatchStore.setState({ fetchArchivedMatches });
|
useMatchStore.setState({ fetchArchivedMatches });
|
||||||
useMatchStore.getState().setCurrentView('archived');
|
try {
|
||||||
expect(useMatchStore.getState()).toMatchObject({ currentView: 'archived', showArchived: true });
|
useMatchStore.getState().setCurrentView('archived');
|
||||||
expect(fetchArchivedMatches).toHaveBeenCalled();
|
expect(useMatchStore.getState()).toMatchObject({ currentView: 'archived', showArchived: true });
|
||||||
|
expect(fetchArchivedMatches).toHaveBeenCalled();
|
||||||
|
} finally {
|
||||||
|
// Actions live in the same store as the data, and clearMatches does not
|
||||||
|
// reset them: leaving the stub in place would silence every later test's
|
||||||
|
// archived fetch.
|
||||||
|
useMatchStore.setState({ fetchArchivedMatches: real });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -225,6 +245,222 @@ describe('matchStore API behavior', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('view switching and cache freshness', () => {
|
||||||
|
/** Answers each of the four view endpoints with an empty payload. */
|
||||||
|
const emptyViews = () => (apiClient.get as jest.Mock).mockImplementation(async (path: string) => {
|
||||||
|
if (path.startsWith('/v1/matches?archived=true')) return { matches: [], totalCount: 0 };
|
||||||
|
if (path.startsWith('/v1/matches')) return { matches: [], totalCount: 0, hasMore: false };
|
||||||
|
return { questions: [] };
|
||||||
|
});
|
||||||
|
|
||||||
|
it('switches between loaded views without a single extra request, even when every view is empty', async () => {
|
||||||
|
emptyViews();
|
||||||
|
const views = ['pending', 'their_turn', 'active', 'archived'] as const;
|
||||||
|
|
||||||
|
// First visit to each view loads it once.
|
||||||
|
for (const view of views) {
|
||||||
|
useMatchStore.getState().setCurrentView(view);
|
||||||
|
await jest.runOnlyPendingTimersAsync();
|
||||||
|
}
|
||||||
|
expect(apiClient.get).toHaveBeenCalledTimes(views.length);
|
||||||
|
expect(useMatchStore.getState()).toMatchObject({
|
||||||
|
matches: [], pendingQuestions: [], theirTurnQuestions: [], archivedMatches: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Every subsequent switch is served from cache. An empty view is loaded,
|
||||||
|
// not unloaded: this is the bug where `array.length === 0` meant "never
|
||||||
|
// fetched" and made every visit pay for the same empty answer.
|
||||||
|
(apiClient.get as jest.Mock).mockClear();
|
||||||
|
for (const view of [...views, ...views].reverse()) {
|
||||||
|
useMatchStore.getState().setCurrentView(view);
|
||||||
|
await jest.runOnlyPendingTimersAsync();
|
||||||
|
}
|
||||||
|
expect(apiClient.get).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps cached rows and raises no foreground spinner during a silent refresh', async () => {
|
||||||
|
(apiClient.get as jest.Mock).mockResolvedValueOnce({ matches: [match('cached', false)], totalCount: 1, hasMore: false });
|
||||||
|
await useMatchStore.getState().fetchMatches(true);
|
||||||
|
expect(useMatchStore.getState().matches.map(item => item.id)).toEqual(['cached']);
|
||||||
|
|
||||||
|
let release: (page: unknown) => void = () => undefined;
|
||||||
|
(apiClient.get as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
|
||||||
|
const refreshing = useMatchStore.getState().fetchMatches(true, { silent: true });
|
||||||
|
|
||||||
|
// Mid-flight: the user still sees the rows they had, and neither the
|
||||||
|
// full-screen loader nor the pull-to-refresh spinner is showing.
|
||||||
|
expect(useMatchStore.getState()).toMatchObject({ isLoading: false, isRefreshing: false });
|
||||||
|
expect(useMatchStore.getState().matches.map(item => item.id)).toEqual(['cached']);
|
||||||
|
|
||||||
|
release({ matches: [match('fresh', false)], totalCount: 1, hasMore: false });
|
||||||
|
await refreshing;
|
||||||
|
expect(useMatchStore.getState().matches.map(item => item.id)).toEqual(['fresh']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the pull-to-refresh spinner for a user-initiated refresh but never the initial loader', async () => {
|
||||||
|
(apiClient.get as jest.Mock).mockResolvedValueOnce({ matches: [match('cached', false)], totalCount: 1, hasMore: false });
|
||||||
|
await useMatchStore.getState().fetchMatches(true);
|
||||||
|
|
||||||
|
let release: (page: unknown) => void = () => undefined;
|
||||||
|
(apiClient.get as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
|
||||||
|
const refreshing = useMatchStore.getState().fetchMatches(true);
|
||||||
|
expect(useMatchStore.getState()).toMatchObject({ isLoading: false, isRefreshing: true });
|
||||||
|
expect(useMatchStore.getState().matches.map(item => item.id)).toEqual(['cached']);
|
||||||
|
release({ matches: [], totalCount: 0, hasMore: false });
|
||||||
|
await refreshing;
|
||||||
|
expect(useMatchStore.getState()).toMatchObject({ isRefreshing: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a silent list refresh out of the pending and their-turn loaders', async () => {
|
||||||
|
(apiClient.get as jest.Mock).mockResolvedValue({ questions: [pending('p1')] });
|
||||||
|
await useMatchStore.getState().fetchPendingQuestions();
|
||||||
|
await useMatchStore.getState().fetchTheirTurnQuestions();
|
||||||
|
|
||||||
|
let release: (page: unknown) => void = () => undefined;
|
||||||
|
(apiClient.get as jest.Mock).mockReturnValue(new Promise(resolve => { release = resolve; }));
|
||||||
|
const quiet = Promise.all([
|
||||||
|
useMatchStore.getState().fetchPendingQuestions({ silent: true }),
|
||||||
|
useMatchStore.getState().fetchTheirTurnQuestions({ silent: true }),
|
||||||
|
]);
|
||||||
|
expect(useMatchStore.getState()).toMatchObject({ isLoadingPending: false, isLoadingTheirTurn: false });
|
||||||
|
expect(useMatchStore.getState().pendingQuestions).toHaveLength(1);
|
||||||
|
release({ questions: [] });
|
||||||
|
await quiet;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('account isolation (generation tokens)', () => {
|
||||||
|
it('does not let a stale fetchMatches response populate the store after sign-out, and lets the next account start immediately', async () => {
|
||||||
|
let release: (page: unknown) => void = () => undefined;
|
||||||
|
(apiClient.get as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
|
||||||
|
const staleFetch = useMatchStore.getState().fetchMatches(true);
|
||||||
|
|
||||||
|
// Simulates sign-out/account switch while the request is still in flight.
|
||||||
|
useMatchStore.getState().clearMatches();
|
||||||
|
release({ matches: [match('stale')], totalCount: 1, hasMore: false });
|
||||||
|
await staleFetch;
|
||||||
|
|
||||||
|
expect(useMatchStore.getState().matches).toEqual([]);
|
||||||
|
|
||||||
|
(apiClient.get as jest.Mock).mockResolvedValueOnce({ matches: [match('fresh')], totalCount: 1, hasMore: false });
|
||||||
|
await useMatchStore.getState().fetchMatches(true);
|
||||||
|
expect(useMatchStore.getState().matches.map(item => item.id)).toEqual(['fresh']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not let a stale seen-marker write over the next account\'s list', async () => {
|
||||||
|
useMatchStore.setState({ matches: [match('theirs')], newMatchesCount: 1 });
|
||||||
|
let release: () => void = () => undefined;
|
||||||
|
(apiClient.patch as jest.Mock).mockReturnValueOnce(new Promise<void>(resolve => { release = () => resolve(); }));
|
||||||
|
const stale = useMatchStore.getState().markAsSeen('theirs');
|
||||||
|
|
||||||
|
useMatchStore.getState().clearMatches();
|
||||||
|
useMatchStore.setState({ matches: [match('mine')], newMatchesCount: 1 });
|
||||||
|
const untouched = useMatchStore.getState().matches;
|
||||||
|
release();
|
||||||
|
await stale;
|
||||||
|
|
||||||
|
// Not merely "the same ids": the previous account's response must not
|
||||||
|
// rewrite the array at all, because the recount it derives is only true
|
||||||
|
// of the rows that account had loaded.
|
||||||
|
expect(useMatchStore.getState().matches).toBe(untouched);
|
||||||
|
expect(useMatchStore.getState().newMatchesCount).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not let a stale mark-all-seen clear the next account\'s new badges', async () => {
|
||||||
|
useMatchStore.setState({ matches: [match('theirs')], newMatchesCount: 1 });
|
||||||
|
let release: () => void = () => undefined;
|
||||||
|
(apiClient.patch as jest.Mock).mockReturnValueOnce(new Promise<void>(resolve => { release = () => resolve(); }));
|
||||||
|
const stale = useMatchStore.getState().markAllAsSeen();
|
||||||
|
|
||||||
|
useMatchStore.getState().clearMatches();
|
||||||
|
useMatchStore.setState({ matches: [match('mine', true), match('mine2', true)], newMatchesCount: 2 });
|
||||||
|
release();
|
||||||
|
await stale;
|
||||||
|
|
||||||
|
expect(useMatchStore.getState().matches.every(item => item.is_new)).toBe(true);
|
||||||
|
expect(useMatchStore.getState().newMatchesCount).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not roll a failed archive or unarchive back into the next account', async () => {
|
||||||
|
jest.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||||
|
let fail: (error: Error) => void = () => undefined;
|
||||||
|
(apiClient.put as jest.Mock).mockReturnValueOnce(new Promise((_resolve, reject) => { fail = reject; }));
|
||||||
|
useMatchStore.setState({ matches: [match('theirs')], archivedMatches: [], archivedMatchIds: new Set<string>(), totalCount: 1 });
|
||||||
|
const staleArchive = useMatchStore.getState().archiveMatch('theirs');
|
||||||
|
|
||||||
|
useMatchStore.getState().clearMatches();
|
||||||
|
useMatchStore.setState({ matches: [match('mine')], totalCount: 1 });
|
||||||
|
fail(new Error('forbidden'));
|
||||||
|
await staleArchive;
|
||||||
|
|
||||||
|
// The revert would otherwise put one couple's match into another's list.
|
||||||
|
expect(useMatchStore.getState().matches.map(item => item.id)).toEqual(['mine']);
|
||||||
|
expect(useMatchStore.getState().totalCount).toBe(1);
|
||||||
|
|
||||||
|
(apiClient.put as jest.Mock).mockReturnValueOnce(new Promise((_resolve, reject) => { fail = reject; }));
|
||||||
|
useMatchStore.setState({ archivedMatches: [match('theirsArchived')], archivedMatchIds: new Set(['theirsArchived']) });
|
||||||
|
const staleUnarchive = useMatchStore.getState().unarchiveMatch('theirsArchived');
|
||||||
|
|
||||||
|
useMatchStore.getState().clearMatches();
|
||||||
|
useMatchStore.setState({ archivedMatches: [match('mineArchived')], archivedMatchIds: new Set(['mineArchived']), totalCount: 0 });
|
||||||
|
fail(new Error('forbidden'));
|
||||||
|
await staleUnarchive;
|
||||||
|
|
||||||
|
expect(useMatchStore.getState().archivedMatches.map(item => item.id)).toEqual(['mineArchived']);
|
||||||
|
expect(useMatchStore.getState().totalCount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not carry a nudge cooldown across an account switch', async () => {
|
||||||
|
let release: (data: unknown) => void = () => undefined;
|
||||||
|
(appDataApi.sendNudge as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
|
||||||
|
const staleSend = useMatchStore.getState().sendNudge();
|
||||||
|
|
||||||
|
useMatchStore.getState().clearMatches();
|
||||||
|
release({ success: true, notification_sent: true, next_nudge_available_at: '2026-08-28T00:00:00Z' });
|
||||||
|
await expect(staleSend).resolves.toEqual({ success: false, notificationSent: false });
|
||||||
|
expect(useMatchStore.getState().nudgeCooldownUntil).toBeNull();
|
||||||
|
|
||||||
|
// The 429 branch writes a cooldown too, and must be gated the same way.
|
||||||
|
let fail: (error: Error) => void = () => undefined;
|
||||||
|
(appDataApi.sendNudge as jest.Mock).mockReturnValueOnce(new Promise((_resolve, reject) => { fail = reject; }));
|
||||||
|
const staleLimit = useMatchStore.getState().sendNudge();
|
||||||
|
useMatchStore.getState().clearMatches();
|
||||||
|
fail(new ApiError('rate limited', 429, { cooldown_remaining_seconds: 60 }));
|
||||||
|
await staleLimit;
|
||||||
|
expect(useMatchStore.getState().nudgeCooldownUntil).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not let a stale cooldown read clear the next account\'s cooldown', async () => {
|
||||||
|
let release: (profile: unknown) => void = () => undefined;
|
||||||
|
(appDataApi.nudgeStatus as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
|
||||||
|
const stale = useMatchStore.getState().checkNudgeCooldown();
|
||||||
|
|
||||||
|
useMatchStore.getState().clearMatches();
|
||||||
|
const mine = new Date('2026-08-27T18:00:00Z');
|
||||||
|
useMatchStore.setState({ nudgeCooldownUntil: mine });
|
||||||
|
// The previous account not being in cooldown says nothing about this one.
|
||||||
|
release({ last_nudge_sent_at: null });
|
||||||
|
await stale;
|
||||||
|
expect(useMatchStore.getState().nudgeCooldownUntil).toEqual(mine);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not let a stale fetchPendingQuestions response populate the store after sign-out, and lets the next account start immediately', async () => {
|
||||||
|
let release: (page: unknown) => void = () => undefined;
|
||||||
|
(apiClient.get as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
|
||||||
|
const staleFetch = useMatchStore.getState().fetchPendingQuestions();
|
||||||
|
|
||||||
|
useMatchStore.getState().clearMatches();
|
||||||
|
release({ questions: [pending('stale')] });
|
||||||
|
await staleFetch;
|
||||||
|
|
||||||
|
expect(useMatchStore.getState().pendingQuestions).toEqual([]);
|
||||||
|
|
||||||
|
(apiClient.get as jest.Mock).mockResolvedValueOnce({ questions: [pending('fresh')] });
|
||||||
|
await useMatchStore.getState().fetchPendingQuestions();
|
||||||
|
expect(useMatchStore.getState().pendingQuestions.map(item => item.id)).toEqual(['fresh']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('clearMatches resets all user-scoped state', () => {
|
it('clearMatches resets all user-scoped state', () => {
|
||||||
useMatchStore.setState({ matches: [match('m1')], archivedMatches: [match('m2')], pendingQuestions: [pending('p')], theirTurnQuestions: [pending('t')], currentView: 'archived', showArchived: true, isNudging: true });
|
useMatchStore.setState({ matches: [match('m1')], archivedMatches: [match('m2')], pendingQuestions: [pending('p')], theirTurnQuestions: [pending('t')], currentView: 'archived', showArchived: true, isNudging: true });
|
||||||
useMatchStore.getState().clearMatches();
|
useMatchStore.getState().clearMatches();
|
||||||
|
|||||||
@@ -47,15 +47,25 @@ describe('messageStore API state', () => {
|
|||||||
expect(useMessageStore.getState()).toMatchObject({ unreadCount: 1, lastMessage: { id: 'new' } });
|
expect(useMessageStore.getState()).toMatchObject({ unreadCount: 1, lastMessage: { id: 'new' } });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('marks partner messages read then reconciles global and per-match counts', async () => {
|
it('marks partner messages read and subtracts the cleared count without re-reading the total', async () => {
|
||||||
|
useMessageStore.setState({ unreadCount: 3 });
|
||||||
(chatApi.markRead as jest.Mock).mockResolvedValue({ updated: 2, read_at: '2026-08-27T00:00:00.000Z' });
|
(chatApi.markRead as jest.Mock).mockResolvedValue({ updated: 2, read_at: '2026-08-27T00:00:00.000Z' });
|
||||||
(chatApi.unread as jest.Mock).mockResolvedValue({ total: 1, by_match: { m2: 1 } });
|
|
||||||
await useMessageStore.getState().markMatchMessagesAsRead('m1');
|
await useMessageStore.getState().markMatchMessagesAsRead('m1');
|
||||||
expect(chatApi.markRead).toHaveBeenCalledWith('m1');
|
expect(chatApi.markRead).toHaveBeenCalledWith('m1');
|
||||||
|
expect(chatApi.unread).not.toHaveBeenCalled();
|
||||||
expect(useMessageStore.getState().unreadCount).toBe(1);
|
expect(useMessageStore.getState().unreadCount).toBe(1);
|
||||||
expect(useMatchStore.getState().matches[0]?.unreadCount).toBe(1);
|
expect(useMatchStore.getState().matches[0]?.unreadCount).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('spends no request and touches no count when the read API cleared nothing', async () => {
|
||||||
|
useMessageStore.setState({ unreadCount: 3 });
|
||||||
|
(chatApi.markRead as jest.Mock).mockResolvedValue({ updated: 0, read_at: null });
|
||||||
|
await useMessageStore.getState().markMatchMessagesAsRead('m1');
|
||||||
|
expect(chatApi.unread).not.toHaveBeenCalled();
|
||||||
|
expect(useMessageStore.getState().unreadCount).toBe(3);
|
||||||
|
expect(useMatchStore.getState().matches[0]?.unreadCount).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
it('does not mark read without an authenticated user', async () => {
|
it('does not mark read without an authenticated user', async () => {
|
||||||
useAuthStore.setState({ user: null } as any);
|
useAuthStore.setState({ user: null } as any);
|
||||||
await useMessageStore.getState().markMatchMessagesAsRead('m1');
|
await useMessageStore.getState().markMatchMessagesAsRead('m1');
|
||||||
@@ -69,6 +79,42 @@ describe('messageStore API state', () => {
|
|||||||
expect(useMatchStore.getState().matches[0]?.unreadCount).toBe(3);
|
expect(useMatchStore.getState().matches[0]?.unreadCount).toBe(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('account isolation (generation tokens)', () => {
|
||||||
|
it('does not let a stale unread total become the next account\'s badge', async () => {
|
||||||
|
let release: (value: unknown) => void = () => undefined;
|
||||||
|
(chatApi.unread as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
|
||||||
|
const staleFetch = useMessageStore.getState().fetchUnreadCount();
|
||||||
|
|
||||||
|
// Sign-out or leaving a couple, while the request is still open.
|
||||||
|
useMessageStore.getState().clearMessages();
|
||||||
|
useMessageStore.setState({ unreadCount: 2 });
|
||||||
|
|
||||||
|
release({ total: 9, by_match: { m1: 9 } });
|
||||||
|
await staleFetch;
|
||||||
|
|
||||||
|
expect(useMessageStore.getState().unreadCount).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not subtract a stale read receipt from the next account\'s badge', async () => {
|
||||||
|
useMessageStore.setState({ unreadCount: 3 });
|
||||||
|
let release: (value: unknown) => void = () => undefined;
|
||||||
|
(chatApi.markRead as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
|
||||||
|
const staleRead = useMessageStore.getState().markMatchMessagesAsRead('m1');
|
||||||
|
|
||||||
|
useMessageStore.getState().clearMessages();
|
||||||
|
useMessageStore.setState({ unreadCount: 2 });
|
||||||
|
useMatchStore.setState({ matches: [{ id: 'm1', unreadCount: 2 } as any] });
|
||||||
|
|
||||||
|
release({ updated: 3, read_at: '2026-08-27T00:00:00.000Z' });
|
||||||
|
await staleRead;
|
||||||
|
|
||||||
|
// The badge is wrong whatever the match id; reusing 'm1' also makes the
|
||||||
|
// second half of the damage — the per-match count — visible.
|
||||||
|
expect(useMessageStore.getState().unreadCount).toBe(2);
|
||||||
|
expect(useMatchStore.getState().matches[0]?.unreadCount).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('clears transient message state', () => {
|
it('clears transient message state', () => {
|
||||||
useMessageStore.setState({ unreadCount: 3, lastMessage: { id: 'last' } as any, activeMatchId: 'm1' });
|
useMessageStore.setState({ unreadCount: 3, lastMessage: { id: 'last' } as any, activeMatchId: 'm1' });
|
||||||
useMessageStore.getState().clearLastMessage();
|
useMessageStore.getState().clearLastMessage();
|
||||||
|
|||||||
@@ -64,4 +64,69 @@ describe('packsStore standalone API integration', () => {
|
|||||||
expect(getSpy).not.toHaveBeenCalled();
|
expect(getSpy).not.toHaveBeenCalled();
|
||||||
expect(usePacksStore.getState().enabledPackIds).toEqual([]);
|
expect(usePacksStore.getState().enabledPackIds).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('account isolation (generation tokens)', () => {
|
||||||
|
it('does not let a stale fetchEnabledPacks response populate the store after clearPacks, and lets the next account start immediately', async () => {
|
||||||
|
let release: (page: unknown) => void = () => undefined;
|
||||||
|
const getSpy = jest.spyOn(apiClient, 'get').mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
|
||||||
|
const staleFetch = usePacksStore.getState().fetchEnabledPacks();
|
||||||
|
|
||||||
|
// Simulates sign-out/account switch while the request is still in flight.
|
||||||
|
usePacksStore.getState().clearPacks();
|
||||||
|
release({ enabledPackIds: ['stale-pack'] });
|
||||||
|
await staleFetch;
|
||||||
|
|
||||||
|
expect(usePacksStore.getState().enabledPackIds).toEqual([]);
|
||||||
|
|
||||||
|
getSpy.mockResolvedValueOnce({ enabledPackIds: ['fresh-pack'] });
|
||||||
|
await usePacksStore.getState().fetchEnabledPacks();
|
||||||
|
expect(usePacksStore.getState().enabledPackIds).toEqual(['fresh-pack']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not let a stale fetchPackProgress response populate the store after clearPacks, and lets the next account start immediately', async () => {
|
||||||
|
let release: (page: unknown) => void = () => undefined;
|
||||||
|
const getSpy = jest.spyOn(apiClient, 'get').mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
|
||||||
|
const staleFetch = usePacksStore.getState().fetchPackProgress();
|
||||||
|
|
||||||
|
usePacksStore.getState().clearPacks();
|
||||||
|
release({ progress: [{ packId: 'stale', totalQuestions: 1, answeredQuestions: 1 }] });
|
||||||
|
await staleFetch;
|
||||||
|
|
||||||
|
expect(usePacksStore.getState().packProgress.size).toBe(0);
|
||||||
|
|
||||||
|
getSpy.mockResolvedValueOnce({ progress: [{ packId: 'fresh', totalQuestions: 2, answeredQuestions: 1 }] });
|
||||||
|
await usePacksStore.getState().fetchPackProgress();
|
||||||
|
expect(usePacksStore.getState().packProgress.get('fresh')).toEqual({ totalQuestions: 2, answeredQuestions: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not let a stale toggle write the previous couple\'s pack selection', async () => {
|
||||||
|
usePacksStore.setState({ enabledPackIds: [] } as any);
|
||||||
|
let release: (value: unknown) => void = () => undefined;
|
||||||
|
jest.spyOn(apiClient, 'put').mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
|
||||||
|
const staleToggle = usePacksStore.getState().togglePack('pack1');
|
||||||
|
|
||||||
|
usePacksStore.getState().clearPacks();
|
||||||
|
usePacksStore.setState({ enabledPackIds: ['next-couple-pack'] } as any);
|
||||||
|
|
||||||
|
release({ enabledPackIds: ['pack1'] });
|
||||||
|
await expect(staleToggle).resolves.toEqual({ success: false, reason: 'stale' });
|
||||||
|
expect(usePacksStore.getState().enabledPackIds).toEqual(['next-couple-pack']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not roll a failed toggle back into the next couple\'s selection', async () => {
|
||||||
|
usePacksStore.setState({ enabledPackIds: ['pack1'] } as any);
|
||||||
|
let reject: (reason: unknown) => void = () => undefined;
|
||||||
|
jest.spyOn(apiClient, 'put').mockReturnValueOnce(new Promise((_resolve, fail) => { reject = fail; }));
|
||||||
|
jest.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||||
|
const staleToggle = usePacksStore.getState().togglePack('pack1');
|
||||||
|
|
||||||
|
usePacksStore.getState().clearPacks();
|
||||||
|
usePacksStore.setState({ enabledPackIds: ['next-couple-pack'] } as any);
|
||||||
|
|
||||||
|
reject(new Error('offline'));
|
||||||
|
await expect(staleToggle).resolves.toEqual({ success: false, reason: 'stale' });
|
||||||
|
// The rollback would have re-added pack1, which this couple never enabled.
|
||||||
|
expect(usePacksStore.getState().enabledPackIds).toEqual(['next-couple-pack']);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -265,6 +265,53 @@ describe('responsesStore', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('account isolation (generation tokens)', () => {
|
||||||
|
it('does not let a stale fetchResponses response populate the store after clearResponses, and lets the next account start immediately', async () => {
|
||||||
|
let resolve!: (value: unknown) => void;
|
||||||
|
apiGet.mockReturnValueOnce(new Promise((done) => { resolve = done; }));
|
||||||
|
const staleFetch = useResponsesStore.getState().fetchResponses(true);
|
||||||
|
|
||||||
|
// Simulates sign-out/account switch while the request is still in flight.
|
||||||
|
useResponsesStore.getState().clearResponses();
|
||||||
|
resolve({ responses: [response({ id: 'stale' })], totalCount: 1 });
|
||||||
|
await staleFetch;
|
||||||
|
|
||||||
|
expect(useResponsesStore.getState().responses).toEqual([]);
|
||||||
|
|
||||||
|
apiGet.mockResolvedValueOnce({ responses: [response({ id: 'fresh' })], totalCount: 1 });
|
||||||
|
await useResponsesStore.getState().fetchResponses(true);
|
||||||
|
expect(useResponsesStore.getState().responses.map((r) => r.id)).toEqual(['fresh']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not let a stale edit rewrite the next account\'s row or refresh its matches', async () => {
|
||||||
|
// Question ids come from the shared catalogue, so the same id names a real
|
||||||
|
// row in both accounts' lists: a stale edit would land on one of them.
|
||||||
|
const fetchMatches = jest.fn();
|
||||||
|
useMatchStore.setState({ fetchMatches } as any);
|
||||||
|
useResponsesStore.setState({
|
||||||
|
responses: [response({ id: 'alice', answer: 'maybe', has_match: false, match_id: undefined })],
|
||||||
|
});
|
||||||
|
|
||||||
|
let resolve!: (value: unknown) => void;
|
||||||
|
apiPatch.mockReturnValueOnce(new Promise((done) => { resolve = done; }));
|
||||||
|
const staleEdit = useResponsesStore.getState().updateResponse('q1', 'yes');
|
||||||
|
|
||||||
|
useResponsesStore.getState().clearResponses();
|
||||||
|
const next = [response({ id: 'bob', answer: 'no', has_match: false, match_id: undefined })];
|
||||||
|
useResponsesStore.setState({ responses: next });
|
||||||
|
|
||||||
|
resolve({ success: true, new_match: { id: 'm2' } });
|
||||||
|
// The caller still hears what the server did for the account that asked.
|
||||||
|
await expect(staleEdit).resolves.toMatchObject({ success: true });
|
||||||
|
|
||||||
|
expect(useResponsesStore.getState().responses).toBe(next);
|
||||||
|
expect(useResponsesStore.getState().responses[0]).toMatchObject({
|
||||||
|
id: 'bob', answer: 'no', has_match: false,
|
||||||
|
});
|
||||||
|
expect(fetchMatches).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('state controls', () => {
|
describe('state controls', () => {
|
||||||
it('sets and toggles grouping options', () => {
|
it('sets and toggles grouping options', () => {
|
||||||
useResponsesStore.getState().setGroupBy('pack');
|
useResponsesStore.getState().setGroupBy('pack');
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { apiClient } from '@/lib/apiClient';
|
||||||
|
import { useAuthStore } from '@/store/authStore';
|
||||||
|
import { type CoupleStreak, useStreakStore } from '@/store/streakStore';
|
||||||
|
|
||||||
|
jest.mock('@/lib/apiClient', () => ({ apiClient: { get: jest.fn() } }));
|
||||||
|
|
||||||
|
const apiGet = apiClient.get as jest.Mock;
|
||||||
|
|
||||||
|
function streak(overrides: Partial<CoupleStreak> = {}): CoupleStreak {
|
||||||
|
return {
|
||||||
|
couple_id: 'couple1', current_streak: 1, longest_streak: 1,
|
||||||
|
last_active_date: '2026-08-27', last_completed_date: '2026-08-27',
|
||||||
|
you_answered_today: true, partner_answered_today: false,
|
||||||
|
partner_name: 'Partner', timezone: 'UTC', streak_celebrated_at: 0,
|
||||||
|
created_at: '2026-08-01T00:00:00.000Z', updated_at: '2026-08-27T00:00:00.000Z',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('streakStore account isolation (generation tokens)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
useStreakStore.setState({ streak: null, isLoading: false, error: null, loadedAt: null, generation: 0 });
|
||||||
|
useAuthStore.setState({ user: { id: 'me', couple_id: 'couple1' } } as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not let a stale fetchStreak response populate the store after clearStreak, and lets the next account start immediately', async () => {
|
||||||
|
let resolve!: (value: unknown) => void;
|
||||||
|
apiGet.mockReturnValueOnce(new Promise((done) => { resolve = done; }));
|
||||||
|
const staleFetch = useStreakStore.getState().fetchStreak();
|
||||||
|
|
||||||
|
// Simulates sign-out/account switch while the request is still in flight.
|
||||||
|
useStreakStore.getState().clearStreak();
|
||||||
|
resolve({ streak: streak({ couple_id: 'stale-couple' }) });
|
||||||
|
await staleFetch;
|
||||||
|
|
||||||
|
expect(useStreakStore.getState().streak).toBeNull();
|
||||||
|
expect(useStreakStore.getState().loadedAt).toBeNull();
|
||||||
|
|
||||||
|
apiGet.mockResolvedValueOnce({ streak: streak({ couple_id: 'fresh-couple' }) });
|
||||||
|
await useStreakStore.getState().fetchStreak();
|
||||||
|
expect(useStreakStore.getState().streak?.couple_id).toBe('fresh-couple');
|
||||||
|
expect(useStreakStore.getState().loadedAt).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refreshStreak silently reloads only an already-loaded streak', async () => {
|
||||||
|
await useStreakStore.getState().refreshStreak();
|
||||||
|
expect(apiGet).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
apiGet.mockResolvedValueOnce({ streak: streak() });
|
||||||
|
await useStreakStore.getState().fetchStreak();
|
||||||
|
expect(useStreakStore.getState().loadedAt).not.toBeNull();
|
||||||
|
|
||||||
|
apiGet.mockResolvedValueOnce({ streak: streak({ current_streak: 2 }) });
|
||||||
|
await useStreakStore.getState().refreshStreak();
|
||||||
|
expect(useStreakStore.getState().streak?.current_streak).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -43,12 +43,12 @@ export function streakStatus(streak: CoupleStreak): { headline: string; status:
|
|||||||
* Displays the couple's shared streak. The flame pulses while the streak is alive.
|
* Displays the couple's shared streak. The flame pulses while the streak is alive.
|
||||||
*/
|
*/
|
||||||
export function StreakDisplay({ showLongest = false, delay = 0, compact = false }: StreakDisplayProps) {
|
export function StreakDisplay({ showLongest = false, delay = 0, compact = false }: StreakDisplayProps) {
|
||||||
const { streak, fetchStreak } = useStreakStore();
|
const { streak, ensureStreakLoaded } = useStreakStore();
|
||||||
|
|
||||||
// Fetch streak on mount
|
// Fetch streak on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchStreak();
|
void ensureStreakLoaded();
|
||||||
}, [fetchStreak]);
|
}, [ensureStreakLoaded]);
|
||||||
|
|
||||||
// Flame pulse animation
|
// Flame pulse animation
|
||||||
const scale = useSharedValue(1);
|
const scale = useSharedValue(1);
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
import Animated, { FadeInDown } from 'react-native-reanimated';
|
||||||
|
import type { PendingQuestion } from '../../store';
|
||||||
|
import { colors, gradients, radius, shadows, spacing, typography } from '../../theme';
|
||||||
|
|
||||||
|
interface PendingQuestionsCardProps {
|
||||||
|
questions: PendingQuestion[];
|
||||||
|
partnerName?: string | null;
|
||||||
|
onPress: () => void;
|
||||||
|
delay?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatWaitingSince(dateString: string, now = new Date()): string {
|
||||||
|
const answeredAt = new Date(dateString);
|
||||||
|
const diffMs = Math.max(0, now.getTime() - answeredAt.getTime());
|
||||||
|
const minutes = Math.floor(diffMs / 60_000);
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
|
||||||
|
if (minutes < 1) return 'just now';
|
||||||
|
if (minutes < 60) return `${minutes}m ago`;
|
||||||
|
if (hours < 24) return `${hours}h ago`;
|
||||||
|
if (days < 7) return `${days}d ago`;
|
||||||
|
return answeredAt.toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PendingQuestionsCard({
|
||||||
|
questions,
|
||||||
|
partnerName,
|
||||||
|
onPress,
|
||||||
|
delay = 0,
|
||||||
|
}: PendingQuestionsCardProps) {
|
||||||
|
const oldestQuestion = useMemo(
|
||||||
|
() => questions.reduce<PendingQuestion | null>((oldest, question) => {
|
||||||
|
if (!oldest) return question;
|
||||||
|
return new Date(question.partnerAnsweredAt) < new Date(oldest.partnerAnsweredAt)
|
||||||
|
? question
|
||||||
|
: oldest;
|
||||||
|
}, null),
|
||||||
|
[questions],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!oldestQuestion) return null;
|
||||||
|
|
||||||
|
const count = questions.length;
|
||||||
|
const subject = partnerName?.trim() || 'Your partner';
|
||||||
|
const questionLabel = count === 1 ? 'question' : 'questions';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View
|
||||||
|
entering={FadeInDown.delay(delay).duration(450).springify()}
|
||||||
|
style={styles.container}
|
||||||
|
testID="pending-questions-card"
|
||||||
|
>
|
||||||
|
<View style={[styles.card, shadows.md]}>
|
||||||
|
<View style={styles.accent} />
|
||||||
|
|
||||||
|
<View style={styles.labelRow}>
|
||||||
|
<View style={styles.labelGroup}>
|
||||||
|
<View style={styles.statusDot} />
|
||||||
|
<Text style={styles.label}>Your turn</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.countBadge} accessibilityLabel={`${count} ${questionLabel} waiting`}>
|
||||||
|
<Text style={styles.countText}>{count}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={styles.title}>
|
||||||
|
{subject} has answered {count} {questionLabel}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.description}>
|
||||||
|
Answer yours to see where you match. Their answers stay private until you respond.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={onPress}
|
||||||
|
activeOpacity={0.85}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={`Answer ${count} waiting ${questionLabel}`}
|
||||||
|
testID="pending-questions-answer"
|
||||||
|
>
|
||||||
|
<LinearGradient
|
||||||
|
colors={gradients.primary as [string, string, ...string[]]}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 0 }}
|
||||||
|
style={styles.button}
|
||||||
|
>
|
||||||
|
<Text style={styles.buttonText}>Answer now</Text>
|
||||||
|
<Ionicons name="arrow-forward" size={17} color={colors.text} />
|
||||||
|
</LinearGradient>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<View style={styles.waitingRow}>
|
||||||
|
<Ionicons name="time-outline" size={13} color={colors.textTertiary} />
|
||||||
|
<Text style={styles.waitingText}>
|
||||||
|
Oldest waiting · {formatWaitingSince(oldestQuestion.partnerAnsweredAt)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
paddingHorizontal: spacing.lg,
|
||||||
|
marginTop: spacing.sm,
|
||||||
|
marginBottom: spacing.sm,
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'hidden',
|
||||||
|
padding: spacing.md,
|
||||||
|
borderRadius: radius.lg,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(232, 164, 174, 0.36)',
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
width: 96,
|
||||||
|
height: 96,
|
||||||
|
borderBottomLeftRadius: 96,
|
||||||
|
backgroundColor: colors.primaryLight,
|
||||||
|
},
|
||||||
|
labelRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
},
|
||||||
|
labelGroup: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
statusDot: {
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: radius.full,
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
shadowColor: colors.primary,
|
||||||
|
shadowOpacity: 0.55,
|
||||||
|
shadowRadius: 6,
|
||||||
|
shadowOffset: { width: 0, height: 0 },
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
...typography.caption1,
|
||||||
|
color: colors.premium.rose,
|
||||||
|
fontWeight: '700',
|
||||||
|
letterSpacing: 1.2,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
},
|
||||||
|
countBadge: {
|
||||||
|
minWidth: 32,
|
||||||
|
height: 26,
|
||||||
|
paddingHorizontal: spacing.sm,
|
||||||
|
borderRadius: radius.full,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
},
|
||||||
|
countText: {
|
||||||
|
...typography.caption1,
|
||||||
|
color: colors.text,
|
||||||
|
fontWeight: '800',
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
...typography.title3,
|
||||||
|
color: colors.text,
|
||||||
|
marginTop: spacing.md,
|
||||||
|
paddingRight: spacing.xl,
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
...typography.subhead,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
marginTop: spacing.sm,
|
||||||
|
marginBottom: spacing.md,
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
minHeight: 46,
|
||||||
|
borderRadius: radius.md,
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
buttonText: {
|
||||||
|
...typography.callout,
|
||||||
|
color: colors.text,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
waitingRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: spacing.xs,
|
||||||
|
marginTop: spacing.sm,
|
||||||
|
},
|
||||||
|
waitingText: {
|
||||||
|
...typography.caption2,
|
||||||
|
color: colors.textTertiary,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -4,3 +4,4 @@ export { CompactHeader } from './CompactHeader';
|
|||||||
export { CircularProgress } from './CircularProgress';
|
export { CircularProgress } from './CircularProgress';
|
||||||
export { LiveDrawTile } from './LiveDrawTile';
|
export { LiveDrawTile } from './LiveDrawTile';
|
||||||
export { DaresTile } from './DaresTile';
|
export { DaresTile } from './DaresTile';
|
||||||
|
export { PendingQuestionsCard } from './PendingQuestionsCard';
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
import { View, StyleSheet, KeyboardAvoidingView, Platform, Alert } from "react-native";
|
import { View, StyleSheet, KeyboardAvoidingView, Platform, Alert } from "react-native";
|
||||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||||
import { useFocusEffect, useIsFocused } from "@react-navigation/native";
|
import { useFocusEffect, useIsFocused } from "@react-navigation/native";
|
||||||
@@ -60,20 +60,31 @@ export const ChatScreen: React.FC = () => {
|
|||||||
|
|
||||||
const isFocused = useIsFocused();
|
const isFocused = useIsFocused();
|
||||||
|
|
||||||
// Typing indicator hook
|
// Each hook needs a value the other produces: the message poll reports typing
|
||||||
|
// state, and an arriving message clears the indicator. The ref breaks the cycle.
|
||||||
|
const clearTypingRef = useRef<() => void>(() => undefined);
|
||||||
|
const handleNewMessage = useCallback(() => clearTypingRef.current(), []);
|
||||||
|
|
||||||
|
// Message subscription hook
|
||||||
|
const { messages, setMessages, partnerTyping: syncedTyping } = useMessageSubscription({
|
||||||
|
matchId,
|
||||||
|
userId: user?.id,
|
||||||
|
onNewMessage: handleNewMessage,
|
||||||
|
isFocused,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Typing indicator hook, reading the state the message poll already fetched
|
||||||
const { partnerTyping, sendTypingEvent, clearTypingIndicator } = useTypingIndicator({
|
const { partnerTyping, sendTypingEvent, clearTypingIndicator } = useTypingIndicator({
|
||||||
channelName: `chat:${matchId}`,
|
channelName: `chat:${matchId}`,
|
||||||
matchId,
|
matchId,
|
||||||
userId: user?.id,
|
userId: user?.id,
|
||||||
isFocused,
|
isFocused,
|
||||||
|
externalTyping: syncedTyping,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Message subscription hook
|
useEffect(() => {
|
||||||
const { messages, setMessages, isFocusedRef } = useMessageSubscription({
|
clearTypingRef.current = clearTypingIndicator;
|
||||||
matchId,
|
}, [clearTypingIndicator]);
|
||||||
userId: user?.id,
|
|
||||||
onNewMessage: clearTypingIndicator,
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
// Message actions hook for deletion and reporting
|
// Message actions hook for deletion and reporting
|
||||||
@@ -174,16 +185,12 @@ export const ChatScreen: React.FC = () => {
|
|||||||
// Track active chat to prevent notifications for current chat
|
// Track active chat to prevent notifications for current chat
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
isFocusedRef.current = true;
|
|
||||||
if (matchId) {
|
if (matchId) {
|
||||||
setActiveMatchId(matchId);
|
setActiveMatchId(matchId);
|
||||||
markMatchMessagesAsRead(matchId);
|
markMatchMessagesAsRead(matchId);
|
||||||
Events.matchViewed();
|
Events.matchViewed();
|
||||||
}
|
}
|
||||||
return () => {
|
return () => setActiveMatchId(null);
|
||||||
isFocusedRef.current = false;
|
|
||||||
setActiveMatchId(null);
|
|
||||||
};
|
|
||||||
}, [matchId, setActiveMatchId, markMatchMessagesAsRead])
|
}, [matchId, setActiveMatchId, markMatchMessagesAsRead])
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { ActivityIndicator, Pressable, RefreshControl, ScrollView, StyleSheet, Text, View } from "react-native";
|
import { ActivityIndicator, Pressable, RefreshControl, ScrollView, StyleSheet, Text, View } from "react-native";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useIsFocused } from "@react-navigation/native";
|
||||||
import Animated, { FadeIn } from "react-native-reanimated";
|
import Animated, { FadeIn } from "react-native-reanimated";
|
||||||
import { GradientBackground } from "../../components/ui";
|
import { GradientBackground } from "../../components/ui";
|
||||||
import { Paywall } from "../../components/paywall";
|
import { Paywall } from "../../components/paywall";
|
||||||
@@ -20,7 +21,8 @@ const TABS: { key: Tab; label: string }[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export function DaresScreen() {
|
export function DaresScreen() {
|
||||||
const dares = useDares();
|
const isFocused = useIsFocused();
|
||||||
|
const dares = useDares({ isFocused });
|
||||||
const [tab, setTab] = useState<Tab>("active");
|
const [tab, setTab] = useState<Tab>("active");
|
||||||
const [sheetPack, setSheetPack] = useState<DarePack | null>(null);
|
const [sheetPack, setSheetPack] = useState<DarePack | null>(null);
|
||||||
const [sheetOpen, setSheetOpen] = useState(false);
|
const [sheetOpen, setSheetOpen] = useState(false);
|
||||||
|
|||||||
@@ -1,11 +1,21 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { daresApi } from "../../../lib/daresApi";
|
import { daresApi } from "../../../lib/daresApi";
|
||||||
import { ApiError } from "../../../lib/apiClient";
|
import { ApiError } from "../../../lib/apiClient";
|
||||||
|
import { usePolling } from "../../../hooks/usePolling";
|
||||||
import type { DareCatalog, DareStats, SendDarePayload, SentDare } from "../types";
|
import type { DareCatalog, DareStats, SendDarePayload, SentDare } from "../types";
|
||||||
|
|
||||||
/** Matches the chat poll cadence; the API has no realtime channel. */
|
/** Matches the chat poll cadence; the API has no realtime channel. */
|
||||||
const POLL_INTERVAL_MS = 5_000;
|
const POLL_INTERVAL_MS = 5_000;
|
||||||
|
|
||||||
|
export interface UseDaresConfig {
|
||||||
|
/**
|
||||||
|
* Supplied by the screen rather than read from navigation here, so the hook
|
||||||
|
* stays renderable without a navigator. Polling stops when the screen is not
|
||||||
|
* on top; `usePolling` additionally stops it when the app backgrounds.
|
||||||
|
*/
|
||||||
|
isFocused?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface UseDaresReturn {
|
export interface UseDaresReturn {
|
||||||
active: SentDare[];
|
active: SentDare[];
|
||||||
history: SentDare[];
|
history: SentDare[];
|
||||||
@@ -25,7 +35,19 @@ export interface UseDaresReturn {
|
|||||||
cancel: (dareId: string) => Promise<void>;
|
cancel: (dareId: string) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useDares(): UseDaresReturn {
|
/**
|
||||||
|
* Identity of the active set as the user experiences it. History, stats and the
|
||||||
|
* remaining-send allowance only move when a dare enters or leaves this set or
|
||||||
|
* changes status, so this is the signal that says the slower endpoints are worth
|
||||||
|
* re-reading.
|
||||||
|
*/
|
||||||
|
const activeSignature = (dares: SentDare[]): string =>
|
||||||
|
dares
|
||||||
|
.map(dare => `${dare.id}:${dare.status}`)
|
||||||
|
.sort()
|
||||||
|
.join("|");
|
||||||
|
|
||||||
|
export function useDares({ isFocused = true }: UseDaresConfig = {}): UseDaresReturn {
|
||||||
const [active, setActive] = useState<SentDare[]>([]);
|
const [active, setActive] = useState<SentDare[]>([]);
|
||||||
const [history, setHistory] = useState<SentDare[]>([]);
|
const [history, setHistory] = useState<SentDare[]>([]);
|
||||||
const [catalog, setCatalog] = useState<DareCatalog | null>(null);
|
const [catalog, setCatalog] = useState<DareCatalog | null>(null);
|
||||||
@@ -36,42 +58,63 @@ export function useDares(): UseDaresReturn {
|
|||||||
const [paywallReason, setPaywallReason] = useState<string | null>(null);
|
const [paywallReason, setPaywallReason] = useState<string | null>(null);
|
||||||
const fetchingRef = useRef(false);
|
const fetchingRef = useRef(false);
|
||||||
const mountedRef = useRef(true);
|
const mountedRef = useRef(true);
|
||||||
|
const signatureRef = useRef<string | null>(null);
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
useEffect(() => {
|
||||||
|
mountedRef.current = true;
|
||||||
|
return () => {
|
||||||
|
mountedRef.current = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `full` reads everything; the polling path reads only the active list and
|
||||||
|
* follows up on the rest when that list actually changed. A failure updates
|
||||||
|
* the UI's error state either way, but is only rethrown for the poll path
|
||||||
|
* (`throwOnError`): `usePolling` needs the rejection to drive its backoff,
|
||||||
|
* while pull-to-refresh and post-mutation callers already surface the error
|
||||||
|
* themselves and must not turn it into an unhandled rejection.
|
||||||
|
*/
|
||||||
|
const load = useCallback(async (full: boolean, throwOnError = false) => {
|
||||||
if (fetchingRef.current) return;
|
if (fetchingRef.current) return;
|
||||||
fetchingRef.current = true;
|
fetchingRef.current = true;
|
||||||
try {
|
try {
|
||||||
const [activeResult, historyResult, catalogResult, statsResult] = await Promise.all([
|
const activeResult = await daresApi.list("active");
|
||||||
daresApi.list("active"),
|
|
||||||
daresApi.list("history"),
|
|
||||||
daresApi.catalog(),
|
|
||||||
daresApi.stats(),
|
|
||||||
]);
|
|
||||||
if (!mountedRef.current) return;
|
if (!mountedRef.current) return;
|
||||||
setActive(activeResult.dares);
|
setActive(activeResult.dares);
|
||||||
setHistory(historyResult.dares);
|
|
||||||
setCatalog(catalogResult);
|
const signature = activeSignature(activeResult.dares);
|
||||||
setStats(statsResult);
|
const changed = signature !== signatureRef.current;
|
||||||
|
signatureRef.current = signature;
|
||||||
|
|
||||||
|
if (full || changed) {
|
||||||
|
const [historyResult, catalogResult, statsResult] = await Promise.all([
|
||||||
|
daresApi.list("history"),
|
||||||
|
daresApi.catalog(),
|
||||||
|
daresApi.stats(),
|
||||||
|
]);
|
||||||
|
if (!mountedRef.current) return;
|
||||||
|
setHistory(historyResult.dares);
|
||||||
|
setCatalog(catalogResult);
|
||||||
|
setStats(statsResult);
|
||||||
|
}
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
if (mountedRef.current) {
|
if (mountedRef.current) {
|
||||||
setError(cause instanceof ApiError ? cause.message : "Could not load dares");
|
setError(cause instanceof ApiError ? cause.message : "Could not load dares");
|
||||||
}
|
}
|
||||||
|
if (throwOnError) throw cause;
|
||||||
} finally {
|
} finally {
|
||||||
fetchingRef.current = false;
|
fetchingRef.current = false;
|
||||||
if (mountedRef.current) setLoading(false);
|
if (mountedRef.current) setLoading(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
/** Pull-to-refresh and post-mutation reloads always read the slow endpoints too. */
|
||||||
mountedRef.current = true;
|
const refresh = useCallback(() => load(true), [load]);
|
||||||
void refresh();
|
const poll = useCallback(() => load(false, true), [load]);
|
||||||
const timer = setInterval(() => void refresh(), POLL_INTERVAL_MS);
|
|
||||||
return () => {
|
usePolling(poll, { intervalMs: POLL_INTERVAL_MS, enabled: isFocused });
|
||||||
mountedRef.current = false;
|
|
||||||
clearInterval(timer);
|
|
||||||
};
|
|
||||||
}, [refresh]);
|
|
||||||
|
|
||||||
/** Premium refusals steer to the paywall; everything else surfaces inline. */
|
/** Premium refusals steer to the paywall; everything else surfaces inline. */
|
||||||
const handleFailure = useCallback((cause: unknown) => {
|
const handleFailure = useCallback((cause: unknown) => {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
useWindowDimensions,
|
useWindowDimensions,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||||
|
import { useIsFocused } from '@react-navigation/native';
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import * as Haptics from 'expo-haptics';
|
import * as Haptics from 'expo-haptics';
|
||||||
@@ -35,6 +36,7 @@ export const LiveDrawScreen: React.FC = () => {
|
|||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const { user } = useAuthStore();
|
const { user } = useAuthStore();
|
||||||
const coupleId = coupleIdParam || user?.couple_id;
|
const coupleId = coupleIdParam || user?.couple_id;
|
||||||
|
const isFocused = useIsFocused();
|
||||||
const { width: screenWidth } = useWindowDimensions();
|
const { width: screenWidth } = useWindowDimensions();
|
||||||
|
|
||||||
const [selectedColor, setSelectedColor] = useState(PRESET_COLORS[0]);
|
const [selectedColor, setSelectedColor] = useState(PRESET_COLORS[0]);
|
||||||
@@ -95,6 +97,7 @@ export const LiveDrawScreen: React.FC = () => {
|
|||||||
const sync = useDrawingSync({
|
const sync = useDrawingSync({
|
||||||
coupleId: coupleId!,
|
coupleId: coupleId!,
|
||||||
userId: user?.id,
|
userId: user?.id,
|
||||||
|
isFocused,
|
||||||
onStrokeStart: handleStrokeStartFromPartner,
|
onStrokeStart: handleStrokeStartFromPartner,
|
||||||
onStrokeContinue: handleStrokeContinueFromPartner,
|
onStrokeContinue: handleStrokeContinueFromPartner,
|
||||||
onStrokeEnd: handleStrokeEndFromPartner,
|
onStrokeEnd: handleStrokeEndFromPartner,
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||||
import { ApiError } from '../../../lib/apiClient';
|
import { ApiError } from '../../../lib/apiClient';
|
||||||
import { appDataApi, type LiveDrawState } from '../../../lib/appDataApi';
|
import { appDataApi, type LiveDrawState } from '../../../lib/appDataApi';
|
||||||
|
import { usePolling } from '../../../hooks/usePolling';
|
||||||
import type { StrokeSegment, StrokePoint } from '../types';
|
import type { StrokeSegment, StrokePoint } from '../types';
|
||||||
import { updateWidget } from '../utils/widgetBridge';
|
import { updateWidget } from '../utils/widgetBridge';
|
||||||
|
|
||||||
|
/** Fast enough for a shared canvas to feel live, and only while one is on screen. */
|
||||||
|
const POLL_INTERVAL_MS = 750;
|
||||||
|
|
||||||
interface UseDrawingSyncConfig {
|
interface UseDrawingSyncConfig {
|
||||||
coupleId: string;
|
coupleId: string;
|
||||||
userId: string | undefined;
|
userId: string | undefined;
|
||||||
@@ -14,6 +18,12 @@ interface UseDrawingSyncConfig {
|
|||||||
onUndo: (strokeId: string) => void;
|
onUndo: (strokeId: string) => void;
|
||||||
onRedo: (stroke: StrokeSegment) => void;
|
onRedo: (stroke: StrokeSegment) => void;
|
||||||
onLoadStrokes: (strokes: StrokeSegment[]) => void;
|
onLoadStrokes: (strokes: StrokeSegment[]) => void;
|
||||||
|
/**
|
||||||
|
* Supplied by the screen rather than read from navigation here, so the hook
|
||||||
|
* stays renderable without a navigator. A canvas nobody is looking at polls
|
||||||
|
* nothing; `usePolling` also stops it when the app goes to the background.
|
||||||
|
*/
|
||||||
|
isFocused?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UseDrawingSyncReturn {
|
interface UseDrawingSyncReturn {
|
||||||
@@ -52,7 +62,7 @@ export function mergeDrawingChanges(base: StrokeSegment[], desired: StrokeSegmen
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useDrawingSync(config: UseDrawingSyncConfig): UseDrawingSyncReturn {
|
export function useDrawingSync(config: UseDrawingSyncConfig): UseDrawingSyncReturn {
|
||||||
const { coupleId, userId, onLoadStrokes } = config;
|
const { coupleId, userId, onLoadStrokes, isFocused = true } = config;
|
||||||
const revisionRef = useRef(0);
|
const revisionRef = useRef(0);
|
||||||
const baselineRef = useRef<StrokeSegment[]>([]);
|
const baselineRef = useRef<StrokeSegment[]>([]);
|
||||||
const workingRef = useRef<StrokeSegment[]>([]);
|
const workingRef = useRef<StrokeSegment[]>([]);
|
||||||
@@ -132,19 +142,25 @@ export function useDrawingSync(config: UseDrawingSyncConfig): UseDrawingSyncRetu
|
|||||||
void appDataApi.getLiveDraw().then(acceptState).catch(error => console.error('Failed to load live drawing:', error));
|
void appDataApi.getLiveDraw().then(acceptState).catch(error => console.error('Failed to load live drawing:', error));
|
||||||
}, [coupleId, acceptState]);
|
}, [coupleId, acceptState]);
|
||||||
|
|
||||||
useEffect(() => {
|
// Our own writes already return the authoritative state, so reading over the top
|
||||||
if (!coupleId || !userId) return;
|
// of one in flight would only race it back to a revision we are about to replace.
|
||||||
let cancelled = false;
|
const poll = useCallback(async () => {
|
||||||
const poll = async () => {
|
if (pendingWritesRef.current > 0) return;
|
||||||
if (pendingWritesRef.current > 0) return;
|
const state = await appDataApi.getLiveDraw();
|
||||||
try {
|
if (state.revision > revisionRef.current) acceptState(state);
|
||||||
const state = await appDataApi.getLiveDraw();
|
}, [acceptState]);
|
||||||
if (!cancelled && state.revision > revisionRef.current) acceptState(state);
|
|
||||||
} catch (error) { console.error('Failed to poll live drawing:', error); }
|
// `leading: false` because the mount effect above already read the initial state;
|
||||||
};
|
// usePolling adds the overlap guard, backoff and background pause the raw
|
||||||
const timer = setInterval(() => void poll(), 750);
|
// interval had none of.
|
||||||
return () => { cancelled = true; clearInterval(timer); if (throttleRef.current) clearTimeout(throttleRef.current); };
|
usePolling(poll, {
|
||||||
}, [coupleId, userId, acceptState]);
|
intervalMs: POLL_INTERVAL_MS,
|
||||||
|
enabled: Boolean(coupleId) && Boolean(userId) && isFocused,
|
||||||
|
leading: false,
|
||||||
|
resetKey: `${coupleId}:${userId ?? ''}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => () => { if (throttleRef.current) clearTimeout(throttleRef.current); }, []);
|
||||||
|
|
||||||
const persistStrokes = useCallback((strokes: StrokeSegment[]) => {
|
const persistStrokes = useCallback((strokes: StrokeSegment[]) => {
|
||||||
if (throttleRef.current) clearTimeout(throttleRef.current);
|
if (throttleRef.current) clearTimeout(throttleRef.current);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useFocusEffect, useLocalSearchParams } from "expo-router";
|
import { useFocusEffect, useLocalSearchParams } from "expo-router";
|
||||||
|
|
||||||
import { useAuthStore, usePacksStore, useStreakStore } from "../../../store";
|
import { useAuthStore, usePacksStore, useResponsesStore, useStreakStore } from "../../../store";
|
||||||
import { skipQuestion, getSkippedQuestionIds } from "../../../lib/skippedQuestions";
|
import { skipQuestion, getSkippedQuestionIds } from "../../../lib/skippedQuestions";
|
||||||
import { ApiError, apiClient } from "../../../lib/apiClient";
|
import { ApiError, apiClient } from "../../../lib/apiClient";
|
||||||
import { Events } from "../../../lib/analytics";
|
import { Events } from "../../../lib/analytics";
|
||||||
@@ -352,6 +352,12 @@ export const useSwipeScreen = () => {
|
|||||||
// wrong partner state for the rest of the session. Refreshing it must never
|
// wrong partner state for the rest of the session. Refreshing it must never
|
||||||
// interfere with recording the answer, so it stays off the critical path.
|
// interfere with recording the answer, so it stays off the critical path.
|
||||||
void useStreakStore.getState().fetchStreak().catch(() => undefined);
|
void useStreakStore.getState().fetchStreak().catch(() => undefined);
|
||||||
|
|
||||||
|
// This answer changed pack progress and the answer history. Marking their
|
||||||
|
// caches stale costs no request: whichever screen the user opens next
|
||||||
|
// reloads once, and the ones they do not open stay quiet.
|
||||||
|
usePacksStore.getState().invalidatePacks();
|
||||||
|
useResponsesStore.getState().invalidateResponses();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// A 429 means the server rejected this answer outright. Do not advance the
|
// A 429 means the server rejected this answer outright. Do not advance the
|
||||||
// index, or the question is silently skipped and lost for the day.
|
// index, or the question is silently skipped and lost for the day.
|
||||||
|
|||||||
Loaded 100 of 127 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user