trash-cleaner
Version:
Finds and deletes trash email in the mailbox
475 lines (366 loc) • 19.9 kB
text/typescript
import fs from 'fs';
import path from 'path';
import os from 'os';
import sinon from 'sinon';
import { assert } from 'chai';
import { Cli } from '../lib/cli.js';
import { ActionLog } from '../lib/utils/action-log.js';
describe('Cli', () => {
let cli: any, sandbox: sinon.SinonSandbox;
beforeEach(() => {
sandbox = sinon.createSandbox();
cli = new Cli();
sandbox.stub(console, 'error');
});
afterEach(() => {
sandbox.restore();
});
describe('run', () => {
it('returns false when config directory does not exist', async () => {
const result = await cli.run(['node', 'trash-cleaner', '-c', '/nonexistent/path']);
assert.isFalse(result);
});
it('returns false and logs error message when config dir missing', async () => {
const result = await cli.run(['node', 'trash-cleaner', '-c', '/nonexistent/path']);
assert.isFalse(result);
sinon.assert.calledOnce(console.error as sinon.SinonStub);
assert.include((console.error as sinon.SinonStub).firstCall.args[0], 'Config directory not found');
assert.include((console.error as sinon.SinonStub).firstCall.args[0], 'trash-cleaner init');
});
it('returns false and logs init hint in debug mode too', async () => {
const result = await cli.run(['node', 'trash-cleaner', '-c', '/nonexistent/path', '-d']);
assert.isFalse(result);
sinon.assert.calledOnce(console.error as sinon.SinonStub);
assert.include((console.error as sinon.SinonStub).firstCall.args[0], 'Config directory not found');
});
it('throws for unsupported email service', async () => {
// Commander will throw/exit for invalid choices, so we test _createEmailClient directly
const result = await cli._createEmailClient({}, 'yahoo', false, false)
.catch((err: Error) => err);
assert.match(result.message, /not yet implemented/);
});
});
describe('init', () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'trash-cleaner-test-'));
sandbox.stub(console, 'log');
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('creates config directory when it does not exist', async () => {
const configDir = path.join(tmpDir, 'newconfig');
cli = new Cli();
const result = await cli.run(['node', 'trash-cleaner', 'init', configDir]);
assert.isTrue(result);
assert.isTrue(fs.existsSync(configDir));
});
it('copies sample files to config directory', async () => {
const configDir = path.join(tmpDir, 'newconfig');
cli = new Cli();
await cli.run(['node', 'trash-cleaner', 'init', configDir]);
assert.isTrue(fs.existsSync(path.join(configDir, 'keywords.yaml')));
assert.isTrue(fs.existsSync(path.join(configDir, 'llm-providers.yaml')));
assert.isTrue(fs.existsSync(path.join(configDir, 'allowlist.yaml')));
assert.isTrue(fs.existsSync(path.join(configDir, 'imap.credentials.json')));
assert.isTrue(fs.existsSync(path.join(configDir, 'gmail.credentials.json')));
assert.isTrue(fs.existsSync(path.join(configDir, 'outlook.credentials.json')));
});
it('does not overwrite existing files', async () => {
const configDir = path.join(tmpDir, 'existing');
fs.mkdirSync(configDir);
const keywordsPath = path.join(configDir, 'keywords.yaml');
fs.writeFileSync(keywordsPath, '- value: custom\n');
cli = new Cli();
await cli.run(['node', 'trash-cleaner', 'init', configDir]);
const content = fs.readFileSync(keywordsPath, 'utf8');
assert.equal(content, '- value: custom\n');
});
it('uses default config path when no argument given', async () => {
// Test that _initConfig is called (we test via the internal method)
cli = new Cli();
const result = cli._initConfig(path.join(tmpDir, 'defaulttest'));
assert.isTrue(result);
assert.isTrue(fs.existsSync(path.join(tmpDir, 'defaulttest')));
});
it('prints next steps after copying files', async () => {
const configDir = path.join(tmpDir, 'newconfig');
cli = new Cli();
await cli.run(['node', 'trash-cleaner', 'init', configDir]);
const logCalls = (console.log as sinon.SinonStub).args.map((a: any[]) => a[0]);
assert.isTrue(logCalls.some((msg: string) => msg.includes('Next steps')));
});
});
describe('list-rules', () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'trash-cleaner-rules-'));
sandbox.stub(console, 'log');
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('lists rules from keywords.json', async () => {
const keywords = [
{ value: 'casino', fields: 'subject', labels: 'spam' },
{ value: 'newsletter', fields: '*', labels: 'inbox', action: 'archive' }
];
fs.writeFileSync(path.join(tmpDir, 'keywords.json'), JSON.stringify(keywords));
cli = new Cli();
const result = await cli.run(['node', 'trash-cleaner', 'list-rules', tmpDir]);
assert.isTrue(result);
const logCalls = (console.log as sinon.SinonStub).args.map((a: any[]) => a[0]);
assert.isTrue(logCalls.some((msg: string) => msg.includes('Total rules: 2')));
assert.isTrue(logCalls.some((msg: string) => msg.includes('/casino/')));
assert.isTrue(logCalls.some((msg: string) => msg.includes('Action: delete')));
assert.isTrue(logCalls.some((msg: string) => msg.includes('Action: archive')));
});
it('shows allowlist when present', async () => {
const keywords = [{ value: 'test', fields: '*', labels: '*' }];
const allowlist = ['boss@example\\.com'];
fs.writeFileSync(path.join(tmpDir, 'keywords.json'), JSON.stringify(keywords));
fs.writeFileSync(path.join(tmpDir, 'allowlist.json'), JSON.stringify(allowlist));
cli = new Cli();
const result = await cli.run(['node', 'trash-cleaner', 'list-rules', tmpDir]);
assert.isTrue(result);
const logCalls = (console.log as sinon.SinonStub).args.map((a: any[]) => a[0]);
assert.isTrue(logCalls.some((msg: string) => msg && msg.includes('Allowlist')));
assert.isTrue(logCalls.some((msg: string) => msg && msg.includes('boss@example')));
});
it('returns false for invalid config directory', async () => {
cli = new Cli();
const result = await cli.run(['node', 'trash-cleaner', 'list-rules', '/nonexistent']);
assert.isFalse(result);
});
});
describe('interactive mode', () => {
it('shows preview and processes on confirm', async () => {
const email = { id: '1', from: 'spam@test.com', subject: 'Win!', _action: 'delete', labels: ['spam'] };
const trashCleaner = {
findTrash: sinon.stub().resolves([email]),
processEmails: sinon.stub().resolves()
};
sandbox.stub(console, 'log');
cli = new Cli();
sandbox.stub(cli, '_promptAction').resolves('yes');
await cli._runInteractive(trashCleaner);
sinon.assert.calledOnce(trashCleaner.findTrash);
sinon.assert.calledWith(trashCleaner.processEmails, [email]);
});
it('skips emails user declines', async () => {
const email1 = { id: '1', from: 'spam@test.com', subject: 'Win!', _action: 'delete', labels: ['spam'] };
const email2 = { id: '2', from: 'store@test.com', subject: 'Sale', _action: 'archive', labels: ['inbox'] };
const trashCleaner = {
findTrash: sinon.stub().resolves([email1, email2]),
processEmails: sinon.stub().resolves()
};
sandbox.stub(console, 'log');
cli = new Cli();
const promptStub = sandbox.stub(cli, '_promptAction');
promptStub.onCall(0).resolves('yes');
promptStub.onCall(1).resolves('no');
await cli._runInteractive(trashCleaner);
sinon.assert.calledWith(trashCleaner.processEmails, [email1]);
});
it('yes-all confirms remaining emails without prompting', async () => {
const email1 = { id: '1', from: 'a@test.com', subject: 'A', _action: 'delete', labels: ['spam'] };
const email2 = { id: '2', from: 'b@test.com', subject: 'B', _action: 'delete', labels: ['spam'] };
const email3 = { id: '3', from: 'c@test.com', subject: 'C', _action: 'delete', labels: ['spam'] };
const trashCleaner = {
findTrash: sinon.stub().resolves([email1, email2, email3]),
processEmails: sinon.stub().resolves()
};
sandbox.stub(console, 'log');
cli = new Cli();
const promptStub = sandbox.stub(cli, '_promptAction');
promptStub.onCall(0).resolves('yes-all');
await cli._runInteractive(trashCleaner);
sinon.assert.calledOnce(promptStub);
sinon.assert.calledWith(trashCleaner.processEmails, [email1, email2, email3]);
});
it('no-all skips remaining emails without prompting', async () => {
const email1 = { id: '1', from: 'a@test.com', subject: 'A', _action: 'delete', labels: ['spam'] };
const email2 = { id: '2', from: 'b@test.com', subject: 'B', _action: 'delete', labels: ['spam'] };
const trashCleaner = {
findTrash: sinon.stub().resolves([email1, email2]),
processEmails: sinon.stub().resolves()
};
sandbox.stub(console, 'log');
cli = new Cli();
const promptStub = sandbox.stub(cli, '_promptAction');
promptStub.onCall(0).resolves('no-all');
await cli._runInteractive(trashCleaner);
sinon.assert.calledOnce(promptStub);
sinon.assert.notCalled(trashCleaner.processEmails);
});
it('shows rule name for each email', async () => {
const email = { id: '1', from: 'spam@test.com', subject: 'Win!', _action: 'delete', _rule: 'Casino spam', labels: ['spam'] };
const trashCleaner = {
findTrash: sinon.stub().resolves([email]),
processEmails: sinon.stub().resolves()
};
sandbox.stub(console, 'log');
cli = new Cli();
sandbox.stub(cli, '_promptAction').resolves('yes');
await cli._runInteractive(trashCleaner);
const logCalls = (console.log as sinon.SinonStub).args.map((a: any[]) => a[0]);
assert.isTrue(logCalls.some((msg: string) => msg && msg.includes('Casino spam')));
});
it('does nothing when all declined', async () => {
const email = { id: '1', from: 'spam@test.com', subject: 'Win!', _action: 'delete', labels: ['spam'] };
const trashCleaner = {
findTrash: sinon.stub().resolves([email]),
processEmails: sinon.stub().resolves()
};
sandbox.stub(console, 'log');
cli = new Cli();
sandbox.stub(cli, '_promptAction').resolves('no');
await cli._runInteractive(trashCleaner);
sinon.assert.notCalled(trashCleaner.processEmails);
const logCalls = (console.log as sinon.SinonStub).args.map((a: any[]) => a[0]);
assert.isTrue(logCalls.some((msg: string) => msg && msg.includes('No emails selected')));
});
it('reports no trash found', async () => {
const trashCleaner = {
findTrash: sinon.stub().resolves([]),
processEmails: sinon.stub().resolves()
};
sandbox.stub(console, 'log');
cli = new Cli();
await cli._runInteractive(trashCleaner);
sinon.assert.notCalled(trashCleaner.processEmails);
const logCalls = (console.log as sinon.SinonStub).args.map((a: any[]) => a[0]);
assert.isTrue(logCalls.some((msg: string) => msg && msg.includes('No trash emails')));
});
});
describe('undo', () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cli-undo-'));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('reports no actions to undo when log is empty', async () => {
sandbox.stub(console, 'log');
cli = new Cli();
const result = await cli.run(['node', 'trash-cleaner', 'undo', tmpDir]);
assert.isTrue(result);
const logCalls = (console.log as sinon.SinonStub).args.map((a: any[]) => a[0]);
assert.isTrue(logCalls.some((msg: string) => msg && msg.includes('No actions to undo')));
});
it('shows last batch and cancels on decline', async () => {
const log = new ActionLog(tmpDir);
log.record([{ id: '1', action: 'delete', from: 'spam@x.com', subject: 'Junk' }]);
sandbox.stub(console, 'log');
cli = new Cli();
sandbox.stub(cli, '_confirm').resolves(false);
const result = await cli.run(['node', 'trash-cleaner', 'undo', tmpDir]);
assert.isTrue(result);
const logCalls = (console.log as sinon.SinonStub).args.map((a: any[]) => a[0]);
assert.isTrue(logCalls.some((msg: string) => msg && msg.includes('Cancelled')));
});
});
describe('validate', () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cli-validate-'));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('returns false for non-existent config directory', async () => {
sandbox.stub(console, 'log');
cli = new Cli();
const result = await cli.run(['node', 'trash-cleaner', 'validate', '/nonexistent']);
assert.isFalse(result);
});
it('reports valid config when keywords.json is correct', async () => {
const keywords = [{ value: 'test', fields: '*', labels: '*' }];
fs.writeFileSync(path.join(tmpDir, 'keywords.json'), JSON.stringify(keywords));
sandbox.stub(console, 'log');
cli = new Cli();
const result = await cli.run(['node', 'trash-cleaner', 'validate', tmpDir]);
assert.isTrue(result);
const logCalls = (console.log as sinon.SinonStub).args.map((a: any[]) => a[0]);
assert.isTrue(logCalls.some((msg: string) => msg && msg.includes('Configuration is valid')));
});
it('reports error for invalid keywords.json', async () => {
fs.writeFileSync(path.join(tmpDir, 'keywords.json'), 'not json');
sandbox.stub(console, 'log');
cli = new Cli();
const result = await cli.run(['node', 'trash-cleaner', 'validate', tmpDir]);
assert.isFalse(result);
const logCalls = (console.log as sinon.SinonStub).args.map((a: any[]) => a[0]);
assert.isTrue(logCalls.some((msg: string) => msg && msg.includes('Validation failed')));
});
it('reports missing keywords.json as error', async () => {
sandbox.stub(console, 'log');
cli = new Cli();
const result = await cli.run(['node', 'trash-cleaner', 'validate', tmpDir]);
assert.isFalse(result);
const logCalls = (console.log as sinon.SinonStub).args.map((a: any[]) => a[0]);
assert.isTrue(logCalls.some((msg: string) => msg && msg.includes('not found')));
});
it('validates allowlist.json when present', async () => {
const keywords = [{ value: 'test', fields: '*', labels: '*' }];
fs.writeFileSync(path.join(tmpDir, 'keywords.json'), JSON.stringify(keywords));
fs.writeFileSync(path.join(tmpDir, 'allowlist.json'), JSON.stringify(['valid@test\\.com']));
sandbox.stub(console, 'log');
cli = new Cli();
const result = await cli.run(['node', 'trash-cleaner', 'validate', tmpDir]);
assert.isTrue(result);
const logCalls = (console.log as sinon.SinonStub).args.map((a: any[]) => a[0]);
assert.isTrue(logCalls.some((msg: string) => msg && msg.includes('1 pattern')));
});
});
describe('login', () => {
beforeEach(() => {
sandbox.stub(console, 'log');
});
function mockReadline(cliInstance: any, answers: string[]) {
let callIndex = 0;
sandbox.stub(cliInstance, '_createReadlineInterface').returns({
question: (_q: string, cb: (answer: string) => void) => cb(answers[callIndex++] || ''),
close: () => {}
});
}
it('returns false when IMAP host is empty', async () => {
mockReadline(cli, ['', '993', 'user@test.com', 'pass123']);
const result = await cli.run(['node', 'trash-cleaner', 'login']);
assert.isFalse(result);
sinon.assert.calledWith(console.error as sinon.SinonStub, 'Error: IMAP host is required.');
});
it('returns false when email is empty', async () => {
mockReadline(cli, ['imap.gmail.com', '993', '', 'pass123']);
const result = await cli.run(['node', 'trash-cleaner', 'login']);
assert.isFalse(result);
sinon.assert.calledWith(console.error as sinon.SinonStub, 'Error: Email address is required.');
});
it('returns false when password is empty', async () => {
mockReadline(cli, ['imap.gmail.com', '993', 'user@test.com', '']);
const result = await cli.run(['node', 'trash-cleaner', 'login']);
assert.isFalse(result);
sinon.assert.calledWith(console.error as sinon.SinonStub, 'Error: App password is required.');
});
it('returns false when Gmail JSON is empty', async () => {
mockReadline(cli, ['']);
const result = await cli.run(['node', 'trash-cleaner', 'login', '-s', 'gmail']);
assert.isFalse(result);
sinon.assert.calledWith(console.error as sinon.SinonStub, 'Error: OAuth2 credentials JSON is required.');
});
it('returns false when Outlook client ID is empty', async () => {
mockReadline(cli, ['', 'tenant-123']);
const result = await cli.run(['node', 'trash-cleaner', 'login', '-s', 'outlook']);
assert.isFalse(result);
sinon.assert.calledWith(console.error as sinon.SinonStub, 'Error: Client ID is required.');
});
it('returns false when Outlook tenant ID is empty', async () => {
mockReadline(cli, ['client-123', '']);
const result = await cli.run(['node', 'trash-cleaner', 'login', '-s', 'outlook']);
assert.isFalse(result);
sinon.assert.calledWith(console.error as sinon.SinonStub, 'Error: Tenant ID is required.');
});
});
});