Skip to content

Commit 9a11f2a

Browse files
committed
chore: add cloning tests
Signed-off-by: skjnldsv <skjnldsv@protonmail.com>
1 parent 3efbae5 commit 9a11f2a

7 files changed

Lines changed: 354 additions & 24 deletions

File tree

__tests__/files/cloning.spec.ts

Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,307 @@
1+
/*
2+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import { describe, expect, test } from 'vitest'
7+
import { File, NodeData, NodeStatus } from '../../lib/node/index.ts'
8+
import { Permission } from '../../lib/permissions.ts'
9+
10+
describe('File cloning', () => {
11+
test('Clone preserves all basic properties', () => {
12+
const file = new File({
13+
source: 'https://cloud.domain.com/remote.php/dav/files/emma/Photos/picture.jpg',
14+
mime: 'image/jpeg',
15+
owner: 'emma',
16+
mtime: new Date(Date.UTC(2023, 0, 1, 0, 0, 0)),
17+
crtime: new Date(Date.UTC(1990, 0, 1, 0, 0, 0)),
18+
size: 12345,
19+
permissions: Permission.ALL,
20+
root: '/files/emma',
21+
status: NodeStatus.NEW,
22+
})
23+
24+
const clone = file.clone()
25+
26+
expect(clone).toBeInstanceOf(File)
27+
expect(clone).not.toBe(file)
28+
expect(clone.source).toBe(file.source)
29+
expect(clone.mime).toBe(file.mime)
30+
expect(clone.owner).toBe(file.owner)
31+
expect(clone.size).toBe(file.size)
32+
expect(clone.permissions).toBe(file.permissions)
33+
expect(clone.root).toBe(file.root)
34+
expect(clone.status).toBe(file.status)
35+
expect(clone.mtime?.toISOString()).toBe(file.mtime?.toISOString())
36+
expect(clone.crtime?.toISOString()).toBe(file.crtime?.toISOString())
37+
})
38+
39+
test('Clone preserves attributes', () => {
40+
const file = new File({
41+
source: 'https://cloud.domain.com/remote.php/dav/files/emma/Photos/picture.jpg',
42+
mime: 'image/jpeg',
43+
owner: 'emma',
44+
attributes: {
45+
favorite: true,
46+
customProp: 'value',
47+
nested: { key: 'value' },
48+
},
49+
})
50+
51+
const clone = file.clone()
52+
53+
expect(clone.attributes).toStrictEqual(file.attributes)
54+
expect(clone.attributes.favorite).toBe(true)
55+
expect(clone.attributes.customProp).toBe('value')
56+
expect(clone.attributes.nested).toStrictEqual({ key: 'value' })
57+
})
58+
59+
test('Clone is independent from original', () => {
60+
const file = new File({
61+
source: 'https://cloud.domain.com/remote.php/dav/files/emma/Photos/picture.jpg',
62+
mime: 'image/jpeg',
63+
owner: 'emma',
64+
size: 100,
65+
attributes: {
66+
test: 'original',
67+
},
68+
})
69+
70+
const clone = file.clone()
71+
72+
// Modify the clone
73+
clone.size = 200
74+
clone.mime = 'image/png'
75+
clone.attributes.test = 'modified'
76+
clone.attributes.newProp = 'new'
77+
78+
// Original should be unchanged
79+
expect(file.size).toBe(100)
80+
expect(file.mime).toBe('image/jpeg')
81+
expect(file.attributes.test).toBe('original')
82+
expect(file.attributes.newProp).toBeUndefined()
83+
})
84+
85+
test('Clone works with minimal file', () => {
86+
const file = new File({
87+
source: 'https://cloud.domain.com/remote.php/dav/files/emma/file.txt',
88+
owner: 'emma',
89+
})
90+
91+
const clone = file.clone()
92+
93+
expect(clone).toBeInstanceOf(File)
94+
expect(clone.source).toBe(file.source)
95+
expect(clone.mime).toBe('application/octet-stream')
96+
expect(clone.owner).toBe('emma')
97+
})
98+
99+
test('Clone works with remote file', () => {
100+
const file = new File({
101+
source: 'https://domain.com/Photos/picture.jpg',
102+
mime: 'image/jpeg',
103+
owner: null,
104+
})
105+
106+
const clone = file.clone()
107+
108+
expect(clone).toBeInstanceOf(File)
109+
expect(clone.source).toBe(file.source)
110+
expect(clone.owner).toBeNull()
111+
expect(clone.isDavResource).toBe(false)
112+
expect(clone.permissions).toBe(Permission.READ)
113+
})
114+
})
115+
116+
describe('File serialization and deserialization', () => {
117+
test('toJSON and JSON.parse roundtrip preserves all properties', () => {
118+
const file = new File({
119+
source: 'https://cloud.domain.com/remote.php/dav/files/emma/Photos/picture.jpg',
120+
mime: 'image/jpeg',
121+
owner: 'emma',
122+
mtime: new Date(Date.UTC(2023, 0, 1, 0, 0, 0)),
123+
crtime: new Date(Date.UTC(1990, 0, 1, 0, 0, 0)),
124+
size: 12345,
125+
permissions: Permission.ALL,
126+
root: '/files/emma',
127+
status: NodeStatus.LOADING,
128+
})
129+
130+
const parsed = JSON.parse(file.toJSON()) as [NodeData, RegExp?]
131+
const reconstructed = new File(parsed[0], parsed[1])
132+
133+
expect(reconstructed).toBeInstanceOf(File)
134+
expect(reconstructed.source).toBe(file.source)
135+
expect(reconstructed.mime).toBe(file.mime)
136+
expect(reconstructed.owner).toBe(file.owner)
137+
expect(reconstructed.size).toBe(file.size)
138+
expect(reconstructed.permissions).toBe(file.permissions)
139+
expect(reconstructed.root).toBe(file.root)
140+
expect(reconstructed.status).toBe(file.status)
141+
expect(reconstructed.mtime?.toISOString()).toBe(file.mtime?.toISOString())
142+
expect(reconstructed.crtime?.toISOString()).toBe(file.crtime?.toISOString())
143+
})
144+
145+
test('toString and JSON.parse preserves attributes', () => {
146+
const file = new File({
147+
source: 'https://cloud.domain.com/remote.php/dav/files/emma/Photos/picture.jpg',
148+
mime: 'image/jpeg',
149+
owner: 'emma',
150+
attributes: {
151+
favorite: true,
152+
tags: ['work', 'important'],
153+
metadata: { author: 'Emma', version: 2 },
154+
},
155+
})
156+
157+
const parsed = JSON.parse(file.toJSON()) as [NodeData, RegExp?]
158+
const reconstructed = new File(parsed[0], parsed[1])
159+
160+
expect(reconstructed.attributes).toStrictEqual(file.attributes)
161+
expect(reconstructed.attributes.favorite).toBe(true)
162+
expect(reconstructed.attributes.tags).toStrictEqual(['work', 'important'])
163+
expect(reconstructed.attributes.metadata).toStrictEqual({ author: 'Emma', version: 2 })
164+
})
165+
166+
test('toString and JSON.parse works with minimal file', () => {
167+
const file = new File({
168+
source: 'https://cloud.domain.com/remote.php/dav/files/emma/file.txt',
169+
owner: 'emma',
170+
})
171+
172+
const parsed = JSON.parse(file.toJSON()) as [NodeData, RegExp?]
173+
const reconstructed = new File(parsed[0], parsed[1])
174+
175+
expect(reconstructed).toBeInstanceOf(File)
176+
expect(reconstructed.source).toBe(file.source)
177+
expect(reconstructed.mime).toBe('application/octet-stream')
178+
expect(reconstructed.owner).toBe('emma')
179+
})
180+
181+
test('toString and JSON.parse is independent from original', () => {
182+
const file = new File({
183+
source: 'https://cloud.domain.com/remote.php/dav/files/emma/Photos/picture.jpg',
184+
mime: 'image/jpeg',
185+
owner: 'emma',
186+
size: 100,
187+
attributes: {
188+
test: 'original',
189+
},
190+
})
191+
192+
const parsed = JSON.parse(file.toJSON()) as [NodeData, RegExp?]
193+
const reconstructed = new File(parsed[0], parsed[1])
194+
195+
// Modify the reconstructed file
196+
reconstructed.size = 200
197+
reconstructed.mime = 'image/png'
198+
reconstructed.attributes.test = 'modified'
199+
200+
// Original should be unchanged
201+
expect(file.size).toBe(100)
202+
expect(file.mime).toBe('image/jpeg')
203+
expect(file.attributes.test).toBe('original')
204+
})
205+
206+
test('toString and JSON.parse preserves displayname', () => {
207+
const file = new File({
208+
source: 'https://cloud.domain.com/remote.php/dav/files/emma/Photos/picture.jpg',
209+
mime: 'image/jpeg',
210+
owner: 'emma',
211+
attributes: {
212+
displayname: 'My Vacation Photo',
213+
},
214+
})
215+
216+
const parsed = JSON.parse(file.toJSON()) as [NodeData, RegExp?]
217+
const reconstructed = new File(parsed[0], parsed[1])
218+
219+
expect(reconstructed.displayname).toBe('My Vacation Photo')
220+
expect(reconstructed.basename).toBe('picture.jpg')
221+
})
222+
223+
test('toString and JSON.parse works with remote file', () => {
224+
const file = new File({
225+
source: 'https://domain.com/Photos/picture.jpg',
226+
mime: 'image/jpeg',
227+
owner: null,
228+
})
229+
230+
expect(file.owner).toBeNull()
231+
expect(file.isDavResource).toBe(false)
232+
expect(file.permissions).toBe(Permission.READ)
233+
234+
const parsed = JSON.parse(file.toJSON()) as [NodeData, RegExp?]
235+
const reconstructed = new File(parsed[0], parsed[1])
236+
237+
expect(reconstructed).toBeInstanceOf(File)
238+
expect(reconstructed.source).toBe(file.source)
239+
expect(reconstructed.owner).toBeNull()
240+
expect(reconstructed.isDavResource).toBe(false)
241+
expect(reconstructed.permissions).toBe(Permission.READ)
242+
})
243+
244+
test('toString and JSON.parse preserves all NodeStatus values', () => {
245+
const statuses = [NodeStatus.NEW, NodeStatus.FAILED, NodeStatus.LOADING, NodeStatus.LOCKED]
246+
247+
for (const status of statuses) {
248+
const file = new File({
249+
source: 'https://cloud.domain.com/remote.php/dav/files/emma/file.txt',
250+
owner: 'emma',
251+
status,
252+
})
253+
254+
const parsed = JSON.parse(file.toJSON()) as [NodeData, RegExp?]
255+
const reconstructed = new File(parsed[0], parsed[1])
256+
expect(reconstructed.status).toBe(status)
257+
}
258+
})
259+
260+
test('toString output is valid JSON', () => {
261+
const file = new File({
262+
source: 'https://cloud.domain.com/remote.php/dav/files/emma/Photos/picture.jpg',
263+
mime: 'image/jpeg',
264+
owner: 'emma',
265+
size: 12345,
266+
})
267+
268+
const str = file.toJSON()
269+
expect(() => JSON.parse(str)).not.toThrow()
270+
271+
const parsed = JSON.parse(str)
272+
expect(Array.isArray(parsed)).toBe(true)
273+
expect(parsed.length).toBeGreaterThanOrEqual(1)
274+
expect(parsed[0]).toHaveProperty('source')
275+
})
276+
})
277+
278+
describe('Cloning methods comparison', () => {
279+
test('clone() and toString/parse produce equivalent files', () => {
280+
const file = new File({
281+
source: 'https://cloud.domain.com/remote.php/dav/files/emma/Photos/picture.jpg',
282+
mime: 'image/jpeg',
283+
owner: 'emma',
284+
mtime: new Date(Date.UTC(2023, 0, 1, 0, 0, 0)),
285+
size: 12345,
286+
permissions: Permission.ALL,
287+
root: '/files/emma',
288+
attributes: {
289+
favorite: true,
290+
tags: ['work'],
291+
},
292+
})
293+
294+
const cloned = file.clone()
295+
const parsed = JSON.parse(file.toJSON()) as [NodeData, RegExp?]
296+
const reconstructed = new File(parsed[0], parsed[1])
297+
298+
expect(cloned.source).toBe(reconstructed.source)
299+
expect(cloned.mime).toBe(reconstructed.mime)
300+
expect(cloned.owner).toBe(reconstructed.owner)
301+
expect(cloned.size).toBe(reconstructed.size)
302+
expect(cloned.permissions).toBe(reconstructed.permissions)
303+
expect(cloned.root).toBe(reconstructed.root)
304+
expect(cloned.mtime?.toISOString()).toBe(reconstructed.mtime?.toISOString())
305+
expect(cloned.attributes).toStrictEqual(reconstructed.attributes)
306+
})
307+
})

lib/node/file.ts

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,19 @@
22
* SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
33
* SPDX-License-Identifier: AGPL-3.0-or-later
44
*/
5+
import type { NodeConstructorData } from './node'
6+
57
import { FileType } from './fileType'
68
import { Node } from './node'
79

810
export class File extends Node {
911

10-
get type(): FileType.File {
11-
return FileType.File
12+
public constructor(...[data, davService]: NodeConstructorData) {
13+
super(data, davService)
1214
}
1315

14-
/**
15-
* Returns a clone of the file
16-
*/
17-
clone(): File {
18-
return new File(structuredClone(this._data), this._knownDavService)
16+
get type(): FileType.File {
17+
return FileType.File
1918
}
2019

2120
}

lib/node/folder.ts

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@
22
* SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
33
* SPDX-License-Identifier: AGPL-3.0-or-later
44
*/
5+
import type { NodeConstructorData } from './node'
6+
57
import { FileType } from './fileType'
68
import { Node } from './node'
79

810
export class Folder extends Node {
911

10-
constructor(...[data, davService]: ConstructorParameters<typeof Node>) {
12+
constructor(...[data, davService]: NodeConstructorData) {
1113
// enforcing mimes
1214
super({
1315
...data,
@@ -27,13 +29,6 @@ export class Folder extends Node {
2729
return 'httpd/unix-directory'
2830
}
2931

30-
/**
31-
* Returns a clone of the folder
32-
*/
33-
clone(): Folder {
34-
return new Folder(structuredClone(this._data), this._knownDavService)
35-
}
36-
3732
}
3833

3934
/**

lib/node/node.ts

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ export enum NodeStatus {
2121
LOCKED = 'locked',
2222
}
2323

24-
type NodeConstructorData = [NodeData, RegExp?]
24+
export type NodeConstructorData = [NodeData, RegExp?]
2525

2626
export abstract class Node {
2727

@@ -436,14 +436,6 @@ export abstract class Node {
436436
return JSON.stringify([structuredClone(this._data), this._knownDavService.toString()])
437437
}
438438

439-
/**
440-
* String representation of the node
441-
*/
442-
toString(): string {
443-
const constructorData: NodeConstructorData = [this._data, this._knownDavService]
444-
return JSON.stringify(constructorData)
445-
}
446-
447439
}
448440

449441
/**

0 commit comments

Comments
 (0)