Skip to content

Commit 3a538b4

Browse files
committed
Add IPC contract tests for write operations (copy/move/delete/trash)
Asserts that the typed bindings send the right snake_case command name and camelCase payload shape (including the optional config object's serde keys), and that WriteOperationError variants round-trip on the error branch. The business logic is owned by the Rust *_core unit tests; this covers the boundary.
1 parent 04c26e4 commit 3a538b4

1 file changed

Lines changed: 166 additions & 0 deletions

File tree

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
/**
2+
* IPC contract tests for the destructive write commands: `copy_files`, `move_files`,
3+
* `delete_files`, `trash_files`.
4+
*
5+
* These verify the **boundary**: that the typed bindings send the right snake_case
6+
* command name, with the camelCase payload shape the Rust signatures expect, including
7+
* the optional config object (which contains `progressIntervalMs`, `conflictResolution`,
8+
* `dryRun`, etc.). The business logic — actually copying bytes — lives in `*_core`
9+
* helpers and is owned by the Rust unit tests.
10+
*
11+
* Error paths use `WriteOperationError` variants (`source_not_found`, `destination_full`,
12+
* etc.) so the test pins the **shape** of the typed-error discriminator that the FE
13+
* branches on. The `LoadingDialog` / `ConflictResolution` UI relies on this shape.
14+
*/
15+
16+
import { afterEach, describe, expect, it } from 'vitest'
17+
18+
import { commands } from '$lib/ipc/bindings'
19+
import type { WriteOperationStartResult } from '$lib/ipc/bindings'
20+
import { clearIpcMocks, installIpcMock } from '$lib/ipc/test-helpers'
21+
22+
afterEach(() => {
23+
clearIpcMocks()
24+
})
25+
26+
const happyResult: WriteOperationStartResult = {
27+
operationId: 'op-1',
28+
operationType: 'copy',
29+
}
30+
31+
describe('commands.copyFiles', () => {
32+
it('invokes copy_files with snake_case payload keys (sources, destination, config)', async () => {
33+
const ipc = installIpcMock()
34+
ipc.mock('copy_files', () => happyResult)
35+
36+
const sources = ['/a/foo.txt', '/a/bar.txt']
37+
const destination = '/b'
38+
const config = {
39+
progressIntervalMs: 250,
40+
conflictResolution: 'rename' as const,
41+
dryRun: false,
42+
maxConflictsToShow: 50,
43+
}
44+
45+
const result = await commands.copyFiles(sources, destination, config)
46+
47+
expect(result).toEqual({ status: 'ok', data: happyResult })
48+
expect(ipc.calls).toHaveLength(1)
49+
expect(ipc.calls[0]).toEqual({
50+
command: 'copy_files',
51+
payload: { sources, destination, config },
52+
})
53+
})
54+
55+
it('passes null config through as null (not omitted)', async () => {
56+
const ipc = installIpcMock()
57+
ipc.mock('copy_files', () => ({ ...happyResult, operationType: 'copy' as const }))
58+
59+
await commands.copyFiles(['/a'], '/b', null)
60+
61+
expect(ipc.lastCall('copy_files')?.payload).toEqual({
62+
sources: ['/a'],
63+
destination: '/b',
64+
config: null,
65+
})
66+
})
67+
68+
it('surfaces a WriteOperationError variant on the error branch', async () => {
69+
const ipc = installIpcMock()
70+
ipc.mock('copy_files', () => {
71+
throw { type: 'source_not_found', path: '/a/missing.txt' }
72+
})
73+
74+
const result = await commands.copyFiles(['/a/missing.txt'], '/b', null)
75+
76+
expect(result.status).toBe('error')
77+
if (result.status === 'error') {
78+
expect(result.error).toEqual({ type: 'source_not_found', path: '/a/missing.txt' })
79+
}
80+
})
81+
})
82+
83+
describe('commands.moveFiles', () => {
84+
it('invokes move_files with the same payload shape as copy_files', async () => {
85+
const ipc = installIpcMock()
86+
ipc.mock('move_files', () => ({ operationId: 'op-2', operationType: 'move' as const }))
87+
88+
await commands.moveFiles(['/a/x'], '/b', null)
89+
90+
expect(ipc.lastCall('move_files')?.payload).toEqual({
91+
sources: ['/a/x'],
92+
destination: '/b',
93+
config: null,
94+
})
95+
})
96+
})
97+
98+
describe('commands.deleteFiles', () => {
99+
it('passes volumeId (snake_case wire key: volume_id is camelCased on the FE) through correctly', async () => {
100+
const ipc = installIpcMock()
101+
ipc.mock('delete_files', () => ({ operationId: 'op-3', operationType: 'delete' as const }))
102+
103+
const volumeId = 'smb://server/share'
104+
await commands.deleteFiles(['/x', '/y'], volumeId, { dryRun: true })
105+
106+
// Note the camelCase `volumeId` payload key — Tauri-Specta sends in camelCase and the
107+
// Rust side deserializes via the standard serde camelCase rename on the IPC layer.
108+
expect(ipc.lastCall('delete_files')?.payload).toEqual({
109+
sources: ['/x', '/y'],
110+
volumeId,
111+
config: { dryRun: true },
112+
})
113+
})
114+
115+
it('treats a null volumeId as "use std::fs" (no volume coercion)', async () => {
116+
const ipc = installIpcMock()
117+
ipc.mock('delete_files', () => ({ operationId: 'op-4', operationType: 'delete' as const }))
118+
119+
await commands.deleteFiles(['/x'], null, null)
120+
121+
expect(ipc.lastCall('delete_files')?.payload).toEqual({
122+
sources: ['/x'],
123+
volumeId: null,
124+
config: null,
125+
})
126+
})
127+
})
128+
129+
describe('commands.trashFiles', () => {
130+
it('forwards optional itemSizes array for accurate progress reporting', async () => {
131+
const ipc = installIpcMock()
132+
ipc.mock('trash_files', () => ({ operationId: 'op-5', operationType: 'trash' as const }))
133+
134+
await commands.trashFiles(['/x', '/y'], [123, 456], null)
135+
136+
expect(ipc.lastCall('trash_files')?.payload).toEqual({
137+
sources: ['/x', '/y'],
138+
itemSizes: [123, 456],
139+
config: null,
140+
})
141+
})
142+
143+
it('accepts a null itemSizes for the "scan during op" path', async () => {
144+
const ipc = installIpcMock()
145+
ipc.mock('trash_files', () => ({ operationId: 'op-6', operationType: 'trash' as const }))
146+
147+
await commands.trashFiles(['/x'], null, null)
148+
149+
expect(ipc.lastCall('trash_files')?.payload?.itemSizes).toBeNull()
150+
})
151+
})
152+
153+
describe('commands.cancelWriteOperation', () => {
154+
it('forwards both operationId and rollback flag as positional args', async () => {
155+
const ipc = installIpcMock()
156+
ipc.mock('cancel_write_operation', () => undefined)
157+
158+
const rollback = true
159+
await commands.cancelWriteOperation('op-7', rollback)
160+
161+
expect(ipc.lastCall('cancel_write_operation')?.payload).toEqual({
162+
operationId: 'op-7',
163+
rollback,
164+
})
165+
})
166+
})

0 commit comments

Comments
 (0)