Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
130 commits
Select commit Hold shift + click to select a range
dfd8dbc
test: audit coverage and fix calibration trigger ordering (#697)
ng Sep 6, 2026
7351643
chore(deps): Update @base-ui/react all non-major dependencies (#699)
renovate[bot] Sep 7, 2026
64c70e6
feat(streaming): expose LPS diagnostic subscriptions (#701)
ng Sep 7, 2026
e8c742f
fix(deploy): require prebuilt updates and stage local builds over SSH…
ng Sep 9, 2026
101bbbc
fix(deploy): support explicit GitHub forks for branch builds (#705)
ng Sep 9, 2026
45a3ddd
chore(deps): Update @types/node all non-major dependencies (#710)
renovate[bot] Sep 14, 2026
6b661b2
chore(deps): Update @formatjs/intl-localematcher all non-major depend…
renovate[bot] Sep 21, 2026
d07adbb
fix(homekit): preserve pairings on shutdown and remove redundant powe…
ng Sep 25, 2026
3ca513b
fix(install): make SSH setup work on a stock Pod 4 (#717)
jasonlarue Sep 25, 2026
f2d1ec1
UI: single-sleeper view in away mode, ambient labels, alarm editor ov…
jasonlarue Sep 25, 2026
93998a7
fix(biometrics): pin RAW frames so the cold archive actually fills (#…
jasonlarue Sep 26, 2026
839deea
fix(sleep-detector): raw-unit, rise-only presence for Pod 3/4 capSense
jasonlarue Sep 24, 2026
ae89830
feat(sleep-detector): persist in-progress sessions across restarts
jasonlarue Sep 24, 2026
5b474cd
feat(sleep-detector): self-adjusting presence baseline, no scheduled …
jasonlarue Sep 24, 2026
a5625c6
feat(biometrics): single-sleeper mode when one side is in away mode
jasonlarue Sep 24, 2026
df5874b
fix(sleep-detector): restore every side's replay watermark after restart
jasonlarue Sep 25, 2026
65a43ed
fix(sleep-detector): persist the presence hysteresis latch
jasonlarue Sep 25, 2026
a81777c
fix(piezo-processor): keep single-sleeper vitals until they are written
jasonlarue Sep 25, 2026
d4344a3
fix(sleep-detector): seed from the published baseline when state is lost
jasonlarue Sep 25, 2026
61ce9ba
fix(biometrics): preserve vitals retries and flush during idle input
ng Sep 26, 2026
cfbe5b8
Fix Pod 3/4 presence: raw-unit rise, self-adjusting baseline, reboot-…
ng Sep 26, 2026
08c55fa
test(biometrics): cover single-sleeper page routing (#721)
ng Sep 26, 2026
4dac123
test(biometrics): cover side-toggle navigation branches (#722)
ng Sep 26, 2026
3d92989
Run CI on merge_group so the dev merge queue can gate on required che…
ng Sep 27, 2026
52f6979
ci: stop failing the test gate on Codecov upload errors (#725)
ng Sep 27, 2026
a7e33ab
docs: consolidate project guidance in AGENTS.md (#723)
ng Sep 27, 2026
3916398
chore(deps): Update @tanstack/react-query all non-major dependencies …
renovate[bot] Sep 28, 2026
97df464
feat: coordinate temperature ownership and persistent manual holds (#…
ng Sep 28, 2026
3a5d9be
perf: cache weekly temperature schedule occurrences (#729)
ng Sep 28, 2026
234ecb8
Merge main into dev to reconcile squash-merged release #720
ng Sep 28, 2026
980a33b
Merge main into dev to reconcile squash-merged release #730
ng Sep 28, 2026
399d9bd
feat: redesign the web UI around what needs attention on each screen,…
ng Sep 29, 2026
d39ed1a
feat(ui): split the sidebar footer into health and full-length issues…
ng Sep 29, 2026
a7e1b65
chore(deps): Update @eslint/json dependency @eslint/json to v2 (#651)
renovate[bot] Sep 29, 2026
c176812
chore(deps): Update @vitejs/plugin-react dependency @vitejs/plugin-re…
renovate[bot] Sep 29, 2026
17d5369
feat: add an Eight Sleep-style Now/Night/Dawn temperature stepper (#740)
ng Sep 29, 2026
6d1b64f
docs: refresh README stepper capture and guide links (#743)
ng Sep 29, 2026
fad75e6
feat(ui): fold System → Pipeline into Health's data path (#742)
ng Sep 29, 2026
2c9669a
fix(ui): make the Tonight timeline readable by merging schedule steps…
ng Sep 29, 2026
7139437
fix(ui): keep the temperature-control picker labels on one line on ph…
ng Sep 29, 2026
3831405
feat(ui): pod health dot on the phone System tab (#746)
ng Sep 29, 2026
e99323d
chore Lock file maintenance (#747)
renovate[bot] Sep 29, 2026
e4951f7
docs: link first-time Pod root-access walkthrough (#748)
ng Sep 30, 2026
a0f41b5
ci: back-merge main into dev after each release (#750)
ng Sep 30, 2026
d6788a4
Merge main into dev to reconcile squash-merged release #749
github-actions[bot] Sep 30, 2026
f7287cf
fix(piezo): repair the firmware's lost-sample markers (#736)
jasonlarue Sep 30, 2026
a488e46
fix(sleep-detector): Pod 3/4 movement above sensor noise, breathing f…
jasonlarue Sep 30, 2026
4ac7105
fix(health): pump reads unknown, not stalled, on pods without pump da…
jasonlarue Sep 30, 2026
203705f
fix(gestures): a temperature tap replaces the firmware's own adjustme…
jasonlarue Sep 30, 2026
ae38a4a
feat(ui): skeleton cards that load data before first paint (#751)
ng Sep 30, 2026
fbfcabd
fix(hardware): read a neutral firmware target as off while its countd…
ng Oct 1, 2026
d727e23
fix(homekit): round the requested temperature to whole °F so 28°C can…
ng Oct 1, 2026
0835c29
fix(api): keep a freshly mutated side's powered state on the status f…
ng Oct 1, 2026
069af10
fix(temperature): wait for the first firmware status before the recon…
ng Oct 1, 2026
a9e4602
test(hardware): assert stall-guard inputs and complete the stall-supp…
ng Oct 1, 2026
cc2e073
fix(hardware): derive a side's power from its commanded target, not t…
ng Oct 1, 2026
7eecbed
fix(temperature): treat device_state as unpowered until the firmware …
ng Oct 1, 2026
c44e17c
feat(mcp): intent-shaped MCP server over the tRPC API (#753)
ng Oct 1, 2026
f377d2a
feat(schedule): draw curves as steps over the measured bed temperatur…
ng Oct 1, 2026
7efcdc0
Merge main into dev to reconcile squash-merged release #761
github-actions[bot] Oct 1, 2026
0f69aff
docs: slim the README now that sleepypod.github.io hosts the guides (…
ng Oct 1, 2026
d4510c3
fix(ci): publish branch builds to one prerelease so feature branches …
ng Oct 2, 2026
959f12b
fix(ci): install feature branches from the Build & Package artifact a…
ng Oct 2, 2026
5a96282
feat(demo): hosted demo mode backed by in-browser fake pod data (#765)
ng Oct 2, 2026
74e05e0
test(automation): cover BiometricsSignalReader and #664 codecov gaps …
ng Oct 2, 2026
210beb4
fix(hardware): clear Pod timer when powering off (#713)
arubacao Oct 2, 2026
3cbbc1b
Merge main into dev to reconcile squash-merged release #769
github-actions[bot] Oct 3, 2026
0348650
Merge main into dev to reconcile squash-merged release #771
github-actions[bot] Oct 4, 2026
8b9ea0b
fix(alarms): alarm fixes — snooze, wake window and warm-up (#766)
jasonlarue Oct 5, 2026
698a579
fix(demo): add system.getSensorSource demo handler (#781)
ng Oct 5, 2026
0e5d4c4
test(alarms): cover missing branches from alarm fixes (#779)
ng Oct 5, 2026
571d558
fix(hardware): recover DAC connections after firmware restarts (#782)
ng Oct 6, 2026
fb76d05
feat(health): report V8 heap and native memory counters alongside RSS…
ng Oct 6, 2026
29bc210
chore(deps): Update @modelcontextprotocol/sdk all non-major dependenc…
renovate[bot] Oct 6, 2026
4f3991a
perf(system): probe firmware once per process for the sensor source c…
ng Oct 6, 2026
c29fc0b
test(hooks): kill useSensorStream surviving mutants (#591 hit list) (…
ng Oct 6, 2026
ff43df9
perf(temperature): take cron-parser and per-call ORM reads off the co…
ng Oct 6, 2026
0aeb906
fix(piezo-processor): no vitals while the bed reads empty (#768)
jasonlarue Oct 6, 2026
4d15c84
feat(base): add experimental adjustable-base controls, schedules and …
ng Oct 6, 2026
0e8e51e
fix(base): prevent 2D fallback flicker during 3D loading (#784)
ng Oct 6, 2026
2adc177
fix(hardware): prevent false pump stalls during startup (#786)
ng Oct 7, 2026
969d352
fix(installer): separate socket discovery from hardware identity (#785)
ng Oct 7, 2026
58ac83f
feat(settings): separate solo bed setup from partner away mode (#767)
jasonlarue Oct 7, 2026
13f0735
feat(temp): 3D thermal bed, temperature Stage and sweep-mode piezo (#…
ng Oct 8, 2026
d6e8806
feat(schedules): maintain temperature after a curve ends (#789)
ng Oct 8, 2026
c7fbe0c
test: cover release #783 gaps in Stage, Base and piezo views (#790)
ng Oct 8, 2026
19bb185
feat(settings): 24-hour time format, stored on the pod (#788)
ng Oct 8, 2026
4d8d19d
Merge main into dev to reconcile squash-merged release #783
github-actions[bot] Oct 8, 2026
c790e94
chore(deps): Update @lingui/babel-plugin-lingui-macro linguijs monore…
renovate[bot] Oct 8, 2026
624e625
chore(deps): Update @eslint/css dependency @eslint/css to v2 (#775)
renovate[bot] Oct 8, 2026
5eee4d5
chore(deps): drop redundant @babel/preset-typescript that blocks Babe…
ng Oct 8, 2026
a8a0ae3
Merge main into dev to reconcile squash-merged release #792
github-actions[bot] Oct 8, 2026
2c28d2e
feat(stage): make the Stage the Temperature screen default, with tone…
ng Oct 8, 2026
0d8ae98
fix(stage): say Off once in the side panel instead of twice (#795)
ng Oct 8, 2026
9f3d5ba
feat(schedule): curve deep links, smooth curves and leaner set points…
ng Oct 9, 2026
8b52e84
feat(temp): shared notice banners and toasts, and an away card state …
ng Oct 9, 2026
8077afa
Merge main into dev to reconcile squash-merged release #797
github-actions[bot] Oct 9, 2026
6d457ef
fix: fail clearly when the Pod has no build instead of crash-looping …
ng Oct 10, 2026
74110f7
fix: prevent priming from triggering false pump leak alerts (#798)
ng Oct 10, 2026
76a9110
Merge main into dev to reconcile squash-merged release #800
github-actions[bot] Oct 10, 2026
025ceae
fix: stop flashing the stalled-pipeline banner before the latest vita…
ng Oct 10, 2026
fa2f339
feat: ingest Apple Watch reference sleep stages for classifier calibr…
ng Oct 11, 2026
d091a11
feat: sleep-stage dataset builder joining Watch stages with pod vital…
ng Oct 11, 2026
0fd1a85
fix: end sessions when the bed empties to a new level and score movem…
ng Oct 11, 2026
9441c62
fix: make the Stage's SCHEDULED label readable at 80° (#806)
ng Oct 11, 2026
84293dd
feat: per-side tabs inside Settings sections replace the global side …
ng Oct 11, 2026
e7c3b2f
feat: away mode toggle in the side card and Stage panel (#808)
ng Oct 11, 2026
cf46b5d
fix(sleep): classify stages with the side's piezo calibration quality…
ng Oct 12, 2026
83394a3
chore(deps): Update @lingui/swc-plugin dependency @lingui/swc-plugin …
renovate[bot] Oct 12, 2026
339925c
chore(deps): Update @semantic-release/git dependency @semantic-releas…
renovate[bot] Oct 12, 2026
84c09b7
fix: let cap-sense occupancy relax piezo presence re-entry so quiet s…
ng Oct 12, 2026
a50eb71
feat(sleep): let users define the sleep stage classification rules (#…
ng Oct 12, 2026
16fecf7
chore(deps): Update @base-ui/react all non-major dependencies (#818)
renovate[bot] Oct 12, 2026
ea3a598
fix(build): pin @babel/runtime to 7.29.7 so Turbopack resolves mqtt's…
ng Oct 12, 2026
add1507
fix(sleep): do not classify the week view at full quality while calib…
ng Oct 12, 2026
3e9027e
feat: import Apple Watch heart rate into the pod and overlay it on th…
ng Oct 12, 2026
6253b97
feat(biometrics): explore vitals and import Apple Watch data (#821)
ng Oct 12, 2026
0cd3151
feat(sleep): sleep stage rules as sentences with quick adjust, previe…
ng Oct 13, 2026
0922bc0
feat(mcp): sleep-stage rule inspection, dry-run preview and apply aga…
ng Oct 13, 2026
2a42ce9
feat(mcp): build and save a sleep curve from pod data (#825)
ng Oct 14, 2026
7e58793
feat(curves): save named curves on the pod (#827)
ng Oct 14, 2026
4ea6aa5
feat(schedule): optional notes on curves (#832)
ng Oct 14, 2026
d4df234
fix(biometrics): accept same-origin Watch uploads and use a button fo…
ng Oct 14, 2026
e681908
fix(security): reject unknown Host headers so DNS-rebinding pages can…
ng Oct 14, 2026
b18e100
fix(stream): gate the sensor WebSocket handshake on the Host/Origin a…
ng Oct 14, 2026
0e7e865
feat(biometrics): add versioned calibration profiles, references and …
ng Oct 14, 2026
35e6141
feat(biometrics): add retained backtest replay engine and isolated pa…
ng Oct 14, 2026
d5dd2dd
feat(biometrics): add prospective shadow runtime, coordinator host an…
ng Oct 14, 2026
f905a77
feat(biometrics): add calibration UI, browser tests and docs (#831)
ng Oct 14, 2026
cbc5387
feat: experimental presence auto-on with person-vs-pet occupancy clas…
ng Oct 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
2 changes: 2 additions & 0 deletions .github/workflows/mutation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ jobs:
mutate: "src/streaming/**/*.ts,!src/**/tests/**,!src/**/*.test.ts,!src/**/*.test.tsx,!src/**/*.d.ts"
- name: hooks
mutate: "src/hooks/**/*.ts,src/hooks/**/*.tsx,!src/**/tests/**,!src/**/*.test.ts,!src/**/*.test.tsx,!src/**/*.d.ts"
- name: biometrics
mutate: "src/biometrics/**/*.ts,!src/**/tests/**,!src/**/*.test.ts,!src/**/*.test.tsx,!src/**/*.d.ts"
- name: db
mutate: "src/db/**/*.ts,!src/**/tests/**,!src/**/*.test.ts,!src/**/*.test.tsx,!src/**/*.d.ts"
name: mutate (${{ matrix.shard.name }})
Expand Down
36 changes: 28 additions & 8 deletions .github/workflows/python-modules.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,23 @@ on:
branches: [dev]
paths:
- 'modules/**/*.py'
- 'modules/common/biometrics/*.json'
- 'modules/piezo-processor/fixtures/*.json'
- 'modules/**/pyproject.toml'
- 'modules/**/uv.lock'
- 'modules/.coveragerc'
- 'scripts/**/*.py'
- '.github/workflows/python-modules.yml'
pull_request:
types: [opened, synchronize, reopened]
paths:
- 'modules/**/*.py'
- 'modules/common/biometrics/*.json'
- 'modules/piezo-processor/fixtures/*.json'
- 'modules/**/pyproject.toml'
- 'modules/**/uv.lock'
- 'modules/.coveragerc'
- 'scripts/**/*.py'
- '.github/workflows/python-modules.yml'
workflow_dispatch:

Expand Down Expand Up @@ -48,9 +54,9 @@ jobs:
workdir: modules
target: common
cov_source: common
# cbor2 is needed to decode the real NATS capture fixtures through
# NatsFollower's decode path (test_nats_follower).
deps: "cbor2"
# Build-identity tests inspect installed NumPy/SciPy metadata;
# use the piezo module's locked environment, including cbor2.
deps: ""
- name: cover-buttons
workdir: modules/cover-buttons
target: .
Expand All @@ -70,12 +76,20 @@ jobs:
workdir: modules/piezo-processor
target: .
cov_source: .
cov_extra: "--cov=../common/biometrics"
deps: "numpy scipy"
- name: sleep-detector
workdir: modules/sleep-detector
target: .
cov_source: .
deps: ""
# Stdlib-only repo scripts; tests load them by path from
# scripts/tests/.
- name: scripts
workdir: scripts
target: tests
cov_source: .
deps: ""

steps:
- name: Checkout
Expand All @@ -86,7 +100,7 @@ jobs:
with:
# Pod runtime is pinned to 3.10 by every module's pyproject
# (`requires-python = ">=3.9,<3.11"`). Match it in CI.
python-version: '3.14'
python-version: '3.10'

- name: Install uv
uses: astral-sh/setup-uv@v4
Expand All @@ -96,11 +110,16 @@ jobs:
- name: Install test deps
working-directory: ${{ matrix.module.workdir }}
run: |
if [ -f uv.lock ]; then
if [ "${{ matrix.module.name }}" = "common" ]; then
# Keep common's cwd/import layout while using the numerical lock.
UV_PROJECT_ENVIRONMENT="$PWD/.venv" uv sync --project piezo-processor --locked --no-install-project
elif [ -f uv.lock ]; then
uv sync --locked --no-install-project
else
uv venv .venv
uv pip install --python .venv/bin/python ${{ matrix.module.deps }}
if [ -n "${{ matrix.module.deps }}" ]; then
uv pip install --python .venv/bin/python ${{ matrix.module.deps }}
fi
fi
# shellcheck source=/dev/null
. .venv/bin/activate
Expand All @@ -114,12 +133,13 @@ jobs:
pytest ${{ matrix.module.target }} \
--cov-config=${{ github.workspace }}/modules/.coveragerc \
--cov=${{ matrix.module.cov_source }} \
${{ matrix.module.cov_extra }} \
--cov-report=xml:coverage.xml \
--junit-xml=junit.xml \
-o junit_family=legacy

- name: Upload coverage to Codecov
if: ${{ !cancelled() }}
if: ${{ github.repository == 'sleepypod/core' && !cancelled() }}
uses: codecov/codecov-action@v5
with:
use_oidc: true
Expand All @@ -132,7 +152,7 @@ jobs:
working-directory: ${{ matrix.module.workdir }}

- name: Upload test results to Codecov
if: ${{ !cancelled() }}
if: ${{ github.repository == 'sleepypod/core' && !cancelled() }}
uses: codecov/codecov-action@v5
with:
report_type: test_results
Expand Down
36 changes: 33 additions & 3 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ jobs:
- { name: "Lint", cmd: "CHANGED=$(git diff --name-only --diff-filter=d origin/${BASE_REF#refs/heads/}...HEAD -- '*.ts' '*.tsx' | head -200); if [ -n \"$CHANGED\" ]; then echo \"$CHANGED\" | xargs -d '\\n' pnpm eslint --; else echo 'No TS/TSX files changed'; fi" }
- { name: "Typecheck", cmd: "pnpm tsc" }
- { name: "Unit Tests", cmd: "pnpm test run --coverage --passWithNoTests" }
- { name: "Browser Tests", cmd: "NODE_ENV=production CI=1 pnpm build && pnpm test:browser" }

steps:
- name: Checkout
Expand All @@ -51,6 +52,27 @@ jobs:
- name: Install Dependencies
run: pnpm install --frozen-lockfile

- name: Setup Python for runtime integration
if: matrix.task.name == 'Unit Tests' || matrix.task.name == 'Browser Tests'
uses: actions/setup-python@v6
with:
python-version: '3.10'

- name: Setup uv for runtime integration
if: matrix.task.name == 'Unit Tests' || matrix.task.name == 'Browser Tests'
uses: astral-sh/setup-uv@v4
with:
enable-cache: true

- name: Install locked runtime integration dependencies
if: matrix.task.name == 'Unit Tests' || matrix.task.name == 'Browser Tests'
working-directory: modules/piezo-processor
run: uv sync --locked --no-install-project

- name: Install browser
if: matrix.task.name == 'Browser Tests'
run: pnpm exec playwright install --with-deps chromium

- name: Cache Assets (Vitest)
id: asset-cache
uses: actions/cache@v5
Expand All @@ -68,7 +90,7 @@ jobs:
BASE_REF: ${{ github.event.pull_request.base.ref || github.event.merge_group.base_ref }}

- name: Upload coverage reports to Codecov
if: matrix.task.name == 'Unit Tests' && !cancelled()
if: github.repository == 'sleepypod/core' && matrix.task.name == 'Unit Tests' && !cancelled()
uses: codecov/codecov-action@v5
with:
use_oidc: true
Expand All @@ -79,7 +101,7 @@ jobs:
flags: javascript

- name: Upload test results to Codecov
if: matrix.task.name == 'Unit Tests' && !cancelled()
if: github.repository == 'sleepypod/core' && matrix.task.name == 'Unit Tests' && !cancelled()
uses: codecov/codecov-action@v5
with:
report_type: test_results
Expand All @@ -89,4 +111,12 @@ jobs:
fail_ci_if_error: false
slug: sleepypod/core
files: test-results/junit.xml
flags: javascript
flags: javascript

- name: Upload browser evidence
if: matrix.task.name == 'Browser Tests' && !cancelled()
uses: actions/upload-artifact@v6
with:
name: calibration-browser-results
path: test-results/browser
retention-days: 7
5 changes: 3 additions & 2 deletions app/[lang]/schedule/[curve]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,16 @@ const SIDES: SideSelection[] = ['left', 'right', 'both']

export default async function ScheduleCurve({ params, searchParams }: {
params: Promise<{ curve: string }>
searchParams: Promise<{ side?: string | string[], from?: string | string[] }>
searchParams: Promise<{ side?: string | string[], from?: string | string[], saved?: string | string[] }>
}) {
const { curve } = await params
const { side, from } = await searchParams
const { side, from, saved } = await searchParams
return (
<SchedulePage
curve={curve}
curveSide={SIDES.find(s => s === side)}
fromBoth={from === 'both'}
savedCurve={typeof saved === 'string' ? saved : undefined}
/>
)
}
157 changes: 157 additions & 0 deletions app/api/biometrics/watch-upload/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
// @vitest-environment node
import { createServer, type Server } from 'node:http'
import { mkdtemp, readdir, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterAll, beforeAll, expect, it, vi } from 'vitest'
import { POST } from './route'

let directory: string
let server: Server
const seen = new Set<number>()
const requests: Array<{ side: string, source: string }> = []
beforeAll(async () => {
directory = await mkdtemp(join(tmpdir(), 'watch-upload-test-'))
vi.stubEnv('WATCH_UPLOAD_DIR', directory)
server = createServer(async (req, res) => {
let body = ''
for await (const chunk of req) body += chunk
const data = JSON.parse(body)
requests.push(data)
let written = 0
for (const sample of data.samples ?? []) {
if (!seen.has(sample.timestamp)) {
seen.add(sample.timestamp)
written++
}
}
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ written }))
})
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
const address = server.address()
if (!address || typeof address === 'string') throw new Error('missing test port')
vi.stubEnv('PORT', String(address.port))
})
afterAll(async () => {
await new Promise<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve()))
await rm(directory, { recursive: true, force: true })
vi.unstubAllEnvs()
})

it('streams XML through the real importer, routes to the selected side, dedupes, and removes the file', async () => {
const date = new Date().toISOString().replace('T', ' ').replace(/\.\d+Z$/, ' +0000')
const xml = `<HealthData><Record type="HKQuantityTypeIdentifierHeartRate" sourceName="Apple Watch" startDate="${date}" endDate="${date}" value="62" unit="count/min" /></HealthData>`
const request = () => new Request('http://pod/api/biometrics/watch-upload?side=right&days=7', { method: 'POST', body: xml })
const response = await POST(request())
expect(response.status).toBe(200)
expect(await response.json()).toMatchObject({ vitals: 1, stages: 0, matchedVitals: 1 })
expect(requests[0]).toMatchObject({ side: 'right', source: 'apple_watch' })
expect(await (await POST(request())).json()).toMatchObject({ vitals: 0, matchedVitals: 1 })
expect(await readdir(directory)).toEqual([])
})

it('rejects malformed files and cleans up after parser failure', async () => {
const response = await POST(new Request('http://pod/api/biometrics/watch-upload?side=left', { method: 'POST', body: '<HealthData><broken' }))
expect(response.status).toBe(422)
expect(await readdir(directory)).toEqual([])
})

it('gives import a fresh budget after staging and ignores the expired upload budget', async () => {
const upload = new AbortController()
const importer = new AbortController()
let staged = false
const timeout = vi.spyOn(AbortSignal, 'timeout')
.mockReturnValueOnce(upload.signal)
.mockImplementationOnce(() => {
expect(staged).toBe(true)
upload.abort(new DOMException('Upload budget expired', 'TimeoutError'))
return importer.signal
})
try {
const body = new ReadableStream({
pull(controller) {
controller.enqueue(new TextEncoder().encode('<HealthData/>'))
staged = true
controller.close()
},
})
const request = new Request('http://pod/api/biometrics/watch-upload?side=left', { method: 'POST', body, duplex: 'half' } as RequestInit)
const response = await POST(request)
expect(response.status).toBe(200)
expect(timeout.mock.calls.map(call => call[0])).toEqual([30 * 60_000, 10 * 60_000])
expect(await readdir(directory)).toEqual([])
}
finally { timeout.mockRestore() }
})

it('accepts a same-origin browser upload even though Next reports the 0.0.0.0 bind address in request.url', async () => {
const response = await POST(new Request('http://0.0.0.0:3000/api/biometrics/watch-upload?side=left', {
method: 'POST', headers: { origin: 'http://192.168.1.88:3000', host: '192.168.1.88:3000' }, body: '<HealthData/>',
}))
expect(response.status).toBe(200)
expect(await readdir(directory)).toEqual([])
})

it('rejects cross-origin requests, missing side, and oversized bodies before import', async () => {
for (const origin of ['http://evil', 'http://192.168.1.88:8080', 'null']) {
const response = await POST(new Request('http://0.0.0.0:3000/api/biometrics/watch-upload?side=left', { method: 'POST', headers: { origin, host: '192.168.1.88:3000' }, body: 'x' }))
expect(response.status).toBe(403)
}
expect((await POST(new Request('http://pod/api/biometrics/watch-upload', { method: 'POST', body: 'x' }))).status).toBe(400)
expect((await POST(new Request('http://pod/api/biometrics/watch-upload?side=left', { method: 'POST', headers: { 'content-length': String(513 * 1024 * 1024) }, body: 'x' }))).status).toBe(413)
})

it('still honors a client disconnect when switching to the import budget', async () => {
const client = new AbortController()
const timeout = vi.spyOn(AbortSignal, 'timeout')
.mockReturnValueOnce(new AbortController().signal)
.mockImplementationOnce(() => {
client.abort()
return new AbortController().signal
})
try {
const response = await POST(new Request('http://pod/api/biometrics/watch-upload?side=left', {
method: 'POST', body: '<HealthData/>', signal: client.signal,
}))
expect(response.status).toBe(408)
expect(await readdir(directory)).toEqual([])
}
finally { timeout.mockRestore() }
})

it('rejects missing and empty bodies without retaining staged files', async () => {
for (const body of [undefined, '']) {
const response = await POST(new Request('http://pod/api/biometrics/watch-upload?side=left', { method: 'POST', body }))
expect(response.status).toBe(400)
expect(await readdir(directory)).toEqual([])
}
})

it('enforces the streaming size limit even without a content-length header', async () => {
const chunk = new Uint8Array(1)
Object.defineProperty(chunk, 'byteLength', { value: 513 * 1024 * 1024 })
const body = new ReadableStream({
start(controller) {
controller.enqueue(chunk)
controller.close()
},
})
const response = await POST(new Request('http://pod/api/biometrics/watch-upload?side=left', { method: 'POST', body, duplex: 'half' } as RequestInit))
expect(response.status).toBe(413)
expect(await response.json()).toEqual({ error: 'Upload exceeds 512 MiB' })
expect(await readdir(directory)).toEqual([])
})

it('rejects concurrent uploads and releases the slot after staging is cancelled', async () => {
const client = new AbortController()
const response = POST(new Request('http://pod/api/biometrics/watch-upload?side=left', {
method: 'POST', body: new ReadableStream(), duplex: 'half', signal: client.signal,
} as RequestInit))
const competing = await POST(new Request('http://pod/api/biometrics/watch-upload?side=right', { method: 'POST', body: '<HealthData/>' }))
expect(competing.status).toBe(409)
client.abort()
expect((await response).status).toBe(408)
expect(await readdir(directory)).toEqual([])
expect((await POST(new Request('http://pod/api/biometrics/watch-upload?side=left', { method: 'POST', body: '<HealthData/>' }))).status).toBe(200)
})
Loading
Loading