@simplysm/storage 1.0.138 → 13.0.0-beta.2

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.
Files changed (124) hide show
  1. package/.cache/typecheck-node.tsbuildinfo +1 -0
  2. package/.cache/typecheck-tests-node.tsbuildinfo +1 -0
  3. package/README.md +262 -0
  4. package/dist/clients/ftp-storage-client.js +126 -0
  5. package/dist/clients/ftp-storage-client.js.map +7 -0
  6. package/dist/clients/sftp-storage-client.js +108 -0
  7. package/dist/clients/sftp-storage-client.js.map +7 -0
  8. package/dist/core-common/src/common.types.d.ts +74 -0
  9. package/dist/core-common/src/common.types.d.ts.map +1 -0
  10. package/dist/core-common/src/env.d.ts +6 -0
  11. package/dist/core-common/src/env.d.ts.map +1 -0
  12. package/dist/core-common/src/errors/argument-error.d.ts +25 -0
  13. package/dist/core-common/src/errors/argument-error.d.ts.map +1 -0
  14. package/dist/core-common/src/errors/not-implemented-error.d.ts +29 -0
  15. package/dist/core-common/src/errors/not-implemented-error.d.ts.map +1 -0
  16. package/dist/core-common/src/errors/sd-error.d.ts +27 -0
  17. package/dist/core-common/src/errors/sd-error.d.ts.map +1 -0
  18. package/dist/core-common/src/errors/timeout-error.d.ts +31 -0
  19. package/dist/core-common/src/errors/timeout-error.d.ts.map +1 -0
  20. package/dist/core-common/src/extensions/arr-ext.d.ts +15 -0
  21. package/dist/core-common/src/extensions/arr-ext.d.ts.map +1 -0
  22. package/dist/core-common/src/extensions/arr-ext.helpers.d.ts +19 -0
  23. package/dist/core-common/src/extensions/arr-ext.helpers.d.ts.map +1 -0
  24. package/dist/core-common/src/extensions/arr-ext.types.d.ts +215 -0
  25. package/dist/core-common/src/extensions/arr-ext.types.d.ts.map +1 -0
  26. package/dist/core-common/src/extensions/map-ext.d.ts +57 -0
  27. package/dist/core-common/src/extensions/map-ext.d.ts.map +1 -0
  28. package/dist/core-common/src/extensions/set-ext.d.ts +36 -0
  29. package/dist/core-common/src/extensions/set-ext.d.ts.map +1 -0
  30. package/dist/core-common/src/features/debounce-queue.d.ts +53 -0
  31. package/dist/core-common/src/features/debounce-queue.d.ts.map +1 -0
  32. package/dist/core-common/src/features/event-emitter.d.ts +66 -0
  33. package/dist/core-common/src/features/event-emitter.d.ts.map +1 -0
  34. package/dist/core-common/src/features/serial-queue.d.ts +47 -0
  35. package/dist/core-common/src/features/serial-queue.d.ts.map +1 -0
  36. package/dist/core-common/src/index.d.ts +32 -0
  37. package/dist/core-common/src/index.d.ts.map +1 -0
  38. package/dist/core-common/src/types/date-only.d.ts +152 -0
  39. package/dist/core-common/src/types/date-only.d.ts.map +1 -0
  40. package/dist/core-common/src/types/date-time.d.ts +96 -0
  41. package/dist/core-common/src/types/date-time.d.ts.map +1 -0
  42. package/dist/core-common/src/types/lazy-gc-map.d.ts +80 -0
  43. package/dist/core-common/src/types/lazy-gc-map.d.ts.map +1 -0
  44. package/dist/core-common/src/types/time.d.ts +68 -0
  45. package/dist/core-common/src/types/time.d.ts.map +1 -0
  46. package/dist/core-common/src/types/uuid.d.ts +35 -0
  47. package/dist/core-common/src/types/uuid.d.ts.map +1 -0
  48. package/dist/core-common/src/utils/bytes.d.ts +51 -0
  49. package/dist/core-common/src/utils/bytes.d.ts.map +1 -0
  50. package/dist/core-common/src/utils/date-format.d.ts +90 -0
  51. package/dist/core-common/src/utils/date-format.d.ts.map +1 -0
  52. package/dist/core-common/src/utils/json.d.ts +34 -0
  53. package/dist/core-common/src/utils/json.d.ts.map +1 -0
  54. package/dist/core-common/src/utils/num.d.ts +60 -0
  55. package/dist/core-common/src/utils/num.d.ts.map +1 -0
  56. package/dist/core-common/src/utils/obj.d.ts +258 -0
  57. package/dist/core-common/src/utils/obj.d.ts.map +1 -0
  58. package/dist/core-common/src/utils/path.d.ts +23 -0
  59. package/dist/core-common/src/utils/path.d.ts.map +1 -0
  60. package/dist/core-common/src/utils/primitive.d.ts +18 -0
  61. package/dist/core-common/src/utils/primitive.d.ts.map +1 -0
  62. package/dist/core-common/src/utils/str.d.ts +103 -0
  63. package/dist/core-common/src/utils/str.d.ts.map +1 -0
  64. package/dist/core-common/src/utils/template-strings.d.ts +84 -0
  65. package/dist/core-common/src/utils/template-strings.d.ts.map +1 -0
  66. package/dist/core-common/src/utils/transferable.d.ts +47 -0
  67. package/dist/core-common/src/utils/transferable.d.ts.map +1 -0
  68. package/dist/core-common/src/utils/wait.d.ts +19 -0
  69. package/dist/core-common/src/utils/wait.d.ts.map +1 -0
  70. package/dist/core-common/src/utils/xml.d.ts +36 -0
  71. package/dist/core-common/src/utils/xml.d.ts.map +1 -0
  72. package/dist/core-common/src/zip/sd-zip.d.ts +80 -0
  73. package/dist/core-common/src/zip/sd-zip.d.ts.map +1 -0
  74. package/dist/index.js +7 -10
  75. package/dist/index.js.map +7 -1
  76. package/dist/storage/src/clients/ftp-storage-client.d.ts +56 -0
  77. package/dist/storage/src/clients/ftp-storage-client.d.ts.map +1 -0
  78. package/dist/storage/src/clients/sftp-storage-client.d.ts +48 -0
  79. package/dist/storage/src/clients/sftp-storage-client.d.ts.map +1 -0
  80. package/dist/storage/src/index.d.ts +7 -0
  81. package/dist/storage/src/index.d.ts.map +1 -0
  82. package/dist/storage/src/storage-factory.d.ts +20 -0
  83. package/dist/storage/src/storage-factory.d.ts.map +1 -0
  84. package/dist/storage/src/types/storage-conn-config.d.ts +7 -0
  85. package/dist/storage/src/types/storage-conn-config.d.ts.map +1 -0
  86. package/dist/storage/src/types/storage-type.d.ts +2 -0
  87. package/dist/storage/src/types/storage-type.d.ts.map +1 -0
  88. package/dist/storage/src/types/storage.d.ts +19 -0
  89. package/dist/storage/src/types/storage.d.ts.map +1 -0
  90. package/dist/storage-factory.js +35 -0
  91. package/dist/storage-factory.js.map +7 -0
  92. package/dist/types/storage-conn-config.js +1 -0
  93. package/dist/types/storage-conn-config.js.map +7 -0
  94. package/dist/types/storage-type.js +1 -0
  95. package/dist/types/storage-type.js.map +7 -0
  96. package/dist/types/storage.js +1 -0
  97. package/dist/types/storage.js.map +7 -0
  98. package/package.json +16 -7
  99. package/src/clients/ftp-storage-client.ts +146 -0
  100. package/src/clients/sftp-storage-client.ts +135 -0
  101. package/src/index.ts +14 -4
  102. package/src/storage-factory.ts +47 -0
  103. package/src/types/storage-conn-config.ts +6 -0
  104. package/src/types/storage-type.ts +1 -0
  105. package/src/types/storage.ts +20 -0
  106. package/tests/ftp-storage-client.spec.ts +259 -0
  107. package/tests/sftp-storage-client.spec.ts +251 -0
  108. package/tests/storage-factory.spec.ts +160 -0
  109. package/dist/common/IStorage.d.ts +0 -7
  110. package/dist/common/IStorage.js +0 -3
  111. package/dist/common/IStorage.js.map +0 -1
  112. package/dist/ftp/FtpStorage.d.ts +0 -11
  113. package/dist/ftp/FtpStorage.js +0 -165
  114. package/dist/ftp/FtpStorage.js.map +0 -1
  115. package/dist/ftp/IFtpConnectionConfig.d.ts +0 -6
  116. package/dist/ftp/IFtpConnectionConfig.js +0 -3
  117. package/dist/ftp/IFtpConnectionConfig.js.map +0 -1
  118. package/dist/index.d.ts +0 -4
  119. package/src/common/IStorage.ts +0 -9
  120. package/src/ftp/FtpStorage.ts +0 -87
  121. package/src/ftp/IFtpConnectionConfig.ts +0 -6
  122. package/tsconfig.build.json +0 -17
  123. package/tsconfig.json +0 -17
  124. package/tslint.json +0 -3
@@ -0,0 +1,135 @@
1
+ import type { Bytes } from "@simplysm/core-common";
2
+ import { SdError } from "@simplysm/core-common";
3
+ import SftpClient from "ssh2-sftp-client";
4
+ import type { Storage, FileInfo } from "../types/storage";
5
+ import type { StorageConnConfig } from "../types/storage-conn-config";
6
+
7
+ // ssh2-sftp-client 라이브러리 타입 정의에서 Buffer 사용
8
+ type SftpGetResult = string | NodeJS.WritableStream | Bytes;
9
+
10
+ /**
11
+ * SFTP 프로토콜을 사용하는 스토리지 클라이언트.
12
+ *
13
+ * @remarks
14
+ * 직접 사용보다 {@link StorageFactory.connect}를 통한 사용을 권장합니다.
15
+ */
16
+ export class SftpStorageClient implements Storage {
17
+ private _client: SftpClient | undefined;
18
+
19
+ /**
20
+ * SFTP 서버에 연결합니다.
21
+ *
22
+ * @remarks
23
+ * - 연결 후 반드시 {@link close}로 연결을 종료해야 합니다.
24
+ * - 동일 인스턴스에서 여러 번 호출하지 마세요. (연결 누수 발생)
25
+ * - 자동 연결/종료 관리가 필요하면 {@link StorageFactory.connect}를 사용하세요. (권장)
26
+ */
27
+ async connect(config: StorageConnConfig): Promise<void> {
28
+ if (this._client !== undefined) {
29
+ throw new SdError("이미 SFTP 서버에 연결되어 있습니다. 먼저 close()를 호출하세요.");
30
+ }
31
+
32
+ const client = new SftpClient();
33
+ try {
34
+ await client.connect({
35
+ host: config.host,
36
+ port: config.port,
37
+ username: config.user,
38
+ password: config.pass,
39
+ });
40
+ this._client = client;
41
+ } catch (err) {
42
+ await client.end();
43
+ throw err;
44
+ }
45
+ }
46
+
47
+ private _requireClient(): SftpClient {
48
+ if (this._client === undefined) {
49
+ throw new SdError("SFTP 서버에 연결되어있지 않습니다.");
50
+ }
51
+ return this._client;
52
+ }
53
+
54
+ /** 디렉토리를 생성합니다. 상위 디렉토리가 없으면 함께 생성합니다. */
55
+ async mkdir(dirPath: string): Promise<void> {
56
+ await this._requireClient().mkdir(dirPath, true);
57
+ }
58
+
59
+ async rename(fromPath: string, toPath: string): Promise<void> {
60
+ await this._requireClient().rename(fromPath, toPath);
61
+ }
62
+
63
+ /**
64
+ * 파일 또는 디렉토리 존재 여부를 확인합니다.
65
+ *
66
+ * @remarks
67
+ * 상위 디렉토리가 존재하지 않는 경우에도 false를 반환합니다.
68
+ * 네트워크 오류, 권한 오류 등 모든 예외도 false를 반환합니다.
69
+ */
70
+ async exists(filePath: string): Promise<boolean> {
71
+ try {
72
+ // ssh2-sftp-client의 exists()는 false | 'd' | '-' | 'l' 를 반환한다.
73
+ // false: 존재하지 않음, 'd': 디렉토리, '-': 파일, 'l': 심볼릭 링크
74
+ const result = await this._requireClient().exists(filePath);
75
+ return typeof result === "string";
76
+ } catch {
77
+ return false;
78
+ }
79
+ }
80
+
81
+ async readdir(dirPath: string): Promise<FileInfo[]> {
82
+ const list = await this._requireClient().list(dirPath);
83
+ return list.map((item) => ({
84
+ name: item.name,
85
+ isFile: item.type === "-",
86
+ }));
87
+ }
88
+
89
+ async readFile(filePath: string): Promise<Bytes> {
90
+ // ssh2-sftp-client의 get()은 dst 미전달 시 Buffer를 반환한다.
91
+ // 타입 정의(string | WritableStream | Buffer)와 달리 실제로는 Buffer만 반환된다.
92
+ const result = (await this._requireClient().get(filePath)) as SftpGetResult;
93
+ if (result instanceof Uint8Array) {
94
+ return result;
95
+ }
96
+ // 타입 정의상 string도 가능하므로 방어 코드
97
+ if (typeof result === "string") {
98
+ return new TextEncoder().encode(result);
99
+ }
100
+ throw new SdError("예상치 못한 응답 타입입니다.");
101
+ }
102
+
103
+ async remove(filePath: string): Promise<void> {
104
+ await this._requireClient().delete(filePath);
105
+ }
106
+
107
+ /** 로컬 파일 경로 또는 바이트 데이터를 원격 경로에 업로드합니다. */
108
+ async put(localPathOrBuffer: string | Bytes, storageFilePath: string): Promise<void> {
109
+ if (typeof localPathOrBuffer === "string") {
110
+ await this._requireClient().fastPut(localPathOrBuffer, storageFilePath);
111
+ } else {
112
+ // eslint-disable-next-line no-restricted-globals -- ssh2-sftp-client 라이브러리 요구사항
113
+ await this._requireClient().put(Buffer.from(localPathOrBuffer), storageFilePath);
114
+ }
115
+ }
116
+
117
+ async uploadDir(fromPath: string, toPath: string): Promise<void> {
118
+ await this._requireClient().uploadDir(fromPath, toPath);
119
+ }
120
+
121
+ /**
122
+ * 연결을 종료합니다.
123
+ *
124
+ * @remarks
125
+ * 이미 종료된 상태에서 호출해도 에러가 발생하지 않습니다.
126
+ * 종료 후에는 동일 인스턴스에서 {@link connect}를 다시 호출하여 재연결할 수 있습니다.
127
+ */
128
+ async close(): Promise<void> {
129
+ if (this._client === undefined) {
130
+ return;
131
+ }
132
+ await this._client.end();
133
+ this._client = undefined;
134
+ }
135
+ }
package/src/index.ts CHANGED
@@ -1,4 +1,14 @@
1
- import "@simplysm/common";
2
- export * from "./common/IStorage";
3
- export * from "./ftp/FtpStorage";
4
- export * from "./ftp/IFtpConnectionConfig";
1
+ //#region Types
2
+ export * from "./types/storage-conn-config";
3
+ export * from "./types/storage";
4
+ export * from "./types/storage-type";
5
+ //#endregion
6
+
7
+ //#region Clients
8
+ export * from "./clients/ftp-storage-client";
9
+ export * from "./clients/sftp-storage-client";
10
+ //#endregion
11
+
12
+ //#region Factory
13
+ export * from "./storage-factory";
14
+ //#endregion
@@ -0,0 +1,47 @@
1
+ import type { StorageConnConfig } from "./types/storage-conn-config";
2
+ import type { Storage } from "./types/storage";
3
+ import type { StorageType } from "./types/storage-type";
4
+ import { FtpStorageClient } from "./clients/ftp-storage-client";
5
+ import { SftpStorageClient } from "./clients/sftp-storage-client";
6
+
7
+ /**
8
+ * 스토리지 클라이언트 팩토리
9
+ *
10
+ * FTP, FTPS, SFTP 스토리지 연결을 생성하고 관리한다.
11
+ */
12
+ export class StorageFactory {
13
+ /**
14
+ * 스토리지에 연결하고 콜백을 실행한 후 자동으로 연결을 종료한다.
15
+ *
16
+ * @remarks
17
+ * 콜백 패턴으로 연결/종료가 자동 관리되므로, 직접 클라이언트를 사용하는 것보다 권장된다.
18
+ * 콜백에서 예외가 발생해도 연결은 자동으로 종료된다.
19
+ */
20
+ static async connect<R>(
21
+ type: StorageType,
22
+ config: StorageConnConfig,
23
+ fn: (storage: Storage) => R | Promise<R>,
24
+ ): Promise<R> {
25
+ const client = StorageFactory._createClient(type);
26
+
27
+ await client.connect(config);
28
+ try {
29
+ return await fn(client);
30
+ } finally {
31
+ await client.close().catch(() => {
32
+ // 이미 닫힌 경우 무시
33
+ });
34
+ }
35
+ }
36
+
37
+ private static _createClient(type: StorageType): Storage {
38
+ switch (type) {
39
+ case "sftp":
40
+ return new SftpStorageClient();
41
+ case "ftps":
42
+ return new FtpStorageClient(true);
43
+ case "ftp":
44
+ return new FtpStorageClient(false);
45
+ }
46
+ }
47
+ }
@@ -0,0 +1,6 @@
1
+ export interface StorageConnConfig {
2
+ host: string;
3
+ port?: number;
4
+ user?: string;
5
+ pass?: string;
6
+ }
@@ -0,0 +1 @@
1
+ export type StorageType = "ftp" | "ftps" | "sftp";
@@ -0,0 +1,20 @@
1
+ import type { Bytes } from "@simplysm/core-common";
2
+ import type { StorageConnConfig } from "./storage-conn-config";
3
+
4
+ export interface FileInfo {
5
+ name: string;
6
+ isFile: boolean;
7
+ }
8
+
9
+ export interface Storage {
10
+ connect(config: StorageConnConfig): Promise<void>;
11
+ mkdir(dirPath: string): Promise<void>;
12
+ rename(fromPath: string, toPath: string): Promise<void>;
13
+ readdir(dirPath: string): Promise<FileInfo[]>;
14
+ readFile(filePath: string): Promise<Bytes>;
15
+ exists(filePath: string): Promise<boolean>;
16
+ put(localPathOrBuffer: string | Bytes, storageFilePath: string): Promise<void>;
17
+ uploadDir(fromPath: string, toPath: string): Promise<void>;
18
+ remove(filePath: string): Promise<void>;
19
+ close(): Promise<void>;
20
+ }
@@ -0,0 +1,259 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { FtpStorageClient } from "../src/clients/ftp-storage-client";
3
+
4
+ // basic-ftp 모듈 모킹
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("연결 설정으로 접속해야 함", 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("secure 모드로 접속해야 함", 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("이미 연결된 상태에서 connect 호출 시 에러", async () => {
75
+ await client.connect({ host: "test" });
76
+ await expect(client.connect({ host: "test" })).rejects.toThrow(
77
+ "이미 FTP 서버에 연결되어 있습니다. 먼저 close()를 호출하세요.",
78
+ );
79
+ });
80
+
81
+ it("연결 실패 시 클라이언트를 정리해야 함", 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("연결 전 메서드 호출", () => {
89
+ it("연결 전 mkdir 호출 시 에러", async () => {
90
+ await expect(client.mkdir("/test")).rejects.toThrow("FTP 서버에 연결되어있지 않습니다.");
91
+ });
92
+
93
+ it("연결 전 rename 호출 시 에러", async () => {
94
+ await expect(client.rename("/from", "/to")).rejects.toThrow("FTP 서버에 연결되어있지 않습니다.");
95
+ });
96
+
97
+ it("연결 전 readdir 호출 시 에러", async () => {
98
+ await expect(client.readdir("/")).rejects.toThrow("FTP 서버에 연결되어있지 않습니다.");
99
+ });
100
+ });
101
+
102
+ describe("mkdir", () => {
103
+ it("디렉토리를 생성해야 함", async () => {
104
+ await client.connect({ host: "test" });
105
+ await client.mkdir("/test/dir");
106
+
107
+ expect(mockEnsureDir).toHaveBeenCalledWith("/test/dir");
108
+ });
109
+ });
110
+
111
+ describe("rename", () => {
112
+ it("파일/디렉토리 이름을 변경해야 함", async () => {
113
+ await client.connect({ host: "test" });
114
+ await client.rename("/from", "/to");
115
+
116
+ expect(mockRename).toHaveBeenCalledWith("/from", "/to");
117
+ });
118
+ });
119
+
120
+ describe("readdir", () => {
121
+ it("디렉토리 목록을 FileInfo 배열로 반환해야 함", async () => {
122
+ await client.connect({ host: "test" });
123
+ const result = await client.readdir("/");
124
+
125
+ expect(result).toEqual([
126
+ { name: "file.txt", isFile: true },
127
+ { name: "dir", isFile: false },
128
+ ]);
129
+ });
130
+ });
131
+
132
+ describe("readFile", () => {
133
+ it("파일 내용을 Uint8Array로 반환해야 함", async () => {
134
+ await client.connect({ host: "test" });
135
+ const result = await client.readFile("/file.txt");
136
+
137
+ expect(result).toBeInstanceOf(Uint8Array);
138
+ expect(new TextDecoder().decode(result)).toBe("test content");
139
+ });
140
+ });
141
+
142
+ describe("exists", () => {
143
+ it("파일이 존재하면 true 반환 (size로 확인)", async () => {
144
+ await client.connect({ host: "test" });
145
+ const result = await client.exists("/path/file.txt");
146
+
147
+ expect(mockSize).toHaveBeenCalledWith("/path/file.txt");
148
+ expect(result).toBe(true);
149
+ });
150
+
151
+ it("디렉토리가 존재하면 true 반환 (list로 확인)", async () => {
152
+ mockSize.mockRejectedValueOnce(new Error("Not a file"));
153
+ await client.connect({ host: "test" });
154
+ const result = await client.exists("/path/dir");
155
+
156
+ expect(mockList).toHaveBeenCalledWith("/path");
157
+ expect(result).toBe(true);
158
+ });
159
+
160
+ it("파일이 존재하지 않으면 false 반환", async () => {
161
+ mockSize.mockRejectedValueOnce(new Error("Not a file"));
162
+ mockList.mockResolvedValueOnce([]);
163
+ await client.connect({ host: "test" });
164
+ const result = await client.exists("/path/nonexistent.txt");
165
+
166
+ expect(result).toBe(false);
167
+ });
168
+
169
+ it("에러 발생 시 false 반환", async () => {
170
+ mockSize.mockRejectedValueOnce(new Error("Not a file"));
171
+ mockList.mockRejectedValueOnce(new Error("Not found"));
172
+ await client.connect({ host: "test" });
173
+ const result = await client.exists("/path/error.txt");
174
+
175
+ expect(result).toBe(false);
176
+ });
177
+
178
+ it("루트 디렉토리 파일 존재 확인", async () => {
179
+ mockSize.mockRejectedValueOnce(new Error("Not a file"));
180
+ await client.connect({ host: "test" });
181
+ const result = await client.exists("/file.txt");
182
+
183
+ expect(mockList).toHaveBeenCalledWith("/");
184
+ expect(result).toBe(true);
185
+ });
186
+
187
+ it("슬래시 없는 경로 존재 확인", async () => {
188
+ mockSize.mockRejectedValueOnce(new Error("Not a file"));
189
+ await client.connect({ host: "test" });
190
+ const result = await client.exists("file.txt");
191
+
192
+ expect(mockList).toHaveBeenCalledWith("/");
193
+ expect(result).toBe(true);
194
+ });
195
+ });
196
+
197
+ describe("remove", () => {
198
+ it("파일을 삭제해야 함", async () => {
199
+ await client.connect({ host: "test" });
200
+ await client.remove("/file.txt");
201
+
202
+ expect(mockRemove).toHaveBeenCalledWith("/file.txt");
203
+ });
204
+ });
205
+
206
+ describe("put", () => {
207
+ it("로컬 경로에서 업로드해야 함", async () => {
208
+ await client.connect({ host: "test" });
209
+ await client.put("/local/file.txt", "/remote/file.txt");
210
+
211
+ expect(mockUploadFrom).toHaveBeenCalledWith("/local/file.txt", "/remote/file.txt");
212
+ });
213
+
214
+ it("Uint8Array에서 업로드해야 함", async () => {
215
+ await client.connect({ host: "test" });
216
+ const bytes = new TextEncoder().encode("content");
217
+ await client.put(bytes, "/remote/file.txt");
218
+
219
+ expect(mockUploadFrom).toHaveBeenCalled();
220
+ });
221
+ });
222
+
223
+ describe("uploadDir", () => {
224
+ it("디렉토리를 업로드해야 함", async () => {
225
+ await client.connect({ host: "test" });
226
+ await client.uploadDir("/local/dir", "/remote/dir");
227
+
228
+ expect(mockUploadFromDir).toHaveBeenCalledWith("/local/dir", "/remote/dir");
229
+ });
230
+ });
231
+
232
+ describe("close", () => {
233
+ it("연결 전 close 호출 시 에러 없이 종료", async () => {
234
+ await expect(client.close()).resolves.toBeUndefined();
235
+ });
236
+
237
+ it("연결을 닫아야 함", async () => {
238
+ await client.connect({ host: "test" });
239
+ await client.close();
240
+
241
+ expect(mockClose).toHaveBeenCalled();
242
+ });
243
+
244
+ it("close 후 메서드 호출 시 에러", async () => {
245
+ await client.connect({ host: "test" });
246
+ await client.close();
247
+
248
+ await expect(client.mkdir("/test")).rejects.toThrow("FTP 서버에 연결되어있지 않습니다.");
249
+ });
250
+
251
+ it("close 후 재연결이 가능해야 함", async () => {
252
+ await client.connect({ host: "test" });
253
+ await client.close();
254
+ await client.connect({ host: "test" });
255
+
256
+ expect(mockAccess).toHaveBeenCalledTimes(2);
257
+ });
258
+ });
259
+ });