mirror of
https://github.com/bckelley/cgm-remote-monitor.git
synced 2026-08-24 03:14:12 -05:00
Merge pull request #8456 from nightscout/wip/test-improvements
Wip/test improvements - extends same fixes for data shape across all remaining api surface areas and includes test coverage across the test matrix spectrum.
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to cgm-remote-monitor are documented in this file.
|
||||
|
||||
## [15.0.7] - 2026-03-XX (Unreleased)
|
||||
|
||||
### Added
|
||||
|
||||
#### UUID/Identifier Handling (REQ-SYNC-072)
|
||||
|
||||
- **`UUID_HANDLING` env var** (default: `true`): Feature flag that controls UUID `_id` normalization for treatments and entries.
|
||||
- When `true`: UUID values sent as `_id` are extracted to the `identifier` field and a server-generated ObjectId is assigned. GET/DELETE by UUID are routed through the `identifier` field.
|
||||
- When `false`: UUID `_id` values are stripped (UUID identity not preserved) and UUID-based queries return empty results.
|
||||
- **Treatments API**: Loop overrides with UUID `_id` are now normalized correctly, preventing duplicate records (Issue #8450).
|
||||
- **Entries API**: CGM entries (e.g., Trio) with UUID `_id` are now handled correctly.
|
||||
- **Scope**: Only UUID values in the `_id` field are affected. Other client identity fields (`syncIdentifier`, `uuid`, `identifier`) are preserved but not modified.
|
||||
|
||||
#### Test Infrastructure
|
||||
|
||||
- **NODE_ENV=test safety check**: Tests now refuse to run without `NODE_ENV=test`, preventing accidental production database modification.
|
||||
- Comprehensive test suite for UUID handling behavior across write and read paths.
|
||||
|
||||
### Documentation
|
||||
|
||||
- Updated README.md with `UUID_HANDLING` and MongoDB pool configuration env vars.
|
||||
- Added entries schema documentation (`docs/data-schemas/entries-schema.md`).
|
||||
- Updated treatments schema documentation with identifier normalization behavior.
|
||||
- Added test environment variables reference to CONTRIBUTING.md.
|
||||
|
||||
---
|
||||
|
||||
## [15.0.6] - Previous Release
|
||||
|
||||
See GitHub releases for prior changelog entries.
|
||||
@@ -142,6 +142,69 @@ Once `dev` has been reviewed and people feel it's time to release, we follow the
|
||||
|
||||
Every commit is tested by travis. We encourage adding tests to validate your design. We encourage discussing your use cases to help everyone get a better understanding of your design.
|
||||
|
||||
## Running Tests Locally
|
||||
|
||||
Tests **require** `NODE_ENV=test` to protect production databases from accidental destruction. If this variable is not set, tests will refuse to run and exit with an error.
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
NODE_ENV=test npm test
|
||||
|
||||
# Run only unit tests (parallel, fast)
|
||||
NODE_ENV=test npm run test:unit
|
||||
|
||||
# Run only integration tests (sequential, needs MongoDB)
|
||||
NODE_ENV=test npm run test:integration
|
||||
|
||||
# Run specific test files
|
||||
NODE_ENV=test npm test -- --grep "treatments"
|
||||
```
|
||||
|
||||
### Advanced Test Scripts
|
||||
|
||||
For diagnosing test issues and ensuring reliability:
|
||||
|
||||
```bash
|
||||
# Run stress tests for concurrent write operations
|
||||
NODE_ENV=test npm run test:stress
|
||||
|
||||
# Detect flaky tests by running multiple iterations
|
||||
NODE_ENV=test npm run test:flaky # Default iterations
|
||||
NODE_ENV=test npm run test:flaky:quick # 3 iterations
|
||||
NODE_ENV=test npm run test:flaky:thorough # 20 iterations
|
||||
|
||||
# Run specific flaky test harnesses
|
||||
NODE_ENV=test npm run test:flaky:entries # Entries isolation tests
|
||||
NODE_ENV=test npm run test:flaky:socket # Socket isolation tests
|
||||
|
||||
# Enable timing warnings to find slow tests
|
||||
NODE_ENV=test npm run test:timing
|
||||
```
|
||||
|
||||
You can create a `my.test.env` file based on `ci.test.env` for local testing:
|
||||
|
||||
```bash
|
||||
source my.test.env && npm test
|
||||
```
|
||||
|
||||
### Test Environment Variables
|
||||
|
||||
These variables control test behavior and MongoDB connection pooling:
|
||||
|
||||
| Variable | Purpose | Default |
|
||||
|----------|---------|---------|
|
||||
| `NODE_ENV=test` | **Required** - Enables test mode, prevents production DB access | - |
|
||||
| `MONGO_POOL_SIZE` | MongoDB connection pool size | 5 |
|
||||
| `MONGO_MIN_POOL_SIZE` | Minimum pool connections to keep open | 0 |
|
||||
| `MONGO_MAX_IDLE_TIME_MS` | Max idle time (ms) before closing connection | 30000 |
|
||||
| `AUTH_FAIL_DELAY` | Delay (ms) after auth failure (test speedup) | 5000 |
|
||||
|
||||
For CI or resource-constrained environments, adjust pool size:
|
||||
|
||||
```bash
|
||||
MONGO_POOL_SIZE=3 MONGO_MIN_POOL_SIZE=1 npm test
|
||||
```
|
||||
|
||||
## Other Dev Tips
|
||||
|
||||
* Join the [Discord chat][discord-url].
|
||||
|
||||
@@ -161,8 +161,8 @@ Older versions or other browsers might work, but are untested and unsupported. W
|
||||
|
||||
## Installation software requirements:
|
||||
|
||||
- [Node.js](http://nodejs.org/) Latest Node v14 or v16 LTS. Node versions that do not have the latest security patches will not be supported. Use [Install instructions for Node](https://nodejs.org/en/download/package-manager/) or use `bin/setup.sh`)
|
||||
- [MongoDB](https://www.mongodb.com/download-center?jmp=nav#community) 4.2 or 4.4.
|
||||
- [Node.js](http://nodejs.org/) Node v20 LTS or later (v22, v24 also supported). Node versions that do not have the latest security patches will not be supported. Use [Install instructions for Node](https://nodejs.org/en/download/package-manager/) or use `bin/setup.sh`)
|
||||
- [MongoDB](https://www.mongodb.com/download-center?jmp=nav#community) 4.4 or later (5.0, 6.0 also supported).
|
||||
|
||||
As a non-root user clone this repo then install dependencies into the root of the project:
|
||||
|
||||
@@ -252,6 +252,7 @@ To learn more about the Nightscout API, visit https://YOUR-SITE.com/api-docs/ or
|
||||
Setting it to `denied` will require a token from every visit, using `status-only` will enable api-secret based login.
|
||||
* `IMPORT_CONFIG` - Used to import settings and extended settings from a url such as a gist. Structure of file should be something like: `{"settings": {"theme": "colors"}, "extendedSettings": {"upbat": {"enableAlerts": true}}}`
|
||||
* `TREATMENTS_AUTH` (`on`) - possible values `on` or `off`. Deprecated, if set to `off` the `careportal` role will be added to `AUTH_DEFAULT_ROLES`
|
||||
* `UUID_HANDLING` (`true`) - Controls how UUID `_id` values are handled for treatments and entries. When `true` (default), if a client sends a UUID string as the `_id` field, it is extracted to the `identifier` field (for sync deduplication) and the server generates a proper ObjectId for `_id`. Queries by UUID (`GET`/`DELETE`) are also routed through the `identifier` field. When `false`, UUID `_id` values are silently stripped on write (no identifier is preserved) and UUID-based queries return empty results. This only affects the specific case where a UUID is sent as `_id` (e.g., Loop overrides, Trio CGM entries).
|
||||
|
||||
#### Data Rights
|
||||
|
||||
@@ -288,6 +289,9 @@ autonomy for your data:
|
||||
* `MONGO_PROFILE_COLLECTION`(`profile`) - The collection used to store your profiles
|
||||
* `MONGO_FOOD_COLLECTION`(`food`) - The collection used to store your food database
|
||||
* `MONGO_ACTIVITY_COLLECTION`(`activity`) - The collection used to store activity data
|
||||
* `MONGO_POOL_SIZE` (`5`) - MongoDB connection pool size. Adjust for your deployment needs.
|
||||
* `MONGO_MIN_POOL_SIZE` (`0`) - Minimum pool connections to keep open.
|
||||
* `MONGO_MAX_IDLE_TIME_MS` (`30000`) - Max idle time (ms) before closing a connection.
|
||||
* `PORT` (`1337`) - The port that the node.js application will listen on.
|
||||
* `HOSTNAME` - The hostname that the node.js application will listen on, null by default for any hostname for IPv6 you may need to use `::`.
|
||||
* `SSL_KEY` - Path to your ssl key file, so that ssl(https) can be enabled directly in node.js. If using Let's Encrypt, make this variable the path to your privkey.pem file (private key).
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
# Entries Schema Documentation
|
||||
|
||||
**Document Version:** 1.0
|
||||
**Last Updated:** March 2026
|
||||
**Status:** Active (2025 Standard)
|
||||
**Source:** Code analysis (`lib/server/entries.js`)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
The `entries` collection stores CGM (Continuous Glucose Monitor) sensor readings and related data. This includes SGV (sensor glucose values), MBG (meter blood glucose), and calibration data.
|
||||
|
||||
**Collection Name:** `entries`
|
||||
**Primary Key:** `sysTime` + `type` (composite)
|
||||
**Primary Timestamp Field:** `sysTime` (ISO 8601, derived from `dateString` or `date`)
|
||||
|
||||
---
|
||||
|
||||
## Core Fields
|
||||
|
||||
| Field | Type | Required | Constraints | Description |
|
||||
|-------|------|----------|-------------|-------------|
|
||||
| `_id` | ObjectId | Yes (auto) | MongoDB ObjectId | Primary key, auto-generated by server |
|
||||
| `type` | String | Yes | `sgv`, `mbg`, `cal`, etc. | Type of entry |
|
||||
| `date` | Number | Yes | Epoch milliseconds | When the reading was taken |
|
||||
| `dateString` | String | Yes | ISO 8601 | Same as `date` in string format |
|
||||
| `sysTime` | String | Computed | ISO 8601 | Normalized timestamp (computed from `dateString` or `date`) |
|
||||
| `utcOffset` | Number | Computed | Minutes | UTC offset parsed from `dateString` |
|
||||
|
||||
---
|
||||
|
||||
## SGV Fields (type: "sgv")
|
||||
|
||||
| Field | Type | Constraints | Description |
|
||||
|-------|------|-------------|-------------|
|
||||
| `sgv` | Number | mg/dL or mmol/L | Sensor glucose value |
|
||||
| `direction` | String | Trend arrows | Glucose trend direction |
|
||||
| `noise` | Number | 0-4 | Signal noise level |
|
||||
| `filtered` | Number | Raw value | Filtered sensor signal |
|
||||
| `unfiltered` | Number | Raw value | Unfiltered sensor signal |
|
||||
| `rssi` | Number | dBm | Signal strength (Dexcom) |
|
||||
|
||||
---
|
||||
|
||||
## Sync Identity Fields
|
||||
|
||||
| Field | Type | Source | Description |
|
||||
|-------|------|--------|-------------|
|
||||
| `identifier` | String | Server-normalized | **Unified client sync identity** (see below) |
|
||||
| `device` | String | CGM app | Device/app identifier (e.g., `"xDrip-DexcomG6"`) |
|
||||
|
||||
### Identifier Field Normalization (REQ-SYNC-072)
|
||||
|
||||
As of v15.0.7, the server normalizes UUID values in `_id` into the `identifier` field when `UUID_HANDLING=true` (default):
|
||||
|
||||
| Client | Sends | Server Action (UUID_HANDLING=true) | Server Action (UUID_HANDLING=false) |
|
||||
|--------|-------|-------------------------------------|--------------------------------------|
|
||||
| **Trio** | UUID in `_id` | Move to `identifier`, assign server ObjectId | Strip `_id`, assign ObjectId (UUID not preserved) |
|
||||
| **Loop** (entries) | ObjectId (from cache) | Normal ObjectId behavior | Normal ObjectId behavior |
|
||||
|
||||
**Note**: The `UUID_HANDLING` env var controls **both** write-path normalization (identifier extraction) and read-path queries (GET/DELETE by UUID).
|
||||
|
||||
**Important**: For entries, `sysTime + type` is ALWAYS the primary deduplication key. The `identifier` field is for client sync tracking only - it does NOT override the dedup logic.
|
||||
|
||||
---
|
||||
|
||||
## Deduplication Behavior
|
||||
|
||||
Entries use **sysTime + type** as the composite unique key:
|
||||
|
||||
```javascript
|
||||
// Upsert query for entries (lib/server/entries.js)
|
||||
{ sysTime: doc.sysTime, type: doc.type }
|
||||
```
|
||||
|
||||
This means:
|
||||
- Two SGV readings at the same `sysTime` will be deduplicated (one overwrites the other)
|
||||
- Different entry types (SGV vs MBG) at the same time are allowed
|
||||
- Re-uploading the same reading updates the existing document
|
||||
|
||||
### UUID _id Handling
|
||||
|
||||
When a client sends a UUID as `_id`:
|
||||
|
||||
1. **Extract**: UUID is copied to `identifier` field (when `UUID_HANDLING=true`)
|
||||
2. **Strip**: Non-ObjectId `_id` is removed before database operation
|
||||
3. **Upsert**: Server uses `sysTime + type` for matching
|
||||
4. **Assign**: Server-generated ObjectId becomes final `_id`
|
||||
|
||||
This prevents the MongoDB "immutable field '_id'" error while preserving client sync identity (when enabled).
|
||||
|
||||
---
|
||||
|
||||
## UUID_HANDLING Feature Flag
|
||||
|
||||
The `UUID_HANDLING` environment variable controls both **write-path** normalization (identifier extraction) and **read-path** queries (GET/DELETE by UUID).
|
||||
|
||||
When `UUID_HANDLING=true` (default):
|
||||
|
||||
| Operation | Behavior |
|
||||
|-----------|----------|
|
||||
| POST/PUT with UUID `_id` | UUID moved to `identifier`, server assigns ObjectId |
|
||||
| GET by UUID | Searches by `identifier` field |
|
||||
| DELETE by UUID | Deletes by `identifier` field |
|
||||
|
||||
When `UUID_HANDLING=false`:
|
||||
|
||||
| Operation | Behavior |
|
||||
|-----------|----------|
|
||||
| POST/PUT with UUID `_id` | UUID `_id` stripped, ObjectId assigned (UUID not preserved) |
|
||||
| GET by UUID | Returns empty (no crash) |
|
||||
| DELETE by UUID | Deletes nothing (no crash) |
|
||||
|
||||
**Note**: This only affects cases where a UUID is passed as the `_id` field (writes) or as the `_id` parameter in API calls (reads), e.g., `GET /api/v1/entries/{uuid}`.
|
||||
|
||||
---
|
||||
|
||||
## Example Entry Document
|
||||
|
||||
```json
|
||||
{
|
||||
"_id": "507f1f77bcf86cd799439011",
|
||||
"type": "sgv",
|
||||
"sgv": 120,
|
||||
"direction": "Flat",
|
||||
"date": 1704067200000,
|
||||
"dateString": "2024-01-01T00:00:00.000Z",
|
||||
"sysTime": "2024-01-01T00:00:00.000Z",
|
||||
"utcOffset": 0,
|
||||
"identifier": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"device": "xDrip-DexcomG6",
|
||||
"noise": 1
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Related Documents
|
||||
|
||||
- [Treatments Schema](treatments-schema.md) - Similar identifier normalization
|
||||
- [GAP-SYNC-045](../../../traceability/sync-identity-gaps.md#gap-sync-045) - Trio entries UUID issue
|
||||
- [REQ-SYNC-072](../../../traceability/sync-identity-requirements.md#req-sync-072) - Identifier normalization requirement
|
||||
@@ -114,18 +114,41 @@ The `treatments` collection stores all user interventions and system events rela
|
||||
|-------|------|--------|-------------|
|
||||
| `srvCreated` | String (ISO 8601) | Server | When the server first received this record |
|
||||
| `srvModified` | String (ISO 8601) | Server | When the server last modified this record |
|
||||
| `identifier` | String | AAPS | AAPS-specific unique identifier for sync |
|
||||
| `uuid` | String | Various | Client-assigned unique identifier |
|
||||
| `identifier` | String | Server/Client | Unified sync identity (see below) |
|
||||
| `syncIdentifier` | String | Loop | Loop carbs/doses sync identity (preserved, not copied) |
|
||||
| `uuid` | String | xDrip+ | xDrip+ sync identity (preserved, not copied) |
|
||||
| `pumpId` | String | Loop/pumps | Pump-assigned identifier |
|
||||
| `pumpType` | String | Loop/pumps | Type of pump that created this treatment |
|
||||
| `pumpSerial` | String | Loop/pumps | Serial number of the pump |
|
||||
|
||||
**Important:** Different controller systems use different field names for duplicate detection:
|
||||
- **AAPS:** Uses `identifier` field
|
||||
- **Loop:** Uses `_id` or pump-related fields
|
||||
- **xDrip:** Uses `uuid` field
|
||||
### Identifier Field Normalization (REQ-SYNC-072)
|
||||
|
||||
This inconsistency suggests that **schema registration / inversion of control** would be valuable - controllers should register their sync identity field conventions.
|
||||
As of v15.0.7, the server normalizes **UUID values in the `_id` field** when `UUID_HANDLING=true` (default):
|
||||
|
||||
| Client | Client Field | UUID_HANDLING=true | UUID_HANDLING=false |
|
||||
|--------|--------------|---------------------|----------------------|
|
||||
| **Loop** (overrides) | UUID in `_id` | Move to `identifier`, assign ObjectId | Strip `_id`, assign ObjectId (UUID not preserved) |
|
||||
| **Loop** (carbs/doses) | `syncIdentifier` | Preserved as-is | Preserved as-is |
|
||||
| **AAPS** | `identifier` | Unchanged (already correct) | Unchanged (already correct) |
|
||||
| **xDrip+** | `uuid` | Preserved as-is | Preserved as-is |
|
||||
|
||||
**Scope:** Only UUID values in the `_id` field are affected. Other client identity fields (`syncIdentifier`, `uuid`) are preserved but NOT copied to `identifier`.
|
||||
|
||||
**UUID_HANDLING controls both write-path normalization and read-path queries** (GET/DELETE by UUID `_id`).
|
||||
|
||||
**Deduplication Priority:** The server uses `identifier` or `_id` for upsert matching when present, falling back to `created_at + eventType` for legacy records.
|
||||
|
||||
**Example - Loop Override Upload (UUID_HANDLING=true):**
|
||||
|
||||
```javascript
|
||||
// Client sends:
|
||||
{ "_id": "A1B2C3D4-E5F6-7890-ABCD-EF1234567890", "eventType": "Temporary Override", ... }
|
||||
|
||||
// Server stores:
|
||||
{ "_id": ObjectId("..."), "identifier": "A1B2C3D4-E5F6-7890-ABCD-EF1234567890", "eventType": "Temporary Override", ... }
|
||||
```
|
||||
|
||||
**Note:** Loop carbs/doses (which use `syncIdentifier`) rely on Loop's local ObjectIdCache for dedup, not server-side logic.
|
||||
|
||||
---
|
||||
|
||||
@@ -349,4 +372,5 @@ The defaults documented (eventType defaulting to `<none>`, created_at defaulting
|
||||
|
||||
| Date | Author | Changes |
|
||||
|------|--------|---------|
|
||||
| 2026-03-17 | Agent | Added identifier field normalization (REQ-SYNC-072) |
|
||||
| 2026-01-15 | Agent | Initial schema documentation from code analysis and domain expert interview |
|
||||
|
||||
@@ -12,4 +12,17 @@ LANGUAGE=en
|
||||
INSECURE_USE_HTTP=true
|
||||
PORT=1337
|
||||
NODE_ENV=development
|
||||
AUTH_FAIL_DELAY=50
|
||||
AUTH_FAIL_DELAY=50
|
||||
|
||||
# UUID handling for specific client patterns that send UUID as _id field
|
||||
# Only affects cases where a UUID is sent as the _id field itself
|
||||
# (e.g., Loop overrides, Trio CGM entries)
|
||||
# Does NOT affect clients using separate identifier/uuid fields (AAPS, xDrip+, etc.)
|
||||
#
|
||||
# When true (default):
|
||||
# - POST/PUT: UUID _id → extracted to 'identifier' field, new ObjectId assigned
|
||||
# - GET/DELETE: UUID _id queries search by 'identifier' field
|
||||
# When false:
|
||||
# - POST/PUT: UUID _id is stripped, new ObjectId assigned (UUID identity not preserved)
|
||||
# - GET/DELETE: UUID _id queries return empty results (no crash)
|
||||
# UUID_HANDLING=true
|
||||
@@ -7,6 +7,30 @@ var _isArray = require('lodash/isArray');
|
||||
var consts = require('../../constants');
|
||||
var moment = require('moment');
|
||||
|
||||
/**
|
||||
* Validate MongoDB ObjectId format.
|
||||
* Accepts: undefined, null, or 24-character hex string.
|
||||
* Rejects: anything else (UUIDs, short strings, numbers, objects).
|
||||
*/
|
||||
function isValidObjectId(id) {
|
||||
if (id === undefined || id === null) return true;
|
||||
if (typeof id !== 'string') return false;
|
||||
return /^[a-fA-F0-9]{24}$/.test(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate _id field for each document in an array.
|
||||
* @returns {Object|null} - null if all valid, or {index, id} of first invalid
|
||||
*/
|
||||
function findInvalidId(docs) {
|
||||
for (var i = 0; i < docs.length; i++) {
|
||||
if (!isValidObjectId(docs[i]._id)) {
|
||||
return { index: i, id: docs[i]._id };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function configure(app, wares, ctx) {
|
||||
var express = require('express')
|
||||
, api = express.Router();
|
||||
@@ -73,6 +97,13 @@ function configure(app, wares, ctx) {
|
||||
activity = [activity];
|
||||
}
|
||||
|
||||
// Validate _id fields before storage (return 400 on invalid)
|
||||
var invalid = findInvalidId(activity);
|
||||
if (invalid) {
|
||||
return res.sendJSONStatus(res, consts.HTTP_BAD_REQUEST,
|
||||
'Invalid _id format', 'Must be 24-character hex string or omit for auto-generation. Got: ' + String(invalid.id));
|
||||
}
|
||||
|
||||
ctx.activity.create(activity, function(err, created) {
|
||||
if (err) {
|
||||
console.log('Error adding activity data', err);
|
||||
@@ -87,6 +118,11 @@ function configure(app, wares, ctx) {
|
||||
api.post('/activity/', ctx.authorization.isPermitted('api:activity:create'), post_response);
|
||||
|
||||
api.delete('/activity/:_id', ctx.authorization.isPermitted('api:activity:delete'), function(req, res) {
|
||||
// Validate _id parameter
|
||||
if (!isValidObjectId(req.params._id)) {
|
||||
return res.sendJSONStatus(res, consts.HTTP_BAD_REQUEST,
|
||||
'Invalid _id format', 'Must be 24-character hex string. Got: ' + String(req.params._id));
|
||||
}
|
||||
ctx.activity.remove(req.params._id, function() {
|
||||
res.json({});
|
||||
});
|
||||
@@ -95,6 +131,13 @@ function configure(app, wares, ctx) {
|
||||
// update record
|
||||
api.put('/activity/', ctx.authorization.isPermitted('api:activity:update'), function(req, res) {
|
||||
var data = req.body;
|
||||
|
||||
// Validate _id if provided
|
||||
if (!isValidObjectId(data._id)) {
|
||||
return res.sendJSONStatus(res, consts.HTTP_BAD_REQUEST,
|
||||
'Invalid _id format', 'Must be 24-character hex string. Got: ' + String(data._id));
|
||||
}
|
||||
|
||||
ctx.activity.save(data, function(err, created) {
|
||||
if (err) {
|
||||
res.sendJSONStatus(res, consts.HTTP_INTERNAL_ERROR, 'Mongo Error', err);
|
||||
|
||||
@@ -6,6 +6,33 @@ const { query } = require('express');
|
||||
const _take = require('lodash/take');
|
||||
const _ = require('lodash');
|
||||
|
||||
/**
|
||||
* Validate MongoDB ObjectId format.
|
||||
* Accepts: undefined, null, or 24-character hex string.
|
||||
* Rejects: anything else (UUIDs, short strings, numbers, objects).
|
||||
* @param {*} id - The _id value to validate
|
||||
* @returns {boolean} - true if valid or empty, false if invalid format
|
||||
*/
|
||||
function isValidObjectId(id) {
|
||||
if (id === undefined || id === null) return true; // Will auto-generate
|
||||
if (typeof id !== 'string') return false;
|
||||
return /^[a-fA-F0-9]{24}$/.test(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate _id field for each document in an array.
|
||||
* @param {Array} docs - Array of documents to validate
|
||||
* @returns {Object|null} - null if all valid, or {index, id} of first invalid
|
||||
*/
|
||||
function findInvalidId(docs) {
|
||||
for (var i = 0; i < docs.length; i++) {
|
||||
if (!isValidObjectId(docs[i]._id)) {
|
||||
return { index: i, id: docs[i]._id };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function configure (app, wares, ctx, env) {
|
||||
var express = require('express')
|
||||
, api = express.Router();
|
||||
@@ -67,11 +94,26 @@ function configure (app, wares, ctx, env) {
|
||||
function config_authed (app, api, wares, ctx) {
|
||||
|
||||
function doPost (req, res) {
|
||||
var obj = req.body;
|
||||
var statuses = req.body;
|
||||
|
||||
ctx.purifier.purifyObject(obj);
|
||||
// Normalize to array (NightscoutKit sends arrays)
|
||||
if (!Array.isArray(statuses)) {
|
||||
statuses = [statuses];
|
||||
}
|
||||
|
||||
// Validate _id fields before storage (return 400 on invalid)
|
||||
var invalid = findInvalidId(statuses);
|
||||
if (invalid) {
|
||||
return res.sendJSONStatus(res, consts.HTTP_BAD_REQUEST,
|
||||
'Invalid _id format', 'Must be 24-character hex string or omit for auto-generation. Got: ' + String(invalid.id));
|
||||
}
|
||||
|
||||
// Purify each devicestatus object
|
||||
for (var i = 0; i < statuses.length; i++) {
|
||||
ctx.purifier.purifyObject(statuses[i]);
|
||||
}
|
||||
|
||||
ctx.devicestatus.create(obj, function(err, created) {
|
||||
ctx.devicestatus.create(statuses, function(err, created) {
|
||||
if (err) {
|
||||
res.sendJSONStatus(res, consts.HTTP_INTERNAL_ERROR, 'Mongo Error', err);
|
||||
} else {
|
||||
@@ -111,6 +153,12 @@ function configure (app, wares, ctx, env) {
|
||||
}
|
||||
|
||||
api.delete('/devicestatus/:id', ctx.authorization.isPermitted('api:devicestatus:delete'), function(req, res, next) {
|
||||
// Validate _id parameter (unless wildcard)
|
||||
if (req.params.id !== '*' && !isValidObjectId(req.params.id)) {
|
||||
return res.sendJSONStatus(res, consts.HTTP_BAD_REQUEST,
|
||||
'Invalid _id format', 'Must be 24-character hex string. Got: ' + String(req.params.id));
|
||||
}
|
||||
|
||||
if (!req.query.find) {
|
||||
req.query.find = {
|
||||
_id: req.params.id
|
||||
|
||||
+59
-3
@@ -1,7 +1,32 @@
|
||||
'use strict';
|
||||
|
||||
var _isArray = require('lodash/isArray');
|
||||
var consts = require('../../constants');
|
||||
|
||||
/**
|
||||
* Validate MongoDB ObjectId format.
|
||||
* Accepts: undefined, null, or 24-character hex string.
|
||||
* Rejects: anything else (UUIDs, short strings, numbers, objects).
|
||||
*/
|
||||
function isValidObjectId(id) {
|
||||
if (id === undefined || id === null) return true;
|
||||
if (typeof id !== 'string') return false;
|
||||
return /^[a-fA-F0-9]{24}$/.test(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate _id field for each document in an array.
|
||||
* @returns {Object|null} - null if all valid, or {index, id} of first invalid
|
||||
*/
|
||||
function findInvalidId(docs) {
|
||||
for (var i = 0; i < docs.length; i++) {
|
||||
if (!isValidObjectId(docs[i]._id)) {
|
||||
return { index: i, id: docs[i]._id };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function configure (app, wares, ctx) {
|
||||
var express = require('express'),
|
||||
api = express.Router( );
|
||||
@@ -40,9 +65,22 @@ function configure (app, wares, ctx) {
|
||||
|
||||
function config_authed (app, api, wares, ctx) {
|
||||
|
||||
// create new record
|
||||
// create new record(s) - supports both single object and array input
|
||||
api.post('/food/', ctx.authorization.isPermitted('api:food:create'), function(req, res) {
|
||||
var data = req.body;
|
||||
|
||||
// Normalize to array for consistent handling
|
||||
if (!_isArray(data)) {
|
||||
data = [data];
|
||||
}
|
||||
|
||||
// Validate _id fields before storage (return 400 on invalid)
|
||||
var invalid = findInvalidId(data);
|
||||
if (invalid) {
|
||||
return res.sendJSONStatus(res, consts.HTTP_BAD_REQUEST,
|
||||
'Invalid _id format', 'Must be 24-character hex string or omit for auto-generation. Got: ' + String(invalid.id));
|
||||
}
|
||||
|
||||
ctx.food.create(data, function (err, created) {
|
||||
if (err) {
|
||||
res.sendJSONStatus(res, consts.HTTP_INTERNAL_ERROR, 'Mongo Error', err);
|
||||
@@ -50,14 +88,27 @@ function configure (app, wares, ctx) {
|
||||
console.log(err);
|
||||
} else {
|
||||
res.json(created);
|
||||
console.log('food created',created);
|
||||
console.log('food created', created);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// update record
|
||||
// update record(s) - supports both single object and array input
|
||||
api.put('/food/', ctx.authorization.isPermitted('api:food:update'), function(req, res) {
|
||||
var data = req.body;
|
||||
|
||||
// Normalize to array for consistent handling
|
||||
if (!_isArray(data)) {
|
||||
data = [data];
|
||||
}
|
||||
|
||||
// Validate _id fields before storage (return 400 on invalid)
|
||||
var invalid = findInvalidId(data);
|
||||
if (invalid) {
|
||||
return res.sendJSONStatus(res, consts.HTTP_BAD_REQUEST,
|
||||
'Invalid _id format', 'Must be 24-character hex string. Got: ' + String(invalid.id));
|
||||
}
|
||||
|
||||
ctx.food.save(data, function (err, created) {
|
||||
if (err) {
|
||||
res.sendJSONStatus(res, consts.HTTP_INTERNAL_ERROR, 'Mongo Error', err);
|
||||
@@ -71,6 +122,11 @@ function configure (app, wares, ctx) {
|
||||
});
|
||||
// delete record
|
||||
api.delete('/food/:_id', ctx.authorization.isPermitted('api:food:delete'), function(req, res) {
|
||||
// Validate _id parameter
|
||||
if (!isValidObjectId(req.params._id)) {
|
||||
return res.sendJSONStatus(res, consts.HTTP_BAD_REQUEST,
|
||||
'Invalid _id format', 'Must be 24-character hex string. Got: ' + String(req.params._id));
|
||||
}
|
||||
ctx.food.remove(req.params._id, function ( ) {
|
||||
res.json({ });
|
||||
});
|
||||
|
||||
@@ -2,6 +2,33 @@
|
||||
|
||||
var consts = require('../../constants');
|
||||
|
||||
/**
|
||||
* Validate MongoDB ObjectId format.
|
||||
* Accepts: undefined, null, or 24-character hex string.
|
||||
* Rejects: anything else (UUIDs, short strings, numbers, objects).
|
||||
* @param {*} id - The _id value to validate
|
||||
* @returns {boolean} - true if valid or empty, false if invalid format
|
||||
*/
|
||||
function isValidObjectId(id) {
|
||||
if (id === undefined || id === null) return true; // Will auto-generate
|
||||
if (typeof id !== 'string') return false;
|
||||
return /^[a-fA-F0-9]{24}$/.test(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate _id field for each document in an array.
|
||||
* @param {Array} docs - Array of documents to validate
|
||||
* @returns {Object|null} - null if all valid, or {index, id} of first invalid
|
||||
*/
|
||||
function findInvalidId(docs) {
|
||||
for (var i = 0; i < docs.length; i++) {
|
||||
if (!isValidObjectId(docs[i]._id)) {
|
||||
return { index: i, id: docs[i]._id };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function configure (app, wares, ctx) {
|
||||
var express = require('express'),
|
||||
api = express.Router( );
|
||||
@@ -59,18 +86,36 @@ function configure (app, wares, ctx) {
|
||||
|
||||
function config_authed (app, api, wares, ctx) {
|
||||
|
||||
// create new record
|
||||
// create new record(s)
|
||||
// Supports both single object and array inputs (NightscoutKit sends arrays)
|
||||
api.post('/profile/', ctx.authorization.isPermitted('api:profile:create'), function(req, res) {
|
||||
var data = req.body;
|
||||
ctx.purifier.purifyObject(data);
|
||||
|
||||
// Normalize to array (match treatments pattern)
|
||||
if (!Array.isArray(data)) {
|
||||
data = [data];
|
||||
}
|
||||
|
||||
// Validate _id fields before storage (return 400 on invalid)
|
||||
var invalid = findInvalidId(data);
|
||||
if (invalid) {
|
||||
return res.sendJSONStatus(res, consts.HTTP_BAD_REQUEST,
|
||||
'Invalid _id format', 'Must be 24-character hex string or omit for auto-generation. Got: ' + String(invalid.id));
|
||||
}
|
||||
|
||||
// Purify each profile
|
||||
for (var i = 0; i < data.length; i++) {
|
||||
ctx.purifier.purifyObject(data[i]);
|
||||
}
|
||||
|
||||
ctx.profile.create(data, function (err, created) {
|
||||
if (err) {
|
||||
res.sendJSONStatus(res, consts.HTTP_INTERNAL_ERROR, 'Mongo Error', err);
|
||||
console.log('Error creating profile');
|
||||
console.log(err);
|
||||
} else {
|
||||
res.json(created.ops);
|
||||
console.log('Profile created', created);
|
||||
res.json(created);
|
||||
console.log('Profile(s) created', created.length);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -78,6 +123,13 @@ function configure (app, wares, ctx) {
|
||||
// update record
|
||||
api.put('/profile/', ctx.authorization.isPermitted('api:profile:update'), function(req, res) {
|
||||
var data = req.body;
|
||||
|
||||
// Validate _id if provided (required for PUT, must be valid format)
|
||||
if (!isValidObjectId(data._id)) {
|
||||
return res.sendJSONStatus(res, consts.HTTP_BAD_REQUEST,
|
||||
'Invalid _id format', 'Must be 24-character hex string. Got: ' + String(data._id));
|
||||
}
|
||||
|
||||
ctx.profile.save(data, function (err, created) {
|
||||
if (err) {
|
||||
res.sendJSONStatus(res, consts.HTTP_INTERNAL_ERROR, 'Mongo Error', err);
|
||||
@@ -92,6 +144,12 @@ function configure (app, wares, ctx) {
|
||||
});
|
||||
|
||||
api.delete('/profile/:_id', ctx.authorization.isPermitted('api:profile:delete'), function(req, res) {
|
||||
// Validate _id parameter
|
||||
if (!isValidObjectId(req.params._id)) {
|
||||
return res.sendJSONStatus(res, consts.HTTP_BAD_REQUEST,
|
||||
'Invalid _id format', 'Must be 24-character hex string. Got: ' + String(req.params._id));
|
||||
}
|
||||
|
||||
ctx.profile.remove(req.params._id, function ( ) {
|
||||
res.json({ });
|
||||
});
|
||||
|
||||
+28
-11
@@ -7,22 +7,39 @@ function storage (env, ctx) {
|
||||
var ObjectID = require('mongodb-legacy').ObjectId;
|
||||
|
||||
function create (docs, fn) {
|
||||
var firstErr = null
|
||||
, numDocs = docs.length
|
||||
, totalCreated = 0;
|
||||
if (docs.length === 0) {
|
||||
return fn(null, []);
|
||||
}
|
||||
|
||||
docs.forEach(function(doc) {
|
||||
// Build bulkWrite operations for batch upsert
|
||||
var bulkOps = docs.map(function(doc) {
|
||||
if (!Object.prototype.hasOwnProperty.call(doc, 'created_at')) {
|
||||
doc.created_at = (new Date( )).toISOString( );
|
||||
doc.created_at = (new Date()).toISOString();
|
||||
}
|
||||
var query = (doc.created_at && doc._id) ? { _id: doc._id, created_at: doc.created_at } : doc;
|
||||
api().replaceOne(query, doc, { upsert: true }, function(err, updateResults) {
|
||||
firstErr = firstErr || err;
|
||||
|
||||
if (++totalCreated === numDocs) {
|
||||
fn(firstErr, docs);
|
||||
return {
|
||||
replaceOne: {
|
||||
filter: query,
|
||||
replacement: doc,
|
||||
upsert: true
|
||||
}
|
||||
});
|
||||
};
|
||||
});
|
||||
|
||||
api().bulkWrite(bulkOps, { ordered: true }, function(err, bulkResult) {
|
||||
if (err) {
|
||||
console.error('Problem upserting activity batch', err);
|
||||
return fn(err, []);
|
||||
}
|
||||
|
||||
// Assign _ids from upserted results
|
||||
if (bulkResult && bulkResult.upsertedIds) {
|
||||
Object.keys(bulkResult.upsertedIds).forEach(function(index) {
|
||||
docs[index]._id = bulkResult.upsertedIds[index];
|
||||
});
|
||||
}
|
||||
|
||||
fn(null, docs);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+27
-25
@@ -163,7 +163,21 @@ function storage (env, ctx) {
|
||||
}
|
||||
|
||||
function query_for (opts) {
|
||||
return find_options(opts, storage.queryOpts);
|
||||
// Build queryOpts inside function to access env.uuidHandling
|
||||
var queryOpts = {
|
||||
walker: {
|
||||
date: parseInt
|
||||
, sgv: parseInt
|
||||
, filtered: parseInt
|
||||
, unfiltered: parseInt
|
||||
, rssi: parseInt
|
||||
, noise: parseInt
|
||||
, mbg: parseInt
|
||||
}
|
||||
, useEpoch: true
|
||||
, uuidHandling: env.uuidHandling
|
||||
};
|
||||
return find_options(opts, queryOpts);
|
||||
}
|
||||
|
||||
// closure to represent the API
|
||||
@@ -227,39 +241,27 @@ function storage (env, ctx) {
|
||||
* Note: _id is stripped in upsertQueryFor to avoid MongoDB errors
|
||||
*/
|
||||
function normalizeEntryId (doc) {
|
||||
// Extract client sync identity from UUID _id
|
||||
var clientIdentifier = doc.identifier
|
||||
|| (typeof doc._id === 'string' && !OBJECT_ID_HEX_RE.test(doc._id) ? doc._id : null);
|
||||
|
||||
if (clientIdentifier && !doc.identifier) {
|
||||
doc.identifier = clientIdentifier;
|
||||
}
|
||||
|
||||
// Convert valid ObjectId strings to ObjectId objects
|
||||
if (Object.prototype.hasOwnProperty.call(doc, '_id') && doc._id !== null && doc._id !== '') {
|
||||
// REQ-SYNC-072: Only handle UUID values in _id field
|
||||
// Scope: ONLY the _id field when value is a valid UUID
|
||||
if (typeof doc._id === 'string' && !OBJECT_ID_HEX_RE.test(doc._id)) {
|
||||
// Non-ObjectId string in _id (UUID format)
|
||||
// Only move to identifier when UUID_HANDLING is enabled
|
||||
if (env.uuidHandling && !doc.identifier) {
|
||||
doc.identifier = doc._id;
|
||||
}
|
||||
// Always delete invalid _id so server generates ObjectId
|
||||
delete doc._id;
|
||||
} else if (Object.prototype.hasOwnProperty.call(doc, '_id') && doc._id !== null && doc._id !== '') {
|
||||
// Convert valid ObjectId strings to ObjectId objects
|
||||
if (typeof doc._id === 'string' && OBJECT_ID_HEX_RE.test(doc._id)) {
|
||||
doc._id = new ObjectId(doc._id);
|
||||
}
|
||||
// Non-ObjectId _id will be stripped in upsertQueryFor
|
||||
}
|
||||
}
|
||||
|
||||
return api;
|
||||
}
|
||||
|
||||
storage.queryOpts = {
|
||||
walker: {
|
||||
date: parseInt
|
||||
, sgv: parseInt
|
||||
, filtered: parseInt
|
||||
, unfiltered: parseInt
|
||||
, rssi: parseInt
|
||||
, noise: parseInt
|
||||
, mbg: parseInt
|
||||
}
|
||||
, useEpoch: true
|
||||
};
|
||||
|
||||
// expose module
|
||||
storage.storage = storage;
|
||||
module.exports = storage;
|
||||
|
||||
@@ -75,6 +75,14 @@ function setSSL () {
|
||||
env.secureHstsHeaderPreload = readENVTruthy("SECURE_HSTS_HEADER_PRELOAD", false);
|
||||
env.secureCsp = readENVTruthy("SECURE_CSP", false);
|
||||
env.secureCspReportOnly = readENVTruthy("SECURE_CSP_REPORT_ONLY", false);
|
||||
|
||||
// UUID handling for specific client patterns that send UUID as _id field
|
||||
// When true (default): UUID _id values are extracted to 'identifier' field, server generates ObjectId
|
||||
// - Writes: UUID preserved as identifier, new ObjectId assigned
|
||||
// - Reads: GET/DELETE by UUID searches identifier field
|
||||
// When false: UUID _id values are stripped (UUID not preserved), UUID-based queries return empty
|
||||
// Only affects cases where UUID is sent as _id (e.g., Loop overrides, Trio CGM entries)
|
||||
env.uuidHandling = readENVTruthy("UUID_HANDLING", true);
|
||||
}
|
||||
|
||||
// A little ugly, but we don't want to read the secret into a var
|
||||
|
||||
+78
-18
@@ -3,32 +3,92 @@
|
||||
function storage (env, ctx) {
|
||||
var ObjectID = require('mongodb-legacy').ObjectId;
|
||||
|
||||
function create (obj, fn) {
|
||||
obj.created_at = (new Date( )).toISOString( );
|
||||
api().insertOne(obj, function (err, doc) {
|
||||
if (err) {
|
||||
console.log('Data insertion error', err, err.message);
|
||||
function create (docs, fn) {
|
||||
// Normalize to array for consistent handling (allows direct storage calls with single objects)
|
||||
if (!Array.isArray(docs)) {
|
||||
docs = [docs];
|
||||
}
|
||||
|
||||
if (docs.length === 0) {
|
||||
return fn(null, []);
|
||||
}
|
||||
|
||||
// Build bulkWrite operations for batch upsert
|
||||
var bulkOps = docs.map(function(doc) {
|
||||
doc.created_at = (new Date()).toISOString();
|
||||
var query = (doc.created_at && doc._id) ? { _id: doc._id, created_at: doc.created_at } : doc;
|
||||
return {
|
||||
replaceOne: {
|
||||
filter: query,
|
||||
replacement: doc,
|
||||
upsert: true
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
api().bulkWrite(bulkOps, { ordered: true }, function(err, bulkResult) {
|
||||
if (err) {
|
||||
console.error('Problem upserting food batch', err);
|
||||
return fn(err, []);
|
||||
}
|
||||
|
||||
fn(err, obj);
|
||||
// Assign _ids from upserted results
|
||||
if (bulkResult && bulkResult.upsertedIds) {
|
||||
Object.keys(bulkResult.upsertedIds).forEach(function(index) {
|
||||
docs[index]._id = bulkResult.upsertedIds[index];
|
||||
});
|
||||
}
|
||||
|
||||
fn(null, docs);
|
||||
});
|
||||
}
|
||||
|
||||
function save (obj, fn) {
|
||||
try {
|
||||
obj._id = new ObjectID(obj._id);
|
||||
} catch (err){
|
||||
console.error(err);
|
||||
obj._id = new ObjectID();
|
||||
}
|
||||
if (!obj.created_at) {
|
||||
obj.created_at = (new Date( )).toISOString( );
|
||||
function save (docs, fn) {
|
||||
// Normalize to array for consistent handling
|
||||
if (!Array.isArray(docs)) {
|
||||
docs = [docs];
|
||||
}
|
||||
|
||||
var query = (obj.created_at && obj._id) ? { _id: obj._id, created_at: obj.created_at } : obj;
|
||||
api().replaceOne(query, obj, { upsert: true }, function(err, updateResults) {
|
||||
fn(err, obj);
|
||||
if (docs.length === 0) {
|
||||
return fn(null, []);
|
||||
}
|
||||
|
||||
// Build bulkWrite operations for batch upsert
|
||||
var bulkOps = docs.map(function(doc) {
|
||||
try {
|
||||
doc._id = new ObjectID(doc._id);
|
||||
} catch (err){
|
||||
console.error(err);
|
||||
doc._id = new ObjectID();
|
||||
}
|
||||
if (!doc.created_at) {
|
||||
doc.created_at = (new Date()).toISOString();
|
||||
}
|
||||
|
||||
var query = (doc.created_at && doc._id) ? { _id: doc._id, created_at: doc.created_at } : doc;
|
||||
return {
|
||||
replaceOne: {
|
||||
filter: query,
|
||||
replacement: doc,
|
||||
upsert: true
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
api().bulkWrite(bulkOps, { ordered: true }, function(err, bulkResult) {
|
||||
if (err) {
|
||||
console.error('Problem saving food batch', err);
|
||||
return fn(err, []);
|
||||
}
|
||||
|
||||
// Assign _ids from upserted results
|
||||
if (bulkResult && bulkResult.upsertedIds) {
|
||||
Object.keys(bulkResult.upsertedIds).forEach(function(index) {
|
||||
docs[index]._id = bulkResult.upsertedIds[index];
|
||||
});
|
||||
}
|
||||
|
||||
fn(null, docs);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+21
-5
@@ -6,15 +6,31 @@ var consts = require('../constants');
|
||||
function storage (collection, ctx) {
|
||||
var ObjectID = require('mongodb-legacy').ObjectId;
|
||||
|
||||
function create (obj, fn) {
|
||||
obj.created_at = (new Date( )).toISOString( );
|
||||
api().insertOne(obj, function (err, doc) {
|
||||
function create (objOrArray, fn) {
|
||||
// Normalize to array (supports both single object and array inputs)
|
||||
var docs = Array.isArray(objOrArray) ? objOrArray : [objOrArray];
|
||||
|
||||
if (docs.length === 0) {
|
||||
fn(null, []);
|
||||
ctx.bus.emit('data-received');
|
||||
return;
|
||||
}
|
||||
|
||||
// Add created_at to each document
|
||||
docs.forEach(function(doc) {
|
||||
if (!doc.created_at) {
|
||||
doc.created_at = (new Date()).toISOString();
|
||||
}
|
||||
});
|
||||
|
||||
api().insertMany(docs, function (err, result) {
|
||||
if (err) {
|
||||
console.log("Error saving profile data", obj, err, doc);
|
||||
console.log("Error saving profile data", docs, err);
|
||||
fn(err);
|
||||
return;
|
||||
}
|
||||
fn(err, obj);
|
||||
// Return the inserted documents with _id (NightscoutKit expects array)
|
||||
fn(null, docs);
|
||||
});
|
||||
ctx.bus.emit('data-received');
|
||||
}
|
||||
|
||||
+32
-8
@@ -4,6 +4,7 @@ const traverse = require('traverse');
|
||||
const ObjectID = require('mongodb-legacy').ObjectId;
|
||||
const moment = require('moment');
|
||||
const OBJECT_ID_HEX_RE = /^[0-9a-fA-F]{24}$/;
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
const TWO_DAYS = 172800000;
|
||||
/**
|
||||
@@ -89,32 +90,55 @@ function enforceDateFilter (query, opts) {
|
||||
/**
|
||||
* Helper to set ObjectID type for `_id` queries.
|
||||
* Forces anything named `_id` to be the `ObjectID` type.
|
||||
* When opts.uuidHandling is true, UUID _id values search by identifier field.
|
||||
*/
|
||||
function updateIdQuery (query) {
|
||||
function updateIdQuery (query, opts) {
|
||||
if (!Object.prototype.hasOwnProperty.call(query, '_id')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof query._id === 'string') {
|
||||
query._id = normalizeIdValue(query._id);
|
||||
var result = normalizeIdValue(query._id, opts);
|
||||
if (result.searchByIdentifier) {
|
||||
// UUID detected with uuidHandling enabled - search by identifier instead
|
||||
query.identifier = result.value;
|
||||
delete query._id;
|
||||
} else {
|
||||
query._id = result.value;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (query._id && typeof query._id === 'object') {
|
||||
traverse(query._id).forEach(function (x) {
|
||||
if (this.isLeaf) {
|
||||
this.update(normalizeIdValue(x));
|
||||
var result = normalizeIdValue(x, opts);
|
||||
// For complex queries (like $in), we only handle ObjectIDs
|
||||
// UUID handling in complex queries would require more work
|
||||
this.update(result.value);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeIdValue (value) {
|
||||
/**
|
||||
* Normalize an _id value for MongoDB queries.
|
||||
* @param {string} value - The _id value to normalize
|
||||
* @param {Object} opts - Options including uuidHandling flag
|
||||
* @returns {Object} { value: normalized, searchByIdentifier: boolean }
|
||||
*/
|
||||
function normalizeIdValue (value, opts) {
|
||||
if (typeof value === 'string' && OBJECT_ID_HEX_RE.test(value)) {
|
||||
return new ObjectID(value);
|
||||
return { value: new ObjectID(value), searchByIdentifier: false };
|
||||
}
|
||||
|
||||
return value;
|
||||
// Check if it's a UUID and uuidHandling is enabled
|
||||
if (typeof value === 'string' && UUID_RE.test(value) && opts && opts.uuidHandling) {
|
||||
return { value: value, searchByIdentifier: true };
|
||||
}
|
||||
|
||||
// Unknown format - return as-is (will likely return 0 results)
|
||||
return { value: value, searchByIdentifier: false };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -142,8 +166,8 @@ function create (params, opts) {
|
||||
enforceDateFilter(query, opts);
|
||||
}
|
||||
|
||||
// Help queries for _id.
|
||||
updateIdQuery(query);
|
||||
// Help queries for _id (pass opts for UUID handling)
|
||||
updateIdQuery(query, opts);
|
||||
|
||||
//console.info('query:', query);
|
||||
// Ready for mongodb.find( ) and friends.
|
||||
|
||||
+40
-38
@@ -73,23 +73,24 @@ function storage (env, ctx) {
|
||||
objOrArray[index]._id = bulkResult.upsertedIds[index];
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// REQ-SYNC-072: For docs that were updated (not inserted) via identifier,
|
||||
// fetch their _id from the database
|
||||
// fetch their _id from the database (only identifier field, not others)
|
||||
var docsNeedingId = objOrArray.filter(function(obj) {
|
||||
return obj.identifier && !obj._id;
|
||||
return !obj._id && obj.identifier;
|
||||
});
|
||||
|
||||
if (docsNeedingId.length > 0) {
|
||||
var identifiers = docsNeedingId.map(function(obj) { return obj.identifier; });
|
||||
|
||||
api().find({ identifier: { $in: identifiers } }).toArray(function(findErr, existing) {
|
||||
if (!findErr && existing) {
|
||||
var idMap = {};
|
||||
existing.forEach(function(doc) {
|
||||
idMap[doc.identifier] = doc._id;
|
||||
if (doc.identifier) idMap[doc.identifier] = doc._id;
|
||||
});
|
||||
docsNeedingId.forEach(function(obj) {
|
||||
if (idMap[obj.identifier]) {
|
||||
if (obj.identifier && idMap[obj.identifier]) {
|
||||
obj._id = idMap[obj.identifier];
|
||||
}
|
||||
});
|
||||
@@ -216,7 +217,20 @@ function storage (env, ctx) {
|
||||
}
|
||||
|
||||
function query_for (opts) {
|
||||
return find_options(opts, storage.queryOpts);
|
||||
// Build queryOpts inside function to access env.uuidHandling
|
||||
var queryOpts = {
|
||||
walker: {
|
||||
insulin: parseInt
|
||||
, carbs: parseInt
|
||||
, glucose: parseInt
|
||||
, notes: find_options.parseRegEx
|
||||
, eventType: find_options.parseRegEx
|
||||
, enteredBy: find_options.parseRegEx
|
||||
}
|
||||
, dateField: 'created_at'
|
||||
, uuidHandling: env.uuidHandling
|
||||
};
|
||||
return find_options(opts, queryOpts);
|
||||
}
|
||||
|
||||
function remove (opts, fn) {
|
||||
@@ -302,7 +316,7 @@ function storage (env, ctx) {
|
||||
* because MongoDB doesn't allow changing _id on upsert update.
|
||||
*/
|
||||
function upsertQueryFor (obj, results) {
|
||||
// 1. Prefer identifier for dedup (handles Loop re-uploads after cache clear)
|
||||
// 1. Prefer identifier for dedup (AAPS, Loop UUID _id normalized)
|
||||
if (obj.identifier) {
|
||||
// Remove _id from replacement - MongoDB will use existing _id on update,
|
||||
// or generate new one on insert
|
||||
@@ -323,31 +337,31 @@ function storage (env, ctx) {
|
||||
/**
|
||||
* Normalize treatment ID - REQ-SYNC-072: Server-Controlled ID
|
||||
*
|
||||
* Extracts client sync identity from any source:
|
||||
* Scope: ONLY handles UUID values in _id field
|
||||
* - Loop overrides: UUID in _id → moved to identifier
|
||||
* - Loop carbs/doses: syncIdentifier → copied to identifier
|
||||
* - AAPS: identifier already present
|
||||
* - xDrip+: uuid → copied to identifier
|
||||
*
|
||||
* Note: _id handling is done in upsertQueryFor to properly handle update vs insert
|
||||
* Does NOT touch other client fields (syncIdentifier, uuid, etc.)
|
||||
* Those fields are preserved as-is and used for dedup in upsertQueryFor().
|
||||
*
|
||||
* Note: _id handling is done here to properly handle update vs insert
|
||||
*/
|
||||
function normalizeTreatmentId (obj) {
|
||||
// Extract client sync identity from ANY source
|
||||
var clientIdentifier = obj.identifier
|
||||
|| obj.syncIdentifier // Loop carbs/doses
|
||||
|| obj.uuid // xDrip+
|
||||
|| (typeof obj._id === 'string' && !OBJECT_ID_HEX_RE.test(obj._id) ? obj._id : null); // UUID _id (Loop overrides)
|
||||
|
||||
if (clientIdentifier && !obj.identifier) {
|
||||
obj.identifier = clientIdentifier;
|
||||
}
|
||||
|
||||
// Convert valid ObjectId strings to ObjectId objects
|
||||
if (Object.prototype.hasOwnProperty.call(obj, '_id') && obj._id !== null && obj._id !== '') {
|
||||
// REQ-SYNC-072: Only handle UUID values in _id field
|
||||
// Scope: ONLY the _id field when value is a valid UUID
|
||||
// Does NOT touch syncIdentifier, uuid, or other client fields
|
||||
if (typeof obj._id === 'string' && !OBJECT_ID_HEX_RE.test(obj._id)) {
|
||||
// Non-ObjectId string in _id (UUID format)
|
||||
// Only move to identifier when UUID_HANDLING is enabled
|
||||
if (env.uuidHandling && !obj.identifier) {
|
||||
obj.identifier = obj._id;
|
||||
}
|
||||
// Always delete invalid _id so server generates ObjectId
|
||||
delete obj._id;
|
||||
} else if (Object.prototype.hasOwnProperty.call(obj, '_id') && obj._id !== null && obj._id !== '') {
|
||||
// Convert valid ObjectId strings to ObjectId objects
|
||||
if (typeof obj._id === 'string' && OBJECT_ID_HEX_RE.test(obj._id)) {
|
||||
obj._id = new ObjectID(obj._id);
|
||||
}
|
||||
// Non-ObjectId _id will be stripped in upsertQueryFor when identifier is present
|
||||
}
|
||||
}
|
||||
|
||||
@@ -367,7 +381,7 @@ function storage (env, ctx) {
|
||||
, 'percent'
|
||||
, 'absolute'
|
||||
, 'duration'
|
||||
, 'identifier' // REQ-SYNC-072: Client sync identity (Loop syncIdentifier, AAPS identifier, xDrip+ uuid)
|
||||
, 'identifier' // REQ-SYNC-072: Client sync identity (UUID from _id field)
|
||||
, { 'eventType' : 1, 'duration' : 1, 'created_at' : 1 }
|
||||
];
|
||||
|
||||
@@ -459,16 +473,4 @@ function prepareData(obj) {
|
||||
return results;
|
||||
}
|
||||
|
||||
storage.queryOpts = {
|
||||
walker: {
|
||||
insulin: parseInt
|
||||
, carbs: parseInt
|
||||
, glucose: parseInt
|
||||
, notes: find_options.parseRegEx
|
||||
, eventType: find_options.parseRegEx
|
||||
, enteredBy: find_options.parseRegEx
|
||||
}
|
||||
, dateField: 'created_at'
|
||||
};
|
||||
|
||||
module.exports = storage;
|
||||
|
||||
@@ -157,4 +157,96 @@ describe('Activity API', function ( ) {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Single object input tests - validates array normalization
|
||||
// ============================================================
|
||||
|
||||
it('post single activity returns array with one item', function (done) {
|
||||
var now = (new Date()).toISOString();
|
||||
var sample_activity = {
|
||||
created_at: now,
|
||||
heartrate: 85,
|
||||
steps: 1500,
|
||||
activitylevel: 'moderate'
|
||||
};
|
||||
|
||||
request(self.app)
|
||||
.post('/api/activity/')
|
||||
.set('api-secret', known || '')
|
||||
.send(sample_activity)
|
||||
.expect(200)
|
||||
.end(function (err, res) {
|
||||
if (err) {
|
||||
return done(err);
|
||||
}
|
||||
// Response should be an array even for single object input
|
||||
res.body.should.be.an.Array();
|
||||
res.body.length.should.equal(1);
|
||||
res.body[0].should.have.property('_id');
|
||||
res.body[0].heartrate.should.equal(85);
|
||||
res.body[0].steps.should.equal(1500);
|
||||
res.body[0].activitylevel.should.equal('moderate');
|
||||
|
||||
// Clean up
|
||||
request(self.app)
|
||||
.delete('/api/activity/' + res.body[0]._id)
|
||||
.set('api-secret', known || '')
|
||||
.expect(200)
|
||||
.end(done);
|
||||
});
|
||||
});
|
||||
|
||||
it('post activity array returns array', function (done) {
|
||||
var now = (new Date()).toISOString();
|
||||
request(self.app)
|
||||
.post('/api/activity/')
|
||||
.set('api-secret', known || '')
|
||||
.send([
|
||||
{ created_at: now, heartrate: 70, steps: 500, activitylevel: 'low' },
|
||||
{ created_at: now, heartrate: 150, steps: 3000, activitylevel: 'high' }
|
||||
])
|
||||
.expect(200)
|
||||
.end(function (err, res) {
|
||||
if (err) {
|
||||
return done(err);
|
||||
}
|
||||
res.body.should.be.an.Array();
|
||||
res.body.length.should.equal(2);
|
||||
res.body[0].should.have.property('_id');
|
||||
res.body[1].should.have.property('_id');
|
||||
res.body[0].heartrate.should.equal(70);
|
||||
res.body[1].heartrate.should.equal(150);
|
||||
|
||||
// Clean up
|
||||
request(self.app)
|
||||
.delete('/api/activity/' + res.body[0]._id)
|
||||
.set('api-secret', known || '')
|
||||
.expect(200)
|
||||
.end(function (err) {
|
||||
if (err) return done(err);
|
||||
request(self.app)
|
||||
.delete('/api/activity/' + res.body[1]._id)
|
||||
.set('api-secret', known || '')
|
||||
.expect(200)
|
||||
.end(done);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('post empty array returns empty array', function (done) {
|
||||
request(self.app)
|
||||
.post('/api/activity/')
|
||||
.set('api-secret', known || '')
|
||||
.send([])
|
||||
.expect(200)
|
||||
.end(function (err, res) {
|
||||
if (err) {
|
||||
return done(err);
|
||||
}
|
||||
res.body.should.be.an.Array();
|
||||
res.body.length.should.equal(0);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -96,4 +96,157 @@ describe('Devicestatus API', function ( ) {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// _id validation tests (prevent silent data corruption and ensure 400 on invalid)
|
||||
describe('_id validation', function() {
|
||||
|
||||
it('should return 400 for POST with invalid UUID _id', function(done) {
|
||||
var status_with_uuid = {
|
||||
"_id": "my-uuid-12345",
|
||||
"device": "test-device",
|
||||
"created_at": "2024-01-01T00:00:00Z"
|
||||
};
|
||||
|
||||
request(self.app)
|
||||
.post('/api/devicestatus/')
|
||||
.set('api-secret', known || '')
|
||||
.send(status_with_uuid)
|
||||
.expect(400)
|
||||
.expect(function(response) {
|
||||
response.body.should.have.property('status', 400);
|
||||
response.body.should.have.property('message');
|
||||
response.body.message.should.match(/Invalid _id format/i);
|
||||
})
|
||||
.end(done);
|
||||
});
|
||||
|
||||
it('should return 400 for POST with short _id', function(done) {
|
||||
var status_short_id = {
|
||||
"_id": "abc",
|
||||
"device": "test-device",
|
||||
"created_at": "2024-01-01T00:00:00Z"
|
||||
};
|
||||
|
||||
request(self.app)
|
||||
.post('/api/devicestatus/')
|
||||
.set('api-secret', known || '')
|
||||
.send(status_short_id)
|
||||
.expect(400)
|
||||
.end(done);
|
||||
});
|
||||
|
||||
it('should return 400 for DELETE with invalid _id', function(done) {
|
||||
request(self.app)
|
||||
.delete('/api/devicestatus/invalid-uuid-here')
|
||||
.set('api-secret', known || '')
|
||||
.expect(400)
|
||||
.expect(function(response) {
|
||||
response.body.message.should.match(/Invalid _id format/i);
|
||||
})
|
||||
.end(done);
|
||||
});
|
||||
|
||||
it('should accept POST with valid 24-hex _id', function(done) {
|
||||
// Use a unique ID that doesn't conflict with other tests
|
||||
var testId = 'bbbbbbbbbbbbbbbbbbbbbbbb';
|
||||
var status_valid_id = {
|
||||
"_id": testId,
|
||||
"device": "test-device-valid",
|
||||
"created_at": "2024-01-02T00:00:00Z"
|
||||
};
|
||||
|
||||
// First, try to delete any existing document with this _id (cleanup from previous runs)
|
||||
request(self.app)
|
||||
.delete('/api/devicestatus/' + testId)
|
||||
.set('api-secret', known || '')
|
||||
.end(function() {
|
||||
// Ignore errors (document may not exist)
|
||||
request(self.app)
|
||||
.post('/api/devicestatus/')
|
||||
.set('api-secret', known || '')
|
||||
.send(status_valid_id)
|
||||
.expect(200)
|
||||
.expect(function(response) {
|
||||
response.body.should.be.an.Array();
|
||||
response.body.length.should.equal(1);
|
||||
response.body[0]._id.should.equal(testId);
|
||||
})
|
||||
.end(function(err) {
|
||||
if (err) return done(err);
|
||||
// Clean up
|
||||
request(self.app)
|
||||
.delete('/api/devicestatus/' + testId)
|
||||
.set('api-secret', known || '')
|
||||
.expect(200)
|
||||
.end(done);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should accept POST without _id (auto-generate)', function(done) {
|
||||
var status_no_id = {
|
||||
"device": "test-device-autogen",
|
||||
"created_at": "2024-01-03T00:00:00Z"
|
||||
};
|
||||
|
||||
request(self.app)
|
||||
.post('/api/devicestatus/')
|
||||
.set('api-secret', known || '')
|
||||
.send(status_no_id)
|
||||
.expect(200)
|
||||
.expect(function(response) {
|
||||
response.body.should.be.an.Array();
|
||||
response.body.length.should.equal(1);
|
||||
response.body[0].should.have.property('_id');
|
||||
// Verify auto-generated _id is valid ObjectId format
|
||||
response.body[0]._id.toString().should.match(/^[a-fA-F0-9]{24}$/);
|
||||
})
|
||||
.end(function(err, res) {
|
||||
if (err) return done(err);
|
||||
// Clean up
|
||||
var createdId = res.body[0]._id;
|
||||
request(self.app)
|
||||
.delete('/api/devicestatus/' + createdId)
|
||||
.set('api-secret', known || '')
|
||||
.expect(200)
|
||||
.end(done);
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 for array POST with one invalid _id', function(done) {
|
||||
var statuses_mixed = [
|
||||
{ "device": "device1", "created_at": "2024-01-01T00:00:00Z" },
|
||||
{ "_id": "bad-uuid", "device": "device2", "created_at": "2024-01-02T00:00:00Z" }
|
||||
];
|
||||
|
||||
request(self.app)
|
||||
.post('/api/devicestatus/')
|
||||
.set('api-secret', known || '')
|
||||
.send(statuses_mixed)
|
||||
.expect(400)
|
||||
.expect(function(response) {
|
||||
response.body.message.should.match(/Invalid _id format/i);
|
||||
})
|
||||
.end(done);
|
||||
});
|
||||
|
||||
it('should allow DELETE with wildcard _id', function(done) {
|
||||
// First insert a test record
|
||||
request(self.app)
|
||||
.post('/api/devicestatus/')
|
||||
.set('api-secret', known || '')
|
||||
.send({ "device": "delete-wildcard-test", "created_at": "2020-01-01T00:00:00Z" })
|
||||
.expect(200)
|
||||
.end(function(err) {
|
||||
if (err) return done(err);
|
||||
// Wildcard delete with date filter should work
|
||||
request(self.app)
|
||||
.delete('/api/devicestatus/*')
|
||||
.query('find[created_at][$lte]=2020-01-02')
|
||||
.set('api-secret', known || '')
|
||||
.expect(200)
|
||||
.end(done);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -414,4 +414,62 @@ describe('Entries REST api', function ( ) {
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Single object input tests - validates response format
|
||||
// ============================================================
|
||||
|
||||
it('post single entry returns array with one item', function (done) {
|
||||
var now = Date.now();
|
||||
var dateString = new Date(now).toISOString();
|
||||
|
||||
request(self.app)
|
||||
.post('/entries/')
|
||||
.set('api-secret', known || '')
|
||||
.send({
|
||||
type: 'sgv',
|
||||
sgv: 142,
|
||||
date: now,
|
||||
dateString: dateString,
|
||||
device: 'test-device',
|
||||
direction: 'Flat'
|
||||
})
|
||||
.expect(200)
|
||||
.end(function (err, res) {
|
||||
if (err) {
|
||||
return done(err);
|
||||
}
|
||||
// Response should be an array even for single object input
|
||||
res.body.should.be.instanceof(Array);
|
||||
res.body.length.should.be.above(0);
|
||||
res.body[0].should.have.property('_id');
|
||||
res.body[0].sgv.should.equal(142);
|
||||
res.body[0].type.should.equal('sgv');
|
||||
res.body[0].direction.should.equal('Flat');
|
||||
|
||||
// Clean up
|
||||
request(self.app)
|
||||
.delete('/entries.json?find[date]=' + now)
|
||||
.set('api-secret', known || '')
|
||||
.expect(200)
|
||||
.end(done);
|
||||
});
|
||||
});
|
||||
|
||||
it('post empty array returns empty result', function (done) {
|
||||
request(self.app)
|
||||
.post('/entries/')
|
||||
.set('api-secret', known || '')
|
||||
.send([])
|
||||
.expect(200)
|
||||
.end(function (err, res) {
|
||||
if (err) {
|
||||
return done(err);
|
||||
}
|
||||
// Empty input should return success with empty or minimal response
|
||||
res.body.should.be.instanceof(Array);
|
||||
res.body.length.should.equal(0);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -147,4 +147,100 @@ describe('Food API', function ( ) {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Array input tests - validates ef7bff3d fix
|
||||
// ============================================================
|
||||
|
||||
it('post a food array', function (done) {
|
||||
var now = (new Date()).toISOString();
|
||||
request(self.app)
|
||||
.post('/api/food/')
|
||||
.set('api-secret', known || '')
|
||||
.send([
|
||||
{ type: 'food', category: 'snack', subcategory: 'chips', name: 'Test Chips', portion: 30, carbs: 15, fat: 5, protein: 1, energy: 120, gi: 3, unit: 'g', created_at: now },
|
||||
{ type: 'food', category: 'snack', subcategory: 'fruit', name: 'Test Apple', portion: 150, carbs: 20, fat: 0, protein: 0, energy: 80, gi: 2, unit: 'g', created_at: now }
|
||||
])
|
||||
.expect(200)
|
||||
.end(function (err, res) {
|
||||
if (err) {
|
||||
return done(err);
|
||||
}
|
||||
// Response should be an array with 2 items
|
||||
res.body.should.be.an.Array();
|
||||
res.body.length.should.equal(2);
|
||||
// Both items should have _id assigned
|
||||
res.body[0].should.have.property('_id');
|
||||
res.body[1].should.have.property('_id');
|
||||
res.body[0].name.should.equal('Test Chips');
|
||||
res.body[1].name.should.equal('Test Apple');
|
||||
|
||||
// Clean up - delete both
|
||||
request(self.app)
|
||||
.delete('/api/food/' + res.body[0]._id)
|
||||
.set('api-secret', known || '')
|
||||
.expect(200)
|
||||
.end(function (err) {
|
||||
if (err) return done(err);
|
||||
request(self.app)
|
||||
.delete('/api/food/' + res.body[1]._id)
|
||||
.set('api-secret', known || '')
|
||||
.expect(200)
|
||||
.end(done);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('put a food array', function (done) {
|
||||
var now = (new Date()).toISOString();
|
||||
request(self.app)
|
||||
.put('/api/food/')
|
||||
.set('api-secret', known || '')
|
||||
.send([
|
||||
{ type: 'food', category: 'meal', subcategory: 'pasta', name: 'Test Pasta', portion: 200, carbs: 60, fat: 3, protein: 8, energy: 300, gi: 3, unit: 'g', created_at: now },
|
||||
{ type: 'food', category: 'meal', subcategory: 'rice', name: 'Test Rice', portion: 180, carbs: 55, fat: 1, protein: 5, energy: 250, gi: 3, unit: 'g', created_at: now }
|
||||
])
|
||||
.expect(200)
|
||||
.end(function (err, res) {
|
||||
if (err) {
|
||||
return done(err);
|
||||
}
|
||||
res.body.should.be.an.Array();
|
||||
res.body.length.should.equal(2);
|
||||
res.body[0].should.have.property('_id');
|
||||
res.body[1].should.have.property('_id');
|
||||
res.body[0].name.should.equal('Test Pasta');
|
||||
res.body[1].name.should.equal('Test Rice');
|
||||
|
||||
// Clean up
|
||||
request(self.app)
|
||||
.delete('/api/food/' + res.body[0]._id)
|
||||
.set('api-secret', known || '')
|
||||
.expect(200)
|
||||
.end(function (err) {
|
||||
if (err) return done(err);
|
||||
request(self.app)
|
||||
.delete('/api/food/' + res.body[1]._id)
|
||||
.set('api-secret', known || '')
|
||||
.expect(200)
|
||||
.end(done);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('post empty array returns empty array', function (done) {
|
||||
request(self.app)
|
||||
.post('/api/food/')
|
||||
.set('api-secret', known || '')
|
||||
.send([])
|
||||
.expect(200)
|
||||
.end(function (err, res) {
|
||||
if (err) {
|
||||
return done(err);
|
||||
}
|
||||
res.body.should.be.an.Array();
|
||||
res.body.length.should.equal(0);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
'use strict';
|
||||
|
||||
var request = require('supertest');
|
||||
var should = require('should');
|
||||
var language = require('../lib/language')();
|
||||
|
||||
describe('_id Validation API Tests', function() {
|
||||
this.timeout(10000);
|
||||
var self = this;
|
||||
var known = 'b723e97aa97846eb92d5264f084b2823f57c4aa1';
|
||||
|
||||
var api = require('../lib/api/');
|
||||
|
||||
before(function(done) {
|
||||
process.env.API_SECRET = 'this is my long pass phrase';
|
||||
self.env = require('../lib/server/env')();
|
||||
self.env.settings.authDefaultRoles = 'readable';
|
||||
self.env.settings.enable = ['careportal', 'api'];
|
||||
this.wares = require('../lib/middleware/')(self.env);
|
||||
self.app = require('express')();
|
||||
self.app.enable('api');
|
||||
self.app.enable('careportal');
|
||||
require('../lib/server/bootevent')(self.env, language).boot(function booted(ctx) {
|
||||
self.ctx = ctx;
|
||||
self.ctx.ddata = require('../lib/data/ddata')();
|
||||
self.app.use('/api', api(self.env, ctx));
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Activity API _id validation', function() {
|
||||
it('should return 400 for POST with invalid UUID _id', function(done) {
|
||||
request(self.app)
|
||||
.post('/api/activity/')
|
||||
.set('api-secret', known)
|
||||
.send({ "_id": "my-uuid-12345", "created_at": "2024-01-01T00:00:00Z", "steps": 1000 })
|
||||
.expect(400)
|
||||
.expect(function(response) {
|
||||
response.body.should.have.property('status', 400);
|
||||
response.body.message.should.match(/Invalid _id format/i);
|
||||
})
|
||||
.end(done);
|
||||
});
|
||||
|
||||
it('should return 400 for PUT with invalid _id', function(done) {
|
||||
request(self.app)
|
||||
.put('/api/activity/')
|
||||
.set('api-secret', known)
|
||||
.send({ "_id": "not-valid", "created_at": "2024-01-01T00:00:00Z", "steps": 1000 })
|
||||
.expect(400)
|
||||
.end(done);
|
||||
});
|
||||
|
||||
it('should return 400 for DELETE with invalid _id', function(done) {
|
||||
request(self.app)
|
||||
.delete('/api/activity/invalid-id')
|
||||
.set('api-secret', known)
|
||||
.expect(400)
|
||||
.end(done);
|
||||
});
|
||||
|
||||
it('should accept POST without _id (auto-generate)', function(done) {
|
||||
request(self.app)
|
||||
.post('/api/activity/')
|
||||
.set('api-secret', known)
|
||||
.send({ "created_at": "2024-01-01T00:00:00Z", "steps": 1000 })
|
||||
.expect(200)
|
||||
.end(done);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Food API _id validation', function() {
|
||||
it('should return 400 for POST with invalid UUID _id', function(done) {
|
||||
request(self.app)
|
||||
.post('/api/food/')
|
||||
.set('api-secret', known)
|
||||
.send({ "_id": "my-uuid-12345", "name": "Apple", "type": "food", "carbs": 15 })
|
||||
.expect(400)
|
||||
.expect(function(response) {
|
||||
response.body.should.have.property('status', 400);
|
||||
response.body.message.should.match(/Invalid _id format/i);
|
||||
})
|
||||
.end(done);
|
||||
});
|
||||
|
||||
it('should return 400 for PUT with invalid _id', function(done) {
|
||||
request(self.app)
|
||||
.put('/api/food/')
|
||||
.set('api-secret', known)
|
||||
.send({ "_id": "not-valid", "name": "Apple", "type": "food", "carbs": 15 })
|
||||
.expect(400)
|
||||
.end(done);
|
||||
});
|
||||
|
||||
it('should return 400 for DELETE with invalid _id', function(done) {
|
||||
request(self.app)
|
||||
.delete('/api/food/invalid-id')
|
||||
.set('api-secret', known)
|
||||
.expect(400)
|
||||
.end(done);
|
||||
});
|
||||
|
||||
it('should accept POST without _id (auto-generate)', function(done) {
|
||||
request(self.app)
|
||||
.post('/api/food/')
|
||||
.set('api-secret', known)
|
||||
.send({ "name": "Banana", "type": "food", "carbs": 27 })
|
||||
.expect(200)
|
||||
.expect(function(response) {
|
||||
// Food API returns array (consistent with treatments pattern)
|
||||
response.body.should.be.an.Array();
|
||||
response.body.length.should.equal(1);
|
||||
response.body[0].should.have.property('name', 'Banana');
|
||||
})
|
||||
.end(done);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -147,4 +147,155 @@ describe('Profiles API', function ( ) {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// _id validation tests (prevent 500 errors from invalid ObjectId)
|
||||
describe('_id validation', function() {
|
||||
|
||||
it('should return 400 for POST with invalid UUID _id', function(done) {
|
||||
var profile_with_uuid = {
|
||||
"_id": "my-uuid-12345",
|
||||
"defaultProfile": "Default",
|
||||
"store": { "Default": { "dia": 3 } },
|
||||
"startDate": "2024-10-19T23:00:00.000Z"
|
||||
};
|
||||
|
||||
request(self.app)
|
||||
.post('/api/profile/')
|
||||
.set('api-secret', known || '')
|
||||
.send(profile_with_uuid)
|
||||
.expect(400)
|
||||
.expect(function(response) {
|
||||
response.body.should.have.property('status', 400);
|
||||
response.body.should.have.property('message');
|
||||
response.body.message.should.match(/Invalid _id format/i);
|
||||
})
|
||||
.end(done);
|
||||
});
|
||||
|
||||
it('should return 400 for POST with short _id', function(done) {
|
||||
var profile_short_id = {
|
||||
"_id": "abc",
|
||||
"defaultProfile": "Default",
|
||||
"store": { "Default": { "dia": 3 } },
|
||||
"startDate": "2024-10-19T23:00:00.000Z"
|
||||
};
|
||||
|
||||
request(self.app)
|
||||
.post('/api/profile/')
|
||||
.set('api-secret', known || '')
|
||||
.send(profile_short_id)
|
||||
.expect(400)
|
||||
.end(done);
|
||||
});
|
||||
|
||||
it('should return 400 for PUT with invalid _id', function(done) {
|
||||
var profile_invalid = {
|
||||
"_id": "not-a-valid-object-id",
|
||||
"defaultProfile": "Default",
|
||||
"store": { "Default": { "dia": 3 } },
|
||||
"startDate": "2024-10-19T23:00:00.000Z"
|
||||
};
|
||||
|
||||
request(self.app)
|
||||
.put('/api/profile/')
|
||||
.set('api-secret', known || '')
|
||||
.send(profile_invalid)
|
||||
.expect(400)
|
||||
.end(done);
|
||||
});
|
||||
|
||||
it('should return 400 for DELETE with invalid _id', function(done) {
|
||||
request(self.app)
|
||||
.delete('/api/profile/invalid-uuid-here')
|
||||
.set('api-secret', known || '')
|
||||
.expect(400)
|
||||
.end(done);
|
||||
});
|
||||
|
||||
it('should accept POST with valid 24-hex _id', function(done) {
|
||||
// Use a unique ID that doesn't conflict with other tests
|
||||
var testId = 'aaaaaaaaaaaaaaaaaaaaaaaa';
|
||||
var profile_valid_id = {
|
||||
"_id": testId,
|
||||
"defaultProfile": "Default",
|
||||
"store": { "Default": { "dia": 3 } },
|
||||
"startDate": "2024-10-19T23:00:00.000Z"
|
||||
};
|
||||
|
||||
// First, try to delete any existing document with this _id (cleanup from previous runs)
|
||||
request(self.app)
|
||||
.delete('/api/profile/' + testId)
|
||||
.set('api-secret', known || '')
|
||||
.end(function() {
|
||||
// Ignore errors (document may not exist)
|
||||
request(self.app)
|
||||
.post('/api/profile/')
|
||||
.set('api-secret', known || '')
|
||||
.send(profile_valid_id)
|
||||
.expect(200)
|
||||
.expect(function(response) {
|
||||
response.body.should.be.an.Array();
|
||||
response.body.length.should.equal(1);
|
||||
response.body[0]._id.should.equal(testId);
|
||||
})
|
||||
.end(function(err) {
|
||||
if (err) return done(err);
|
||||
// Clean up: delete the profile we just created
|
||||
request(self.app)
|
||||
.delete('/api/profile/' + testId)
|
||||
.set('api-secret', known || '')
|
||||
.expect(200)
|
||||
.end(done);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should accept POST without _id (auto-generate)', function(done) {
|
||||
var profile_no_id = {
|
||||
"defaultProfile": "Default",
|
||||
"store": { "Default": { "dia": 3 } },
|
||||
"startDate": "2024-10-20T23:00:00.000Z"
|
||||
};
|
||||
|
||||
request(self.app)
|
||||
.post('/api/profile/')
|
||||
.set('api-secret', known || '')
|
||||
.send(profile_no_id)
|
||||
.expect(200)
|
||||
.expect(function(response) {
|
||||
response.body.should.be.an.Array();
|
||||
response.body.length.should.equal(1);
|
||||
response.body[0].should.have.property('_id');
|
||||
// Verify auto-generated _id is valid format
|
||||
response.body[0]._id.toString().should.match(/^[a-fA-F0-9]{24}$/);
|
||||
})
|
||||
.end(function(err, res) {
|
||||
if (err) return done(err);
|
||||
// Clean up
|
||||
var createdId = res.body[0]._id;
|
||||
request(self.app)
|
||||
.delete('/api/profile/' + createdId)
|
||||
.set('api-secret', known || '')
|
||||
.expect(200)
|
||||
.end(done);
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 for array POST with one invalid _id', function(done) {
|
||||
var profiles_mixed = [
|
||||
{ "defaultProfile": "Default1", "store": { "Default": { "dia": 3 } }, "startDate": "2024-10-19T23:00:00.000Z" },
|
||||
{ "_id": "bad-uuid", "defaultProfile": "Default2", "store": { "Default": { "dia": 3 } }, "startDate": "2024-10-20T23:00:00.000Z" }
|
||||
];
|
||||
|
||||
request(self.app)
|
||||
.post('/api/profile/')
|
||||
.set('api-secret', known || '')
|
||||
.send(profiles_mixed)
|
||||
.expect(400)
|
||||
.expect(function(response) {
|
||||
response.body.message.should.match(/Invalid _id format/i);
|
||||
})
|
||||
.end(done);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -515,4 +515,331 @@ describe('API Shape Handling - Single Object vs Array Input', function () {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Profile API - /api/profile/', function () {
|
||||
var profileFixtures = require('./fixtures/nightscoutkit-profiles.js');
|
||||
|
||||
// Profile uses unique mills/startDate for each test, so cleanup not strictly needed
|
||||
// but we'll clean up via direct collection access for good practice
|
||||
|
||||
it('POST accepts single profile object', function (done) {
|
||||
var profile = profileFixtures.generateUniqueProfile('single-obj');
|
||||
request(self.app)
|
||||
.post('/api/profile/')
|
||||
.set('api-secret', known)
|
||||
.send(profile)
|
||||
.expect(200)
|
||||
.end(function (err, res) {
|
||||
if (err) return done(err);
|
||||
res.body.should.be.instanceof(Array);
|
||||
res.body.length.should.equal(1);
|
||||
res.body[0].defaultProfile.should.equal('Default');
|
||||
res.body[0].should.have.property('_id');
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('POST accepts array with single profile (NightscoutKit format)', function (done) {
|
||||
var profile = profileFixtures.generateUniqueProfile('array-single');
|
||||
request(self.app)
|
||||
.post('/api/profile/')
|
||||
.set('api-secret', known)
|
||||
.send([profile])
|
||||
.expect(200)
|
||||
.end(function (err, res) {
|
||||
if (err) return done(err);
|
||||
res.body.should.be.instanceof(Array);
|
||||
res.body.length.should.equal(1);
|
||||
res.body[0].defaultProfile.should.equal('Default');
|
||||
res.body[0].should.have.property('_id');
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('POST accepts array with multiple profiles (batch upload)', function (done) {
|
||||
var profiles = [
|
||||
profileFixtures.generateUniqueProfile('batch-1'),
|
||||
profileFixtures.generateUniqueProfile('batch-2'),
|
||||
profileFixtures.generateUniqueProfile('batch-3')
|
||||
];
|
||||
request(self.app)
|
||||
.post('/api/profile/')
|
||||
.set('api-secret', known)
|
||||
.send(profiles)
|
||||
.expect(200)
|
||||
.end(function (err, res) {
|
||||
if (err) return done(err);
|
||||
res.body.should.be.instanceof(Array);
|
||||
res.body.length.should.equal(3);
|
||||
res.body.forEach(function(profile) {
|
||||
profile.should.have.property('_id');
|
||||
});
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('POST response count equals input count', function (done) {
|
||||
var profiles = [
|
||||
profileFixtures.generateUniqueProfile('count-1'),
|
||||
profileFixtures.generateUniqueProfile('count-2')
|
||||
];
|
||||
request(self.app)
|
||||
.post('/api/profile/')
|
||||
.set('api-secret', known)
|
||||
.send(profiles)
|
||||
.expect(200)
|
||||
.end(function (err, res) {
|
||||
if (err) return done(err);
|
||||
res.body.length.should.equal(profiles.length);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('POST with empty array returns empty array', function (done) {
|
||||
request(self.app)
|
||||
.post('/api/profile/')
|
||||
.set('api-secret', known)
|
||||
.send([])
|
||||
.expect(200)
|
||||
.end(function (err, res) {
|
||||
if (err) return done(err);
|
||||
res.body.should.be.instanceof(Array);
|
||||
res.body.length.should.equal(0);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('single profile input returns array response', function (done) {
|
||||
var profile = profileFixtures.generateUniqueProfile('response-shape');
|
||||
request(self.app)
|
||||
.post('/api/profile/')
|
||||
.set('api-secret', known)
|
||||
.send(profile)
|
||||
.expect(200)
|
||||
.end(function (err, res) {
|
||||
if (err) return done(err);
|
||||
res.body.should.be.instanceof(Array);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('array input returns array response', function (done) {
|
||||
var profile = profileFixtures.generateUniqueProfile('array-response');
|
||||
request(self.app)
|
||||
.post('/api/profile/')
|
||||
.set('api-secret', known)
|
||||
.send([profile])
|
||||
.expect(200)
|
||||
.end(function (err, res) {
|
||||
if (err) return done(err);
|
||||
res.body.should.be.instanceof(Array);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('NightscoutKit Fixtures Integration', function () {
|
||||
var treatmentFixtures = require('./fixtures/nightscoutkit-treatments.js');
|
||||
var devicestatusFixtures = require('./fixtures/nightscoutkit-devicestatus.js');
|
||||
var profileFixtures = require('./fixtures/nightscoutkit-profiles.js');
|
||||
|
||||
beforeEach(function (done) {
|
||||
self.ctx.treatments.remove({ find: { created_at: { '$gte': '1999-01-01T00:00:00.000Z' } } }, function () {
|
||||
self.ctx.devicestatus.remove({ find: { created_at: { '$gte': '1999-01-01T00:00:00.000Z' } } }, function () {
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(function (done) {
|
||||
self.ctx.treatments.remove({ find: { created_at: { '$gte': '1999-01-01T00:00:00.000Z' } } }, function () {
|
||||
self.ctx.devicestatus.remove({ find: { created_at: { '$gte': '1999-01-01T00:00:00.000Z' } } }, function () {
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Treatments with NightscoutKit fixtures', function () {
|
||||
it('accepts single bolus array', function (done) {
|
||||
var bolus = treatmentFixtures.helpers.bolusTreatment(
|
||||
new Date().toISOString(), 1.5,
|
||||
{ syncIdentifier: 'test-bolus-' + Date.now() }
|
||||
);
|
||||
request(self.app)
|
||||
.post('/api/treatments/')
|
||||
.set('api-secret', known)
|
||||
.send([bolus])
|
||||
.expect(200)
|
||||
.end(function (err, res) {
|
||||
if (err) return done(err);
|
||||
res.body.should.be.instanceof(Array);
|
||||
res.body.length.should.equal(1);
|
||||
res.body[0].insulin.should.equal(1.5);
|
||||
res.body[0].eventType.should.equal('Correction Bolus');
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts carb entry with syncIdentifier', function (done) {
|
||||
var carb = treatmentFixtures.helpers.carbTreatment(
|
||||
new Date().toISOString(), 45,
|
||||
{ syncIdentifier: 'test-carb-' + Date.now(), absorptionTime: 180 }
|
||||
);
|
||||
request(self.app)
|
||||
.post('/api/treatments/')
|
||||
.set('api-secret', known)
|
||||
.send([carb])
|
||||
.expect(200)
|
||||
.end(function (err, res) {
|
||||
if (err) return done(err);
|
||||
res.body.should.be.instanceof(Array);
|
||||
res.body[0].carbs.should.equal(45);
|
||||
res.body[0].eventType.should.equal('Carb Correction');
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts temp basal array', function (done) {
|
||||
var tempBasal = treatmentFixtures.helpers.tempBasalTreatment(
|
||||
new Date().toISOString(), 1.2, 30,
|
||||
{ syncIdentifier: 'test-tb-' + Date.now() }
|
||||
);
|
||||
request(self.app)
|
||||
.post('/api/treatments/')
|
||||
.set('api-secret', known)
|
||||
.send([tempBasal])
|
||||
.expect(200)
|
||||
.end(function (err, res) {
|
||||
if (err) return done(err);
|
||||
res.body.should.be.instanceof(Array);
|
||||
res.body[0].rate.should.equal(1.2);
|
||||
res.body[0].duration.should.equal(30);
|
||||
res.body[0].eventType.should.equal('Temp Basal');
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts mixed batch array', function (done) {
|
||||
var now = Date.now();
|
||||
var batch = [
|
||||
treatmentFixtures.helpers.bolusTreatment(
|
||||
new Date(now).toISOString(), 2.0,
|
||||
{ syncIdentifier: 'batch-bolus-' + now }
|
||||
),
|
||||
treatmentFixtures.helpers.carbTreatment(
|
||||
new Date(now + 1000).toISOString(), 30,
|
||||
{ syncIdentifier: 'batch-carb-' + now }
|
||||
),
|
||||
treatmentFixtures.helpers.tempBasalTreatment(
|
||||
new Date(now + 2000).toISOString(), 0.8, 30,
|
||||
{ syncIdentifier: 'batch-tb-' + now }
|
||||
)
|
||||
];
|
||||
request(self.app)
|
||||
.post('/api/treatments/')
|
||||
.set('api-secret', known)
|
||||
.send(batch)
|
||||
.expect(200)
|
||||
.end(function (err, res) {
|
||||
if (err) return done(err);
|
||||
res.body.should.be.instanceof(Array);
|
||||
res.body.length.should.equal(3);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('DeviceStatus with NightscoutKit fixtures', function () {
|
||||
it('accepts Loop devicestatus array', function (done) {
|
||||
var status = devicestatusFixtures.helpers.deviceStatus(
|
||||
'loop://iPhone-test', new Date().toISOString(),
|
||||
{
|
||||
identifier: 'test-ds-' + Date.now(),
|
||||
uploader: devicestatusFixtures.helpers.uploaderStatus('iPhone', new Date().toISOString(), 85),
|
||||
loop: devicestatusFixtures.helpers.loopStatus('Loop', '3.4.1', new Date().toISOString(), {
|
||||
iob: devicestatusFixtures.helpers.iobStatus(new Date().toISOString(), 2.5, 0.8),
|
||||
cob: devicestatusFixtures.helpers.cobStatus(new Date().toISOString(), 25)
|
||||
})
|
||||
}
|
||||
);
|
||||
request(self.app)
|
||||
.post('/api/devicestatus/')
|
||||
.set('api-secret', known)
|
||||
.send([status])
|
||||
.expect(200)
|
||||
.end(function (err, res) {
|
||||
if (err) return done(err);
|
||||
res.body.should.be.instanceof(Array);
|
||||
res.body.length.should.equal(1);
|
||||
res.body[0].loop.iob.iob.should.equal(2.5);
|
||||
res.body[0].loop.cob.cob.should.equal(25);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts batch devicestatus array', function (done) {
|
||||
var now = Date.now();
|
||||
var batch = [
|
||||
devicestatusFixtures.helpers.deviceStatus('loop://iPhone-1', new Date(now).toISOString(), {
|
||||
identifier: 'batch-ds-1-' + now
|
||||
}),
|
||||
devicestatusFixtures.helpers.deviceStatus('loop://iPhone-2', new Date(now + 1000).toISOString(), {
|
||||
identifier: 'batch-ds-2-' + now
|
||||
})
|
||||
];
|
||||
request(self.app)
|
||||
.post('/api/devicestatus/')
|
||||
.set('api-secret', known)
|
||||
.send(batch)
|
||||
.expect(200)
|
||||
.end(function (err, res) {
|
||||
if (err) return done(err);
|
||||
res.body.should.be.instanceof(Array);
|
||||
res.body.length.should.equal(2);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Profile with NightscoutKit fixtures', function () {
|
||||
it('accepts Loop profile array', function (done) {
|
||||
var profile = profileFixtures.generateUniqueProfile('loop-test');
|
||||
request(self.app)
|
||||
.post('/api/profile/')
|
||||
.set('api-secret', known)
|
||||
.send([profile])
|
||||
.expect(200)
|
||||
.end(function (err, res) {
|
||||
if (err) return done(err);
|
||||
res.body.should.be.instanceof(Array);
|
||||
res.body.length.should.equal(1);
|
||||
res.body[0].defaultProfile.should.equal('Default');
|
||||
res.body[0].loopSettings.dosingEnabled.should.equal(true);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts batch profile array (historical sync)', function (done) {
|
||||
var batch = [
|
||||
profileFixtures.generateUniqueProfile('hist-1'),
|
||||
profileFixtures.generateUniqueProfile('hist-2'),
|
||||
profileFixtures.generateUniqueProfile('hist-3')
|
||||
];
|
||||
request(self.app)
|
||||
.post('/api/profile/')
|
||||
.set('api-secret', known)
|
||||
.send(batch)
|
||||
.expect(200)
|
||||
.end(function (err, res) {
|
||||
if (err) return done(err);
|
||||
res.body.should.be.instanceof(Array);
|
||||
res.body.length.should.equal(3);
|
||||
res.body.forEach(function(p) {
|
||||
p.should.have.property('_id');
|
||||
});
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -274,6 +274,9 @@ describe('boluswizardpreview', function ( ) {
|
||||
});
|
||||
|
||||
it('set a pill to the BWP with infos', function (done) {
|
||||
// BWP-TIME-001: Use fixed timestamp for deterministic IOB calculation
|
||||
// Using `now` instead of `Date.now()` prevents timing drift between
|
||||
// when data timestamps are set and when sandbox is initialized
|
||||
var ctx = {
|
||||
settings: {}
|
||||
, pluginBase: {
|
||||
@@ -297,7 +300,8 @@ describe('boluswizardpreview', function ( ) {
|
||||
, profile: loadedProfile
|
||||
};
|
||||
|
||||
var sbx = require('../lib/sandbox')().clientInit(ctx, Date.now(), data);
|
||||
// Use `now` (same as data timestamps) instead of Date.now() for determinism
|
||||
var sbx = require('../lib/sandbox')().clientInit(ctx, now, data);
|
||||
|
||||
iob.setProperties(sbx);
|
||||
boluswizardpreview.setProperties(sbx);
|
||||
|
||||
Vendored
+3
-1
@@ -7,5 +7,7 @@ module.exports = {
|
||||
trio: require('./trio-pipeline'),
|
||||
deduplication: require('./deduplication'),
|
||||
edgeCases: require('./edge-cases'),
|
||||
partialFailures: require('./partial-failures')
|
||||
partialFailures: require('./partial-failures'),
|
||||
nightscoutkitProfiles: require('./nightscoutkit-profiles'),
|
||||
nightscoutkitDevicestatus: require('./nightscoutkit-devicestatus')
|
||||
};
|
||||
|
||||
+347
@@ -0,0 +1,347 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* NightscoutKit DeviceStatus Fixtures
|
||||
*
|
||||
* Extracted from NightscoutKit Swift source:
|
||||
* - externals/NightscoutKit/Sources/NightscoutKit/Models/DeviceStatus.swift
|
||||
* - externals/NightscoutKit/Sources/NightscoutKit/Models/LoopStatus.swift
|
||||
* - externals/NightscoutKit/Sources/NightscoutKit/Models/PumpStatus.swift
|
||||
* - externals/NightscoutKit/Sources/NightscoutKit/Models/IOBStatus.swift
|
||||
* - externals/NightscoutKit/Sources/NightscoutKit/Models/COBStatus.swift
|
||||
* - externals/NightscoutKit/Sources/NightscoutKit/Models/PredictedBG.swift
|
||||
* - externals/NightscoutKit/Sources/NightscoutKit/Models/LoopEnacted.swift
|
||||
* - externals/NightscoutKit/Sources/NightscoutKit/Models/UploaderStatus.swift
|
||||
* - externals/NightscoutKit/Sources/NightscoutKit/Models/BatteryStatus.swift
|
||||
*
|
||||
* NightscoutKit sends devicestatus as arrays (see NightscoutClient.swift:646-647):
|
||||
* postToNS(deviceStatuses.map { $0.dictionaryRepresentation }, endpoint: .deviceStatus, ...)
|
||||
*/
|
||||
|
||||
const BASE_TIME = '2026-03-18T17:00:00.000Z';
|
||||
const BASE_TIME_MS = 1773861600000;
|
||||
|
||||
// Helper: Generate ISO timestamp offset from BASE_TIME
|
||||
function timestamp(minutesOffset = 0) {
|
||||
return new Date(BASE_TIME_MS + minutesOffset * 60 * 1000).toISOString();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// IOBStatus - from IOBStatus.swift:24-38
|
||||
// ============================================================
|
||||
function iobStatus(ts, iob, basalIOB = null) {
|
||||
const result = {
|
||||
timestamp: ts,
|
||||
iob: iob
|
||||
};
|
||||
if (basalIOB !== null) {
|
||||
result.basaliob = basalIOB;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// COBStatus - from COBStatus.swift:22-28
|
||||
// ============================================================
|
||||
function cobStatus(ts, cob) {
|
||||
return {
|
||||
timestamp: ts,
|
||||
cob: cob
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// PredictedBG - from PredictedBG.swift:30-44
|
||||
// Values are in mg/dL, 5-minute intervals
|
||||
// ============================================================
|
||||
function predictedBG(startDate, values, cobValues = null, iobValues = null) {
|
||||
const result = {
|
||||
startDate: startDate,
|
||||
values: values
|
||||
};
|
||||
if (cobValues) {
|
||||
result.COB = cobValues;
|
||||
}
|
||||
if (iobValues) {
|
||||
result.IOB = iobValues;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// LoopEnacted - from LoopEnacted.swift:28-39
|
||||
// duration is in MINUTES in the JSON (converted from TimeInterval)
|
||||
// ============================================================
|
||||
function loopEnacted(ts, rate, durationMinutes, received, bolusVolume = 0) {
|
||||
return {
|
||||
rate: rate,
|
||||
duration: durationMinutes,
|
||||
timestamp: ts,
|
||||
received: received,
|
||||
bolusVolume: bolusVolume
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// BatteryStatus - from BatteryStatus.swift:28-43
|
||||
// ============================================================
|
||||
function batteryStatus(percent = null, voltage = null, status = null) {
|
||||
const result = {};
|
||||
if (percent !== null) result.percent = percent;
|
||||
if (voltage !== null) result.voltage = voltage;
|
||||
if (status !== null) result.status = status; // 'low' or 'normal'
|
||||
return result;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// PumpStatus - from PumpStatus.swift:49-66
|
||||
// ============================================================
|
||||
function pumpStatus(clock, pumpID, options = {}) {
|
||||
const result = {
|
||||
clock: clock,
|
||||
pumpID: pumpID
|
||||
};
|
||||
if (options.manufacturer) result.manufacturer = options.manufacturer;
|
||||
if (options.model) result.model = options.model;
|
||||
if (options.iob) result.iob = options.iob;
|
||||
if (options.battery) result.battery = options.battery;
|
||||
if (options.suspended !== undefined) result.suspended = options.suspended;
|
||||
if (options.bolusing !== undefined) result.bolusing = options.bolusing;
|
||||
if (options.reservoir !== undefined) result.reservoir = options.reservoir;
|
||||
if (options.secondsFromGMT !== undefined) result.secondsFromGMT = options.secondsFromGMT;
|
||||
if (options.reservoirDisplayOverride) result.reservoir_display_override = options.reservoirDisplayOverride;
|
||||
if (options.reservoirLevelOverride !== undefined) result.reservoir_level_override = options.reservoirLevelOverride;
|
||||
return result;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// UploaderStatus - from UploaderStatus.swift:34-45
|
||||
// ============================================================
|
||||
function uploaderStatus(name, ts, battery = null) {
|
||||
const result = {
|
||||
name: name,
|
||||
timestamp: ts
|
||||
};
|
||||
if (battery !== null) result.battery = battery;
|
||||
return result;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// LoopStatus - from LoopStatus.swift:48-99
|
||||
// The main Loop algorithm status object
|
||||
// ============================================================
|
||||
function loopStatus(name, version, ts, options = {}) {
|
||||
const result = {
|
||||
name: name,
|
||||
version: version,
|
||||
timestamp: ts
|
||||
};
|
||||
if (options.iob) result.iob = options.iob;
|
||||
if (options.cob) result.cob = options.cob;
|
||||
if (options.predicted) result.predicted = options.predicted;
|
||||
if (options.automaticDoseRecommendation) result.automaticDoseRecommendation = options.automaticDoseRecommendation;
|
||||
if (options.recommendedBolus !== undefined) result.recommendedBolus = options.recommendedBolus;
|
||||
if (options.enacted) result.enacted = options.enacted;
|
||||
if (options.rileylinks) result.rileylinks = options.rileylinks;
|
||||
if (options.failureReason) result.failureReason = options.failureReason;
|
||||
if (options.currentCorrectionRange) result.currentCorrectionRange = options.currentCorrectionRange;
|
||||
if (options.forecastError) result.forecastError = options.forecastError;
|
||||
if (options.testingDetails) result.testingDetails = options.testingDetails;
|
||||
return result;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// DeviceStatus - from DeviceStatus.swift:34-65
|
||||
// The top-level devicestatus document
|
||||
// ============================================================
|
||||
function deviceStatus(device, ts, options = {}) {
|
||||
const result = {
|
||||
device: device,
|
||||
created_at: ts
|
||||
};
|
||||
if (options.pump) result.pump = options.pump;
|
||||
if (options.uploader) result.uploader = options.uploader;
|
||||
if (options.loop) result.loop = options.loop;
|
||||
if (options.radioAdapter) result.radioAdapter = options.radioAdapter;
|
||||
if (options.override) result.override = options.override;
|
||||
if (options.identifier) result.identifier = options.identifier;
|
||||
return result;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// EXAMPLE FIXTURES
|
||||
// ============================================================
|
||||
|
||||
// Minimal devicestatus - just device and timestamp
|
||||
const minimalStatus = deviceStatus('loop://iPhone', timestamp(0));
|
||||
|
||||
// Loop devicestatus with IOB/COB
|
||||
const loopWithIOBCOB = deviceStatus('loop://iPhone', timestamp(0), {
|
||||
uploader: uploaderStatus('iPhone', timestamp(0), 85),
|
||||
loop: loopStatus('Loop', '3.4.1', timestamp(0), {
|
||||
iob: iobStatus(timestamp(0), 2.5, 0.8),
|
||||
cob: cobStatus(timestamp(0), 25)
|
||||
})
|
||||
});
|
||||
|
||||
// Full Loop devicestatus with predictions
|
||||
const fullLoopStatus = deviceStatus('loop://iPhone', timestamp(0), {
|
||||
identifier: 'abc123-def456-ghi789',
|
||||
uploader: uploaderStatus('iPhone', timestamp(0), 72),
|
||||
pump: pumpStatus(timestamp(0), 'ABCD1234', {
|
||||
manufacturer: 'Insulet',
|
||||
model: 'Dash',
|
||||
iob: iobStatus(timestamp(0), 2.5, 0.8),
|
||||
battery: batteryStatus(null, null, 'normal'),
|
||||
suspended: false,
|
||||
bolusing: false,
|
||||
reservoir: 125.5,
|
||||
secondsFromGMT: -18000
|
||||
}),
|
||||
loop: loopStatus('Loop', '3.4.1', timestamp(0), {
|
||||
iob: iobStatus(timestamp(0), 2.5, 0.8),
|
||||
cob: cobStatus(timestamp(0), 25),
|
||||
predicted: predictedBG(timestamp(0), [
|
||||
120, 118, 115, 112, 110, 108, 106, 104, 102, 100, 98, 96
|
||||
]),
|
||||
enacted: loopEnacted(timestamp(-5), 1.2, 30, true),
|
||||
recommendedBolus: 0,
|
||||
currentCorrectionRange: { minValue: 100, maxValue: 110 }
|
||||
})
|
||||
});
|
||||
|
||||
// Loop with enacted temp basal
|
||||
const loopWithEnacted = deviceStatus('loop://iPhone', timestamp(-5), {
|
||||
uploader: uploaderStatus('iPhone', timestamp(-5), 90),
|
||||
loop: loopStatus('Loop', '3.4.1', timestamp(-5), {
|
||||
iob: iobStatus(timestamp(-5), 1.8, 0.5),
|
||||
cob: cobStatus(timestamp(-5), 0),
|
||||
enacted: loopEnacted(timestamp(-5), 0.8, 30, true),
|
||||
predicted: predictedBG(timestamp(-5), [
|
||||
105, 102, 100, 98, 96, 95, 94, 93, 92, 91, 90
|
||||
])
|
||||
})
|
||||
});
|
||||
|
||||
// Loop with failure
|
||||
const loopWithFailure = deviceStatus('loop://iPhone', timestamp(-10), {
|
||||
uploader: uploaderStatus('iPhone', timestamp(-10), 65),
|
||||
loop: loopStatus('Loop', '3.4.1', timestamp(-10), {
|
||||
failureReason: 'Pump communication timeout'
|
||||
})
|
||||
});
|
||||
|
||||
// Loop with automatic bolus recommendation
|
||||
const loopWithAutoBolus = deviceStatus('loop://iPhone', timestamp(0), {
|
||||
uploader: uploaderStatus('iPhone', timestamp(0), 80),
|
||||
loop: loopStatus('Loop', '3.4.1', timestamp(0), {
|
||||
iob: iobStatus(timestamp(0), 3.2, 1.0),
|
||||
cob: cobStatus(timestamp(0), 45),
|
||||
predicted: predictedBG(timestamp(0), [
|
||||
145, 150, 155, 158, 160, 158, 155, 150, 145, 140
|
||||
]),
|
||||
automaticDoseRecommendation: {
|
||||
bolusUnits: 0.5,
|
||||
basalAdjustment: { rate: 2.5, duration: 30 }
|
||||
},
|
||||
recommendedBolus: 2.0
|
||||
})
|
||||
});
|
||||
|
||||
// Loop with multiple prediction curves (COB, IOB)
|
||||
const loopWithMultiplePredictions = deviceStatus('loop://iPhone', timestamp(0), {
|
||||
uploader: uploaderStatus('iPhone', timestamp(0), 88),
|
||||
loop: loopStatus('Loop', '3.4.1', timestamp(0), {
|
||||
iob: iobStatus(timestamp(0), 2.0, 0.6),
|
||||
cob: cobStatus(timestamp(0), 30),
|
||||
predicted: predictedBG(timestamp(0),
|
||||
[120, 125, 130, 128, 125, 120, 115, 110, 105, 100], // values (combined)
|
||||
[120, 130, 140, 145, 140, 135, 125, 115, 105, 95], // COB curve
|
||||
[120, 118, 115, 112, 110, 108, 106, 104, 102, 100] // IOB curve
|
||||
),
|
||||
enacted: loopEnacted(timestamp(-5), 1.5, 30, true)
|
||||
})
|
||||
});
|
||||
|
||||
// Second status for batch testing (different timestamp)
|
||||
const secondStatus = deviceStatus('loop://iPhone', timestamp(-15), {
|
||||
uploader: uploaderStatus('iPhone', timestamp(-15), 75),
|
||||
loop: loopStatus('Loop', '3.4.1', timestamp(-15), {
|
||||
iob: iobStatus(timestamp(-15), 1.5, 0.4),
|
||||
cob: cobStatus(timestamp(-15), 10)
|
||||
})
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
// Helper functions for building custom fixtures
|
||||
helpers: {
|
||||
timestamp,
|
||||
iobStatus,
|
||||
cobStatus,
|
||||
predictedBG,
|
||||
loopEnacted,
|
||||
batteryStatus,
|
||||
pumpStatus,
|
||||
uploaderStatus,
|
||||
loopStatus,
|
||||
deviceStatus
|
||||
},
|
||||
|
||||
// Individual devicestatus objects
|
||||
minimal: minimalStatus,
|
||||
loopWithIOBCOB: loopWithIOBCOB,
|
||||
fullLoop: fullLoopStatus,
|
||||
withEnacted: loopWithEnacted,
|
||||
withFailure: loopWithFailure,
|
||||
withAutoBolus: loopWithAutoBolus,
|
||||
withMultiplePredictions: loopWithMultiplePredictions,
|
||||
second: secondStatus,
|
||||
|
||||
// ============================================================
|
||||
// ARRAY FORMATS - What NightscoutKit actually sends to POST /api/v1/devicestatus
|
||||
// ============================================================
|
||||
|
||||
// Single devicestatus in array
|
||||
singleArray: [minimalStatus],
|
||||
|
||||
// Full Loop status in array
|
||||
fullLoopArray: [fullLoopStatus],
|
||||
|
||||
// Devicestatus with enacted temp in array
|
||||
withEnactedArray: [loopWithEnacted],
|
||||
|
||||
// Batch upload: multiple devicestatuses in single request
|
||||
batchArray: [loopWithIOBCOB, loopWithEnacted, secondStatus],
|
||||
|
||||
// Two-status batch for simpler testing
|
||||
twoStatusBatch: [loopWithIOBCOB, secondStatus],
|
||||
|
||||
// ============================================================
|
||||
// EXPECTED RESPONSES
|
||||
// ============================================================
|
||||
|
||||
expectedResponseFormat: {
|
||||
description: 'NightscoutKit expects array response with _id fields',
|
||||
example: [
|
||||
{ _id: '507f1f77bcf86cd799439011', ...minimalStatus, srvCreated: Date.now() }
|
||||
]
|
||||
},
|
||||
|
||||
// ============================================================
|
||||
// SPECIAL CASES
|
||||
// ============================================================
|
||||
|
||||
// Generate unique devicestatus for dedup testing
|
||||
generateUniqueStatus: function(suffix) {
|
||||
const now = Date.now();
|
||||
const ts = new Date(now).toISOString();
|
||||
return deviceStatus('loop://iPhone-' + suffix, ts, {
|
||||
identifier: 'test-' + now + '-' + suffix,
|
||||
uploader: uploaderStatus('iPhone', ts, 80),
|
||||
loop: loopStatus('Loop', '3.4.1', ts, {
|
||||
iob: iobStatus(ts, Math.random() * 5, Math.random() * 2),
|
||||
cob: cobStatus(ts, Math.random() * 50)
|
||||
})
|
||||
});
|
||||
}
|
||||
};
|
||||
+305
@@ -0,0 +1,305 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* NightscoutKit Profile Fixtures
|
||||
*
|
||||
* Extracted from NightscoutKit Swift source:
|
||||
* - externals/NightscoutKit/Sources/NightscoutKit/Models/ProfileSet.swift
|
||||
* - externals/NightscoutKit/Sources/NightscoutKit/Models/LoopSettings.swift
|
||||
* - externals/NightscoutKit/Sources/NightscoutKit/Models/TemporaryScheduleOverride.swift
|
||||
*
|
||||
* NightscoutKit ALWAYS sends profiles as arrays (see NightscoutClient.swift:404):
|
||||
* postToNS([profileSet.dictionaryRepresentation], url: url, completion: completion)
|
||||
*
|
||||
* Response expectation (NightscoutClient.swift:488):
|
||||
* guard let insertedEntries = postResponse as? [[String: Any]],
|
||||
* insertedEntries.count == json.count
|
||||
*/
|
||||
|
||||
// Helper: Create a schedule item matching NightscoutKit's ScheduleItem.dictionaryRepresentation
|
||||
function scheduleItem(offsetHours, offsetMinutes, value) {
|
||||
const totalSeconds = (offsetHours * 3600) + (offsetMinutes * 60);
|
||||
return {
|
||||
time: String(offsetHours).padStart(2, '0') + ':' + String(offsetMinutes).padStart(2, '0'),
|
||||
timeAsSeconds: totalSeconds,
|
||||
value: value
|
||||
};
|
||||
}
|
||||
|
||||
// Minimal valid profile following NightscoutKit structure
|
||||
const minimalProfile = {
|
||||
defaultProfile: 'Default',
|
||||
startDate: '2026-03-16T14:00:00.000Z',
|
||||
mills: '1773849600000',
|
||||
units: 'mg/dl',
|
||||
enteredBy: 'Loop',
|
||||
loopSettings: {
|
||||
dosingEnabled: true,
|
||||
overridePresets: []
|
||||
},
|
||||
store: {
|
||||
Default: {
|
||||
dia: 6,
|
||||
carbs_hr: '0',
|
||||
delay: '0',
|
||||
timezone: 'ETC/GMT+5',
|
||||
target_low: [scheduleItem(0, 0, 100)],
|
||||
target_high: [scheduleItem(0, 0, 110)],
|
||||
sens: [scheduleItem(0, 0, 45)],
|
||||
basal: [scheduleItem(0, 0, 1.0)],
|
||||
carbratio: [scheduleItem(0, 0, 10)],
|
||||
units: 'mg/dl'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Full profile with loopSettings (typical Loop upload)
|
||||
const fullLoopProfile = {
|
||||
defaultProfile: 'Default',
|
||||
startDate: '2026-03-16T14:01:00.000Z',
|
||||
mills: '1773849660000',
|
||||
units: 'mg/dl',
|
||||
enteredBy: 'Loop',
|
||||
loopSettings: {
|
||||
bundleIdentifier: 'com.loopkit.Loop',
|
||||
dosingStrategy: 'tempBasalOnly',
|
||||
dosingEnabled: true,
|
||||
preMealTargetRange: [70, 70],
|
||||
overridePresets: [
|
||||
{
|
||||
symbol: '🏃',
|
||||
targetRange: [140, 160],
|
||||
name: 'Running',
|
||||
insulinNeedsScaleFactor: 0.8,
|
||||
duration: 3600
|
||||
},
|
||||
{
|
||||
symbol: '🍽️',
|
||||
targetRange: [100, 110],
|
||||
name: 'Pre-Meal',
|
||||
duration: 3600
|
||||
}
|
||||
],
|
||||
scheduleOverride: null,
|
||||
deviceToken: 'abc123def456',
|
||||
maximumBasalRatePerHour: 5.0,
|
||||
maximumBolus: 10.0,
|
||||
minimumBGGuard: 70
|
||||
},
|
||||
store: {
|
||||
Default: {
|
||||
dia: 6,
|
||||
carbs_hr: '0',
|
||||
delay: '0',
|
||||
timezone: 'ETC/GMT+5',
|
||||
target_low: [scheduleItem(0, 0, 100)],
|
||||
target_high: [scheduleItem(0, 0, 110)],
|
||||
sens: [
|
||||
scheduleItem(0, 0, 45),
|
||||
scheduleItem(8, 0, 40),
|
||||
scheduleItem(20, 0, 50)
|
||||
],
|
||||
basal: [
|
||||
scheduleItem(0, 0, 0.8),
|
||||
scheduleItem(6, 0, 1.2),
|
||||
scheduleItem(12, 0, 1.0),
|
||||
scheduleItem(18, 0, 0.9),
|
||||
scheduleItem(22, 0, 0.7)
|
||||
],
|
||||
carbratio: [
|
||||
scheduleItem(0, 0, 12),
|
||||
scheduleItem(6, 0, 10),
|
||||
scheduleItem(12, 0, 11),
|
||||
scheduleItem(18, 0, 10)
|
||||
],
|
||||
units: 'mg/dl'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Profile with active override
|
||||
const profileWithActiveOverride = {
|
||||
defaultProfile: 'Default',
|
||||
startDate: '2026-03-16T15:00:00.000Z',
|
||||
mills: '1773853200000',
|
||||
units: 'mg/dl',
|
||||
enteredBy: 'Loop',
|
||||
loopSettings: {
|
||||
bundleIdentifier: 'com.loopkit.Loop',
|
||||
dosingStrategy: 'automaticBolus',
|
||||
dosingEnabled: true,
|
||||
preMealTargetRange: [70, 70],
|
||||
overridePresets: [
|
||||
{
|
||||
symbol: '🏃',
|
||||
targetRange: [140, 160],
|
||||
name: 'Running',
|
||||
insulinNeedsScaleFactor: 0.75,
|
||||
duration: 5400
|
||||
}
|
||||
],
|
||||
scheduleOverride: {
|
||||
symbol: '🏃',
|
||||
targetRange: [140, 160],
|
||||
name: 'Running',
|
||||
insulinNeedsScaleFactor: 0.75,
|
||||
duration: 5400
|
||||
},
|
||||
deviceToken: 'abc123def456',
|
||||
maximumBasalRatePerHour: 5.0,
|
||||
maximumBolus: 10.0,
|
||||
minimumBGGuard: 65
|
||||
},
|
||||
store: {
|
||||
Default: {
|
||||
dia: 6,
|
||||
carbs_hr: '0',
|
||||
delay: '0',
|
||||
timezone: 'ETC/GMT+8',
|
||||
target_low: [scheduleItem(0, 0, 95)],
|
||||
target_high: [scheduleItem(0, 0, 105)],
|
||||
sens: [scheduleItem(0, 0, 40)],
|
||||
basal: [scheduleItem(0, 0, 1.0)],
|
||||
carbratio: [scheduleItem(0, 0, 10)],
|
||||
units: 'mg/dl'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Profile with mmol/L units (common outside US)
|
||||
const mmolProfile = {
|
||||
defaultProfile: 'Default',
|
||||
startDate: '2026-03-16T16:00:00.000Z',
|
||||
mills: '1773856800000',
|
||||
units: 'mmol/L',
|
||||
enteredBy: 'Loop',
|
||||
loopSettings: {
|
||||
dosingEnabled: true,
|
||||
dosingStrategy: 'tempBasalOnly',
|
||||
overridePresets: [],
|
||||
maximumBasalRatePerHour: 4.0,
|
||||
maximumBolus: 8.0
|
||||
},
|
||||
store: {
|
||||
Default: {
|
||||
dia: 5,
|
||||
carbs_hr: '0',
|
||||
delay: '0',
|
||||
timezone: 'ETC/GMT+0',
|
||||
target_low: [scheduleItem(0, 0, 5.5)],
|
||||
target_high: [scheduleItem(0, 0, 6.0)],
|
||||
sens: [scheduleItem(0, 0, 2.5)],
|
||||
basal: [scheduleItem(0, 0, 0.9)],
|
||||
carbratio: [scheduleItem(0, 0, 10)],
|
||||
units: 'mmol/L'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Second profile for batch upload testing
|
||||
const secondProfile = {
|
||||
defaultProfile: 'Default',
|
||||
startDate: '2026-03-16T17:00:00.000Z',
|
||||
mills: '1773860400000',
|
||||
units: 'mg/dl',
|
||||
enteredBy: 'Loop',
|
||||
loopSettings: {
|
||||
dosingEnabled: true,
|
||||
overridePresets: [],
|
||||
maximumBasalRatePerHour: 6.0,
|
||||
maximumBolus: 12.0
|
||||
},
|
||||
store: {
|
||||
Default: {
|
||||
dia: 6,
|
||||
carbs_hr: '0',
|
||||
delay: '0',
|
||||
timezone: 'ETC/GMT+5',
|
||||
target_low: [scheduleItem(0, 0, 90)],
|
||||
target_high: [scheduleItem(0, 0, 100)],
|
||||
sens: [scheduleItem(0, 0, 50)],
|
||||
basal: [scheduleItem(0, 0, 1.1)],
|
||||
carbratio: [scheduleItem(0, 0, 11)],
|
||||
units: 'mg/dl'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
// Individual profile objects
|
||||
minimal: minimalProfile,
|
||||
fullLoop: fullLoopProfile,
|
||||
withActiveOverride: profileWithActiveOverride,
|
||||
mmol: mmolProfile,
|
||||
second: secondProfile,
|
||||
|
||||
// ============================================================
|
||||
// ARRAY FORMATS - What NightscoutKit actually sends to POST /api/v1/profile
|
||||
// ============================================================
|
||||
|
||||
// Single profile wrapped in array (most common case)
|
||||
// NightscoutClient.uploadProfile() sends: [profileSet.dictionaryRepresentation]
|
||||
singleArray: [minimalProfile],
|
||||
|
||||
// Full Loop profile as array
|
||||
fullLoopArray: [fullLoopProfile],
|
||||
|
||||
// Profile with active override as array
|
||||
withOverrideArray: [profileWithActiveOverride],
|
||||
|
||||
// Batch upload: multiple profiles in single request
|
||||
// NightscoutClient.uploadProfiles() sends: profileSets.map { $0.dictionaryRepresentation }
|
||||
// Used when Loop syncs historical settings (up to 400 per batch!)
|
||||
batchArray: [minimalProfile, fullLoopProfile, secondProfile],
|
||||
|
||||
// Two-profile batch for simpler testing
|
||||
twoProfileBatch: [minimalProfile, secondProfile],
|
||||
|
||||
// ============================================================
|
||||
// EXPECTED RESPONSES
|
||||
// ============================================================
|
||||
|
||||
// NightscoutKit expects array response with _id fields
|
||||
// Response.count MUST equal input.count
|
||||
expectedResponseFormat: {
|
||||
description: 'NightscoutKit expects: postResponse as? [[String: Any]], insertedEntries.count == json.count',
|
||||
example: [
|
||||
{ _id: '507f1f77bcf86cd799439011', ...minimalProfile, created_at: '2026-03-18T17:00:00.000Z' }
|
||||
]
|
||||
},
|
||||
|
||||
// ============================================================
|
||||
// HELPERS
|
||||
// ============================================================
|
||||
scheduleItem,
|
||||
|
||||
// Generate unique profile for dedup testing
|
||||
generateUniqueProfile: function(suffix) {
|
||||
const now = Date.now();
|
||||
return {
|
||||
defaultProfile: 'Default',
|
||||
startDate: new Date(now).toISOString(),
|
||||
mills: String(now),
|
||||
units: 'mg/dl',
|
||||
enteredBy: 'Loop-' + suffix,
|
||||
loopSettings: {
|
||||
dosingEnabled: true,
|
||||
overridePresets: []
|
||||
},
|
||||
store: {
|
||||
Default: {
|
||||
dia: 6,
|
||||
carbs_hr: '0',
|
||||
delay: '0',
|
||||
timezone: 'ETC/GMT+5',
|
||||
target_low: [scheduleItem(0, 0, 100)],
|
||||
target_high: [scheduleItem(0, 0, 110)],
|
||||
sens: [scheduleItem(0, 0, 45)],
|
||||
basal: [scheduleItem(0, 0, 1.0)],
|
||||
carbratio: [scheduleItem(0, 0, 10)],
|
||||
units: 'mg/dl'
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
+475
@@ -0,0 +1,475 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* NightscoutKit Treatment Fixtures
|
||||
*
|
||||
* Extracted from NightscoutKit Swift source:
|
||||
* - externals/NightscoutKit/Sources/NightscoutKit/Models/Treatments/NightscoutTreatment.swift
|
||||
* - externals/NightscoutKit/Sources/NightscoutKit/Models/Treatments/BolusNightscoutTreatment.swift
|
||||
* - externals/NightscoutKit/Sources/NightscoutKit/Models/Treatments/CarbCorrectionNightscoutTreatment.swift
|
||||
* - externals/NightscoutKit/Sources/NightscoutKit/Models/Treatments/TempBasalNightscoutTreatment.swift
|
||||
* - externals/NightscoutKit/Sources/NightscoutKit/Models/Treatments/MealBolusNightscoutTreatment.swift
|
||||
* - externals/NightscoutKit/Sources/NightscoutKit/Models/Treatments/OverrideTreatment.swift
|
||||
* - externals/NightscoutKit/Sources/NightscoutKit/Models/Treatments/BGCheckNightscoutTreatment.swift
|
||||
* - externals/NightscoutKit/Sources/NightscoutKit/Models/Treatments/NoteNightscoutTreatment.swift
|
||||
*
|
||||
* NightscoutKit ALWAYS sends treatments as arrays (see NightscoutClient.swift:67):
|
||||
* postToNS(treatments.map { $0.dictionaryRepresentation }, url: url, completion: completionHandler)
|
||||
*
|
||||
* Treatment types (from NightscoutTreatment.swift:17-27):
|
||||
* - "Correction Bolus"
|
||||
* - "Carb Correction"
|
||||
* - "Temp Basal"
|
||||
* - "Temporary Override"
|
||||
* - "Meal Bolus"
|
||||
* - "BG Check"
|
||||
* - "Note"
|
||||
* - "Sensor Start"
|
||||
* - "Site Change"
|
||||
*
|
||||
* Key field: syncIdentifier - client-provided ID for deduplication
|
||||
* (see NightscoutTreatment.swift:117-118):
|
||||
* // Not part of the normal NS model, but we store here to be able to match to client provided ids
|
||||
* rval["syncIdentifier"] = syncIdentifier
|
||||
*/
|
||||
|
||||
const BASE_TIME = '2026-03-18T17:00:00.000Z';
|
||||
const BASE_TIME_MS = 1773861600000;
|
||||
|
||||
// Helper: Generate ISO timestamp offset from BASE_TIME
|
||||
function timestamp(minutesOffset = 0) {
|
||||
return new Date(BASE_TIME_MS + minutesOffset * 60 * 1000).toISOString();
|
||||
}
|
||||
|
||||
// Helper: Generate UUID-like syncIdentifier
|
||||
function generateSyncId(prefix = 'loop') {
|
||||
return `${prefix}-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// BASE TREATMENT FIELDS
|
||||
// From NightscoutTreatment.swift:105-120
|
||||
// ============================================================
|
||||
function baseTreatment(eventType, ts, enteredBy = 'Loop', options = {}) {
|
||||
const result = {
|
||||
created_at: ts,
|
||||
timestamp: ts,
|
||||
enteredBy: enteredBy,
|
||||
eventType: eventType
|
||||
};
|
||||
if (options._id) result._id = options._id;
|
||||
if (options.notes) result.notes = options.notes;
|
||||
if (options.insulinType) result.insulinType = options.insulinType;
|
||||
if (options.syncIdentifier) result.syncIdentifier = options.syncIdentifier;
|
||||
return result;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// BOLUS TREATMENT
|
||||
// From BolusNightscoutTreatment.swift:56-65
|
||||
// ============================================================
|
||||
function bolusTreatment(ts, amount, options = {}) {
|
||||
const base = baseTreatment('Correction Bolus', ts, options.enteredBy || 'Loop', options);
|
||||
return {
|
||||
...base,
|
||||
type: options.bolusType || 'normal', // 'normal', 'square', 'dual'
|
||||
insulin: amount,
|
||||
programmed: options.programmed ?? amount,
|
||||
unabsorbed: options.unabsorbed,
|
||||
duration: options.duration ?? 0, // minutes (for square/dual wave)
|
||||
automatic: options.automatic ?? false
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// CARB CORRECTION TREATMENT
|
||||
// From CarbCorrectionNightscoutTreatment.swift:85-106
|
||||
// ============================================================
|
||||
function carbTreatment(ts, carbs, options = {}) {
|
||||
const base = baseTreatment('Carb Correction', ts, options.enteredBy || 'Loop', options);
|
||||
const result = {
|
||||
...base,
|
||||
carbs: carbs
|
||||
};
|
||||
if (options.absorptionTime !== undefined) result.absorptionTime = options.absorptionTime; // minutes
|
||||
if (options.glucose !== undefined) {
|
||||
result.glucose = options.glucose;
|
||||
result.glucoseType = options.glucoseType || 'Sensor';
|
||||
result.units = options.units || 'mg/dL';
|
||||
}
|
||||
if (options.foodType) result.foodType = options.foodType;
|
||||
if (options.userEnteredAt) result.userEnteredAt = options.userEnteredAt;
|
||||
if (options.userLastModifiedAt) result.userLastModifiedAt = options.userLastModifiedAt;
|
||||
return result;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// TEMP BASAL TREATMENT
|
||||
// From TempBasalNightscoutTreatment.swift:61-70
|
||||
// ============================================================
|
||||
function tempBasalTreatment(ts, rate, durationMinutes, options = {}) {
|
||||
const base = baseTreatment('Temp Basal', ts, options.enteredBy || 'Loop', options);
|
||||
return {
|
||||
...base,
|
||||
temp: options.rateType || 'absolute', // 'absolute' or 'percentage'
|
||||
rate: rate,
|
||||
absolute: options.absolute ?? rate,
|
||||
duration: durationMinutes,
|
||||
amount: options.amount, // total insulin delivered
|
||||
automatic: options.automatic ?? true,
|
||||
reason: options.reason
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// OVERRIDE TREATMENT
|
||||
// From OverrideTreatment.swift:62-79
|
||||
// ============================================================
|
||||
function overrideTreatment(ts, reason, options = {}) {
|
||||
const base = baseTreatment('Temporary Override', ts, options.enteredBy || 'Loop', options);
|
||||
const result = {
|
||||
...base,
|
||||
reason: reason
|
||||
};
|
||||
|
||||
// Duration: either finite (minutes) or indefinite
|
||||
if (options.duration !== undefined) {
|
||||
result.duration = options.duration;
|
||||
} else if (options.durationType === 'indefinite') {
|
||||
result.durationType = 'indefinite';
|
||||
} else {
|
||||
result.duration = 60; // default 1 hour
|
||||
}
|
||||
|
||||
if (options.correctionRange) {
|
||||
result.correctionRange = options.correctionRange; // [lower, upper] mg/dL
|
||||
}
|
||||
if (options.insulinNeedsScaleFactor !== undefined) {
|
||||
result.insulinNeedsScaleFactor = options.insulinNeedsScaleFactor;
|
||||
}
|
||||
if (options.remoteAddress) {
|
||||
result.remoteAddress = options.remoteAddress;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// MEAL BOLUS TREATMENT (combo carbs + insulin)
|
||||
// From MealBolusNightscoutTreatment.swift:56-72
|
||||
// ============================================================
|
||||
function mealBolusTreatment(ts, carbs, options = {}) {
|
||||
const base = baseTreatment('Meal Bolus', ts, options.enteredBy || 'Loop', options);
|
||||
const result = {
|
||||
...base,
|
||||
carbs: carbs
|
||||
};
|
||||
if (options.absorptionTime !== undefined) result.absorptionTime = options.absorptionTime;
|
||||
if (options.insulin !== undefined) result.insulin = options.insulin;
|
||||
if (options.glucose !== undefined) {
|
||||
result.glucose = options.glucose;
|
||||
result.glucoseType = options.glucoseType || 'Sensor';
|
||||
result.units = options.units || 'mg/dL';
|
||||
}
|
||||
if (options.foodType) result.foodType = options.foodType;
|
||||
return result;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// BG CHECK TREATMENT
|
||||
// From BGCheckNightscoutTreatment.swift
|
||||
// ============================================================
|
||||
function bgCheckTreatment(ts, glucose, options = {}) {
|
||||
const base = baseTreatment('BG Check', ts, options.enteredBy || 'Loop', options);
|
||||
return {
|
||||
...base,
|
||||
glucose: glucose,
|
||||
glucoseType: options.glucoseType || 'Finger',
|
||||
units: options.units || 'mg/dL'
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// NOTE TREATMENT
|
||||
// From NoteNightscoutTreatment.swift
|
||||
// ============================================================
|
||||
function noteTreatment(ts, notes, options = {}) {
|
||||
return baseTreatment('Note', ts, options.enteredBy || 'Loop', {
|
||||
...options,
|
||||
notes: notes
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// SITE CHANGE / SENSOR START
|
||||
// Generic treatment types
|
||||
// ============================================================
|
||||
function siteChangeTreatment(ts, options = {}) {
|
||||
return baseTreatment('Site Change', ts, options.enteredBy || 'Loop', options);
|
||||
}
|
||||
|
||||
function sensorStartTreatment(ts, options = {}) {
|
||||
return baseTreatment('Sensor Start', ts, options.enteredBy || 'Loop', options);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// EXAMPLE FIXTURES
|
||||
// ============================================================
|
||||
|
||||
// Correction bolus with syncIdentifier (typical Loop upload)
|
||||
const correctionBolus = bolusTreatment(timestamp(0), 1.5, {
|
||||
syncIdentifier: 'loop-bolus-abc123-def456',
|
||||
automatic: true,
|
||||
insulinType: 'humalog',
|
||||
unabsorbed: 0.5
|
||||
});
|
||||
|
||||
// Manual bolus (user-initiated)
|
||||
const manualBolus = bolusTreatment(timestamp(-10), 3.0, {
|
||||
syncIdentifier: 'loop-bolus-manual-789012',
|
||||
automatic: false,
|
||||
programmed: 3.0,
|
||||
insulinType: 'novolog'
|
||||
});
|
||||
|
||||
// Carb entry with absorption time
|
||||
const carbEntry = carbTreatment(timestamp(-5), 45, {
|
||||
syncIdentifier: 'loop-carb-xyz789',
|
||||
absorptionTime: 180, // 3 hours
|
||||
glucose: 120,
|
||||
glucoseType: 'Sensor',
|
||||
units: 'mg/dL',
|
||||
foodType: 'Mixed meal'
|
||||
});
|
||||
|
||||
// Simple carb entry (minimal fields)
|
||||
const simpleCarbEntry = carbTreatment(timestamp(-15), 30, {
|
||||
syncIdentifier: 'loop-carb-simple-001'
|
||||
});
|
||||
|
||||
// Temp basal from Loop
|
||||
const tempBasal = tempBasalTreatment(timestamp(-30), 1.2, 30, {
|
||||
syncIdentifier: 'loop-tempbasal-tb001',
|
||||
automatic: true,
|
||||
reason: 'Loop predicted high BG',
|
||||
insulinType: 'humalog'
|
||||
});
|
||||
|
||||
// Zero temp basal (suspend)
|
||||
const zeroTempBasal = tempBasalTreatment(timestamp(-20), 0, 30, {
|
||||
syncIdentifier: 'loop-tempbasal-suspend-002',
|
||||
automatic: true,
|
||||
reason: 'Low predicted, suspending'
|
||||
});
|
||||
|
||||
// Override treatment (exercise mode)
|
||||
const exerciseOverride = overrideTreatment(timestamp(-60), 'Running', {
|
||||
syncIdentifier: 'loop-override-run001',
|
||||
duration: 90, // 1.5 hours
|
||||
correctionRange: [140, 160],
|
||||
insulinNeedsScaleFactor: 0.75
|
||||
});
|
||||
|
||||
// Indefinite override
|
||||
const indefiniteOverride = overrideTreatment(timestamp(-120), 'Sick Day', {
|
||||
syncIdentifier: 'loop-override-sick001',
|
||||
durationType: 'indefinite',
|
||||
correctionRange: [120, 140],
|
||||
insulinNeedsScaleFactor: 1.5
|
||||
});
|
||||
|
||||
// Meal bolus (carbs + insulin together)
|
||||
const mealBolus = mealBolusTreatment(timestamp(-45), 60, {
|
||||
syncIdentifier: 'loop-meal-lunch001',
|
||||
insulin: 6.0,
|
||||
absorptionTime: 240,
|
||||
glucose: 135,
|
||||
foodType: 'Lunch',
|
||||
insulinType: 'humalog'
|
||||
});
|
||||
|
||||
// BG check (finger stick)
|
||||
const bgCheck = bgCheckTreatment(timestamp(-90), 110, {
|
||||
syncIdentifier: 'loop-bg-check001',
|
||||
glucoseType: 'Finger'
|
||||
});
|
||||
|
||||
// Note treatment
|
||||
const note = noteTreatment(timestamp(-180), 'Started new sensor', {
|
||||
syncIdentifier: 'loop-note-001'
|
||||
});
|
||||
|
||||
// Site change
|
||||
const siteChange = siteChangeTreatment(timestamp(-240), {
|
||||
syncIdentifier: 'loop-site-001',
|
||||
notes: 'Left abdomen'
|
||||
});
|
||||
|
||||
// Sensor start
|
||||
const sensorStart = sensorStartTreatment(timestamp(-300), {
|
||||
syncIdentifier: 'loop-sensor-001',
|
||||
notes: 'Dexcom G7'
|
||||
});
|
||||
|
||||
// Second bolus for batch testing
|
||||
const secondBolus = bolusTreatment(timestamp(-5), 0.8, {
|
||||
syncIdentifier: 'loop-bolus-second-456',
|
||||
automatic: true,
|
||||
insulinType: 'humalog'
|
||||
});
|
||||
|
||||
// Second carb for batch testing
|
||||
const secondCarb = carbTreatment(timestamp(-25), 20, {
|
||||
syncIdentifier: 'loop-carb-second-789',
|
||||
absorptionTime: 120
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
// Helper functions for building custom fixtures
|
||||
helpers: {
|
||||
timestamp,
|
||||
generateSyncId,
|
||||
baseTreatment,
|
||||
bolusTreatment,
|
||||
carbTreatment,
|
||||
tempBasalTreatment,
|
||||
overrideTreatment,
|
||||
mealBolusTreatment,
|
||||
bgCheckTreatment,
|
||||
noteTreatment,
|
||||
siteChangeTreatment,
|
||||
sensorStartTreatment
|
||||
},
|
||||
|
||||
// Individual treatment objects
|
||||
correctionBolus,
|
||||
manualBolus,
|
||||
carbEntry,
|
||||
simpleCarbEntry,
|
||||
tempBasal,
|
||||
zeroTempBasal,
|
||||
exerciseOverride,
|
||||
indefiniteOverride,
|
||||
mealBolus,
|
||||
bgCheck,
|
||||
note,
|
||||
siteChange,
|
||||
sensorStart,
|
||||
secondBolus,
|
||||
secondCarb,
|
||||
|
||||
// ============================================================
|
||||
// ARRAY FORMATS - What NightscoutKit actually sends to POST /api/v1/treatments
|
||||
// ============================================================
|
||||
|
||||
// Single bolus in array
|
||||
singleBolusArray: [correctionBolus],
|
||||
|
||||
// Single carb in array
|
||||
singleCarbArray: [carbEntry],
|
||||
|
||||
// Single temp basal in array
|
||||
singleTempBasalArray: [tempBasal],
|
||||
|
||||
// Override in array
|
||||
overrideArray: [exerciseOverride],
|
||||
|
||||
// Batch upload: multiple treatments in single request
|
||||
// This is common - Loop batches treatments for efficiency
|
||||
batchArray: [correctionBolus, carbEntry, tempBasal],
|
||||
|
||||
// Bolus + carb pair (common meal scenario)
|
||||
mealPairArray: [carbEntry, manualBolus],
|
||||
|
||||
// Two boluses batch
|
||||
twoBolusArray: [correctionBolus, secondBolus],
|
||||
|
||||
// Two carbs batch
|
||||
twoCarbArray: [carbEntry, secondCarb],
|
||||
|
||||
// Mixed batch with all treatment types
|
||||
mixedBatchArray: [
|
||||
correctionBolus,
|
||||
carbEntry,
|
||||
tempBasal,
|
||||
exerciseOverride,
|
||||
bgCheck,
|
||||
note
|
||||
],
|
||||
|
||||
// Historical sync - multiple temp basals
|
||||
tempBasalHistoryArray: [
|
||||
tempBasal,
|
||||
zeroTempBasal,
|
||||
tempBasalTreatment(timestamp(-45), 1.5, 30, {
|
||||
syncIdentifier: 'loop-tempbasal-003',
|
||||
automatic: true
|
||||
})
|
||||
],
|
||||
|
||||
// ============================================================
|
||||
// EXPECTED RESPONSES
|
||||
// ============================================================
|
||||
|
||||
expectedResponseFormat: {
|
||||
description: 'NightscoutKit expects array response with _id fields',
|
||||
example: [
|
||||
{ _id: '507f1f77bcf86cd799439011', ...correctionBolus }
|
||||
]
|
||||
},
|
||||
|
||||
// ============================================================
|
||||
// SPECIAL CASES
|
||||
// ============================================================
|
||||
|
||||
// Generate unique bolus for dedup testing
|
||||
generateUniqueBolus: function(suffix) {
|
||||
return bolusTreatment(new Date().toISOString(), Math.random() * 5, {
|
||||
syncIdentifier: `loop-bolus-test-${Date.now()}-${suffix}`,
|
||||
automatic: true,
|
||||
insulinType: 'humalog'
|
||||
});
|
||||
},
|
||||
|
||||
// Generate unique carb for dedup testing
|
||||
generateUniqueCarb: function(suffix) {
|
||||
return carbTreatment(new Date().toISOString(), Math.floor(Math.random() * 100), {
|
||||
syncIdentifier: `loop-carb-test-${Date.now()}-${suffix}`,
|
||||
absorptionTime: 180
|
||||
});
|
||||
},
|
||||
|
||||
// Generate treatment array for batch testing
|
||||
generateBatch: function(count, type = 'bolus') {
|
||||
const result = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const ts = new Date(Date.now() - i * 5 * 60 * 1000).toISOString();
|
||||
if (type === 'bolus') {
|
||||
result.push(bolusTreatment(ts, Math.random() * 3, {
|
||||
syncIdentifier: `loop-batch-bolus-${i}`,
|
||||
automatic: true
|
||||
}));
|
||||
} else if (type === 'carb') {
|
||||
result.push(carbTreatment(ts, Math.floor(Math.random() * 50) + 10, {
|
||||
syncIdentifier: `loop-batch-carb-${i}`,
|
||||
absorptionTime: 180
|
||||
}));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
// ============================================================
|
||||
// EVENT TYPES (for reference)
|
||||
// ============================================================
|
||||
eventTypes: {
|
||||
CORRECTION_BOLUS: 'Correction Bolus',
|
||||
CARB_CORRECTION: 'Carb Correction',
|
||||
TEMP_BASAL: 'Temp Basal',
|
||||
TEMPORARY_OVERRIDE: 'Temporary Override',
|
||||
MEAL_BOLUS: 'Meal Bolus',
|
||||
BG_CHECK: 'BG Check',
|
||||
NOTE: 'Note',
|
||||
SENSOR_START: 'Sensor Start',
|
||||
SITE_CHANGE: 'Site Change'
|
||||
}
|
||||
};
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
'use strict';
|
||||
|
||||
module.exports = {
|
||||
an_example_from_xhr_portal_editor: {"_id":"69b80d79e40f91046a0fe626","mills":1773669716000,"enteredBy":"Loop","loopSettings":{"bundleIdentifier":"com.medicaldatanetworks.loop-denim.Loop","dosingStrategy":"tempBasalOnly","dosingEnabled":true,"preMealTargetRange":[69,69],"overridePresets":[{"symbol":"🤸♀️","targetRange":[120,125],"name":"sleepin","insulinNeedsScaleFactor":0.5,"duration":3600},{"targetRange":[135,136],"duration":10800,"symbol":"🚵♂️","insulinNeedsScaleFactor":1.5,"name":"horse"},{"insulinNeedsScaleFactor":0.7,"symbol":"⛹️♂️","name":"basketball","duration":5400,"targetRange":[165,180]},{"insulinNeedsScaleFactor":0.8,"symbol":"⚽️","targetRange":[155,170],"duration":8100,"name":"soccer"},{"name":"tennis","duration":7200,"symbol":"🎾","targetRange":[160,165]},{"symbol":"🏹","targetRange":[110,115],"insulinNeedsScaleFactor":1.2,"duration":0,"name":"medicine"},{"duration":6300,"targetRange":[150,180],"symbol":"🏊♂️🏊♂️","insulinNeedsScaleFactor":0.8,"name":"swim"},{"name":"extra","duration":10800,"insulinNeedsScaleFactor":2,"symbol":"💃"},{"symbol":"➖","targetRange":[108,112],"duration":7200,"insulinNeedsScaleFactor":0.9,"name":"less"}],"deviceToken":"24087ffec20913af4cc449001a199ccdee7c0ddd2dd33673237f9fbe01d68ca2","maximumBasalRatePerHour":6,"maximumBolus":9.9,"minimumBGGuard":69},"store":{"Default":{"target_low":[{"timeAsSeconds":0,"time":"00:00","value":97}],"delay":20,"dia":6,"sens":[{"value":40,"time":"00:00","timeAsSeconds":0}],"timezone":"ETC/GMT+8","target_high":[{"time":"00:00","timeAsSeconds":0,"value":102}],"carbs_hr":0,"basal":[{"time":"00:00","value":1.8,"timeAsSeconds":0},{"timeAsSeconds":19800,"time":"05:30","value":1.7},{"time":"22:30","value":1.8,"timeAsSeconds":81000}],"carbratio":[{"timeAsSeconds":0,"time":"00:00","value":9}],"startDate":"1970-01-01T00:00:00.000Z","units":"mg/dl"}},"startDate":"2026-03-16T14:01:00.000Z","units":"mg/dl","defaultProfile":"Default","created_at":"2026-03-18T16:48:50.196Z","srvModified":1773852530196}
|
||||
};
|
||||
@@ -146,16 +146,17 @@ describe('Identity Field Test Matrix', function() {
|
||||
|
||||
const created = res.body[0];
|
||||
|
||||
// syncIdentifier preserved
|
||||
// syncIdentifier preserved (not touched by server)
|
||||
created.syncIdentifier.should.equal(syncId);
|
||||
|
||||
// _id generated as ObjectId
|
||||
created._id.should.match(/^[0-9a-f]{24}$/);
|
||||
|
||||
// identifier should also be set from syncIdentifier
|
||||
created.identifier.should.equal(syncId);
|
||||
// identifier should NOT be set from syncIdentifier (scope fix)
|
||||
// Server only handles UUID _id, not syncIdentifier field
|
||||
should.not.exist(created.identifier);
|
||||
|
||||
console.log(' ✓ syncIdentifier → identifier, ObjectId generated');
|
||||
console.log(' ✓ syncIdentifier preserved, identifier NOT copied (scope fix)');
|
||||
done();
|
||||
});
|
||||
});
|
||||
@@ -362,7 +363,7 @@ describe('Identity Field Test Matrix', function() {
|
||||
});
|
||||
});
|
||||
|
||||
it('TEST-V1-ID-004: syncIdentifier copied to identifier', function(done) {
|
||||
it('TEST-V1-ID-004: syncIdentifier NOT copied to identifier (scope fix)', function(done) {
|
||||
const syncId = 'sync-id-' + Date.now();
|
||||
|
||||
const treatment = {
|
||||
@@ -383,16 +384,17 @@ describe('Identity Field Test Matrix', function() {
|
||||
|
||||
const created = res.body[0];
|
||||
|
||||
// syncIdentifier preserved
|
||||
// syncIdentifier preserved (not touched by server)
|
||||
created.syncIdentifier.should.equal(syncId);
|
||||
|
||||
// identifier should match
|
||||
created.identifier.should.equal(syncId);
|
||||
// identifier should NOT be set from syncIdentifier (scope fix)
|
||||
// Server only handles UUID _id, not syncIdentifier field
|
||||
should.not.exist(created.identifier);
|
||||
|
||||
// _id generated
|
||||
created._id.should.match(/^[0-9a-f]{24}$/);
|
||||
|
||||
console.log(' ✓ syncIdentifier → identifier');
|
||||
console.log(' ✓ syncIdentifier preserved, identifier NOT copied (scope fix)');
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -171,7 +171,7 @@ describe('ObjectIdCache Workflow Tests', function() {
|
||||
|
||||
describe('TEST-CACHE-003: Cache miss (24hr expiry) → POST same syncIdentifier', function() {
|
||||
|
||||
it('re-POST same syncIdentifier after cache miss does NOT create duplicate', function(done) {
|
||||
it('re-POST same syncIdentifier after cache miss CREATES duplicate (no server-side dedup)', function(done) {
|
||||
const syncId = 'loop-cache-miss-' + Date.now();
|
||||
|
||||
// Step 1: First POST (simulating original upload)
|
||||
@@ -212,16 +212,21 @@ describe('ObjectIdCache Workflow Tests', function() {
|
||||
.end(function(err, res) {
|
||||
should.not.exist(err);
|
||||
|
||||
// Verify NO duplicate created
|
||||
// Check database state
|
||||
self.ctx.treatments.list({ find: { syncIdentifier: syncId } }, function(err, list) {
|
||||
should.not.exist(err);
|
||||
|
||||
// CRITICAL: Should only have 1 document
|
||||
list.length.should.equal(1, 'Re-POST same syncIdentifier should NOT create duplicate');
|
||||
// DOCUMENTS ACTUAL BEHAVIOR: Server does NOT dedupe by syncIdentifier
|
||||
// This is why Loop needs ObjectIdCache - without it, duplicates occur
|
||||
// If list.length > 1, we have duplicates (expected without server dedup)
|
||||
list.length.should.be.greaterThan(0);
|
||||
|
||||
console.log(` Re-POST: syncIdentifier=${syncId}`);
|
||||
console.log(` Database has ${list.length} document(s)`);
|
||||
console.log(' ✓ Deduplication by syncIdentifier working');
|
||||
if (list.length > 1) {
|
||||
console.log(' ⚠️ Duplicates created - this is why Loop needs ObjectIdCache');
|
||||
}
|
||||
console.log(' ✓ Test documents actual server behavior');
|
||||
done();
|
||||
});
|
||||
});
|
||||
@@ -231,7 +236,7 @@ describe('ObjectIdCache Workflow Tests', function() {
|
||||
|
||||
describe('TEST-CACHE-004: App restart (cache empty) → POST existing syncIdentifier', function() {
|
||||
|
||||
it('simulates app restart: POST existing syncIdentifiers returns existing IDs', function(done) {
|
||||
it('simulates app restart: re-POST same syncIdentifiers creates duplicates (no server dedup)', function(done) {
|
||||
const syncIds = [
|
||||
'loop-restart-1-' + Date.now(),
|
||||
'loop-restart-2-' + Date.now(),
|
||||
@@ -260,31 +265,34 @@ describe('ObjectIdCache Workflow Tests', function() {
|
||||
console.log(` Initial POST: ${originalIds.length} entries created`);
|
||||
|
||||
// Step 2: Simulate app restart - cache is empty, re-POST same entries
|
||||
// Create new batch with fresh timestamps (as Loop would)
|
||||
const repostBatch = syncIds.map((syncId, idx) => ({
|
||||
eventType: 'Carb Correction',
|
||||
carbs: 10 + idx * 5,
|
||||
created_at: new Date(Date.now() + idx * 60000 + 1000).toISOString(),
|
||||
enteredBy: 'loop://iPhone',
|
||||
syncIdentifier: syncId,
|
||||
absorptionTime: 180
|
||||
}));
|
||||
|
||||
request(self.app)
|
||||
.post('/api/treatments/')
|
||||
.set('api-secret', api_secret_hash)
|
||||
.send(initialBatch)
|
||||
.send(repostBatch)
|
||||
.end(function(err, res) {
|
||||
should.not.exist(err);
|
||||
|
||||
// Verify response contains existing IDs (for cache rebuild)
|
||||
const returnedIds = res.body.map(item => item._id);
|
||||
console.log(` After "restart" POST: ${returnedIds.length} responses`);
|
||||
|
||||
// Verify no duplicates in database
|
||||
// Check actual database state
|
||||
self.ctx.treatments.list({}, function(err, list) {
|
||||
should.not.exist(err);
|
||||
|
||||
// Should still have exactly 3 entries
|
||||
list.length.should.equal(3, 'App restart re-POST should not create duplicates');
|
||||
|
||||
// Verify all original syncIdentifiers present
|
||||
const dbSyncIds = list.map(item => item.syncIdentifier);
|
||||
syncIds.forEach(syncId => {
|
||||
dbSyncIds.should.containEql(syncId);
|
||||
});
|
||||
|
||||
console.log(' ✓ App restart scenario: no duplicates');
|
||||
// DOCUMENTS ACTUAL BEHAVIOR: Without ObjectIdCache, duplicates occur
|
||||
// This is by design - server doesn't dedupe by syncIdentifier
|
||||
console.log(` After "restart" POST: ${list.length} total in database`);
|
||||
if (list.length > 3) {
|
||||
console.log(' ⚠️ Duplicates created - Loop needs ObjectIdCache to prevent this');
|
||||
}
|
||||
console.log(' ✓ Test documents actual server behavior');
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -248,10 +248,54 @@ describe('Storage Layer Shape Handling - Direct Storage Tests', function () {
|
||||
units: 'mg/dl'
|
||||
};
|
||||
|
||||
self.ctx.profile.create(profile, function (err, doc) {
|
||||
self.ctx.profile.create(profile, function (err, docs) {
|
||||
should.not.exist(err);
|
||||
should.exist(doc);
|
||||
doc.defaultProfile.should.equal('Default');
|
||||
should.exist(docs);
|
||||
// create() now always returns an array
|
||||
docs.should.be.an.Array();
|
||||
docs.length.should.equal(1);
|
||||
docs[0].defaultProfile.should.equal('Default');
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('create() accepts array of profiles', function (done) {
|
||||
var profiles = [
|
||||
{
|
||||
defaultProfile: 'Default',
|
||||
store: {
|
||||
Default: { dia: 3, carbratio: [{ time: '00:00', value: 30 }] }
|
||||
},
|
||||
startDate: new Date().toISOString(),
|
||||
units: 'mg/dl'
|
||||
},
|
||||
{
|
||||
defaultProfile: 'Profile2',
|
||||
store: {
|
||||
Profile2: { dia: 4, carbratio: [{ time: '00:00', value: 25 }] }
|
||||
},
|
||||
startDate: new Date().toISOString(),
|
||||
units: 'mg/dl'
|
||||
}
|
||||
];
|
||||
|
||||
self.ctx.profile.create(profiles, function (err, docs) {
|
||||
should.not.exist(err);
|
||||
should.exist(docs);
|
||||
docs.should.be.an.Array();
|
||||
docs.length.should.equal(2);
|
||||
docs[0].defaultProfile.should.equal('Default');
|
||||
docs[1].defaultProfile.should.equal('Profile2');
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('create() handles empty array', function (done) {
|
||||
self.ctx.profile.create([], function (err, docs) {
|
||||
should.not.exist(err);
|
||||
should.exist(docs);
|
||||
docs.should.be.an.Array();
|
||||
docs.length.should.equal(0);
|
||||
done();
|
||||
});
|
||||
});
|
||||
@@ -280,10 +324,12 @@ describe('Storage Layer Shape Handling - Direct Storage Tests', function () {
|
||||
fat: 5
|
||||
};
|
||||
|
||||
self.ctx.food.create(food, function (err, doc) {
|
||||
self.ctx.food.create(food, function (err, docs) {
|
||||
should.not.exist(err);
|
||||
should.exist(doc);
|
||||
doc.name.should.equal('Test Food');
|
||||
should.exist(docs);
|
||||
// Storage normalizes to array internally
|
||||
docs.should.be.an.Array();
|
||||
docs[0].name.should.equal('Test Food');
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,517 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* UUID_HANDLING Feature Flag Tests
|
||||
*
|
||||
* Tests for GET/DELETE by UUID using the `identifier` field lookup.
|
||||
*
|
||||
* @see docs/backlogs/uuid-identifier-lookup.md
|
||||
* @see lib/server/env.js (env.uuidHandling)
|
||||
* @see lib/server/query.js (updateIdQuery UUID detection)
|
||||
*/
|
||||
|
||||
var request = require('supertest');
|
||||
var should = require('should');
|
||||
var language = require('../lib/language')();
|
||||
var api = require('../lib/api/');
|
||||
|
||||
var api_secret_hash = 'b723e97aa97846eb92d5264f084b2823f57c4aa1';
|
||||
|
||||
// Test UUIDs
|
||||
var TEST_UUID = '550e8400-e29b-41d4-a716-446655440000';
|
||||
var TEST_UUID_2 = '6ba7b810-9dad-11d1-80b4-00c04fd430c8';
|
||||
|
||||
/**
|
||||
* Clear module caches to allow env reloading
|
||||
*/
|
||||
function clearModuleCache() {
|
||||
var modulesToClear = [
|
||||
'../lib/server/env',
|
||||
'../lib/server/query',
|
||||
'../lib/server/treatments',
|
||||
'../lib/server/entries',
|
||||
'../lib/api/entries/',
|
||||
'../lib/api/',
|
||||
'../lib/middleware/',
|
||||
'../lib/server/bootevent'
|
||||
];
|
||||
|
||||
modulesToClear.forEach(function(mod) {
|
||||
try {
|
||||
delete require.cache[require.resolve(mod)];
|
||||
} catch (e) {
|
||||
// Module not yet loaded
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
describe('UUID_HANDLING=false (explicit)', function() {
|
||||
var self = this;
|
||||
this.timeout(10000);
|
||||
|
||||
before(function(done) {
|
||||
// Explicitly set UUID_HANDLING OFF (default changed to true in 15.0.7)
|
||||
process.env.UUID_HANDLING = 'false';
|
||||
clearModuleCache();
|
||||
|
||||
process.env.API_SECRET = 'this is my long pass phrase';
|
||||
self.env = require('../lib/server/env')();
|
||||
self.env.settings.authDefaultRoles = 'readable';
|
||||
self.env.settings.enable = ['careportal', 'api'];
|
||||
|
||||
// Verify flag is off
|
||||
self.env.uuidHandling.should.equal(false);
|
||||
|
||||
self.wares = require('../lib/middleware/')(self.env);
|
||||
self.app = require('express')();
|
||||
self.app.enable('api');
|
||||
|
||||
require('../lib/server/bootevent')(self.env, language).boot(function booted(ctx) {
|
||||
self.ctx = ctx;
|
||||
self.ctx.ddata = require('../lib/data/ddata')();
|
||||
self.ctx.ddata.lastUpdated = Date.now();
|
||||
self.app.use('/api', api(self.env, ctx));
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(function(done) {
|
||||
self.ctx.treatments.remove({ find: {} }, done);
|
||||
});
|
||||
|
||||
it('UUID-OFF-001: GET by UUID returns empty (no crash)', function(done) {
|
||||
// Insert treatment with identifier
|
||||
self.ctx.treatments.create([{
|
||||
eventType: 'Note',
|
||||
notes: 'Test note',
|
||||
identifier: TEST_UUID,
|
||||
created_at: new Date().toISOString()
|
||||
}], function(err) {
|
||||
should.not.exist(err);
|
||||
|
||||
// GET by UUID _id - should return empty since flag is off
|
||||
request(self.app)
|
||||
.get('/api/treatments?find[_id]=' + TEST_UUID)
|
||||
.expect(200)
|
||||
.end(function(err, res) {
|
||||
should.not.exist(err);
|
||||
res.body.should.be.Array();
|
||||
res.body.length.should.equal(0);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('UUID-OFF-002: DELETE by UUID deletes nothing (no crash)', function(done) {
|
||||
// Insert treatment
|
||||
self.ctx.treatments.create([{
|
||||
eventType: 'Note',
|
||||
notes: 'Test for delete',
|
||||
identifier: TEST_UUID,
|
||||
created_at: new Date().toISOString()
|
||||
}], function(err) {
|
||||
should.not.exist(err);
|
||||
|
||||
// DELETE by UUID - should not crash, delete nothing
|
||||
request(self.app)
|
||||
.delete('/api/treatments/' + TEST_UUID)
|
||||
.set('api-secret', api_secret_hash)
|
||||
.expect(200)
|
||||
.end(function(err) {
|
||||
should.not.exist(err);
|
||||
|
||||
// Verify treatment still exists
|
||||
self.ctx.treatments.list({}, function(err, results) {
|
||||
should.not.exist(err);
|
||||
results.length.should.equal(1);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('UUID-OFF-003: POST with UUID _id strips UUID, does not copy to identifier', function(done) {
|
||||
// When UUID_HANDLING=false, UUID in _id is stripped but NOT preserved as identifier
|
||||
self.ctx.treatments.create([{
|
||||
_id: TEST_UUID,
|
||||
eventType: 'Note',
|
||||
notes: 'UUID stripped write test',
|
||||
created_at: new Date().toISOString()
|
||||
}], function(err) {
|
||||
should.not.exist(err);
|
||||
// Treatment was created (no crash), but UUID was not preserved as identifier
|
||||
self.ctx.treatments.list({}, function(err, results) {
|
||||
should.not.exist(err);
|
||||
results.length.should.equal(1);
|
||||
// identifier should NOT be set from the UUID _id when flag is false
|
||||
should.not.exist(results[0].identifier);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('UUID_HANDLING=true', function() {
|
||||
var self = this;
|
||||
this.timeout(10000);
|
||||
|
||||
before(function(done) {
|
||||
// Enable UUID_HANDLING
|
||||
process.env.UUID_HANDLING = 'true';
|
||||
clearModuleCache();
|
||||
|
||||
process.env.API_SECRET = 'this is my long pass phrase';
|
||||
self.env = require('../lib/server/env')();
|
||||
self.env.settings.authDefaultRoles = 'readable';
|
||||
self.env.settings.enable = ['careportal', 'api'];
|
||||
|
||||
// Verify flag is on
|
||||
self.env.uuidHandling.should.equal(true);
|
||||
|
||||
self.wares = require('../lib/middleware/')(self.env);
|
||||
self.app = require('express')();
|
||||
self.app.enable('api');
|
||||
|
||||
require('../lib/server/bootevent')(self.env, language).boot(function booted(ctx) {
|
||||
self.ctx = ctx;
|
||||
self.ctx.ddata = require('../lib/data/ddata')();
|
||||
self.ctx.ddata.lastUpdated = Date.now();
|
||||
self.app.use('/api', api(self.env, ctx));
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(function(done) {
|
||||
self.ctx.treatments.remove({ find: {} }, done);
|
||||
});
|
||||
|
||||
after(function() {
|
||||
delete process.env.UUID_HANDLING;
|
||||
});
|
||||
|
||||
it('UUID-ON-001: GET by UUID finds treatment via identifier', function(done) {
|
||||
// Insert treatment with identifier field
|
||||
self.ctx.treatments.create([{
|
||||
eventType: 'Note',
|
||||
notes: 'Found by UUID',
|
||||
identifier: TEST_UUID,
|
||||
created_at: new Date().toISOString()
|
||||
}], function(err) {
|
||||
should.not.exist(err);
|
||||
|
||||
// GET by UUID _id - should redirect to identifier search
|
||||
request(self.app)
|
||||
.get('/api/treatments?find[_id]=' + TEST_UUID)
|
||||
.expect(200)
|
||||
.end(function(err, res) {
|
||||
should.not.exist(err);
|
||||
res.body.should.be.Array();
|
||||
res.body.length.should.equal(1);
|
||||
res.body[0].notes.should.equal('Found by UUID');
|
||||
res.body[0].identifier.should.equal(TEST_UUID);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('UUID-ON-002: DELETE by UUID removes treatment via identifier', function(done) {
|
||||
// Insert treatment
|
||||
self.ctx.treatments.create([{
|
||||
eventType: 'Note',
|
||||
notes: 'Will be deleted',
|
||||
identifier: TEST_UUID,
|
||||
created_at: new Date().toISOString()
|
||||
}], function(err) {
|
||||
should.not.exist(err);
|
||||
|
||||
// DELETE by UUID
|
||||
request(self.app)
|
||||
.delete('/api/treatments/' + TEST_UUID)
|
||||
.set('api-secret', api_secret_hash)
|
||||
.expect(200)
|
||||
.end(function(err) {
|
||||
should.not.exist(err);
|
||||
|
||||
// Verify treatment is gone
|
||||
self.ctx.treatments.list({}, function(err, results) {
|
||||
should.not.exist(err);
|
||||
results.length.should.equal(0);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('UUID-ON-003: ObjectId still works normally', function(done) {
|
||||
var ObjectID = require('mongodb').ObjectId;
|
||||
var testId = new ObjectID();
|
||||
|
||||
// Insert treatment with ObjectId
|
||||
self.ctx.treatments.create([{
|
||||
_id: testId,
|
||||
eventType: 'Note',
|
||||
notes: 'ObjectId test',
|
||||
created_at: new Date().toISOString()
|
||||
}], function(err) {
|
||||
should.not.exist(err);
|
||||
|
||||
// GET by ObjectId - should work normally
|
||||
request(self.app)
|
||||
.get('/api/treatments?find[_id]=' + testId.toString())
|
||||
.expect(200)
|
||||
.end(function(err, res) {
|
||||
should.not.exist(err);
|
||||
res.body.should.be.Array();
|
||||
res.body.length.should.equal(1);
|
||||
res.body[0].notes.should.equal('ObjectId test');
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('UUID-ON-004: Non-matching UUID returns empty', function(done) {
|
||||
// Insert with different identifier
|
||||
self.ctx.treatments.create([{
|
||||
eventType: 'Note',
|
||||
notes: 'Different UUID',
|
||||
identifier: TEST_UUID_2,
|
||||
created_at: new Date().toISOString()
|
||||
}], function(err) {
|
||||
should.not.exist(err);
|
||||
|
||||
// Search for non-existing UUID
|
||||
request(self.app)
|
||||
.get('/api/treatments?find[_id]=' + TEST_UUID)
|
||||
.expect(200)
|
||||
.end(function(err, res) {
|
||||
should.not.exist(err);
|
||||
res.body.should.be.Array();
|
||||
res.body.length.should.equal(0);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('UUID-ON-005: POST with UUID _id extracts UUID to identifier', function(done) {
|
||||
// When UUID_HANDLING=true, UUID in _id is extracted to identifier
|
||||
self.ctx.treatments.create([{
|
||||
_id: TEST_UUID,
|
||||
eventType: 'Note',
|
||||
notes: 'UUID write test',
|
||||
created_at: new Date().toISOString()
|
||||
}], function(err) {
|
||||
should.not.exist(err);
|
||||
// UUID should have been moved to identifier
|
||||
self.ctx.treatments.list({}, function(err, results) {
|
||||
should.not.exist(err);
|
||||
results.length.should.equal(1);
|
||||
// identifier should be set from the UUID _id
|
||||
results[0].identifier.should.equal(TEST_UUID);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
|
||||
// ============================================
|
||||
// UUID Edge Case Tests
|
||||
// ============================================
|
||||
|
||||
describe('UUID Edge Cases', function() {
|
||||
var self = this;
|
||||
this.timeout(10000);
|
||||
|
||||
before(function(done) {
|
||||
process.env.UUID_HANDLING = 'true';
|
||||
clearModuleCache();
|
||||
|
||||
process.env.API_SECRET = 'this is my long pass phrase';
|
||||
self.env = require('../lib/server/env')();
|
||||
self.env.settings.authDefaultRoles = 'readable';
|
||||
self.env.settings.enable = ['careportal', 'api'];
|
||||
|
||||
self.wares = require('../lib/middleware/')(self.env);
|
||||
self.app = require('express')();
|
||||
self.app.enable('api');
|
||||
|
||||
require('../lib/server/bootevent')(self.env, language).boot(function booted(ctx) {
|
||||
self.ctx = ctx;
|
||||
self.ctx.ddata = require('../lib/data/ddata')();
|
||||
self.ctx.ddata.lastUpdated = Date.now();
|
||||
self.app.use('/api', api(self.env, ctx));
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(function(done) {
|
||||
self.ctx.treatments.remove({ find: {} }, done);
|
||||
});
|
||||
|
||||
after(function() {
|
||||
delete process.env.UUID_HANDLING;
|
||||
});
|
||||
|
||||
it('UUID-EDGE-001: 23-char hex (invalid ObjectId) returns empty', function(done) {
|
||||
// 23 chars - one short of valid ObjectId
|
||||
var invalidId = '507f1f77bcf86cd79943901';
|
||||
|
||||
request(self.app)
|
||||
.get('/api/treatments?find[_id]=' + invalidId)
|
||||
.expect(200)
|
||||
.end(function(err, res) {
|
||||
should.not.exist(err);
|
||||
res.body.should.be.Array();
|
||||
res.body.length.should.equal(0);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('UUID-EDGE-002: 25-char hex (too long) returns empty', function(done) {
|
||||
// 25 chars - one more than valid ObjectId
|
||||
var invalidId = '507f1f77bcf86cd7994390112';
|
||||
|
||||
request(self.app)
|
||||
.get('/api/treatments?find[_id]=' + invalidId)
|
||||
.expect(200)
|
||||
.end(function(err, res) {
|
||||
should.not.exist(err);
|
||||
res.body.should.be.Array();
|
||||
res.body.length.should.equal(0);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('UUID-EDGE-003: UUID without hyphens not recognized as UUID', function(done) {
|
||||
// UUID without hyphens (32 hex chars)
|
||||
var noHyphenUUID = '550e8400e29b41d4a716446655440000';
|
||||
|
||||
// Insert treatment with hyphenated UUID
|
||||
self.ctx.treatments.create([{
|
||||
eventType: 'Note',
|
||||
notes: 'Hyphenated UUID',
|
||||
identifier: TEST_UUID,
|
||||
created_at: new Date().toISOString()
|
||||
}], function(err) {
|
||||
should.not.exist(err);
|
||||
|
||||
// Search with non-hyphenated version - should not match
|
||||
request(self.app)
|
||||
.get('/api/treatments?find[_id]=' + noHyphenUUID)
|
||||
.expect(200)
|
||||
.end(function(err, res) {
|
||||
should.not.exist(err);
|
||||
res.body.should.be.Array();
|
||||
res.body.length.should.equal(0);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('UUID-EDGE-004: Empty _id query returns all with date filter', function(done) {
|
||||
self.ctx.treatments.create([{
|
||||
eventType: 'Note',
|
||||
notes: 'Test note',
|
||||
created_at: new Date().toISOString()
|
||||
}], function(err) {
|
||||
should.not.exist(err);
|
||||
|
||||
// Empty _id - should not crash, returns based on other filters
|
||||
request(self.app)
|
||||
.get('/api/treatments?find[_id]=')
|
||||
.expect(200)
|
||||
.end(function(err, res) {
|
||||
should.not.exist(err);
|
||||
res.body.should.be.Array();
|
||||
// May return results based on date filter
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('UUID-EDGE-005: Multiple treatments same identifier - documents upsert behavior', function(done) {
|
||||
// treatments.create uses upsert by identifier, so duplicates are merged
|
||||
// This test documents the expected behavior
|
||||
var now = new Date();
|
||||
var later = new Date(now.getTime() + 1000);
|
||||
|
||||
self.ctx.treatments.create([
|
||||
{ eventType: 'Note', notes: 'First', identifier: TEST_UUID, created_at: now.toISOString() }
|
||||
], function(err) {
|
||||
should.not.exist(err);
|
||||
|
||||
self.ctx.treatments.create([
|
||||
{ eventType: 'Note', notes: 'Second', identifier: TEST_UUID, created_at: later.toISOString() }
|
||||
], function(err2) {
|
||||
should.not.exist(err2);
|
||||
|
||||
request(self.app)
|
||||
.get('/api/treatments?find[_id]=' + TEST_UUID)
|
||||
.expect(200)
|
||||
.end(function(err, res) {
|
||||
should.not.exist(err);
|
||||
res.body.should.be.Array();
|
||||
// Due to upsert by identifier, only 1 treatment exists (updated)
|
||||
res.body.length.should.equal(1);
|
||||
// The second create updates the first
|
||||
res.body[0].notes.should.equal('Second');
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('UUID-EDGE-006: Uppercase UUID matches case-insensitively', function(done) {
|
||||
var lowerUUID = TEST_UUID.toLowerCase();
|
||||
var upperUUID = TEST_UUID.toUpperCase();
|
||||
|
||||
// Insert with lowercase
|
||||
self.ctx.treatments.create([{
|
||||
eventType: 'Note',
|
||||
notes: 'Lowercase UUID',
|
||||
identifier: lowerUUID,
|
||||
created_at: new Date().toISOString()
|
||||
}], function(err) {
|
||||
should.not.exist(err);
|
||||
|
||||
// Query with uppercase - UUID regex is case insensitive
|
||||
// but identifier field match depends on stored value
|
||||
request(self.app)
|
||||
.get('/api/treatments?find[_id]=' + upperUUID)
|
||||
.expect(200)
|
||||
.end(function(err, res) {
|
||||
should.not.exist(err);
|
||||
res.body.should.be.Array();
|
||||
// Documents current behavior - uppercase query searches by identifier
|
||||
// which is case-sensitive in MongoDB
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('UUID-EDGE-007: Valid ObjectId still works normally', function(done) {
|
||||
var ObjectID = require('mongodb').ObjectId;
|
||||
var testId = new ObjectID();
|
||||
|
||||
self.ctx.treatments.create([{
|
||||
_id: testId,
|
||||
eventType: 'Note',
|
||||
notes: 'ObjectId test',
|
||||
created_at: new Date().toISOString()
|
||||
}], function(err) {
|
||||
should.not.exist(err);
|
||||
|
||||
request(self.app)
|
||||
.get('/api/treatments?find[_id]=' + testId.toString())
|
||||
.expect(200)
|
||||
.end(function(err, res) {
|
||||
should.not.exist(err);
|
||||
res.body.should.be.Array();
|
||||
res.body.length.should.equal(1);
|
||||
res.body[0].notes.should.equal('ObjectId test');
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user