Skip to content

Commit 64daab4

Browse files
committed
Close #30: Test cases for special characters.
1 parent 0e6d66f commit 64daab4

File tree

36 files changed

+2232
-64
lines changed

36 files changed

+2232
-64
lines changed

Sources/Rainmaker/Server.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,17 @@ extension Server: Serving {
185185
}
186186
}
187187

188+
public func enumerate(at path: String, recursively: Bool) async throws -> [Item] {
189+
var items = [Item]()
190+
let stream: AsyncThrowingStream<Item, Error> = try await enumerate(at: path, recursively: recursively)
191+
192+
for try await item in stream {
193+
items.append(item)
194+
}
195+
196+
return items
197+
}
198+
188199
public func login() async throws -> LoginFlow {
189200
logger.debug("Fetching login information...")
190201

Sources/Rainmaker/Serving.swift

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,22 @@ protocol Serving: Sendable {
4545
///
4646
func enumerate(at path: String, recursively: Bool) async throws -> AsyncThrowingStream<Item, Error>
4747

48+
///
49+
/// A convenience wrapper that aggregates all items first before returning.
50+
///
51+
/// > Warning: It is recommended to use the equally named streaming alternative which returns an `AsyncThrowingStream` whenever possible.
52+
/// Using this method may result in high memory peaks in case of large hierarchies in recursive enumeration.
53+
///
54+
/// - Parameters:
55+
/// - path: The root directory to enter.
56+
/// - recursively: Whether subdirectories should be traversed, too.
57+
///
58+
/// - Returns: All items found at the given path (and, optionally, in its subdirectories) collected in an array.
59+
///
60+
/// - Throws: Any error that might occur during the listing of a remote directory.
61+
///
62+
func enumerate(at path: String, recursively: Bool) async throws -> [Item]
63+
4864
///
4965
/// Look up the login flow information.
5066
///

Sources/RainmakerCLI/Commands/List.swift

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,12 +32,7 @@ struct List: AsyncParsableCommand {
3232
}
3333

3434
let server = Server(address: address, password: authenticatedArguments.passwordValue, user: authenticatedArguments.userValue)
35-
let stream = try await server.enumerate(at: path, recursively: recursive)
36-
var items = [Item]()
37-
38-
for try await item in stream {
39-
items.append(item)
40-
}
35+
let items: [Item] = try await server.enumerate(at: path, recursively: recursive)
4136

4237
switch formatArguments.outputFormat {
4338
case .json:

Tests/RainmakerTests/Extensions/HTTPURLResponse+init.swift

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,29 +11,24 @@ extension HTTPURLResponse {
1111
/// More comprehensive and robust implementations by others have been left out intentionally for now.
1212
///
1313
/// - Parameters:
14-
/// - file: The fixture to parse for the values.
14+
/// - data: The raw HTTP response header data to parse.
15+
/// - request: The original URL request this information is supposed to be initialized for.
1516
///
16-
convenience init?(from file: URL, for request: URL) throws {
17-
guard FileManager.default.fileExists(atPath: file.path()) else {
18-
throw RainmakerTestsError.missingFixture(file)
19-
}
20-
21-
let data = try Data(contentsOf: file)
22-
17+
convenience init?(from data: Data, for request: URL) throws {
2318
guard let text = String(data: data, encoding: .utf8) else {
24-
throw RainmakerTestsError.decodingError(file)
19+
throw RainmakerTestsError.decodingError(request)
2520
}
2621

2722
let lines = text.split(separator: "\n")
2823

2924
guard let status = lines.first?.split(separator: " ") else {
30-
throw RainmakerTestsError.decodingError(file)
25+
throw RainmakerTestsError.decodingError(request)
3126
}
3227

3328
let httpVersion = String(status[0])
3429

3530
guard let statusCode = Int(status[1]) else {
36-
throw RainmakerTestsError.decodingError(file)
31+
throw RainmakerTestsError.decodingError(request)
3732
}
3833

3934
var headerFields = [String: String]()
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
// SPDX-FileCopyrightText: 2026 Iva Horn
2+
// SPDX-License-Identifier: MIT
3+
4+
import Foundation
5+
6+
extension URL {
7+
///
8+
/// A convenience method to encode the path of a URL with a custom set of allowed characters to ensure maximum file system compatibility for test fixture paths.
9+
///
10+
var percentEncodedPath: String {
11+
var allowedCharacters = CharacterSet.alphanumerics
12+
allowedCharacters.insert(charactersIn: ".")
13+
allowedCharacters.insert(charactersIn: "-")
14+
allowedCharacters.insert(charactersIn: "_")
15+
allowedCharacters.insert(charactersIn: "/")
16+
17+
return path(percentEncoded: false).addingPercentEncoding(withAllowedCharacters: allowedCharacters) ?? path(percentEncoded: false)
18+
}
19+
}

Tests/RainmakerTests/ListingTests.swift

Lines changed: 77 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -14,49 +14,102 @@ import Testing
1414
let server = try makeServer(user: nil, password: nil, serverVersion: serverVersion)
1515

1616
await #expect(throws: RainmakerError.credentialsRequired) {
17-
_ = try await server.enumerate(at: "/", recursively: false)
17+
let _: [Item] = try await server.enumerate(at: "/", recursively: false)
1818
}
1919
}
2020

2121
@Test("List Root Folder Content", arguments: ServerVersion.allCases)
2222
func listRootFolderContent(_ serverVersion: ServerVersion) async throws {
2323
let server = try makeServer(serverVersion: serverVersion)
24-
let stream = try await server.enumerate(at: "/", recursively: false)
25-
var items = [Item]()
26-
27-
for try await item in stream {
28-
items.append(item)
29-
}
30-
31-
#expect(items.contains { $0.href.path() == "/remote.php/dav/files/admin/Readme.md" })
24+
let items: [Item] = try await server.enumerate(at: "/", recursively: false)
25+
let readme = try #require(items.first { $0.name == "Readme.md" })
26+
#expect(readme.path == "/Readme.md")
27+
#expect(readme.href.path() == "/remote.php/dav/files/admin/Readme.md")
3228
}
3329

3430
@Test("List Documents Folder Content", arguments: ServerVersion.allCases)
3531
func listDocumentsFolderContent(_ serverVersion: ServerVersion) async throws {
3632
let server = try makeServer(serverVersion: serverVersion)
37-
let stream = try await server.enumerate(at: "/Documents", recursively: false)
38-
var items = [Item]()
39-
40-
for try await item in stream {
41-
items.append(item)
42-
}
43-
44-
#expect(items.contains { $0.href.path() == "/remote.php/dav/files/admin/Documents/Example.md" })
33+
let items: [Item] = try await server.enumerate(at: "/Documents", recursively: false)
34+
let example = try #require(items.first { $0.name == "Example.md" })
35+
#expect(example.path == "/Documents/Example.md")
36+
#expect(example.href.path() == "/remote.php/dav/files/admin/Documents/Example.md")
4537
}
4638

4739
@Test("List All Content Recursively and Asynchronously", arguments: ServerVersion.allCases)
4840
func listAllContentRecursivelyAndAsynchronously(_ serverVersion: ServerVersion) async throws {
4941
let server = try makeServer(serverVersion: serverVersion)
50-
var items = [Item]()
51-
let stream: AsyncThrowingStream<Item, Error> = try await server.enumerate(at: "/", recursively: true)
52-
53-
for try await item in stream {
54-
items.append(item)
55-
}
56-
42+
let items: [Item] = try await server.enumerate(at: "/", recursively: true)
5743
#expect(items.contains { $0.href.path() == "/remote.php/dav/files/admin/Readme.md" })
5844
#expect(items.contains { $0.href.path() == "/remote.php/dav/files/admin/Documents/Example.md" })
5945
#expect(items.contains { $0.href.path() == "/remote.php/dav/files/admin/Photos/Frog.jpg" })
6046
#expect(items.contains { $0.href.path() == "/remote.php/dav/files/admin/Templates/Brainstorming.whiteboard" })
6147
}
48+
49+
@Test("List Whitespace Folder Content", arguments: ServerVersion.allCases)
50+
func listWhitespaceFolderContent(_ serverVersion: ServerVersion) async throws {
51+
let server = try makeServer(serverVersion: serverVersion)
52+
let items: [Item] = try await server.enumerate(at: "/Special Characters", recursively: false)
53+
#expect(items.contains { $0.name == ":" })
54+
#expect(items.contains { $0.name == "?" })
55+
#expect(items.contains { $0.name == "&" })
56+
#expect(items.contains { $0.name == "#" })
57+
#expect(items.contains { $0.name == "%" })
58+
}
59+
60+
@Test("List Percent-encoded Folder Content", arguments: ServerVersion.allCases)
61+
func listPercentEncodedFolderContent(_ serverVersion: ServerVersion) async throws {
62+
let server = try makeServer(serverVersion: serverVersion)
63+
let items: [Item] = try await server.enumerate(at: "/Special%20Characters", recursively: false)
64+
#expect(items.contains { $0.name == ":" })
65+
#expect(items.contains { $0.name == "?" })
66+
#expect(items.contains { $0.name == "&" })
67+
#expect(items.contains { $0.name == "#" })
68+
#expect(items.contains { $0.name == "%" })
69+
}
70+
71+
@Test("List : Folder Content", arguments: ServerVersion.allCases)
72+
func listColonFolderContent(_ serverVersion: ServerVersion) async throws {
73+
let server = try makeServer(serverVersion: serverVersion)
74+
let items: [Item] = try await server.enumerate(at: "/Special Characters/:", recursively: false)
75+
let readme = try #require(items.first { $0.name == "Readme.md" })
76+
#expect(readme.href.path() == "/remote.php/dav/files/admin/Special%20Characters/:/Readme.md")
77+
#expect(readme.path == "/Special Characters/:/Readme.md")
78+
}
79+
80+
@Test("List ? Folder Content", arguments: ServerVersion.allCases)
81+
func listQuestionMarkFolderContent(_ serverVersion: ServerVersion) async throws {
82+
let server = try makeServer(serverVersion: serverVersion)
83+
let items: [Item] = try await server.enumerate(at: "/Special Characters/?", recursively: false)
84+
let readme = try #require(items.first { $0.name == "Readme.md" })
85+
#expect(readme.href.path() == "/remote.php/dav/files/admin/Special%20Characters/%3f/Readme.md")
86+
#expect(readme.path == "/Special Characters/?/Readme.md")
87+
}
88+
89+
@Test("List & Folder Content", arguments: ServerVersion.allCases)
90+
func listAmpersandFolderContent(_ serverVersion: ServerVersion) async throws {
91+
let server = try makeServer(serverVersion: serverVersion)
92+
let items: [Item] = try await server.enumerate(at: "/Special Characters/&", recursively: false)
93+
let readme = try #require(items.first { $0.name == "Readme.md" })
94+
#expect(readme.href.path() == "/remote.php/dav/files/admin/Special%20Characters/%26/Readme.md")
95+
#expect(readme.path == "/Special Characters/&/Readme.md")
96+
}
97+
98+
@Test("List # Folder Content", arguments: ServerVersion.allCases)
99+
func listHashFolderContent(_ serverVersion: ServerVersion) async throws {
100+
let server = try makeServer(serverVersion: serverVersion)
101+
let items: [Item] = try await server.enumerate(at: "/Special Characters/#", recursively: false)
102+
let readme = try #require(items.first { $0.name == "Readme.md" })
103+
#expect(readme.href.path() == "/remote.php/dav/files/admin/Special%20Characters/%23/Readme.md")
104+
#expect(readme.path == "/Special Characters/#/Readme.md")
105+
}
106+
107+
@Test("List % Folder Content", arguments: ServerVersion.allCases)
108+
func listPercentFolderContent(_ serverVersion: ServerVersion) async throws {
109+
let server = try makeServer(serverVersion: serverVersion)
110+
let items: [Item] = try await server.enumerate(at: "/Special Characters/%", recursively: false)
111+
let readme = try #require(items.first { $0.name == "Readme.md" })
112+
#expect(readme.href.path() == "/remote.php/dav/files/admin/Special%20Characters/%25/Readme.md")
113+
#expect(readme.path == "/Special Characters/%/Readme.md")
114+
}
62115
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
<d:multistatus xmlns:d="DAV:" xmlns:s="http://sabredav.org/ns" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns">
2+
<d:response>
3+
<d:href>/remote.php/dav/files/admin/Special%20Characters/%26/</d:href>
4+
<d:propstat>
5+
<d:prop>
6+
<d:creationdate>1970-01-01T00:00:00+00:00</d:creationdate>
7+
<d:displayname>&amp;</d:displayname>
8+
<d:getetag>"6966648ee0be6"</d:getetag>
9+
<d:getlastmodified>Tue, 13 Jan 2026 15:28:14 GMT</d:getlastmodified>
10+
<d:quota-available-bytes>-3</d:quota-available-bytes>
11+
<d:quota-used-bytes>554</d:quota-used-bytes>
12+
<d:resourcetype>
13+
<d:collection/>
14+
</d:resourcetype>
15+
<nc:has-preview>false</nc:has-preview>
16+
<nc:hidden>false</nc:hidden>
17+
<nc:is-mount-root>false</nc:is-mount-root>
18+
<oc:comments-count>0</oc:comments-count>
19+
<oc:comments-href>/remote.php/dav/comments/files/442</oc:comments-href>
20+
<oc:comments-unread>0</oc:comments-unread>
21+
<oc:favorite>0</oc:favorite>
22+
<oc:fileid>442</oc:fileid>
23+
<oc:id>00000442ocvseob65bkf</oc:id>
24+
<oc:owner-display-name>admin</oc:owner-display-name>
25+
<oc:owner-id>admin</oc:owner-id>
26+
<oc:permissions>RGDNVCK</oc:permissions>
27+
<oc:size>554</oc:size>
28+
</d:prop>
29+
<d:status>HTTP/1.1 200 OK</d:status>
30+
</d:propstat>
31+
<d:propstat>
32+
<d:prop>
33+
<d:getcontenttype/>
34+
<nc:is-encrypted/>
35+
<nc:lock/>
36+
<nc:lock-owner/>
37+
<nc:lock-owner-displayname/>
38+
<nc:lock-owner-editor/>
39+
<nc:lock-owner-type/>
40+
<nc:lock-time/>
41+
<nc:lock-timeout/>
42+
<nc:lock-token/>
43+
<nc:upload_time/>
44+
</d:prop>
45+
<d:status>HTTP/1.1 404 Not Found</d:status>
46+
</d:propstat>
47+
</d:response>
48+
<d:response>
49+
<d:href>/remote.php/dav/files/admin/Special%20Characters/%26/Readme.md</d:href>
50+
<d:propstat>
51+
<d:prop>
52+
<d:creationdate>1970-01-01T00:00:00+00:00</d:creationdate>
53+
<d:displayname>Readme.md</d:displayname>
54+
<d:getcontenttype>text/markdown</d:getcontenttype>
55+
<d:getetag>"47645c72bff9c92ebf83edf2807916e6"</d:getetag>
56+
<d:getlastmodified>Tue, 13 Jan 2026 15:28:14 GMT</d:getlastmodified>
57+
<d:resourcetype/>
58+
<nc:has-preview>true</nc:has-preview>
59+
<nc:hidden>false</nc:hidden>
60+
<nc:is-mount-root>false</nc:is-mount-root>
61+
<nc:upload_time>0</nc:upload_time>
62+
<oc:comments-count>0</oc:comments-count>
63+
<oc:comments-href>/remote.php/dav/comments/files/467</oc:comments-href>
64+
<oc:comments-unread>0</oc:comments-unread>
65+
<oc:favorite>0</oc:favorite>
66+
<oc:fileid>467</oc:fileid>
67+
<oc:id>00000467ocvseob65bkf</oc:id>
68+
<oc:owner-display-name>admin</oc:owner-display-name>
69+
<oc:owner-id>admin</oc:owner-id>
70+
<oc:permissions>RGDNVW</oc:permissions>
71+
<oc:size>554</oc:size>
72+
</d:prop>
73+
<d:status>HTTP/1.1 200 OK</d:status>
74+
</d:propstat>
75+
<d:propstat>
76+
<d:prop>
77+
<d:quota-available-bytes/>
78+
<d:quota-used-bytes/>
79+
<nc:is-encrypted/>
80+
<nc:lock/>
81+
<nc:lock-owner/>
82+
<nc:lock-owner-displayname/>
83+
<nc:lock-owner-editor/>
84+
<nc:lock-owner-type/>
85+
<nc:lock-time/>
86+
<nc:lock-timeout/>
87+
<nc:lock-token/>
88+
</d:prop>
89+
<d:status>HTTP/1.1 404 Not Found</d:status>
90+
</d:propstat>
91+
</d:response>
92+
</d:multistatus>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
HTTP/1.1 207 Multi-Status
2+
Content-Type: application/xml; charset=utf-8

0 commit comments

Comments
 (0)