diff --git a/src/app/api/workflow-images/__tests__/route.test.ts b/src/app/api/workflow-images/__tests__/route.test.ts new file mode 100644 index 00000000..a5e34b37 --- /dev/null +++ b/src/app/api/workflow-images/__tests__/route.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { NextRequest } from "next/server"; + +const mockStat = vi.fn(); +const mockMkdir = vi.fn(); +const mockWriteFile = vi.fn(); + +vi.mock("fs/promises", () => ({ + stat: (...args: unknown[]) => mockStat(...args), + mkdir: (...args: unknown[]) => mockMkdir(...args), + writeFile: (...args: unknown[]) => mockWriteFile(...args), +})); + +vi.mock("@/utils/logger", () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +import { POST } from "../route"; + +function createMockPostRequest(body: unknown): NextRequest { + return { + json: vi.fn().mockResolvedValue(body), + } as unknown as NextRequest; +} + +describe("/api/workflow-images route", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + describe("POST - Save workflow image", () => { + it("should save image when workflow directory exists", async () => { + mockStat.mockResolvedValue({ + isDirectory: () => true, + }); + mockMkdir.mockResolvedValue(undefined); + mockWriteFile.mockResolvedValue(undefined); + + const request = createMockPostRequest({ + workflowPath: "/test/workflow", + imageId: "img_123", + folder: "inputs", + imageData: "data:image/png;base64,aGVsbG8=", + }); + + const response = await POST(request); + const data = await response.json(); + + expect(data.success).toBe(true); + expect(data.imageId).toBe("img_123"); + expect(data.filePath).toBe("/test/workflow/inputs/img_123.png"); + expect(mockMkdir).toHaveBeenCalledWith("/test/workflow/inputs", { recursive: true }); + expect(mockWriteFile).toHaveBeenCalled(); + }); + + it("should create missing workflow directory and save image", async () => { + mockStat.mockRejectedValue(new Error("ENOENT")); + mockMkdir.mockResolvedValue(undefined); + mockWriteFile.mockResolvedValue(undefined); + + const request = createMockPostRequest({ + workflowPath: "/test/new-workflow", + imageId: "img_123", + folder: "inputs", + imageData: "data:image/png;base64,aGVsbG8=", + }); + + const response = await POST(request); + const data = await response.json(); + + expect(data.success).toBe(true); + expect(mockMkdir).toHaveBeenCalledWith("/test/new-workflow", { recursive: true }); + expect(mockMkdir).toHaveBeenCalledWith("/test/new-workflow/inputs", { recursive: true }); + }); + }); +}); diff --git a/src/app/api/workflow-images/route.ts b/src/app/api/workflow-images/route.ts index 1436141d..81902831 100644 --- a/src/app/api/workflow-images/route.ts +++ b/src/app/api/workflow-images/route.ts @@ -62,7 +62,7 @@ export async function POST(request: NextRequest) { ); } - // Validate workflow directory exists + // Validate workflow directory exists, or create it if missing try { const stats = await fs.stat(workflowPath); if (!stats.isDirectory()) { @@ -75,13 +75,20 @@ export async function POST(request: NextRequest) { ); } } catch (dirError) { - logger.warn('file.error', 'Workflow image save failed: directory does not exist', { - workflowPath, - }); - return NextResponse.json( - { success: false, error: "Workflow directory does not exist" }, - { status: 400 } - ); + try { + await fs.mkdir(workflowPath, { recursive: true }); + logger.info('file.save', 'Created workflow directory for image save', { + workflowPath, + }); + } catch (mkdirError) { + logger.error('file.error', 'Failed to create workflow directory', { + workflowPath, + }, mkdirError instanceof Error ? mkdirError : undefined); + return NextResponse.json( + { success: false, error: "Failed to create workflow directory" }, + { status: 500 } + ); + } } // Create target folder if it doesn't exist diff --git a/src/components/ProjectSetupModal.tsx b/src/components/ProjectSetupModal.tsx index 349a09aa..3e680d04 100644 --- a/src/components/ProjectSetupModal.tsx +++ b/src/components/ProjectSetupModal.tsx @@ -85,6 +85,40 @@ export function ProjectSetupModal({ onSave, mode, }: ProjectSetupModalProps) { + const sanitizeProjectFolderName = (projectName: string): string => { + return projectName + .trim() + .replace(/[<>:"/\\|?*\x00-\x1F]/g, "_") + .replace(/\.+$/g, "") + .trim(); + }; + + const joinPathForPlatform = (basePath: string, folderName: string): string => { + const trimmedBase = basePath.trim(); + const separator = /^[A-Za-z]:[\\\/]/.test(trimmedBase) || trimmedBase.startsWith("\\\\") ? "\\" : "/"; + const endsWithSeparator = trimmedBase.endsWith("/") || trimmedBase.endsWith("\\"); + return `${trimmedBase}${endsWithSeparator ? "" : separator}${folderName}`; + }; + + const getPathBasename = (fullPath: string): string => { + const withoutTrailingSeparator = fullPath.trim().replace(/[\\/]+$/, ""); + const parts = withoutTrailingSeparator.split(/[\\/]/); + return parts[parts.length - 1] || ""; + }; + + const ensureProjectSubfolderPath = (basePath: string, projectName: string): string => { + const trimmedBase = basePath.trim(); + const sanitizedFolder = sanitizeProjectFolderName(projectName); + if (!sanitizedFolder) return trimmedBase; + + const basename = getPathBasename(trimmedBase); + if (basename.toLowerCase() === sanitizedFolder.toLowerCase()) { + return trimmedBase; + } + + return joinPathForPlatform(trimmedBase, sanitizedFolder); + }; + const { workflowName, saveDirectoryPath, @@ -226,8 +260,9 @@ export function ProjectSetupModal({ return; } - const trimmedPath = directoryPath.trim(); - if (!(trimmedPath.startsWith("/") || /^[A-Za-z]:[\\\/]/.test(trimmedPath) || trimmedPath.startsWith("\\\\"))) { + const fullProjectPath = ensureProjectSubfolderPath(directoryPath, name); + + if (!(fullProjectPath.startsWith("/") || /^[A-Za-z]:[\\\/]/.test(fullProjectPath) || fullProjectPath.startsWith("\\\\"))) { setError("Project directory must be an absolute path (starting with /, a drive letter, or a UNC path)"); return; } @@ -236,9 +271,9 @@ export function ProjectSetupModal({ setError(null); try { - // Validate project directory exists + // Validate path shape when it already exists const response = await fetch( - `/api/workflow?path=${encodeURIComponent(directoryPath.trim())}` + `/api/workflow?path=${encodeURIComponent(fullProjectPath)}` ); const result = await response.json(); @@ -251,7 +286,7 @@ export function ProjectSetupModal({ const id = mode === "new" ? generateWorkflowId() : useWorkflowStore.getState().workflowId || generateWorkflowId(); // Update external storage setting setUseExternalImageStorage(externalStorage); - onSave(id, name.trim(), directoryPath.trim()); + onSave(id, name.trim(), fullProjectPath); setIsValidating(false); } catch (err) { setError( diff --git a/src/components/__tests__/ProjectSetupModal.test.tsx b/src/components/__tests__/ProjectSetupModal.test.tsx index 64e861bd..cc9c0c6c 100644 --- a/src/components/__tests__/ProjectSetupModal.test.tsx +++ b/src/components/__tests__/ProjectSetupModal.test.tsx @@ -27,6 +27,20 @@ global.fetch = mockFetch; const mockConfirm = vi.fn(() => true); global.confirm = mockConfirm; +// Ensure localStorage is always available in this test environment +const localStorageMock = { + getItem: vi.fn(() => null), + setItem: vi.fn(), + removeItem: vi.fn(), + clear: vi.fn(), + key: vi.fn(() => null), + length: 0, +}; +Object.defineProperty(globalThis, "localStorage", { + value: localStorageMock, + configurable: true, +}); + // Default provider settings const defaultProviderSettings: ProviderSettings = { providers: { @@ -339,7 +353,7 @@ describe("ProjectSetupModal", () => { expect(onSave).not.toHaveBeenCalled(); }); - it("should allow creating project in a new subfolder path", async () => { + it("should allow save when directory does not exist yet", async () => { mockFetch.mockImplementation((url: string) => { if (url === "/api/env-status") { return Promise.resolve({ @@ -379,13 +393,12 @@ describe("ProjectSetupModal", () => { fireEvent.click(screen.getByText("Create")); await waitFor(() => { - expect(onSave).toHaveBeenCalledTimes(1); + expect(onSave).toHaveBeenCalledWith( + "mock-workflow-id", + "My Project", + "/nonexistent/path/My Project" + ); }); - expect(onSave).toHaveBeenCalledWith( - expect.any(String), - "My Project", - "/nonexistent/path" - ); }); it("should show error when path is not absolute", async () => { @@ -513,7 +526,7 @@ describe("ProjectSetupModal", () => { expect(onSave).toHaveBeenCalledWith( "mock-workflow-id", "My New Project", - "/path/to/project" + "/path/to/project/My New Project" ); }); });