Skip to content

Commit de07b3f

Browse files
cubapthehabes
andauthored
overwrite with version (#198)
* overwrite with version - adding version match for overwrite - testing locking - generated tests (not implemented) * AI loves CJS * Update overwrite-optimistic-locking.test.js * Let Claude have a try * surely you jest * ain't nobody got time for that * Update db-controller.txt * send this immediately The expressError handling was turning this into a text error with no payload, just a message * send object back * why doctype error? * experimenting with errors * add message * `message` is a bad key here * notes don't work either * test for new header in /v1/id response * Update db-controller.js * don't send current version --------- Co-authored-by: Bryan Haberberger <bryan.j.haberberger@slu.edu>
1 parent 1f5fcf9 commit de07b3f

File tree

5 files changed

+402
-27
lines changed

5 files changed

+402
-27
lines changed

__mocks__/db-controller.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,4 @@ See https://www.npmjs.com/package/node-mocks-http
99
See https://www.npmjs.com/package/@jest-mock/express
1010
See https://expressjs.com/en/api.html#req
1111
See https://expressjs.com/en/api.html#res
12+

db-controller.js

Lines changed: 41 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -764,33 +764,49 @@ const overwrite = async function (req, res, next) {
764764
})
765765
}
766766
else {
767-
let context = objectReceived["@context"] ? { "@context": objectReceived["@context"] } : {}
768-
let rerumProp = { "__rerum": originalObject["__rerum"] }
769-
rerumProp["__rerum"].isOverwritten = new Date(Date.now()).toISOString().replace("Z", "")
770-
const id = originalObject["_id"]
771-
//Get rid of them so we can enforce the order
772-
delete objectReceived["@id"]
773-
delete objectReceived["_id"]
774-
delete objectReceived["__rerum"]
775-
// id is also protected in this case, so it can't be set.
776-
if(_contextid(objectReceived["@context"])) delete objectReceived.id
777-
delete objectReceived["@context"]
778-
let newObject = Object.assign(context, { "@id": originalObject["@id"] }, objectReceived, rerumProp, { "_id": id })
779-
let result
780-
try {
781-
result = await db.replaceOne({ "_id": id }, newObject)
782-
} catch (error) {
783-
next(createExpressError(error))
767+
// Optimistic locking check - no expected version is a brutal overwrite
768+
const expectedVersion = req.get('If-Overwritten-Version') ?? req.body.__rerum?.isOverwritten
769+
const currentVersionTS = originalObject.__rerum?.isOverwritten ?? ""
770+
771+
if (expectedVersion !== undefined && expectedVersion !== currentVersionTS) {
772+
res.status(409)
773+
res.json({
774+
currentVersion: originalObject
775+
})
776+
return
784777
}
785-
if (result.modifiedCount == 0) {
786-
//result didn't error out, the action was not performed. Sometimes, this is a neutral thing. Sometimes it is indicative of an error.
778+
else {
779+
let context = objectReceived["@context"] ? { "@context": objectReceived["@context"] } : {}
780+
let rerumProp = { "__rerum": originalObject["__rerum"] }
781+
rerumProp["__rerum"].isOverwritten = new Date(Date.now()).toISOString().replace("Z", "")
782+
const id = originalObject["_id"]
783+
//Get rid of them so we can enforce the order
784+
delete objectReceived["@id"]
785+
delete objectReceived["_id"]
786+
delete objectReceived["__rerum"]
787+
// id is also protected in this case, so it can't be set.
788+
if(_contextid(objectReceived["@context"])) delete objectReceived.id
789+
delete objectReceived["@context"]
790+
let newObject = Object.assign(context, { "@id": originalObject["@id"] }, objectReceived, rerumProp, { "_id": id })
791+
let result
792+
try {
793+
result = await db.replaceOne({ "_id": id }, newObject)
794+
} catch (error) {
795+
next(createExpressError(error))
796+
return
797+
}
798+
if (result.modifiedCount == 0) {
799+
//result didn't error out, the action was not performed. Sometimes, this is a neutral thing. Sometimes it is indicative of an error.
800+
}
801+
// Include current version in response headers for future optimistic locking
802+
res.set('Current-Overwritten-Version', rerumProp["__rerum"].isOverwritten)
803+
res.set(utils.configureWebAnnoHeadersFor(newObject))
804+
newObject = idNegotiation(newObject)
805+
newObject.new_obj_state = JSON.parse(JSON.stringify(newObject))
806+
res.location(newObject[_contextid(newObject["@context"]) ? "id":"@id"])
807+
res.json(newObject)
808+
return
787809
}
788-
res.set(utils.configureWebAnnoHeadersFor(newObject))
789-
newObject = idNegotiation(newObject)
790-
newObject.new_obj_state = JSON.parse(JSON.stringify(newObject))
791-
res.location(newObject[_contextid(newObject["@context"]) ? "id":"@id"])
792-
res.json(newObject)
793-
return
794810
}
795811
}
796812
else {

routes/__tests__/id.test.js

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,6 @@ it("'/id/:id' route functions", async () => {
3030
expect(response.headers["last-modified"]).toBeTruthy()
3131
expect(response.headers["link"]).toBeTruthy()
3232
expect(response.headers["location"]).toBeTruthy()
33-
3433
})
3534

3635
it.skip("Proper '@id-id' negotation on GET by URI.", async () => {
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
import { jest } from '@jest/globals'
2+
import express from 'express'
3+
import request from 'supertest'
4+
5+
// Create mock functions
6+
const mockFindOne = jest.fn()
7+
const mockReplaceOne = jest.fn()
8+
9+
// Mock the database module
10+
jest.mock('../../database/index.js', () => ({
11+
db: {
12+
findOne: mockFindOne,
13+
replaceOne: mockReplaceOne
14+
}
15+
}))
16+
17+
// Import controller after mocking
18+
import controller from '../../db-controller.js'
19+
20+
// Helper to add auth to requests
21+
const addAuth = (req, res, next) => {
22+
req.user = {"http://store.rerum.io/agent": "test-user"}
23+
next()
24+
}
25+
26+
// Create a test Express app
27+
const routeTester = express()
28+
routeTester.use(express.json())
29+
routeTester.use(express.urlencoded({ extended: false }))
30+
31+
// Mount our routes
32+
routeTester.use('/overwrite', [addAuth, controller.overwrite])
33+
routeTester.use('/id/:_id', controller.id)
34+
35+
describe('Overwrite Optimistic Locking', () => {
36+
beforeEach(() => {
37+
jest.clearAllMocks()
38+
})
39+
40+
test('should succeed when no version is specified (backwards compatibility)', async () => {
41+
const mockObject = {
42+
_id: 'test-id',
43+
'@id': 'http://example.com/test-id',
44+
'@context': 'http://example.com/context',
45+
'__rerum': {
46+
isOverwritten: '',
47+
generatedBy: 'test-user'
48+
},
49+
data: 'original-data'
50+
}
51+
52+
mockFindOne.mockResolvedValue(mockObject)
53+
mockReplaceOne.mockResolvedValue({ modifiedCount: 1 })
54+
55+
const response = await request(routeTester)
56+
.put('/overwrite')
57+
.send({
58+
'@id': 'http://example.com/test-id',
59+
data: 'updated-data'
60+
})
61+
62+
expect(response.status).toBe(200)
63+
})
64+
65+
test('should succeed when correct version is provided', async () => {
66+
const mockObject = {
67+
_id: 'test-id',
68+
'@id': 'http://example.com/test-id',
69+
'@context': 'http://example.com/context',
70+
'__rerum': {
71+
isOverwritten: '2025-06-24T10:00:00',
72+
generatedBy: 'test-user'
73+
},
74+
data: 'original-data'
75+
}
76+
77+
mockFindOne.mockResolvedValue(mockObject)
78+
mockReplaceOne.mockResolvedValue({ modifiedCount: 1 })
79+
80+
const response = await request(routeTester)
81+
.put('/overwrite')
82+
.set('If-Overwritten-Version', '2025-06-24T10:00:00')
83+
.send({
84+
'@id': 'http://example.com/test-id',
85+
data: 'updated-data'
86+
})
87+
88+
expect(response.status).toBe(200)
89+
})
90+
91+
test('should fail with 409 when version mismatch occurs', async () => {
92+
const mockObject = {
93+
_id: 'test-id',
94+
'@id': 'http://example.com/test-id',
95+
'@context': 'http://example.com/context',
96+
'__rerum': {
97+
isOverwritten: '2025-06-24T10:30:00', // Different from expected
98+
generatedBy: 'test-user'
99+
},
100+
data: 'original-data'
101+
}
102+
103+
mockFindOne.mockResolvedValue(mockObject)
104+
105+
const response = await request(routeTester)
106+
.put('/overwrite')
107+
.set('If-Overwritten-Version', '2025-06-24T10:00:00')
108+
.send({
109+
'@id': 'http://example.com/test-id',
110+
data: 'updated-data'
111+
})
112+
113+
expect(response.status).toBe(409)
114+
expect(response.body.message).toContain('Version conflict detected')
115+
expect(response.body.currentVersion).toBe('2025-06-24T10:30:00')
116+
})
117+
118+
test('should accept version via request body as fallback', async () => {
119+
const mockObject = {
120+
_id: 'test-id',
121+
'@id': 'http://example.com/test-id',
122+
'@context': 'http://example.com/context',
123+
'__rerum': {
124+
isOverwritten: '2025-06-24T10:00:00',
125+
generatedBy: 'test-user'
126+
},
127+
data: 'original-data'
128+
}
129+
130+
mockFindOne.mockResolvedValue(mockObject)
131+
mockReplaceOne.mockResolvedValue({ modifiedCount: 1 })
132+
133+
const response = await request(routeTester)
134+
.put('/overwrite')
135+
.send({
136+
'@id': 'http://example.com/test-id',
137+
'__expectedVersion': '2025-06-24T10:00:00',
138+
data: 'updated-data'
139+
})
140+
141+
expect(response.status).toBe(200)
142+
})
143+
})
144+
145+
describe('ID endpoint includes version header', () => {
146+
beforeEach(() => {
147+
jest.clearAllMocks()
148+
})
149+
150+
test('should include Current-Overwritten-Version header in GET /id response', async () => {
151+
const mockObject = {
152+
_id: 'test-id',
153+
'@id': 'http://example.com/test-id',
154+
'__rerum': {
155+
isOverwritten: '2025-06-24T10:00:00'
156+
},
157+
data: 'some-data'
158+
}
159+
160+
mockFindOne.mockResolvedValue(mockObject)
161+
162+
const response = await request(routeTester)
163+
.get('/id/test-id')
164+
165+
expect(response.status).toBe(200)
166+
})
167+
168+
test('should include empty string for new objects', async () => {
169+
const mockObject = {
170+
_id: 'test-id',
171+
'@id': 'http://example.com/test-id',
172+
'__rerum': {
173+
isOverwritten: ''
174+
},
175+
data: 'some-data'
176+
}
177+
178+
mockFindOne.mockResolvedValue(mockObject)
179+
180+
const response = await request(routeTester)
181+
.get('/id/test-id')
182+
183+
expect(response.status).toBe(200)
184+
})
185+
})

0 commit comments

Comments
 (0)