@uploadista/core 0.0.18-beta.2 → 0.0.18-beta.4

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 (97) hide show
  1. package/dist/{checksum-B1XGxTsI.cjs → checksum-Bs89yEZy.cjs} +1 -1
  2. package/dist/{checksum-DsSiXsPO.mjs → checksum-COoD-F1l.mjs} +2 -2
  3. package/dist/{checksum-DsSiXsPO.mjs.map → checksum-COoD-F1l.mjs.map} +1 -1
  4. package/dist/errors/index.cjs +1 -1
  5. package/dist/errors/index.d.cts +1 -1
  6. package/dist/errors/index.d.mts +1 -1
  7. package/dist/errors/index.mjs +1 -1
  8. package/dist/flow/index.cjs +1 -1
  9. package/dist/flow/index.d.cts +5 -5
  10. package/dist/flow/index.d.mts +5 -5
  11. package/dist/flow/index.mjs +1 -1
  12. package/dist/flow-B0AKihCK.cjs +1 -0
  13. package/dist/flow-CszNZOpc.mjs +2 -0
  14. package/dist/flow-CszNZOpc.mjs.map +1 -0
  15. package/dist/{index-CHQtirAp.d.mts → index-9gyMMEIB.d.cts} +2 -2
  16. package/dist/index-9gyMMEIB.d.cts.map +1 -0
  17. package/dist/{index-CoAMCnm6.d.cts → index-B9V5SSxl.d.mts} +2 -2
  18. package/dist/{index-CHQtirAp.d.mts.map → index-B9V5SSxl.d.mts.map} +1 -1
  19. package/dist/{index-C4zZWqtz.d.cts → index-BFSHumky.d.mts} +2 -2
  20. package/dist/{index-C4zZWqtz.d.cts.map → index-BFSHumky.d.mts.map} +1 -1
  21. package/dist/{index-zQ707AXp.d.mts → index-DFbu_-zn.d.cts} +2 -2
  22. package/dist/{index-zQ707AXp.d.mts.map → index-DFbu_-zn.d.cts.map} +1 -1
  23. package/dist/{index-D5ALjvAb.d.cts → index-cYpdknQ_.d.cts} +2059 -1155
  24. package/dist/index-cYpdknQ_.d.cts.map +1 -0
  25. package/dist/{index-DiHUjE9t.d.mts → index-xq80GmLX.d.mts} +2059 -1155
  26. package/dist/index-xq80GmLX.d.mts.map +1 -0
  27. package/dist/index.cjs +1 -1
  28. package/dist/index.d.cts +5 -5
  29. package/dist/index.d.mts +5 -5
  30. package/dist/index.mjs +1 -1
  31. package/dist/{stream-limiter-ByVdSC5T.mjs → stream-limiter-B9nsn2gb.mjs} +2 -2
  32. package/dist/{stream-limiter-ByVdSC5T.mjs.map → stream-limiter-B9nsn2gb.mjs.map} +1 -1
  33. package/dist/{stream-limiter-B6CRA3Zd.cjs → stream-limiter-jdTNLczW.cjs} +1 -1
  34. package/dist/streams/index.cjs +1 -1
  35. package/dist/streams/index.d.cts +2 -2
  36. package/dist/streams/index.d.mts +2 -2
  37. package/dist/streams/index.mjs +1 -1
  38. package/dist/testing/index.cjs +1 -1
  39. package/dist/testing/index.d.cts +4 -4
  40. package/dist/testing/index.d.mts +4 -4
  41. package/dist/testing/index.mjs +1 -1
  42. package/dist/types/index.cjs +1 -1
  43. package/dist/types/index.d.cts +5 -5
  44. package/dist/types/index.d.mts +5 -5
  45. package/dist/types/index.mjs +1 -1
  46. package/dist/types-Bbd8jExI.mjs +2 -0
  47. package/dist/types-Bbd8jExI.mjs.map +1 -0
  48. package/dist/types-CkVwVXLA.cjs +1 -0
  49. package/dist/upload/index.cjs +1 -1
  50. package/dist/upload/index.d.cts +4 -4
  51. package/dist/upload/index.d.mts +4 -4
  52. package/dist/upload/index.mjs +1 -1
  53. package/dist/{upload-DPX3jSQH.mjs → upload-Cnbo-Ks3.mjs} +2 -2
  54. package/dist/{upload-DPX3jSQH.mjs.map → upload-Cnbo-Ks3.mjs.map} +1 -1
  55. package/dist/{upload-uQfkhcMj.cjs → upload-CrEoWXaa.cjs} +1 -1
  56. package/dist/{uploadista-error-DigegPz2.d.cts → uploadista-error-CYCmAtkZ.d.cts} +2 -2
  57. package/dist/uploadista-error-CYCmAtkZ.d.cts.map +1 -0
  58. package/dist/{uploadista-error-B-kFH_SE.mjs → uploadista-error-CkSxSyNo.mjs} +2 -1
  59. package/dist/uploadista-error-CkSxSyNo.mjs.map +1 -0
  60. package/dist/{uploadista-error-Di9fniB1.cjs → uploadista-error-DIW99WZ1.cjs} +2 -1
  61. package/dist/{uploadista-error-DMMrZF03.d.mts → uploadista-error-DR0XimpE.d.mts} +2 -2
  62. package/dist/uploadista-error-DR0XimpE.d.mts.map +1 -0
  63. package/dist/utils/index.cjs +1 -1
  64. package/dist/utils/index.d.cts +2 -2
  65. package/dist/utils/index.d.mts +2 -2
  66. package/dist/utils/index.mjs +1 -1
  67. package/dist/{utils-C9dntrSe.mjs → utils-B-ZhQ6b0.mjs} +2 -2
  68. package/dist/{utils-C9dntrSe.mjs.map → utils-B-ZhQ6b0.mjs.map} +1 -1
  69. package/dist/{utils-B_unvkI4.cjs → utils-bCZ9j3Ve.cjs} +1 -1
  70. package/docs/CIRCUIT_BREAKER.md +335 -0
  71. package/package.json +7 -4
  72. package/src/errors/uploadista-error.ts +6 -1
  73. package/src/flow/README.md +102 -0
  74. package/src/flow/circuit-breaker-store.ts +382 -0
  75. package/src/flow/circuit-breaker.ts +99 -0
  76. package/src/flow/distributed-circuit-breaker.ts +437 -0
  77. package/src/flow/flow.ts +138 -0
  78. package/src/flow/index.ts +7 -0
  79. package/src/flow/node.ts +6 -0
  80. package/src/flow/nodes/transform-node.ts +43 -2
  81. package/src/flow/types/flow-types.ts +230 -0
  82. package/src/flow/utils/file-naming.ts +308 -0
  83. package/src/types/circuit-breaker-store.ts +222 -0
  84. package/src/types/index.ts +1 -0
  85. package/tests/flow/file-naming.test.ts +390 -0
  86. package/dist/flow-CAywogte.mjs +0 -2
  87. package/dist/flow-CAywogte.mjs.map +0 -1
  88. package/dist/flow-D7QeEZVs.cjs +0 -1
  89. package/dist/index-CoAMCnm6.d.cts.map +0 -1
  90. package/dist/index-D5ALjvAb.d.cts.map +0 -1
  91. package/dist/index-DiHUjE9t.d.mts.map +0 -1
  92. package/dist/types-Ce7ILjFt.cjs +0 -1
  93. package/dist/types-CnhCQFkg.mjs +0 -2
  94. package/dist/types-CnhCQFkg.mjs.map +0 -1
  95. package/dist/uploadista-error-B-kFH_SE.mjs.map +0 -1
  96. package/dist/uploadista-error-DMMrZF03.d.mts.map +0 -1
  97. package/dist/uploadista-error-DigegPz2.d.cts.map +0 -1
@@ -0,0 +1,222 @@
1
+ /**
2
+ * Circuit Breaker Store - Distributed state storage for circuit breakers.
3
+ *
4
+ * This module defines the interface for storing circuit breaker state in
5
+ * distributed environments. It allows circuit breaker state to be shared
6
+ * across multiple instances in a cluster.
7
+ *
8
+ * @module types/circuit-breaker-store
9
+ */
10
+
11
+ import { Context, Effect, Layer } from "effect";
12
+ import { UploadistaError } from "../errors";
13
+
14
+ // ============================================================================
15
+ // State Types
16
+ // ============================================================================
17
+
18
+ /**
19
+ * Circuit breaker state values.
20
+ */
21
+ export type CircuitBreakerStateValue = "closed" | "open" | "half-open";
22
+
23
+ /**
24
+ * Persisted circuit breaker state data.
25
+ *
26
+ * This represents the full state of a circuit breaker that needs to be
27
+ * stored and shared across instances.
28
+ */
29
+ export interface CircuitBreakerStateData {
30
+ /** Current circuit state */
31
+ state: CircuitBreakerStateValue;
32
+ /** Number of failures in current window */
33
+ failureCount: number;
34
+ /** Timestamp of last state transition */
35
+ lastStateChange: number;
36
+ /** Number of successful requests in half-open state */
37
+ halfOpenSuccesses: number;
38
+ /** Timestamp when the current failure window started */
39
+ windowStart: number;
40
+ /** Configuration snapshot for consistency */
41
+ config: {
42
+ failureThreshold: number;
43
+ resetTimeout: number;
44
+ halfOpenRequests: number;
45
+ windowDuration: number;
46
+ };
47
+ }
48
+
49
+ /**
50
+ * Statistics about a circuit breaker.
51
+ */
52
+ export interface CircuitBreakerStats {
53
+ nodeType: string;
54
+ state: CircuitBreakerStateValue;
55
+ failureCount: number;
56
+ halfOpenSuccesses: number;
57
+ timeSinceLastStateChange: number;
58
+ timeUntilHalfOpen?: number; // Only when state is "open"
59
+ }
60
+
61
+ // ============================================================================
62
+ // Store Interface
63
+ // ============================================================================
64
+
65
+ /**
66
+ * Interface for circuit breaker state storage.
67
+ *
68
+ * Implementations should handle distributed state for circuit breakers,
69
+ * allowing multiple instances to share circuit state. The interface is
70
+ * designed to work with eventually consistent stores - perfect consistency
71
+ * is not required for circuit breaker functionality.
72
+ *
73
+ * @example
74
+ * ```typescript
75
+ * // Using the store
76
+ * const store: CircuitBreakerStore = yield* CircuitBreakerStoreService;
77
+ *
78
+ * // Record a failure
79
+ * const newCount = yield* store.incrementFailures("describe-image", 60000);
80
+ * if (newCount >= 5) {
81
+ * yield* store.setState("describe-image", {
82
+ * state: "open",
83
+ * failureCount: newCount,
84
+ * lastStateChange: Date.now(),
85
+ * // ...
86
+ * });
87
+ * }
88
+ * ```
89
+ */
90
+ export interface CircuitBreakerStore {
91
+ /**
92
+ * Gets the current state data for a circuit breaker.
93
+ *
94
+ * @param nodeType - The node type identifier
95
+ * @returns The state data or null if no state exists
96
+ */
97
+ readonly getState: (
98
+ nodeType: string,
99
+ ) => Effect.Effect<CircuitBreakerStateData | null, UploadistaError>;
100
+
101
+ /**
102
+ * Sets the complete state for a circuit breaker.
103
+ *
104
+ * @param nodeType - The node type identifier
105
+ * @param state - The new state data
106
+ */
107
+ readonly setState: (
108
+ nodeType: string,
109
+ state: CircuitBreakerStateData,
110
+ ) => Effect.Effect<void, UploadistaError>;
111
+
112
+ /**
113
+ * Increments the failure count and returns the new count.
114
+ *
115
+ * This operation should be atomic where possible. For stores that don't
116
+ * support atomic increment, a read-modify-write is acceptable as circuit
117
+ * breakers tolerate eventual consistency.
118
+ *
119
+ * The implementation should also handle window expiry - if the window
120
+ * has expired, reset the count before incrementing.
121
+ *
122
+ * @param nodeType - The node type identifier
123
+ * @param windowDuration - Duration of the sliding window in milliseconds
124
+ * @returns The new failure count after incrementing
125
+ */
126
+ readonly incrementFailures: (
127
+ nodeType: string,
128
+ windowDuration: number,
129
+ ) => Effect.Effect<number, UploadistaError>;
130
+
131
+ /**
132
+ * Resets the failure count to zero.
133
+ *
134
+ * Called when circuit closes or on successful requests.
135
+ *
136
+ * @param nodeType - The node type identifier
137
+ */
138
+ readonly resetFailures: (
139
+ nodeType: string,
140
+ ) => Effect.Effect<void, UploadistaError>;
141
+
142
+ /**
143
+ * Increments the half-open success count.
144
+ *
145
+ * @param nodeType - The node type identifier
146
+ * @returns The new half-open success count
147
+ */
148
+ readonly incrementHalfOpenSuccesses: (
149
+ nodeType: string,
150
+ ) => Effect.Effect<number, UploadistaError>;
151
+
152
+ /**
153
+ * Gets statistics for all tracked circuit breakers.
154
+ *
155
+ * @returns Map of node type to stats
156
+ */
157
+ readonly getAllStats: () => Effect.Effect<
158
+ Map<string, CircuitBreakerStats>,
159
+ UploadistaError
160
+ >;
161
+
162
+ /**
163
+ * Deletes circuit breaker state for a node type.
164
+ *
165
+ * @param nodeType - The node type identifier
166
+ */
167
+ readonly delete: (nodeType: string) => Effect.Effect<void, UploadistaError>;
168
+ }
169
+
170
+ // ============================================================================
171
+ // Effect Context
172
+ // ============================================================================
173
+
174
+ /**
175
+ * Effect-TS context tag for the CircuitBreakerStore service.
176
+ *
177
+ * Use this to inject a circuit breaker store into your Effect programs.
178
+ *
179
+ * @example
180
+ * ```typescript
181
+ * const program = Effect.gen(function* () {
182
+ * const cbStore = yield* CircuitBreakerStoreService;
183
+ * const state = yield* cbStore.getState("my-node-type");
184
+ * // ...
185
+ * });
186
+ *
187
+ * // Provide the implementation
188
+ * const result = yield* program.pipe(
189
+ * Effect.provide(kvCircuitBreakerStoreLayer)
190
+ * );
191
+ * ```
192
+ */
193
+ export class CircuitBreakerStoreService extends Context.Tag(
194
+ "CircuitBreakerStoreService",
195
+ )<CircuitBreakerStoreService, CircuitBreakerStore>() {}
196
+
197
+ // ============================================================================
198
+ // Default State Factory
199
+ // ============================================================================
200
+
201
+ /**
202
+ * Creates a default initial state for a circuit breaker.
203
+ *
204
+ * @param config - Circuit breaker configuration
205
+ * @returns Initial state data with closed circuit
206
+ */
207
+ export function createInitialCircuitBreakerState(config: {
208
+ failureThreshold: number;
209
+ resetTimeout: number;
210
+ halfOpenRequests: number;
211
+ windowDuration: number;
212
+ }): CircuitBreakerStateData {
213
+ const now = Date.now();
214
+ return {
215
+ state: "closed",
216
+ failureCount: 0,
217
+ lastStateChange: now,
218
+ halfOpenSuccesses: 0,
219
+ windowStart: now,
220
+ config,
221
+ };
222
+ }
@@ -1,3 +1,4 @@
1
+ export * from "./circuit-breaker-store";
1
2
  export * from "./data-store";
2
3
  export * from "./event-broadcaster";
3
4
  export * from "./event-emitter";
@@ -0,0 +1,390 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import type { UploadFile } from "../../src/types/upload-file";
4
+ import {
5
+ AVAILABLE_TEMPLATE_VARIABLES,
6
+ applyFileNaming,
7
+ buildNamingContext,
8
+ getBaseName,
9
+ getExtension,
10
+ interpolateFileName,
11
+ validatePattern,
12
+ } from "../../src/flow/utils/file-naming";
13
+
14
+ // Mock UploadFile for testing
15
+ const createMockFile = (overrides?: Partial<UploadFile>): UploadFile => ({
16
+ id: "file-123",
17
+ offset: 0,
18
+ storage: { id: "storage-1", type: "S3", bucket: "test-bucket" },
19
+ size: 1024,
20
+ url: "https://example.com/file.jpg",
21
+ metadata: {
22
+ fileName: "photo.jpg",
23
+ width: 1920,
24
+ height: 1080,
25
+ },
26
+ ...overrides,
27
+ });
28
+
29
+ describe("getBaseName", () => {
30
+ it("should extract base name from filename with extension", () => {
31
+ expect(getBaseName("photo.jpg")).toBe("photo");
32
+ expect(getBaseName("document.pdf")).toBe("document");
33
+ expect(getBaseName("video.mp4")).toBe("video");
34
+ });
35
+
36
+ it("should handle multiple dots in filename", () => {
37
+ expect(getBaseName("document.tar.gz")).toBe("document.tar");
38
+ expect(getBaseName("file.name.with.dots.txt")).toBe("file.name.with.dots");
39
+ });
40
+
41
+ it("should return full name if no extension", () => {
42
+ expect(getBaseName("noextension")).toBe("noextension");
43
+ expect(getBaseName("README")).toBe("README");
44
+ });
45
+
46
+ it("should handle hidden files (starting with dot)", () => {
47
+ expect(getBaseName(".gitignore")).toBe(".gitignore");
48
+ expect(getBaseName(".env")).toBe(".env");
49
+ });
50
+
51
+ it("should handle empty string", () => {
52
+ expect(getBaseName("")).toBe("");
53
+ });
54
+ });
55
+
56
+ describe("getExtension", () => {
57
+ it("should extract extension from filename", () => {
58
+ expect(getExtension("photo.jpg")).toBe("jpg");
59
+ expect(getExtension("document.pdf")).toBe("pdf");
60
+ expect(getExtension("video.MP4")).toBe("MP4");
61
+ });
62
+
63
+ it("should return last extension for multiple dots", () => {
64
+ expect(getExtension("document.tar.gz")).toBe("gz");
65
+ expect(getExtension("file.name.txt")).toBe("txt");
66
+ });
67
+
68
+ it("should return empty string if no extension", () => {
69
+ expect(getExtension("noextension")).toBe("");
70
+ expect(getExtension("README")).toBe("");
71
+ });
72
+
73
+ it("should handle hidden files", () => {
74
+ expect(getExtension(".gitignore")).toBe("");
75
+ expect(getExtension(".env")).toBe("");
76
+ });
77
+
78
+ it("should handle empty string", () => {
79
+ expect(getExtension("")).toBe("");
80
+ });
81
+ });
82
+
83
+ describe("buildNamingContext", () => {
84
+ it("should build context from file and flow context", () => {
85
+ const file = createMockFile();
86
+ const flowContext = {
87
+ flowId: "flow-abc",
88
+ jobId: "job-123",
89
+ nodeId: "resize-1",
90
+ nodeType: "resize",
91
+ };
92
+
93
+ const context = buildNamingContext(file, flowContext);
94
+
95
+ expect(context.baseName).toBe("photo");
96
+ expect(context.extension).toBe("jpg");
97
+ expect(context.fileName).toBe("photo.jpg");
98
+ expect(context.nodeType).toBe("resize");
99
+ expect(context.nodeId).toBe("resize-1");
100
+ expect(context.flowId).toBe("flow-abc");
101
+ expect(context.jobId).toBe("job-123");
102
+ expect(context.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T/); // ISO format
103
+ });
104
+
105
+ it("should include extra variables", () => {
106
+ const file = createMockFile();
107
+ const flowContext = {
108
+ flowId: "flow-abc",
109
+ jobId: "job-123",
110
+ nodeId: "resize-1",
111
+ nodeType: "resize",
112
+ };
113
+ const extraVars = { width: 800, height: 600, format: "webp" };
114
+
115
+ const context = buildNamingContext(file, flowContext, extraVars);
116
+
117
+ expect(context.width).toBe(800);
118
+ expect(context.height).toBe(600);
119
+ expect(context.format).toBe("webp");
120
+ });
121
+
122
+ it("should use fallback for missing fileName", () => {
123
+ const file = createMockFile({ metadata: {} });
124
+ const flowContext = {
125
+ flowId: "flow-abc",
126
+ jobId: "job-123",
127
+ nodeId: "node-1",
128
+ nodeType: "process",
129
+ };
130
+
131
+ const context = buildNamingContext(file, flowContext);
132
+
133
+ expect(context.fileName).toBe("unnamed");
134
+ expect(context.baseName).toBe("unnamed");
135
+ expect(context.extension).toBe("");
136
+ });
137
+
138
+ it("should handle originalName as fallback", () => {
139
+ const file = createMockFile({
140
+ metadata: { originalName: "original-file.png" },
141
+ });
142
+ const flowContext = {
143
+ flowId: "flow-abc",
144
+ jobId: "job-123",
145
+ nodeId: "node-1",
146
+ nodeType: "process",
147
+ };
148
+
149
+ const context = buildNamingContext(file, flowContext);
150
+
151
+ expect(context.fileName).toBe("original-file.png");
152
+ expect(context.baseName).toBe("original-file");
153
+ expect(context.extension).toBe("png");
154
+ });
155
+ });
156
+
157
+ describe("interpolateFileName", () => {
158
+ const sampleContext = {
159
+ baseName: "photo",
160
+ extension: "jpg",
161
+ fileName: "photo.jpg",
162
+ nodeType: "resize",
163
+ nodeId: "resize-1",
164
+ flowId: "flow-abc",
165
+ jobId: "job-123",
166
+ timestamp: "2024-01-15T10:30:00Z",
167
+ width: 800,
168
+ height: 600,
169
+ };
170
+
171
+ it("should interpolate simple variables", () => {
172
+ const result = interpolateFileName(
173
+ "{{baseName}}.{{extension}}",
174
+ sampleContext,
175
+ );
176
+ expect(result).toBe("photo.jpg");
177
+ });
178
+
179
+ it("should interpolate multiple variables", () => {
180
+ const result = interpolateFileName(
181
+ "{{baseName}}-{{width}}x{{height}}.{{extension}}",
182
+ sampleContext,
183
+ );
184
+ expect(result).toBe("photo-800x600.jpg");
185
+ });
186
+
187
+ it("should interpolate with static text", () => {
188
+ const result = interpolateFileName(
189
+ "processed-{{baseName}}-final.{{extension}}",
190
+ sampleContext,
191
+ );
192
+ expect(result).toBe("processed-photo-final.jpg");
193
+ });
194
+
195
+ it("should handle missing variables gracefully", () => {
196
+ const result = interpolateFileName(
197
+ "{{baseName}}-{{unknownVar}}.{{extension}}",
198
+ sampleContext,
199
+ );
200
+ // micromustache returns empty string for missing vars
201
+ expect(result).toBe("photo-.jpg");
202
+ });
203
+
204
+ it("should handle pattern without variables", () => {
205
+ const result = interpolateFileName("static-name.txt", sampleContext);
206
+ expect(result).toBe("static-name.txt");
207
+ });
208
+
209
+ it("should handle empty pattern", () => {
210
+ const result = interpolateFileName("", sampleContext);
211
+ expect(result).toBe("");
212
+ });
213
+ });
214
+
215
+ describe("applyFileNaming", () => {
216
+ const file = createMockFile();
217
+ const flowContext = {
218
+ flowId: "flow-abc",
219
+ jobId: "job-123",
220
+ nodeId: "resize-1",
221
+ nodeType: "resize",
222
+ };
223
+
224
+ it("should return original filename when no config", () => {
225
+ const context = buildNamingContext(file, flowContext);
226
+ const result = applyFileNaming(file, context);
227
+ expect(result).toBe("photo.jpg");
228
+ });
229
+
230
+ it("should return original filename when no config is undefined", () => {
231
+ const context = buildNamingContext(file, flowContext);
232
+ const result = applyFileNaming(file, context, undefined);
233
+ expect(result).toBe("photo.jpg");
234
+ });
235
+
236
+ it("should apply auto naming with suffix", () => {
237
+ const context = buildNamingContext(file, flowContext, {
238
+ width: 800,
239
+ height: 600,
240
+ });
241
+ const config = {
242
+ mode: "auto" as const,
243
+ autoSuffix: (ctx: typeof context) => `${ctx.width}x${ctx.height}`,
244
+ };
245
+
246
+ const result = applyFileNaming(file, context, config);
247
+ expect(result).toBe("photo-800x600.jpg");
248
+ });
249
+
250
+ it("should return original when auto mode has no suffix generator", () => {
251
+ const context = buildNamingContext(file, flowContext);
252
+ const config = { mode: "auto" as const };
253
+
254
+ const result = applyFileNaming(file, context, config);
255
+ expect(result).toBe("photo.jpg");
256
+ });
257
+
258
+ it("should apply custom naming with template pattern", () => {
259
+ const context = buildNamingContext(file, flowContext, {
260
+ width: 800,
261
+ height: 600,
262
+ });
263
+ const config = {
264
+ mode: "custom" as const,
265
+ pattern: "{{baseName}}-{{nodeType}}-{{width}}w.{{extension}}",
266
+ };
267
+
268
+ const result = applyFileNaming(file, context, config);
269
+ expect(result).toBe("photo-resize-800w.jpg");
270
+ });
271
+
272
+ it("should apply custom naming with rename function", () => {
273
+ const context = buildNamingContext(file, flowContext);
274
+ const config = {
275
+ mode: "custom" as const,
276
+ rename: (_file: UploadFile, ctx: typeof context) =>
277
+ `custom-${ctx.baseName}.${ctx.extension}`,
278
+ };
279
+
280
+ const result = applyFileNaming(file, context, config);
281
+ expect(result).toBe("custom-photo.jpg");
282
+ });
283
+
284
+ it("should prefer rename function over pattern in custom mode", () => {
285
+ const context = buildNamingContext(file, flowContext);
286
+ const config = {
287
+ mode: "custom" as const,
288
+ pattern: "pattern-{{baseName}}.{{extension}}",
289
+ rename: () => "function-result.jpg",
290
+ };
291
+
292
+ const result = applyFileNaming(file, context, config);
293
+ expect(result).toBe("function-result.jpg");
294
+ });
295
+
296
+ it("should handle file without extension in auto mode", () => {
297
+ const noExtFile = createMockFile({
298
+ metadata: { fileName: "noextension" },
299
+ });
300
+ const context = buildNamingContext(noExtFile, flowContext);
301
+ const config = {
302
+ mode: "auto" as const,
303
+ autoSuffix: () => "processed",
304
+ };
305
+
306
+ const result = applyFileNaming(noExtFile, context, config);
307
+ expect(result).toBe("noextension-processed");
308
+ });
309
+
310
+ it("should fallback to original on error in rename function", () => {
311
+ const context = buildNamingContext(file, flowContext);
312
+ const config = {
313
+ mode: "custom" as const,
314
+ rename: () => {
315
+ throw new Error("Intentional error");
316
+ },
317
+ };
318
+
319
+ const result = applyFileNaming(file, context, config);
320
+ expect(result).toBe("photo.jpg");
321
+ });
322
+ });
323
+
324
+ describe("validatePattern", () => {
325
+ it("should accept valid patterns", () => {
326
+ expect(validatePattern("{{baseName}}.{{extension}}")).toEqual({
327
+ isValid: true,
328
+ });
329
+ expect(
330
+ validatePattern("{{baseName}}-{{width}}x{{height}}.{{extension}}"),
331
+ ).toEqual({ isValid: true });
332
+ expect(validatePattern("static-name.txt")).toEqual({ isValid: true });
333
+ });
334
+
335
+ it("should reject empty pattern", () => {
336
+ const result = validatePattern("");
337
+ expect(result.isValid).toBe(false);
338
+ expect(result.error).toContain("empty");
339
+ });
340
+
341
+ it("should reject whitespace-only pattern", () => {
342
+ const result = validatePattern(" ");
343
+ expect(result.isValid).toBe(false);
344
+ expect(result.error).toContain("empty");
345
+ });
346
+
347
+ it("should reject unbalanced braces", () => {
348
+ let result = validatePattern("{{baseName");
349
+ expect(result.isValid).toBe(false);
350
+ expect(result.error).toContain("Unbalanced");
351
+
352
+ result = validatePattern("baseName}}");
353
+ expect(result.isValid).toBe(false);
354
+ expect(result.error).toContain("Unbalanced");
355
+
356
+ result = validatePattern("{{a}}{{b");
357
+ expect(result.isValid).toBe(false);
358
+ expect(result.error).toContain("Unbalanced");
359
+ });
360
+
361
+ it("should accept patterns with no variables", () => {
362
+ expect(validatePattern("static-file.txt")).toEqual({ isValid: true });
363
+ });
364
+ });
365
+
366
+ describe("AVAILABLE_TEMPLATE_VARIABLES", () => {
367
+ it("should include all expected variables", () => {
368
+ const varNames = AVAILABLE_TEMPLATE_VARIABLES.map((v) => v.name);
369
+
370
+ expect(varNames).toContain("baseName");
371
+ expect(varNames).toContain("extension");
372
+ expect(varNames).toContain("fileName");
373
+ expect(varNames).toContain("nodeType");
374
+ expect(varNames).toContain("nodeId");
375
+ expect(varNames).toContain("flowId");
376
+ expect(varNames).toContain("jobId");
377
+ expect(varNames).toContain("timestamp");
378
+ expect(varNames).toContain("width");
379
+ expect(varNames).toContain("height");
380
+ expect(varNames).toContain("format");
381
+ });
382
+
383
+ it("should have description and example for each variable", () => {
384
+ for (const variable of AVAILABLE_TEMPLATE_VARIABLES) {
385
+ expect(variable.name).toBeTruthy();
386
+ expect(variable.description).toBeTruthy();
387
+ expect(variable.example).toBeTruthy();
388
+ }
389
+ });
390
+ });