fix architecture and write new tests
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
formatDate,
|
||||
marketplaceReservationEmail,
|
||||
resetPasswordEmail,
|
||||
signupEmail,
|
||||
} from './emailTranslations'
|
||||
|
||||
describe('emailTranslations', () => {
|
||||
const trialEnd = new Date('2026-07-15T12:00:00.000Z')
|
||||
|
||||
it('renders signup subjects in every supported locale', () => {
|
||||
expect(signupEmail.subject('en')).toContain('workspace')
|
||||
expect(signupEmail.subject('fr')).toContain('espace')
|
||||
expect(signupEmail.subject('ar')).toContain('جاهزة')
|
||||
})
|
||||
|
||||
it('renders localized signup text with billing and provider details', () => {
|
||||
const text = signupEmail.text({
|
||||
firstName: 'Aya',
|
||||
companyName: 'Atlas Cars',
|
||||
plan: 'PRO',
|
||||
billingPeriod: 'ANNUAL',
|
||||
currency: 'MAD',
|
||||
paymentProvider: 'AmanPay',
|
||||
trialEnd,
|
||||
}, 'fr')
|
||||
|
||||
expect(text).toContain('Bonjour Aya')
|
||||
expect(text).toContain('Atlas Cars')
|
||||
expect(text).toContain('Forfait : PRO (annuel)')
|
||||
expect(text).toContain('Fournisseur de paiement principal : AmanPay')
|
||||
})
|
||||
|
||||
it('marks Arabic reset-password HTML as right-to-left and embeds the reset URL', () => {
|
||||
const html = resetPasswordEmail.html('https://example.test/reset/token', 'Mina', 45, 'ar')
|
||||
|
||||
expect(html).toContain('dir="rtl"')
|
||||
expect(html).toContain('https://example.test/reset/token')
|
||||
expect(html).toContain('45')
|
||||
})
|
||||
|
||||
it('includes optional contact phone in marketplace reservation HTML when present', () => {
|
||||
const html = marketplaceReservationEmail.html({
|
||||
firstName: 'Yassine',
|
||||
vehicleYear: 2024,
|
||||
vehicleMake: 'Dacia',
|
||||
vehicleModel: 'Duster',
|
||||
companyName: 'Atlas Cars',
|
||||
startDate: new Date('2026-08-01T00:00:00.000Z'),
|
||||
endDate: new Date('2026-08-05T00:00:00.000Z'),
|
||||
totalDays: 4,
|
||||
rateDisplay: '400.00',
|
||||
totalDisplay: '1600.00',
|
||||
email: 'yassine@example.test',
|
||||
phone: '+212600000000',
|
||||
}, 'en')
|
||||
|
||||
expect(html).toContain('2024 Dacia Duster')
|
||||
expect(html).toContain('Atlas Cars')
|
||||
expect(html).toContain('yassine@example.test or +212600000000')
|
||||
})
|
||||
|
||||
it('formats dates with the locale-specific formatter', () => {
|
||||
expect(formatDate(new Date('2026-02-03T00:00:00.000Z'), 'en')).toContain('2026')
|
||||
expect(formatDate(new Date('2026-02-03T00:00:00.000Z'), 'fr')).toContain('2026')
|
||||
expect(formatDate(new Date('2026-02-03T00:00:00.000Z'), 'ar')).toMatch(/2026|٢٠٢٦/)
|
||||
expect(formatDate(new Date('2026-02-03T00:00:00.000Z'), 'ar')).toContain('فبراير')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isDatabaseUnavailableError } from './isDatabaseUnavailable'
|
||||
|
||||
describe('isDatabaseUnavailableError', () => {
|
||||
it('detects Prisma P1001 connection failures', () => {
|
||||
expect(isDatabaseUnavailableError({ code: 'P1001' })).toBe(true)
|
||||
})
|
||||
|
||||
it('detects database reachability failures by message', () => {
|
||||
expect(isDatabaseUnavailableError({ message: "Can't reach database server at postgres:5432" })).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects unrelated, null, and primitive errors', () => {
|
||||
expect(isDatabaseUnavailableError({ code: 'P2002' })).toBe(false)
|
||||
expect(isDatabaseUnavailableError(new Error('network hiccup'))).toBe(false)
|
||||
expect(isDatabaseUnavailableError(null)).toBe(false)
|
||||
expect(isDatabaseUnavailableError('P1001')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
import fs from 'fs'
|
||||
import os from 'os'
|
||||
import path from 'path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
assertStorageConfiguration,
|
||||
deleteImage,
|
||||
getPrivateStorageRoot,
|
||||
getProtectedCustomerLicenseImageUrl,
|
||||
getPublicStorageRoot,
|
||||
getStorageRoot,
|
||||
resolveStoredFilePath,
|
||||
uploadImage,
|
||||
} from './storage'
|
||||
|
||||
const originalEnv = { ...process.env }
|
||||
|
||||
describe('storage', () => {
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv }
|
||||
})
|
||||
|
||||
it('uses FILE_STORAGE_ROOT when configured and builds protected customer license URLs from dashboard URL', () => {
|
||||
process.env.FILE_STORAGE_ROOT = '/tmp/rdg-storage'
|
||||
process.env.DASHBOARD_URL = 'https://dashboard.rentaldrivego.test/dashboard/'
|
||||
|
||||
expect(getStorageRoot()).toBe('/tmp/rdg-storage')
|
||||
expect(getPublicStorageRoot()).toBe('/tmp/rdg-storage/public')
|
||||
expect(getPrivateStorageRoot()).toBe('/tmp/rdg-storage/private')
|
||||
expect(getProtectedCustomerLicenseImageUrl('customer_1')).toBe('https://dashboard.rentaldrivego.test/dashboard/api/v1/customers/customer_1/license-image')
|
||||
})
|
||||
|
||||
it('rejects implicit in-app storage in production', () => {
|
||||
delete process.env.FILE_STORAGE_ROOT
|
||||
process.env.NODE_ENV = 'production'
|
||||
|
||||
expect(() => assertStorageConfiguration()).toThrow('FILE_STORAGE_ROOT must be set in production')
|
||||
})
|
||||
|
||||
it('uploads, resolves, and deletes files under the configured storage root', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'rdg-storage-'))
|
||||
process.env.FILE_STORAGE_ROOT = root
|
||||
process.env.API_URL = 'https://api.rentaldrivego.test/'
|
||||
|
||||
const url = await uploadImage(Buffer.from('image-bytes'), 'customers/licenses', 'customer_1')
|
||||
|
||||
expect(url).toBe('https://api.rentaldrivego.test/storage/customers/licenses/customer_1.jpg')
|
||||
const filePath = resolveStoredFilePath(url)
|
||||
expect(filePath).toBe(path.join(root, 'private/customers/licenses/customer_1.jpg'))
|
||||
expect(fs.readFileSync(filePath!, 'utf8')).toBe('image-bytes')
|
||||
|
||||
await deleteImage(url)
|
||||
expect(fs.existsSync(filePath!)).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores non-storage URLs when resolving or deleting files', async () => {
|
||||
process.env.FILE_STORAGE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), 'rdg-storage-'))
|
||||
|
||||
expect(resolveStoredFilePath('https://cdn.test/not-storage/file.jpg')).toBeNull()
|
||||
await expect(deleteImage('https://cdn.test/not-storage/file.jpg')).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -10,12 +10,22 @@ const LEGACY_STORAGE_ROOTS = [
|
||||
path.join(APP_SOURCE_ROOT, 'lib', 'storage'),
|
||||
]
|
||||
|
||||
export type StorageVisibility = 'public' | 'private'
|
||||
|
||||
export function getStorageRoot(): string {
|
||||
return process.env.FILE_STORAGE_ROOT
|
||||
? path.resolve(process.env.FILE_STORAGE_ROOT)
|
||||
: DEFAULT_STORAGE_ROOT
|
||||
}
|
||||
|
||||
export function getPublicStorageRoot(): string {
|
||||
return path.join(getStorageRoot(), 'public')
|
||||
}
|
||||
|
||||
export function getPrivateStorageRoot(): string {
|
||||
return path.join(getStorageRoot(), 'private')
|
||||
}
|
||||
|
||||
export function getLegacyStorageRoots(): string[] {
|
||||
return LEGACY_STORAGE_ROOTS
|
||||
}
|
||||
@@ -45,10 +55,22 @@ export function assertStorageConfiguration(): string {
|
||||
return storageRoot
|
||||
}
|
||||
|
||||
function ensureStorageRoot(): string {
|
||||
const storageRoot = assertStorageConfiguration()
|
||||
fs.mkdirSync(storageRoot, { recursive: true })
|
||||
return storageRoot
|
||||
function ensureStorageRoot(visibility: StorageVisibility): string {
|
||||
assertStorageConfiguration()
|
||||
const root = visibility === 'public' ? getPublicStorageRoot() : getPrivateStorageRoot()
|
||||
fs.mkdirSync(root, { recursive: true })
|
||||
return root
|
||||
}
|
||||
|
||||
function inferVisibility(folder: string): StorageVisibility {
|
||||
const normalized = folder.replace(/\\/g, '/')
|
||||
if (/\/customers\//.test(`/${normalized}/`)) return 'private'
|
||||
if (/\/licenses?\//.test(`/${normalized}/`)) return 'private'
|
||||
if (/\/contracts?\//.test(`/${normalized}/`)) return 'private'
|
||||
if (/\/documents?\//.test(`/${normalized}/`)) return 'private'
|
||||
if (/\/inspections?\//.test(`/${normalized}/`)) return 'private'
|
||||
if (/\/internal\//.test(`/${normalized}/`)) return 'private'
|
||||
return 'public'
|
||||
}
|
||||
|
||||
function getApiBase(): string {
|
||||
@@ -77,10 +99,14 @@ export function getProtectedCustomerLicenseImageUrl(customerId: string): string
|
||||
export async function uploadImage(
|
||||
buffer: Buffer,
|
||||
folder: string,
|
||||
publicId?: string
|
||||
publicId?: string,
|
||||
visibility: StorageVisibility = inferVisibility(folder),
|
||||
): Promise<string> {
|
||||
const storageRoot = ensureStorageRoot()
|
||||
const storageRoot = ensureStorageRoot(visibility)
|
||||
const folderPath = path.join(storageRoot, folder)
|
||||
if (!isWithinPath(folderPath, storageRoot)) {
|
||||
throw new Error('Upload path escapes storage root')
|
||||
}
|
||||
fs.mkdirSync(folderPath, { recursive: true })
|
||||
|
||||
const filename = publicId
|
||||
@@ -96,9 +122,10 @@ export function resolveStoredFilePath(imageUrl: string): string | null {
|
||||
const relative = getStorageRelativePath(imageUrl)
|
||||
if (!relative) return null
|
||||
|
||||
const roots = [assertStorageConfiguration(), ...getLegacyStorageRoots()]
|
||||
const roots = [getPublicStorageRoot(), getPrivateStorageRoot(), ...getLegacyStorageRoots(), assertStorageConfiguration()]
|
||||
for (const root of roots) {
|
||||
const filePath = path.join(root, relative)
|
||||
if (!isWithinPath(filePath, root)) continue
|
||||
if (fs.existsSync(filePath)) {
|
||||
return filePath
|
||||
}
|
||||
|
||||
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 418 KiB |
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 167 KiB |
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 167 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 7.6 KiB |
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 23 KiB |
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 23 KiB |
Reference in New Issue
Block a user