Skip to content

Commit 3cd4bde

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

5 files changed

Lines changed: 351 additions & 4 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('toString 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.toString()) 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.toString()) 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.toString()) 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.toString()) 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.toString()) 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.toString()) 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.toString()) 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.toString()
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.toString()) 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/node.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { encodePath } from '@nextcloud/paths'
77

88
import { Permission } from '../permissions'
99
import { FileType } from './fileType'
10-
import { Attribute, isDavResource, NodeData, validateData } from './nodeData'
10+
import { Attribute, fixDates, fixRegExp, isDavResource, NodeData, validateData } from './nodeData'
1111
import logger from '../utils/logger'
1212

1313
export enum NodeStatus {
@@ -66,8 +66,12 @@ export abstract class Node {
6666
data.mime = 'application/octet-stream'
6767
}
6868

69+
// Fix primitive types if needed
70+
fixDates(data)
71+
davService = fixRegExp(davService || this._knownDavService)
72+
6973
// Validate data
70-
validateData(data, davService || this._knownDavService)
74+
validateData(data, davService)
7175

7276
this._data = {
7377
// TODO: Remove with next major release, this is just for compatibility
@@ -426,8 +430,7 @@ export abstract class Node {
426430
* String representation of the node
427431
*/
428432
toString(): string {
429-
const constructorData: NodeConstructorData = [this._data, this._knownDavService]
430-
return JSON.stringify(constructorData)
433+
return JSON.stringify([this._data, this._knownDavService.toString()])
431434
}
432435

433436
}

lib/node/nodeData.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
*/
55

66
import { join } from 'path'
7+
import RegexParser from 'regex-parser'
8+
79
import { Permission } from '../permissions'
810
import { NodeStatus } from './node'
911

@@ -157,3 +159,30 @@ export const validateData = (data: NodeData, davService: RegExp) => {
157159
throw new Error('Status must be a valid NodeStatus')
158160
}
159161
}
162+
163+
/**
164+
* In case we try to create a node from deserialized data,
165+
* we need to fix date types.
166+
*/
167+
export const fixDates = (data: NodeData) => {
168+
if (data.mtime && typeof data.mtime === 'string') {
169+
if (!isNaN(Date.parse(data.mtime))
170+
&& JSON.stringify(new Date(data.mtime)) === JSON.stringify(data.mtime)) {
171+
data.mtime = new Date(data.mtime)
172+
}
173+
}
174+
175+
if (data.crtime && typeof data.crtime === 'string') {
176+
if (!isNaN(Date.parse(data.crtime))
177+
&& JSON.stringify(new Date(data.crtime)) === JSON.stringify(data.crtime)) {
178+
data.crtime = new Date(data.crtime)
179+
}
180+
}
181+
}
182+
183+
export const fixRegExp = (pattern: string | RegExp): RegExp => {
184+
if (typeof pattern === 'string') {
185+
return RegexParser(pattern)
186+
}
187+
return pattern
188+
}

0 commit comments

Comments
 (0)