Merge pull request #8 from jjbrunton/staging

chore(release): promote staging for v1.0.6
This commit is contained in:
jjbrunton authored and GitHub committed 2026-08-29 23:34:30 +01:00
commit 387b242aea
127 files changed
+5407 -431

No files matched your search

+11
View File
@@ -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.
"""
+12
View File
@@ -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.
"""
+12
View File
@@ -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.
"""
+11
View File
@@ -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.
"""
+12
View File
@@ -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.
"""
+11
View File
@@ -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.
"""
+11
View File
@@ -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.
"""
+5
View File
@@ -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"
+73
View File
@@ -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
+116
View File
@@ -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
View File
@@ -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}`);
+6 -3
View File
@@ -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
+3 -1
View File
@@ -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
+14 -1
View File
@@ -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`
+9
View File
@@ -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.
+4
View File
@@ -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",
+13
View File
@@ -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 {
+9
View File
@@ -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,
+52
View File
@@ -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();
}
+21 -3
View File
@@ -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);
} }
} }
+5 -3
View File
@@ -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); }
} }
+20 -7
View File
@@ -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);}
} }
+129 -2
View File
@@ -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); }
} }
+8
View File
@@ -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));
+7 -3
View File
@@ -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);
} }
} }
+111 -17
View File
@@ -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;
const visible = `not exists (select 1 from message_deletions d where d.message_id = m.id and d.user_id = $2)`;
// Expiry is the one visible change no row write announces: the clock crosses
// `media_expires_at` and nothing is updated, so no timestamp moves past the
// cursor. Comparing the deadline against the window instead emits the row in
// the single poll whose `(since, now()]` contains it, which is exactly when
// the derived `media_expired` above flips.
const changed = `(m.created_at > $3 or m.read_at > $3 or m.delivered_at > $3
or m.deleted_at > $3 or m.media_viewed_at > $3
or (m.media_expires_at > $3 and m.media_expires_at <= now()))`;
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} `select ${columns}
from messages m from messages m
where m.match_id = $1 where m.match_id = $1 and ${visible}${since === null ? '' : ` and ${changed}`}
and not exists (
select 1 from message_deletions d where d.message_id = m.id and d.user_id = $2
)
order by m.created_at desc, m.id desc order by m.created_at desc, m.id desc
limit 500`, limit 500`,
[matchId, userId], since === null ? [matchId, userId] : [matchId, userId, since],
); ),
return result.rows.map(toMessage); 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);
} }
} }
+12 -2
View File
@@ -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) => {
+7 -3
View File
@@ -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);
} }
} }
+7 -3
View File
@@ -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);
} }
} }
+4 -2
View File
@@ -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); }
} }
+38 -2
View File
@@ -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) {
+6 -1
View File
@@ -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};
} }
} }
+7 -3
View File
@@ -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
View File
@@ -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);
+16
View File
@@ -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;
}
+43
View File
@@ -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();
+64
View File
@@ -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;
}
+2 -1
View File
@@ -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};
+8 -6
View File
@@ -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();
+47
View File
@@ -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();
});
});
+66 -1
View File
@@ -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'});
+11 -1
View File
@@ -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);});
}); });
+25
View File
@@ -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);
+6
View File
@@ -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),
+12
View File
@@ -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');
});
}); });
+99 -3
View File
@@ -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);
+24 -1
View File
@@ -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);
+49
View File
@@ -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();
});
});
+17
View File
@@ -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');
});
});
+10 -1
View File
@@ -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);
});
});
+8 -1
View File
@@ -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});
});
}); });
+50
View File
@@ -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();
});
});
+30
View File
@@ -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();
});
});
+11
View File
@@ -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,
},
},
}, },
}); });
+11
View File
@@ -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,
},
},
}, },
}); });
+31 -45
View File
@@ -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(() => {
+53 -15
View File
@@ -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)}
/> />
+18 -8
View File
@@ -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);
} }
+6 -5
View File
@@ -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]}
+2 -4
View File
@@ -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]);
+15
View File
@@ -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/.*)',
], ],
+1
View File
@@ -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 });
try {
useMatchStore.getState().setCurrentView('archived'); useMatchStore.getState().setCurrentView('archived');
expect(useMatchStore.getState()).toMatchObject({ currentView: 'archived', showArchived: true }); expect(useMatchStore.getState()).toMatchObject({ currentView: 'archived', showArchived: true });
expect(fetchArchivedMatches).toHaveBeenCalled(); 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);
});
});
+3 -3
View File
@@ -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';
+20 -13
View File
@@ -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"), if (!mountedRef.current) return;
setActive(activeResult.dares);
const signature = activeSignature(activeResult.dares);
const changed = signature !== signatureRef.current;
signatureRef.current = signature;
if (full || changed) {
const [historyResult, catalogResult, statsResult] = await Promise.all([
daresApi.list("history"), daresApi.list("history"),
daresApi.catalog(), daresApi.catalog(),
daresApi.stats(), daresApi.stats(),
]); ]);
if (!mountedRef.current) return; if (!mountedRef.current) return;
setActive(activeResult.dares);
setHistory(historyResult.dares); setHistory(historyResult.dares);
setCatalog(catalogResult); setCatalog(catalogResult);
setStats(statsResult); 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;
try {
const state = await appDataApi.getLiveDraw(); const state = await appDataApi.getLiveDraw();
if (!cancelled && state.revision > revisionRef.current) acceptState(state); if (state.revision > revisionRef.current) acceptState(state);
} catch (error) { console.error('Failed to poll live drawing:', error); } }, [acceptState]);
};
const timer = setInterval(() => void poll(), 750); // `leading: false` because the mount effect above already read the initial state;
return () => { cancelled = true; clearInterval(timer); if (throttleRef.current) clearTimeout(throttleRef.current); }; // usePolling adds the overlap guard, backoff and background pause the raw
}, [coupleId, userId, acceptState]); // interval had none of.
usePolling(poll, {
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