@terreno/api 0.13.2 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (175) hide show
  1. package/dist/__tests__/versionCheckPlugin.test.js +53 -3
  2. package/dist/api.arrayOperations.test.js +1 -0
  3. package/dist/api.asyncHandler.test.d.ts +1 -0
  4. package/dist/api.asyncHandler.test.js +236 -0
  5. package/dist/api.d.ts +15 -4
  6. package/dist/api.errors.test.js +1 -0
  7. package/dist/api.hooks.test.js +1 -0
  8. package/dist/api.js +153 -104
  9. package/dist/api.query.test.js +1 -0
  10. package/dist/api.test.js +174 -0
  11. package/dist/auth.d.ts +10 -5
  12. package/dist/auth.js +163 -90
  13. package/dist/auth.test.js +159 -0
  14. package/dist/betterAuthApp.test.js +1 -0
  15. package/dist/betterAuthSetup.d.ts +5 -6
  16. package/dist/betterAuthSetup.js +17 -14
  17. package/dist/betterAuthSetup.test.js +1 -0
  18. package/dist/config.d.ts +48 -0
  19. package/dist/config.js +248 -0
  20. package/dist/config.test.d.ts +1 -0
  21. package/dist/config.test.js +328 -0
  22. package/dist/configuration.test.js +1 -0
  23. package/dist/configurationApp.d.ts +1 -1
  24. package/dist/configurationApp.js +17 -13
  25. package/dist/configurationPlugin.test.js +1 -0
  26. package/dist/consentApp.test.js +1 -0
  27. package/dist/envConfigurationPlugin.d.ts +2 -0
  28. package/dist/envConfigurationPlugin.js +173 -0
  29. package/dist/envConfigurationPlugin.test.d.ts +1 -0
  30. package/dist/envConfigurationPlugin.test.js +322 -0
  31. package/dist/errors.d.ts +18 -7
  32. package/dist/errors.js +106 -10
  33. package/dist/errors.test.js +16 -1
  34. package/dist/example.js +16 -7
  35. package/dist/expressServer.d.ts +10 -9
  36. package/dist/expressServer.js +62 -53
  37. package/dist/expressServer.test.js +53 -2
  38. package/dist/githubAuth.d.ts +2 -1
  39. package/dist/githubAuth.js +41 -26
  40. package/dist/githubAuth.test.js +1 -0
  41. package/dist/index.d.ts +4 -0
  42. package/dist/index.js +4 -0
  43. package/dist/logger.d.ts +1 -1
  44. package/dist/logger.js +42 -20
  45. package/dist/models/versionConfig.d.ts +2 -0
  46. package/dist/models/versionConfig.js +8 -0
  47. package/dist/notifiers/googleChatNotifier.js +14 -16
  48. package/dist/notifiers/googleChatNotifier.test.js +1 -0
  49. package/dist/notifiers/slackNotifier.js +16 -14
  50. package/dist/notifiers/slackNotifier.test.js +41 -3
  51. package/dist/notifiers/zoomNotifier.js +7 -10
  52. package/dist/notifiers/zoomNotifier.test.js +1 -0
  53. package/dist/openApi.d.ts +1 -1
  54. package/dist/openApi.test.js +1 -0
  55. package/dist/openApiBuilder.d.ts +39 -6
  56. package/dist/openApiBuilder.js +1 -31
  57. package/dist/openApiBuilder.test.js +1 -0
  58. package/dist/openApiValidator.js +1 -0
  59. package/dist/openApiValidator.test.js +65 -0
  60. package/dist/permissions.d.ts +4 -4
  61. package/dist/permissions.js +67 -65
  62. package/dist/permissions.middleware.test.js +1 -0
  63. package/dist/permissions.test.js +1 -0
  64. package/dist/plugins.d.ts +5 -5
  65. package/dist/plugins.js +18 -9
  66. package/dist/plugins.test.js +1 -1
  67. package/dist/populate.d.ts +15 -8
  68. package/dist/populate.js +23 -24
  69. package/dist/populate.test.js +1 -0
  70. package/dist/realtime/changeStreamWatcher.d.ts +73 -0
  71. package/dist/realtime/changeStreamWatcher.js +720 -0
  72. package/dist/realtime/index.d.ts +6 -0
  73. package/dist/realtime/index.js +27 -0
  74. package/dist/realtime/queryMatcher.d.ts +14 -0
  75. package/dist/realtime/queryMatcher.js +250 -0
  76. package/dist/realtime/queryStore.d.ts +37 -0
  77. package/dist/realtime/queryStore.js +195 -0
  78. package/dist/realtime/realtime.test.d.ts +10 -0
  79. package/dist/realtime/realtime.test.js +2158 -0
  80. package/dist/realtime/realtimeApp.d.ts +93 -0
  81. package/dist/realtime/realtimeApp.js +560 -0
  82. package/dist/realtime/registry.d.ts +40 -0
  83. package/dist/realtime/registry.js +38 -0
  84. package/dist/realtime/socketUser.d.ts +10 -0
  85. package/dist/realtime/socketUser.js +17 -0
  86. package/dist/realtime/types.d.ts +100 -0
  87. package/dist/realtime/types.js +2 -0
  88. package/dist/requestContext.d.ts +37 -0
  89. package/dist/requestContext.js +344 -0
  90. package/dist/requestContext.test.d.ts +1 -0
  91. package/dist/requestContext.test.js +241 -0
  92. package/dist/terrenoApp.d.ts +8 -0
  93. package/dist/terrenoApp.js +50 -13
  94. package/dist/terrenoApp.test.js +194 -21
  95. package/dist/terrenoPlugin.d.ts +11 -0
  96. package/dist/tests/bunSetup.js +1 -0
  97. package/dist/tests.js +1 -1
  98. package/dist/transformers.d.ts +2 -2
  99. package/dist/transformers.js +5 -3
  100. package/dist/transformers.test.js +90 -0
  101. package/dist/types/consentResponse.d.ts +6 -3
  102. package/dist/versionCheckPlugin.d.ts +2 -0
  103. package/dist/versionCheckPlugin.js +18 -12
  104. package/package.json +4 -2
  105. package/src/__tests__/versionCheckPlugin.test.ts +37 -3
  106. package/src/api.arrayOperations.test.ts +1 -0
  107. package/src/api.asyncHandler.test.ts +177 -0
  108. package/src/api.errors.test.ts +1 -0
  109. package/src/api.hooks.test.ts +1 -0
  110. package/src/api.query.test.ts +1 -0
  111. package/src/api.test.ts +132 -0
  112. package/src/api.ts +199 -84
  113. package/src/auth.test.ts +160 -0
  114. package/src/auth.ts +120 -50
  115. package/src/betterAuthApp.test.ts +1 -0
  116. package/src/betterAuthSetup.test.ts +1 -0
  117. package/src/betterAuthSetup.ts +46 -19
  118. package/src/config.test.ts +255 -0
  119. package/src/config.ts +206 -0
  120. package/src/configuration.test.ts +1 -0
  121. package/src/configurationApp.ts +59 -24
  122. package/src/configurationPlugin.test.ts +1 -0
  123. package/src/consentApp.test.ts +1 -0
  124. package/src/envConfigurationPlugin.test.ts +143 -0
  125. package/src/envConfigurationPlugin.ts +100 -0
  126. package/src/errors.test.ts +19 -1
  127. package/src/errors.ts +94 -20
  128. package/src/example.ts +46 -21
  129. package/src/express.d.ts +18 -1
  130. package/src/expressServer.test.ts +50 -2
  131. package/src/expressServer.ts +80 -50
  132. package/src/githubAuth.test.ts +1 -0
  133. package/src/githubAuth.ts +59 -38
  134. package/src/index.ts +4 -0
  135. package/src/logger.ts +47 -17
  136. package/src/models/versionConfig.ts +13 -2
  137. package/src/notifiers/googleChatNotifier.test.ts +1 -0
  138. package/src/notifiers/googleChatNotifier.ts +7 -9
  139. package/src/notifiers/slackNotifier.test.ts +29 -3
  140. package/src/notifiers/slackNotifier.ts +9 -7
  141. package/src/notifiers/zoomNotifier.test.ts +1 -0
  142. package/src/notifiers/zoomNotifier.ts +8 -11
  143. package/src/openApi.test.ts +1 -0
  144. package/src/openApi.ts +4 -4
  145. package/src/openApiBuilder.test.ts +1 -0
  146. package/src/openApiBuilder.ts +14 -11
  147. package/src/openApiValidator.test.ts +59 -0
  148. package/src/openApiValidator.ts +3 -2
  149. package/src/permissions.middleware.test.ts +1 -0
  150. package/src/permissions.test.ts +1 -0
  151. package/src/permissions.ts +30 -25
  152. package/src/plugins.test.ts +1 -1
  153. package/src/plugins.ts +21 -14
  154. package/src/populate.test.ts +1 -0
  155. package/src/populate.ts +44 -36
  156. package/src/realtime/changeStreamWatcher.ts +568 -0
  157. package/src/realtime/index.ts +34 -0
  158. package/src/realtime/queryMatcher.ts +179 -0
  159. package/src/realtime/queryStore.ts +132 -0
  160. package/src/realtime/realtime.test.ts +1755 -0
  161. package/src/realtime/realtimeApp.ts +478 -0
  162. package/src/realtime/registry.ts +64 -0
  163. package/src/realtime/socketUser.ts +25 -0
  164. package/src/realtime/types.ts +112 -0
  165. package/src/requestContext.test.ts +196 -0
  166. package/src/requestContext.ts +368 -0
  167. package/src/terrenoApp.test.ts +137 -11
  168. package/src/terrenoApp.ts +64 -17
  169. package/src/terrenoPlugin.ts +12 -0
  170. package/src/tests/bunSetup.ts +1 -0
  171. package/src/tests.ts +7 -2
  172. package/src/transformers.test.ts +70 -2
  173. package/src/transformers.ts +15 -7
  174. package/src/types/consentResponse.ts +8 -10
  175. package/src/versionCheckPlugin.ts +15 -7
@@ -1,3 +1,4 @@
1
+ // biome-ignore-all lint/suspicious/noExplicitAny: test mock typing
1
2
  import {afterEach, beforeEach, describe, expect, it} from "bun:test";
2
3
  import supertest from "supertest";
3
4
  import {VersionConfig} from "../models/versionConfig";
@@ -29,7 +30,7 @@ describe("VersionCheckPlugin", () => {
29
30
  it("returns ok when no VersionConfig exists", async () => {
30
31
  const res = await app.get("/version-check").query({platform: "web", version: 100});
31
32
  expect(res.status).toBe(200);
32
- expect(res.body).toEqual({status: "ok"});
33
+ expect(res.body).toEqual({pollingIntervalMs: 86400000, status: "ok"});
33
34
  });
34
35
 
35
36
  it("returns ok when version param is missing", async () => {
@@ -52,7 +53,12 @@ describe("VersionCheckPlugin", () => {
52
53
 
53
54
  const res = await app.get("/version-check").query({platform: "web", version: 150});
54
55
  expect(res.status).toBe(200);
55
- expect(res.body).toEqual({requiredVersion: 50, status: "ok", warningVersion: 100});
56
+ expect(res.body).toEqual({
57
+ pollingIntervalMs: 86400000,
58
+ requiredVersion: 50,
59
+ status: "ok",
60
+ warningVersion: 100,
61
+ });
56
62
  });
57
63
 
58
64
  it("returns warning when client version < warning (web)", async () => {
@@ -116,7 +122,35 @@ describe("VersionCheckPlugin", () => {
116
122
 
117
123
  const res = await app.get("/version-check").query({platform: "web", version: 100});
118
124
  expect(res.status).toBe(200);
119
- expect(res.body).toEqual({requiredVersion: 50, status: "ok", warningVersion: 100});
125
+ expect(res.body).toEqual({
126
+ pollingIntervalMs: 86400000,
127
+ requiredVersion: 50,
128
+ status: "ok",
129
+ warningVersion: 100,
130
+ });
131
+ });
132
+
133
+ it("returns pollingIntervalMs from config pollingIntervalMinutes", async () => {
134
+ await VersionConfig.create({
135
+ pollingIntervalMinutes: 60,
136
+ webRequiredVersion: 0,
137
+ webWarningVersion: 0,
138
+ });
139
+
140
+ const res = await app.get("/version-check").query({platform: "web", version: 100});
141
+ expect(res.status).toBe(200);
142
+ expect(res.body.pollingIntervalMs).toBe(3600000);
143
+ });
144
+
145
+ it("returns default pollingIntervalMs (86400000) when pollingIntervalMinutes not set", async () => {
146
+ await VersionConfig.create({
147
+ webRequiredVersion: 0,
148
+ webWarningVersion: 0,
149
+ });
150
+
151
+ const res = await app.get("/version-check").query({platform: "web", version: 100});
152
+ expect(res.status).toBe(200);
153
+ expect(res.body.pollingIntervalMs).toBe(86400000);
120
154
  });
121
155
 
122
156
  it("version equal to required returns warning not required", async () => {
@@ -1,3 +1,4 @@
1
+ // biome-ignore-all lint/suspicious/noExplicitAny: test mock typing
1
2
  import {beforeEach, describe, expect, it} from "bun:test";
2
3
  import type express from "express";
3
4
  import supertest from "supertest";
@@ -0,0 +1,177 @@
1
+ import {afterEach, describe, expect, it} from "bun:test";
2
+ import express, {type NextFunction, type Request, type Response} from "express";
3
+ import supertest from "supertest";
4
+
5
+ import {asyncHandler} from "./api";
6
+ import {configureOpenApiValidator, resetOpenApiValidatorConfig} from "./openApiValidator";
7
+
8
+ afterEach(() => {
9
+ resetOpenApiValidatorConfig();
10
+ });
11
+
12
+ const createApp = (): express.Application => {
13
+ const app = express();
14
+ app.use(express.json());
15
+ return app;
16
+ };
17
+
18
+ const errorHandler = (
19
+ err: {status?: number; title?: string; message?: string},
20
+ _req: Request,
21
+ res: Response,
22
+ _next: NextFunction
23
+ ): void => {
24
+ res.status(err.status || 500).json({error: err.title || err.message});
25
+ };
26
+
27
+ describe("asyncHandler with bodySchema validation", () => {
28
+ it("validates and accepts a conforming body", async () => {
29
+ configureOpenApiValidator({});
30
+ const app = createApp();
31
+ app.post(
32
+ "/test",
33
+ asyncHandler(
34
+ async (_req: Request, res: Response) => {
35
+ res.json({ok: true});
36
+ },
37
+ {
38
+ bodySchema: {name: {required: true, type: "string"}},
39
+ validate: true,
40
+ }
41
+ )
42
+ );
43
+ app.use(errorHandler);
44
+
45
+ const res = await supertest(app).post("/test").send({name: "hello"}).expect(200);
46
+ expect(res.body.ok).toBe(true);
47
+ });
48
+
49
+ it("rejects a body missing a required field", async () => {
50
+ configureOpenApiValidator({});
51
+ const app = createApp();
52
+ app.post(
53
+ "/test",
54
+ asyncHandler(
55
+ async (_req: Request, res: Response) => {
56
+ res.json({ok: true});
57
+ },
58
+ {
59
+ bodySchema: {name: {required: true, type: "string"}},
60
+ validate: true,
61
+ }
62
+ )
63
+ );
64
+ app.use(errorHandler);
65
+
66
+ await supertest(app).post("/test").send({}).expect(400);
67
+ });
68
+
69
+ it("skips body validation when validate is false", async () => {
70
+ configureOpenApiValidator({});
71
+ const app = createApp();
72
+ app.post(
73
+ "/test",
74
+ asyncHandler(
75
+ async (_req: Request, res: Response) => {
76
+ res.json({ok: true});
77
+ },
78
+ {
79
+ bodySchema: {name: {required: true, type: "string"}},
80
+ validate: false,
81
+ }
82
+ )
83
+ );
84
+ app.use(errorHandler);
85
+
86
+ const res = await supertest(app).post("/test").send({}).expect(200);
87
+ expect(res.body.ok).toBe(true);
88
+ });
89
+ });
90
+
91
+ describe("asyncHandler with querySchema validation", () => {
92
+ it("validates and accepts conforming query params", async () => {
93
+ configureOpenApiValidator({});
94
+ const app = createApp();
95
+ app.get(
96
+ "/test",
97
+ asyncHandler(
98
+ async (_req: Request, res: Response) => {
99
+ res.json({ok: true});
100
+ },
101
+ {
102
+ querySchema: {page: {type: "integer"}},
103
+ validate: true,
104
+ }
105
+ )
106
+ );
107
+ app.use(errorHandler);
108
+
109
+ const res = await supertest(app).get("/test?page=1").expect(200);
110
+ expect(res.body.ok).toBe(true);
111
+ });
112
+
113
+ it("rejects invalid query params", async () => {
114
+ configureOpenApiValidator({});
115
+ const app = createApp();
116
+ app.get(
117
+ "/test",
118
+ asyncHandler(
119
+ async (_req: Request, res: Response) => {
120
+ res.json({ok: true});
121
+ },
122
+ {
123
+ querySchema: {page: {required: true, type: "integer"}},
124
+ validate: true,
125
+ }
126
+ )
127
+ );
128
+ app.use(errorHandler);
129
+
130
+ await supertest(app).get("/test").expect(400);
131
+ });
132
+ });
133
+
134
+ describe("asyncHandler with both schemas", () => {
135
+ it("runs both body and query validators sequentially", async () => {
136
+ configureOpenApiValidator({});
137
+ const app = createApp();
138
+ app.post(
139
+ "/test",
140
+ asyncHandler(
141
+ async (_req: Request, res: Response) => {
142
+ res.json({ok: true});
143
+ },
144
+ {
145
+ bodySchema: {name: {required: true, type: "string"}},
146
+ querySchema: {page: {type: "integer"}},
147
+ validate: true,
148
+ }
149
+ )
150
+ );
151
+ app.use(errorHandler);
152
+
153
+ const res = await supertest(app).post("/test?page=1").send({name: "hi"}).expect(200);
154
+ expect(res.body.ok).toBe(true);
155
+ });
156
+
157
+ it("forwards handler errors through next", async () => {
158
+ configureOpenApiValidator({});
159
+ const app = createApp();
160
+ app.post(
161
+ "/test",
162
+ asyncHandler(
163
+ async () => {
164
+ throw new Error("handler boom");
165
+ },
166
+ {
167
+ bodySchema: {name: {type: "string"}},
168
+ validate: true,
169
+ }
170
+ )
171
+ );
172
+ app.use(errorHandler);
173
+
174
+ const res = await supertest(app).post("/test").send({name: "ok"}).expect(500);
175
+ expect(res.body.error).toBe("handler boom");
176
+ });
177
+ });
@@ -1,3 +1,4 @@
1
+ // biome-ignore-all lint/suspicious/noExplicitAny: test mock typing
1
2
  import {describe, expect, it} from "bun:test";
2
3
  import mongoose from "mongoose";
3
4
 
@@ -1,3 +1,4 @@
1
+ // biome-ignore-all lint/suspicious/noExplicitAny: test mock typing
1
2
  import {beforeEach, describe, expect, it} from "bun:test";
2
3
  import type express from "express";
3
4
  import supertest from "supertest";
@@ -1,3 +1,4 @@
1
+ // biome-ignore-all lint/suspicious/noExplicitAny: test mock typing
1
2
  import {beforeEach, describe, expect, it} from "bun:test";
2
3
  import * as Sentry from "@sentry/bun";
3
4
  import type express from "express";
package/src/api.test.ts CHANGED
@@ -1,5 +1,9 @@
1
+ // biome-ignore-all lint/suspicious/noExplicitAny: test mock typing
2
+ // biome-ignore-all lint/suspicious/noImplicitAnyLet: test mock typing
1
3
  import {beforeEach, describe, expect, it} from "bun:test";
2
4
  import type express from "express";
5
+ import {DateTime} from "luxon";
6
+ import type mongoose from "mongoose";
3
7
  import supertest from "supertest";
4
8
  import type TestAgent from "supertest/lib/agent";
5
9
 
@@ -14,6 +18,7 @@ import {
14
18
  getBaseServer,
15
19
  RequiredModel,
16
20
  setupDb,
21
+ type User,
17
22
  UserModel,
18
23
  } from "./tests";
19
24
  import {AdminOwnerTransformer} from "./transformers";
@@ -1901,4 +1906,131 @@ describe("@terreno/api", () => {
1901
1906
  expect(res.body.title).toContain("preUpdate hook error");
1902
1907
  });
1903
1908
  });
1909
+
1910
+ describe("conflict detection (If-Unmodified-Since)", () => {
1911
+ let admin: mongoose.HydratedDocument<User>;
1912
+ let _notAdmin: mongoose.HydratedDocument<User>;
1913
+ let agent: TestAgent;
1914
+ let spinach: Food;
1915
+
1916
+ beforeEach(async () => {
1917
+ [admin, _notAdmin] = await setupDb();
1918
+
1919
+ spinach = await FoodModel.create({
1920
+ calories: 10,
1921
+ created: DateTime.fromISO("2025-06-15T12:00:00.000Z").toJSDate(),
1922
+ hidden: false,
1923
+ name: "Spinach",
1924
+ ownerId: admin._id,
1925
+ });
1926
+ await FoodModel.collection.updateOne(
1927
+ {_id: spinach._id as unknown as mongoose.Types.ObjectId},
1928
+ {$set: {updated: DateTime.fromISO("2025-06-15T12:00:00.000Z").toJSDate()}}
1929
+ );
1930
+
1931
+ app = getBaseServer();
1932
+ setupAuth(app, UserModel as unknown as Parameters<typeof setupAuth>[1]);
1933
+ addAuthRoutes(app, UserModel as unknown as Parameters<typeof addAuthRoutes>[1]);
1934
+ app.use(
1935
+ "/food",
1936
+ modelRouter(FoodModel, {
1937
+ permissions: {
1938
+ create: [Permissions.IsAny],
1939
+ delete: [Permissions.IsAny],
1940
+ list: [Permissions.IsAny],
1941
+ read: [Permissions.IsAny],
1942
+ update: [Permissions.IsAny],
1943
+ },
1944
+ })
1945
+ );
1946
+ server = supertest(app);
1947
+ agent = await authAsUser(app, "admin");
1948
+ });
1949
+
1950
+ it("returns 409 when If-Unmodified-Since is older than doc.updated", async () => {
1951
+ const staleTimestamp = DateTime.fromISO("2025-06-15T11:00:00.000Z").toHTTP();
1952
+
1953
+ const res = await agent
1954
+ .patch(`/food/${spinach._id}`)
1955
+ .set("If-Unmodified-Since", staleTimestamp)
1956
+ .send({name: "Should Fail"})
1957
+ .expect(409);
1958
+
1959
+ expect(res.body.error).toBe("Conflict");
1960
+ expect(res.body.message).toBe("Document was modified since your last read");
1961
+ expect(res.body.data).toBeDefined();
1962
+ // The response should contain the current server version
1963
+ expect(res.body.data.name).toBe("Spinach");
1964
+ });
1965
+
1966
+ it("succeeds when If-Unmodified-Since matches or is newer than doc.updated", async () => {
1967
+ const freshTimestamp = DateTime.fromISO("2025-06-15T13:00:00.000Z").toHTTP();
1968
+
1969
+ const res = await agent
1970
+ .patch(`/food/${spinach._id}`)
1971
+ .set("If-Unmodified-Since", freshTimestamp)
1972
+ .send({name: "Updated Spinach"})
1973
+ .expect(200);
1974
+
1975
+ expect(res.body.data.name).toBe("Updated Spinach");
1976
+ });
1977
+
1978
+ it("succeeds normally when If-Unmodified-Since header is not present", async () => {
1979
+ const res = await agent
1980
+ .patch(`/food/${spinach._id}`)
1981
+ .send({name: "No Header Update"})
1982
+ .expect(200);
1983
+
1984
+ expect(res.body.data.name).toBe("No Header Update");
1985
+ });
1986
+
1987
+ it("succeeds when If-Unmodified-Since exactly matches doc.updated", async () => {
1988
+ const exactTimestamp = DateTime.fromISO("2025-06-15T12:00:00.000Z").toHTTP();
1989
+
1990
+ const res = await agent
1991
+ .patch(`/food/${spinach._id}`)
1992
+ .set("If-Unmodified-Since", exactTimestamp)
1993
+ .send({name: "Exact Match"})
1994
+ .expect(200);
1995
+
1996
+ expect(res.body.data.name).toBe("Exact Match");
1997
+ });
1998
+
1999
+ it("prefers precise conflict timestamp header when present", async () => {
2000
+ const roundedStaleTimestamp = DateTime.fromISO("2025-06-15T11:59:59.000Z").toHTTP();
2001
+
2002
+ const res = await agent
2003
+ .patch(`/food/${spinach._id}`)
2004
+ .set("If-Unmodified-Since", roundedStaleTimestamp)
2005
+ .set("X-Unmodified-Since-ISO", "2025-06-15T12:00:00.750Z")
2006
+ .send({name: "Precise Match"})
2007
+ .expect(200);
2008
+
2009
+ expect(res.body.data.name).toBe("Precise Match");
2010
+ });
2011
+
2012
+ it("returns 409 when precise conflict timestamp is older than doc.updated", async () => {
2013
+ await agent
2014
+ .patch(`/food/${spinach._id}`)
2015
+ .set("If-Unmodified-Since", DateTime.fromISO("2025-06-15T12:00:01.000Z").toHTTP()!)
2016
+ .set("X-Unmodified-Since-ISO", "2025-06-15T11:59:59.500Z")
2017
+ .send({name: "Precise Stale"})
2018
+ .expect(409);
2019
+ });
2020
+
2021
+ it("falls back to doc.created when doc.updated is unavailable", async () => {
2022
+ await FoodModel.collection.updateOne(
2023
+ {_id: spinach._id as unknown as mongoose.Types.ObjectId},
2024
+ {$unset: {updated: ""}}
2025
+ );
2026
+
2027
+ const res = await agent
2028
+ .patch(`/food/${spinach._id}`)
2029
+ .set("If-Unmodified-Since", DateTime.fromISO("2025-06-15T11:59:59.999Z").toHTTP()!)
2030
+ .send({name: "Created Fallback"})
2031
+ .expect(409);
2032
+
2033
+ expect(res.body.data.name).toBe("Spinach");
2034
+ });
2035
+ });
1904
2036
  });