Skip to content

Commit

Permalink
chore(storage): Add type tests & extra tests for savers and result ex…
Browse files Browse the repository at this point in the history
…tension (#11416)
  • Loading branch information
dac09 authored Sep 6, 2024
1 parent 12ec12a commit 3baacb6
Show file tree
Hide file tree
Showing 14 changed files with 229 additions and 39 deletions.
1 change: 1 addition & 0 deletions packages/framework-tools/src/buildDefaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export const defaultIgnorePatterns = [
'**/__fixtures__',
'**/testUtils',
'**/__testfixtures__',
'**/__typetests__',
]

interface BuildOptions {
Expand Down
6 changes: 4 additions & 2 deletions packages/storage/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,13 @@
"scripts": {
"build": "yarn setup:test && tsx ./build.mts",
"build:pack": "yarn pack -o redwoodjs-storage.tgz",
"build:types": "tsc --build --verbose",
"build:types": "tsc --build --verbose ./tsconfig.build.json",
"build:types-cjs": "tsc --build --verbose tsconfig.types-cjs.json",
"check:attw": "tsx attw.ts",
"check:package": "concurrently npm:check:attw yarn publint",
"setup:test": "npx prisma db push --accept-data-loss --schema ./src/__tests__/unit-test-schema.prisma",
"test": "vitest run",
"test:types": "yarn setup:test && tstyche",
"test:watch": "vitest watch"
},
"dependencies": {
Expand All @@ -64,9 +65,10 @@
"concurrently": "8.2.2",
"esbuild": "0.23.1",
"publint": "0.2.10",
"tstyche": "2.1.1",
"tsx": "4.17.0",
"typescript": "5.5.4",
"vitest": "2.0.5"
},
"gitHead": "3905ed045508b861b495f8d5630d76c7a157d8f1"
}
}
8 changes: 5 additions & 3 deletions packages/storage/src/__tests__/createSavers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,20 @@ import { ensurePosixPath } from '@redwoodjs/project-config'

import { MemoryStorage } from '../adapters/MemoryStorage/MemoryStorage.js'
import { createUploadSavers } from '../createSavers.js'
import type { UploadsConfig } from '../prismaExtension.js'
import { createUploadsConfig } from '../index.js'

const memStore = new MemoryStorage({
baseDir: '/memory_store_basedir',
})

const uploadsConfig: UploadsConfig = {
const uploadsConfig = createUploadsConfig({
dumbo: {
fields: ['firstUpload', 'secondUpload'],
},
dummy: {
fields: 'uploadField',
},
}
})

describe('Create savers', () => {
const fileToStorage = createUploadSavers(uploadsConfig, memStore)
Expand All @@ -27,7 +27,9 @@ describe('Create savers', () => {
expect(fileToStorage.forDummy).toBeDefined()

// These are in the schema but not in the config
// @ts-expect-error - testing!
expect(fileToStorage.forBook).not.toBeDefined()
// @ts-expect-error - testing!
expect(fileToStorage.forNoUploadFields).not.toBeDefined()
})

Expand Down
7 changes: 3 additions & 4 deletions packages/storage/src/__tests__/queryExtensions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,7 @@ import { describe, it, vi, expect, beforeEach, beforeAll } from 'vitest'
import { ensurePosixPath } from '@redwoodjs/project-config'

import { FileSystemStorage } from '../adapters/FileSystemStorage/FileSystemStorage.js'
import { setupStorage } from '../index.js'
import type { UploadsConfig } from '../prismaExtension.js'
import { createUploadsConfig, setupStorage } from '../index.js'

// @MARK: use the local prisma client in the test
import type { Dumbo, Dummy } from './prisma-client/index.js'
Expand All @@ -31,14 +30,14 @@ vi.mock('node:fs', () => ({
}))

describe('Query extensions', () => {
const uploadsConfig: UploadsConfig = {
const uploadsConfig = createUploadsConfig({
dummy: {
fields: 'uploadField',
},
dumbo: {
fields: ['firstUpload', 'secondUpload'],
},
}
})

const { storagePrismaExtension, saveFiles } = setupStorage({
uploadsConfig: uploadsConfig,
Expand Down
108 changes: 82 additions & 26 deletions packages/storage/src/__tests__/resultExtensions.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { describe, it, expect, vi } from 'vitest'

import { MemoryStorage } from '../adapters/MemoryStorage/MemoryStorage.js'
import { setupStorage } from '../index.js'
import type { UploadsConfig } from '../prismaExtension.js'
import { createUploadsConfig, setupStorage } from '../index.js'
import { UrlSigner } from '../UrlSigner.js'

// @MARK: use the local prisma client in the test
Expand All @@ -23,20 +22,22 @@ vi.mock('@redwoodjs/project-config', async (importOriginal) => {
})

describe('Result extensions', () => {
const uploadsConfig: UploadsConfig = {
const uploadsConfig = createUploadsConfig({
dummy: {
fields: 'uploadField',
},
dumbo: {
fields: ['firstUpload', 'secondUpload'],
},
}
})

const memStorage = new MemoryStorage({
baseDir: '/tmp',
})

const { storagePrismaExtension } = setupStorage({
uploadsConfig,
storageAdapter: new MemoryStorage({
baseDir: '/tmp',
}),
storageAdapter: memStorage,
urlSigner: new UrlSigner({
endpoint: '/signed-url',
secret: 'my-sekret',
Expand All @@ -45,25 +46,80 @@ describe('Result extensions', () => {

const prismaClient = new PrismaClient().$extends(storagePrismaExtension)

describe('withSignedUrl', () => {
it('Generates signed urls for each upload field', async () => {
const dumbo = await prismaClient.dumbo.create({
data: {
firstUpload: '/dumbo/first.txt',
secondUpload: '/dumbo/second.txt',
},
})

const signedUrlDumbo = await dumbo.withSignedUrl({
expiresIn: 254,
})
expect(signedUrlDumbo.firstUpload).toContain(
'/.redwood/functions/signed-url',
)
expect(signedUrlDumbo.firstUpload).toContain('path=%2Fdumbo%2Ffirst.txt')
expect(signedUrlDumbo.secondUpload).toContain(
'path=%2Fdumbo%2Fsecond.txt',
)
it('Adds signedURL and dataURI extensions', async () => {
const dummy = await prismaClient.dummy.create({
data: {
uploadField: '/dummy/upload.txt',
},
})

expect(dummy).toHaveProperty('withSignedUrl')
expect(dummy).toHaveProperty('withDataUri')
})

it('Does not add it to models without upload fields', async () => {
const noUpload = await prismaClient.noUploadFields.create({
data: {
name: 'no-upload',
},
})

expect(noUpload).not.toHaveProperty('withSignedUrl')
expect(noUpload).not.toHaveProperty('withDataUri')
})

it('Generates signed urls for each upload field', async () => {
const dumbo = await prismaClient.dumbo.create({
data: {
firstUpload: '/dumbo/first.txt',
secondUpload: '/dumbo/second.txt',
},
})

const signedUrlDumbo = dumbo.withSignedUrl({
expiresIn: 254,
})

expect(signedUrlDumbo.firstUpload).toContain(
'/.redwood/functions/signed-url',
)
expect(signedUrlDumbo.firstUpload).toContain('path=%2Fdumbo%2Ffirst.txt')
expect(signedUrlDumbo.secondUpload).toContain('path=%2Fdumbo%2Fsecond.txt')
})

it('Generates data uris for each upload field', async () => {
// Save these files to disk
const { location: firstUploadLocation } = await memStorage.save(
new File(['SOFT_KITTENS'], 'first.txt'),
{
fileName: 'first.txt',
path: '/dumbo',
},
)
const { location: secondUploadLocation } = await memStorage.save(
new File(['PURR_PURR'], 'second.txt'),
{
fileName: 'second.txt',
path: '/dumbo',
},
)

const dumbo = await prismaClient.dumbo.create({
data: {
firstUpload: firstUploadLocation,
secondUpload: secondUploadLocation,
},
})

// Note that this is async!
const signedUrlDumbo = await dumbo.withDataUri()

expect(signedUrlDumbo.firstUpload).toMatch(
`data:text/plain;base64,${Buffer.from('SOFT_KITTENS').toString('base64')}`,
)

expect(signedUrlDumbo.secondUpload).toMatch(
`data:text/plain;base64,${Buffer.from('PURR_PURR').toString('base64')}`,
)
})
})
78 changes: 78 additions & 0 deletions packages/storage/src/__typetests__/types.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { expect, test } from 'tstyche'

import { createUploadsConfig, setupStorage } from 'src/index.js'

import { MemoryStorage } from '../adapters/MemoryStorage/MemoryStorage.js'
import { type UploadsConfig } from '../prismaExtension.js'

// Use the createUplodsConfig helper here....
// otherwise the types won't be accurate
const uploadsConfig = createUploadsConfig({
dummy: {
fields: 'uploadField',
},
dumbo: {
fields: ['firstUpload', 'secondUpload'],
},
})

const { saveFiles } = setupStorage({
uploadsConfig,
storageAdapter: new MemoryStorage({
baseDir: '/tmp',
}),
})

// const prismaClient = new PrismaClient().$extends(storagePrismaExtension)

test('only configured models have savers', async () => {
expect(saveFiles).type.toHaveProperty('forDummy')
expect(saveFiles).type.toHaveProperty('forDumbo')

// These weren't configured above
expect(saveFiles).type.not.toHaveProperty('forNoUploadFields')
expect(saveFiles).type.not.toHaveProperty('forBook')
expect(saveFiles).type.not.toHaveProperty('forBookCover')
})

test('inline config for save files is OK!', () => {
const { saveFiles } = setupStorage({
uploadsConfig: {
bookCover: {
fields: 'photo',
},
},
storageAdapter: new MemoryStorage({
baseDir: '/tmp',
}),
})

expect(saveFiles).type.toHaveProperty('forBookCover')
expect(saveFiles).type.not.toHaveProperty('forDummy')
expect(saveFiles).type.not.toHaveProperty('forDumbo')
})

test('UploadsConfig accepts all available models with their fields', async () => {
expect<UploadsConfig>().type.toHaveProperty('dummy')
expect<UploadsConfig>().type.toHaveProperty('dumbo')
expect<UploadsConfig>().type.toHaveProperty('book')
expect<UploadsConfig>().type.toHaveProperty('bookCover')
expect<UploadsConfig>().type.toHaveProperty('noUploadFields')

expect<UploadsConfig['dumbo']>().type.toBeAssignableWith<{
fields: ['firstUpload'] // one of the fields, but not all of them
}>()

expect<UploadsConfig['dumbo']>().type.toBeAssignableWith<{
fields: ['firstUpload', 'secondUpload'] // one of the fields, but not all of them
}>()

expect<UploadsConfig['bookCover']>().type.toBeAssignableWith<{
fields: 'photo'
}>()

// If you give it something else, it won't accept it
expect<UploadsConfig['bookCover']>().type.not.toBeAssignableWith<{
fields: ['bazinga']
}>()
})
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ describe('FileSystemStorage', () => {

beforeEach(() => {
vol.reset()
// Avoiding printing on stdout
vi.spyOn(console, 'log').mockImplementation(() => {})

storage = new FileSystemStorage({ baseDir })
})

Expand Down
10 changes: 10 additions & 0 deletions packages/storage/src/adapters/MemoryStorage/MemoryStorage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,16 @@ describe('MemoryStorage', () => {
expect(storage.store[result.location]).toBeDefined()
})

test('read should return file contents and type', async () => {
const file = new File(['ABCDEF'], 'test.txt', { type: 'image/png' })
const { location } = await storage.save(file)

const result = await storage.read(location)
expect(result.contents).toBeInstanceOf(Buffer)
expect(result.contents.toString()).toBe('ABCDEF')
expect(result.type).toBe('image/png')
})

test('remove should delete a file from memory', async () => {
const file = new File(['test content'], 'test.txt', { type: 'text/plain' })
const { location } = await storage.save(file)
Expand Down
22 changes: 21 additions & 1 deletion packages/storage/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import type { BaseStorageAdapter } from './adapters/BaseStorageAdapter.js'
import { createUploadSavers } from './createSavers.js'
import type { ModelNames, UploadsConfig } from './prismaExtension.js'
import type {
ModelNames,
UploadConfigForModel,
UploadsConfig,
} from './prismaExtension.js'
import { createUploadsExtension } from './prismaExtension.js'
import type { UrlSigner } from './UrlSigner.js'

Expand Down Expand Up @@ -29,4 +33,20 @@ export const setupStorage = <MNames extends ModelNames>({
}
}

/**
* This utility function ensures that you receive accurate type suggestions for your savers.
* If you use the type UploadsConfig directly, you may receive suggestions for saveFiles.forY where Y hasn't been configured.
* By using this utility function, you will only receive suggestions for the models that you have configured.
*
* @param uploadsConfig The uploads configuration object.
* @returns The same uploads configuration object, but with filtered types
*/
export function createUploadsConfig<
T extends Partial<{
[K in ModelNames]?: UploadConfigForModel<K>
}>,
>(uploadsConfig: T): T {
return uploadsConfig
}

export type { ModelNames, UploadsConfig } from './prismaExtension.js'
2 changes: 1 addition & 1 deletion packages/storage/src/prismaExtension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export const createUploadsExtension = <MNames extends ModelNames = ModelNames>(
needs: Record<string, boolean>
compute: (
modelData: Record<string, unknown>,
) => <T>(this: T, signArgs?: WithSignedUrlArgs) => Promise<T>
) => <T>(this: T, signArgs?: WithSignedUrlArgs) => T
}
}
}
Expand Down
11 changes: 11 additions & 0 deletions packages/storage/tsconfig.build.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// This file is here so we don't build types for tests
{
"extends": "./tsconfig.json",
"exclude": [
"dist",
"node_modules",
"**/__mocks__",
"**/__tests__",
"**/__typetests__"
]
}
Loading

0 comments on commit 3baacb6

Please sign in to comment.