@startsimpli/integrations 0.2.18 → 0.2.20
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/package.json +1 -1
- package/src/__tests__/corpus-field-gate.test.ts +29 -0
- package/src/__tests__/corpus-question.test.ts +318 -0
- package/src/__tests__/corpus-scope.test.ts +12 -3
- package/src/__tests__/translation-corpus.test.ts +428 -0
- package/src/corpus/ask.ts +76 -8
- package/src/corpus/client.ts +3 -0
- package/src/corpus/fixtures.ts +153 -15
- package/src/corpus/index.ts +3 -1
- package/src/corpus/retrieval.ts +237 -17
- package/src/corpus/translation.ts +321 -0
- package/src/corpus/types.ts +8 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/integrations",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.20",
|
|
4
4
|
"description": "Provider-agnostic types, API clients, hooks, and UI primitives for email + calendar integrations across StartSimpli apps.",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
@@ -217,6 +217,35 @@ describe('askCorpus — the withheld text never reaches the model', () => {
|
|
|
217
217
|
expect(result.answer).not.toContain('relapsed');
|
|
218
218
|
});
|
|
219
219
|
|
|
220
|
+
it('does not retrieve on a withheld term carried in on a LOOSER query', async () => {
|
|
221
|
+
// The widening in bd startsim-768w.38.36 is the obvious way to reopen this.
|
|
222
|
+
// A question is no longer sent as a conjunction — it is sent as a
|
|
223
|
+
// DISJUNCTION of its content words — so "lease" is now enough to bring this
|
|
224
|
+
// record back on its own, and the withheld word rides along in the same
|
|
225
|
+
// query. If the ranker ever scored on what the SERVER matched rather than on
|
|
226
|
+
// what the reader may see, that record would arrive with the withheld term
|
|
227
|
+
// in its matched set and the model would be invited to answer about it.
|
|
228
|
+
//
|
|
229
|
+
// The fixture server here is the leaking one and searches the withheld
|
|
230
|
+
// `body` too, so the row genuinely comes back on this query. Everything
|
|
231
|
+
// after that has to hold on its own.
|
|
232
|
+
const { llm, seen } = spyLlm();
|
|
233
|
+
const result = await askCorpus(
|
|
234
|
+
{ question: 'was anything said about the lease or relapsed treatment?', mode: 'ask', scope: SCOPE },
|
|
235
|
+
{ corpus: apiFor([leakingEntity()]), llm },
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
// It DID retrieve — on `lease`, which is in the summary and always was.
|
|
239
|
+
expect(result.status).toBe('answered');
|
|
240
|
+
expect(seen[0]).toContain('lease');
|
|
241
|
+
// ...and the withheld word reached neither the prompt, the answer, nor the
|
|
242
|
+
// evidence the surface will show for it.
|
|
243
|
+
expect(seen[0]).not.toContain('relapsed');
|
|
244
|
+
expect(JSON.stringify(result.citations)).not.toContain('relapsed');
|
|
245
|
+
expect(JSON.stringify(result.considered)).not.toContain('relapsed');
|
|
246
|
+
expect(result.considered.flatMap((item) => item.matchedTerms)).not.toContain('relapsed');
|
|
247
|
+
});
|
|
248
|
+
|
|
220
249
|
it('answers identically whether the server leaked or behaved', async () => {
|
|
221
250
|
// The falsifiable version of "the client does not depend on the server
|
|
222
251
|
// getting it right": same question, one API stripping the field and one
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A QUESTION IS NOT A BOOLEAN AND OF ITS WORDS (bd startsim-768w.38.36).
|
|
3
|
+
*
|
|
4
|
+
* `corpus-ask.test.ts` next door protects the honesty rules — that an untraceable
|
|
5
|
+
* answer can never be rendered as a traceable one. This file protects the stage
|
|
6
|
+
* BEFORE any of that is reachable: whether the records that would answer the
|
|
7
|
+
* question are still in the candidate set by the time the ranker runs.
|
|
8
|
+
*
|
|
9
|
+
* WHAT WENT WRONG, MEASURED ON THE LIVE TANDEM TENANT. `loadCandidates` sent the
|
|
10
|
+
* WHOLE question as `?search=`. The tenant compiles that with
|
|
11
|
+
* `websearch_to_tsquery('english', …)`, which joins bare words with `&` — so
|
|
12
|
+
* "What did we agree about the parking allocation?" only retrieved a document
|
|
13
|
+
* containing `agree` AND `parking` AND `allocation`, and 18 documents and 63,208
|
|
14
|
+
* characters of summary were discarded before ranking ever happened. A single
|
|
15
|
+
* rare word still answered, which is what made it look like a corpus problem;
|
|
16
|
+
* the surface said so out loud, blaming the connector for a query bug.
|
|
17
|
+
*
|
|
18
|
+
* SO THE FIXTURE HERE RUNS THE TENANT'S OWN SEARCH (`searchBackend:
|
|
19
|
+
* 'postgres_fulltext'`). Against the permissive fixture this package shipped
|
|
20
|
+
* with — whitespace-split substrings, OR'd — every case below passes on the
|
|
21
|
+
* broken client. A fixture easier to satisfy than production does not fail safe.
|
|
22
|
+
*
|
|
23
|
+
* THE TWO FAILURES ARE OPPOSITE, and both are here on purpose:
|
|
24
|
+
*
|
|
25
|
+
* TOO NARROW is the bug: an ordinary question retrieves nothing.
|
|
26
|
+
* TOO WIDE is the tempting fix: match everything, put the whole corpus in the
|
|
27
|
+
* prompt and let the model sort it out. That trades a visible failure for an
|
|
28
|
+
* invisible one — a confident answer assembled from whatever happened to be
|
|
29
|
+
* recent. So a rare single term must still retrieve exactly its own record.
|
|
30
|
+
*/
|
|
31
|
+
import { describe, expect, it } from 'vitest';
|
|
32
|
+
|
|
33
|
+
import { askCorpus } from '../corpus/ask';
|
|
34
|
+
import { createCorpusApi } from '../corpus/client';
|
|
35
|
+
import type { TenantEntity } from '../corpus/entities';
|
|
36
|
+
import { createFixtureCorpusHttp, createFixtureLlm, FIXTURE_ORG } from '../corpus/fixtures';
|
|
37
|
+
import { searchTermFor } from '../corpus/retrieval';
|
|
38
|
+
import type { AskResult, CorpusHttp, CorpusScope } from '../corpus/types';
|
|
39
|
+
|
|
40
|
+
const SCOPE: CorpusScope = { orgId: FIXTURE_ORG };
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A corpus shaped like the real one: meeting notes whose whole substance is a
|
|
44
|
+
* long `summary`, written the way a note-taker writes rather than the way a
|
|
45
|
+
* query is typed. No document contains every word of any question asked below,
|
|
46
|
+
* which is the entire point.
|
|
47
|
+
*/
|
|
48
|
+
function meeting(
|
|
49
|
+
id: string,
|
|
50
|
+
title: string,
|
|
51
|
+
occurredAt: string,
|
|
52
|
+
summary: string,
|
|
53
|
+
): TenantEntity {
|
|
54
|
+
return {
|
|
55
|
+
id,
|
|
56
|
+
entity_type: 'source_document',
|
|
57
|
+
external_id: `granola:${id}`,
|
|
58
|
+
name: title,
|
|
59
|
+
org_id: FIXTURE_ORG,
|
|
60
|
+
created_at: `${occurredAt.slice(0, 10)}T12:00:00Z`,
|
|
61
|
+
data: { source: 'granola', subtype: 'meeting', title, summary, occurred_at: occurredAt },
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const CORPUS: TenantEntity[] = [
|
|
66
|
+
meeting(
|
|
67
|
+
'doc_office',
|
|
68
|
+
'Office move — landlord terms and fit-out',
|
|
69
|
+
'2026-07-28T09:00:00Z',
|
|
70
|
+
[
|
|
71
|
+
'Landlord came back on the renewal. We agreed to a twelve month extension on the',
|
|
72
|
+
'lease at the current rate, with a three month break clause either side.',
|
|
73
|
+
'Fit-out costs are split, capped at 20k. Signature target is the 15th.',
|
|
74
|
+
].join(' '),
|
|
75
|
+
),
|
|
76
|
+
meeting(
|
|
77
|
+
'doc_site',
|
|
78
|
+
'Site walkthrough — Lamar building',
|
|
79
|
+
'2026-07-21T09:00:00Z',
|
|
80
|
+
[
|
|
81
|
+
'Walked the Lamar building with the broker. Six parking bays come with the unit;',
|
|
82
|
+
'two are currently used by the ground floor tenant and nobody has written down',
|
|
83
|
+
'who gets what. Broker to send the site plan so we can settle it before signing.',
|
|
84
|
+
].join(' '),
|
|
85
|
+
),
|
|
86
|
+
meeting(
|
|
87
|
+
'doc_july',
|
|
88
|
+
'July financials, tenders strategy, staffing update',
|
|
89
|
+
'2026-07-14T09:00:00Z',
|
|
90
|
+
[
|
|
91
|
+
'Collections are ahead of plan. On the patent dispute, a filing action tying up',
|
|
92
|
+
'the IP could block the funding round, so we hold the interest notice until Jeff',
|
|
93
|
+
'has reviewed it. Staffing: two offers out, one accepted.',
|
|
94
|
+
].join(' '),
|
|
95
|
+
),
|
|
96
|
+
meeting(
|
|
97
|
+
'doc_insurance',
|
|
98
|
+
'Property finances — loan renewals and insurance',
|
|
99
|
+
'2026-06-30T09:00:00Z',
|
|
100
|
+
[
|
|
101
|
+
'The insurance renewal is with the broker and we are waiting on their revised',
|
|
102
|
+
'quote before we can sign anything. Loan renewals for two of the properties are',
|
|
103
|
+
'due in September; the bank wants updated valuations first.',
|
|
104
|
+
].join(' '),
|
|
105
|
+
),
|
|
106
|
+
meeting(
|
|
107
|
+
'doc_rent',
|
|
108
|
+
'Rent Manager Express setup and Zillow posting',
|
|
109
|
+
'2026-06-16T09:00:00Z',
|
|
110
|
+
[
|
|
111
|
+
'Rent Manager Express is configured for the two smaller properties. Zillow',
|
|
112
|
+
'postings go up Monday. The QUIBBLETRON migration script is still the only',
|
|
113
|
+
'blocker on moving the rest of the portfolio across.',
|
|
114
|
+
].join(' '),
|
|
115
|
+
),
|
|
116
|
+
meeting(
|
|
117
|
+
'doc_mcr',
|
|
118
|
+
'MCR platform demo — streaming data and task automation',
|
|
119
|
+
'2026-05-19T09:00:00Z',
|
|
120
|
+
[
|
|
121
|
+
'Demoed the streaming ingest. Fuga integration is behind a vendor key we do not',
|
|
122
|
+
'have yet. Task automation from Granola notes is the piece they cared about.',
|
|
123
|
+
].join(' '),
|
|
124
|
+
),
|
|
125
|
+
];
|
|
126
|
+
|
|
127
|
+
function corpusFor(entities: TenantEntity[] = CORPUS) {
|
|
128
|
+
const { http, requests } = createFixtureCorpusHttp({
|
|
129
|
+
entities,
|
|
130
|
+
relationships: [],
|
|
131
|
+
searchBackend: 'postgres_fulltext',
|
|
132
|
+
});
|
|
133
|
+
return {
|
|
134
|
+
requests,
|
|
135
|
+
api: createCorpusApi({ http: http as CorpusHttp, scope: SCOPE, supportedFilters: [] }),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function askWith(question: string, entities?: TenantEntity[]): Promise<AskResult> {
|
|
140
|
+
return askCorpus(
|
|
141
|
+
{ question, mode: 'ask', scope: SCOPE },
|
|
142
|
+
{ corpus: corpusFor(entities).api, llm: createFixtureLlm() },
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const citedIds = (result: AskResult) => result.citations.map((citation) => citation.documentId);
|
|
147
|
+
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
describe('an ordinary question retrieves', () => {
|
|
151
|
+
/**
|
|
152
|
+
* The three the user reproduced on prod, plus the phrasings people actually
|
|
153
|
+
* type. Every one of these returned `no_evidence` with zero citations against
|
|
154
|
+
* a corpus that answers them.
|
|
155
|
+
*/
|
|
156
|
+
const cases: Array<[question: string, expected: string]> = [
|
|
157
|
+
['What did we agree about the lease?', 'doc_office'],
|
|
158
|
+
['what is blocked?', 'doc_july'],
|
|
159
|
+
['What did we agree about the parking allocation?', 'doc_site'],
|
|
160
|
+
['who are we waiting on for the insurance renewal?', 'doc_insurance'],
|
|
161
|
+
['what did we decide about staffing?', 'doc_july'],
|
|
162
|
+
['when are the loan renewals due?', 'doc_insurance'],
|
|
163
|
+
['did we sort out the parking bays', 'doc_site'],
|
|
164
|
+
['whats the story with the landlord', 'doc_office'],
|
|
165
|
+
];
|
|
166
|
+
|
|
167
|
+
for (const [question, expected] of cases) {
|
|
168
|
+
it(`answers "${question}" and cites ${expected}`, async () => {
|
|
169
|
+
const result = await askWith(question);
|
|
170
|
+
expect(result.status).toBe('answered');
|
|
171
|
+
expect(result.citations.length).toBeGreaterThan(0);
|
|
172
|
+
expect(citedIds(result)).toContain(expected);
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
it('retrieves a question whose words are stemmed apart from the record’s', async () => {
|
|
177
|
+
// The tenant's `english` configuration folds `blocked` and `block` to one
|
|
178
|
+
// lexeme, so the SERVER matches. The client ranker tokenizes exactly, so it
|
|
179
|
+
// used to throw the row away again on the way to the prompt — two retrieval
|
|
180
|
+
// layers disagreeing about what a match is, which is invisible from either
|
|
181
|
+
// side alone.
|
|
182
|
+
const result = await askWith('what is blocked?');
|
|
183
|
+
expect(citedIds(result)).toContain('doc_july');
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('does not need every word of the question to appear in one record', async () => {
|
|
187
|
+
// No document contains `allocation` at all. Under a conjunction that is the
|
|
188
|
+
// end of it; under a question, `parking` is plenty.
|
|
189
|
+
const result = await askWith('What did we agree about the parking allocation?');
|
|
190
|
+
expect(result.status).toBe('answered');
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe('but a question is not an excuse to retrieve everything', () => {
|
|
195
|
+
it('retrieves ONLY the record holding a rare single term', async () => {
|
|
196
|
+
const result = await askWith('QUIBBLETRON');
|
|
197
|
+
expect(result.status).toBe('answered');
|
|
198
|
+
expect(citedIds(result)).toEqual(['doc_rent']);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('keeps a rare term rare even when it is asked as a sentence', async () => {
|
|
202
|
+
const result = await askWith('what is the QUIBBLETRON migration waiting on?');
|
|
203
|
+
expect(citedIds(result)).toContain('doc_rent');
|
|
204
|
+
// `migration` and `waiting` are real words in this corpus, so a couple of
|
|
205
|
+
// other records may legitimately come along — but not the whole shelf.
|
|
206
|
+
expect(result.citations.length).toBeLessThan(CORPUS.length);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('does not put the entire corpus in front of the model for a narrow question', async () => {
|
|
210
|
+
const seen: number[] = [];
|
|
211
|
+
await askCorpus(
|
|
212
|
+
{ question: 'what did we agree about the lease?', mode: 'ask', scope: SCOPE },
|
|
213
|
+
{
|
|
214
|
+
corpus: corpusFor().api,
|
|
215
|
+
llm: {
|
|
216
|
+
describe: () => ({ provider: 'spy', model: 'spy', live: false }),
|
|
217
|
+
async answer(input) {
|
|
218
|
+
seen.push(input.documents.length);
|
|
219
|
+
return { answer: 'ok', citations: [], unanswerable: false };
|
|
220
|
+
},
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
);
|
|
224
|
+
expect(seen[0]).toBeGreaterThan(0);
|
|
225
|
+
expect(seen[0]).toBeLessThan(CORPUS.length);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('still refuses when the corpus genuinely has nothing, without calling the model', async () => {
|
|
229
|
+
const calls: unknown[] = [];
|
|
230
|
+
const result = await askCorpus(
|
|
231
|
+
{ question: 'what did the arbitration panel rule on the trademark?', mode: 'ask', scope: SCOPE },
|
|
232
|
+
{
|
|
233
|
+
corpus: corpusFor().api,
|
|
234
|
+
llm: {
|
|
235
|
+
describe: () => ({ provider: 'spy', model: 'spy', live: false }),
|
|
236
|
+
async answer(input) {
|
|
237
|
+
calls.push(input);
|
|
238
|
+
return { answer: 'I could improvise.', citations: [], unanswerable: false };
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
},
|
|
242
|
+
);
|
|
243
|
+
expect(result.status).toBe('no_evidence');
|
|
244
|
+
expect(calls).toHaveLength(0);
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
describe('the search term the tenant is actually sent', () => {
|
|
249
|
+
it('is a disjunction of the question’s content words, not the question', async () => {
|
|
250
|
+
const { api, requests } = corpusFor();
|
|
251
|
+
await askCorpus(
|
|
252
|
+
{ question: 'What did we agree about the parking allocation?', mode: 'ask', scope: SCOPE },
|
|
253
|
+
{ corpus: api, llm: createFixtureLlm() },
|
|
254
|
+
);
|
|
255
|
+
const term = String(
|
|
256
|
+
requests.find((request) => request.params.type === 'source_document')?.params.search ?? '',
|
|
257
|
+
);
|
|
258
|
+
expect(term).not.toContain('?');
|
|
259
|
+
expect(term.toLowerCase()).toContain(' or ');
|
|
260
|
+
expect(term.toLowerCase()).toContain('parking');
|
|
261
|
+
// Interrogatives and function words carry no signal and, ANDed, delete the
|
|
262
|
+
// question outright.
|
|
263
|
+
expect(term.toLowerCase().split(/\s+or\s+/)).not.toContain('what');
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('leaves a single rare term exactly as it was typed', () => {
|
|
267
|
+
// The precision case has to be untouched: one word in, one word out, no
|
|
268
|
+
// disjunction to widen it.
|
|
269
|
+
expect(searchTermFor('QUIBBLETRON')).toBe('quibbletron');
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('sends NO search term at all when the question is only function words', () => {
|
|
273
|
+
// `websearch_to_tsquery` compiles a stopword-only query to an EMPTY tsquery,
|
|
274
|
+
// which matches no row. Sending it would be asking the server for nothing.
|
|
275
|
+
expect(searchTermFor('what is it?')).toBe('');
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it('keeps a quoted phrase quoted, and an excluded word excluded', () => {
|
|
279
|
+
expect(searchTermFor('"funding round"')).toBe('"funding round"');
|
|
280
|
+
expect(searchTermFor('parking -zillow')).toContain('-zillow');
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it('asks the server to order by relevance, so page one is the relevant page', async () => {
|
|
284
|
+
const { api, requests } = corpusFor();
|
|
285
|
+
await askCorpus(
|
|
286
|
+
{ question: 'what did we agree about the lease?', mode: 'ask', scope: SCOPE },
|
|
287
|
+
{ corpus: api, llm: createFixtureLlm() },
|
|
288
|
+
);
|
|
289
|
+
const request = requests.find((entry) => entry.params.type === 'source_document');
|
|
290
|
+
expect(request?.params.order).toBe('relevance');
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
describe('a question with no content words falls back to the recent window', () => {
|
|
295
|
+
it('answers "what happened lately?" from the newest records', async () => {
|
|
296
|
+
const result = await askWith('what is going on?');
|
|
297
|
+
expect(result.status).toBe('answered');
|
|
298
|
+
expect(citedIds(result)).toContain('doc_office'); // the newest record
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
describe('the copy tells the truth about whose fault it is', () => {
|
|
303
|
+
it('does not blame the corpus when the corpus is full and the question missed', async () => {
|
|
304
|
+
const result = await askWith('what did the arbitration panel rule on the trademark?');
|
|
305
|
+
expect(result.status).toBe('no_evidence');
|
|
306
|
+
// The shipped sentence — "Either it has not been pulled in yet, or it
|
|
307
|
+
// belongs to a different workspace" — sent someone to check the connector
|
|
308
|
+
// for a query bug. Six records were sitting right there.
|
|
309
|
+
expect(result.answer).not.toMatch(/has not been pulled in/i);
|
|
310
|
+
expect(result.answer).toMatch(/6 record/i);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it('DOES say the workspace is empty when the workspace is empty', async () => {
|
|
314
|
+
const result = await askWith('what did we agree about the lease?', []);
|
|
315
|
+
expect(result.status).toBe('no_evidence');
|
|
316
|
+
expect(result.answer).toMatch(/nothing has been pulled in|no records/i);
|
|
317
|
+
});
|
|
318
|
+
});
|
|
@@ -80,10 +80,19 @@ describe('enforceScope — nothing is dropped silently', () => {
|
|
|
80
80
|
});
|
|
81
81
|
|
|
82
82
|
describe('retrieval refuses the bait', () => {
|
|
83
|
-
it('ranks the foreign document
|
|
84
|
-
// Establishes that the guard is doing real work: without it, the leak
|
|
83
|
+
it('ranks the foreign document at the very top when the guard is removed', () => {
|
|
84
|
+
// Establishes that the guard is doing real work: without it, the leak is not
|
|
85
|
+
// some tail result the model would ignore — it is the most attractive answer
|
|
86
|
+
// in the set and it reaches the prompt.
|
|
87
|
+
//
|
|
88
|
+
// ASSERTED AS "in the top two", not "first", since bd startsim-768w.38.36:
|
|
89
|
+
// the ranker now agrees with the tenant's stemmer, so the caller's OWN Acme
|
|
90
|
+
// note matches `agree` against its "agreed" and covers all six terms of the
|
|
91
|
+
// question rather than five. It edges ahead by 3%. The bait is no less
|
|
92
|
+
// attractive and the guard is no less necessary; pinning an exact ordinal
|
|
93
|
+
// pinned an artifact of the tokenizer instead of the property.
|
|
85
94
|
const unguarded = rankDocuments(FIXTURE_DOCUMENTS, BAIT, { limit: 5 });
|
|
86
|
-
expect(unguarded
|
|
95
|
+
expect(unguarded.slice(0, 2).map((item) => item.document.id)).toContain('nw_contract_44');
|
|
87
96
|
});
|
|
88
97
|
|
|
89
98
|
it('never returns it once the guard runs', () => {
|