Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions apps/mobile/__tests__/lib/upload-file.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { uploadFile, type LocalUpload } from '@/lib/upload-file'

const { apiClientMock, fetchMock } = vi.hoisted(() => ({
apiClientMock: vi.fn(),
fetchMock: vi.fn(),
}))

vi.mock('@/lib/api-client', () => ({
apiClient: apiClientMock,
}))

vi.stubGlobal('fetch', fetchMock)

const signed = {
key: 'user-1/abc.webp',
signedUrl: 'https://project.supabase.co/storage/v1/object/upload/sign/uploads/user-1/abc.webp?token=jwt',
publicUrl: 'https://project.supabase.co/storage/v1/object/public/uploads/user-1/abc.webp',
}

const localUpload: LocalUpload = {
uri: 'file:///tmp/avatar.webp',
contentType: 'image/webp',
sizeBytes: 2048,
}

describe('mobile uploadFile', () => {
beforeEach(() => {
apiClientMock.mockReset()
fetchMock.mockReset()
apiClientMock.mockResolvedValue(signed)
fetchMock.mockResolvedValue({ ok: true, status: 200 })
})

it('signs through apiClient then PUTs the file to the signed url', async () => {
const result = await uploadFile(localUpload)

expect(apiClientMock).toHaveBeenCalledWith('/api/uploads/sign', expect.objectContaining({
method: 'POST',
body: JSON.stringify({
contentType: 'image/webp',
sizeBytes: 2048,
}),
}))
expect(fetchMock).toHaveBeenCalledWith(signed.signedUrl, expect.objectContaining({
method: 'PUT',
headers: { 'Content-Type': 'image/webp', 'x-upsert': 'true' },
}))
expect(result).toEqual({ key: signed.key, publicUrl: signed.publicUrl })
})

it('throws when the upload PUT fails', async () => {
fetchMock.mockResolvedValue({ ok: false, status: 500 })

await expect(uploadFile(localUpload)).rejects.toThrow(/status 500/)
})
})
44 changes: 44 additions & 0 deletions apps/mobile/lib/upload-file.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { File } from 'expo-file-system'
import {
SignUploadResponseSchema,
UPLOAD_ALLOWED_CONTENT_TYPES,
type SignUploadResponse,
} from '@orbit/shared'
import { API } from '@orbit/shared/api'
import { apiClient } from './api-client'

export type StoredFile = Pick<SignUploadResponse, 'key' | 'publicUrl'>

export interface LocalUpload {
uri: string
contentType: (typeof UPLOAD_ALLOWED_CONTENT_TYPES)[number]
sizeBytes: number
}

/**
* Signs an upload through the API, PUTs the local file straight to object
* storage via its signed URL, and returns only the stored key + public URL.
*/
export async function uploadFile(upload: LocalUpload): Promise<StoredFile> {
const signed = SignUploadResponseSchema.parse(
await apiClient<SignUploadResponse>(API.uploads.sign, {
method: 'POST',
body: JSON.stringify({
contentType: upload.contentType,
sizeBytes: upload.sizeBytes,
}),
}),
)

const response = await fetch(signed.signedUrl, {
method: 'PUT',
headers: { 'Content-Type': upload.contentType, 'x-upsert': 'true' },
body: new File(upload.uri),
})

if (!response.ok) {
throw new Error(`Upload failed with status ${response.status}`)
}

return { key: signed.key, publicUrl: signed.publicUrl }
}
57 changes: 57 additions & 0 deletions apps/web/__tests__/lib/upload-file.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'

const signUploadMock = vi.fn()
vi.mock('@/app/actions/uploads', () => ({
signUpload: signUploadMock,
}))

const fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)

const { uploadFile } = await import('@/lib/upload-file')

const signed = {
key: 'user-1/abc.png',
signedUrl: 'https://project.supabase.co/storage/v1/object/upload/sign/uploads/user-1/abc.png?token=jwt',
publicUrl: 'https://project.supabase.co/storage/v1/object/public/uploads/user-1/abc.png',
}

function makeFile(type = 'image/png'): File {
return new File([new Uint8Array([1, 2, 3])], 'avatar.png', { type })
}

describe('web uploadFile', () => {
beforeEach(() => {
signUploadMock.mockReset()
fetchMock.mockReset()
signUploadMock.mockResolvedValue(signed)
fetchMock.mockResolvedValue({ ok: true, status: 200 })
})

it('signs with file metadata then PUTs the file to the signed url', async () => {
const file = makeFile()

const result = await uploadFile(file)

expect(signUploadMock).toHaveBeenCalledWith({
contentType: 'image/png',
sizeBytes: file.size,
})
expect(fetchMock).toHaveBeenCalledWith(signed.signedUrl, expect.objectContaining({
method: 'PUT',
body: file,
}))
expect(result).toEqual({ key: signed.key, publicUrl: signed.publicUrl })
})

it('rejects a disallowed content type before signing', async () => {
await expect(uploadFile(makeFile('application/pdf'))).rejects.toThrow(/Unsupported content type/)
expect(signUploadMock).not.toHaveBeenCalled()
})

it('throws when the upload PUT fails', async () => {
fetchMock.mockResolvedValue({ ok: false, status: 500 })

await expect(uploadFile(makeFile())).rejects.toThrow(/status 500/)
})
})
12 changes: 12 additions & 0 deletions apps/web/app/actions/uploads.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
'use server'

import type { SignUploadRequest, SignUploadResponse } from '@orbit/shared'
import { API } from '@orbit/shared/api'
import { serverAuthFetch } from '@/lib/server-fetch'

export async function signUpload(input: SignUploadRequest): Promise<SignUploadResponse> {
return serverAuthFetch(API.uploads.sign, {
method: 'POST',
body: JSON.stringify(input),
})
}
43 changes: 43 additions & 0 deletions apps/web/lib/upload-file.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import {
SignUploadResponseSchema,
UPLOAD_ALLOWED_CONTENT_TYPES,
type SignUploadResponse,
} from '@orbit/shared'
import { signUpload } from '@/app/actions/uploads'

export type StoredFile = Pick<SignUploadResponse, 'key' | 'publicUrl'>

type AllowedContentType = (typeof UPLOAD_ALLOWED_CONTENT_TYPES)[number]

function assertAllowedContentType(contentType: string): asserts contentType is AllowedContentType {
if (!(UPLOAD_ALLOWED_CONTENT_TYPES as readonly string[]).includes(contentType)) {
throw new Error(`Unsupported content type: ${contentType}`)
}
}

/**
* Signs an upload server-side, PUTs the file straight to object storage,
* and returns only the stored key + public URL to persist.
*/
export async function uploadFile(file: File): Promise<StoredFile> {
assertAllowedContentType(file.type)

const signed = SignUploadResponseSchema.parse(
await signUpload({
contentType: file.type,
sizeBytes: file.size,
}),
)

const response = await fetch(signed.signedUrl, {
method: 'PUT',
headers: { 'Content-Type': file.type, 'x-upsert': 'true' },
body: file,
})

if (!response.ok) {
throw new Error(`Upload failed with status ${response.status}`)
}

return { key: signed.key, publicUrl: signed.publicUrl }
}
72 changes: 72 additions & 0 deletions packages/shared/src/__tests__/upload.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { describe, it, expect } from 'vitest'
import { API } from '../api/endpoints'
import { uploadKeys } from '../query/keys'
import {
SignUploadRequestSchema,
SignUploadResponseSchema,
UPLOAD_MAX_SIZE_BYTES,
} from '../types/upload'

describe('upload contract', () => {
it('exposes the sign endpoint', () => {
expect(API.uploads.sign).toBe('/api/uploads/sign')
})

it('builds the sign mutation key', () => {
expect(uploadKeys.sign()).toEqual(['uploads', 'sign'])
})

describe('SignUploadRequestSchema', () => {
it('accepts an allowed image under the size cap', () => {
const parsed = SignUploadRequestSchema.safeParse({
contentType: 'image/png',
sizeBytes: 1024,
})
expect(parsed.success).toBe(true)
})

it('rejects a disallowed content type', () => {
const parsed = SignUploadRequestSchema.safeParse({
contentType: 'application/pdf',
sizeBytes: 1024,
})
expect(parsed.success).toBe(false)
})

it('rejects a file over the size cap', () => {
const parsed = SignUploadRequestSchema.safeParse({
contentType: 'image/png',
sizeBytes: UPLOAD_MAX_SIZE_BYTES + 1,
})
expect(parsed.success).toBe(false)
})

it('rejects a non-positive size', () => {
const parsed = SignUploadRequestSchema.safeParse({
contentType: 'image/png',
sizeBytes: 0,
})
expect(parsed.success).toBe(false)
})
})

describe('SignUploadResponseSchema', () => {
it('parses a well-formed response', () => {
const parsed = SignUploadResponseSchema.parse({
key: 'user/file.png',
signedUrl: 'https://project.supabase.co/storage/v1/object/upload/sign/uploads/user/file.png?token=jwt',
publicUrl: 'https://project.supabase.co/storage/v1/object/public/uploads/user/file.png',
})
expect(parsed.key).toBe('user/file.png')
})

it('rejects a non-URL signed url', () => {
const parsed = SignUploadResponseSchema.safeParse({
key: 'user/file.png',
signedUrl: 'not-a-url',
publicUrl: 'https://project.supabase.co/x',
})
expect(parsed.success).toBe(false)
})
})
})
4 changes: 4 additions & 0 deletions packages/shared/src/api/endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,4 +167,8 @@ export const API = {
create: '/api/checklist-templates',
delete: (id: string) => `/api/checklist-templates/${id}` as const,
},

uploads: {
sign: '/api/uploads/sign',
},
} as const
5 changes: 5 additions & 0 deletions packages/shared/src/query/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,3 +100,8 @@ export const versionCheckKeys = {
all: ['version-check'] as const,
latest: (pkg: string) => [...versionCheckKeys.all, pkg] as const,
}

export const uploadKeys = {
all: ['uploads'] as const,
sign: () => [...uploadKeys.all, 'sign'] as const,
}
1 change: 1 addition & 0 deletions packages/shared/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,4 @@ export * from './chat'
export * from './ai'
export * from './tour'
export * from './data-export'
export * from './upload'
20 changes: 20 additions & 0 deletions packages/shared/src/types/upload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { z } from 'zod'

export const UPLOAD_ALLOWED_CONTENT_TYPES = ['image/png', 'image/jpeg', 'image/webp'] as const

export const UPLOAD_MAX_SIZE_BYTES = 8 * 1024 * 1024

export const SignUploadRequestSchema = z.object({
contentType: z.enum(UPLOAD_ALLOWED_CONTENT_TYPES),
sizeBytes: z.number().int().positive().max(UPLOAD_MAX_SIZE_BYTES),
})

export type SignUploadRequest = z.infer<typeof SignUploadRequestSchema>

export const SignUploadResponseSchema = z.object({
key: z.string().min(1),
signedUrl: z.string().url(),

Check warning on line 16 in packages/shared/src/types/upload.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

'(params?: string | { abort?: boolean | undefined; pattern?: RegExp | undefined; hostname?: RegExp | undefined; protocol?: RegExp | undefined; normalize?: boolean | undefined; error?: string | ... 1 more ... | undefined; message?: string | undefined; } | undefined): ZodString' is deprecated.

See more on https://sonarcloud.io/project/issues?id=thomasluizon_orbit-ui-mobile&issues=AZ70BKmHFSxzLqliWVDq&open=AZ70BKmHFSxzLqliWVDq&pullRequest=254
publicUrl: z.string().url(),

Check warning on line 17 in packages/shared/src/types/upload.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

'(params?: string | { abort?: boolean | undefined; pattern?: RegExp | undefined; hostname?: RegExp | undefined; protocol?: RegExp | undefined; normalize?: boolean | undefined; error?: string | ... 1 more ... | undefined; message?: string | undefined; } | undefined): ZodString' is deprecated.

See more on https://sonarcloud.io/project/issues?id=thomasluizon_orbit-ui-mobile&issues=AZ70BKmHFSxzLqliWVDr&open=AZ70BKmHFSxzLqliWVDr&pullRequest=254
})

export type SignUploadResponse = z.infer<typeof SignUploadResponseSchema>
Loading