test: add unit test suite with Vitest for core modules

This commit is contained in:
Gustavo Carreiro 2026-02-22 18:01:02 -03:00
parent f07e2b9ecf
commit b95bd315f2
12 changed files with 2374 additions and 350 deletions

1728
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -6,6 +6,8 @@
"main": "main.js",
"scripts": {
"dev": "node scripts/esbuild.config.mjs",
"test": "vitest run",
"test:watch": "vitest",
"test-cofre": "node scripts/install-vault.mjs",
"sync-conflict": "node scripts/create-conflict.mjs",
"build": "tsc -noEmit -skipLibCheck && node scripts/esbuild.config.mjs production",
@ -30,8 +32,10 @@
"eslint": "^9.39.1",
"eslint-plugin-obsidianmd": "^0.1.9",
"globals": "^16.5.0",
"jsdom": "^28.1.0",
"obsidian": "latest",
"tslib": "2.4.0",
"typescript": "^4.8.4"
"typescript": "^4.8.4",
"vitest": "^4.0.18"
}
}

View file

@ -21,6 +21,33 @@ rl.question("👉 Digite a nova versão: ", (targetVersion) => {
process.exit(1);
}
// --- GATE 1: LINT ---
console.clear();
console.log("🔍 Executando lint...\n");
try {
execSync("npm run lint", { stdio: "inherit" });
console.log("\n✅ Lint passou!\n");
} catch {
console.error(
"\n❌ Lint falhou. Corrija os erros antes de fazer o release.",
);
process.exit(1);
}
// --- GATE 2: TESTES ---
console.clear();
console.log("🧪 Executando testes...\n");
try {
execSync("npm test", { stdio: "inherit" });
console.log("\n✅ Testes passaram!\n");
} catch {
console.error(
"\n❌ Testes falharam. Corrija os erros antes de fazer o release.",
);
process.exit(1);
}
console.clear();
console.log(`\n⚙️ Aplicando versão: ${targetVersion}...\n`);
// --- BLOCO 1: ATUALIZAR MANIFEST.JSON ---

148
tests/__mocks__/obsidian.ts Normal file
View file

@ -0,0 +1,148 @@
/**
* Mock manual do módulo 'obsidian' para uso em testes unitários.
* Fornece stubs minimalistas das APIs usadas pelo plugin.
*/
import { vi } from "vitest";
// --- requestUrl ---
export const requestUrl = vi.fn();
// --- Plugin ---
export class Plugin {
app: App;
manifest = { dir: ".obsidian/plugins/syncthing-manager" };
constructor() {
this.app = new App();
}
loadData = vi.fn().mockResolvedValue({});
saveData = vi.fn().mockResolvedValue(undefined);
registerEvent = vi.fn();
addCommand = vi.fn();
addSettingTab = vi.fn();
addRibbonIcon = vi.fn();
}
// --- App ---
export class App {
vault = {
adapter: {
exists: vi.fn().mockResolvedValue(false),
read: vi.fn().mockResolvedValue("{}"),
write: vi.fn().mockResolvedValue(undefined),
},
configDir: ".obsidian",
};
workspace = {
on: vi.fn(),
onLayoutReady: vi.fn(),
iterateAllLeaves: vi.fn(),
};
secretStorage?: {
setSecret: ReturnType<typeof vi.fn>;
getSecret: ReturnType<typeof vi.fn>;
listSecrets: ReturnType<typeof vi.fn>;
};
}
// --- Component ---
export class Component {
load = vi.fn();
unload = vi.fn();
addChild = vi.fn();
removeChild = vi.fn();
register = vi.fn();
registerEvent = vi.fn();
registerInterval = vi.fn();
}
// --- debounce ---
export function debounce<T extends (...args: unknown[]) => unknown>(
fn: T,
_delay?: number,
_immediate?: boolean,
): T {
return fn;
}
// --- Stubs de UI (não usados nos testes, mas necessários para imports) ---
export class Modal {
app: App;
contentEl = { empty: vi.fn(), createEl: vi.fn(), createDiv: vi.fn() };
constructor(app: App) {
this.app = app;
}
open = vi.fn();
close = vi.fn();
}
export class Setting {
constructor(_containerEl: unknown) {}
setName = vi.fn().mockReturnThis();
setDesc = vi.fn().mockReturnThis();
setHeading = vi.fn().mockReturnThis();
addText = vi.fn().mockReturnThis();
addToggle = vi.fn().mockReturnThis();
addButton = vi.fn().mockReturnThis();
addDropdown = vi.fn().mockReturnThis();
controlEl = { createSpan: vi.fn() };
}
export class PluginSettingTab {
app: App;
containerEl = { empty: vi.fn() };
constructor(app: App, _plugin: Plugin) {
this.app = app;
}
display = vi.fn();
}
export class TFile {
path: string = "";
name: string = "";
basename: string = "";
extension: string = "";
}
export class TFolder {
path: string = "";
name: string = "";
children: unknown[] = [];
}
export class FileView {
file: TFile | null = null;
}
export class ButtonComponent {
constructor(_containerEl: unknown) {}
setButtonText = vi.fn().mockReturnThis();
setCta = vi.fn().mockReturnThis();
onClick = vi.fn().mockReturnThis();
}
export class TextComponent {
inputEl = { type: "", addClass: vi.fn(), setCssStyles: vi.fn() };
constructor(_containerEl: unknown) {}
setValue = vi.fn().mockReturnThis();
getValue = vi.fn().mockReturnValue("");
setPlaceholder = vi.fn().mockReturnThis();
onChange = vi.fn().mockReturnThis();
}
export class Menu {
addItem = vi.fn().mockReturnThis();
}
export type MenuItem = unknown;
export function setIcon(_el: unknown, _iconId: string): void {}
export class WorkspaceLeaf {
view: unknown = {};
}
export function requireApiVersion(_version: string): boolean {
return false;
}

View file

@ -0,0 +1,147 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { FileStateManager } from "../src/services/file-state-manager";
function createMockApp() {
return {
vault: {
adapter: {
exists: vi.fn().mockResolvedValue(false),
read: vi.fn().mockResolvedValue("{}"),
write: vi.fn().mockResolvedValue(undefined),
},
},
};
}
describe("FileStateManager", () => {
let manager: FileStateManager;
let mockApp: ReturnType<typeof createMockApp>;
beforeEach(() => {
mockApp = createMockApp();
manager = new FileStateManager(
mockApp as never,
".obsidian/plugins/test",
);
vi.spyOn(console, "debug").mockImplementation(() => {});
vi.spyOn(console, "warn").mockImplementation(() => {});
vi.spyOn(console, "error").mockImplementation(() => {});
});
describe("markAsDirty()", () => {
it("marca arquivo como pending", () => {
manager.markAsDirty("notes/test.md");
const state = manager.getState("notes/test.md");
expect(state).toBeDefined();
expect(state!.status).toBe("pending");
});
it("ignora arquivos ~syncthing~", () => {
manager.markAsDirty("notes/~syncthing~test.md.tmp");
const pending = manager.getPendingFiles();
expect(pending).toHaveLength(0);
});
it("ignora arquivos .tmp", () => {
manager.markAsDirty("notes/test.md.tmp");
const pending = manager.getPendingFiles();
expect(pending).toHaveLength(0);
});
it("normaliza barras \\ para /", () => {
manager.markAsDirty("notes\\subfolder\\test.md");
const state = manager.getState("notes/subfolder/test.md");
expect(state).toBeDefined();
expect(state!.path).toBe("notes/subfolder/test.md");
});
});
describe("markAsSynced()", () => {
it("marca como synced via path exato", () => {
manager.markAsDirty("notes/test.md");
manager.markAsSynced("notes/test.md");
const state = manager.getState("notes/test.md");
expect(state!.status).toBe("synced");
});
it("resolve path com prefixo (match por sufixo)", () => {
manager.markAsDirty("notes/test.md");
manager.markAsSynced("dev-obsidian/notes/test.md");
const state = manager.getState("notes/test.md");
expect(state!.status).toBe("synced");
});
});
describe("findMatchingKey (via getState)", () => {
it("retorna undefined para path inexistente", () => {
const state = manager.getState("nonexistent.md");
expect(state).toBeUndefined();
});
});
describe("getPendingFiles()", () => {
it("retorna apenas pendentes", () => {
manager.markAsDirty("a.md");
manager.markAsDirty("b.md");
manager.markAsDirty("c.md");
manager.markAsSynced("b.md");
const pending = manager.getPendingFiles();
expect(pending).toHaveLength(2);
expect(pending.map((f) => f.path)).toContain("a.md");
expect(pending.map((f) => f.path)).toContain("c.md");
});
});
describe("Garbage Collection", () => {
it("remove synced antigos (TTL expirado)", async () => {
manager.markAsDirty("old.md");
manager.markAsSynced("old.md");
// Força lastSyncCheck para o passado (25h atrás)
const state = manager.getState("old.md");
if (state) {
state.lastSyncCheck = Date.now() - 25 * 60 * 60 * 1000;
}
// Dispara GC via saveImmediate (que chama performGarbageCollection)
mockApp.vault.adapter.write.mockResolvedValue(undefined);
// Chamamos requestSave que no mock executa imediatamente (debounce mockado)
manager.requestSave();
// Aguarda o debounce resolver
await new Promise((resolve) => setTimeout(resolve, 50));
const stateAfterGC = manager.getState("old.md");
expect(stateAfterGC).toBeUndefined();
});
it("mantém máximo de 50 synced, mais recentes", () => {
// Cria 60 arquivos synced
for (let i = 0; i < 60; i++) {
manager.markAsDirty(`file-${i}.md`);
manager.markAsSynced(`file-${i}.md`);
}
// Força GC via save
mockApp.vault.adapter.write.mockResolvedValue(undefined);
manager.requestSave();
// Conta quantos synced restam
let syncedCount = 0;
for (let i = 0; i < 60; i++) {
if (manager.getState(`file-${i}.md`)) {
syncedCount++;
}
}
expect(syncedCount).toBeLessThanOrEqual(50);
});
});
});

126
tests/logger.test.ts Normal file
View file

@ -0,0 +1,126 @@
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
import { Logger, LOG_MODULES } from "../src/utils/logger";
describe("Logger", () => {
beforeEach(() => {
vi.restoreAllMocks();
Logger.setDebugMode(false);
Logger.setLogLevel("debug");
Logger.setActiveModules([]);
Logger.clearBuffer();
vi.spyOn(console, "debug").mockImplementation(() => {});
vi.spyOn(console, "warn").mockImplementation(() => {});
vi.spyOn(console, "error").mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("debug()", () => {
it("não loga quando debugMode está OFF", () => {
Logger.setDebugMode(false);
Logger.setActiveModules([LOG_MODULES.MAIN]);
Logger.debug(LOG_MODULES.MAIN, "test message");
expect(console.debug).not.toHaveBeenCalled();
expect(Logger.getEntries()).toHaveLength(0);
});
it("loga quando debugMode ON + módulo ativo", () => {
Logger.setDebugMode(true);
Logger.setActiveModules([LOG_MODULES.MAIN]);
Logger.debug(LOG_MODULES.MAIN, "test message");
expect(console.debug).toHaveBeenCalledOnce();
expect(Logger.getEntries()).toHaveLength(1);
expect(Logger.getEntries()[0].level).toBe("debug");
});
it("não loga quando debugMode ON mas módulo inativo", () => {
Logger.setDebugMode(true);
Logger.setActiveModules([LOG_MODULES.API]);
Logger.debug(LOG_MODULES.MAIN, "should not log");
expect(console.debug).not.toHaveBeenCalled();
expect(Logger.getEntries()).toHaveLength(0);
});
});
describe("warn()", () => {
it("sempre envia ao console", () => {
Logger.setDebugMode(false);
Logger.warn(LOG_MODULES.MAIN, "warning");
expect(console.warn).toHaveBeenCalledOnce();
});
});
describe("error()", () => {
it("sempre envia ao console e ao buffer", () => {
Logger.setDebugMode(false);
Logger.error(LOG_MODULES.MAIN, "critical error");
expect(console.error).toHaveBeenCalledOnce();
expect(Logger.getEntries()).toHaveLength(1);
expect(Logger.getEntries()[0].level).toBe("error");
});
});
describe("buffer circular", () => {
it("descarta entradas mais antigas ao exceder 200", () => {
Logger.setDebugMode(true);
Logger.setActiveModules([LOG_MODULES.MAIN]);
for (let i = 0; i < 210; i++) {
Logger.debug(LOG_MODULES.MAIN, `msg-${i}`);
}
const entries = Logger.getEntries();
expect(entries).toHaveLength(200);
expect(entries[0].message).toBe("msg-10");
expect(entries[199].message).toBe("msg-209");
});
});
describe("listeners", () => {
it("recebe entradas novas", () => {
const listener = vi.fn();
Logger.onNewEntry(listener);
Logger.error(LOG_MODULES.MAIN, "test");
expect(listener).toHaveBeenCalledOnce();
expect(listener.mock.calls[0][0].message).toBe("test");
});
it("unsubscribe remove o listener", () => {
const listener = vi.fn();
const unsubscribe = Logger.onNewEntry(listener);
unsubscribe();
Logger.error(LOG_MODULES.MAIN, "test");
expect(listener).not.toHaveBeenCalled();
});
});
describe("sanitizeDetails", () => {
it("remove caminhos absolutos do Windows", () => {
Logger.error(
LOG_MODULES.MAIN,
"error",
"C:\\Users\\Gustavo\\file.txt",
);
const entries = Logger.getEntries();
expect(entries[0].details).toBe("<path>");
expect(entries[0].details).not.toContain("Gustavo");
});
});
});

View file

@ -0,0 +1,130 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { SecretManager } from "../src/services/secret-manager";
import { LS_KEY_API, SECRET_KEY_API } from "../src/types";
import { localStorageStore } from "./setup";
/** Cria um App mock com secretStorage opcional. */
function createMockApp(withKeychain = true) {
const app: {
secretStorage?: {
getSecret: ReturnType<typeof vi.fn>;
setSecret: ReturnType<typeof vi.fn>;
listSecrets: ReturnType<typeof vi.fn>;
};
} = {};
if (withKeychain) {
app.secretStorage = {
getSecret: vi.fn().mockReturnValue(null),
setSecret: vi.fn(),
listSecrets: vi.fn().mockReturnValue([]),
};
}
return app;
}
describe("SecretManager", () => {
beforeEach(() => {
// Limpa o store do localStorage mock
for (const key in localStorageStore) {
delete localStorageStore[key];
}
});
describe("hasKeychainSupport", () => {
it("retorna true quando secretStorage existe", () => {
const app = createMockApp(true);
const manager = new SecretManager(app as never);
expect(manager.hasKeychainSupport).toBe(true);
});
it("retorna false quando secretStorage está ausente", () => {
const app = createMockApp(false);
const manager = new SecretManager(app as never);
expect(manager.hasKeychainSupport).toBe(false);
});
});
describe("loadApiKey()", () => {
it("prioriza Keychain sobre localStorage", () => {
const app = createMockApp(true);
app.secretStorage!.getSecret.mockReturnValue("keychain-value");
localStorageStore[LS_KEY_API] = "local-value";
const manager = new SecretManager(app as never);
expect(manager.loadApiKey()).toBe("keychain-value");
});
it("usa localStorage quando Keychain está vazio", () => {
const app = createMockApp(true);
app.secretStorage!.getSecret.mockReturnValue(null);
localStorageStore[LS_KEY_API] = "local-value";
const manager = new SecretManager(app as never);
expect(manager.loadApiKey()).toBe("local-value");
});
it("retorna string vazia quando ambos estão vazios", () => {
const app = createMockApp(true);
app.secretStorage!.getSecret.mockReturnValue(null);
const manager = new SecretManager(app as never);
expect(manager.loadApiKey()).toBe("");
});
});
describe("saveApiKey()", () => {
it("salva em ambos Keychain e localStorage", () => {
const app = createMockApp(true);
const manager = new SecretManager(app as never);
manager.saveApiKey("my-api-key");
expect(app.secretStorage!.setSecret).toHaveBeenCalledWith(
SECRET_KEY_API,
"my-api-key",
);
expect(localStorageStore[LS_KEY_API]).toBe("my-api-key");
});
});
describe("migrateIfNeeded()", () => {
it("move key do localStorage para o Keychain", () => {
const app = createMockApp(true);
app.secretStorage!.getSecret.mockReturnValue(null);
localStorageStore[LS_KEY_API] = "old-key";
const manager = new SecretManager(app as never);
manager.migrateIfNeeded();
expect(app.secretStorage!.setSecret).toHaveBeenCalledWith(
SECRET_KEY_API,
"old-key",
);
expect(localStorageStore[LS_KEY_API]).toBeUndefined();
});
it("não migra se Keychain já tem valor", () => {
const app = createMockApp(true);
app.secretStorage!.getSecret.mockReturnValue("existing-key");
localStorageStore[LS_KEY_API] = "old-key";
const manager = new SecretManager(app as never);
manager.migrateIfNeeded();
expect(app.secretStorage!.setSecret).not.toHaveBeenCalled();
expect(localStorageStore[LS_KEY_API]).toBe("old-key");
});
it("não faz nada sem suporte a Keychain", () => {
const app = createMockApp(false);
localStorageStore[LS_KEY_API] = "old-key";
const manager = new SecretManager(app as never);
manager.migrateIfNeeded();
expect(localStorageStore[LS_KEY_API]).toBe("old-key");
});
});
});

View file

@ -0,0 +1,125 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { SettingsManager } from "../src/services/settings-manager";
import { SecretManager } from "../src/services/secret-manager";
import {
DEFAULT_SETTINGS,
LS_KEY_HOST,
LS_KEY_PORT,
LS_KEY_HTTPS,
} from "../src/types";
import { localStorageStore } from "./setup";
function createMockPlugin() {
return {
loadData: vi.fn().mockResolvedValue({}),
saveData: vi.fn().mockResolvedValue(undefined),
};
}
function createMockSecretManager() {
return {
migrateIfNeeded: vi.fn(),
loadApiKey: vi.fn().mockReturnValue(""),
saveApiKey: vi.fn(),
} as unknown as SecretManager;
}
describe("SettingsManager", () => {
beforeEach(() => {
for (const key in localStorageStore) {
delete localStorageStore[key];
}
});
describe("loadSettings()", () => {
it("faz merge correto de defaults com dados parciais", async () => {
const plugin = createMockPlugin();
const secret = createMockSecretManager();
plugin.loadData.mockResolvedValue({
showStatusBar: false,
language: "pt",
});
const manager = new SettingsManager(plugin as never, secret);
const settings = await manager.loadSettings();
expect(settings.showStatusBar).toBe(false);
expect(settings.language).toBe("pt");
expect(settings.updateInterval).toBe(
DEFAULT_SETTINGS.updateInterval,
);
expect(settings.showRibbonIcon).toBe(
DEFAULT_SETTINGS.showRibbonIcon,
);
});
it("limpa placeholder 'device-specific' para string vazia", async () => {
const plugin = createMockPlugin();
const secret = createMockSecretManager();
plugin.loadData.mockResolvedValue({
syncthingHost: "device-specific",
syncthingPort: "device-specific",
});
const manager = new SettingsManager(plugin as never, secret);
const settings = await manager.loadSettings();
expect(settings.syncthingHost).toBe("");
expect(settings.syncthingPort).toBe("");
});
it("valores do localStorage sobrescrevem defaults", async () => {
const plugin = createMockPlugin();
const secret = createMockSecretManager();
localStorageStore[LS_KEY_HOST] = "192.168.1.1";
localStorageStore[LS_KEY_PORT] = "9999";
localStorageStore[LS_KEY_HTTPS] = "true";
const manager = new SettingsManager(plugin as never, secret);
const settings = await manager.loadSettings();
expect(settings.syncthingHost).toBe("192.168.1.1");
expect(settings.syncthingPort).toBe("9999");
expect(settings.useHttps).toBe(true);
});
it("chama migrateIfNeeded do SecretManager", async () => {
const plugin = createMockPlugin();
const secret = createMockSecretManager();
const manager = new SettingsManager(plugin as never, secret);
await manager.loadSettings();
expect(secret.migrateIfNeeded).toHaveBeenCalledOnce();
});
});
describe("saveSettings()", () => {
it("persiste no data.json com placeholders device-specific", async () => {
const plugin = createMockPlugin();
const secret = createMockSecretManager();
const manager = new SettingsManager(plugin as never, secret);
const settings = {
...DEFAULT_SETTINGS,
syncthingHost: "192.168.1.1",
};
await manager.saveSettings(settings);
const savedData = plugin.saveData.mock.calls[0][0];
expect(savedData.syncthingHost).toBe("device-specific");
expect(savedData.syncthingApiKey).toBe("device-specific");
});
it("delega API key ao SecretManager", async () => {
const plugin = createMockPlugin();
const secret = createMockSecretManager();
const manager = new SettingsManager(plugin as never, secret);
const settings = { ...DEFAULT_SETTINGS, syncthingApiKey: "my-key" };
await manager.saveSettings(settings);
expect(secret.saveApiKey).toHaveBeenCalledWith("my-key");
});
});
});

50
tests/setup.ts Normal file
View file

@ -0,0 +1,50 @@
/**
* Setup global para testes.
* Substitui window.localStorage por uma implementação mock controlável.
*/
import { vi } from "vitest";
const localStorageStore: Record<string, string> = {};
const localStorageMock = {
getItem: vi.fn((key: string): string | null => {
return localStorageStore[key] ?? null;
}),
setItem: vi.fn((key: string, value: string): void => {
localStorageStore[key] = value;
}),
removeItem: vi.fn((key: string): void => {
delete localStorageStore[key];
}),
clear: vi.fn((): void => {
for (const key in localStorageStore) {
delete localStorageStore[key];
}
}),
get length(): number {
return Object.keys(localStorageStore).length;
},
key: vi.fn((index: number): string | null => {
const keys = Object.keys(localStorageStore);
return keys[index] ?? null;
}),
};
Object.defineProperty(globalThis, "localStorage", {
value: localStorageMock,
writable: true,
configurable: true,
});
// window pode não existir em ambiente node puro
if (typeof globalThis.window === "undefined") {
(globalThis as Record<string, unknown>).window = globalThis;
}
Object.defineProperty(globalThis.window, "localStorage", {
value: localStorageMock,
writable: true,
configurable: true,
});
export { localStorageStore, localStorageMock };

187
tests/syncthing-api.test.ts Normal file
View file

@ -0,0 +1,187 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { SyncthingAPI } from "../src/api/syncthing-api";
import { requestUrl } from "obsidian";
const mockRequestUrl = requestUrl as ReturnType<typeof vi.fn>;
const BASE_URL = "http://127.0.0.1:8384";
const API_KEY = "test-api-key-123";
/** Helper para criar resposta mock no formato que SyncthingAPI.request() espera. */
function mockResponse(status: number, data: unknown) {
const text = JSON.stringify(data);
return { status, json: data, text };
}
describe("SyncthingAPI", () => {
beforeEach(() => {
mockRequestUrl.mockReset();
SyncthingAPI.invalidateConfigCache();
vi.spyOn(console, "debug").mockImplementation(() => {});
vi.spyOn(console, "warn").mockImplementation(() => {});
vi.spyOn(console, "error").mockImplementation(() => {});
});
describe("getStatus()", () => {
it("faz GET com header X-API-Key correto", async () => {
mockRequestUrl.mockResolvedValue(
mockResponse(200, {
myID: "device-123",
alloc: 100,
cpuPerf: 50,
}),
);
const result = await SyncthingAPI.getStatus(BASE_URL, API_KEY);
expect(mockRequestUrl).toHaveBeenCalledWith(
expect.objectContaining({
url: `${BASE_URL}/rest/system/status`,
method: "GET",
headers: expect.objectContaining({ "X-API-Key": API_KEY }),
}),
);
expect(result.myID).toBe("device-123");
});
});
describe("getConfig() — cache", () => {
it("usa cache dentro do TTL", async () => {
const configData = {
folders: [{ id: "f1", label: "Test" }],
devices: [],
};
mockRequestUrl.mockResolvedValue(mockResponse(200, configData));
await SyncthingAPI.getConfig(BASE_URL, API_KEY);
await SyncthingAPI.getConfig(BASE_URL, API_KEY);
expect(mockRequestUrl).toHaveBeenCalledTimes(1);
});
it("invalida cache após invalidateConfigCache()", async () => {
const configData = { folders: [], devices: [] };
mockRequestUrl.mockResolvedValue(mockResponse(200, configData));
await SyncthingAPI.getConfig(BASE_URL, API_KEY);
SyncthingAPI.invalidateConfigCache();
await SyncthingAPI.getConfig(BASE_URL, API_KEY);
expect(mockRequestUrl).toHaveBeenCalledTimes(2);
});
});
describe("getFolders()", () => {
it("extrai folders do config", async () => {
mockRequestUrl.mockResolvedValue(
mockResponse(200, {
folders: [
{ id: "f1", label: "Folder 1" },
{ id: "f2", label: "Folder 2" },
],
devices: [],
}),
);
const folders = await SyncthingAPI.getFolders(BASE_URL, API_KEY);
expect(folders).toHaveLength(2);
expect(folders[0].id).toBe("f1");
});
});
describe("forceScan()", () => {
it("faz POST com folderId no query", async () => {
mockRequestUrl.mockResolvedValue(mockResponse(200, {}));
await SyncthingAPI.forceScan(BASE_URL, API_KEY, "my-folder");
expect(mockRequestUrl).toHaveBeenCalledWith(
expect.objectContaining({
method: "POST",
}),
);
const calledUrl = mockRequestUrl.mock.calls[0][0].url;
expect(calledUrl).toContain("folder=my-folder");
});
});
describe("request() — tratamento de erros", () => {
it("lança erro em status 403", async () => {
mockRequestUrl.mockResolvedValue(
mockResponse(403, { error: "Forbidden" }),
);
await expect(
SyncthingAPI.getStatus(BASE_URL, API_KEY),
).rejects.toThrow("HTTP Error 403");
});
it("lança erro em falha de conexão", async () => {
mockRequestUrl.mockRejectedValue(
new Error("net::ERR_CONNECTION_REFUSED"),
);
await expect(
SyncthingAPI.getStatus(BASE_URL, API_KEY),
).rejects.toThrow("ERR_CONNECTION_REFUSED");
});
it("permite status em allowErrors sem impedir a execução", async () => {
mockRequestUrl.mockResolvedValue(mockResponse(404, null));
const result = await SyncthingAPI.getFileInfo(
BASE_URL,
API_KEY,
"folder-id",
"nonexistent.md",
);
expect(result).toBeNull();
});
});
describe("getHistory()", () => {
it("filtra e mapeia eventos corretamente", async () => {
const events = [
{
id: 1,
type: "ItemFinished",
time: "2026-01-01T00:00:00Z",
data: {
item: "test.md",
type: "file",
action: "update",
folder: "my-folder",
},
},
{
id: 2,
type: "ItemFinished",
time: "2026-01-01T00:01:00Z",
data: {
item: "other.md",
type: "file",
action: "delete",
folder: "my-folder",
},
},
];
mockRequestUrl.mockResolvedValue(mockResponse(200, events));
const history = await SyncthingAPI.getHistory(
BASE_URL,
API_KEY,
"my-folder",
"",
"device-123",
);
expect(history).toHaveLength(2);
expect(history[0].filename).toBe("other.md");
expect(history[0].action).toBe("deleted");
expect(history[1].filename).toBe("test.md");
expect(history[1].action).toBe("updated");
});
});
});

View file

@ -1,24 +1,18 @@
{
"compilerOptions": {
"baseUrl": ".",
"inlineSourceMap": true,
"inlineSources": true,
"module": "ESNext",
"target": "ES6",
"allowJs": true,
"noImplicitAny": true,
"moduleResolution": "node",
"importHelpers": true,
"isolatedModules": true,
"strictNullChecks": true,
"lib": [
"DOM",
"ES5",
"ES6",
"ES7"
]
},
"include": [
"**/*.ts"
]
"compilerOptions": {
"baseUrl": ".",
"inlineSourceMap": true,
"inlineSources": true,
"module": "ESNext",
"target": "ES6",
"allowJs": true,
"noImplicitAny": true,
"moduleResolution": "node",
"importHelpers": true,
"isolatedModules": true,
"strictNullChecks": true,
"lib": ["DOM", "ES5", "ES6", "ES7"]
},
"include": ["**/*.ts"],
"exclude": ["tests/**", "vitest.config.ts"]
}

12
vitest.config.ts Normal file
View file

@ -0,0 +1,12 @@
import { defineConfig } from "vitest/config";
import * as path from "path";
export default defineConfig({
test: {
include: ["tests/**/*.test.ts"],
setupFiles: ["tests/setup.ts"],
alias: {
obsidian: path.resolve(__dirname, "tests/__mocks__/obsidian.ts"),
},
},
});