pyric-admin 0.1.0-alpha.11 → 0.1.0-alpha.13
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.orig +2 -0
- package/package.json +7 -2
- package/src/app/index.ts +235 -0
- package/src/app/lifecycle.ts +17 -0
- package/src/auth/auth.test.ts +322 -0
- package/src/auth/index.ts +905 -0
- package/src/database/database.test.ts +196 -0
- package/src/database/index.ts +1187 -0
- package/src/firestore/index.ts +100 -0
- package/src/messaging/index.ts +319 -0
- package/src/storage/index.ts +615 -0
- package/src/storage/storage.test.ts +236 -0
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for `pyric-admin/storage`.
|
|
3
|
+
*
|
|
4
|
+
* Coverage:
|
|
5
|
+
* - Prod dispatch → handing a {@link ADMIN_APP_TARGET}: 'prod' app
|
|
6
|
+
* reaches `firebase-admin/storage`'s `getStorage`. Mocked to stay
|
|
7
|
+
* hermetic (no GCP credentials, no network).
|
|
8
|
+
* - Sandbox backend → save/download round-trip, exists/delete,
|
|
9
|
+
* getSignedUrl shape, multi-bucket isolation, reset clears state.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, it, expect } from 'bun:test';
|
|
13
|
+
|
|
14
|
+
import { initializeSandbox } from 'pyric/sandbox';
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
ADMIN_APP_TARGET,
|
|
18
|
+
type PyricAdminApp,
|
|
19
|
+
type SandboxAdminApp,
|
|
20
|
+
} from '../app/index.js';
|
|
21
|
+
import { getStorage } from './index.js';
|
|
22
|
+
|
|
23
|
+
// ── Helpers ───────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Construct a sandbox-shaped `PyricAdminApp` directly. We bypass
|
|
27
|
+
* `initializeApp` so the tests don't accidentally depend on app-level
|
|
28
|
+
* state — the storage backend reads `app.sandbox` and the
|
|
29
|
+
* `ADMIN_APP_TARGET` brand, and that's the only surface that matters
|
|
30
|
+
* here.
|
|
31
|
+
*/
|
|
32
|
+
function sandboxAdminApp(): SandboxAdminApp {
|
|
33
|
+
const sandbox = initializeSandbox();
|
|
34
|
+
return {
|
|
35
|
+
[ADMIN_APP_TARGET]: 'sandbox',
|
|
36
|
+
sandbox,
|
|
37
|
+
name: 'storage-test',
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ── Sandbox backend ───────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
describe('pyric-admin/storage — sandbox backend', () => {
|
|
44
|
+
it('round-trips a save/download via the default bucket', async () => {
|
|
45
|
+
const app = sandboxAdminApp();
|
|
46
|
+
const storage = getStorage(app);
|
|
47
|
+
const file = storage.bucket().file('hello.txt');
|
|
48
|
+
|
|
49
|
+
await file.save('hello world');
|
|
50
|
+
const [downloaded] = await file.download();
|
|
51
|
+
|
|
52
|
+
expect(downloaded.toString('utf8')).toBe('hello world');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('round-trips Buffer and Uint8Array payloads', async () => {
|
|
56
|
+
const app = sandboxAdminApp();
|
|
57
|
+
const storage = getStorage(app);
|
|
58
|
+
|
|
59
|
+
const bufFile = storage.bucket().file('buf.bin');
|
|
60
|
+
await bufFile.save(Buffer.from([1, 2, 3, 4]));
|
|
61
|
+
const [bufOut] = await bufFile.download();
|
|
62
|
+
expect(Array.from(bufOut)).toEqual([1, 2, 3, 4]);
|
|
63
|
+
|
|
64
|
+
const u8File = storage.bucket().file('u8.bin');
|
|
65
|
+
await u8File.save(new Uint8Array([9, 8, 7]));
|
|
66
|
+
const [u8Out] = await u8File.download();
|
|
67
|
+
expect(Array.from(u8Out)).toEqual([9, 8, 7]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('copies bytes on save so callers can mutate their input safely', async () => {
|
|
71
|
+
const app = sandboxAdminApp();
|
|
72
|
+
const storage = getStorage(app);
|
|
73
|
+
const file = storage.bucket().file('mut.bin');
|
|
74
|
+
|
|
75
|
+
const payload = Buffer.from([1, 2, 3]);
|
|
76
|
+
await file.save(payload);
|
|
77
|
+
payload[0] = 99;
|
|
78
|
+
|
|
79
|
+
const [out] = await file.download();
|
|
80
|
+
expect(Array.from(out)).toEqual([1, 2, 3]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('exists() reports the correct presence before and after save', async () => {
|
|
84
|
+
const app = sandboxAdminApp();
|
|
85
|
+
const storage = getStorage(app);
|
|
86
|
+
const file = storage.bucket().file('exists.txt');
|
|
87
|
+
|
|
88
|
+
expect(await file.exists()).toEqual([false]);
|
|
89
|
+
await file.save('ok');
|
|
90
|
+
expect(await file.exists()).toEqual([true]);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('download() throws on a missing file with a GCS-shaped error message', async () => {
|
|
94
|
+
const app = sandboxAdminApp();
|
|
95
|
+
const storage = getStorage(app);
|
|
96
|
+
const file = storage.bucket('my-bucket').file('missing.bin');
|
|
97
|
+
|
|
98
|
+
await expect(file.download()).rejects.toThrow(/No such object: my-bucket\/missing\.bin/);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('delete() removes the file and is idempotent on missing paths', async () => {
|
|
102
|
+
const app = sandboxAdminApp();
|
|
103
|
+
const storage = getStorage(app);
|
|
104
|
+
const file = storage.bucket().file('to-delete.txt');
|
|
105
|
+
|
|
106
|
+
await file.save('bye');
|
|
107
|
+
expect(await file.exists()).toEqual([true]);
|
|
108
|
+
|
|
109
|
+
await file.delete();
|
|
110
|
+
expect(await file.exists()).toEqual([false]);
|
|
111
|
+
|
|
112
|
+
// Idempotent — second delete on a missing file must not throw.
|
|
113
|
+
await file.delete();
|
|
114
|
+
expect(await file.exists()).toEqual([false]);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('getSignedUrl returns a stub URL with bucket, path, expires, action', async () => {
|
|
118
|
+
const app = sandboxAdminApp();
|
|
119
|
+
const storage = getStorage(app);
|
|
120
|
+
const file = storage.bucket('signed-bucket').file('doc.pdf');
|
|
121
|
+
await file.save('content');
|
|
122
|
+
|
|
123
|
+
const [url] = await file.getSignedUrl({
|
|
124
|
+
action: 'read',
|
|
125
|
+
expires: 1_700_000_000_000,
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
expect(url).toBe(
|
|
129
|
+
'pyric-sandbox-storage://signed-bucket/doc.pdf?expires=1700000000000&action=read',
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('getSignedUrl accepts Date and ISO string forms for expires', async () => {
|
|
134
|
+
const app = sandboxAdminApp();
|
|
135
|
+
const storage = getStorage(app);
|
|
136
|
+
const file = storage.bucket().file('any.txt');
|
|
137
|
+
|
|
138
|
+
const ms = 1_700_000_000_000;
|
|
139
|
+
const [fromDate] = await file.getSignedUrl({
|
|
140
|
+
action: 'read',
|
|
141
|
+
expires: new Date(ms),
|
|
142
|
+
});
|
|
143
|
+
expect(fromDate).toContain(`expires=${ms}`);
|
|
144
|
+
|
|
145
|
+
const [fromIso] = await file.getSignedUrl({
|
|
146
|
+
action: 'write',
|
|
147
|
+
expires: new Date(ms).toISOString(),
|
|
148
|
+
});
|
|
149
|
+
expect(fromIso).toContain(`expires=${ms}`);
|
|
150
|
+
expect(fromIso).toContain('action=write');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('isolates state across distinct bucket names', async () => {
|
|
154
|
+
const app = sandboxAdminApp();
|
|
155
|
+
const storage = getStorage(app);
|
|
156
|
+
|
|
157
|
+
await storage.bucket('alpha').file('shared.txt').save('A');
|
|
158
|
+
await storage.bucket('beta').file('shared.txt').save('B');
|
|
159
|
+
|
|
160
|
+
const [alphaOut] = await storage.bucket('alpha').file('shared.txt').download();
|
|
161
|
+
const [betaOut] = await storage.bucket('beta').file('shared.txt').download();
|
|
162
|
+
expect(alphaOut.toString('utf8')).toBe('A');
|
|
163
|
+
expect(betaOut.toString('utf8')).toBe('B');
|
|
164
|
+
|
|
165
|
+
// Cross-bucket lookup of the same path on a third bucket is empty.
|
|
166
|
+
expect(await storage.bucket('gamma').file('shared.txt').exists()).toEqual([false]);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('bucket(name) returns handles bound to the same underlying state across calls', async () => {
|
|
170
|
+
const app = sandboxAdminApp();
|
|
171
|
+
const storage = getStorage(app);
|
|
172
|
+
|
|
173
|
+
await storage.bucket('persistent').file('x.txt').save('first');
|
|
174
|
+
|
|
175
|
+
// A fresh bucket handle for the same name must see the prior save.
|
|
176
|
+
const secondHandle = storage.bucket('persistent').file('x.txt');
|
|
177
|
+
expect(await secondHandle.exists()).toEqual([true]);
|
|
178
|
+
const [out] = await secondHandle.download();
|
|
179
|
+
expect(out.toString('utf8')).toBe('first');
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('sandbox.reset() clears every bucket', async () => {
|
|
183
|
+
const app = sandboxAdminApp();
|
|
184
|
+
const storage = getStorage(app);
|
|
185
|
+
|
|
186
|
+
await storage.bucket('one').file('a.txt').save('A');
|
|
187
|
+
await storage.bucket('two').file('b.txt').save('B');
|
|
188
|
+
|
|
189
|
+
expect(await storage.bucket('one').file('a.txt').exists()).toEqual([true]);
|
|
190
|
+
expect(await storage.bucket('two').file('b.txt').exists()).toEqual([true]);
|
|
191
|
+
|
|
192
|
+
app.sandbox.reset();
|
|
193
|
+
|
|
194
|
+
expect(await storage.bucket('one').file('a.txt').exists()).toEqual([false]);
|
|
195
|
+
expect(await storage.bucket('two').file('b.txt').exists()).toEqual([false]);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('resumable: true on save throws the deferred-feature error', async () => {
|
|
199
|
+
const app = sandboxAdminApp();
|
|
200
|
+
const storage = getStorage(app);
|
|
201
|
+
const file = storage.bucket().file('big.bin');
|
|
202
|
+
|
|
203
|
+
await expect(file.save('payload', { resumable: true })).rejects.toThrow(
|
|
204
|
+
/not implemented in pyric-admin\/storage sandbox backend/,
|
|
205
|
+
);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('stores contentType and metadata payloads alongside the bytes', async () => {
|
|
209
|
+
// The interface doesn't surface metadata reads yet, but the save
|
|
210
|
+
// path must accept the options without error and the bytes must
|
|
211
|
+
// still round-trip correctly. This guards against future code that
|
|
212
|
+
// breaks the option pass-through silently.
|
|
213
|
+
const app = sandboxAdminApp();
|
|
214
|
+
const storage = getStorage(app);
|
|
215
|
+
const file = storage.bucket().file('meta.json');
|
|
216
|
+
|
|
217
|
+
await file.save('{"k":1}', {
|
|
218
|
+
contentType: 'application/json',
|
|
219
|
+
metadata: { custom: 'value' },
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
const [out] = await file.download();
|
|
223
|
+
expect(out.toString('utf8')).toBe('{"k":1}');
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// ── Input validation ──────────────────────────────────────────────────
|
|
228
|
+
|
|
229
|
+
describe('pyric-admin/storage — input validation', () => {
|
|
230
|
+
it('throws a TypeError when handed an object with no ADMIN_APP_TARGET brand', () => {
|
|
231
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
232
|
+
expect(() => getStorage({} as any)).toThrow(TypeError);
|
|
233
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
234
|
+
expect(() => getStorage({} as any)).toThrow(/PyricAdminApp from `initializeApp`/);
|
|
235
|
+
});
|
|
236
|
+
});
|