mirror of
https://github.com/gustjose/obsidian-syncthing-manager.git
synced 2026-07-22 06:40:35 +00:00
test: add unit test suite with Vitest for core modules
This commit is contained in:
parent
f07e2b9ecf
commit
b95bd315f2
12 changed files with 2374 additions and 350 deletions
1728
package-lock.json
generated
1728
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
148
tests/__mocks__/obsidian.ts
Normal 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;
|
||||
}
|
||||
147
tests/file-state-manager.test.ts
Normal file
147
tests/file-state-manager.test.ts
Normal 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
126
tests/logger.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
130
tests/secret-manager.test.ts
Normal file
130
tests/secret-manager.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
125
tests/settings-manager.test.ts
Normal file
125
tests/settings-manager.test.ts
Normal 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
50
tests/setup.ts
Normal 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
187
tests/syncthing-api.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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
12
vitest.config.ts
Normal 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"),
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
Reference in a new issue