mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +00:00
Fixes from the review of the Files work (1cf9944b..a9a25b94).
- A file shared on its own, or one below a folder its owner took back,
could not be reached. Once Stalwart 1.0's placeholder folders were
left out it became a root of "Shared with me", which listed folders
only, and search opened it on the drive root. 0.16 lists such files
as roots too. Each owner's files shared on their own are now listed
on one page under "Shared with me" ("Files from <name>"), where they
open, preview and download like any other file and nothing can be
added; two of the same name get a number. Search opens a shared hit
where it is listed, and links into a shared drive name the node by id
(`/files?shared=<owner>:<id>`), so a shared folder or page also
survives a reload. A Files page that is already open takes the link
from search directly, since Next keeps it mounted when only the query
changes.
- A copy that fails part-way destroys only the nodes it made, the
deepest first and without `onDestroyRemoveChildren`, one call per
level because 0.16 refuses a folder destroyed in the same call as
its children. It used to destroy the new folder with everything
below it, so whatever someone put there during a long copy was
deleted for good. A folder holding such nodes now stays, and the
error says part of the copy was left in place.
- The document editor opens read-only on 0.16 for a sharee without the
rename right. 0.16.6 to 0.16.23 refuse any change a sharee makes
without Modify, so every save of such a document failed with a 500.
1.0, told apart by its `maxFileNodeDepth`, still asks for
`mayModifyContent` alone.
- Ctrl+C with only symlinks selected empties the clipboard instead of
keeping an earlier cut for the next paste.
- A paste reads each account's file tree once, not once per pasted
item for the listing and 1.0's depth check.
- The Stalwart bundle's README says that on older 0.16 releases and
the 1.0 pre-releases a failed update also drops the cached copy, and
the next restart or reload of the Applications downloads it again,
not only a later `UpdateApps`. The static README says a restart
applies the CORS setting on the current 1.0 pre-releases, where
`ReloadSettings` fails with the stock spam-filter rules.
485 lines
22 KiB
TypeScript
485 lines
22 KiB
TypeScript
import { describe, it, expect, beforeAll, vi } from 'vitest';
|
|
|
|
// Mock the environment variable
|
|
beforeAll(() => {
|
|
vi.stubEnv('DEV_MOCK_JMAP', 'true');
|
|
});
|
|
|
|
// We test via dynamic import to get a fresh module with env set
|
|
async function loadRoute() {
|
|
// Clear module cache to reset mock email state between test suites
|
|
const mod = await import('../../app/api/dev-jmap/[...path]/route');
|
|
return mod;
|
|
}
|
|
|
|
function makeRequest(url: string, options?: globalThis.RequestInit): Request {
|
|
return new Request(url, options);
|
|
}
|
|
|
|
describe('dev-jmap mock server', () => {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
let GET: (request: any, ctx: { params: Promise<{ path: string[] }> }) => Promise<Response>;
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
let POST: (request: any, ctx: { params: Promise<{ path: string[] }> }) => Promise<Response>;
|
|
|
|
beforeAll(async () => {
|
|
const mod = await loadRoute();
|
|
GET = mod.GET as typeof GET;
|
|
POST = mod.POST as typeof POST;
|
|
});
|
|
|
|
describe('GET /.well-known/jmap', () => {
|
|
it('should return session object with capabilities', async () => {
|
|
const req = makeRequest('http://localhost:3000/api/dev-jmap/.well-known/jmap', {
|
|
headers: { host: 'localhost:3000' },
|
|
});
|
|
const res = await GET(req, { params: Promise.resolve({ path: ['.well-known', 'jmap'] }) });
|
|
const data = await res.json();
|
|
expect(res.status).toBe(200);
|
|
expect(data.capabilities).toBeDefined();
|
|
expect(data.capabilities['urn:ietf:params:jmap:core']).toBeDefined();
|
|
expect(data.accounts).toBeDefined();
|
|
expect(data.apiUrl).toContain('/api/dev-jmap/api');
|
|
expect(data.downloadUrl).toContain('/download/');
|
|
expect(data.eventSourceUrl).toContain('/eventsource');
|
|
});
|
|
});
|
|
|
|
describe('GET /download', () => {
|
|
it('should return a response with attachment disposition', async () => {
|
|
const req = makeRequest('http://localhost:3000/api/dev-jmap/download/dev-account-001/blob-att-001/Q1-Bericht.pdf?accept=application/pdf', {
|
|
headers: { host: 'localhost:3000' },
|
|
});
|
|
const res = await GET(req, { params: Promise.resolve({ path: ['download', 'dev-account-001', 'blob-att-001', 'Q1-Bericht.pdf'] }) });
|
|
expect(res.status).toBe(200);
|
|
expect(res.headers.get('Content-Type')).toBe('application/pdf');
|
|
expect(res.headers.get('Content-Disposition')).toContain('Q1-Bericht.pdf');
|
|
});
|
|
});
|
|
|
|
describe('GET /eventsource', () => {
|
|
it('should return text/event-stream content type', async () => {
|
|
const req = makeRequest('http://localhost:3000/api/dev-jmap/eventsource?types=*&ping=30', {
|
|
headers: { host: 'localhost:3000' },
|
|
});
|
|
const res = await GET(req, { params: Promise.resolve({ path: ['eventsource'] }) });
|
|
expect(res.status).toBe(200);
|
|
expect(res.headers.get('Content-Type')).toBe('text/event-stream');
|
|
expect(res.headers.get('Cache-Control')).toBe('no-cache');
|
|
});
|
|
});
|
|
|
|
describe('POST /api - Mailbox/get', () => {
|
|
it('should return list of mailboxes', async () => {
|
|
const req = makeRequest('http://localhost:3000/api/dev-jmap/api', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', host: 'localhost:3000' },
|
|
body: JSON.stringify({
|
|
methodCalls: [['Mailbox/get', { accountId: 'dev-account-001' }, 'c0']],
|
|
}),
|
|
});
|
|
const res = await POST(req, { params: Promise.resolve({ path: ['api'] }) });
|
|
const data = await res.json();
|
|
expect(data.methodResponses).toBeDefined();
|
|
expect(data.methodResponses[0][0]).toBe('Mailbox/get');
|
|
expect(data.methodResponses[0][1].list.length).toBeGreaterThan(0);
|
|
const inbox = data.methodResponses[0][1].list.find((m: { role: string }) => m.role === 'inbox');
|
|
expect(inbox).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe('POST /api - Email/query', () => {
|
|
it('should filter by mailbox', async () => {
|
|
const req = makeRequest('http://localhost:3000/api/dev-jmap/api', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', host: 'localhost:3000' },
|
|
body: JSON.stringify({
|
|
methodCalls: [['Email/query', { accountId: 'dev-account-001', filter: { inMailbox: 'mb-inbox' } }, 'c0']],
|
|
}),
|
|
});
|
|
const res = await POST(req, { params: Promise.resolve({ path: ['api'] }) });
|
|
const data = await res.json();
|
|
expect(data.methodResponses[0][0]).toBe('Email/query');
|
|
expect(data.methodResponses[0][1].ids.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('should filter by text search', async () => {
|
|
const req = makeRequest('http://localhost:3000/api/dev-jmap/api', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', host: 'localhost:3000' },
|
|
body: JSON.stringify({
|
|
methodCalls: [['Email/query', { accountId: 'dev-account-001', filter: { text: 'willkommen' } }, 'c0']],
|
|
}),
|
|
});
|
|
const res = await POST(req, { params: Promise.resolve({ path: ['api'] }) });
|
|
const data = await res.json();
|
|
expect(data.methodResponses[0][1].ids.length).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
describe('POST /api - text conditions as the app sends them', () => {
|
|
// Search sends each quoted phrase and each `word*` as its own condition.
|
|
async function queryIds(method: string, filter: Record<string, unknown>): Promise<string[]> {
|
|
const req = makeRequest('http://localhost:3000/api/dev-jmap/api', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', host: 'localhost:3000' },
|
|
body: JSON.stringify({ methodCalls: [[method, { accountId: 'dev-account-001', filter }, 'c0']] }),
|
|
});
|
|
const res = await POST(req, { params: Promise.resolve({ path: ['api'] }) });
|
|
const data = await res.json();
|
|
return data.methodResponses[0][1].ids;
|
|
}
|
|
|
|
it('matches a quoted phrase ANDed with a prefix in Email/query', async () => {
|
|
const found = await queryIds('Email/query', { operator: 'AND', conditions: [{ text: '"sprint priorities"' }, { text: 'wee*' }] });
|
|
expect(found.length).toBeGreaterThan(0);
|
|
expect(await queryIds('Email/query', { operator: 'AND', conditions: [{ text: '"priorities sprint"' }, { text: 'wee*' }] })).toEqual([]);
|
|
});
|
|
|
|
it('resolves an AND of bounds and text parts in CalendarEvent/query', async () => {
|
|
expect(await queryIds('CalendarEvent/query', {
|
|
operator: 'AND',
|
|
conditions: [
|
|
{ after: '2000-01-01T00:00:00Z' },
|
|
{ before: '2100-01-01T23:59:59Z' },
|
|
{ text: '"sprint planning"' },
|
|
{ text: 'plan*' },
|
|
],
|
|
})).toEqual(['evt-002']);
|
|
});
|
|
|
|
it('resolves an AND of text parts in ContactCard/query', async () => {
|
|
expect(await queryIds('ContactCard/query', {
|
|
operator: 'AND',
|
|
conditions: [{ text: '"sophie müller"' }, { text: 'euro*' }],
|
|
})).toEqual(['contact-001']);
|
|
});
|
|
});
|
|
|
|
describe('POST /api - Email/get', () => {
|
|
it('should return emails by ids', async () => {
|
|
const req = makeRequest('http://localhost:3000/api/dev-jmap/api', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', host: 'localhost:3000' },
|
|
body: JSON.stringify({
|
|
methodCalls: [['Email/get', { accountId: 'dev-account-001', ids: ['email-001'] }, 'c0']],
|
|
}),
|
|
});
|
|
const res = await POST(req, { params: Promise.resolve({ path: ['api'] }) });
|
|
const data = await res.json();
|
|
expect(data.methodResponses[0][0]).toBe('Email/get');
|
|
expect(data.methodResponses[0][1].list).toHaveLength(1);
|
|
expect(data.methodResponses[0][1].list[0].subject).toContain('Willkommen');
|
|
});
|
|
|
|
it('should filter properties when specified', async () => {
|
|
const req = makeRequest('http://localhost:3000/api/dev-jmap/api', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', host: 'localhost:3000' },
|
|
body: JSON.stringify({
|
|
methodCalls: [['Email/get', { accountId: 'dev-account-001', ids: ['email-001'], properties: ['id', 'subject'] }, 'c0']],
|
|
}),
|
|
});
|
|
const res = await POST(req, { params: Promise.resolve({ path: ['api'] }) });
|
|
const data = await res.json();
|
|
const email = data.methodResponses[0][1].list[0];
|
|
expect(email.id).toBe('email-001');
|
|
expect(email.subject).toBeDefined();
|
|
expect(email.bodyValues).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('POST /api - Email/set', () => {
|
|
it('should update email keywords', async () => {
|
|
const req = makeRequest('http://localhost:3000/api/dev-jmap/api', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', host: 'localhost:3000' },
|
|
body: JSON.stringify({
|
|
methodCalls: [['Email/set', { accountId: 'dev-account-001', update: { 'email-001': { 'keywords/$seen': true } } }, 'c0']],
|
|
}),
|
|
});
|
|
const res = await POST(req, { params: Promise.resolve({ path: ['api'] }) });
|
|
const data = await res.json();
|
|
expect(data.methodResponses[0][0]).toBe('Email/set');
|
|
expect(data.methodResponses[0][1].updated['email-001']).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('POST /api - Identity/get', () => {
|
|
it('should return identities', async () => {
|
|
const req = makeRequest('http://localhost:3000/api/dev-jmap/api', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', host: 'localhost:3000' },
|
|
body: JSON.stringify({
|
|
methodCalls: [['Identity/get', { accountId: 'dev-account-001' }, 'c0']],
|
|
}),
|
|
});
|
|
const res = await POST(req, { params: Promise.resolve({ path: ['api'] }) });
|
|
const data = await res.json();
|
|
expect(data.methodResponses[0][0]).toBe('Identity/get');
|
|
expect(data.methodResponses[0][1].list.length).toBeGreaterThan(0);
|
|
expect(data.methodResponses[0][1].list[0].email).toBe('dev@localhost');
|
|
});
|
|
});
|
|
|
|
describe('POST /api - unknown method', () => {
|
|
it('should return error for unknown methods', async () => {
|
|
const req = makeRequest('http://localhost:3000/api/dev-jmap/api', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', host: 'localhost:3000' },
|
|
body: JSON.stringify({
|
|
methodCalls: [['FakeMethod/get', {}, 'c0']],
|
|
}),
|
|
});
|
|
const res = await POST(req, { params: Promise.resolve({ path: ['api'] }) });
|
|
const data = await res.json();
|
|
expect(data.methodResponses[0][0]).toBe('error');
|
|
expect(data.methodResponses[0][1].type).toBe('unknownMethod');
|
|
});
|
|
});
|
|
|
|
describe('POST /api - PushSubscription lifecycle', () => {
|
|
it('creates, verifies, lists, and destroys a push subscription', async () => {
|
|
const relayFetch = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
|
new Response(null, { status: 202 }),
|
|
);
|
|
|
|
try {
|
|
const createReq = makeRequest('http://localhost:3000/api/dev-jmap/api', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', host: 'localhost:3000' },
|
|
body: JSON.stringify({
|
|
methodCalls: [['PushSubscription/set', {
|
|
create: {
|
|
new: {
|
|
deviceClientId: 'browser-device-1',
|
|
url: 'https://notifications.relay.bulwarkmail.org/api/push/jmap/browser-device-1',
|
|
types: ['Email'],
|
|
expires: '2030-01-01T00:00:00.000Z',
|
|
},
|
|
},
|
|
}, 'c0']],
|
|
}),
|
|
});
|
|
const createRes = await POST(createReq, { params: Promise.resolve({ path: ['api'] }) });
|
|
const createData = await createRes.json();
|
|
const id = createData.methodResponses[0][1].created.new.id as string;
|
|
const verificationBody = JSON.parse(
|
|
String(relayFetch.mock.calls[0]?.[1]?.body),
|
|
) as { verificationCode: string };
|
|
|
|
expect(id).toMatch(/^push-/);
|
|
expect(relayFetch).toHaveBeenCalledOnce();
|
|
|
|
const verifyReq = makeRequest('http://localhost:3000/api/dev-jmap/api', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', host: 'localhost:3000' },
|
|
body: JSON.stringify({
|
|
methodCalls: [['PushSubscription/set', {
|
|
update: { [id]: { verificationCode: verificationBody.verificationCode } },
|
|
}, 'v0']],
|
|
}),
|
|
});
|
|
const verifyRes = await POST(verifyReq, { params: Promise.resolve({ path: ['api'] }) });
|
|
const verifyData = await verifyRes.json();
|
|
expect(verifyData.methodResponses[0][1].updated[id]).toBeNull();
|
|
|
|
const getReq = makeRequest('http://localhost:3000/api/dev-jmap/api', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', host: 'localhost:3000' },
|
|
body: JSON.stringify({
|
|
methodCalls: [['PushSubscription/get', { ids: [id] }, 'g0']],
|
|
}),
|
|
});
|
|
const getRes = await POST(getReq, { params: Promise.resolve({ path: ['api'] }) });
|
|
const getData = await getRes.json();
|
|
expect(getData.methodResponses[0][1].list).toEqual([
|
|
expect.objectContaining({ id, deviceClientId: 'browser-device-1' }),
|
|
]);
|
|
|
|
const destroyReq = makeRequest('http://localhost:3000/api/dev-jmap/api', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', host: 'localhost:3000' },
|
|
body: JSON.stringify({
|
|
methodCalls: [['PushSubscription/set', { destroy: [id] }, 'd0']],
|
|
}),
|
|
});
|
|
const destroyRes = await POST(destroyReq, { params: Promise.resolve({ path: ['api'] }) });
|
|
const destroyData = await destroyRes.json();
|
|
expect(destroyData.methodResponses[0][1].destroyed).toEqual([id]);
|
|
} finally {
|
|
relayFetch.mockRestore();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('POST /api - EmailSubmission destroy', () => {
|
|
async function call(methodCalls: unknown[]) {
|
|
const req = makeRequest('http://localhost:3000/api/dev-jmap/api', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', host: 'localhost:3000' },
|
|
body: JSON.stringify({ methodCalls }),
|
|
});
|
|
const res = await POST(req, { params: Promise.resolve({ path: ['api'] }) });
|
|
return (await res.json()).methodResponses;
|
|
}
|
|
|
|
it('removes a canceled scheduled submission and reports unknown ids as notFound', async () => {
|
|
const [[, created]] = await call([['EmailSubmission/set', {
|
|
accountId: 'dev-account-001',
|
|
create: { s1: { emailId: 'email-001', identityId: 'identity-001', envelope: { mailFrom: { email: 'me@example.com', parameters: { HOLDFOR: '3600' } }, rcptTo: [] } } },
|
|
}, 'c0']]);
|
|
const id = created.created.s1.id as string;
|
|
await call([['EmailSubmission/set', { accountId: 'dev-account-001', update: { [id]: { undoStatus: 'canceled' } } }, 'u0']]);
|
|
|
|
const [[, result]] = await call([['EmailSubmission/set', { accountId: 'dev-account-001', destroy: [id, 'submission-unknown'] }, 'd0']]);
|
|
|
|
expect(result.destroyed).toEqual([id]);
|
|
expect(result.notDestroyed).toEqual({ 'submission-unknown': { type: 'notFound' } });
|
|
const [[, got]] = await call([['EmailSubmission/get', { accountId: 'dev-account-001', ids: [id] }, 'g0']]);
|
|
expect(got.list).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('POST /api - back-references', () => {
|
|
it('should resolve #ids from Email/query result', async () => {
|
|
const req = makeRequest('http://localhost:3000/api/dev-jmap/api', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', host: 'localhost:3000' },
|
|
body: JSON.stringify({
|
|
methodCalls: [
|
|
['Email/query', { accountId: 'dev-account-001', filter: { inMailbox: 'mb-inbox' }, limit: 2 }, 'q0'],
|
|
['Email/get', { accountId: 'dev-account-001', '#ids': { resultOf: 'q0', name: 'Email/query', path: 'ids' }, properties: ['id', 'subject'] }, 'g0'],
|
|
],
|
|
}),
|
|
});
|
|
const res = await POST(req, { params: Promise.resolve({ path: ['api'] }) });
|
|
const data = await res.json();
|
|
expect(data.methodResponses).toHaveLength(2);
|
|
expect(data.methodResponses[1][0]).toBe('Email/get');
|
|
// The get should have resolved ids from the query
|
|
expect(data.methodResponses[1][1].list.length).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
describe('POST /api - invalid request', () => {
|
|
it('should return 400 for missing methodCalls', async () => {
|
|
const req = makeRequest('http://localhost:3000/api/dev-jmap/api', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', host: 'localhost:3000' },
|
|
body: JSON.stringify({}),
|
|
});
|
|
const res = await POST(req, { params: Promise.resolve({ path: ['api'] }) });
|
|
expect(res.status).toBe(400);
|
|
});
|
|
});
|
|
|
|
describe('POST /api - request limits', () => {
|
|
it('should refuse a request with more method calls than it advertises', async () => {
|
|
const methodCalls = Array.from({ length: 17 }, (_, i) => [
|
|
'Email/query',
|
|
{ accountId: 'dev-account-001', limit: 0, calculateTotal: true },
|
|
`c${i}`,
|
|
]);
|
|
const req = makeRequest('http://localhost:3000/api/dev-jmap/api', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', host: 'localhost:3000' },
|
|
body: JSON.stringify({ methodCalls }),
|
|
});
|
|
const res = await POST(req, { params: Promise.resolve({ path: ['api'] }) });
|
|
const data = await res.json();
|
|
expect(res.status).toBe(400);
|
|
expect(data.type).toBe('urn:ietf:params:jmap:error:limit');
|
|
expect(data.limit).toBe('maxCallsInRequest');
|
|
});
|
|
|
|
it('should reject an over-sized /set with requestTooLarge', async () => {
|
|
const destroy = Array.from({ length: 501 }, (_, i) => `email-${i}`);
|
|
const req = makeRequest('http://localhost:3000/api/dev-jmap/api', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', host: 'localhost:3000' },
|
|
body: JSON.stringify({ methodCalls: [['Email/set', { accountId: 'dev-account-001', destroy }, '0']] }),
|
|
});
|
|
const res = await POST(req, { params: Promise.resolve({ path: ['api'] }) });
|
|
const data = await res.json();
|
|
expect(res.status).toBe(200);
|
|
expect(data.methodResponses[0][0]).toBe('error');
|
|
expect(data.methodResponses[0][1].type).toBe('requestTooLarge');
|
|
});
|
|
|
|
it('should reject an over-sized /get with requestTooLarge', async () => {
|
|
const ids = Array.from({ length: 501 }, (_, i) => `email-${i}`);
|
|
const req = makeRequest('http://localhost:3000/api/dev-jmap/api', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', host: 'localhost:3000' },
|
|
body: JSON.stringify({ methodCalls: [['Email/get', { accountId: 'dev-account-001', ids }, '0']] }),
|
|
});
|
|
const res = await POST(req, { params: Promise.resolve({ path: ['api'] }) });
|
|
const data = await res.json();
|
|
expect(data.methodResponses[0][0]).toBe('error');
|
|
expect(data.methodResponses[0][1].type).toBe('requestTooLarge');
|
|
});
|
|
});
|
|
|
|
describe('POST /upload', () => {
|
|
it('should return a fake blob response', async () => {
|
|
const req = makeRequest('http://localhost:3000/api/dev-jmap/upload/dev-account-001/', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'image/png', 'Content-Length': '1024', host: 'localhost:3000' },
|
|
body: 'fake-data',
|
|
});
|
|
const res = await POST(req, { params: Promise.resolve({ path: ['upload', 'dev-account-001'] }) });
|
|
const data = await res.json();
|
|
expect(data.accountId).toBe('dev-account-001');
|
|
expect(data.blobId).toBeDefined();
|
|
expect(data.type).toBe('image/png');
|
|
});
|
|
});
|
|
|
|
describe('POST /api - FileNode/set', () => {
|
|
it('reads the size of a new file off its blob, like a real server', async () => {
|
|
const upload = await POST(makeRequest('http://localhost:3000/api/dev-jmap/upload/dev-account-001/', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'text/plain', host: 'localhost:3000' },
|
|
body: 'hello',
|
|
}), { params: Promise.resolve({ path: ['upload', 'dev-account-001'] }) });
|
|
const { blobId } = await upload.json();
|
|
|
|
const res = await POST(makeRequest('http://localhost:3000/api/dev-jmap/api', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', host: 'localhost:3000' },
|
|
body: JSON.stringify({
|
|
methodCalls: [['FileNode/set', {
|
|
accountId: 'dev-account-001',
|
|
create: {
|
|
uploaded: { name: 'hello.txt', type: 'text/plain', blobId },
|
|
// A copy points at the blob of a seeded node.
|
|
copied: { name: 'copy.pdf', type: 'application/pdf', blobId: 'demo-blob-file-2' },
|
|
},
|
|
}, '0']],
|
|
}),
|
|
}), { params: Promise.resolve({ path: ['api'] }) });
|
|
const { created } = (await res.json()).methodResponses[0][1];
|
|
expect(created.uploaded.size).toBe(5);
|
|
expect(created.copied.size).toBe(148480);
|
|
});
|
|
|
|
it('keeps a folder that still holds something unless onDestroyRemoveChildren is set, like a real server', async () => {
|
|
const fileNodeSet = async (args: Record<string, unknown>) => {
|
|
const res = await POST(makeRequest('http://localhost:3000/api/dev-jmap/api', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', host: 'localhost:3000' },
|
|
body: JSON.stringify({ methodCalls: [['FileNode/set', { accountId: 'dev-account-001', ...args }, '0']] }),
|
|
}), { params: Promise.resolve({ path: ['api'] }) });
|
|
return (await res.json()).methodResponses[0][1];
|
|
};
|
|
const folder = (await fileNodeSet({ create: { f: { name: 'Rollback test' } } })).created.f.id;
|
|
const inside = (await fileNodeSet({ create: { x: { name: 'x.pdf', type: 'application/pdf', blobId: 'demo-blob-file-2', parentId: folder } } })).created.x.id;
|
|
|
|
expect((await fileNodeSet({ destroy: [folder] })).notDestroyed[folder].type).toBe('nodeHasChildren');
|
|
expect((await fileNodeSet({ destroy: [inside] })).destroyed).toEqual([inside]);
|
|
expect((await fileNodeSet({ destroy: [folder] })).destroyed).toEqual([folder]);
|
|
});
|
|
});
|
|
});
|