@proteinjs/db-file-storage-driver-gcs 1.4.32 → 1.5.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.
@@ -0,0 +1,128 @@
1
+ import { GoogleCloudStorageDriver } from '../src/GoogleCloudStorageDriver';
2
+
3
+ type StoredObject = { bytes: Buffer; generation: number; metadata: Record<string, any> };
4
+
5
+ /**
6
+ * An in-memory bucket keyed by OBJECT NAME — the one fact this suite is about. Every storage call
7
+ * the driver makes goes through `bucket.file(name)`, so whatever name the driver decides is the
8
+ * key an object is stored, read, signed and deleted under here.
9
+ */
10
+ const objects = new Map<string, StoredObject>();
11
+ let nextGeneration = 1;
12
+
13
+ const fileMock = jest.fn((name: string) => ({
14
+ save: async (bytes: Buffer, options?: { metadata?: Record<string, any> }) => {
15
+ objects.set(name, { bytes, generation: nextGeneration++, metadata: { ...(options?.metadata ?? {}) } });
16
+ },
17
+ download: async () => {
18
+ const existing = objects.get(name);
19
+ if (!existing) {
20
+ throw Object.assign(new Error(`No such object: ${name}`), { code: 404 });
21
+ }
22
+ return [existing.bytes];
23
+ },
24
+ getMetadata: async () => {
25
+ const existing = objects.get(name);
26
+ if (!existing) {
27
+ throw Object.assign(new Error(`No such object: ${name}`), { code: 404 });
28
+ }
29
+ return [{ ...existing.metadata, generation: String(existing.generation) }, {}];
30
+ },
31
+ setMetadata: async (metadata: Record<string, any>) => {
32
+ const existing = objects.get(name);
33
+ if (!existing) {
34
+ throw Object.assign(new Error(`No such object: ${name}`), { code: 404 });
35
+ }
36
+ existing.metadata = { ...existing.metadata, ...metadata };
37
+ },
38
+ getSignedUrl: async () => [`https://storage.example/test-bucket/${name}?signed`],
39
+ delete: async () => {
40
+ objects.delete(name);
41
+ },
42
+ }));
43
+ const bucketMock = jest.fn(() => ({ file: fileMock }));
44
+
45
+ jest.mock('@google-cloud/storage', () => ({
46
+ Storage: jest.fn(() => ({ bucket: bucketMock })),
47
+ }));
48
+
49
+ /**
50
+ * One bucket can hold the files of several independent deployments of an application — each a
51
+ * driver with its own `objectPrefix`. The prefix is only worth having if EVERY operation honours
52
+ * it: an object written under a prefix and read, signed or deleted by its bare id is a file that
53
+ * cannot be found, and a deployment's files could not be listed or removed as a group.
54
+ */
55
+ describe('GoogleCloudStorageDriver objectPrefix', () => {
56
+ const bytes = Buffer.from('the bytes of a picture');
57
+ const picture = (id: string) => ({ id, name: 'holiday.jpg', type: 'image/jpeg', size: bytes.length }) as never;
58
+
59
+ beforeEach(() => {
60
+ objects.clear();
61
+ nextGeneration = 1;
62
+ });
63
+
64
+ it('stores a file under the prefix, never under its bare id', async () => {
65
+ const driver = new GoogleCloudStorageDriver({
66
+ projectId: 'test-project',
67
+ bucketName: 'test-bucket',
68
+ objectPrefix: 'deployment-a/',
69
+ });
70
+
71
+ await driver.createFile(picture('file-1'), bytes.toString('base64'));
72
+
73
+ expect(Array.from(objects.keys())).toEqual(['deployment-a/file-1']);
74
+ // The file's own id is what the object says about itself — the prefix names where, not what.
75
+ expect(objects.get('deployment-a/file-1')!.metadata.metadata.fileId).toBe('file-1');
76
+ });
77
+
78
+ it('every operation reaches the object it wrote: read, overwrite, describe, sign, delete', async () => {
79
+ const driver = new GoogleCloudStorageDriver({
80
+ projectId: 'test-project',
81
+ bucketName: 'test-bucket',
82
+ objectPrefix: 'deployment-a/',
83
+ });
84
+ await driver.createFile(picture('file-1'), bytes.toString('base64'));
85
+
86
+ expect(await driver.getFileData('file-1')).toBe(bytes.toString('base64'));
87
+
88
+ const rewritten = Buffer.from('the same picture, rewritten');
89
+ await driver.updateFileData('file-1', rewritten.toString('base64'));
90
+ expect(objects.get('deployment-a/file-1')!.bytes.equals(rewritten)).toBe(true);
91
+
92
+ await driver.updateFile({ id: 'file-1', name: 'renamed.jpg', type: 'image/jpeg', size: rewritten.length } as never);
93
+ expect(objects.get('deployment-a/file-1')!.metadata.metadata.fileName).toBe('renamed.jpg');
94
+
95
+ expect(await driver.getSignedUrl('file-1')).toBe('https://storage.example/test-bucket/deployment-a/file-1?signed');
96
+
97
+ await driver.deleteFile('file-1');
98
+ expect(Array.from(objects.keys())).toEqual([]);
99
+ });
100
+
101
+ it('two prefixes in one bucket never reach each other’s files', async () => {
102
+ const a = new GoogleCloudStorageDriver({
103
+ projectId: 'test-project',
104
+ bucketName: 'test-bucket',
105
+ objectPrefix: 'deployment-a/',
106
+ });
107
+ const b = new GoogleCloudStorageDriver({
108
+ projectId: 'test-project',
109
+ bucketName: 'test-bucket',
110
+ objectPrefix: 'deployment-b/',
111
+ });
112
+ await a.createFile(picture('file-1'), bytes.toString('base64'));
113
+
114
+ await expect(b.getFileData('file-1')).rejects.toMatchObject({ code: 'not-found' });
115
+ await b.deleteFile('file-1');
116
+
117
+ expect(Array.from(objects.keys())).toEqual(['deployment-a/file-1']);
118
+ });
119
+
120
+ it('with no prefix configured an object is named by the file id alone', async () => {
121
+ const driver = new GoogleCloudStorageDriver({ projectId: 'test-project', bucketName: 'test-bucket' });
122
+
123
+ await driver.createFile(picture('file-1'), bytes.toString('base64'));
124
+
125
+ expect(Array.from(objects.keys())).toEqual(['file-1']);
126
+ expect(await driver.getFileData('file-1')).toBe(bytes.toString('base64'));
127
+ });
128
+ });
@@ -0,0 +1,146 @@
1
+ import { GoogleCloudStorageDriver } from '../src/GoogleCloudStorageDriver';
2
+
3
+ type StoredObject = {
4
+ bytes: Buffer;
5
+ generation: number;
6
+ metadata: Record<string, any>;
7
+ };
8
+
9
+ /**
10
+ * A small in-memory stand-in for the storage SDK that keeps the one service behaviour this suite
11
+ * is about: writing an object's bytes creates a NEW GENERATION, and a new generation holds only
12
+ * the metadata sent with that write — nothing of the previous generation's content type or custom
13
+ * metadata survives by itself. With no content type sent, the SDK can only infer one from the
14
+ * object name's extension; the driver names objects by file id, so there is none and the service
15
+ * falls back to `application/octet-stream`.
16
+ */
17
+ const objects = new Map<string, StoredObject>();
18
+ let nextGeneration = 1;
19
+ let afterMetadataRead: ((name: string) => void) | undefined;
20
+
21
+ const fileMock = jest.fn((name: string) => ({
22
+ save: async (bytes: Buffer, options?: { metadata?: Record<string, any>; preconditionOpts?: Record<string, any> }) => {
23
+ const existing = objects.get(name);
24
+ const expectedGeneration = options?.preconditionOpts?.ifGenerationMatch;
25
+ if (expectedGeneration !== undefined && String(existing?.generation ?? 0) !== String(expectedGeneration)) {
26
+ throw Object.assign(new Error('conditionNotMet'), { code: 412 });
27
+ }
28
+
29
+ const sent = options?.metadata ?? {};
30
+ objects.set(name, {
31
+ bytes,
32
+ generation: nextGeneration++,
33
+ metadata: { ...sent, contentType: sent.contentType ?? 'application/octet-stream' },
34
+ });
35
+ },
36
+ getMetadata: async () => {
37
+ const existing = objects.get(name);
38
+ if (!existing) {
39
+ throw Object.assign(new Error(`No such object: ${name}`), { code: 404 });
40
+ }
41
+
42
+ const snapshot = { ...existing.metadata, generation: String(existing.generation) };
43
+ afterMetadataRead?.(name);
44
+ return [snapshot, {}];
45
+ },
46
+ }));
47
+ const bucketMock = jest.fn(() => ({ file: fileMock }));
48
+
49
+ jest.mock('@google-cloud/storage', () => ({
50
+ Storage: jest.fn(() => ({ bucket: bucketMock })),
51
+ }));
52
+
53
+ /**
54
+ * `GET /file/:id` redirects the browser to the object itself, so the object's own content type is
55
+ * what the browser is told. A byte overwrite that loses it turns every rewritten picture and clip
56
+ * into an `application/octet-stream` download. The driver is the only party that talks to the
57
+ * store, so the overwrite restates what the object already says about itself.
58
+ */
59
+ describe('GoogleCloudStorageDriver.updateFileData keeps the object metadata', () => {
60
+ const driver = new GoogleCloudStorageDriver({ projectId: 'test-project', bucketName: 'test-bucket' });
61
+ const firstBytes = Buffer.from('first bytes of the picture');
62
+ const nextBytes = Buffer.from('the same picture, rewritten smaller');
63
+
64
+ beforeEach(() => {
65
+ objects.clear();
66
+ nextGeneration = 1;
67
+ afterMetadataRead = undefined;
68
+ });
69
+
70
+ async function createPicture(id: string) {
71
+ await driver.createFile(
72
+ { id, name: 'holiday.jpg', type: 'image/jpeg', size: firstBytes.length } as never,
73
+ firstBytes.toString('base64')
74
+ );
75
+ }
76
+
77
+ it('an overwritten object keeps its content type', async () => {
78
+ await createPicture('0b9c6d1e-object-id-without-extension');
79
+ const before = objects.get('0b9c6d1e-object-id-without-extension')!.metadata.contentType;
80
+
81
+ await driver.updateFileData('0b9c6d1e-object-id-without-extension', nextBytes.toString('base64'));
82
+
83
+ const after = objects.get('0b9c6d1e-object-id-without-extension')!;
84
+ expect(before).toEqual('image/jpeg');
85
+ expect(after.metadata.contentType).toEqual('image/jpeg');
86
+ expect(Buffer.compare(after.bytes, nextBytes)).toBe(0);
87
+ });
88
+
89
+ it('an overwritten object keeps its custom metadata, with the size restated for the new bytes', async () => {
90
+ await createPicture('file-2');
91
+
92
+ await driver.updateFileData('file-2', nextBytes.toString('base64'));
93
+
94
+ expect(objects.get('file-2')!.metadata.metadata).toEqual({
95
+ fileId: 'file-2',
96
+ fileName: 'holiday.jpg',
97
+ fileSize: nextBytes.length.toString(),
98
+ });
99
+ });
100
+
101
+ it('an overwritten object keeps its cache, disposition and language headers', async () => {
102
+ await createPicture('file-3');
103
+ Object.assign(objects.get('file-3')!.metadata, {
104
+ cacheControl: 'private, max-age=3600',
105
+ contentDisposition: 'inline',
106
+ contentLanguage: 'en',
107
+ });
108
+
109
+ await driver.updateFileData('file-3', nextBytes.toString('base64'));
110
+
111
+ expect(objects.get('file-3')!.metadata).toMatchObject({
112
+ contentType: 'image/jpeg',
113
+ cacheControl: 'private, max-age=3600',
114
+ contentDisposition: 'inline',
115
+ contentLanguage: 'en',
116
+ });
117
+ });
118
+
119
+ it('an overwrite that raced another writer fails loudly and leaves the other write in place', async () => {
120
+ await createPicture('file-4');
121
+ const otherWritersBytes = Buffer.from('another writer got there first');
122
+ afterMetadataRead = (name) => {
123
+ afterMetadataRead = undefined;
124
+ const current = objects.get(name)!;
125
+ objects.set(name, { ...current, bytes: otherWritersBytes, generation: nextGeneration++ });
126
+ };
127
+
128
+ await expect(driver.updateFileData('file-4', nextBytes.toString('base64'))).rejects.toMatchObject({
129
+ name: 'FileStorageError',
130
+ code: 'precondition-failed',
131
+ status: 412,
132
+ });
133
+
134
+ expect(Buffer.compare(objects.get('file-4')!.bytes, otherWritersBytes)).toBe(0);
135
+ });
136
+
137
+ it('overwriting an object that does not exist fails loudly and creates nothing', async () => {
138
+ await expect(driver.updateFileData('file-5', nextBytes.toString('base64'))).rejects.toMatchObject({
139
+ name: 'FileStorageError',
140
+ code: 'not-found',
141
+ status: 404,
142
+ });
143
+
144
+ expect(objects.has('file-5')).toBe(false);
145
+ });
146
+ });