jinaga-server 3.7.3 → 3.7.5
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/.github/workflows/main.yml +1 -1
- package/.github/workflows/publish.yml +8 -6
- package/.github/workflows/upgrade-jinaga.yml +3 -3
- package/dist/http/router.js +188 -57
- package/dist/http/router.js.map +1 -1
- package/package.json +2 -2
- package/src/http/router.ts +218 -60
- package/test/http/errorStatusMappingSpec.ts +351 -0
- package/test/postgres/userGivenFeedSqlSpec.ts +115 -0
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
import express from "express";
|
|
2
|
+
import { AddressInfo } from "net";
|
|
3
|
+
import { Server } from "http";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
Authorization,
|
|
7
|
+
AuthorizationRules,
|
|
8
|
+
buildModel,
|
|
9
|
+
dehydrateFact,
|
|
10
|
+
DistributionRules,
|
|
11
|
+
FactEnvelope,
|
|
12
|
+
FactManager,
|
|
13
|
+
FactReference,
|
|
14
|
+
FeedCache,
|
|
15
|
+
Invalid,
|
|
16
|
+
MemoryStore,
|
|
17
|
+
NetworkNoOp,
|
|
18
|
+
ObservableSource,
|
|
19
|
+
PassThroughFork,
|
|
20
|
+
PredecessorNotResolvedError,
|
|
21
|
+
User
|
|
22
|
+
} from "jinaga";
|
|
23
|
+
|
|
24
|
+
import { AuthorizationKeystore, SubscriptionAuthorizer } from "../../src/authorization/authorization-keystore";
|
|
25
|
+
import { HttpRouter, RequestUser } from "../../src/http/router";
|
|
26
|
+
import { MemoryKeystore } from "../../src/memory/memory-keystore";
|
|
27
|
+
|
|
28
|
+
class Workspace {
|
|
29
|
+
static Type = "ErrorStatus.Workspace" as const;
|
|
30
|
+
public type = Workspace.Type;
|
|
31
|
+
constructor(public owner: User, public identifier: string) { }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
class Application {
|
|
35
|
+
static Type = "ErrorStatus.Application" as const;
|
|
36
|
+
public type = Application.Type;
|
|
37
|
+
constructor(public workspace: Workspace, public name: string) { }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const model = buildModel(b => b
|
|
41
|
+
.type(User)
|
|
42
|
+
.type(Workspace, w => w.predecessor("owner", User))
|
|
43
|
+
.type(Application, a => a.predecessor("workspace", Workspace))
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
const ownerIdentity = { provider: "mock", id: "owner" };
|
|
47
|
+
|
|
48
|
+
const requestUser: RequestUser = {
|
|
49
|
+
provider: ownerIdentity.provider,
|
|
50
|
+
id: ownerIdentity.id,
|
|
51
|
+
profile: {} as any
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
async function createHarness() {
|
|
55
|
+
const store = new MemoryStore();
|
|
56
|
+
const keystore = new MemoryKeystore();
|
|
57
|
+
const ownerFact = await keystore.getOrCreateUserFact(ownerIdentity);
|
|
58
|
+
const owner = new User(ownerFact.fields.publicKey);
|
|
59
|
+
|
|
60
|
+
const factManager = new FactManager(
|
|
61
|
+
new PassThroughFork(store),
|
|
62
|
+
new ObservableSource(store),
|
|
63
|
+
store,
|
|
64
|
+
new NetworkNoOp(),
|
|
65
|
+
[]
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
const authorizationRules = new AuthorizationRules(model)
|
|
69
|
+
.any(User.Type)
|
|
70
|
+
.any(Workspace.Type)
|
|
71
|
+
.type(Application, app => app.workspace.owner);
|
|
72
|
+
const authorization = new AuthorizationKeystore(
|
|
73
|
+
factManager, store, keystore, authorizationRules, new DistributionRules([]));
|
|
74
|
+
const router = new HttpRouter(factManager, authorization, new FeedCache(), "*");
|
|
75
|
+
|
|
76
|
+
return { store, authorization, router, owner };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// A batch holding a successor whose predecessors are in neither the batch nor
|
|
80
|
+
// storage. jinaga's AuthorizationEngine detects this in its topological sort
|
|
81
|
+
// and reports it as a plain Error.
|
|
82
|
+
function unclosedBatch(owner: User): FactEnvelope[] {
|
|
83
|
+
const records = dehydrateFact(new Application(new Workspace(owner, "workspace-1"), "application-1"));
|
|
84
|
+
return records
|
|
85
|
+
.filter(fact => fact.type === Application.Type)
|
|
86
|
+
.map(fact => ({ fact, signatures: [] }));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function graphSourceOf(envelopes: FactEnvelope[]) {
|
|
90
|
+
return {
|
|
91
|
+
read: async (onEnvelopes: (envelopes: FactEnvelope[]) => Promise<void>) => {
|
|
92
|
+
await onEnvelopes(envelopes);
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// A router whose only job is to reject the authorization call with a chosen
|
|
98
|
+
// error, so the translation can be exercised for a route whose input grammar
|
|
99
|
+
// cannot provoke the error on its own.
|
|
100
|
+
function createRouterWithFailingSave(error: Error): HttpRouter {
|
|
101
|
+
const store = new MemoryStore();
|
|
102
|
+
const factManager = new FactManager(
|
|
103
|
+
new PassThroughFork(store), new ObservableSource(store), store, new NetworkNoOp(), []);
|
|
104
|
+
const keystore = new MemoryKeystore();
|
|
105
|
+
const authorization = {
|
|
106
|
+
getOrCreateUserFact: (identity: any) => keystore.getOrCreateUserFact(identity),
|
|
107
|
+
save: () => Promise.reject(error)
|
|
108
|
+
} as unknown as Authorization & SubscriptionAuthorizer;
|
|
109
|
+
return new HttpRouter(factManager, authorization, new FeedCache(), "*");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function captureError(action: () => Promise<unknown>): Promise<any> {
|
|
113
|
+
try {
|
|
114
|
+
await action();
|
|
115
|
+
} catch (error) {
|
|
116
|
+
return error;
|
|
117
|
+
}
|
|
118
|
+
throw new Error("Expected the operation to fail, but it succeeded.");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Issue #182 findings 1 and 2. These drive the real AuthorizationEngine rather
|
|
122
|
+
// than a hand-written message, so the classification is pinned to what jinaga
|
|
123
|
+
// actually throws. That mattered concretely: the original version of this
|
|
124
|
+
// translation matched the engine's prose, and 6.11.3 reworded it one release
|
|
125
|
+
// later — these tests failed rather than letting the 400 regress to a 500 in
|
|
126
|
+
// production. The unresolved-predecessor path is now an instanceof check on
|
|
127
|
+
// the typed error jinaga.js#234 introduced.
|
|
128
|
+
describe("save authorization error translation", () => {
|
|
129
|
+
it("reports an unresolved predecessor from the real engine as a typed error", async () => {
|
|
130
|
+
const { authorization, owner } = await createHarness();
|
|
131
|
+
|
|
132
|
+
const error = await captureError(() =>
|
|
133
|
+
authorization.save(ownerIdentity, unclosedBatch(owner)));
|
|
134
|
+
|
|
135
|
+
// The untranslated baseline: still not an Invalid, so handleError alone
|
|
136
|
+
// would map it to 500 — but now it carries the structured detail the
|
|
137
|
+
// translation reads instead of parsing prose.
|
|
138
|
+
expect(error).toBeInstanceOf(PredecessorNotResolvedError);
|
|
139
|
+
expect(error).not.toBeInstanceOf(Invalid);
|
|
140
|
+
expect(error.missingPredecessors.map((r: FactReference) => r.type)).toContain(Workspace.Type);
|
|
141
|
+
expect(error.unresolvedFacts.map((r: FactReference) => r.type)).toContain(Application.Type);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("translates the unresolved predecessor to Invalid on /save", async () => {
|
|
145
|
+
const { router, owner } = await createHarness();
|
|
146
|
+
|
|
147
|
+
const error = await captureError(() =>
|
|
148
|
+
(router as any).save(requestUser, graphSourceOf(unclosedBatch(owner))));
|
|
149
|
+
|
|
150
|
+
expect(error).toBeInstanceOf(Invalid);
|
|
151
|
+
// Names the predecessor that is actually missing — the structured
|
|
152
|
+
// fields make this possible, where the prose match could only report
|
|
153
|
+
// the successors that failed to sort.
|
|
154
|
+
expect(error.message).toContain(Workspace.Type);
|
|
155
|
+
expect(error.message).toContain("was not found in storage and was not included in the request");
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("translates the unresolved predecessor to Invalid on /write", async () => {
|
|
159
|
+
// The declaration grammar /write accepts cannot express a dangling
|
|
160
|
+
// predecessor, so the engine error is captured from the reachable
|
|
161
|
+
// /save path and replayed here. The route still needs the translation:
|
|
162
|
+
// it makes the same authorization call, and this keeps both routes
|
|
163
|
+
// reading one upstream error type.
|
|
164
|
+
const { authorization, owner } = await createHarness();
|
|
165
|
+
const engineError = await captureError(() =>
|
|
166
|
+
authorization.save(ownerIdentity, unclosedBatch(owner)));
|
|
167
|
+
|
|
168
|
+
const router = createRouterWithFailingSave(engineError);
|
|
169
|
+
const declaration =
|
|
170
|
+
`let workspace: ${Workspace.Type} = { owner: me, identifier: "workspace-1" }\n` +
|
|
171
|
+
`let application: ${Application.Type} = { workspace: workspace, name: "application-1" }\n`;
|
|
172
|
+
|
|
173
|
+
const error = await captureError(() => (router as any).write(requestUser, declaration));
|
|
174
|
+
|
|
175
|
+
expect(error).toBeInstanceOf(Invalid);
|
|
176
|
+
expect(error.message).toContain("was not found in storage and was not included in the request");
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("still translates the authorization-rule shape fixed by issue #175", async () => {
|
|
180
|
+
const router = createRouterWithFailingSave(
|
|
181
|
+
new Error("The fact ErrorStatus.Workspace:abc123 is not defined."));
|
|
182
|
+
|
|
183
|
+
const error = await captureError(() =>
|
|
184
|
+
(router as any).save(requestUser, graphSourceOf([])));
|
|
185
|
+
|
|
186
|
+
expect(error).toBeInstanceOf(Invalid);
|
|
187
|
+
expect(error.message).toContain("ErrorStatus.Workspace:abc123 is required to authorize this save");
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// Issue #182 findings 3 and 4, exercised over real HTTP so the assertions are
|
|
192
|
+
// on the status line a client actually receives.
|
|
193
|
+
describe("HTTP status mapping", () => {
|
|
194
|
+
let server: Server;
|
|
195
|
+
let baseUrl: string;
|
|
196
|
+
|
|
197
|
+
async function listen(app: express.Express) {
|
|
198
|
+
server = app.listen(0);
|
|
199
|
+
await new Promise<void>(resolve => server.once("listening", () => resolve()));
|
|
200
|
+
const port = (server.address() as AddressInfo).port;
|
|
201
|
+
baseUrl = `http://127.0.0.1:${port}`;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
afterEach(async () => {
|
|
205
|
+
if (server) {
|
|
206
|
+
await new Promise<void>(resolve => server.close(() => resolve()));
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
describe("with no body parser registered", () => {
|
|
211
|
+
beforeEach(async () => {
|
|
212
|
+
const { router } = await createHarness();
|
|
213
|
+
const app = express();
|
|
214
|
+
app.use(router.handler);
|
|
215
|
+
await listen(app);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
// Each of these parses the body synchronously in the handler. Express
|
|
219
|
+
// used to hand the throw to its default error handler, reporting the
|
|
220
|
+
// client's malformed request as a 500.
|
|
221
|
+
it.each([
|
|
222
|
+
["/write", "text/plain"],
|
|
223
|
+
["/read", "text/plain"],
|
|
224
|
+
["/feeds", "text/plain"],
|
|
225
|
+
["/load", "application/json"]
|
|
226
|
+
])("answers 400 for %s when the body cannot be read", async (path, contentType) => {
|
|
227
|
+
const response = await fetch(`${baseUrl}${path}`, {
|
|
228
|
+
method: "POST",
|
|
229
|
+
headers: { "Content-Type": contentType },
|
|
230
|
+
body: contentType === "application/json" ? "{}" : "anything"
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
expect(response.status).toBe(400);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
// The text/plain-only routes name the parser the caller is missing,
|
|
237
|
+
// rather than falling back to parseString's generic content-type
|
|
238
|
+
// message.
|
|
239
|
+
it.each(["/write", "/read"])("names express.text() in the 400 body for %s", async (path) => {
|
|
240
|
+
const response = await fetch(`${baseUrl}${path}`, {
|
|
241
|
+
method: "POST",
|
|
242
|
+
headers: { "Content-Type": "text/plain" },
|
|
243
|
+
body: "anything"
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
expect(await response.text()).toContain("express.text()");
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// Saying what arrived tells the caller which end is misconfigured.
|
|
250
|
+
it("names the Content-Type that was received", async () => {
|
|
251
|
+
const response = await fetch(`${baseUrl}/write`, {
|
|
252
|
+
method: "POST",
|
|
253
|
+
headers: { "Content-Type": "application/json" },
|
|
254
|
+
body: "{}"
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
expect(await response.text()).toContain("Received Content-Type: application/json");
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("says so when no Content-Type was received", async () => {
|
|
261
|
+
const response = await fetch(`${baseUrl}/write`, {
|
|
262
|
+
method: "POST",
|
|
263
|
+
body: new Blob(["anything"])
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
expect(await response.text()).toContain("No Content-Type header was received");
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
// The echoed Content-Type is client-supplied, so it must not be able to
|
|
270
|
+
// carry markup into the body.
|
|
271
|
+
it("neutralizes markup in the echoed Content-Type", async () => {
|
|
272
|
+
const response = await fetch(`${baseUrl}/write`, {
|
|
273
|
+
method: "POST",
|
|
274
|
+
headers: { "Content-Type": "text/plain<script>alert(1)</script>" },
|
|
275
|
+
body: "anything"
|
|
276
|
+
});
|
|
277
|
+
const body = await response.text();
|
|
278
|
+
|
|
279
|
+
expect(response.status).toBe(400);
|
|
280
|
+
expect(body).not.toContain("<script>");
|
|
281
|
+
expect(body).toContain("<script>");
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("caps the length of the echoed Content-Type", async () => {
|
|
285
|
+
const response = await fetch(`${baseUrl}/write`, {
|
|
286
|
+
method: "POST",
|
|
287
|
+
headers: { "Content-Type": `text/plain;padding=${"x".repeat(500)}` },
|
|
288
|
+
body: "anything"
|
|
289
|
+
});
|
|
290
|
+
const body = await response.text();
|
|
291
|
+
|
|
292
|
+
expect(response.status).toBe(400);
|
|
293
|
+
expect(body).toContain("…");
|
|
294
|
+
expect(body.length).toBeLessThan(300);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("marks error responses nosniff", async () => {
|
|
298
|
+
const response = await fetch(`${baseUrl}/write`, {
|
|
299
|
+
method: "POST",
|
|
300
|
+
headers: { "Content-Type": "text/plain" },
|
|
301
|
+
body: "anything"
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
expect(response.status).toBe(400);
|
|
305
|
+
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it("answers 500 without echoing the internal error message", async () => {
|
|
310
|
+
const store = new MemoryStore();
|
|
311
|
+
const factManager = new FactManager(
|
|
312
|
+
new PassThroughFork(store), new ObservableSource(store), store, new NetworkNoOp(), []);
|
|
313
|
+
const secret = "connection to postgres://internal-host:5432 refused";
|
|
314
|
+
const failingAuthorization = {
|
|
315
|
+
load: () => Promise.reject(new Error(secret))
|
|
316
|
+
} as unknown as Authorization & SubscriptionAuthorizer;
|
|
317
|
+
const router = new HttpRouter(factManager, failingAuthorization, new FeedCache(), "*");
|
|
318
|
+
|
|
319
|
+
const app = express();
|
|
320
|
+
app.use(express.json());
|
|
321
|
+
app.use(router.handler);
|
|
322
|
+
await listen(app);
|
|
323
|
+
|
|
324
|
+
const response = await fetch(`${baseUrl}/load`, {
|
|
325
|
+
method: "POST",
|
|
326
|
+
headers: { "Content-Type": "application/json" },
|
|
327
|
+
body: JSON.stringify({ references: [] })
|
|
328
|
+
});
|
|
329
|
+
const body = await response.text();
|
|
330
|
+
|
|
331
|
+
expect(response.status).toBe(500);
|
|
332
|
+
expect(body).toBe("Internal server error");
|
|
333
|
+
expect(body).not.toContain("internal-host");
|
|
334
|
+
// A plain-text body labelled text/html invites a browser to render it.
|
|
335
|
+
expect(response.headers.get("content-type")).toContain("text/plain");
|
|
336
|
+
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it("answers 404 with a distinguishable body for a feed route miss", async () => {
|
|
340
|
+
const { router } = await createHarness();
|
|
341
|
+
const app = express();
|
|
342
|
+
app.use(router.handler);
|
|
343
|
+
await listen(app);
|
|
344
|
+
|
|
345
|
+
// A hash the feed cache has never seen resolves to FeedNotFound, which
|
|
346
|
+
// is a different 404 from the route miss below.
|
|
347
|
+
const cacheMiss = await fetch(`${baseUrl}/feeds/unknownhash`);
|
|
348
|
+
expect(cacheMiss.status).toBe(404);
|
|
349
|
+
expect(await cacheMiss.text()).toBe("feed_not_found");
|
|
350
|
+
});
|
|
351
|
+
});
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { buildFeeds, dehydrateReference, getAllFactTypes, getAllRoles, Specification, SpecificationParser } from "jinaga";
|
|
2
|
+
|
|
3
|
+
import { addFactType, addRole, emptyFactTypeMap, emptyRoleMap, FactTypeMap, getFactTypeId, RoleMap } from "../../src/postgres/maps";
|
|
4
|
+
import { sqlFromFeed } from "../../src/postgres/specification-sql";
|
|
5
|
+
|
|
6
|
+
// Regression coverage for jinaga-server#179.
|
|
7
|
+
//
|
|
8
|
+
// The issue named two candidate loci for a `Jinaga.User`-given feed returning
|
|
9
|
+
// empty despite matching data: the distribution decision (jinaga.js) and the
|
|
10
|
+
// Postgres feed SQL (this repo, `sqlFromFeed`). These tests pin down the
|
|
11
|
+
// second: the feed SQL generated for a specification whose given is typed
|
|
12
|
+
// `Jinaga.User` is structurally identical to the SQL for the same join shape
|
|
13
|
+
// against any other given type. There is no `Jinaga.User` special-casing in
|
|
14
|
+
// jinaga-server's query generation, so the empty-result trigger does not live
|
|
15
|
+
// here.
|
|
16
|
+
|
|
17
|
+
function parseSpecification(input: string): Specification {
|
|
18
|
+
const parser = new SpecificationParser(input);
|
|
19
|
+
parser.skipWhitespace();
|
|
20
|
+
return parser.parseSpecification();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function buildMaps(specification: Specification): { factTypes: FactTypeMap; roleMap: RoleMap } {
|
|
24
|
+
// Assign deterministic type ids in first-seen order so two specifications
|
|
25
|
+
// with the same shape but different type names produce identical parameter
|
|
26
|
+
// sequences. Filter the synthetic "Unknown" fact type and "unknown" role
|
|
27
|
+
// name (matching the other Postgres SQL generator specs) so they cannot
|
|
28
|
+
// shift the id assignment. Roles get ids offset well past the type ids.
|
|
29
|
+
const factTypes = getAllFactTypes(specification)
|
|
30
|
+
.filter(factType => factType !== 'Unknown')
|
|
31
|
+
.reduce((f, factType, i) => addFactType(f, factType, i + 1), emptyFactTypeMap());
|
|
32
|
+
const roleMap = getAllRoles(specification)
|
|
33
|
+
.filter(role => role.name !== 'unknown')
|
|
34
|
+
.reduce((r, role, i) => {
|
|
35
|
+
const factTypeId = getFactTypeId(factTypes, role.successorType);
|
|
36
|
+
if (!factTypeId) {
|
|
37
|
+
return r;
|
|
38
|
+
}
|
|
39
|
+
return addRole(r, factTypeId, role.name, i + 100);
|
|
40
|
+
}, emptyRoleMap());
|
|
41
|
+
return { factTypes, roleMap };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Pure: generates the feed SQL for a single-feed specification. All assertions
|
|
45
|
+
// live in the tests (or beforeAll) so a parse/build failure surfaces as a test
|
|
46
|
+
// failure rather than a suite-collection error.
|
|
47
|
+
function feedSqlFor(descriptiveString: string, startType: string) {
|
|
48
|
+
const specification = parseSpecification(descriptiveString);
|
|
49
|
+
const { factTypes, roleMap } = buildMaps(specification);
|
|
50
|
+
const feeds = buildFeeds(specification);
|
|
51
|
+
const start = [dehydrateReference({ type: startType, key: "value" })];
|
|
52
|
+
const query = sqlFromFeed(feeds[0], start, "public", "", 100, factTypes, roleMap);
|
|
53
|
+
return { query, feedCount: feeds.length, factTypes };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// The canonical "owned by me" shape: a successor edged to the given user
|
|
57
|
+
// through a role named `owner`.
|
|
58
|
+
const USER_GIVEN_SPEC = `
|
|
59
|
+
(user: Jinaga.User) {
|
|
60
|
+
activity: Networking.Activity [
|
|
61
|
+
activity->owner: Jinaga.User = user
|
|
62
|
+
]
|
|
63
|
+
}`;
|
|
64
|
+
|
|
65
|
+
// The identical join shape rooted at a non-user given.
|
|
66
|
+
const ACTIVITY_GIVEN_SPEC = `
|
|
67
|
+
(activity: Networking.Activity) {
|
|
68
|
+
log: Networking.ActivityCrmLog [
|
|
69
|
+
log->activity: Networking.Activity = activity
|
|
70
|
+
]
|
|
71
|
+
}`;
|
|
72
|
+
|
|
73
|
+
describe("Jinaga.User-given feed SQL (issue #179)", () => {
|
|
74
|
+
let userGiven: ReturnType<typeof feedSqlFor>;
|
|
75
|
+
let activityGiven: ReturnType<typeof feedSqlFor>;
|
|
76
|
+
|
|
77
|
+
beforeAll(() => {
|
|
78
|
+
userGiven = feedSqlFor(USER_GIVEN_SPEC, "Jinaga.User");
|
|
79
|
+
activityGiven = feedSqlFor(ACTIVITY_GIVEN_SPEC, "Networking.Activity");
|
|
80
|
+
// Both shapes must reduce to a single feed for the comparison to be
|
|
81
|
+
// apples-to-apples.
|
|
82
|
+
expect(userGiven.feedCount).toBe(1);
|
|
83
|
+
expect(activityGiven.feedCount).toBe(1);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("produces a satisfiable, single feed query for a Jinaga.User given", () => {
|
|
87
|
+
// A satisfiable query is the whole point: a null query here would be
|
|
88
|
+
// the silent-empty the issue describes. The given's type is loaded and
|
|
89
|
+
// the predecessor edge on `owner` is generated.
|
|
90
|
+
expect(userGiven.query).not.toBeNull();
|
|
91
|
+
expect(userGiven.query!.sql).toContain("f1.fact_type_id = $1 AND f1.hash = $2");
|
|
92
|
+
expect(userGiven.query!.sql).toContain("public.edge");
|
|
93
|
+
expect(userGiven.query!.labels.length).toBe(1);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("generates SQL structurally identical to a non-user given of the same shape", () => {
|
|
97
|
+
// No Jinaga.User special-casing: the two SQL strings match exactly.
|
|
98
|
+
expect(userGiven.query).not.toBeNull();
|
|
99
|
+
expect(activityGiven.query).not.toBeNull();
|
|
100
|
+
expect(userGiven.query!.sql).toEqual(activityGiven.query!.sql);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("binds the given type id (from the fact-type map) and hash as the leading parameters", () => {
|
|
104
|
+
// The given fact type participates in the WHERE clause like any other —
|
|
105
|
+
// it is not dropped or defaulted to a constant for Jinaga.User. Derive
|
|
106
|
+
// the expected id from the map built for this spec so the assertion is
|
|
107
|
+
// not coupled to getAllFactTypes ordering.
|
|
108
|
+
const userReference = dehydrateReference({ type: "Jinaga.User", key: "value" });
|
|
109
|
+
const expectedTypeId = getFactTypeId(userGiven.factTypes, "Jinaga.User");
|
|
110
|
+
expect(expectedTypeId).toBeDefined();
|
|
111
|
+
expect(userGiven.query).not.toBeNull();
|
|
112
|
+
expect(userGiven.query!.parameters[0]).toBe(expectedTypeId);
|
|
113
|
+
expect(userGiven.query!.parameters[1]).toBe(userReference.hash);
|
|
114
|
+
});
|
|
115
|
+
});
|