Files
linkder/packages/storage/test/storage.test.ts
T
serfaandClaude Opus 5 1808ad4cba Move the demo market to Mexico City, priced in US dollars
The showcase was a Barcelona market: Catalan names, +34 numbers, euro
rates and "Carrer Example 12" on every job. Presented to a Mexican
client, all of that reads as somebody else's product.

City comes from NEXT_PUBLIC_CITY_* as before, now Ciudad de México at
19.4326/-99.1332, with MAPBOX_COUNTRY=mx. The seed's fallbacks were
Barcelona literals, so an unset env quietly seeded a different city
than the app rendered — they now agree.

Two db tests pinned the Barcelona centre as a hardcoded constant, which
is why the deck returned zero cards on the first run here: every pro was
a continent outside the radius. They read the same env as the seed now,
so the trap cannot recur.

Money: formatCents defaults to USD/en-US, and the nine hardcoded euro
signs across the card, search rows, quote strip and forms are dollars.
The rate NUMBERS are unchanged and still read high for CDMX — that is a
pricing decision, not a currency one, and is left alone deliberately.

Seed people are Mexican, addressed on real Roma/Condesa streets rotated
by index rather than one placeholder repeated. Phones moved to +52 55,
which moves the demo login to +525500000000 / 000000.

Also in here, from the same session:
- Sending a job now confirms. The mutation always succeeded; the sheet
  just closed with no receipt, which from the customer's side is
  indistinguishable from a dead button. Dismissing that receipt resolves
  as 'sent', so the card does not return to the deck.
- Media moves to DigitalOcean Spaces, with the public origin derived
  from bucket and region instead of a second env var to keep in sync.
- Managed-Postgres TLS: DATABASE_CA_CERT takes a path or inline PEM.
- The client-facing project panel beside the running app.
- Two profiles removed and four renamed to match their photos.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 10:56:31 -04:00

179 lines
5.8 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
StorageError,
UPLOAD_KINDS,
buildKey,
isPrivateKind,
resetStorageClient,
validateUpload,
} from '../src/index';
const OWNER = '11111111-2222-4333-8444-555555555555';
describe('validateUpload', () => {
it('accepts an image for a photo upload', () => {
expect(() =>
validateUpload({ kind: 'pro_photo', contentType: 'image/jpeg', contentLength: 1024 }),
).not.toThrow();
});
it('rejects a content type that is not on the allow list', () => {
expect(() =>
validateUpload({ kind: 'pro_photo', contentType: 'text/html', contentLength: 1024 }),
).toThrow(StorageError);
});
it('rejects an SVG — it can carry script', () => {
expect(() =>
validateUpload({ kind: 'avatar', contentType: 'image/svg+xml', contentLength: 1024 }),
).toThrow(StorageError);
});
it('rejects a file over the per-kind cap', () => {
expect(() =>
validateUpload({
kind: 'avatar',
contentType: 'image/png',
contentLength: UPLOAD_KINDS.avatar.maxBytes + 1,
}),
).toThrow(/too large/i);
});
it('accepts a file exactly on the cap', () => {
expect(() =>
validateUpload({
kind: 'avatar',
contentType: 'image/png',
contentLength: UPLOAD_KINDS.avatar.maxBytes,
}),
).not.toThrow();
});
it('allows PDFs for credentials but not for avatars', () => {
expect(() =>
validateUpload({ kind: 'credential', contentType: 'application/pdf', contentLength: 1024 }),
).not.toThrow();
expect(() =>
validateUpload({ kind: 'avatar', contentType: 'application/pdf', contentLength: 1024 }),
).toThrow(StorageError);
});
it('names the allowed types in the error so the UI can show it', () => {
try {
validateUpload({ kind: 'avatar', contentType: 'video/mp4', contentLength: 10 });
throw new Error('should have thrown');
} catch (error) {
expect((error as Error).message).toMatch(/image\/jpeg/);
}
});
});
describe('buildKey', () => {
it('puts the owner in the path and the right extension on the end', () => {
const key = buildKey('credential', OWNER, 'application/pdf');
expect(key).toMatch(new RegExp(`^credentials/${OWNER}/[0-9a-f-]{36}\\.pdf$`));
});
it('never collides across two calls', () => {
const a = buildKey('pro_photo', OWNER, 'image/png');
const b = buildKey('pro_photo', OWNER, 'image/png');
expect(a).not.toBe(b);
});
it("keeps one owner out of another owner's prefix", () => {
const mine = buildKey('credential', OWNER, 'image/png');
const theirs = buildKey('credential', '99999999-2222-4333-8444-555555555555', 'image/png');
expect(mine.startsWith(`credentials/${OWNER}/`)).toBe(true);
expect(theirs.startsWith(`credentials/${OWNER}/`)).toBe(false);
});
it('falls back to .bin for an unmapped type rather than producing a bare key', () => {
// validateUpload is the gate; buildKey must still not emit an extensionless key.
expect(buildKey('pro_photo', OWNER, 'application/octet-stream')).toMatch(/\.bin$/);
});
});
describe('isPrivateKind', () => {
it('marks credentials private and photos public', () => {
expect(isPrivateKind('credential')).toBe(true);
expect(isPrivateKind('pro_photo')).toBe(false);
expect(isPrivateKind('avatar')).toBe(false);
});
});
describe('configuration', () => {
const saved = { ...process.env };
beforeEach(() => {
resetStorageClient();
for (const k of [
'SPACES_REGION',
'SPACES_KEY',
'SPACES_SECRET',
'SPACES_BUCKET',
'SPACES_CDN_URL',
]) {
delete process.env[k];
}
});
afterEach(() => {
process.env = { ...saved };
resetStorageClient();
});
it('names every missing variable instead of failing vaguely', async () => {
const { createPresignedUpload } = await import('../src/index');
await expect(
createPresignedUpload({
kind: 'avatar',
contentType: 'image/png',
contentLength: 100,
ownerId: OWNER,
}),
).rejects.toThrow(/SPACES_REGION.*SPACES_KEY/s);
});
it('validates the upload before it complains about configuration', async () => {
const { createPresignedUpload } = await import('../src/index');
// A bad content type is the caller's fault and should be reported as such,
// even on a machine with no R2 credentials.
await expect(
createPresignedUpload({
kind: 'avatar',
contentType: 'text/html',
contentLength: 100,
ownerId: OWNER,
}),
).rejects.toThrow(/not allowed/i);
});
});
describe('key/URL contract with the API', () => {
/**
* Regression: credential uploads return an object KEY (they are private and
* have no public URL), but the credential schema originally demanded
* z.string().url(). The result was that pro onboarding could never be
* completed — every document upload failed validation at the last step.
*
* This asserts the contract in both directions so the two halves cannot drift
* apart again.
*/
it('produces a key that is NOT a URL for private kinds', () => {
const key = buildKey('credential', OWNER, 'application/pdf');
expect(() => new URL(key)).toThrow();
expect(isPrivateKind('credential')).toBe(true);
});
it('accepts that key against the credential schema', async () => {
const { credentialSchema } = await import('@linkdr/shared');
const key = buildKey('credential', OWNER, 'application/pdf');
expect(credentialSchema.safeParse({ kind: 'insurance', fileKey: key }).success).toBe(true);
});
it('rejects an empty key rather than storing a dangling reference', async () => {
const { credentialSchema } = await import('@linkdr/shared');
expect(credentialSchema.safeParse({ kind: 'insurance', fileKey: '' }).success).toBe(false);
});
});