Files
webmail/stores/__tests__/file-store.test.ts
T

608 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect, beforeEach } from 'vitest';
import { useFileStore, resourceServerRef } from '../file-store';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import type { FileNode } from '@/lib/jmap/types';
// Minimal in-memory FileNode backend that models the JMAP hierarchy via
// parentId links (the behaviour issue #379 asks for).
function makeMockClient(initial: FileNode[] = []) {
let nodes: FileNode[] = initial.map(n => ({ ...n }));
let seq = 0;
const now = () => new Date().toISOString();
const client = {
getCapabilities: () => ({}),
probeFileNodeSupport: async () => true,
getFilesAccountId: () => 'acct',
async listAllFileNodes() {
return nodes.map(n => ({ ...n }));
},
async listFileNodes(parentId: string | null) {
return nodes.filter(n => (n.parentId ?? null) === parentId).map(n => ({ ...n }));
},
async getFileNodes(ids: string[] | null) {
return (ids === null ? nodes : nodes.filter(n => ids.includes(n.id))).map(n => ({ ...n }));
},
async createFileDirectory(name: string, parentId: string | null) {
// A real folder has no content blob (this is how Stalwart marks a container).
const node: FileNode = { id: `n${++seq}`, parentId, name, type: '', blobId: null, size: 0, created: now(), modified: now() };
nodes.push(node);
return { ...node };
},
async createFileNode(name: string, blobId: string, type: string, size: number, parentId: string | null) {
const node: FileNode = { id: `n${++seq}`, parentId, name, type, blobId, size, created: now(), modified: now() };
nodes.push(node);
return { ...node };
},
async updateFileNode(id: string, updates: Partial<Pick<FileNode, 'name' | 'parentId'>>) {
const node = nodes.find(n => n.id === id);
if (node) Object.assign(node, updates);
},
async updateFileNodes(updates: Record<string, Partial<Pick<FileNode, 'name' | 'parentId'>>>) {
const updated: string[] = [];
const notUpdated: Record<string, string> = {};
for (const [id, patch] of Object.entries(updates)) {
const node = nodes.find(n => n.id === id);
if (!node) { notUpdated[id] = 'not found'; continue; }
// Mirror Stalwart: a parentId must point at a real folder, i.e. a node
// with no content blob. A blob-backed node (file or old "folder marker")
// is not a container and is rejected.
if (patch.parentId != null) {
const parent = nodes.find(n => n.id === patch.parentId);
if (!parent || parent.blobId != null) {
notUpdated[id] = 'Parent ID does not exist or is not a folder.';
continue;
}
}
Object.assign(node, patch);
updated.push(id);
}
return { updated, notUpdated };
},
async destroyFileNodes(ids: string[]) {
// Emulate Stalwart's onDestroyRemoveChildren: removing a directory also
// removes its whole subtree.
const toRemove = new Set(ids);
let grew = true;
while (grew) {
grew = false;
for (const n of nodes) {
if (n.parentId && toRemove.has(n.parentId) && !toRemove.has(n.id)) {
toRemove.add(n.id);
grew = true;
}
}
}
nodes = nodes.filter(n => !toRemove.has(n.id));
return { destroyed: [...toRemove], notDestroyed: [] };
},
async copyFileNode(id: string, newName: string, parentId: string | null) {
const original = nodes.find(n => n.id === id);
if (!original) throw new Error('not found');
return this.createFileNode(newName, original.blobId ?? '', original.type, original.size, parentId);
},
async uploadBlob(file: File) {
return { blobId: `blob${++seq}`, type: file.type };
},
// test helper
_nodes: () => nodes,
};
return client as unknown as IJMAPClient & { _nodes: () => FileNode[] };
}
const dir = (id: string, name: string, parentId: string | null): FileNode => ({
id, parentId, name, type: 'd', blobId: null, size: 0, created: '', modified: '',
});
// An old build's "folder": a directory-typed node that is actually a blob-backed
// file, so the server won't let anything be parented under it.
const marker = (id: string, name: string, parentId: string | null): FileNode => ({
id, parentId, name, type: 'd', blobId: `b-${id}`, size: 0, created: '', modified: '',
});
const file = (id: string, name: string, parentId: string | null): FileNode => ({
id, parentId, name, type: 'text/plain', blobId: `b-${id}`, size: 10, created: '', modified: '',
});
describe('file-store hierarchy (issue #379)', () => {
beforeEach(() => {
useFileStore.setState({
client: null,
currentParentId: null,
currentPath: '/',
pathStack: [{ id: null, name: '' }],
resources: [],
selectedResources: new Set(),
clipboard: null,
lastAction: null,
});
});
it('lists only root-level nodes at the root, not the whole tree flattened', async () => {
// Stuff > Nonsense > Notes.md / Other.md (the exact shape from the issue)
const client = makeMockClient([
dir('stuff', 'Stuff', null),
dir('nonsense', 'Nonsense', 'stuff'),
file('notes', 'Notes.md', 'nonsense'),
file('other', 'Other.md', 'nonsense'),
]);
useFileStore.getState().initClient(client);
await useFileStore.getState().navigate(null);
const names = useFileStore.getState().resources.map(r => r.name);
expect(names).toEqual(['Stuff']);
});
it('navigates into a folder via parentId and shows its direct children', async () => {
const client = makeMockClient([
dir('stuff', 'Stuff', null),
dir('nonsense', 'Nonsense', 'stuff'),
file('notes', 'Notes.md', 'nonsense'),
file('other', 'Other.md', 'nonsense'),
]);
useFileStore.getState().initClient(client);
await useFileStore.getState().navigate('stuff', 'Stuff');
expect(useFileStore.getState().resources.map(r => r.name)).toEqual(['Nonsense']);
await useFileStore.getState().navigate('nonsense', 'Nonsense');
expect(useFileStore.getState().resources.map(r => r.name).sort()).toEqual(['Notes.md', 'Other.md']);
});
it('creates a directory as a child node with a plain name (no path encoding)', async () => {
const client = makeMockClient([dir('stuff', 'Stuff', null)]);
useFileStore.getState().initClient(client);
await useFileStore.getState().navigate('stuff', 'Stuff');
await useFileStore.getState().createDirectory('Nonsense');
const created = client._nodes().find(n => n.name === 'Nonsense');
expect(created).toBeDefined();
expect(created!.parentId).toBe('stuff');
expect(created!.name).toBe('Nonsense'); // not "Stuff/Nonsense"
});
it('uploads a file into the current folder as a child node', async () => {
const client = makeMockClient([dir('stuff', 'Stuff', null)]);
useFileStore.getState().initClient(client);
await useFileStore.getState().navigate('stuff', 'Stuff');
await useFileStore.getState().uploadFile(new File(['hi'], 'Notes.md', { type: 'text/markdown' }));
const uploaded = client._nodes().find(n => n.name === 'Notes.md');
expect(uploaded!.parentId).toBe('stuff');
});
it('moves a node into a folder by reparenting (parentId), not by renaming', async () => {
const client = makeMockClient([
dir('b', 'B', null),
file('f', 'f.txt', null),
]);
useFileStore.getState().initClient(client);
await useFileStore.getState().navigate(null);
// Drag f.txt onto the B folder visible in the current (root) view.
await useFileStore.getState().moveToFolder(['f.txt'], 'B');
const moved = client._nodes().find(n => n.id === 'f')!;
expect(moved.parentId).toBe('b');
expect(moved.name).toBe('f.txt'); // name untouched
});
it('renames a node by changing only its name', async () => {
const client = makeMockClient([dir('stuff', 'Stuff', null)]);
useFileStore.getState().initClient(client);
await useFileStore.getState().navigate(null);
await useFileStore.getState().renameResource('Stuff', 'Things');
const node = client._nodes().find(n => n.id === 'stuff')!;
expect(node.name).toBe('Things');
expect(node.parentId).toBeNull();
});
it('migrates legacy flat path-encoded nodes into the real hierarchy', async () => {
const SEP = '∕';
// Exactly the broken shape from issue #379: everything flat at the root
// with the Unicode separator baked into the names.
const client = makeMockClient([
dir('stuff', 'Stuff', null),
dir('nonsense', `Stuff${SEP}Nonsense`, null),
file('notes', `Stuff${SEP}Nonsense${SEP}Notes.md`, null),
file('other', `Stuff${SEP}Nonsense${SEP}Other.md`, null),
]);
useFileStore.getState().initClient(client);
const migrated = await useFileStore.getState().migrateLegacyFlatNodes();
expect(migrated).toBe(true);
const byId = Object.fromEntries(client._nodes().map(n => [n.id, n]));
expect(byId.stuff).toMatchObject({ name: 'Stuff', parentId: null });
expect(byId.nonsense).toMatchObject({ name: 'Nonsense', parentId: 'stuff' });
expect(byId.notes).toMatchObject({ name: 'Notes.md', parentId: 'nonsense' });
expect(byId.other).toMatchObject({ name: 'Other.md', parentId: 'nonsense' });
// Browsing now reflects the real tree.
await useFileStore.getState().navigate(null);
expect(useFileStore.getState().resources.map(r => r.name)).toEqual(['Stuff']);
await useFileStore.getState().navigate('nonsense', 'Nonsense');
expect(useFileStore.getState().resources.map(r => r.name).sort()).toEqual(['Notes.md', 'Other.md']);
});
it('migrates legacy nodes that used a plain "/" separator (issue #379 data)', async () => {
// A folder upload of a git checkout, stored flat with "/" in the names.
const client = makeMockClient([
dir('root', 'night-medieval-castle-lake', null),
dir('branding', 'night-medieval-castle-lake/branding', null),
dir('git', 'night-medieval-castle-lake/branding/.git', null),
file('cfg', 'night-medieval-castle-lake/branding/.git/config', null),
file('logo', 'kunden/logo.svg', null),
dir('kunden', 'kunden', null),
]);
useFileStore.getState().initClient(client);
expect(await useFileStore.getState().migrateLegacyFlatNodes()).toBe(true);
const byId = Object.fromEntries(client._nodes().map(n => [n.id, n]));
expect(byId.root).toMatchObject({ name: 'night-medieval-castle-lake', parentId: null });
expect(byId.branding).toMatchObject({ name: 'branding', parentId: 'root' });
expect(byId.git).toMatchObject({ name: '.git', parentId: 'branding' });
expect(byId.cfg).toMatchObject({ name: 'config', parentId: 'git' });
expect(byId.logo).toMatchObject({ name: 'logo.svg', parentId: 'kunden' });
await useFileStore.getState().navigate(null);
expect(useFileStore.getState().resources.map(r => r.name).sort())
.toEqual(['kunden', 'night-medieval-castle-lake']);
});
it('creates missing intermediate folders instead of reparenting under a non-folder', async () => {
// The shape that produced "Parent ID does not exist or is not a folder" in
// production (#379): flat files whose path prefixes have no real directory
// node at all. The migration must materialize the folders, not skip them.
const SEP = '∕';
const client = makeMockClient([
file('a', `Docs${SEP}report.pdf`, null),
file('b', `Docs${SEP}Sub${SEP}notes.txt`, null),
]);
useFileStore.getState().initClient(client);
expect(await useFileStore.getState().migrateLegacyFlatNodes()).toBe(true);
const nodes = client._nodes();
const docs = nodes.find(n => n.name === 'Docs' && n.parentId === null);
expect(docs?.blobId).toBeNull();
const sub = nodes.find(n => n.name === 'Sub' && n.parentId === docs!.id);
expect(sub?.blobId).toBeNull();
const byId = Object.fromEntries(nodes.map(n => [n.id, n]));
expect(byId.a).toMatchObject({ name: 'report.pdf', parentId: docs!.id });
expect(byId.b).toMatchObject({ name: 'notes.txt', parentId: sub!.id });
// No leftover separators anywhere.
expect(nodes.some(n => n.name.includes(SEP))).toBe(false);
});
it('replaces legacy folder-marker files with real folders and reparents children', async () => {
// Old builds stored folders as 0-byte directory-typed FILES (blobId set).
// The server treats those as non-containers, which is the actual cause of
// "Parent ID does not exist or is not a folder" (#379): the migration must
// delete the markers and recreate real folders before reparenting.
const SEP = '∕';
const client = makeMockClient([
marker('docs', 'Documents', null),
file('a', `Documents${SEP}readme.txt`, null),
file('b', `Documents${SEP}img${SEP}logo.png`, null),
]);
useFileStore.getState().initClient(client);
expect(await useFileStore.getState().migrateLegacyFlatNodes()).toBe(true);
const nodes = client._nodes();
// The marker file is gone, replaced by a real folder (no blob).
expect(nodes.find(n => n.id === 'docs')).toBeUndefined();
const docs = nodes.find(n => n.name === 'Documents' && n.parentId === null);
expect(docs?.blobId).toBeNull();
const img = nodes.find(n => n.name === 'img' && n.parentId === docs!.id);
expect(img?.blobId).toBeNull();
const byId = Object.fromEntries(nodes.map(n => [n.id, n]));
expect(byId.a).toMatchObject({ name: 'readme.txt', parentId: docs!.id });
expect(byId.b).toMatchObject({ name: 'logo.png', parentId: img!.id });
expect(nodes.some(n => n.name.includes(SEP))).toBe(false);
});
it('handles a folder-marker whose own name is path-encoded', async () => {
// A nested old "folder" was itself stored flat ("Docs∕Sub", type d + blob).
// It is both legacy and a marker; it must be deleted (not reparented as a
// file) and replaced by a real folder holding the child.
const SEP = '∕';
const client = makeMockClient([
marker('docs', 'Docs', null),
marker('sub', `Docs${SEP}Sub`, null),
file('f', `Docs${SEP}Sub${SEP}file.txt`, null),
]);
useFileStore.getState().initClient(client);
expect(await useFileStore.getState().migrateLegacyFlatNodes()).toBe(true);
const nodes = client._nodes();
expect(nodes.find(n => n.id === 'docs')).toBeUndefined();
expect(nodes.find(n => n.id === 'sub')).toBeUndefined();
const docs = nodes.find(n => n.name === 'Docs' && n.parentId === null);
const sub = nodes.find(n => n.name === 'Sub' && n.parentId === docs!.id);
expect(sub?.blobId).toBeNull();
expect(nodes.find(n => n.id === 'f')).toMatchObject({ name: 'file.txt', parentId: sub!.id });
expect(nodes.some(n => n.name.includes(SEP))).toBe(false);
});
it('aborts and restores markers (no data loss) if the server cannot create real folders', async () => {
const SEP = '∕';
const client = makeMockClient([
marker('docs', 'Documents', null),
file('a', `Documents${SEP}readme.txt`, null),
]);
// Simulate a broken/stale server: "folders" come back blob-backed (not real
// containers). The migration must detect this and undo its marker rename.
(client as unknown as { createFileDirectory: typeof client.createFileDirectory }).createFileDirectory =
async (name: string, parentId: string | null) =>
({ id: 'bad', parentId, name, type: 'd', blobId: 'b-bad', size: 0, created: '', modified: '' });
useFileStore.getState().initClient(client);
expect(await useFileStore.getState().migrateLegacyFlatNodes()).toBe(false);
const byId = Object.fromEntries(client._nodes().map(n => [n.id, n]));
// Marker restored to its original name; content file untouched (still flat).
expect(byId.docs).toMatchObject({ name: 'Documents' });
expect(byId.a).toMatchObject({ name: `Documents${SEP}readme.txt`, parentId: null });
});
it('migration is a no-op when no legacy nodes exist', async () => {
const client = makeMockClient([dir('a', 'A', null), file('f', 'f.txt', 'a')]);
useFileStore.getState().initClient(client);
expect(await useFileStore.getState().migrateLegacyFlatNodes()).toBe(false);
});
it('deletes a folder and the server removes its subtree', async () => {
const client = makeMockClient([
dir('stuff', 'Stuff', null),
dir('nonsense', 'Nonsense', 'stuff'),
file('notes', 'Notes.md', 'nonsense'),
]);
useFileStore.getState().initClient(client);
await useFileStore.getState().navigate(null);
await useFileStore.getState().deleteResource('Stuff');
expect(client._nodes()).toHaveLength(0);
});
});
describe('file-store modification date (issue #700)', () => {
beforeEach(() => {
useFileStore.setState({
client: null,
currentParentId: null,
currentPath: '/',
pathStack: [{ id: null, name: '' }],
resources: [],
selectedResources: new Set(),
clipboard: null,
lastAction: null,
});
});
it('shows the server-maintained `modified` date, not `created`', async () => {
// Stalwart names the property `modified` (draft-ietf-jmap-filenode); there
// is no `updated`. Asking for the wrong name used to leave lastModified
// pinned to the creation date, so replacing a file looked unmodified.
const client = makeMockClient([
{ ...file('notes', 'Notes.md', null), created: '2025-01-01T00:00:00Z', modified: '2026-07-30T12:00:00Z' },
]);
useFileStore.getState().initClient(client);
await useFileStore.getState().navigate(null);
expect(useFileStore.getState().resources[0].lastModified).toBe('2026-07-30T12:00:00Z');
});
it('falls back to `created` when the server sends no `modified`', async () => {
const client = makeMockClient([
{ ...file('notes', 'Notes.md', null), created: '2025-01-01T00:00:00Z', modified: '' },
]);
useFileStore.getState().initClient(client);
await useFileStore.getState().navigate(null);
expect(useFileStore.getState().resources[0].lastModified).toBe('2025-01-01T00:00:00Z');
});
});
describe('resourceServerRef (issue #1094)', () => {
it('splits a node of a shared account into its owner account and bare id', () => {
// The WOPI editor used to send "d:b" with the user's own account, and
// FileNode/get answered notFound.
expect(resourceServerRef({ id: 'd:b', ownerAccountId: 'd' })).toEqual({ accountId: 'd', id: 'b' });
});
it('keeps own nodes as they are', () => {
expect(resourceServerRef({ id: 'b', ownerAccountId: 'c' })).toEqual({ accountId: 'c', id: 'b' });
expect(resourceServerRef({ id: 'b' })).toEqual({ accountId: undefined, id: 'b' });
});
});
describe('file-store folder tree and moves', () => {
beforeEach(() => {
useFileStore.setState({
client: null,
currentParentId: null,
currentPath: '/',
pathStack: [{ id: null, name: '' }],
resources: [],
allFolders: [],
selectedResources: new Set(),
clipboard: null,
lastAction: null,
});
});
it('keeps every folder from the listing for the tree, at any depth', async () => {
const client = makeMockClient([
dir('a', 'A', null),
dir('b', 'B', 'a'),
file('f', 'f.txt', 'b'),
]);
useFileStore.getState().initClient(client);
await useFileStore.getState().navigate(null);
expect(useFileStore.getState().allFolders.map(f => f.id).sort()).toEqual(['a', 'b']);
});
it('moves items into any folder by id and records the move for undo', async () => {
const client = makeMockClient([
dir('a', 'A', null),
dir('b', 'B', 'a'),
file('f', 'f.txt', null),
]);
useFileStore.getState().initClient(client);
await useFileStore.getState().navigate(null);
await useFileStore.getState().moveToFolderId(['f.txt'], 'b');
expect(client._nodes().find(n => n.id === 'f')!.parentId).toBe('b');
await useFileStore.getState().undoLastAction();
expect(client._nodes().find(n => n.id === 'f')!.parentId).toBeNull();
});
it('refuses to move a folder into itself or a folder below it', async () => {
const client = makeMockClient([
dir('a', 'A', null),
dir('b', 'B', 'a'),
]);
useFileStore.getState().initClient(client);
await useFileStore.getState().navigate(null);
await useFileStore.getState().moveToFolderId(['A'], 'b');
await useFileStore.getState().moveToFolderId(['A'], 'a');
expect(client._nodes().find(n => n.id === 'a')!.parentId).toBeNull();
});
it('reveals a file deep in the tree: opens its folder with the full path and selects it', async () => {
const client = makeMockClient([
dir('a', 'A', null),
dir('b', 'B', 'a'),
file('f', 'f.txt', 'b'),
]);
useFileStore.getState().initClient(client);
await useFileStore.getState().navigate(null);
expect(await useFileStore.getState().revealNode('f')).toBe('f.txt');
const state = useFileStore.getState();
expect(state.currentPath).toBe('/A/B');
expect(state.currentParentId).toBe('b');
expect(state.resources.map(r => r.name)).toEqual(['f.txt']);
expect([...state.selectedResources]).toEqual(['f.txt']);
});
it('returns null when the revealed node no longer exists', async () => {
const client = makeMockClient([dir('a', 'A', null)]);
useFileStore.getState().initClient(client);
expect(await useFileStore.getState().revealNode('gone')).toBeNull();
});
});
describe('file-store upload result', () => {
beforeEach(() => {
useFileStore.setState({
client: null,
currentParentId: null,
currentPath: '/',
pathStack: [{ id: null, name: '' }],
resources: [],
uploadProgress: null,
uploadAbortController: null,
});
});
it('reports every file when the upload runs to the end', async () => {
const client = makeMockClient();
useFileStore.getState().initClient(client);
const result = await useFileStore.getState().uploadFiles([
new File(['a'], 'a.txt'), new File(['b'], 'b.txt'),
]);
expect(result).toEqual({ uploaded: 2, canceled: false });
});
it('reports a cancel and only the files that made it before it', async () => {
const client = makeMockClient();
const upload = client.uploadBlob.bind(client);
let calls = 0;
client.uploadBlob = (async (file: File) => {
calls++;
// Cancel while the second file is on its way up.
if (calls === 2) useFileStore.getState().cancelUpload();
return upload(file);
}) as typeof client.uploadBlob;
useFileStore.getState().initClient(client);
const result = await useFileStore.getState().uploadFiles([
new File(['a'], 'a.txt'), new File(['b'], 'b.txt'), new File(['c'], 'c.txt'),
]);
expect(result).toEqual({ uploaded: 1, canceled: true });
expect(client._nodes().map(n => n.name)).toEqual(['a.txt']);
});
});
describe('file-store actions by id', () => {
beforeEach(() => {
localStorage.clear();
useFileStore.setState({
client: null,
currentParentId: null,
currentPath: '/',
pathStack: [{ id: null, name: '' }],
resources: [],
allFolders: [],
favorites: [],
recentFiles: [],
selectedResources: new Set(),
lastAction: null,
});
});
it('renames a folder that is not listed and moves its stars along', async () => {
const client = makeMockClient([dir('a', 'A', null), dir('b', 'B', 'a')]);
useFileStore.getState().initClient(client);
await useFileStore.getState().navigate(null);
useFileStore.setState({ favorites: ['/A/B', '/Other'] });
await useFileStore.getState().renameNode('a', 'Alpha');
expect(client._nodes().find(n => n.id === 'a')!.name).toBe('Alpha');
expect(useFileStore.getState().favorites).toEqual(['/Alpha/B', '/Other']);
});
it('updates the path on screen when an open folder is renamed', async () => {
const client = makeMockClient([dir('a', 'A', null), dir('b', 'B', 'a')]);
useFileStore.getState().initClient(client);
await useFileStore.getState().navigate('a', 'A');
await useFileStore.getState().navigate('b', 'B');
await useFileStore.getState().renameNode('a', 'Alpha');
expect(useFileStore.getState().currentPath).toBe('/Alpha/B');
});
it('deletes the open folder and steps out to its parent, dropping its stars', async () => {
const client = makeMockClient([dir('a', 'A', null), dir('b', 'B', 'a')]);
useFileStore.getState().initClient(client);
await useFileStore.getState().navigate('a', 'A');
await useFileStore.getState().navigate('b', 'B');
useFileStore.setState({ favorites: ['/A/B', '/A'] });
await useFileStore.getState().deleteNode('b');
const state = useFileStore.getState();
expect(state.currentPath).toBe('/A');
expect(state.currentParentId).toBe('a');
expect(state.favorites).toEqual(['/A']);
});
it('forgets a recent file', () => {
useFileStore.setState({ recentFiles: [{ name: 'x', id: '1', timestamp: 1 }, { name: 'y', id: '2', timestamp: 2 }] });
useFileStore.getState().removeRecentFile('1');
expect(useFileStore.getState().recentFiles.map(r => r.id)).toEqual(['2']);
});
});