diff --git a/backend/controllers/inspirationController.js b/backend/controllers/inspirationController.js index 5390ff2..da96ba7 100644 --- a/backend/controllers/inspirationController.js +++ b/backend/controllers/inspirationController.js @@ -4,14 +4,18 @@ * Falls back to live Dribbble scraping if cache results are low. */ -const fs = require('fs'); const path = require('path'); +const { readFile } = require('../utils/fileSystem'); const { launchBrowser, scrapeDribbble } = require('../services/scraperService'); const DATA_FILE = path.join(__dirname, '../data/inspiration.json'); let CACHED_DATA = null; +function _resetCache() { + CACHED_DATA = null; +} + /** * GET /api/inspiration?q=web+design * Serves data from static JSON cache + live fallback. @@ -28,7 +32,7 @@ async function getInspiration(req, res) { allItems = [...CACHED_DATA]; } else { try { - const rawData = await fs.promises.readFile(DATA_FILE, 'utf-8'); + const rawData = await readFile(DATA_FILE); CACHED_DATA = JSON.parse(rawData); allItems = [...CACHED_DATA]; } catch (err) { @@ -106,4 +110,4 @@ async function getDribbbleInspiration(req, res) { return getInspiration(req, res); } -module.exports = { getInspiration, getDribbbleInspiration }; +module.exports = { getInspiration, getDribbbleInspiration, _resetCache }; diff --git a/backend/tests/inspirationController.test.js b/backend/tests/inspirationController.test.js new file mode 100644 index 0000000..a90414a --- /dev/null +++ b/backend/tests/inspirationController.test.js @@ -0,0 +1,190 @@ +import { describe, it, expect, mock, beforeEach } from "bun:test"; + +// Mock dependencies +const mockReadFile = mock(); +const mockCheckFileExists = mock(); +const mockLaunchBrowser = mock(); +const mockScrapeDribbble = mock(); +const mockCloseBrowser = mock(); + +mock.module("../utils/fileSystem", () => ({ + readFile: mockReadFile, + checkFileExists: mockCheckFileExists +})); + +mock.module("../services/scraperService", () => ({ + launchBrowser: mockLaunchBrowser, + scrapeDribbble: mockScrapeDribbble +})); + +// Import the controller (CommonJS) +const { getInspiration, _resetCache } = require("../controllers/inspirationController"); + +// Helper to mock response +const mockRes = () => { + const res = {}; + res.json = mock(() => res); + res.status = mock(() => res); + return res; +}; + +// Test Data +const MOCK_CACHE_DATA = [ + { title: "Web Design 1", link: "link1", tags: ["web", "design"], source: "Dribbble" }, + { title: "App Design 1", link: "link2", tags: ["app", "ui"], source: "Behance" }, + { title: "Logo Design", link: "link3", tags: ["logo", "branding"], source: "Dribbble" }, + { title: "Web Dashboard", link: "link4", tags: ["web", "dashboard"], source: "Lapa" }, + { title: "Mobile App", link: "link5", tags: ["mobile", "app"], source: "Godly" }, + { title: "Landing Page", link: "link6", tags: ["landing", "page"], source: "SiteInspire" } +]; + +describe("Inspiration Controller", () => { + beforeEach(() => { + _resetCache(); + mockReadFile.mockReset(); + mockLaunchBrowser.mockReset(); + mockScrapeDribbble.mockReset(); + mockCloseBrowser.mockReset(); + + // Default mock implementation + mockLaunchBrowser.mockResolvedValue({ close: mockCloseBrowser }); + }); + + it("should serve all items from cache if no query provided", async () => { + mockReadFile.mockResolvedValue(JSON.stringify(MOCK_CACHE_DATA)); + + const req = { query: {} }; + const res = mockRes(); + + await getInspiration(req, res); + + expect(mockReadFile).toHaveBeenCalled(); + expect(res.json).toHaveBeenCalled(); + const result = res.json.mock.calls[0][0]; + expect(result.ok).toBe(true); + expect(result.count).toBe(MOCK_CACHE_DATA.length); + // Check if items are shuffled (order might differ, but set of IDs should be same) + const resultIds = result.items.map(i => i.link).sort(); + const expectedIds = MOCK_CACHE_DATA.map(i => i.link).sort(); + expect(resultIds).toEqual(expectedIds); + }); + + it("should filter items based on query", async () => { + mockReadFile.mockResolvedValue(JSON.stringify(MOCK_CACHE_DATA)); + + const req = { query: { q: "web" } }; + const res = mockRes(); + + // Mock fallback to empty so we only check cache results + empty fallback + mockScrapeDribbble.mockResolvedValue([]); + + await getInspiration(req, res); + + const result = res.json.mock.calls[0][0]; + // "Web Design 1" and "Web Dashboard" match "web" + // Scraper returns empty, so we expect 2 items. + expect(result.count).toBe(2); + expect(result.items.some(i => i.title === "Web Design 1")).toBe(true); + expect(result.items.some(i => i.title === "Web Dashboard")).toBe(true); + }); + + it("should trigger fallback scraper if cache results are low (< 5) and query is present", async () => { + // Return full cache, but filtering "logo" only yields 1 result + mockReadFile.mockResolvedValue(JSON.stringify(MOCK_CACHE_DATA)); + + const req = { query: { q: "logo" } }; + const res = mockRes(); + + // Mock scraper results + const scraperResults = [ + { title: "New Logo 1", link: "new_link1", source: "Dribbble" }, + { title: "New Logo 2", link: "new_link2", source: "Dribbble" } + ]; + mockScrapeDribbble.mockResolvedValue(scraperResults); + + await getInspiration(req, res); + + expect(mockLaunchBrowser).toHaveBeenCalled(); + expect(mockScrapeDribbble).toHaveBeenCalledWith(expect.anything(), "logo"); + expect(mockCloseBrowser).toHaveBeenCalled(); + + const result = res.json.mock.calls[0][0]; + // Cache has "Logo Design" (1 match) + // Scraper returned 2 matches + // Total should be 3 + expect(result.items.length).toBe(3); + expect(result.items.some(i => i.title === "New Logo 1")).toBe(true); + }); + + it("should NOT trigger fallback if query is empty", async () => { + // Empty query returns all cached items (6 items). 6 >= 5. + mockReadFile.mockResolvedValue(JSON.stringify(MOCK_CACHE_DATA)); + + const req = { query: { q: "" } }; + const res = mockRes(); + + await getInspiration(req, res); + + expect(mockLaunchBrowser).not.toHaveBeenCalled(); + const result = res.json.mock.calls[0][0]; + expect(result.count).toBe(6); + }); + + it("should NOT trigger fallback if cache results are sufficient (>= 5) even with query", async () => { + // Return many matching items + const manyItems = [ + { title: "Web 1", link: "1", tags: ["web"] }, + { title: "Web 2", link: "2", tags: ["web"] }, + { title: "Web 3", link: "3", tags: ["web"] }, + { title: "Web 4", link: "4", tags: ["web"] }, + { title: "Web 5", link: "5", tags: ["web"] }, + ]; + mockReadFile.mockResolvedValue(JSON.stringify(manyItems)); + + const req = { query: { q: "web" } }; + const res = mockRes(); + + await getInspiration(req, res); + + expect(mockLaunchBrowser).not.toHaveBeenCalled(); + const result = res.json.mock.calls[0][0]; + expect(result.count).toBe(5); + }); + + it("should handle cache read error gracefully", async () => { + mockReadFile.mockRejectedValue({ code: 'ENOENT' }); + + const req = { query: { q: "test" } }; + const res = mockRes(); + + // Scraper should trigger because cache is empty (0 < 5) and query is present + mockScrapeDribbble.mockResolvedValue([]); + + await getInspiration(req, res); + + expect(mockReadFile).toHaveBeenCalled(); + // Should log warning but not crash + const result = res.json.mock.calls[0][0]; + expect(result.ok).toBe(true); + expect(result.items).toEqual([]); + }); + + it("should handle scraper error gracefully", async () => { + mockReadFile.mockResolvedValue(JSON.stringify(MOCK_CACHE_DATA)); + + const req = { query: { q: "logo" } }; // 1 result, triggers fallback + const res = mockRes(); + + mockLaunchBrowser.mockRejectedValue(new Error("Browser launch failed")); + + await getInspiration(req, res); + + expect(mockLaunchBrowser).toHaveBeenCalled(); + // Controller catches error and continues with cached items + const result = res.json.mock.calls[0][0]; + expect(result.ok).toBe(true); + // Should contain filtered cache items (1 item) + expect(result.items.length).toBe(1); + expect(result.items[0].title).toBe("Logo Design"); + }); +}); diff --git a/backend/utils/fileSystem.js b/backend/utils/fileSystem.js new file mode 100644 index 0000000..a62b51d --- /dev/null +++ b/backend/utils/fileSystem.js @@ -0,0 +1,14 @@ +const fs = require('fs'); + +async function readFile(filePath) { + return fs.promises.readFile(filePath, 'utf-8'); +} + +function checkFileExists(filePath) { + return fs.existsSync(filePath); +} + +module.exports = { + readFile, + checkFileExists +}; diff --git a/electron/tests/setup.ts b/electron/tests/setup.ts index 30cb213..879741e 100644 --- a/electron/tests/setup.ts +++ b/electron/tests/setup.ts @@ -367,6 +367,7 @@ afterAll(() => { * Extend the global namespace with custom test types. */ declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace namespace Vi { interface Assertion { toExist(): void; diff --git a/package-lock.json b/package-lock.json index ec4f60a..031f933 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1408,6 +1408,28 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/@electron/windows-sign": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@electron/windows-sign/-/windows-sign-1.2.2.tgz", + "integrity": "sha512-dfZeox66AvdPtb2lD8OsIIQh12Tp0GNCRUDfBHIKGpbmopZto2/A8nSpYYLoedPIHpqkeblZ/k8OV0Gy7PYuyQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "peer": true, + "dependencies": { + "cross-dirname": "^0.1.0", + "debug": "^4.3.4", + "fs-extra": "^11.1.1", + "minimist": "^1.2.8", + "postject": "^1.0.0-alpha.6" + }, + "bin": { + "electron-windows-sign": "bin/electron-windows-sign.js" + }, + "engines": { + "node": ">=14.14" + } + }, "node_modules/@emoji-mart/data": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emoji-mart/data/-/data-1.2.1.tgz", @@ -10799,6 +10821,15 @@ "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", "license": "MIT" }, + "node_modules/cross-dirname": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz", + "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/cross-env": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", @@ -12245,6 +12276,16 @@ "integrity": "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==", "license": "MIT" }, + "node_modules/encoding": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", + "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "license": "MIT", + "optional": true, + "dependencies": { + "iconv-lite": "^0.6.2" + } + }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", @@ -14831,7 +14872,7 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -20693,6 +20734,36 @@ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "license": "MIT" }, + "node_modules/postject": { + "version": "1.0.0-alpha.6", + "resolved": "https://registry.npmjs.org/postject/-/postject-1.0.0-alpha.6.tgz", + "integrity": "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "commander": "^9.4.0" + }, + "bin": { + "postject": "dist/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/postject/node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": "^12.20.0 || >=14" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -22472,7 +22543,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/sanitize-filename": { diff --git a/src/components/notes/NoteEditor.tsx b/src/components/notes/NoteEditor.tsx index 881069b..66b7dcf 100644 --- a/src/components/notes/NoteEditor.tsx +++ b/src/components/notes/NoteEditor.tsx @@ -297,7 +297,7 @@ const NoteEditor: React.FC = ({ note, user, onUpdate, className // Simplified: Just use the text content of the block where cursor is. if (block && block.content) { // Block content is array of InlineContent. Join text. - // @ts-ignore + // @ts-expect-error BlockNote content typing is complex and varies between versions text = block.content.map(c => c.text || "").join(""); } diff --git a/src/components/ui/command.tsx b/src/components/ui/command.tsx index 68d5378..2fe9d44 100644 --- a/src/components/ui/command.tsx +++ b/src/components/ui/command.tsx @@ -21,7 +21,7 @@ const Command = React.forwardRef< )); Command.displayName = CommandPrimitive.displayName; -interface CommandDialogProps extends DialogProps {} +type CommandDialogProps = DialogProps const CommandDialog = ({ children, ...props }: CommandDialogProps) => { return ( diff --git a/src/components/ui/textarea.tsx b/src/components/ui/textarea.tsx index 4a5643e..bf0dc97 100644 --- a/src/components/ui/textarea.tsx +++ b/src/components/ui/textarea.tsx @@ -2,7 +2,7 @@ import * as React from "react"; import { cn } from "@/lib/utils"; -export interface TextareaProps extends React.TextareaHTMLAttributes {} +export type TextareaProps = React.TextareaHTMLAttributes const Textarea = React.forwardRef(({ className, ...props }, ref) => { return ( diff --git a/src/lib/utils.test.ts b/src/lib/utils.test.ts index ae03113..985c11e 100644 --- a/src/lib/utils.test.ts +++ b/src/lib/utils.test.ts @@ -36,6 +36,7 @@ describe("utils.ts", () => { }); test("should handle conditional classes", () => { + // eslint-disable-next-line no-constant-binary-expression expect(cn("btn", true && "btn-active", false && "btn-hidden")).toBe("btn btn-active"); });