Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions backend/controllers/inspirationController.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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) {
Expand Down Expand Up @@ -106,4 +110,4 @@ async function getDribbbleInspiration(req, res) {
return getInspiration(req, res);
}

module.exports = { getInspiration, getDribbbleInspiration };
module.exports = { getInspiration, getDribbbleInspiration, _resetCache };
190 changes: 190 additions & 0 deletions backend/tests/inspirationController.test.js
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +5 to +12
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The mockCheckFileExists function is mocked but never used in the code. The checkFileExists export from fileSystem.js is not used anywhere in the controller. This creates unnecessary test overhead and confusion. Consider removing this mock and the corresponding export from fileSystem.js if it's not needed.

Suggested change
const mockCheckFileExists = mock();
const mockLaunchBrowser = mock();
const mockScrapeDribbble = mock();
const mockCloseBrowser = mock();
mock.module("../utils/fileSystem", () => ({
readFile: mockReadFile,
checkFileExists: mockCheckFileExists
const mockLaunchBrowser = mock();
const mockScrapeDribbble = mock();
const mockCloseBrowser = mock();
mock.module("../utils/fileSystem", () => ({
readFile: mockReadFile

Copilot uses AI. Check for mistakes.
}));

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");
});
});
14 changes: 14 additions & 0 deletions backend/utils/fileSystem.js
Original file line number Diff line number Diff line change
@@ -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
};
Comment on lines +12 to +14
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The checkFileExists function is exported but never used in the controller. This export appears to be unnecessary and should be removed to keep the API surface minimal and avoid confusion about which functions are actually needed.

Suggested change
readFile,
checkFileExists
};
readFile
};

Copilot uses AI. Check for mistakes.
1 change: 1 addition & 0 deletions electron/tests/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
75 changes: 73 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src/components/notes/NoteEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -297,7 +297,7 @@ const NoteEditor: React.FC<NoteEditorProps> = ({ 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("");
}

Expand Down
2 changes: 1 addition & 1 deletion src/components/ui/command.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
2 changes: 1 addition & 1 deletion src/components/ui/textarea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import * as React from "react";

import { cn } from "@/lib/utils";

export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
export type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement>

const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(({ className, ...props }, ref) => {
return (
Expand Down
Loading
Loading