@homeflare/alchemy 0.24.0 → 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -0
- package/dist/paperless/client.d.ts +37 -0
- package/dist/paperless/client.d.ts.map +1 -0
- package/dist/paperless/constraint-guard.d.ts +19 -0
- package/dist/paperless/constraint-guard.d.ts.map +1 -0
- package/dist/paperless/constraints.d.ts +50 -0
- package/dist/paperless/constraints.d.ts.map +1 -0
- package/dist/paperless/credentials.d.ts +40 -0
- package/dist/paperless/credentials.d.ts.map +1 -0
- package/dist/paperless/custom-field.d.ts +75 -0
- package/dist/paperless/custom-field.d.ts.map +1 -0
- package/dist/paperless/document-type.d.ts +30 -0
- package/dist/paperless/document-type.d.ts.map +1 -0
- package/dist/paperless/errors.d.ts +49 -0
- package/dist/paperless/errors.d.ts.map +1 -0
- package/dist/paperless/fake-paperless.d.ts +39 -0
- package/dist/paperless/fake-paperless.d.ts.map +1 -0
- package/dist/paperless/generated/constraints/index.d.ts +13 -0
- package/dist/paperless/generated/constraints/index.d.ts.map +1 -0
- package/dist/paperless/generated/constraints/paperless-custom_fields.d.ts +21 -0
- package/dist/paperless/generated/constraints/paperless-custom_fields.d.ts.map +1 -0
- package/dist/paperless/generated/constraints/paperless-document_types.d.ts +21 -0
- package/dist/paperless/generated/constraints/paperless-document_types.d.ts.map +1 -0
- package/dist/paperless/generated/constraints/paperless-storage_paths.d.ts +21 -0
- package/dist/paperless/generated/constraints/paperless-storage_paths.d.ts.map +1 -0
- package/dist/paperless/generated/constraints/paperless-tags.d.ts +21 -0
- package/dist/paperless/generated/constraints/paperless-tags.d.ts.map +1 -0
- package/dist/paperless/generated/types/custom-fields.d.ts +23 -0
- package/dist/paperless/generated/types/custom-fields.d.ts.map +1 -0
- package/dist/paperless/generated/types/document-types.d.ts +39 -0
- package/dist/paperless/generated/types/document-types.d.ts.map +1 -0
- package/dist/paperless/generated/types/index.d.ts +10 -0
- package/dist/paperless/generated/types/index.d.ts.map +1 -0
- package/dist/paperless/generated/types/storage-paths.d.ts +31 -0
- package/dist/paperless/generated/types/storage-paths.d.ts.map +1 -0
- package/dist/paperless/generated/types/tags.d.ts +37 -0
- package/dist/paperless/generated/types/tags.d.ts.map +1 -0
- package/dist/paperless/index.d.ts +19 -0
- package/dist/paperless/index.d.ts.map +1 -0
- package/dist/paperless/index.js +524 -0
- package/dist/paperless/index.js.map +26 -0
- package/dist/paperless/matching-locate.d.ts +33 -0
- package/dist/paperless/matching-locate.d.ts.map +1 -0
- package/dist/paperless/matching-types.d.ts +60 -0
- package/dist/paperless/matching-types.d.ts.map +1 -0
- package/dist/paperless/matching.d.ts +61 -0
- package/dist/paperless/matching.d.ts.map +1 -0
- package/dist/paperless/providers.d.ts +15 -0
- package/dist/paperless/providers.d.ts.map +1 -0
- package/dist/paperless/storage-path.d.ts +32 -0
- package/dist/paperless/storage-path.d.ts.map +1 -0
- package/dist/paperless/tag.d.ts +47 -0
- package/dist/paperless/tag.d.ts.map +1 -0
- package/docs/paperless.md +115 -0
- package/package.json +5 -1
- package/src/paperless/client.ts +88 -0
- package/src/paperless/constraint-guard.ts +50 -0
- package/src/paperless/constraints.test.ts +74 -0
- package/src/paperless/constraints.ts +111 -0
- package/src/paperless/credentials.ts +69 -0
- package/src/paperless/custom-field.test.ts +74 -0
- package/src/paperless/custom-field.ts +95 -0
- package/src/paperless/document-type.ts +74 -0
- package/src/paperless/errors.ts +62 -0
- package/src/paperless/fake-paperless.ts +156 -0
- package/src/paperless/generated/constraints/index.ts +23 -0
- package/src/paperless/generated/constraints/paperless-custom_fields.ts +29 -0
- package/src/paperless/generated/constraints/paperless-document_types.ts +32 -0
- package/src/paperless/generated/constraints/paperless-storage_paths.ts +34 -0
- package/src/paperless/generated/constraints/paperless-tags.ts +34 -0
- package/src/paperless/generated/types/custom-fields.ts +23 -0
- package/src/paperless/generated/types/document-types.ts +30 -0
- package/src/paperless/generated/types/index.ts +9 -0
- package/src/paperless/generated/types/storage-paths.ts +31 -0
- package/src/paperless/generated/types/tags.ts +37 -0
- package/src/paperless/index.ts +56 -0
- package/src/paperless/matching-identity.test.ts +194 -0
- package/src/paperless/matching-locate.ts +104 -0
- package/src/paperless/matching-types.ts +61 -0
- package/src/paperless/matching.test.ts +128 -0
- package/src/paperless/matching.ts +239 -0
- package/src/paperless/providers.ts +27 -0
- package/src/paperless/storage-path.ts +78 -0
- package/src/paperless/tag.ts +91 -0
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression suite for the PR 163 red-team HIGH finding: `diff`/`reconcile` re-derived identity
|
|
3
|
+
* from `news` alone on every call, so renaming (or re-owning) an already-deployed Tag/
|
|
4
|
+
* DocumentType/StoragePath/CustomField missed the row it previously wrote, POSTed a second
|
|
5
|
+
* object under the new name/owner, and left the original permanently orphaned — while `diff`
|
|
6
|
+
* reported a plain `{action:'update'}`. See `matching.ts`'s header for the fix: once `output` is
|
|
7
|
+
* defined, identity is `output.id`, not `(name, owner)`.
|
|
8
|
+
*
|
|
9
|
+
* ⛔ EVERY TEST HERE FAILS ON THE PRE-FIX `matching.ts` (measured before the fix landed — see the
|
|
10
|
+
* PR comment this file was introduced with):
|
|
11
|
+
* - the two rename/re-own tests fail because a second POST fires and `fake.rows('tags').length`
|
|
12
|
+
* is 2, not 1 (the exact duplicate-and-orphan bug);
|
|
13
|
+
* - the deleted-out-of-band test fails because `diff` never called `fetchLive` at all when
|
|
14
|
+
* `output` was ignored the pre-fix way — it silently returns `{action:'update'}` instead of
|
|
15
|
+
* refusing;
|
|
16
|
+
* - the first-create and adopt tests already passed pre-fix (the no-state path was never
|
|
17
|
+
* broken) and are here to prove the fix does not disturb them.
|
|
18
|
+
*
|
|
19
|
+
* ★ THE LAST `describe` BELOW IS A SECOND ROUND (re-review of this same PR): the d3983ff fix
|
|
20
|
+
* rewired `diff`/`reconcile` to locate by `output.id`, but left `destroy` locating by
|
|
21
|
+
* `(name, owner)` alone — the exact pre-fix mechanism, with the exact pre-fix consequence
|
|
22
|
+
* (silently missing a live-but-renamed row) just on the delete path instead of update. See
|
|
23
|
+
* `matching.ts`'s `destroy` for the fix this suite proves.
|
|
24
|
+
*/
|
|
25
|
+
import { describe, expect, test } from 'bun:test';
|
|
26
|
+
import { paperless } from './client.ts';
|
|
27
|
+
import { type MatchingProps, matchingHandlers, matchingOperations } from './matching.ts';
|
|
28
|
+
import { run, withFake } from './fake-paperless.ts';
|
|
29
|
+
|
|
30
|
+
interface Props extends MatchingProps {
|
|
31
|
+
note?: string;
|
|
32
|
+
}
|
|
33
|
+
/** ★ The same minimal spec `matching.test.ts` uses — this suite tests `matching.ts`'s own identity contract, not any one family's wiring. */
|
|
34
|
+
const spec = {
|
|
35
|
+
attributes: (live: Record<string, unknown>, props: Props) => {
|
|
36
|
+
const id = live['id'];
|
|
37
|
+
if (typeof id !== 'number') return undefined;
|
|
38
|
+
return {
|
|
39
|
+
id,
|
|
40
|
+
name: typeof live['name'] === 'string' ? live['name'] : props.name,
|
|
41
|
+
note: typeof live['note'] === 'string' ? live['note'] : '',
|
|
42
|
+
owner: typeof live['owner'] === 'number' ? live['owner'] : undefined,
|
|
43
|
+
};
|
|
44
|
+
},
|
|
45
|
+
collection: 'tags' as const,
|
|
46
|
+
describe: (props: Props) => `tags ${props.name}`,
|
|
47
|
+
endpoint: { create: 'paperless:POST /api/tags/', update: 'paperless:PATCH /api/tags/{id}/' },
|
|
48
|
+
fields: [
|
|
49
|
+
{ key: 'note', live: (r: Record<string, unknown>) => r['note'], prop: (p: Props) => p.note },
|
|
50
|
+
],
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const writesOf = (fake: { seen: readonly { method: string }[] }) =>
|
|
54
|
+
fake.seen.filter((s) => s.method !== 'GET');
|
|
55
|
+
|
|
56
|
+
describe('renaming or re-owning an already-deployed object (PR 163)', () => {
|
|
57
|
+
test('a rename PATCHes the same id — no POST, no orphan', async () => {
|
|
58
|
+
await withFake(async (fake) => {
|
|
59
|
+
const handlers = matchingHandlers(spec);
|
|
60
|
+
const created = await run(handlers.reconcile({ news: { name: 'Invoices' } }));
|
|
61
|
+
const before = writesOf(fake).length;
|
|
62
|
+
|
|
63
|
+
const renamed = await run(
|
|
64
|
+
handlers.reconcile({ news: { name: 'Invoices - Renamed' }, output: created }),
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
const writes = writesOf(fake);
|
|
68
|
+
const newWrites = writes.slice(before);
|
|
69
|
+
expect(writes.length).toBe(before + 1);
|
|
70
|
+
expect(newWrites.every((w) => w.method === 'PATCH')).toBe(true);
|
|
71
|
+
expect(fake.rows('tags').length).toBe(1);
|
|
72
|
+
expect(renamed?.id).toBe(created?.id);
|
|
73
|
+
expect(renamed?.name).toBe('Invoices - Renamed');
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('an owner change PATCHes the same id — no POST, no orphan', async () => {
|
|
78
|
+
await withFake(async (fake) => {
|
|
79
|
+
const handlers = matchingHandlers(spec);
|
|
80
|
+
const created = await run(handlers.reconcile({ news: { name: 'Invoices', owner: 7 } }));
|
|
81
|
+
const before = writesOf(fake).length;
|
|
82
|
+
|
|
83
|
+
const reowned = await run(
|
|
84
|
+
handlers.reconcile({ news: { name: 'Invoices', owner: 9 }, output: created }),
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
const writes = writesOf(fake);
|
|
88
|
+
expect(writes.length).toBe(before + 1);
|
|
89
|
+
expect(writes[writes.length - 1]?.method).toBe('PATCH');
|
|
90
|
+
expect(fake.rows('tags').length).toBe(1);
|
|
91
|
+
expect(reowned?.id).toBe(created?.id);
|
|
92
|
+
expect(reowned?.owner).toBe(9);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe('a deleted-out-of-band object (PR 163)', () => {
|
|
98
|
+
test('diff refuses at plan rather than creating a replacement', async () => {
|
|
99
|
+
await withFake(async (fake) => {
|
|
100
|
+
const handlers = matchingHandlers(spec);
|
|
101
|
+
const created = await run(handlers.reconcile({ news: { name: 'Invoices' } }));
|
|
102
|
+
await run(paperless('DELETE', `tags/${String(created?.id)}/`));
|
|
103
|
+
|
|
104
|
+
await expect(
|
|
105
|
+
run(handlers.diff({ news: { name: 'Invoices' }, output: created })),
|
|
106
|
+
).rejects.toThrow(/no longer exists live|deleted out of band/);
|
|
107
|
+
|
|
108
|
+
expect(fake.rows('tags').length).toBe(0);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('reconcile also refuses, not only diff, if called with no prior diff', async () => {
|
|
113
|
+
await withFake(async (fake) => {
|
|
114
|
+
const handlers = matchingHandlers(spec);
|
|
115
|
+
const created = await run(handlers.reconcile({ news: { name: 'Invoices' } }));
|
|
116
|
+
await run(paperless('DELETE', `tags/${String(created?.id)}/`));
|
|
117
|
+
|
|
118
|
+
await expect(
|
|
119
|
+
run(handlers.reconcile({ news: { name: 'Invoices' }, output: created })),
|
|
120
|
+
).rejects.toThrow(/no longer exists live|deleted out of band/);
|
|
121
|
+
|
|
122
|
+
expect(fake.rows('tags').length).toBe(0);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe('the no-state path is unaffected (PR 163)', () => {
|
|
128
|
+
test('a first create still locates by name', async () => {
|
|
129
|
+
await withFake(async (fake) => {
|
|
130
|
+
const handlers = matchingHandlers(spec);
|
|
131
|
+
await run(handlers.reconcile({ news: { name: 'Invoices' } }));
|
|
132
|
+
|
|
133
|
+
const get = fake.seen.find((s) => s.method === 'GET');
|
|
134
|
+
expect(get?.path).toContain('name__iexact=Invoices');
|
|
135
|
+
const post = fake.seen.find((s) => s.method === 'POST');
|
|
136
|
+
expect(post).toBeDefined();
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("adopt (output populated from the engine's own read) locates the id it read, no duplicate", async () => {
|
|
141
|
+
await withFake(async (fake) => {
|
|
142
|
+
const ops = matchingOperations(spec);
|
|
143
|
+
const handlers = matchingHandlers(spec);
|
|
144
|
+
// A pre-existing object, created out of band — what the engine's `provider.read` (always
|
|
145
|
+
// called with `output: undefined`) discovers during its adoption probe (`Plan.ts`).
|
|
146
|
+
await run(paperless('POST', 'tags/', { name: 'Invoices', owner: null }));
|
|
147
|
+
const readAttrs = await run(ops.read({ name: 'Invoices' }));
|
|
148
|
+
expect(readAttrs).toBeDefined();
|
|
149
|
+
if (readAttrs === undefined) throw new Error('unreachable: asserted defined above');
|
|
150
|
+
|
|
151
|
+
// The engine forces `action: 'adopted'` through the normal update/reconcile path with
|
|
152
|
+
// `output: node.state.attr` already the just-read attributes (Apply.ts).
|
|
153
|
+
const after = await run(
|
|
154
|
+
handlers.reconcile({ news: { name: 'Invoices' }, output: readAttrs }),
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
expect(fake.rows('tags').length).toBe(1);
|
|
158
|
+
expect(after?.id).toBe(readAttrs?.id);
|
|
159
|
+
expect(fake.seen.filter((s) => s.method === 'POST').length).toBe(1);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe('delete locates by output.id, not by (possibly stale) olds.name (re-review)', () => {
|
|
165
|
+
test('a row renamed out of band is still found and deleted, by id', async () => {
|
|
166
|
+
await withFake(async (fake) => {
|
|
167
|
+
const handlers = matchingHandlers(spec);
|
|
168
|
+
const created = await run(handlers.reconcile({ news: { name: 'Invoices' } }));
|
|
169
|
+
if (created === undefined) throw new Error('unreachable: reconcile always returns attrs');
|
|
170
|
+
|
|
171
|
+
// Nothing this package did — a human (or another tool) renamed the live object directly
|
|
172
|
+
// in Paperless. State's `olds` still says the OLD name; `output` still holds the real id.
|
|
173
|
+
await run(paperless('PATCH', `tags/${String(created.id)}/`, { name: 'Renamed Elsewhere' }));
|
|
174
|
+
|
|
175
|
+
await run(handlers.delete({ olds: { name: 'Invoices' }, output: created }));
|
|
176
|
+
|
|
177
|
+
expect(fake.rows('tags').length).toBe(0);
|
|
178
|
+
const deletes = fake.seen.filter((s) => s.method === 'DELETE');
|
|
179
|
+
expect(deletes.length).toBe(1);
|
|
180
|
+
expect(deletes[0]?.path).toBe(`/api/tags/${String(created.id)}/`);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test('with no output (no-state path), delete still falls back to locating by name', async () => {
|
|
185
|
+
await withFake(async (fake) => {
|
|
186
|
+
const handlers = matchingHandlers(spec);
|
|
187
|
+
await run(handlers.reconcile({ news: { name: 'Invoices' } }));
|
|
188
|
+
|
|
189
|
+
await run(handlers.delete({ olds: { name: 'Invoices' } }));
|
|
190
|
+
|
|
191
|
+
expect(fake.rows('tags').length).toBe(0);
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The identity-resolution slice of `matching.ts`'s lifecycle, split into its own file so
|
|
3
|
+
* `matching.ts` stays under the house's 250-line-per-file cap. Owns exactly one question — which
|
|
4
|
+
* live Paperless row IS this declaration — by name while there is no state, by the stored
|
|
5
|
+
* `output.id` once there is one. See `matching.ts`'s header for the PR 163 red-team HIGH finding
|
|
6
|
+
* this identity switch fixes.
|
|
7
|
+
*/
|
|
8
|
+
import * as Effect from 'effect/Effect';
|
|
9
|
+
import { PaperlessNotFound } from './errors.ts';
|
|
10
|
+
import { type PaperlessList, type PaperlessRow, paperless } from './client.ts';
|
|
11
|
+
import type { MatchingError, MatchingProps, MatchingRequirements } from './matching-types.ts';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* ⚠️ Only 404 folds to "absent" — a 401/403 is never read as absent (acceptance criteria).
|
|
15
|
+
* Exported — `matching.ts`'s `destroy` uses it directly around its own DELETE call.
|
|
16
|
+
*/
|
|
17
|
+
export const absentOn404 = <A>(io: Effect.Effect<A, MatchingError, MatchingRequirements>) =>
|
|
18
|
+
io.pipe(
|
|
19
|
+
Effect.catchIf(
|
|
20
|
+
(cause): cause is PaperlessNotFound => cause instanceof PaperlessNotFound,
|
|
21
|
+
() => Effect.succeed(undefined as A),
|
|
22
|
+
),
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
/** ★ Wide enough to see every case-variant candidate `name__iexact` returns; a natural key past it is not one. */
|
|
26
|
+
const LOCATE_PAGE = 20;
|
|
27
|
+
|
|
28
|
+
/** ⚠️ Exported — `matching.ts`'s `patchBody`/`matches` compare the live `owner` too (PR 163). */
|
|
29
|
+
export const ownerOf = (row: PaperlessRow): number | undefined =>
|
|
30
|
+
typeof row['owner'] === 'number' ? row['owner'] : undefined;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The name/id lookups every `matching.ts` operation shares, built once per family spec.
|
|
34
|
+
* `collection` and `describe` are all this slice needs out of a full `MatchingSpec`.
|
|
35
|
+
*/
|
|
36
|
+
export const matchingLocate = <Props extends MatchingProps>(spec: {
|
|
37
|
+
readonly collection: string;
|
|
38
|
+
readonly describe: (props: Props) => string;
|
|
39
|
+
}) => {
|
|
40
|
+
/**
|
|
41
|
+
* ★ THE NO-STATE LOCATE. Used only when there is no `output` yet: a genuine first create, or
|
|
42
|
+
* the engine's own adoption probe (`provider.read`, always called with `output: undefined`).
|
|
43
|
+
*/
|
|
44
|
+
const fetchByName = (props: Props) =>
|
|
45
|
+
absentOn404(
|
|
46
|
+
paperless<PaperlessList<PaperlessRow>>(
|
|
47
|
+
'GET',
|
|
48
|
+
`${spec.collection}/?name__iexact=${encodeURIComponent(props.name)}&page_size=${String(LOCATE_PAGE)}`,
|
|
49
|
+
),
|
|
50
|
+
).pipe(
|
|
51
|
+
Effect.map((list) => {
|
|
52
|
+
if (list !== undefined && list.count > LOCATE_PAGE) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
`${spec.describe(props)}: name__iexact matched ${String(list.count)} rows, more than ` +
|
|
55
|
+
`the ${String(LOCATE_PAGE)}-row page this reads. Narrow the name rather than paging.`,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
const rows = (list?.results ?? []).filter(
|
|
59
|
+
(row) => row['name'] === props.name && ownerOf(row) === (props.owner ?? undefined),
|
|
60
|
+
);
|
|
61
|
+
if (rows.length > 1) {
|
|
62
|
+
throw new Error(
|
|
63
|
+
`${spec.describe(props)}: matched ${String(rows.length)} Paperless objects with the same (name, owner).`,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
return rows[0];
|
|
67
|
+
}),
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
/** The stored id itself, once this declaration has one. A 404 here means the row is gone. */
|
|
71
|
+
const fetchById = (id: number) =>
|
|
72
|
+
absentOn404(paperless<PaperlessRow>('GET', `${spec.collection}/${String(id)}/`));
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* ⛔ THE IDENTITY SWITCH (PR 163, red-team HIGH finding — see `matching.ts`'s header). `output`
|
|
76
|
+
* defined means this declaration has already written something: identity is `output.id`, full
|
|
77
|
+
* stop, and a changed `name`/`owner` in `props` is drift to PATCH, never a reason to relocate.
|
|
78
|
+
* `output` undefined means there is nothing to be wrong about yet — locate by name.
|
|
79
|
+
*/
|
|
80
|
+
const fetchLive = <Attributes extends { readonly id: number }>(
|
|
81
|
+
props: Props,
|
|
82
|
+
output: Attributes | undefined,
|
|
83
|
+
) =>
|
|
84
|
+
output === undefined
|
|
85
|
+
? fetchByName(props)
|
|
86
|
+
: fetchById(output.id).pipe(
|
|
87
|
+
Effect.flatMap((live) =>
|
|
88
|
+
live === undefined
|
|
89
|
+
? Effect.die(
|
|
90
|
+
new Error(
|
|
91
|
+
`${spec.describe(props)}: id ${String(output.id)} is recorded in state but no ` +
|
|
92
|
+
'longer exists live (deleted out of band). Refusing to create a replacement ' +
|
|
93
|
+
'— adopt the live object back under this declaration, or remove it from ' +
|
|
94
|
+
'state, before deploying again.',
|
|
95
|
+
),
|
|
96
|
+
)
|
|
97
|
+
: Effect.succeed(live),
|
|
98
|
+
),
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
const objectPath = (live: PaperlessRow): string => `${spec.collection}/${String(live['id'])}`;
|
|
102
|
+
|
|
103
|
+
return { fetchByName, fetchById, fetchLive, objectPath };
|
|
104
|
+
};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The contract every Paperless taxonomy object (Tag, DocumentType, StoragePath, CustomField)
|
|
3
|
+
* declares against `matching.ts`'s generic lifecycle — split into its own file so `matching.ts`
|
|
4
|
+
* itself (the identity/locate/create/patch logic the PR 163 red-team HIGH finding is about) stays
|
|
5
|
+
* under the house's 250-line-per-file cap. See `matching.ts`'s header for the identity design
|
|
6
|
+
* (`output.id` once a resource has state) this `MatchingSpec` shape supports.
|
|
7
|
+
*/
|
|
8
|
+
import type { PaperlessCredentialsError } from './credentials.ts';
|
|
9
|
+
import type { PaperlessError } from './errors.ts';
|
|
10
|
+
import type { PaperlessRequirements, PaperlessRow } from './client.ts';
|
|
11
|
+
import type { EndpointKey } from './constraints.ts';
|
|
12
|
+
|
|
13
|
+
/** = `client.ts`'s `PaperlessRequirements` — `HttpClient` and the credentials service both. */
|
|
14
|
+
export type MatchingRequirements = PaperlessRequirements;
|
|
15
|
+
export type MatchingError = PaperlessError | PaperlessCredentialsError;
|
|
16
|
+
|
|
17
|
+
/** A wire field beyond `name`/`owner`: how to read it from props, and how to read it back. */
|
|
18
|
+
export interface FieldSpec<Props> {
|
|
19
|
+
readonly key: string;
|
|
20
|
+
readonly prop: (props: Props) => unknown;
|
|
21
|
+
readonly live: (row: PaperlessRow) => unknown;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface MatchingProps {
|
|
25
|
+
readonly name: string;
|
|
26
|
+
/** `undefined` means undeclared — the create body still sends `owner: null` (see matching.ts's header). */
|
|
27
|
+
readonly owner?: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface MatchingSpec<
|
|
31
|
+
Props extends MatchingProps,
|
|
32
|
+
Attributes extends { readonly id: number },
|
|
33
|
+
> {
|
|
34
|
+
/** `tags`, `document_types`, `storage_paths`, `custom_fields`. */
|
|
35
|
+
readonly collection: string;
|
|
36
|
+
/**
|
|
37
|
+
* ⚠️ DEFAULT `true`. `Tag`, `DocumentType` and `StoragePath` extend Paperless-ngx's
|
|
38
|
+
* `OwnedObjectSerializer`; `CustomField` does not — its wire body has no `owner` key at all
|
|
39
|
+
* (measured against the v3.1.1 schema: `CustomFieldRequest` is `{name, data_type,
|
|
40
|
+
* extra_data}`). `false` stops `createBody` sending a field the vendor would silently ignore.
|
|
41
|
+
*/
|
|
42
|
+
readonly owned?: boolean;
|
|
43
|
+
/** Compared, PATCHed when they differ, and sent on create. */
|
|
44
|
+
readonly fields: readonly FieldSpec<Props>[];
|
|
45
|
+
/**
|
|
46
|
+
* Sent on CREATE only — never compared, never PATCHed. `CustomField.dataType` (custom-field.ts)
|
|
47
|
+
* is the one user: it must reach the create body, but `immutable` is what refuses a later
|
|
48
|
+
* change, and `matches`/`patchBody` must never see it as ordinary drift.
|
|
49
|
+
*/
|
|
50
|
+
readonly createOnly?: readonly FieldSpec<Props>[];
|
|
51
|
+
readonly attributes: (live: PaperlessRow, props: Props) => Attributes | undefined;
|
|
52
|
+
readonly endpoint: { readonly create: EndpointKey; readonly update?: EndpointKey };
|
|
53
|
+
readonly describe: (props: Props) => string;
|
|
54
|
+
/**
|
|
55
|
+
* A field this object refuses to change once set — `CustomField.dataType` (custom-field.ts):
|
|
56
|
+
* a replace would delete that field's value on every document, so neither a PATCH nor a
|
|
57
|
+
* replace is acceptable. Return the refusal message, or `undefined` when unchanged. Checked in
|
|
58
|
+
* `diff` (plan time) and again in `reconcile` (an adoption `diff` never saw).
|
|
59
|
+
*/
|
|
60
|
+
readonly immutable?: (live: PaperlessRow, props: Props) => string | undefined;
|
|
61
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `matching.ts`'s generic lifecycle against a fake Paperless: owner injection, the version
|
|
3
|
+
* header, 403-never-absent, case-sensitive identity inside a case-insensitive locate, and
|
|
4
|
+
* Unowned-on-every-match (H1).
|
|
5
|
+
*
|
|
6
|
+
* ⛔ THESE TESTS FAIL WITHOUT THE UNIT. `matching.ts`, `client.ts`, `credentials.ts` and
|
|
7
|
+
* `errors.ts` did not exist before this change — there was nothing to import.
|
|
8
|
+
*/
|
|
9
|
+
import { Unowned } from 'alchemy/AdoptPolicy';
|
|
10
|
+
import { describe, expect, test } from 'bun:test';
|
|
11
|
+
import { run, runFailure, withFake } from './fake-paperless.ts';
|
|
12
|
+
import { type MatchingProps, matchingHandlers, matchingOperations } from './matching.ts';
|
|
13
|
+
|
|
14
|
+
interface Props extends MatchingProps {
|
|
15
|
+
note?: string;
|
|
16
|
+
}
|
|
17
|
+
/** ★ A minimal spec — this file tests `matching.ts`'s own contract, not any one family's wiring. */
|
|
18
|
+
const spec = {
|
|
19
|
+
attributes: (live: Record<string, unknown>, props: Props) => {
|
|
20
|
+
const id = live['id'];
|
|
21
|
+
if (typeof id !== 'number') return undefined;
|
|
22
|
+
return {
|
|
23
|
+
id,
|
|
24
|
+
name: typeof live['name'] === 'string' ? live['name'] : props.name,
|
|
25
|
+
note: typeof live['note'] === 'string' ? live['note'] : '',
|
|
26
|
+
owner: typeof live['owner'] === 'number' ? live['owner'] : undefined,
|
|
27
|
+
};
|
|
28
|
+
},
|
|
29
|
+
collection: 'tags' as const,
|
|
30
|
+
describe: (props: Props) => `tags ${props.name}`,
|
|
31
|
+
endpoint: { create: 'paperless:POST /api/tags/', update: 'paperless:PATCH /api/tags/{id}/' },
|
|
32
|
+
fields: [
|
|
33
|
+
{ key: 'note', live: (r: Record<string, unknown>) => r['note'], prop: (p: Props) => p.note },
|
|
34
|
+
],
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
describe('create always carries owner, null when undeclared', () => {
|
|
38
|
+
test('a create with no owner prop sends owner: null', async () => {
|
|
39
|
+
await withFake(async (fake) => {
|
|
40
|
+
const handlers = matchingHandlers(spec);
|
|
41
|
+
await run(handlers.reconcile({ news: { name: 'Invoices' } }));
|
|
42
|
+
const post = fake.seen.find((s) => s.method === 'POST');
|
|
43
|
+
expect(post).toBeDefined();
|
|
44
|
+
expect(JSON.parse(post?.body ?? '{}')).toMatchObject({ name: 'Invoices', owner: null });
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('a create with an owner prop sends that owner', async () => {
|
|
49
|
+
await withFake(async (fake) => {
|
|
50
|
+
const handlers = matchingHandlers(spec);
|
|
51
|
+
await run(handlers.reconcile({ news: { name: 'Receipts', owner: 7 } }));
|
|
52
|
+
const post = fake.seen.find((s) => s.method === 'POST');
|
|
53
|
+
expect(JSON.parse(post?.body ?? '{}')).toMatchObject({ owner: 7 });
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('every request carries Accept: application/json; version=10', async () => {
|
|
59
|
+
await withFake(async (fake) => {
|
|
60
|
+
const handlers = matchingHandlers(spec);
|
|
61
|
+
await run(handlers.reconcile({ news: { name: 'Invoices' } }));
|
|
62
|
+
expect(fake.seen.length).toBeGreaterThan(0);
|
|
63
|
+
for (const call of fake.seen) {
|
|
64
|
+
expect(call.headers.get('Accept')).toBe('application/json; version=10');
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('a no-drift reconcile makes zero writes', async () => {
|
|
70
|
+
await withFake(async (fake) => {
|
|
71
|
+
const handlers = matchingHandlers(spec);
|
|
72
|
+
await run(handlers.reconcile({ news: { name: 'Invoices' } }));
|
|
73
|
+
const before = fake.seen.filter((s) => s.method !== 'GET').length;
|
|
74
|
+
await run(handlers.reconcile({ news: { name: 'Invoices' } }));
|
|
75
|
+
const after = fake.seen.filter((s) => s.method !== 'GET').length;
|
|
76
|
+
expect(after).toBe(before);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('a 403 fails rather than reading as absent', async () => {
|
|
81
|
+
await withFake(async (fake) => {
|
|
82
|
+
const ops = matchingOperations(spec);
|
|
83
|
+
fake.forceStatus = 403;
|
|
84
|
+
const error = await runFailure(ops.read({ name: 'Invoices' }));
|
|
85
|
+
expect((error as { _tag?: string })._tag).toBe('PaperlessUnauthorized');
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('a 404 on read folds to absent (create is the right plan)', async () => {
|
|
90
|
+
await withFake(async () => {
|
|
91
|
+
const ops = matchingOperations(spec);
|
|
92
|
+
expect(await run(ops.read({ name: 'Nothing here' }))).toBeUndefined();
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe('case-insensitive locate, case-sensitive identity', () => {
|
|
97
|
+
test('two differently-cased names never collapse into one object', async () => {
|
|
98
|
+
await withFake(async (fake) => {
|
|
99
|
+
const handlers = matchingHandlers(spec);
|
|
100
|
+
await run(handlers.reconcile({ news: { name: 'Invoices' } }));
|
|
101
|
+
await run(handlers.reconcile({ news: { name: 'invoices' } }));
|
|
102
|
+
expect(fake.rows('tags').length).toBe(2);
|
|
103
|
+
|
|
104
|
+
const ops = matchingOperations(spec);
|
|
105
|
+
const exact = await run(ops.read({ name: 'Invoices' }));
|
|
106
|
+
expect(exact?.name).toBe('Invoices');
|
|
107
|
+
const other = await run(ops.read({ name: 'invoices' }));
|
|
108
|
+
expect(other?.name).toBe('invoices');
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('read answers Unowned on a match, even one identical to the declaration (H1)', async () => {
|
|
114
|
+
await withFake(async () => {
|
|
115
|
+
const handlers = matchingHandlers(spec);
|
|
116
|
+
await run(handlers.reconcile({ news: { name: 'Invoices' } }));
|
|
117
|
+
const result = await run(handlers.read({ olds: { name: 'Invoices' } }));
|
|
118
|
+
expect(result).toBeDefined();
|
|
119
|
+
expect(Unowned.is(result)).toBe(true);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('read answers undefined, not Unowned, when nothing matches', async () => {
|
|
124
|
+
await withFake(async () => {
|
|
125
|
+
const handlers = matchingHandlers(spec);
|
|
126
|
+
expect(await run(handlers.read({ olds: { name: 'Nothing here' } }))).toBeUndefined();
|
|
127
|
+
});
|
|
128
|
+
});
|