@startsimpli/integrations 0.2.25 → 0.2.27

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startsimpli/integrations",
3
- "version": "0.2.25",
3
+ "version": "0.2.27",
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",
@@ -0,0 +1,319 @@
1
+ /**
2
+ * OPENING ONE RECORD (bd startsim-4s4um RED / startsim-2lt8y GREEN).
3
+ *
4
+ * REPORTED, with a live URL:
5
+ * `https://tandem.ai.startsimpli.com/library?doc=5ab48b58-8573-4114-b408-215dd0eaf5d7`
6
+ * renders no document.
7
+ *
8
+ * WHAT THE TENANT ACTUALLY DOES, read out of
9
+ * `start-simpli-api/tenant-starter/apps/api/{views,filters}.py` @ origin/main
10
+ * rather than assumed:
11
+ *
12
+ * - `/api/v1/entities/<id>/` is a real DRF detail route. It answers the ONE
13
+ * row, or 404 when the row is absent for this reader — the read gate makes a
14
+ * forbidden row ABSENT rather than refused, deliberately.
15
+ * - `/api/v1/entities/` applies `?type=` plus a fixed vocabulary
16
+ * (`search`, `source`, `party`, `occurred_after`, `occurred_before`,
17
+ * `related_*`, `order`) and `attr.*`. `_note_unknown` sweeps EVERY OTHER
18
+ * KEY into `ignored_filters` and returns the page unnarrowed. `id` is not in
19
+ * that vocabulary, which is why `?id=<uuid>` comes back with count 180.
20
+ *
21
+ * SO THE FAKE BELOW IGNORES UNKNOWN PARAMETERS RATHER THAN ERRORING ON THEM,
22
+ * and that is the whole point of writing a new one instead of reaching for
23
+ * `createFixtureCorpusHttp`: that fixture applies `?search=` when a backend is
24
+ * named and never reports `ignored_filters`, so a lookup expressed as a filter
25
+ * would come back plausibly narrowed there. A fake that answers an unsupported
26
+ * filter with `[]` — or with a filtered page — makes this suite green while the
27
+ * deployed Library stays blank, which is the exact trap this bead names.
28
+ *
29
+ * WHAT IS NOT THE BUG, established before any of this was written. The DEPLOYED
30
+ * bundle on tandem.ai.startsimpli.com already asks the detail route:
31
+ *
32
+ * getDocument(e){let[a,r]=await Promise.all([
33
+ * t.get(`${m}/${encodeURIComponent(e)}`),f()]);...}
34
+ *
35
+ * and that slash-less path resolves — `curl` returns 401 with
36
+ * `allow: GET, PUT, PATCH, DELETE, HEAD, OPTIONS`, the detail route's own verb
37
+ * set, not a redirect and not the list's. The request shape is right. What is
38
+ * missing is everything that happens when it does not come back: the client
39
+ * accepts whatever it is handed without checking it is the record it asked for,
40
+ * and the composer turns every failure into an empty screen.
41
+ */
42
+ import { describe, expect, it } from 'vitest';
43
+ import { render, screen, within } from '@testing-library/react';
44
+
45
+ import { LibraryPage } from '../components/pages/LibraryPage';
46
+ import { createCorpusApi } from '../corpus/client';
47
+ import type { CorpusHttp } from '../corpus/client';
48
+ import type { TenantEntity } from '../corpus/entities';
49
+ import type { CorpusScope } from '../corpus/types';
50
+
51
+ const SCOPE: CorpusScope = { orgId: 'org_tandem' };
52
+
53
+ /** The record the user deep-linked, live, when they reported this. */
54
+ const OPENED = '5ab48b58-8573-4114-b408-215dd0eaf5d7';
55
+ /** A well-formed id for a row that is simply not there. */
56
+ const ABSENT = '00000000-0000-4000-8000-000000000000';
57
+
58
+ function document(id: string, title: string, index: number): TenantEntity {
59
+ return {
60
+ id,
61
+ entity_type: 'source_document',
62
+ external_id: `granola:${id}`,
63
+ name: title,
64
+ org_id: SCOPE.orgId,
65
+ created_at: `2026-08-${String((index % 27) + 1).padStart(2, '0')}T10:00:00Z`,
66
+ data: {
67
+ source: 'granola',
68
+ title,
69
+ summary: `Summary of ${title}.`,
70
+ occurred_at: `2026-08-${String((index % 27) + 1).padStart(2, '0')}T09:00:00Z`,
71
+ },
72
+ };
73
+ }
74
+
75
+ /**
76
+ * A corpus the size of the reported one, with the deep-linked record buried
77
+ * where the composer's on-page shortcut cannot reach it.
78
+ *
79
+ * THE POSITION MATTERS. The record is 100 rows in, so page one does not contain
80
+ * it and `documents.find(...)` misses — which is precisely the state the
81
+ * reported URL lands in and the only state where the lookup is exercised at all.
82
+ */
83
+ const CORPUS: TenantEntity[] = Array.from({ length: 180 }, (_, index) =>
84
+ index === 100
85
+ ? document(OPENED, 'Tandem <> Obwb quarterly review', index)
86
+ : document(`doc-${index}`, `Record ${index}`, index),
87
+ );
88
+
89
+ /** Parameters this tenant recognises on `/entities/`. Everything else is ignored. */
90
+ const HONOURED = new Set([
91
+ 'search', 'source', 'party', 'occurred_after', 'occurred_before',
92
+ 'related_to', 'related_via', 'related_direction', 'related_depth', 'order',
93
+ ]);
94
+ const RESERVED = new Set(['page', 'page_size', 'format', 'type']);
95
+
96
+ interface Recorded {
97
+ path: string;
98
+ params: Record<string, unknown>;
99
+ }
100
+
101
+ /**
102
+ * The tenant, as the tenant is.
103
+ *
104
+ * `detailStatus` stands in for the read gate and the session: the tenant answers
105
+ * an absent row with 404 and an expired bearer with 401, and those are different
106
+ * facts that must not collapse into the same screen.
107
+ */
108
+ function tenantHttp(options: {
109
+ entities?: TenantEntity[];
110
+ pageSize?: number;
111
+ detailStatus?: number;
112
+ } = {}) {
113
+ const rows = options.entities ?? CORPUS;
114
+ const pageSize = options.pageSize ?? 20;
115
+ const requests: Recorded[] = [];
116
+
117
+ const http: CorpusHttp = {
118
+ async get<T>(path: string, init?: { params?: Record<string, unknown> }): Promise<T> {
119
+ const params = init?.params ?? {};
120
+ requests.push({ path, params });
121
+
122
+ if (path.includes('relationships')) {
123
+ return { count: 0, next: null, previous: null, results: [] } as T;
124
+ }
125
+ if (path.includes('connections')) {
126
+ return { count: 0, next: null, previous: null, results: [] } as T;
127
+ }
128
+ if (!path.includes('entities')) {
129
+ throw Object.assign(new Error(`${path} responded 404`), { status: 404 });
130
+ }
131
+
132
+ // BOTH SPELLINGS OF THE DETAIL ROUTE, because both were measured to
133
+ // resolve on the live tenant. A fake that insisted on the trailing slash
134
+ // would manufacture evidence for a path change the wire does not ask for.
135
+ const detail = /entities\/([^/?]+)\/?$/.exec(path)?.[1];
136
+ if (detail) {
137
+ if (options.detailStatus) {
138
+ throw Object.assign(new Error(`${path} responded ${options.detailStatus}`), {
139
+ status: options.detailStatus,
140
+ });
141
+ }
142
+ const found = rows.find((row) => String(row.id) === decodeURIComponent(detail));
143
+ // ABSENT, NOT REFUSED — `readable_types_q` / `readable_rows_q` narrow the
144
+ // queryset, so a row this reader may not see 404s exactly like one that
145
+ // was never written.
146
+ if (!found) throw Object.assign(new Error(`${path} responded 404`), { status: 404 });
147
+ return found as T;
148
+ }
149
+
150
+ // The LIST. `?type=` narrows; every other key is swept into
151
+ // `ignored_filters` and the page comes back exactly as wide as it was.
152
+ const type = params.type ? String(params.type) : '';
153
+ const matching = type ? rows.filter((row) => row.entity_type === type) : rows;
154
+ const size = Number(params.page_size ?? pageSize) || pageSize;
155
+ const page = Number(params.page ?? 1) || 1;
156
+ const start = (page - 1) * size;
157
+ const applied: string[] = [];
158
+ const ignored: string[] = [];
159
+ for (const key of Object.keys(params)) {
160
+ if (RESERVED.has(key)) continue;
161
+ (HONOURED.has(key) ? applied : ignored).push(key);
162
+ }
163
+ return {
164
+ count: matching.length,
165
+ next: start + size < matching.length ? `?page=${page + 1}` : null,
166
+ previous: page > 1 ? `?page=${page - 1}` : null,
167
+ results: matching.slice(start, start + size),
168
+ applied_filters: applied,
169
+ ignored_filters: ignored,
170
+ } as T;
171
+ },
172
+ };
173
+
174
+ return { http, requests };
175
+ }
176
+
177
+ /**
178
+ * The tenant as it answers a lookup that was expressed as a FILTER.
179
+ *
180
+ * `GET /api/v1/entities/?id=5ab48b58-…` was checked against prod for this epic
181
+ * and returned 200 with count 180 — the whole corpus, unnarrowed, because `id`
182
+ * is not a filter this API has. Any client that reaches the entity list instead
183
+ * of the detail route gets THIS: a page envelope where it expected a row.
184
+ *
185
+ * The contract under test is that such a response is a FAILURE, not a document.
186
+ * Mapping it produces a record with no id and no title that renders as a blank
187
+ * panel — an arbitrary answer wearing a real record's clothes, which is worse
188
+ * than an error because nobody can tell it is wrong.
189
+ */
190
+ function pageEnvelopeHttp() {
191
+ const { requests } = tenantHttp();
192
+ const http: CorpusHttp = {
193
+ async get<T>(path: string, init?: { params?: Record<string, unknown> }): Promise<T> {
194
+ requests.push({ path, params: init?.params ?? {} });
195
+ if (path.includes('entities')) {
196
+ return {
197
+ count: CORPUS.length,
198
+ next: '?page=2',
199
+ previous: null,
200
+ results: CORPUS.slice(0, 20),
201
+ ignored_filters: ['id'],
202
+ } as T;
203
+ }
204
+ return { count: 0, next: null, previous: null, results: [] } as T;
205
+ },
206
+ };
207
+ return { http, requests };
208
+ }
209
+
210
+ function corpusOver(http: CorpusHttp) {
211
+ return createCorpusApi({ http, scope: SCOPE, supportedFilters: [] });
212
+ }
213
+
214
+ // ---------------------------------------------------------------------------
215
+ // the client
216
+ // ---------------------------------------------------------------------------
217
+
218
+ describe('a document lookup returns THAT document', () => {
219
+ it('answers with the record that was asked for, not the first of many', async () => {
220
+ const api = corpusOver(tenantHttp().http);
221
+ const found = await api.getDocument(OPENED);
222
+ expect(found.id).toBe(OPENED);
223
+ expect(found.title).toBe('Tandem <> Obwb quarterly review');
224
+ });
225
+
226
+ it('asks the DETAIL route, and never expresses the id as a filter', async () => {
227
+ // The distinction the whole bead turns on. `?id=` is swept into
228
+ // `ignored_filters` by this API, so a lookup spelled that way is handed the
229
+ // unfiltered corpus and has to guess.
230
+ const { http, requests } = tenantHttp();
231
+ await corpusOver(http).getDocument(OPENED);
232
+
233
+ const lookup = requests.find((request) => request.path.includes(OPENED));
234
+ expect(lookup, 'the id belongs in the PATH').toBeTruthy();
235
+ expect(
236
+ requests.every((request) => !('id' in request.params)),
237
+ 'no request may carry the id as a query parameter',
238
+ ).toBe(true);
239
+ });
240
+
241
+ it('refuses a page envelope instead of fabricating a record from it', async () => {
242
+ // A response that is a LIST is not the record, whatever produced it — an
243
+ // ignored `?id=`, a proxy that dropped the path segment, a deployment whose
244
+ // detail route is a list. Mapping it yields id `undefined` and the title
245
+ // "Untitled unknown record": a blank panel that claims to be the document.
246
+ const api = corpusOver(pageEnvelopeHttp().http);
247
+ await expect(api.getDocument(OPENED)).rejects.toMatchObject({ status: 404 });
248
+ });
249
+
250
+ it('surfaces a record that is genuinely absent as not-found', async () => {
251
+ const api = corpusOver(tenantHttp().http);
252
+ await expect(api.getDocument(ABSENT)).rejects.toMatchObject({ status: 404 });
253
+ });
254
+
255
+ it('never answers one id with a different record', async () => {
256
+ const api = corpusOver(tenantHttp().http);
257
+ const found = await api.getDocument('doc-3');
258
+ expect(found.id).toBe('doc-3');
259
+ await expect(api.getDocument(ABSENT)).rejects.toBeTruthy();
260
+ });
261
+ });
262
+
263
+ // ---------------------------------------------------------------------------
264
+ // the screen
265
+ // ---------------------------------------------------------------------------
266
+
267
+ describe('the Library opens the record it was deep-linked to', () => {
268
+ it('renders a record that is not on the first page', async () => {
269
+ const api = corpusOver(tenantHttp().http);
270
+ render(<LibraryPage api={api} scope={SCOPE} initialDocumentId={OPENED} />);
271
+
272
+ expect(
273
+ await screen.findByRole('region', { name: 'Record detail' }),
274
+ ).toHaveTextContent('Tandem <> Obwb quarterly review');
275
+ });
276
+
277
+ it('SAYS SO when the record cannot be found, instead of rendering nothing', async () => {
278
+ // THE REPORTED SYMPTOM. `?doc=<id>` in the address bar, a list on screen,
279
+ // and no record and no explanation — the composer swallowed the failure into
280
+ // `setSelected(null)`. A screen that asked for a record and shows neither the
281
+ // record nor a reason is the one outcome the user cannot act on.
282
+ const api = corpusOver(tenantHttp().http);
283
+ render(<LibraryPage api={api} scope={SCOPE} initialDocumentId={ABSENT} />);
284
+
285
+ const notice = await screen.findByRole('region', { name: 'Record' });
286
+ expect(notice).toHaveTextContent(/could not be found/i);
287
+ expect(notice).toHaveTextContent(new RegExp(ABSENT));
288
+ });
289
+
290
+ it('calls an expired session an expired session, not a missing record', async () => {
291
+ // "Sign in again" and "that record is gone" send the reader to different
292
+ // places, and only one of them is true at a time.
293
+ const api = corpusOver(tenantHttp({ detailStatus: 401 }).http);
294
+ render(<LibraryPage api={api} scope={SCOPE} initialDocumentId={OPENED} />);
295
+
296
+ const notice = await screen.findByRole('region', { name: 'Record' });
297
+ expect(notice).toHaveTextContent(/session has expired/i);
298
+ expect(notice).not.toHaveTextContent(/could not be found/i);
299
+ });
300
+
301
+ it('calls a refusal a refusal', async () => {
302
+ const api = corpusOver(tenantHttp({ detailStatus: 403 }).http);
303
+ render(<LibraryPage api={api} scope={SCOPE} initialDocumentId={OPENED} />);
304
+
305
+ expect(await screen.findByRole('region', { name: 'Record' })).toHaveTextContent(
306
+ /do not have access/i,
307
+ );
308
+ });
309
+
310
+ it('lets the reader dismiss the notice and get back to the list', async () => {
311
+ // Without this the `?doc=` in the URL is a dead end: the panel cannot be
312
+ // closed because there is no panel, only a message.
313
+ const api = corpusOver(tenantHttp().http);
314
+ render(<LibraryPage api={api} scope={SCOPE} initialDocumentId={ABSENT} />);
315
+
316
+ const notice = await screen.findByRole('region', { name: 'Record' });
317
+ expect(within(notice).getByRole('button', { name: /close/i })).toBeInTheDocument();
318
+ });
319
+ });