Add ObjectIdCache workflow tests for Loop sync patterns

TEST-CACHE-001: POST carb → cache syncIdentifier → PUT with id
TEST-CACHE-002: POST dose → cache syncIdentifier → DELETE with id
TEST-CACHE-003: Cache miss (24hr expiry) → POST same syncIdentifier
TEST-CACHE-004: App restart (cache empty) → POST existing syncIdentifier
TEST-CACHE-005: Batch POST → verify response order → cache mapping

7 new tests validating Loop's ObjectIdCache behavior:
- syncIdentifier → ObjectId mapping
- Response order for batch operations
- Deduplication by syncIdentifier
- Hex string syncIdentifier handling

All 7 tests passing.

Refs: Loop ObjectIdCache.swift analysis

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Ben West
2026-03-10 14:56:01 -07:00
co-authored by Copilot
parent 4af34767c2
commit 8ba8bbb18e
+468
View File
@@ -0,0 +1,468 @@
'use strict';
/**
* ObjectIdCache Workflow Tests
*
* These tests simulate Loop's ObjectIdCache behavior where:
* 1. POST treatment with syncIdentifier (no _id)
* 2. Server returns ObjectId in response
* 3. Client caches syncIdentifier → ObjectId mapping
* 4. Future PUT/DELETE uses cached ObjectId
*
* REQUIREMENT REFERENCE: Loop source analysis (ObjectIdCache.swift)
* TEST MATRIX REFERENCE: docs/backlogs/loop-nightscout-upload-testing.md
* - TEST-CACHE-001: POST carb → cache → PUT
* - TEST-CACHE-002: POST dose → cache → DELETE
* - TEST-CACHE-003: Cache miss (re-POST same syncIdentifier)
* - TEST-CACHE-004: App restart (cache empty) scenario
* - TEST-CACHE-005: Batch POST → response order → cache mapping
*/
const request = require('supertest');
const should = require('should');
const language = require('../lib/language')();
describe('ObjectIdCache Workflow Tests', function() {
this.timeout(30000);
const self = this;
const api_secret_hash = 'b723e97aa97846eb92d5264f084b2823f57c4aa1';
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'];
const 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.wares = wares;
self.ctx.ddata = require('../lib/data/ddata')();
self.app.use('/api', require('../lib/api/')(self.env, ctx));
done();
});
});
beforeEach(function(done) {
// Clear treatments before each test
self.ctx.treatments.remove({
find: { created_at: { '$gte': '1999-01-01T00:00:00.000Z' } }
}, done);
});
describe('TEST-CACHE-001: POST carb → cache syncIdentifier → PUT with id', function() {
it('simulates full Loop carb workflow: POST, cache, PUT', function(done) {
const syncId = 'loop-carb-sync-' + Date.now();
// Step 1: POST carb with syncIdentifier (no _id)
const carbEntry = {
eventType: 'Carb Correction',
carbs: 25,
created_at: new Date().toISOString(),
enteredBy: 'loop://iPhone',
syncIdentifier: syncId,
absorptionTime: 180
};
request(self.app)
.post('/api/treatments/')
.set('api-secret', api_secret_hash)
.send([carbEntry])
.expect(200)
.end(function(err, res) {
should.not.exist(err);
res.body.length.should.equal(1);
// Step 2: Server returns ObjectId - client would cache this
const serverId = res.body[0]._id;
serverId.should.match(/^[0-9a-f]{24}$/);
res.body[0].syncIdentifier.should.equal(syncId);
console.log(` POST: syncIdentifier=${syncId}`);
console.log(` Response: _id=${serverId} (cached)`);
// Step 3: PUT using cached ObjectId
const updatedCarb = {
_id: serverId, // Use cached ObjectId
eventType: 'Carb Correction',
carbs: 30, // Updated value
created_at: new Date().toISOString(),
enteredBy: 'loop://iPhone',
syncIdentifier: syncId,
absorptionTime: 240 // Updated
};
request(self.app)
.put('/api/treatments/')
.set('api-secret', api_secret_hash)
.send(updatedCarb)
.expect(200)
.end(function(err) {
should.not.exist(err);
// Verify update
self.ctx.treatments.list({ find: { syncIdentifier: syncId } }, function(err, list) {
should.not.exist(err);
list.length.should.equal(1);
list[0].carbs.should.equal(30);
list[0].absorptionTime.should.equal(240);
list[0]._id.toString().should.equal(serverId);
console.log(` PUT: _id=${serverId}, carbs=30 ✓`);
console.log(' ✓ Full carb workflow completed');
done();
});
});
});
});
});
describe('TEST-CACHE-002: POST dose → cache syncIdentifier → DELETE with id', function() {
it('simulates Loop dose workflow: POST, cache, DELETE', function(done) {
const syncId = 'loop-dose-sync-' + Date.now();
// Step 1: POST bolus with syncIdentifier
const doseEntry = {
eventType: 'Bolus',
insulin: 2.5,
created_at: new Date().toISOString(),
enteredBy: 'loop://iPhone',
syncIdentifier: syncId
};
request(self.app)
.post('/api/treatments/')
.set('api-secret', api_secret_hash)
.send([doseEntry])
.expect(200)
.end(function(err, res) {
should.not.exist(err);
// Step 2: Cache the returned ObjectId
const serverId = res.body[0]._id;
console.log(` POST: syncIdentifier=${syncId}`);
console.log(` Response: _id=${serverId} (cached)`);
// Step 3: DELETE using cached ObjectId
request(self.app)
.delete('/api/treatments/' + serverId)
.set('api-secret', api_secret_hash)
.expect(200)
.end(function(err) {
should.not.exist(err);
// Verify deletion
self.ctx.treatments.list({ find: { syncIdentifier: syncId } }, function(err, list) {
should.not.exist(err);
list.length.should.equal(0);
console.log(` DELETE: _id=${serverId}`);
console.log(' ✓ Full dose delete workflow completed');
done();
});
});
});
});
});
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) {
const syncId = 'loop-cache-miss-' + Date.now();
// Step 1: First POST (simulating original upload)
const firstEntry = {
eventType: 'Carb Correction',
carbs: 20,
created_at: new Date().toISOString(),
enteredBy: 'loop://iPhone',
syncIdentifier: syncId,
absorptionTime: 180
};
request(self.app)
.post('/api/treatments/')
.set('api-secret', api_secret_hash)
.send([firstEntry])
.expect(200)
.end(function(err, res) {
should.not.exist(err);
const firstId = res.body[0]._id;
console.log(` First POST: _id=${firstId}`);
// Step 2: Simulate cache miss - re-POST same syncIdentifier
// (This happens when Loop's ObjectIdCache expires after 24h)
const repostEntry = {
eventType: 'Carb Correction',
carbs: 20, // Same value
created_at: new Date().toISOString(),
enteredBy: 'loop://iPhone',
syncIdentifier: syncId, // Same syncIdentifier
absorptionTime: 180
};
request(self.app)
.post('/api/treatments/')
.set('api-secret', api_secret_hash)
.send([repostEntry])
.end(function(err, res) {
should.not.exist(err);
// Verify NO duplicate created
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');
console.log(` Re-POST: syncIdentifier=${syncId}`);
console.log(` Database has ${list.length} document(s)`);
console.log(' ✓ Deduplication by syncIdentifier working');
done();
});
});
});
});
});
describe('TEST-CACHE-004: App restart (cache empty) → POST existing syncIdentifier', function() {
it('simulates app restart: POST existing syncIdentifiers returns existing IDs', function(done) {
const syncIds = [
'loop-restart-1-' + Date.now(),
'loop-restart-2-' + Date.now(),
'loop-restart-3-' + Date.now()
];
// Step 1: Create initial entries (before "restart")
const initialBatch = syncIds.map((syncId, idx) => ({
eventType: 'Carb Correction',
carbs: 10 + idx * 5,
created_at: new Date(Date.now() + idx * 60000).toISOString(),
enteredBy: 'loop://iPhone',
syncIdentifier: syncId,
absorptionTime: 180
}));
request(self.app)
.post('/api/treatments/')
.set('api-secret', api_secret_hash)
.send(initialBatch)
.expect(200)
.end(function(err, res) {
should.not.exist(err);
const originalIds = res.body.map(item => item._id);
console.log(` Initial POST: ${originalIds.length} entries created`);
// Step 2: Simulate app restart - cache is empty, re-POST same entries
request(self.app)
.post('/api/treatments/')
.set('api-secret', api_secret_hash)
.send(initialBatch)
.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
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');
done();
});
});
});
});
});
describe('TEST-CACHE-005: Batch POST → verify response order → cache mapping', function() {
it('batch response maintains order for correct syncIdentifier → _id mapping', function(done) {
const batchSize = 5;
const syncIds = [];
const batch = [];
for (let i = 0; i < batchSize; i++) {
const syncId = `loop-batch-${i}-${Date.now()}`;
syncIds.push(syncId);
batch.push({
eventType: 'Carb Correction',
carbs: 10 + i * 5,
created_at: new Date(Date.now() + i * 60000).toISOString(),
enteredBy: 'loop://iPhone',
syncIdentifier: syncId,
absorptionTime: 180 + i * 30
});
}
request(self.app)
.post('/api/treatments/')
.set('api-secret', api_secret_hash)
.send(batch)
.expect(200)
.end(function(err, res) {
should.not.exist(err);
res.body.length.should.equal(batchSize);
console.log(` Batch POST: ${batchSize} entries`);
// Verify response order matches request order
// This is CRITICAL for ObjectIdCache to map correctly
for (let i = 0; i < batchSize; i++) {
res.body[i].syncIdentifier.should.equal(syncIds[i],
`Response[${i}] syncIdentifier should match request[${i}]`);
res.body[i]._id.should.match(/^[0-9a-f]{24}$/);
res.body[i].carbs.should.equal(10 + i * 5);
}
console.log(' Response order:');
res.body.forEach((item, idx) => {
console.log(` [${idx}] syncId=${item.syncIdentifier.substring(0, 20)}... → _id=${item._id}`);
});
// Build cache mapping (as Loop would)
const cache = {};
res.body.forEach(item => {
cache[item.syncIdentifier] = item._id;
});
// Verify cache can be used for updates
const updateIdx = 2;
const updateEntry = {
_id: cache[syncIds[updateIdx]],
eventType: 'Carb Correction',
carbs: 99, // Updated value
created_at: new Date().toISOString(),
enteredBy: 'loop://iPhone',
syncIdentifier: syncIds[updateIdx]
};
request(self.app)
.put('/api/treatments/')
.set('api-secret', api_secret_hash)
.send(updateEntry)
.expect(200)
.end(function(err) {
should.not.exist(err);
// Verify update worked using cached _id
self.ctx.treatments.list({ find: { syncIdentifier: syncIds[updateIdx] } }, function(err, list) {
should.not.exist(err);
list.length.should.equal(1);
list[0].carbs.should.equal(99);
console.log(` PUT using cached _id: carbs=99 ✓`);
console.log(' ✓ Batch response order enables correct cache mapping');
done();
});
});
});
});
});
describe('Additional Cache Scenarios', function() {
it('temp basal with syncIdentifier follows cache workflow', function(done) {
const syncId = 'loop-tempbasal-' + Date.now();
const tempBasal = {
eventType: 'Temp Basal',
duration: 30,
rate: 1.5,
absolute: 1.5,
created_at: new Date().toISOString(),
enteredBy: 'loop://iPhone',
syncIdentifier: syncId
};
request(self.app)
.post('/api/treatments/')
.set('api-secret', api_secret_hash)
.send([tempBasal])
.expect(200)
.end(function(err, res) {
should.not.exist(err);
const serverId = res.body[0]._id;
serverId.should.match(/^[0-9a-f]{24}$/);
res.body[0].syncIdentifier.should.equal(syncId);
// Update using cached _id
const updated = Object.assign({}, tempBasal, {
_id: serverId,
rate: 0.5,
absolute: 0.5
});
request(self.app)
.put('/api/treatments/')
.set('api-secret', api_secret_hash)
.send(updated)
.expect(200)
.end(function(err) {
should.not.exist(err);
self.ctx.treatments.list({ find: { syncIdentifier: syncId } }, function(err, list) {
should.not.exist(err);
list.length.should.equal(1);
list[0].rate.should.equal(0.5);
console.log(' ✓ Temp basal cache workflow works');
done();
});
});
});
});
it('hex string syncIdentifier (from pump events) is preserved', function(done) {
// Loop uses hex-encoded pump event IDs for some syncIdentifiers
const hexSyncId = 'deadbeef0123456789abcdef';
const pumpEvent = {
eventType: 'Bolus',
insulin: 1.0,
created_at: new Date().toISOString(),
enteredBy: 'loop://iPhone',
syncIdentifier: hexSyncId
};
request(self.app)
.post('/api/treatments/')
.set('api-secret', api_secret_hash)
.send([pumpEvent])
.expect(200)
.end(function(err, res) {
should.not.exist(err);
// Hex syncIdentifier should NOT be confused with ObjectId
res.body[0].syncIdentifier.should.equal(hexSyncId);
res.body[0]._id.should.match(/^[0-9a-f]{24}$/);
// They should be different values
res.body[0]._id.should.not.equal(hexSyncId);
console.log(` syncIdentifier: ${hexSyncId}`);
console.log(` _id: ${res.body[0]._id}`);
console.log(' ✓ Hex syncIdentifier preserved correctly');
done();
});
});
});
});