@reventlessdev/reventless-local 3.0.0-alpha.166 → 3.0.0-alpha.168
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.
- package/CHANGELOG.md +21 -0
- package/package.json +11 -11
- package/src/Platform.res +11 -1
- package/src/adapter/CommandGenerator/InboundTranslationResolvers_GraphQL.res +16 -8
- package/src/adapter/CommandGenerator/InboundTranslationResolvers_GraphQL.res.mjs +7 -7
- package/src/adapter/DomainGraphQL_Server.res +96 -0
- package/src/adapter/DomainGraphQL_Server.res.mjs +84 -0
- package/src/adapter/GraphQL_Server.res.mjs +18 -0
- package/src/adapter/LocalObjectStore.res +64 -0
- package/src/adapter/LocalObjectStore.res.mjs +55 -0
- package/src/adapter/PgProjectionCatchup.res +6 -1
- package/src/adapter/PgProjectionCatchup.res.mjs +4 -2
- package/src/adapter/ProjectionCheckpoint.res +11 -2
- package/src/adapter/ProjectionCheckpoint.res.mjs +9 -3
- package/tests/PluginEventDecodeTest.res +2 -0
- package/tests/PluginEventDecodeTest.res.mjs +2 -2
- package/tests/adapter/GraphQL_SchemaInspectorTest.res +6 -0
- package/tests/adapter/GraphQL_SchemaInspectorTest.res.mjs +6 -0
- package/tests/adapter/InboundTranslationMutationTest.res +139 -0
- package/tests/adapter/InboundTranslationMutationTest.res.mjs +132 -0
- package/tests/adapter/QueryDbListResolverTest.res +2 -0
- package/tests/adapter/QueryDbListResolverTest.res.mjs +2 -0
- package/tests/adapter/ServedBucketHttpTest.res +174 -0
- package/tests/adapter/ServedBucketHttpTest.res.mjs +177 -0
- package/tests/components/inboundtranslationslice/InboundTranslationSliceCallbackTest.res +13 -9
- package/tests/components/inboundtranslationslice/InboundTranslationSliceCallbackTest.res.mjs +21 -13
- package/tests/components/outboundtranslationslice/OutboundTranslationSlicePlatformFixtures.res +215 -0
- package/tests/components/outboundtranslationslice/OutboundTranslationSlicePlatformFixtures.res.mjs +273 -0
- package/tests/components/outboundtranslationslice/OutboundTranslationSlicePlatformTest.res +38 -0
- package/tests/components/outboundtranslationslice/OutboundTranslationSlicePlatformTest.res.mjs +28 -0
- package/tests/components/stateviewslice/StateViewSliceFixtures.res +1 -1
- package/tests/components/stateviewslice/StateViewSliceFixtures.res.mjs +2 -1
- package/tests/components/stateviewslice/StateViewSliceSubIdFixtures.res +1 -1
- package/tests/components/stateviewslice/StateViewSliceSubIdFixtures.res.mjs +2 -1
|
@@ -77,7 +77,7 @@ async function runCatchup(pool, bounds, handlers) {
|
|
|
77
77
|
}
|
|
78
78
|
}
|
|
79
79
|
}
|
|
80
|
-
let dcbRows = await PgDriver$ReventlessPostgres.query(pool, "SELECT log_name, event_type, data, meta, tags FROM dcb_event WHERE position <= $1::bigint ORDER BY position ASC", [bounds.dcbBound]);
|
|
80
|
+
let dcbRows = await PgDriver$ReventlessPostgres.query(pool, "SELECT log_name, event_type, data, meta, tags, recorded_at FROM dcb_event WHERE position <= $1::bigint ORDER BY position ASC", [bounds.dcbBound]);
|
|
81
81
|
for (let i$1 = 0, i_finish$1 = dcbRows.length; i$1 < i_finish$1; ++i$1) {
|
|
82
82
|
let row$1 = dcbRows[i$1];
|
|
83
83
|
let match = row$1["log_name"];
|
|
@@ -85,7 +85,9 @@ async function runCatchup(pool, bounds, handlers) {
|
|
|
85
85
|
let match$2 = row$1["data"];
|
|
86
86
|
let match$3 = row$1["meta"];
|
|
87
87
|
if (typeof match === "string" && typeof match$1 === "string" && match$2 !== undefined && match$3 !== undefined) {
|
|
88
|
-
let
|
|
88
|
+
let match$4 = row$1["recorded_at"];
|
|
89
|
+
let recordedAt = typeof match$4 === "string" ? match$4 : "";
|
|
90
|
+
let envelope$1 = ProjectionCheckpoint$ReventlessLocal.dcbCatchupEnvelope(match, match$1, JSON.stringify(match$2), JSON.stringify(match$3), recordedAt, firstTagValue(row$1));
|
|
89
91
|
if (envelope$1 !== undefined) {
|
|
90
92
|
await deliver(handlers, envelope$1);
|
|
91
93
|
}
|
|
@@ -180,16 +180,21 @@ let dcbCatchupEnvelope = (
|
|
|
180
180
|
~eventType: string,
|
|
181
181
|
~dataText: string,
|
|
182
182
|
~metaText: string,
|
|
183
|
+
~recordedAt: string,
|
|
183
184
|
~firstTagValue: option<string>,
|
|
184
185
|
): option<JSON.t> =>
|
|
185
186
|
switch (JSON.parseOrThrow(dataText), JSON.parseOrThrow(metaText)) {
|
|
186
187
|
| (data, meta) =>
|
|
187
188
|
let dataDict = data->JSON.Decode.object->Option.getOr(Dict.make())
|
|
188
189
|
let entityId = firstTagValue->Option.getOr(logName)
|
|
190
|
+
// The stored `recorded_at` column is the authoritative storage time; unlike
|
|
191
|
+
// live publish we must NOT re-stamp here (catch-up runs at startup, long
|
|
192
|
+
// after append). StateViewSlice projections read this as `consumed.recordedAt`.
|
|
189
193
|
Some(
|
|
190
194
|
Dict.fromArray([
|
|
191
195
|
("id", JSON.Encode.string(entityId)),
|
|
192
196
|
("meta", meta),
|
|
197
|
+
("recordedAt", JSON.Encode.string(recordedAt)),
|
|
193
198
|
("event", Message.combineMessage(eventType, dataDict)),
|
|
194
199
|
])->JSON.Encode.object,
|
|
195
200
|
)
|
|
@@ -226,7 +231,7 @@ let missedRows = (
|
|
|
226
231
|
// dcb_tag rows are inserted in tag order, so MIN(rowid) is the first tag.
|
|
227
232
|
db
|
|
228
233
|
->SqliteDriver.prepare(
|
|
229
|
-
"SELECT rowid AS pos, log_name, event_type, data, meta, (SELECT t.tag_value FROM dcb_tag t WHERE t.log_name = dcb_event.log_name AND t.position = dcb_event.position ORDER BY t.rowid ASC LIMIT 1) AS first_tag FROM dcb_event WHERE rowid > ? AND rowid <= ? ORDER BY rowid ASC",
|
|
234
|
+
"SELECT rowid AS pos, log_name, event_type, data, meta, recorded_at, (SELECT t.tag_value FROM dcb_tag t WHERE t.log_name = dcb_event.log_name AND t.position = dcb_event.position ORDER BY t.rowid ASC LIMIT 1) AS first_tag FROM dcb_event WHERE rowid > ? AND rowid <= ? ORDER BY rowid ASC",
|
|
230
235
|
)
|
|
231
236
|
->SqliteDriver.all([JSON.Encode.int(afterPos), JSON.Encode.int(upTo)])
|
|
232
237
|
->Array.filterMap(row =>
|
|
@@ -248,9 +253,13 @@ let missedRows = (
|
|
|
248
253
|
| Some(JSON.String(v)) => Some(v)
|
|
249
254
|
| _ => None
|
|
250
255
|
}
|
|
256
|
+
let recordedAt = switch row->Dict.get("recorded_at") {
|
|
257
|
+
| Some(JSON.String(v)) => v
|
|
258
|
+
| _ => ""
|
|
259
|
+
}
|
|
251
260
|
Some((
|
|
252
261
|
Float.toInt(pos),
|
|
253
|
-
dcbCatchupEnvelope(~logName, ~eventType, ~dataText, ~metaText, ~firstTagValue),
|
|
262
|
+
dcbCatchupEnvelope(~logName, ~eventType, ~dataText, ~metaText, ~recordedAt, ~firstTagValue),
|
|
254
263
|
))
|
|
255
264
|
| _ => None
|
|
256
265
|
}
|
|
@@ -129,7 +129,7 @@ function catchupEnvelope(flat) {
|
|
|
129
129
|
}
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
-
function dcbCatchupEnvelope(logName, eventType, dataText, metaText, firstTagValue) {
|
|
132
|
+
function dcbCatchupEnvelope(logName, eventType, dataText, metaText, recordedAt, firstTagValue) {
|
|
133
133
|
let val;
|
|
134
134
|
let val$1;
|
|
135
135
|
try {
|
|
@@ -149,6 +149,10 @@ function dcbCatchupEnvelope(logName, eventType, dataText, metaText, firstTagValu
|
|
|
149
149
|
"meta",
|
|
150
150
|
val$1
|
|
151
151
|
],
|
|
152
|
+
[
|
|
153
|
+
"recordedAt",
|
|
154
|
+
recordedAt
|
|
155
|
+
],
|
|
152
156
|
[
|
|
153
157
|
"event",
|
|
154
158
|
Message$ReventlessCore.combineMessage(eventType, dataDict)
|
|
@@ -191,7 +195,7 @@ function missedRows(db, axis, afterPos, upTo) {
|
|
|
191
195
|
];
|
|
192
196
|
});
|
|
193
197
|
} else {
|
|
194
|
-
return Stdlib_Array.filterMap(SqliteDriver$ReventlessLocal.all(SqliteDriver$ReventlessLocal.prepare(db, "SELECT rowid AS pos, log_name, event_type, data, meta, (SELECT t.tag_value FROM dcb_tag t WHERE t.log_name = dcb_event.log_name AND t.position = dcb_event.position ORDER BY t.rowid ASC LIMIT 1) AS first_tag FROM dcb_event WHERE rowid > ? AND rowid <= ? ORDER BY rowid ASC"), [
|
|
198
|
+
return Stdlib_Array.filterMap(SqliteDriver$ReventlessLocal.all(SqliteDriver$ReventlessLocal.prepare(db, "SELECT rowid AS pos, log_name, event_type, data, meta, recorded_at, (SELECT t.tag_value FROM dcb_tag t WHERE t.log_name = dcb_event.log_name AND t.position = dcb_event.position ORDER BY t.rowid ASC LIMIT 1) AS first_tag FROM dcb_event WHERE rowid > ? AND rowid <= ? ORDER BY rowid ASC"), [
|
|
195
199
|
afterPos,
|
|
196
200
|
upTo
|
|
197
201
|
]), row => {
|
|
@@ -232,9 +236,11 @@ function missedRows(db, axis, afterPos, upTo) {
|
|
|
232
236
|
}
|
|
233
237
|
let match$5 = row["first_tag"];
|
|
234
238
|
let firstTagValue = typeof match$5 === "string" ? match$5 : undefined;
|
|
239
|
+
let match$6 = row["recorded_at"];
|
|
240
|
+
let recordedAt = typeof match$6 === "string" ? match$6 : "";
|
|
235
241
|
return [
|
|
236
242
|
match | 0,
|
|
237
|
-
dcbCatchupEnvelope(match$1, match$2, match$3, match$4, firstTagValue)
|
|
243
|
+
dcbCatchupEnvelope(match$1, match$2, match$3, match$4, recordedAt, firstTagValue)
|
|
238
244
|
];
|
|
239
245
|
});
|
|
240
246
|
}
|
|
@@ -15,6 +15,7 @@ let envelopeOf = (event: ReventlessCore.PluginSpec.event) =>
|
|
|
15
15
|
ReventlessCore.Message.composeEventJson'(
|
|
16
16
|
"Catalog",
|
|
17
17
|
meta,
|
|
18
|
+
~recordedAt="2024-01-01T00:00:00Z",
|
|
18
19
|
event->S.reverseConvertToJsonOrThrow(ReventlessCore.PluginSpec.eventSchema),
|
|
19
20
|
)
|
|
20
21
|
|
|
@@ -68,6 +69,7 @@ describe("Platform.decodeUiFragmentRegistryEventEnvelope", () => {
|
|
|
68
69
|
ReventlessCore.Message.composeEventJson'(
|
|
69
70
|
"Catalog",
|
|
70
71
|
dcbMeta,
|
|
72
|
+
~recordedAt="2024-01-01T00:00:00Z",
|
|
71
73
|
ReventlessCore.Message.combineMessage(eventType, data),
|
|
72
74
|
)
|
|
73
75
|
}
|
|
@@ -15,7 +15,7 @@ TestRunner$ReventlessLocal.setup();
|
|
|
15
15
|
let meta = Message$ReventlessCore.generateMeta(PluginSpec$ReventlessCore.name, undefined, undefined, undefined, undefined, undefined, undefined, undefined);
|
|
16
16
|
|
|
17
17
|
function envelopeOf(event) {
|
|
18
|
-
return Message$ReventlessCore.composeEventJson$p("Catalog", meta, S.reverseConvertToJsonOrThrow(event, PluginSpec$ReventlessCore.eventSchema));
|
|
18
|
+
return Message$ReventlessCore.composeEventJson$p("Catalog", meta, "2024-01-01T00:00:00Z", S.reverseConvertToJsonOrThrow(event, PluginSpec$ReventlessCore.eventSchema));
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
globalThis.describe("Platform.decodePluginEventEnvelope", () => {
|
|
@@ -64,7 +64,7 @@ globalThis.describe("Platform.decodeUiFragmentRegistryEventEnvelope", () => {
|
|
|
64
64
|
let dcbEnvelopeOf = event => {
|
|
65
65
|
let json = S.reverseConvertToJsonOrThrow(event, UiFragmentRegistry$ReventlessCore.eventSchema);
|
|
66
66
|
let match = Message$ReventlessCore.splitMessage(json);
|
|
67
|
-
return Message$ReventlessCore.composeEventJson$p("Catalog", dcbMeta, Message$ReventlessCore.combineMessage(match[0], match[1]));
|
|
67
|
+
return Message$ReventlessCore.composeEventJson$p("Catalog", dcbMeta, "2024-01-01T00:00:00Z", Message$ReventlessCore.combineMessage(match[0], match[1]));
|
|
68
68
|
};
|
|
69
69
|
globalThis.test("decodes a UiFragmentRegistered event from the published envelope", () => {
|
|
70
70
|
let event = {
|
|
@@ -84,6 +84,8 @@ let indexedStateSchemaWithAnnotations = indexedStateSchema->S.Metadata.set(
|
|
|
84
84
|
collapsed: [],
|
|
85
85
|
scan: [],
|
|
86
86
|
scanSort: [],
|
|
87
|
+
semantic: [],
|
|
88
|
+
metric: [],
|
|
87
89
|
status: None,
|
|
88
90
|
groupBy: None,
|
|
89
91
|
visibility: None,
|
|
@@ -111,6 +113,8 @@ let orderedStateSchemaWithAnnotations = orderedStateSchema->S.Metadata.set(
|
|
|
111
113
|
collapsed: [],
|
|
112
114
|
scan: [],
|
|
113
115
|
scanSort: [],
|
|
116
|
+
semantic: [],
|
|
117
|
+
metric: [],
|
|
114
118
|
status: None,
|
|
115
119
|
groupBy: None,
|
|
116
120
|
visibility: None,
|
|
@@ -139,6 +143,8 @@ let scanStateSchemaWithAnnotations = scanStateSchema->S.Metadata.set(
|
|
|
139
143
|
collapsed: [],
|
|
140
144
|
scan: ["status"],
|
|
141
145
|
scanSort: ["name"],
|
|
146
|
+
semantic: [],
|
|
147
|
+
metric: [],
|
|
142
148
|
status: None,
|
|
143
149
|
groupBy: None,
|
|
144
150
|
visibility: None,
|
|
@@ -95,6 +95,8 @@ let indexedStateSchemaWithAnnotations = S.Metadata.set(indexedStateSchema, State
|
|
|
95
95
|
collapsed: [],
|
|
96
96
|
scan: [],
|
|
97
97
|
scanSort: [],
|
|
98
|
+
semantic: [],
|
|
99
|
+
metric: [],
|
|
98
100
|
status: undefined,
|
|
99
101
|
groupBy: undefined,
|
|
100
102
|
visibility: undefined
|
|
@@ -119,6 +121,8 @@ let orderedStateSchemaWithAnnotations = S.Metadata.set(orderedStateSchema, State
|
|
|
119
121
|
collapsed: [],
|
|
120
122
|
scan: [],
|
|
121
123
|
scanSort: [],
|
|
124
|
+
semantic: [],
|
|
125
|
+
metric: [],
|
|
122
126
|
status: undefined,
|
|
123
127
|
groupBy: undefined,
|
|
124
128
|
visibility: undefined
|
|
@@ -144,6 +148,8 @@ let scanStateSchemaWithAnnotations = S.Metadata.set(scanStateSchema, StateAnnota
|
|
|
144
148
|
collapsed: [],
|
|
145
149
|
scan: ["status"],
|
|
146
150
|
scanSort: ["name"],
|
|
151
|
+
semantic: [],
|
|
152
|
+
metric: [],
|
|
147
153
|
status: undefined,
|
|
148
154
|
groupBy: undefined,
|
|
149
155
|
visibility: undefined
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// Response-envelope tests for InboundTranslationSlice mutations.
|
|
2
|
+
//
|
|
3
|
+
// The field is declared `CommandResult!`, an abstract type. A resolver invoked
|
|
4
|
+
// directly cannot show whether the value it returns actually resolves to a union
|
|
5
|
+
// member — only a real execution can, and until one existed every inbound
|
|
6
|
+
// mutation came back as `Abstract type "CommandResult" must resolve to an Object
|
|
7
|
+
// type at runtime` even though the translation had succeeded.
|
|
8
|
+
//
|
|
9
|
+
// So these tests compose the schema the way the running platform does
|
|
10
|
+
// (DomainGraphQL_Server.composeSchema) and execute a document against it, then
|
|
11
|
+
// assert on the FULL envelope — `errors` as well as `data`.
|
|
12
|
+
//
|
|
13
|
+
// The resolver is driven by a real InboundTranslationSlice_Callback, so the
|
|
14
|
+
// mutation exercises parse -> translate -> publish -> encode end to end.
|
|
15
|
+
|
|
16
|
+
@@warning("-44")
|
|
17
|
+
|
|
18
|
+
open JestGlobals
|
|
19
|
+
open InboundTranslationSliceFixtures
|
|
20
|
+
|
|
21
|
+
module PaymentWebhookTranslation = {
|
|
22
|
+
let translate = PaymentWebhookSpec.translate
|
|
23
|
+
let moduleUrl = PaymentWebhookSpec.moduleUrl
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
module Callback = ReventlessCore.InboundTranslationSlice_Callback.Make(
|
|
27
|
+
PaymentWebhookSpec,
|
|
28
|
+
PaymentWebhookTranslation,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
// Registered inside a plugin scope, not the platform one: a plugin bucket's
|
|
32
|
+
// document is validated standalone before the cross-plugin merge, so this also
|
|
33
|
+
// pins that the inbound path registers the CommandResult union itself rather
|
|
34
|
+
// than borrowing a registration from a sibling command handler.
|
|
35
|
+
let scope = "Payments"
|
|
36
|
+
let fieldName = "Payments_PaymentWebhook"
|
|
37
|
+
|
|
38
|
+
let published: ref<array<Reventless.Message.commandJson>> = ref([])
|
|
39
|
+
|
|
40
|
+
let publishJsons: ReventlessInfra.CommandTopic.publishJsons = async cmds =>
|
|
41
|
+
published.contents = published.contents->Array.concat(cmds)
|
|
42
|
+
|
|
43
|
+
let selection = `__typename
|
|
44
|
+
... on CommandAccepted { msgId entityId eventCount }
|
|
45
|
+
... on CommandRejected { msgId errorCode errorDetail }`
|
|
46
|
+
|
|
47
|
+
let runMutation = async (~status: string) => {
|
|
48
|
+
let source = `mutation {
|
|
49
|
+
r: ${fieldName}(paymentId: "pay-1", orderId: "ord-1", status: "${status}") { ${selection} }
|
|
50
|
+
}`
|
|
51
|
+
await GraphqlYoga.graphql({
|
|
52
|
+
"schema": DomainGraphQL_Server.composeSchema(),
|
|
53
|
+
"source": source,
|
|
54
|
+
"contextValue": JSON.Encode.null,
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
let errorMessages = (result: GraphqlYoga.executionResult): array<string> =>
|
|
59
|
+
result.errors
|
|
60
|
+
->Option.getOr([])
|
|
61
|
+
->Array.map(e => e->JsExn.message->Option.getOr("unknown error"))
|
|
62
|
+
|
|
63
|
+
let payload = (result: GraphqlYoga.executionResult): JSON.t =>
|
|
64
|
+
result.data
|
|
65
|
+
->Option.getOr(JSON.Encode.null)
|
|
66
|
+
->JSON.Decode.object
|
|
67
|
+
->Option.flatMap(d => d->Dict.get("r"))
|
|
68
|
+
->Option.getOr(JSON.Encode.null)
|
|
69
|
+
|
|
70
|
+
let str = (node: JSON.t, key: string): option<string> =>
|
|
71
|
+
node->JSON.Decode.object->Option.flatMap(d => d->Dict.get(key))->Option.flatMap(JSON.Decode.string)
|
|
72
|
+
|
|
73
|
+
let num = (node: JSON.t, key: string): option<float> =>
|
|
74
|
+
node->JSON.Decode.object->Option.flatMap(d => d->Dict.get(key))->Option.flatMap(JSON.Decode.float)
|
|
75
|
+
|
|
76
|
+
describe("InboundTranslationSlice mutation — response envelope", () => {
|
|
77
|
+
beforeEach(() => {
|
|
78
|
+
DomainGraphQL_Server.asInterface.reset()
|
|
79
|
+
published.contents = []
|
|
80
|
+
Callback.auditLog->Dict.keysToArray->Array.forEach(k => Callback.auditLog->Dict.delete(k))
|
|
81
|
+
|
|
82
|
+
DomainGraphQL_Server.setScope(scope)
|
|
83
|
+
InboundTranslationResolvers_GraphQL.register(
|
|
84
|
+
~fieldName,
|
|
85
|
+
~externalInputSchema=PaymentWebhookSpec.externalInputSchema->S.castToUnknown,
|
|
86
|
+
~server=DomainGraphQL_Server.asInterface,
|
|
87
|
+
)
|
|
88
|
+
DomainGraphQL_Server.resetScope()
|
|
89
|
+
|
|
90
|
+
InboundTranslationResolvers_GraphQL.bindReceive(
|
|
91
|
+
~fieldName,
|
|
92
|
+
~receive=inputJson => Callback.receive(publishJsons, inputJson),
|
|
93
|
+
)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
testPromise("a successful translation resolves as CommandAccepted, with no errors", async () => {
|
|
97
|
+
let result = await runMutation(~status="completed")
|
|
98
|
+
|
|
99
|
+
expect(errorMessages(result))->toEqual([])
|
|
100
|
+
|
|
101
|
+
let node = payload(result)
|
|
102
|
+
expect(node->str("__typename"))->toEqual(Some("CommandAccepted"))
|
|
103
|
+
expect(node->str("entityId"))->toEqual(Some("ord-1"))
|
|
104
|
+
expect(node->num("eventCount"))->toEqual(Some(1.0))
|
|
105
|
+
|
|
106
|
+
// msgId is the audit-row key, so the caller can look the request up.
|
|
107
|
+
let msgId = node->str("msgId")->Option.getOr("")
|
|
108
|
+
expect(Callback.auditLog->Dict.get(msgId)->Option.isSome)->toBe(true)
|
|
109
|
+
|
|
110
|
+
expect(published.contents->Array.length)->toBe(1)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
testPromise("a rejected translation resolves as CommandRejected, with no errors", async () => {
|
|
114
|
+
let result = await runMutation(~status="pending")
|
|
115
|
+
|
|
116
|
+
expect(errorMessages(result))->toEqual([])
|
|
117
|
+
|
|
118
|
+
let node = payload(result)
|
|
119
|
+
expect(node->str("__typename"))->toEqual(Some("CommandRejected"))
|
|
120
|
+
expect(node->str("errorCode"))->toEqual(Some("TranslationFailed"))
|
|
121
|
+
expect(node->str("errorDetail"))->toEqual(Some("Unknown payment status: pending"))
|
|
122
|
+
|
|
123
|
+
let msgId = node->str("msgId")->Option.getOr("")
|
|
124
|
+
switch Callback.auditLog->Dict.get(msgId) {
|
|
125
|
+
| Some(row) => expect(row.status)->toBe(ReventlessCore.InboundTranslationSlice_Callback.Failure)
|
|
126
|
+
| None => expect(true)->toBe(false)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
expect(published.contents->Array.length)->toBe(0)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
testSync("the mutation field is typed CommandResult!", () => {
|
|
133
|
+
let fieldLine =
|
|
134
|
+
DomainGraphQL_Server.asInterface.buildSdl()
|
|
135
|
+
->String.split("\n")
|
|
136
|
+
->Array.find(line => line->String.includes(fieldName))
|
|
137
|
+
expect(fieldLine->Option.map(l => l->String.endsWith(": CommandResult!")))->toEqual(Some(true))
|
|
138
|
+
})
|
|
139
|
+
})
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as Graphql from "graphql";
|
|
4
|
+
import * as Stdlib_Dict from "@rescript/runtime/lib/es6/Stdlib_Dict.js";
|
|
5
|
+
import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
|
|
6
|
+
import * as Stdlib_JsExn from "@rescript/runtime/lib/es6/Stdlib_JsExn.js";
|
|
7
|
+
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
8
|
+
import * as DomainGraphQL_Server$ReventlessLocal from "../../src/adapter/DomainGraphQL_Server.res.mjs";
|
|
9
|
+
import * as InboundTranslationSliceFixtures$ReventlessLocal from "../components/inboundtranslationslice/InboundTranslationSliceFixtures.res.mjs";
|
|
10
|
+
import * as InboundTranslationSlice_Callback$ReventlessCore from "@reventlessdev/reventless-core/src/components/InboundTranslationSlice/InboundTranslationSlice_Callback.res.mjs";
|
|
11
|
+
import * as InboundTranslationResolvers_GraphQL$ReventlessLocal from "../../src/adapter/CommandGenerator/InboundTranslationResolvers_GraphQL.res.mjs";
|
|
12
|
+
|
|
13
|
+
let translate = InboundTranslationSliceFixtures$ReventlessLocal.PaymentWebhookSpec.translate;
|
|
14
|
+
|
|
15
|
+
let moduleUrl = InboundTranslationSliceFixtures$ReventlessLocal.PaymentWebhookSpec.moduleUrl;
|
|
16
|
+
|
|
17
|
+
let PaymentWebhookTranslation = {
|
|
18
|
+
translate: translate,
|
|
19
|
+
moduleUrl: moduleUrl
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
let Callback = InboundTranslationSlice_Callback$ReventlessCore.Make({
|
|
23
|
+
name: InboundTranslationSliceFixtures$ReventlessLocal.PaymentWebhookSpec.name,
|
|
24
|
+
moduleUrl: InboundTranslationSliceFixtures$ReventlessLocal.PaymentWebhookSpec.moduleUrl,
|
|
25
|
+
externalInputSchema: InboundTranslationSliceFixtures$ReventlessLocal.PaymentWebhookSpec.externalInputSchema,
|
|
26
|
+
commandSchema: InboundTranslationSliceFixtures$ReventlessLocal.PaymentWebhookSpec.commandSchema,
|
|
27
|
+
targetName: InboundTranslationSliceFixtures$ReventlessLocal.PaymentWebhookSpec.targetName,
|
|
28
|
+
externalSystem: InboundTranslationSliceFixtures$ReventlessLocal.PaymentWebhookSpec.externalSystem,
|
|
29
|
+
commandAuthorization: InboundTranslationSliceFixtures$ReventlessLocal.PaymentWebhookSpec.commandAuthorization
|
|
30
|
+
})(PaymentWebhookTranslation);
|
|
31
|
+
|
|
32
|
+
let scope = "Payments";
|
|
33
|
+
|
|
34
|
+
let fieldName = "Payments_PaymentWebhook";
|
|
35
|
+
|
|
36
|
+
let published = {
|
|
37
|
+
contents: []
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
async function publishJsons(cmds) {
|
|
41
|
+
published.contents = published.contents.concat(cmds);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let selection = `__typename
|
|
45
|
+
... on CommandAccepted { msgId entityId eventCount }
|
|
46
|
+
... on CommandRejected { msgId errorCode errorDetail }`;
|
|
47
|
+
|
|
48
|
+
async function runMutation(status) {
|
|
49
|
+
let source = `mutation {
|
|
50
|
+
r: ` + fieldName + `(paymentId: "pay-1", orderId: "ord-1", status: "` + status + `") { ` + selection + ` }
|
|
51
|
+
}`;
|
|
52
|
+
return await Graphql.graphql({
|
|
53
|
+
schema: DomainGraphQL_Server$ReventlessLocal.composeSchema(),
|
|
54
|
+
source: source,
|
|
55
|
+
contextValue: null
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function errorMessages(result) {
|
|
60
|
+
return Stdlib_Option.getOr(result.errors, []).map(e => Stdlib_Option.getOr(Stdlib_JsExn.message(e), "unknown error"));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function payload(result) {
|
|
64
|
+
return Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(Stdlib_Option.getOr(result.data, null)), d => d["r"]), null);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function str(node, key) {
|
|
68
|
+
return Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(node), d => d[key]), Stdlib_JSON.Decode.string);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function num(node, key) {
|
|
72
|
+
return Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(node), d => d[key]), Stdlib_JSON.Decode.float);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
globalThis.describe("InboundTranslationSlice mutation — response envelope", () => {
|
|
76
|
+
globalThis.beforeEach(() => {
|
|
77
|
+
DomainGraphQL_Server$ReventlessLocal.asInterface.reset();
|
|
78
|
+
published.contents = [];
|
|
79
|
+
Object.keys(Callback.auditLog).forEach(k => Stdlib_Dict.$$delete(Callback.auditLog, k));
|
|
80
|
+
DomainGraphQL_Server$ReventlessLocal.setScope(scope);
|
|
81
|
+
InboundTranslationResolvers_GraphQL$ReventlessLocal.register(fieldName, InboundTranslationSliceFixtures$ReventlessLocal.PaymentWebhookSpec.externalInputSchema, DomainGraphQL_Server$ReventlessLocal.asInterface);
|
|
82
|
+
DomainGraphQL_Server$ReventlessLocal.resetScope();
|
|
83
|
+
InboundTranslationResolvers_GraphQL$ReventlessLocal.bindReceive(fieldName, inputJson => Callback.receive(publishJsons, inputJson));
|
|
84
|
+
});
|
|
85
|
+
globalThis.test("a successful translation resolves as CommandAccepted, with no errors", async () => {
|
|
86
|
+
let result = await runMutation("completed");
|
|
87
|
+
globalThis.expect(errorMessages(result)).toEqual([]);
|
|
88
|
+
let node = payload(result);
|
|
89
|
+
globalThis.expect(str(node, "__typename")).toEqual("CommandAccepted");
|
|
90
|
+
globalThis.expect(str(node, "entityId")).toEqual("ord-1");
|
|
91
|
+
globalThis.expect(num(node, "eventCount")).toEqual(1.0);
|
|
92
|
+
let msgId = Stdlib_Option.getOr(str(node, "msgId"), "");
|
|
93
|
+
globalThis.expect(Stdlib_Option.isSome(Callback.auditLog[msgId])).toBe(true);
|
|
94
|
+
globalThis.expect(published.contents.length).toBe(1);
|
|
95
|
+
});
|
|
96
|
+
globalThis.test("a rejected translation resolves as CommandRejected, with no errors", async () => {
|
|
97
|
+
let result = await runMutation("pending");
|
|
98
|
+
globalThis.expect(errorMessages(result)).toEqual([]);
|
|
99
|
+
let node = payload(result);
|
|
100
|
+
globalThis.expect(str(node, "__typename")).toEqual("CommandRejected");
|
|
101
|
+
globalThis.expect(str(node, "errorCode")).toEqual("TranslationFailed");
|
|
102
|
+
globalThis.expect(str(node, "errorDetail")).toEqual("Unknown payment status: pending");
|
|
103
|
+
let msgId = Stdlib_Option.getOr(str(node, "msgId"), "");
|
|
104
|
+
let row = Callback.auditLog[msgId];
|
|
105
|
+
if (row !== undefined) {
|
|
106
|
+
globalThis.expect(row.status).toBe("Failure");
|
|
107
|
+
} else {
|
|
108
|
+
globalThis.expect(true).toBe(false);
|
|
109
|
+
}
|
|
110
|
+
globalThis.expect(published.contents.length).toBe(0);
|
|
111
|
+
});
|
|
112
|
+
globalThis.test("the mutation field is typed CommandResult!", () => {
|
|
113
|
+
let fieldLine = DomainGraphQL_Server$ReventlessLocal.asInterface.buildSdl().split("\n").find(line => line.includes(fieldName));
|
|
114
|
+
globalThis.expect(Stdlib_Option.map(fieldLine, l => l.endsWith(": CommandResult!"))).toEqual(true);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
export {
|
|
119
|
+
PaymentWebhookTranslation,
|
|
120
|
+
Callback,
|
|
121
|
+
scope,
|
|
122
|
+
fieldName,
|
|
123
|
+
published,
|
|
124
|
+
publishJsons,
|
|
125
|
+
selection,
|
|
126
|
+
runMutation,
|
|
127
|
+
errorMessages,
|
|
128
|
+
payload,
|
|
129
|
+
str,
|
|
130
|
+
num,
|
|
131
|
+
}
|
|
132
|
+
/* Callback Not a pure module */
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// End-to-end test for the local served-bucket HTTP routes on DomainGraphQL_Server
|
|
2
|
+
// (the dev analogue of the AWS CloudFront read path). Boots a real server on a
|
|
3
|
+
// private port, then exercises the full upload → store → serve loop:
|
|
4
|
+
// POST /__inmemory/upload → {uploadUrl, storageRef}
|
|
5
|
+
// PUT /{prefix}/{key} → 200 (store)
|
|
6
|
+
// GET /{prefix}/{key} → the stored bytes + content-type
|
|
7
|
+
// Talks to the server over node:http (Jest 27's VM strips global fetch), same
|
|
8
|
+
// as LocalAuthHttpTest.
|
|
9
|
+
|
|
10
|
+
@@warning("-44")
|
|
11
|
+
|
|
12
|
+
open JestGlobals
|
|
13
|
+
|
|
14
|
+
let _ = TestRunner.setup()
|
|
15
|
+
|
|
16
|
+
// High ephemeral port, clear of dev-server defaults and the auth HTTP test.
|
|
17
|
+
let port = 49322
|
|
18
|
+
|
|
19
|
+
// ── Minimal node:http helpers ───────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
type httpRequestOptions = {
|
|
22
|
+
hostname: string,
|
|
23
|
+
port: int,
|
|
24
|
+
path: string,
|
|
25
|
+
method: string,
|
|
26
|
+
headers: dict<string>,
|
|
27
|
+
}
|
|
28
|
+
type httpReq
|
|
29
|
+
type httpRes
|
|
30
|
+
@module("node:http") external _request: (httpRequestOptions, httpRes => unit) => httpReq = "request"
|
|
31
|
+
@send external _reqWrite: (httpReq, string) => unit = "write"
|
|
32
|
+
@send external _reqEnd: (httpReq, @as(json`null`) _) => unit = "end"
|
|
33
|
+
@send external _reqOnError: (httpReq, @as("error") _, 'err => unit) => unit = "on"
|
|
34
|
+
@send external _resOnData: (httpRes, @as("data") _, 'chunk => unit) => unit = "on"
|
|
35
|
+
@send external _resOnEnd: (httpRes, @as("end") _, unit => unit) => unit = "on"
|
|
36
|
+
@send external _resSetEncoding: (httpRes, string) => unit = "setEncoding"
|
|
37
|
+
@get external _resStatusCode: httpRes => int = "statusCode"
|
|
38
|
+
@get external _resHeaders: httpRes => dict<string> = "headers"
|
|
39
|
+
|
|
40
|
+
let postJson = (path: string, body: dict<JSON.t>): promise<(int, JSON.t)> =>
|
|
41
|
+
Promise.make((resolve, reject) => {
|
|
42
|
+
let bodyStr = body->JSON.Encode.object->JSON.stringify
|
|
43
|
+
let req =
|
|
44
|
+
_request(
|
|
45
|
+
{
|
|
46
|
+
hostname: "localhost",
|
|
47
|
+
port,
|
|
48
|
+
path,
|
|
49
|
+
method: "POST",
|
|
50
|
+
headers: Dict.fromArray([
|
|
51
|
+
("content-type", "application/json"),
|
|
52
|
+
("content-length", bodyStr->String.length->Int.toString),
|
|
53
|
+
]),
|
|
54
|
+
},
|
|
55
|
+
res => {
|
|
56
|
+
let status = _resStatusCode(res)
|
|
57
|
+
let buf = ref("")
|
|
58
|
+
res->_resSetEncoding("utf8")
|
|
59
|
+
res->_resOnData(chunk => buf := buf.contents ++ Obj.magic(chunk))
|
|
60
|
+
res->_resOnEnd(() => {
|
|
61
|
+
let parsed = try buf.contents->JSON.parseOrThrow catch {
|
|
62
|
+
| _ => JSON.Encode.null
|
|
63
|
+
}
|
|
64
|
+
resolve((status, parsed))
|
|
65
|
+
})
|
|
66
|
+
},
|
|
67
|
+
)
|
|
68
|
+
req->_reqOnError(err => reject(Obj.magic(err)))
|
|
69
|
+
req->_reqWrite(bodyStr)
|
|
70
|
+
req->_reqEnd
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
// PUT a raw (ASCII) body to a served-object path. Content-length is byte-safe
|
|
74
|
+
// only for ASCII payloads, which the SVG fixture below is.
|
|
75
|
+
let putRaw = (path: string, body: string, ~contentType: string): promise<int> =>
|
|
76
|
+
Promise.make((resolve, reject) => {
|
|
77
|
+
let req =
|
|
78
|
+
_request(
|
|
79
|
+
{
|
|
80
|
+
hostname: "localhost",
|
|
81
|
+
port,
|
|
82
|
+
path,
|
|
83
|
+
method: "PUT",
|
|
84
|
+
headers: Dict.fromArray([
|
|
85
|
+
("content-type", contentType),
|
|
86
|
+
("content-length", body->String.length->Int.toString),
|
|
87
|
+
]),
|
|
88
|
+
},
|
|
89
|
+
res => {
|
|
90
|
+
let status = _resStatusCode(res)
|
|
91
|
+
res->_resOnData(_ => ())
|
|
92
|
+
res->_resOnEnd(() => resolve(status))
|
|
93
|
+
},
|
|
94
|
+
)
|
|
95
|
+
req->_reqOnError(err => reject(Obj.magic(err)))
|
|
96
|
+
req->_reqWrite(body)
|
|
97
|
+
req->_reqEnd
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
let getRaw = (path: string): promise<(int, string, string)> =>
|
|
101
|
+
Promise.make((resolve, reject) => {
|
|
102
|
+
let req =
|
|
103
|
+
_request(
|
|
104
|
+
{hostname: "localhost", port, path, method: "GET", headers: Dict.make()},
|
|
105
|
+
res => {
|
|
106
|
+
let status = _resStatusCode(res)
|
|
107
|
+
let contentType = _resHeaders(res)->Dict.get("content-type")->Option.getOr("")
|
|
108
|
+
let buf = ref("")
|
|
109
|
+
res->_resSetEncoding("utf8")
|
|
110
|
+
res->_resOnData(chunk => buf := buf.contents ++ Obj.magic(chunk))
|
|
111
|
+
res->_resOnEnd(() => resolve((status, buf.contents, contentType)))
|
|
112
|
+
},
|
|
113
|
+
)
|
|
114
|
+
req->_reqOnError(err => reject(Obj.magic(err)))
|
|
115
|
+
req->_reqEnd
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
let getString = (j: JSON.t, k: string): string =>
|
|
119
|
+
j->JSON.Decode.object->Option.flatMap(d => d->Dict.get(k))->Option.flatMap(JSON.Decode.string)->Option.getOr("")
|
|
120
|
+
|
|
121
|
+
// ── Lifecycle ────────────────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
beforeAllAsync(async () => {
|
|
124
|
+
DomainGraphQL_Server.reset()
|
|
125
|
+
DomainGraphQL_Server.start(~port, ())
|
|
126
|
+
await Promise.make((resolve, _) => setTimeout(() => resolve(), 50)->ignore)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
afterAll(() => {
|
|
130
|
+
DomainGraphQL_Server.stop()
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
// ── Tests ──────────────────────────────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
testPromise("POST /__inmemory/upload returns matching uploadUrl + storageRef under the prefix", async () => {
|
|
136
|
+
let (status, body) =
|
|
137
|
+
await postJson(
|
|
138
|
+
"/__inmemory/upload",
|
|
139
|
+
Dict.fromArray([
|
|
140
|
+
("fileName", JSON.Encode.string("logo.svg")),
|
|
141
|
+
("contentType", JSON.Encode.string("image/svg+xml")),
|
|
142
|
+
]),
|
|
143
|
+
)
|
|
144
|
+
expect(status)->toEqual(200)
|
|
145
|
+
let uploadUrl = getString(body, "uploadUrl")
|
|
146
|
+
let storageRef = getString(body, "storageRef")
|
|
147
|
+
// Same same-origin ref serves as both the PUT target and the stored value.
|
|
148
|
+
expect(uploadUrl)->toEqual(storageRef)
|
|
149
|
+
expect(storageRef->String.startsWith("/uploads/"))->toEqual(true)
|
|
150
|
+
expect(storageRef->String.endsWith("/logo.svg"))->toEqual(true)
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
testPromise("PUT then GET round-trips the bytes and content-type", async () => {
|
|
154
|
+
let (_, body) =
|
|
155
|
+
await postJson(
|
|
156
|
+
"/__inmemory/upload",
|
|
157
|
+
Dict.fromArray([("fileName", JSON.Encode.string("pixel.svg"))]),
|
|
158
|
+
)
|
|
159
|
+
let ref = getString(body, "storageRef")
|
|
160
|
+
let svg = "<svg xmlns='http://www.w3.org/2000/svg' width='1' height='1'></svg>"
|
|
161
|
+
|
|
162
|
+
let putStatus = await putRaw(ref, svg, ~contentType="image/svg+xml")
|
|
163
|
+
expect(putStatus)->toEqual(200)
|
|
164
|
+
|
|
165
|
+
let (getStatus, gotBody, gotContentType) = await getRaw(ref)
|
|
166
|
+
expect(getStatus)->toEqual(200)
|
|
167
|
+
expect(gotBody)->toEqual(svg)
|
|
168
|
+
expect(gotContentType)->toEqual("image/svg+xml")
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
testPromise("GET a missing served object returns 404", async () => {
|
|
172
|
+
let (status, _, _) = await getRaw("/uploads/missing/none.svg")
|
|
173
|
+
expect(status)->toEqual(404)
|
|
174
|
+
})
|