@simplysm/storage 13.0.69 → 13.0.70
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -276
- package/dist/clients/ftp-storage-client.d.ts +18 -18
- package/dist/clients/ftp-storage-client.d.ts.map +1 -1
- package/dist/clients/ftp-storage-client.js +17 -17
- package/dist/clients/ftp-storage-client.js.map +1 -1
- package/dist/clients/sftp-storage-client.d.ts +14 -14
- package/dist/clients/sftp-storage-client.d.ts.map +1 -1
- package/dist/clients/sftp-storage-client.js +15 -15
- package/dist/clients/sftp-storage-client.js.map +1 -1
- package/dist/storage-factory.d.ts +5 -5
- package/dist/storage-factory.js +3 -3
- package/package.json +6 -5
- package/src/clients/ftp-storage-client.ts +22 -22
- package/src/clients/sftp-storage-client.ts +26 -26
- package/src/storage-factory.ts +6 -6
- package/tests/ftp-storage-client.spec.ts +261 -0
- package/tests/sftp-storage-client.spec.ts +253 -0
- package/tests/storage-factory.spec.ts +172 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/clients/sftp-storage-client.ts"],
|
|
4
|
-
"mappings": "AACA,SAAS,eAAe;AACxB,OAAO,gBAAgB;AAahB,MAAM,kBAAqC;AAAA,EACxC;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAUR,MAAM,QAAQ,QAA0C;AACtD,QAAI,KAAK,YAAY,QAAW;AAC9B,YAAM,IAAI,QAAQ,
|
|
4
|
+
"mappings": "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",
|
|
5
5
|
"names": []
|
|
6
6
|
}
|
|
@@ -2,17 +2,17 @@ import type { StorageConnConfig } from "./types/storage-conn-config";
|
|
|
2
2
|
import type { Storage } from "./types/storage";
|
|
3
3
|
import type { StorageType } from "./types/storage-type";
|
|
4
4
|
/**
|
|
5
|
-
*
|
|
5
|
+
* Storage client factory
|
|
6
6
|
*
|
|
7
|
-
* FTP, FTPS, SFTP
|
|
7
|
+
* Creates and manages FTP, FTPS, and SFTP storage connections.
|
|
8
8
|
*/
|
|
9
9
|
export declare class StorageFactory {
|
|
10
10
|
/**
|
|
11
|
-
*
|
|
11
|
+
* Connect to storage, execute the callback, and automatically close the connection.
|
|
12
12
|
*
|
|
13
13
|
* @remarks
|
|
14
|
-
*
|
|
15
|
-
*
|
|
14
|
+
* The callback pattern auto-manages connection/close, so this is preferred over direct client usage.
|
|
15
|
+
* The connection is automatically closed even if the callback throws an exception.
|
|
16
16
|
*/
|
|
17
17
|
static connect<R>(type: StorageType, config: StorageConnConfig, fn: (storage: Storage) => R | Promise<R>): Promise<R>;
|
|
18
18
|
private static _createClient;
|
package/dist/storage-factory.js
CHANGED
|
@@ -2,11 +2,11 @@ import { FtpStorageClient } from "./clients/ftp-storage-client.js";
|
|
|
2
2
|
import { SftpStorageClient } from "./clients/sftp-storage-client.js";
|
|
3
3
|
class StorageFactory {
|
|
4
4
|
/**
|
|
5
|
-
*
|
|
5
|
+
* Connect to storage, execute the callback, and automatically close the connection.
|
|
6
6
|
*
|
|
7
7
|
* @remarks
|
|
8
|
-
*
|
|
9
|
-
*
|
|
8
|
+
* The callback pattern auto-manages connection/close, so this is preferred over direct client usage.
|
|
9
|
+
* The connection is automatically closed even if the callback throws an exception.
|
|
10
10
|
*/
|
|
11
11
|
static async connect(type, config, fn) {
|
|
12
12
|
const client = StorageFactory._createClient(type);
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@simplysm/storage",
|
|
3
|
-
"version": "13.0.
|
|
4
|
-
"description": "
|
|
5
|
-
"author": "
|
|
3
|
+
"version": "13.0.70",
|
|
4
|
+
"description": "Simplysm Package - Storage Module (node)",
|
|
5
|
+
"author": "simplysm",
|
|
6
6
|
"license": "Apache-2.0",
|
|
7
7
|
"repository": {
|
|
8
8
|
"type": "git",
|
|
@@ -14,13 +14,14 @@
|
|
|
14
14
|
"types": "./dist/index.d.ts",
|
|
15
15
|
"files": [
|
|
16
16
|
"dist",
|
|
17
|
-
"src"
|
|
17
|
+
"src",
|
|
18
|
+
"tests"
|
|
18
19
|
],
|
|
19
20
|
"sideEffects": false,
|
|
20
21
|
"dependencies": {
|
|
21
22
|
"basic-ftp": "^5.2.0",
|
|
22
23
|
"ssh2-sftp-client": "^12.0.1",
|
|
23
|
-
"@simplysm/core-common": "13.0.
|
|
24
|
+
"@simplysm/core-common": "13.0.70"
|
|
24
25
|
},
|
|
25
26
|
"devDependencies": {
|
|
26
27
|
"@types/ssh2-sftp-client": "^9.0.6"
|
|
@@ -6,11 +6,11 @@ import type { Storage, FileInfo } from "../types/storage";
|
|
|
6
6
|
import type { StorageConnConfig } from "../types/storage-conn-config";
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
|
-
* FTP/FTPS
|
|
9
|
+
* Storage client using FTP/FTPS protocol.
|
|
10
10
|
*
|
|
11
11
|
* @remarks
|
|
12
|
-
*
|
|
13
|
-
*
|
|
12
|
+
* The `secure` constructor parameter configures whether to use FTPS.
|
|
13
|
+
* Using {@link StorageFactory.connect} is recommended over direct usage.
|
|
14
14
|
*/
|
|
15
15
|
export class FtpStorageClient implements Storage {
|
|
16
16
|
private _client: ftp.Client | undefined;
|
|
@@ -18,16 +18,16 @@ export class FtpStorageClient implements Storage {
|
|
|
18
18
|
constructor(private readonly _secure: boolean = false) {}
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
|
-
* FTP
|
|
21
|
+
* Connect to the FTP server.
|
|
22
22
|
*
|
|
23
23
|
* @remarks
|
|
24
|
-
* -
|
|
25
|
-
* -
|
|
26
|
-
* -
|
|
24
|
+
* - Must close the connection with {@link close} after use.
|
|
25
|
+
* - Do not call multiple times on the same instance (connection leak).
|
|
26
|
+
* - Use {@link StorageFactory.connect} for automatic connection/close management (recommended).
|
|
27
27
|
*/
|
|
28
28
|
async connect(config: StorageConnConfig): Promise<void> {
|
|
29
29
|
if (this._client !== undefined) {
|
|
30
|
-
throw new SdError("
|
|
30
|
+
throw new SdError("FTP server is already connected. Please call close() first.");
|
|
31
31
|
}
|
|
32
32
|
const client = new ftp.Client();
|
|
33
33
|
try {
|
|
@@ -47,12 +47,12 @@ export class FtpStorageClient implements Storage {
|
|
|
47
47
|
|
|
48
48
|
private _requireClient(): ftp.Client {
|
|
49
49
|
if (this._client === undefined) {
|
|
50
|
-
throw new SdError("
|
|
50
|
+
throw new SdError("Not connected to FTP server.");
|
|
51
51
|
}
|
|
52
52
|
return this._client;
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
/**
|
|
55
|
+
/** Create a directory. Creates parent directories if they do not exist. */
|
|
56
56
|
async mkdir(dirPath: string): Promise<void> {
|
|
57
57
|
await this._requireClient().ensureDir(dirPath);
|
|
58
58
|
}
|
|
@@ -78,24 +78,24 @@ export class FtpStorageClient implements Storage {
|
|
|
78
78
|
}
|
|
79
79
|
|
|
80
80
|
/**
|
|
81
|
-
*
|
|
81
|
+
* Check whether a file or directory exists.
|
|
82
82
|
*
|
|
83
83
|
* @remarks
|
|
84
|
-
*
|
|
85
|
-
*
|
|
84
|
+
* For files, uses the size() command for O(1) performance.
|
|
85
|
+
* For directories, queries the parent directory listing, so performance may degrade with many entries.
|
|
86
86
|
*
|
|
87
|
-
*
|
|
87
|
+
* Paths without slashes (e.g. `file.txt`) are searched in the root directory (`/`).
|
|
88
88
|
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
89
|
+
* Returns false even if the parent directory does not exist.
|
|
90
|
+
* Returns false for all exceptions including network errors and permission errors.
|
|
91
91
|
*/
|
|
92
92
|
async exists(filePath: string): Promise<boolean> {
|
|
93
93
|
try {
|
|
94
|
-
//
|
|
94
|
+
// Quick check for files via size() (O(1))
|
|
95
95
|
await this._requireClient().size(filePath);
|
|
96
96
|
return true;
|
|
97
97
|
} catch {
|
|
98
|
-
// size()
|
|
98
|
+
// If size() fails, it may be a directory, so check via list()
|
|
99
99
|
try {
|
|
100
100
|
const lastSlash = filePath.lastIndexOf("/");
|
|
101
101
|
const dirPath = lastSlash > 0 ? filePath.substring(0, lastSlash) : "/";
|
|
@@ -112,7 +112,7 @@ export class FtpStorageClient implements Storage {
|
|
|
112
112
|
await this._requireClient().remove(filePath);
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
-
/**
|
|
115
|
+
/** Upload a local file path or byte data to the remote path. */
|
|
116
116
|
async put(localPathOrBuffer: string | Bytes, storageFilePath: string): Promise<void> {
|
|
117
117
|
let param: string | Readable;
|
|
118
118
|
if (typeof localPathOrBuffer === "string") {
|
|
@@ -128,11 +128,11 @@ export class FtpStorageClient implements Storage {
|
|
|
128
128
|
}
|
|
129
129
|
|
|
130
130
|
/**
|
|
131
|
-
*
|
|
131
|
+
* Close the connection.
|
|
132
132
|
*
|
|
133
133
|
* @remarks
|
|
134
|
-
*
|
|
135
|
-
*
|
|
134
|
+
* Safe to call when already closed (no error thrown).
|
|
135
|
+
* After closing, you can reconnect by calling {@link connect} again on the same instance.
|
|
136
136
|
*/
|
|
137
137
|
close(): Promise<void> {
|
|
138
138
|
if (this._client === undefined) {
|
|
@@ -4,29 +4,29 @@ import SftpClient from "ssh2-sftp-client";
|
|
|
4
4
|
import type { Storage, FileInfo } from "../types/storage";
|
|
5
5
|
import type { StorageConnConfig } from "../types/storage-conn-config";
|
|
6
6
|
|
|
7
|
-
// ssh2-sftp-client
|
|
7
|
+
// Buffer usage from ssh2-sftp-client library type definitions
|
|
8
8
|
type SftpGetResult = string | NodeJS.WritableStream | Bytes;
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
|
-
*
|
|
11
|
+
* Storage client using SFTP protocol.
|
|
12
12
|
*
|
|
13
13
|
* @remarks
|
|
14
|
-
*
|
|
14
|
+
* Using {@link StorageFactory.connect} is recommended over direct usage.
|
|
15
15
|
*/
|
|
16
16
|
export class SftpStorageClient implements Storage {
|
|
17
17
|
private _client: SftpClient | undefined;
|
|
18
18
|
|
|
19
19
|
/**
|
|
20
|
-
* SFTP
|
|
20
|
+
* Connect to the SFTP server.
|
|
21
21
|
*
|
|
22
22
|
* @remarks
|
|
23
|
-
* -
|
|
24
|
-
* -
|
|
25
|
-
* -
|
|
23
|
+
* - Must close the connection with {@link close} after use.
|
|
24
|
+
* - Do not call multiple times on the same instance (connection leak).
|
|
25
|
+
* - Use {@link StorageFactory.connect} for automatic connection/close management (recommended).
|
|
26
26
|
*/
|
|
27
27
|
async connect(config: StorageConnConfig): Promise<void> {
|
|
28
28
|
if (this._client !== undefined) {
|
|
29
|
-
throw new SdError("
|
|
29
|
+
throw new SdError("SFTP server is already connected. Please call close() first.");
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
const client = new SftpClient();
|
|
@@ -39,7 +39,7 @@ export class SftpStorageClient implements Storage {
|
|
|
39
39
|
password: config.pass,
|
|
40
40
|
});
|
|
41
41
|
} else {
|
|
42
|
-
// SSH agent +
|
|
42
|
+
// Authenticate with SSH agent + key file
|
|
43
43
|
const fsP = await import("fs/promises");
|
|
44
44
|
const os = await import("os");
|
|
45
45
|
const pathMod = await import("path");
|
|
@@ -58,7 +58,7 @@ export class SftpStorageClient implements Storage {
|
|
|
58
58
|
privateKey: await fsP.readFile(keyPath),
|
|
59
59
|
});
|
|
60
60
|
} catch {
|
|
61
|
-
// privateKey
|
|
61
|
+
// privateKey parsing failed (encrypted key, etc.) -> retry with agent only
|
|
62
62
|
await client.connect(baseOptions);
|
|
63
63
|
}
|
|
64
64
|
}
|
|
@@ -71,12 +71,12 @@ export class SftpStorageClient implements Storage {
|
|
|
71
71
|
|
|
72
72
|
private _requireClient(): SftpClient {
|
|
73
73
|
if (this._client === undefined) {
|
|
74
|
-
throw new SdError("
|
|
74
|
+
throw new SdError("Not connected to SFTP server.");
|
|
75
75
|
}
|
|
76
76
|
return this._client;
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
-
/**
|
|
79
|
+
/** Create a directory. Creates parent directories if they do not exist. */
|
|
80
80
|
async mkdir(dirPath: string): Promise<void> {
|
|
81
81
|
await this._requireClient().mkdir(dirPath, true);
|
|
82
82
|
}
|
|
@@ -86,16 +86,16 @@ export class SftpStorageClient implements Storage {
|
|
|
86
86
|
}
|
|
87
87
|
|
|
88
88
|
/**
|
|
89
|
-
*
|
|
89
|
+
* Check whether a file or directory exists.
|
|
90
90
|
*
|
|
91
91
|
* @remarks
|
|
92
|
-
*
|
|
93
|
-
*
|
|
92
|
+
* Returns false even if the parent directory does not exist.
|
|
93
|
+
* Returns false for all exceptions including network errors and permission errors.
|
|
94
94
|
*/
|
|
95
95
|
async exists(filePath: string): Promise<boolean> {
|
|
96
96
|
try {
|
|
97
|
-
// ssh2-sftp-client
|
|
98
|
-
// false:
|
|
97
|
+
// ssh2-sftp-client's exists() returns false | 'd' | '-' | 'l'.
|
|
98
|
+
// false: does not exist, 'd': directory, '-': file, 'l': symbolic link
|
|
99
99
|
const result = await this._requireClient().exists(filePath);
|
|
100
100
|
return typeof result === "string";
|
|
101
101
|
} catch {
|
|
@@ -112,29 +112,29 @@ export class SftpStorageClient implements Storage {
|
|
|
112
112
|
}
|
|
113
113
|
|
|
114
114
|
async readFile(filePath: string): Promise<Bytes> {
|
|
115
|
-
// ssh2-sftp-client
|
|
116
|
-
//
|
|
115
|
+
// ssh2-sftp-client's get() returns Buffer when dst is not provided.
|
|
116
|
+
// Despite the type definition (string | WritableStream | Buffer), only Buffer is actually returned.
|
|
117
117
|
const result = (await this._requireClient().get(filePath)) as SftpGetResult;
|
|
118
118
|
if (result instanceof Uint8Array) {
|
|
119
119
|
return result;
|
|
120
120
|
}
|
|
121
|
-
//
|
|
121
|
+
// Defensive code since string is possible per type definition
|
|
122
122
|
if (typeof result === "string") {
|
|
123
123
|
return new TextEncoder().encode(result);
|
|
124
124
|
}
|
|
125
|
-
throw new SdError("
|
|
125
|
+
throw new SdError("Unexpected response type.");
|
|
126
126
|
}
|
|
127
127
|
|
|
128
128
|
async remove(filePath: string): Promise<void> {
|
|
129
129
|
await this._requireClient().delete(filePath);
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
-
/**
|
|
132
|
+
/** Upload a local file path or byte data to the remote path. */
|
|
133
133
|
async put(localPathOrBuffer: string | Bytes, storageFilePath: string): Promise<void> {
|
|
134
134
|
if (typeof localPathOrBuffer === "string") {
|
|
135
135
|
await this._requireClient().fastPut(localPathOrBuffer, storageFilePath);
|
|
136
136
|
} else {
|
|
137
|
-
// eslint-disable-next-line no-restricted-globals -- ssh2-sftp-client
|
|
137
|
+
// eslint-disable-next-line no-restricted-globals -- ssh2-sftp-client library requirement
|
|
138
138
|
await this._requireClient().put(Buffer.from(localPathOrBuffer), storageFilePath);
|
|
139
139
|
}
|
|
140
140
|
}
|
|
@@ -144,11 +144,11 @@ export class SftpStorageClient implements Storage {
|
|
|
144
144
|
}
|
|
145
145
|
|
|
146
146
|
/**
|
|
147
|
-
*
|
|
147
|
+
* Close the connection.
|
|
148
148
|
*
|
|
149
149
|
* @remarks
|
|
150
|
-
*
|
|
151
|
-
*
|
|
150
|
+
* Safe to call when already closed (no error thrown).
|
|
151
|
+
* After closing, you can reconnect by calling {@link connect} again on the same instance.
|
|
152
152
|
*/
|
|
153
153
|
async close(): Promise<void> {
|
|
154
154
|
if (this._client === undefined) {
|
package/src/storage-factory.ts
CHANGED
|
@@ -5,17 +5,17 @@ import { FtpStorageClient } from "./clients/ftp-storage-client";
|
|
|
5
5
|
import { SftpStorageClient } from "./clients/sftp-storage-client";
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
*
|
|
8
|
+
* Storage client factory
|
|
9
9
|
*
|
|
10
|
-
* FTP, FTPS, SFTP
|
|
10
|
+
* Creates and manages FTP, FTPS, and SFTP storage connections.
|
|
11
11
|
*/
|
|
12
12
|
export class StorageFactory {
|
|
13
13
|
/**
|
|
14
|
-
*
|
|
14
|
+
* Connect to storage, execute the callback, and automatically close the connection.
|
|
15
15
|
*
|
|
16
16
|
* @remarks
|
|
17
|
-
*
|
|
18
|
-
*
|
|
17
|
+
* The callback pattern auto-manages connection/close, so this is preferred over direct client usage.
|
|
18
|
+
* The connection is automatically closed even if the callback throws an exception.
|
|
19
19
|
*/
|
|
20
20
|
static async connect<R>(
|
|
21
21
|
type: StorageType,
|
|
@@ -29,7 +29,7 @@ export class StorageFactory {
|
|
|
29
29
|
return await fn(client);
|
|
30
30
|
} finally {
|
|
31
31
|
await client.close().catch(() => {
|
|
32
|
-
//
|
|
32
|
+
// Ignore if already closed
|
|
33
33
|
});
|
|
34
34
|
}
|
|
35
35
|
}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { FtpStorageClient } from "../src/clients/ftp-storage-client";
|
|
3
|
+
|
|
4
|
+
// Mock basic-ftp module
|
|
5
|
+
const mockAccess = vi.fn().mockResolvedValue(undefined);
|
|
6
|
+
const mockEnsureDir = vi.fn().mockResolvedValue(undefined);
|
|
7
|
+
const mockRename = vi.fn().mockResolvedValue(undefined);
|
|
8
|
+
const mockSize = vi.fn().mockResolvedValue(100);
|
|
9
|
+
const mockList = vi.fn().mockResolvedValue([
|
|
10
|
+
{ name: "file.txt", isFile: true },
|
|
11
|
+
{ name: "dir", isFile: false },
|
|
12
|
+
]);
|
|
13
|
+
const mockDownloadTo = vi.fn().mockImplementation((writable) => {
|
|
14
|
+
writable.emit("data", new TextEncoder().encode("test content"));
|
|
15
|
+
return Promise.resolve();
|
|
16
|
+
});
|
|
17
|
+
const mockRemove = vi.fn().mockResolvedValue(undefined);
|
|
18
|
+
const mockUploadFrom = vi.fn().mockResolvedValue(undefined);
|
|
19
|
+
const mockUploadFromDir = vi.fn().mockResolvedValue(undefined);
|
|
20
|
+
const mockClose = vi.fn();
|
|
21
|
+
|
|
22
|
+
vi.mock("basic-ftp", () => {
|
|
23
|
+
return {
|
|
24
|
+
default: {
|
|
25
|
+
Client: class MockClient {
|
|
26
|
+
access = mockAccess;
|
|
27
|
+
ensureDir = mockEnsureDir;
|
|
28
|
+
rename = mockRename;
|
|
29
|
+
size = mockSize;
|
|
30
|
+
list = mockList;
|
|
31
|
+
downloadTo = mockDownloadTo;
|
|
32
|
+
remove = mockRemove;
|
|
33
|
+
uploadFrom = mockUploadFrom;
|
|
34
|
+
uploadFromDir = mockUploadFromDir;
|
|
35
|
+
close = mockClose;
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("FtpStorageClient", () => {
|
|
42
|
+
let client: FtpStorageClient;
|
|
43
|
+
|
|
44
|
+
beforeEach(() => {
|
|
45
|
+
vi.clearAllMocks();
|
|
46
|
+
client = new FtpStorageClient();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("connect", () => {
|
|
50
|
+
it("Should connect with connection settings", async () => {
|
|
51
|
+
await client.connect({
|
|
52
|
+
host: "ftp.example.com",
|
|
53
|
+
port: 21,
|
|
54
|
+
user: "user",
|
|
55
|
+
pass: "pass",
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
expect(mockAccess).toHaveBeenCalledWith({
|
|
59
|
+
host: "ftp.example.com",
|
|
60
|
+
port: 21,
|
|
61
|
+
user: "user",
|
|
62
|
+
password: "pass",
|
|
63
|
+
secure: false,
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("Should connect in secure mode", async () => {
|
|
68
|
+
const secureClient = new FtpStorageClient(true);
|
|
69
|
+
await secureClient.connect({ host: "ftp.example.com" });
|
|
70
|
+
|
|
71
|
+
expect(mockAccess).toHaveBeenCalledWith(expect.objectContaining({ secure: true }));
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("Should throw error when connect is called on already connected client", async () => {
|
|
75
|
+
await client.connect({ host: "test" });
|
|
76
|
+
await expect(client.connect({ host: "test" })).rejects.toThrow(
|
|
77
|
+
"FTP server is already connected. Please call close() first.",
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("Should clean up client on connection failure", async () => {
|
|
82
|
+
mockAccess.mockRejectedValueOnce(new Error("Auth failed"));
|
|
83
|
+
await expect(client.connect({ host: "test" })).rejects.toThrow("Auth failed");
|
|
84
|
+
expect(mockClose).toHaveBeenCalled();
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe("Method calls before connection", () => {
|
|
89
|
+
it("Should throw error when mkdir is called before connection", async () => {
|
|
90
|
+
await expect(client.mkdir("/test")).rejects.toThrow("Not connected to FTP server.");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("Should throw error when rename is called before connection", async () => {
|
|
94
|
+
await expect(client.rename("/from", "/to")).rejects.toThrow(
|
|
95
|
+
"Not connected to FTP server.",
|
|
96
|
+
);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("Should throw error when readdir is called before connection", async () => {
|
|
100
|
+
await expect(client.readdir("/")).rejects.toThrow("Not connected to FTP server.");
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe("mkdir", () => {
|
|
105
|
+
it("Should create directory", async () => {
|
|
106
|
+
await client.connect({ host: "test" });
|
|
107
|
+
await client.mkdir("/test/dir");
|
|
108
|
+
|
|
109
|
+
expect(mockEnsureDir).toHaveBeenCalledWith("/test/dir");
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe("rename", () => {
|
|
114
|
+
it("Should rename file/directory", async () => {
|
|
115
|
+
await client.connect({ host: "test" });
|
|
116
|
+
await client.rename("/from", "/to");
|
|
117
|
+
|
|
118
|
+
expect(mockRename).toHaveBeenCalledWith("/from", "/to");
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe("readdir", () => {
|
|
123
|
+
it("Should return directory list as FileInfo array", async () => {
|
|
124
|
+
await client.connect({ host: "test" });
|
|
125
|
+
const result = await client.readdir("/");
|
|
126
|
+
|
|
127
|
+
expect(result).toEqual([
|
|
128
|
+
{ name: "file.txt", isFile: true },
|
|
129
|
+
{ name: "dir", isFile: false },
|
|
130
|
+
]);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe("readFile", () => {
|
|
135
|
+
it("Should return file content as Uint8Array", async () => {
|
|
136
|
+
await client.connect({ host: "test" });
|
|
137
|
+
const result = await client.readFile("/file.txt");
|
|
138
|
+
|
|
139
|
+
expect(result).toBeInstanceOf(Uint8Array);
|
|
140
|
+
expect(new TextDecoder().decode(result)).toBe("test content");
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe("exists", () => {
|
|
145
|
+
it("Should return true if file exists (checked via size)", async () => {
|
|
146
|
+
await client.connect({ host: "test" });
|
|
147
|
+
const result = await client.exists("/path/file.txt");
|
|
148
|
+
|
|
149
|
+
expect(mockSize).toHaveBeenCalledWith("/path/file.txt");
|
|
150
|
+
expect(result).toBe(true);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("Should return true if directory exists (checked via list)", async () => {
|
|
154
|
+
mockSize.mockRejectedValueOnce(new Error("Not a file"));
|
|
155
|
+
await client.connect({ host: "test" });
|
|
156
|
+
const result = await client.exists("/path/dir");
|
|
157
|
+
|
|
158
|
+
expect(mockList).toHaveBeenCalledWith("/path");
|
|
159
|
+
expect(result).toBe(true);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("Should return false if file does not exist", async () => {
|
|
163
|
+
mockSize.mockRejectedValueOnce(new Error("Not a file"));
|
|
164
|
+
mockList.mockResolvedValueOnce([]);
|
|
165
|
+
await client.connect({ host: "test" });
|
|
166
|
+
const result = await client.exists("/path/nonexistent.txt");
|
|
167
|
+
|
|
168
|
+
expect(result).toBe(false);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("Should return false on error", async () => {
|
|
172
|
+
mockSize.mockRejectedValueOnce(new Error("Not a file"));
|
|
173
|
+
mockList.mockRejectedValueOnce(new Error("Not found"));
|
|
174
|
+
await client.connect({ host: "test" });
|
|
175
|
+
const result = await client.exists("/path/error.txt");
|
|
176
|
+
|
|
177
|
+
expect(result).toBe(false);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("Should check file existence in root directory", async () => {
|
|
181
|
+
mockSize.mockRejectedValueOnce(new Error("Not a file"));
|
|
182
|
+
await client.connect({ host: "test" });
|
|
183
|
+
const result = await client.exists("/file.txt");
|
|
184
|
+
|
|
185
|
+
expect(mockList).toHaveBeenCalledWith("/");
|
|
186
|
+
expect(result).toBe(true);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("Should check file existence for paths without slashes", async () => {
|
|
190
|
+
mockSize.mockRejectedValueOnce(new Error("Not a file"));
|
|
191
|
+
await client.connect({ host: "test" });
|
|
192
|
+
const result = await client.exists("file.txt");
|
|
193
|
+
|
|
194
|
+
expect(mockList).toHaveBeenCalledWith("/");
|
|
195
|
+
expect(result).toBe(true);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
describe("remove", () => {
|
|
200
|
+
it("Should delete file", async () => {
|
|
201
|
+
await client.connect({ host: "test" });
|
|
202
|
+
await client.remove("/file.txt");
|
|
203
|
+
|
|
204
|
+
expect(mockRemove).toHaveBeenCalledWith("/file.txt");
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
describe("put", () => {
|
|
209
|
+
it("Should upload from local path", async () => {
|
|
210
|
+
await client.connect({ host: "test" });
|
|
211
|
+
await client.put("/local/file.txt", "/remote/file.txt");
|
|
212
|
+
|
|
213
|
+
expect(mockUploadFrom).toHaveBeenCalledWith("/local/file.txt", "/remote/file.txt");
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("Should upload from Uint8Array", async () => {
|
|
217
|
+
await client.connect({ host: "test" });
|
|
218
|
+
const bytes = new TextEncoder().encode("content");
|
|
219
|
+
await client.put(bytes, "/remote/file.txt");
|
|
220
|
+
|
|
221
|
+
expect(mockUploadFrom).toHaveBeenCalled();
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
describe("uploadDir", () => {
|
|
226
|
+
it("Should upload directory", async () => {
|
|
227
|
+
await client.connect({ host: "test" });
|
|
228
|
+
await client.uploadDir("/local/dir", "/remote/dir");
|
|
229
|
+
|
|
230
|
+
expect(mockUploadFromDir).toHaveBeenCalledWith("/local/dir", "/remote/dir");
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
describe("close", () => {
|
|
235
|
+
it("Should exit without error when close is called before connection", async () => {
|
|
236
|
+
await expect(client.close()).resolves.toBeUndefined();
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("Should close connection", async () => {
|
|
240
|
+
await client.connect({ host: "test" });
|
|
241
|
+
await client.close();
|
|
242
|
+
|
|
243
|
+
expect(mockClose).toHaveBeenCalled();
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("Should throw error when calling method after close", async () => {
|
|
247
|
+
await client.connect({ host: "test" });
|
|
248
|
+
await client.close();
|
|
249
|
+
|
|
250
|
+
await expect(client.mkdir("/test")).rejects.toThrow("Not connected to FTP server.");
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("Should allow reconnection after close", async () => {
|
|
254
|
+
await client.connect({ host: "test" });
|
|
255
|
+
await client.close();
|
|
256
|
+
await client.connect({ host: "test" });
|
|
257
|
+
|
|
258
|
+
expect(mockAccess).toHaveBeenCalledTimes(2);
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
});
|