inboxd 1.0.0 → 1.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/publish.yml +27 -0
- package/CLAUDE.md +73 -0
- package/LICENSE +21 -0
- package/README.md +164 -0
- package/__mocks__/@google-cloud/local-auth.js +11 -0
- package/__mocks__/googleapis.js +42 -0
- package/eslint.config.js +75 -0
- package/images/banner.png +0 -0
- package/package.json +12 -2
- package/src/cli.js +416 -7
- package/src/deletion-log.js +21 -4
- package/src/gmail-auth.js +88 -10
- package/src/gmail-monitor.js +29 -4
- package/src/state.js +5 -4
- package/src/types.js +45 -0
- package/src/utils.js +30 -0
- package/tests/analyze.test.js +165 -0
- package/tests/deletion-log.test.js +171 -0
- package/tests/gmail-auth.test.js +77 -0
- package/tests/gmail-monitor.test.js +135 -0
- package/tests/notifier.test.js +27 -0
- package/tests/setup.js +16 -0
- package/tests/setup.test.js +214 -0
- package/tests/state-multiacccount.test.js +91 -0
- package/tests/state.test.js +66 -0
- package/vitest.config.js +13 -0
- package/com.danielparedes.inboxd.plist +0 -40
- package/credentials.json +0 -1
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
// Don't import gmail-monitor at all - just test the logic patterns it uses
|
|
4
|
+
// This avoids loading the real gmail-auth which triggers browser auth
|
|
5
|
+
|
|
6
|
+
describe('Gmail Monitor Logic', () => {
|
|
7
|
+
describe('Email Parsing Logic', () => {
|
|
8
|
+
it('should extract headers correctly', () => {
|
|
9
|
+
const headers = [
|
|
10
|
+
{ name: 'From', value: 'sender@example.com' },
|
|
11
|
+
{ name: 'Subject', value: 'Test Subject' },
|
|
12
|
+
{ name: 'Date', value: 'Mon, 1 Jan 2024 10:00:00 +0000' },
|
|
13
|
+
];
|
|
14
|
+
const getHeader = (name) => {
|
|
15
|
+
const header = headers.find((h) => h.name === name);
|
|
16
|
+
return header ? header.value : '';
|
|
17
|
+
};
|
|
18
|
+
expect(getHeader('From')).toBe('sender@example.com');
|
|
19
|
+
expect(getHeader('Subject')).toBe('Test Subject');
|
|
20
|
+
expect(getHeader('Date')).toBe('Mon, 1 Jan 2024 10:00:00 +0000');
|
|
21
|
+
expect(getHeader('NonExistent')).toBe('');
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe('Untrash Operation Logic', () => {
|
|
26
|
+
it('should handle successful untrash results', async () => {
|
|
27
|
+
// Simulate the untrash logic without importing the real module
|
|
28
|
+
const mockGmail = {
|
|
29
|
+
users: {
|
|
30
|
+
messages: {
|
|
31
|
+
untrash: vi.fn().mockResolvedValue({ data: { id: 'msg123', labelIds: ['INBOX'] } })
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// This is the logic from untrashEmails
|
|
37
|
+
const messageIds = ['msg123'];
|
|
38
|
+
const results = [];
|
|
39
|
+
|
|
40
|
+
for (const id of messageIds) {
|
|
41
|
+
try {
|
|
42
|
+
await mockGmail.users.messages.untrash({ userId: 'me', id });
|
|
43
|
+
results.push({ id, success: true });
|
|
44
|
+
} catch (err) {
|
|
45
|
+
results.push({ id, success: false, error: err.message });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
expect(results).toHaveLength(1);
|
|
50
|
+
expect(results[0].success).toBe(true);
|
|
51
|
+
expect(results[0].id).toBe('msg123');
|
|
52
|
+
expect(mockGmail.users.messages.untrash).toHaveBeenCalledWith({
|
|
53
|
+
userId: 'me',
|
|
54
|
+
id: 'msg123'
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('should handle errors during untrash', async () => {
|
|
59
|
+
const mockGmail = {
|
|
60
|
+
users: {
|
|
61
|
+
messages: {
|
|
62
|
+
untrash: vi.fn().mockRejectedValue(new Error('API Error'))
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const messageIds = ['msg123'];
|
|
68
|
+
const results = [];
|
|
69
|
+
|
|
70
|
+
for (const id of messageIds) {
|
|
71
|
+
try {
|
|
72
|
+
await mockGmail.users.messages.untrash({ userId: 'me', id });
|
|
73
|
+
results.push({ id, success: true });
|
|
74
|
+
} catch (err) {
|
|
75
|
+
results.push({ id, success: false, error: err.message });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
expect(results).toHaveLength(1);
|
|
80
|
+
expect(results[0].success).toBe(false);
|
|
81
|
+
expect(results[0].error).toBe('API Error');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('should handle multiple message IDs', async () => {
|
|
85
|
+
const mockUntrash = vi.fn()
|
|
86
|
+
.mockResolvedValueOnce({ data: { id: 'msg1' } })
|
|
87
|
+
.mockResolvedValueOnce({ data: { id: 'msg2' } })
|
|
88
|
+
.mockRejectedValueOnce(new Error('Not found'));
|
|
89
|
+
|
|
90
|
+
const mockGmail = {
|
|
91
|
+
users: { messages: { untrash: mockUntrash } }
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const messageIds = ['msg1', 'msg2', 'msg3'];
|
|
95
|
+
const results = [];
|
|
96
|
+
|
|
97
|
+
for (const id of messageIds) {
|
|
98
|
+
try {
|
|
99
|
+
await mockGmail.users.messages.untrash({ userId: 'me', id });
|
|
100
|
+
results.push({ id, success: true });
|
|
101
|
+
} catch (err) {
|
|
102
|
+
results.push({ id, success: false, error: err.message });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
expect(results).toHaveLength(3);
|
|
107
|
+
expect(results[0]).toEqual({ id: 'msg1', success: true });
|
|
108
|
+
expect(results[1]).toEqual({ id: 'msg2', success: true });
|
|
109
|
+
expect(results[2]).toEqual({ id: 'msg3', success: false, error: 'Not found' });
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe('Retry Logic', () => {
|
|
114
|
+
it('should retry on network errors', async () => {
|
|
115
|
+
// Test the withRetry pattern
|
|
116
|
+
const networkErrors = ['ENOTFOUND', 'ETIMEDOUT', 'ECONNRESET', 'EAI_AGAIN'];
|
|
117
|
+
|
|
118
|
+
for (const code of networkErrors) {
|
|
119
|
+
const error = new Error('Network error');
|
|
120
|
+
error.code = code;
|
|
121
|
+
|
|
122
|
+
expect(networkErrors.includes(error.code)).toBe(true);
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('should not retry on auth errors', () => {
|
|
127
|
+
const authCodes = [401, 403];
|
|
128
|
+
|
|
129
|
+
for (const code of authCodes) {
|
|
130
|
+
// Auth errors should not trigger retry
|
|
131
|
+
expect(authCodes.includes(code)).toBe(true);
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { extractSenderName } from '../src/notifier';
|
|
3
|
+
|
|
4
|
+
describe('Notifier', () => {
|
|
5
|
+
describe('extractSenderName', () => {
|
|
6
|
+
it('should extract name from "Name <email>" format', () => {
|
|
7
|
+
expect(extractSenderName('John Doe <john@example.com>')).toBe('John Doe');
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('should extract name from quoted format', () => {
|
|
11
|
+
expect(extractSenderName('"Jane Smith" <jane@example.com>')).toBe('Jane Smith');
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('should handle email-only format', () => {
|
|
15
|
+
expect(extractSenderName('user@example.com')).toBe('user');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('should handle undefined/null', () => {
|
|
19
|
+
expect(extractSenderName(undefined)).toBe('Unknown');
|
|
20
|
+
expect(extractSenderName(null)).toBe('Unknown');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('should handle complex names', () => {
|
|
24
|
+
expect(extractSenderName('Dr. John Smith Jr. <john@hospital.com>')).toBe('Dr. John Smith Jr.');
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
});
|
package/tests/setup.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { vi, beforeAll } from 'vitest';
|
|
2
|
+
|
|
3
|
+
// Mock modules using __mocks__ directory (no factory = uses manual mock)
|
|
4
|
+
vi.mock('@google-cloud/local-auth');
|
|
5
|
+
vi.mock('googleapis');
|
|
6
|
+
vi.mock('open', () => ({ default: vi.fn() }));
|
|
7
|
+
vi.mock('node-notifier', () => ({ notify: vi.fn() }));
|
|
8
|
+
|
|
9
|
+
// Also mock fs.promises.access to prevent "credentials.json not found" errors
|
|
10
|
+
// which would trigger the authenticate flow
|
|
11
|
+
beforeAll(() => {
|
|
12
|
+
// Prevent any process from spawning browsers
|
|
13
|
+
vi.stubGlobal('open', vi.fn());
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
console.log('Global Google/Open mocks applied');
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import os from 'os';
|
|
5
|
+
|
|
6
|
+
// Use a test-specific directory
|
|
7
|
+
const TEST_DIR = path.join(os.tmpdir(), 'inboxd-setup-test-' + Date.now());
|
|
8
|
+
|
|
9
|
+
describe('Credentials Validation', () => {
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
fs.mkdirSync(TEST_DIR, { recursive: true });
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
fs.rmSync(TEST_DIR, { recursive: true, force: true });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe('validateCredentialsFile', () => {
|
|
19
|
+
// Import the function dynamically to avoid mocking issues
|
|
20
|
+
const validateCredentialsFile = (filePath) => {
|
|
21
|
+
if (!fs.existsSync(filePath)) {
|
|
22
|
+
return { valid: false, error: 'File not found' };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
27
|
+
const json = JSON.parse(content);
|
|
28
|
+
|
|
29
|
+
if (!json.installed && !json.web) {
|
|
30
|
+
return {
|
|
31
|
+
valid: false,
|
|
32
|
+
error: 'Invalid format: missing "installed" or "web" key. Make sure you downloaded OAuth Desktop app credentials.',
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const key = json.installed || json.web;
|
|
37
|
+
if (!key.client_id || !key.client_secret) {
|
|
38
|
+
return {
|
|
39
|
+
valid: false,
|
|
40
|
+
error: 'Invalid format: missing client_id or client_secret',
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return { valid: true };
|
|
45
|
+
} catch (err) {
|
|
46
|
+
return { valid: false, error: `Invalid JSON: ${err.message}` };
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
it('should return error for non-existent file', () => {
|
|
51
|
+
const result = validateCredentialsFile('/nonexistent/path.json');
|
|
52
|
+
expect(result.valid).toBe(false);
|
|
53
|
+
expect(result.error).toBe('File not found');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('should return error for invalid JSON', () => {
|
|
57
|
+
const filePath = path.join(TEST_DIR, 'invalid.json');
|
|
58
|
+
fs.writeFileSync(filePath, 'not valid json');
|
|
59
|
+
|
|
60
|
+
const result = validateCredentialsFile(filePath);
|
|
61
|
+
expect(result.valid).toBe(false);
|
|
62
|
+
expect(result.error).toContain('Invalid JSON');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('should return error for JSON without installed or web key', () => {
|
|
66
|
+
const filePath = path.join(TEST_DIR, 'missing-key.json');
|
|
67
|
+
fs.writeFileSync(filePath, JSON.stringify({ foo: 'bar' }));
|
|
68
|
+
|
|
69
|
+
const result = validateCredentialsFile(filePath);
|
|
70
|
+
expect(result.valid).toBe(false);
|
|
71
|
+
expect(result.error).toContain('missing "installed" or "web" key');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('should return error for missing client_id', () => {
|
|
75
|
+
const filePath = path.join(TEST_DIR, 'missing-client-id.json');
|
|
76
|
+
fs.writeFileSync(filePath, JSON.stringify({
|
|
77
|
+
installed: { client_secret: 'secret' }
|
|
78
|
+
}));
|
|
79
|
+
|
|
80
|
+
const result = validateCredentialsFile(filePath);
|
|
81
|
+
expect(result.valid).toBe(false);
|
|
82
|
+
expect(result.error).toContain('missing client_id or client_secret');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('should return error for missing client_secret', () => {
|
|
86
|
+
const filePath = path.join(TEST_DIR, 'missing-client-secret.json');
|
|
87
|
+
fs.writeFileSync(filePath, JSON.stringify({
|
|
88
|
+
installed: { client_id: 'id' }
|
|
89
|
+
}));
|
|
90
|
+
|
|
91
|
+
const result = validateCredentialsFile(filePath);
|
|
92
|
+
expect(result.valid).toBe(false);
|
|
93
|
+
expect(result.error).toContain('missing client_id or client_secret');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('should validate correct installed credentials', () => {
|
|
97
|
+
const filePath = path.join(TEST_DIR, 'valid-installed.json');
|
|
98
|
+
fs.writeFileSync(filePath, JSON.stringify({
|
|
99
|
+
installed: {
|
|
100
|
+
client_id: 'test-client-id.apps.googleusercontent.com',
|
|
101
|
+
client_secret: 'test-secret',
|
|
102
|
+
redirect_uris: ['http://localhost']
|
|
103
|
+
}
|
|
104
|
+
}));
|
|
105
|
+
|
|
106
|
+
const result = validateCredentialsFile(filePath);
|
|
107
|
+
expect(result.valid).toBe(true);
|
|
108
|
+
expect(result.error).toBeUndefined();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('should validate correct web credentials', () => {
|
|
112
|
+
const filePath = path.join(TEST_DIR, 'valid-web.json');
|
|
113
|
+
fs.writeFileSync(filePath, JSON.stringify({
|
|
114
|
+
web: {
|
|
115
|
+
client_id: 'test-client-id.apps.googleusercontent.com',
|
|
116
|
+
client_secret: 'test-secret'
|
|
117
|
+
}
|
|
118
|
+
}));
|
|
119
|
+
|
|
120
|
+
const result = validateCredentialsFile(filePath);
|
|
121
|
+
expect(result.valid).toBe(true);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe('Path Resolution', () => {
|
|
127
|
+
const resolvePath = (filePath) => {
|
|
128
|
+
if (filePath.startsWith('~')) {
|
|
129
|
+
return path.join(os.homedir(), filePath.slice(1));
|
|
130
|
+
}
|
|
131
|
+
return path.resolve(filePath);
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
it('should expand ~ to home directory', () => {
|
|
135
|
+
const result = resolvePath('~/Documents/test.json');
|
|
136
|
+
expect(result).toBe(path.join(os.homedir(), 'Documents/test.json'));
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('should expand ~/ correctly', () => {
|
|
140
|
+
const result = resolvePath('~/test.json');
|
|
141
|
+
expect(result).toBe(path.join(os.homedir(), 'test.json'));
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('should resolve relative paths', () => {
|
|
145
|
+
const result = resolvePath('./test.json');
|
|
146
|
+
expect(result).toBe(path.resolve('./test.json'));
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('should keep absolute paths unchanged', () => {
|
|
150
|
+
const result = resolvePath('/absolute/path/test.json');
|
|
151
|
+
expect(result).toBe('/absolute/path/test.json');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('should handle paths with spaces', () => {
|
|
155
|
+
const result = resolvePath('~/My Documents/test file.json');
|
|
156
|
+
expect(result).toBe(path.join(os.homedir(), 'My Documents/test file.json'));
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('should handle quoted paths from drag-drop', () => {
|
|
160
|
+
// Simulate drag-drop which may add quotes
|
|
161
|
+
const input = '"/Users/test/My Documents/creds.json"';
|
|
162
|
+
const cleaned = input.replace(/['"]/g, '').trim();
|
|
163
|
+
expect(cleaned).toBe('/Users/test/My Documents/creds.json');
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe('Install Credentials', () => {
|
|
168
|
+
beforeEach(() => {
|
|
169
|
+
fs.mkdirSync(TEST_DIR, { recursive: true });
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
afterEach(() => {
|
|
173
|
+
fs.rmSync(TEST_DIR, { recursive: true, force: true });
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('should copy credentials file to destination', () => {
|
|
177
|
+
const sourcePath = path.join(TEST_DIR, 'source-creds.json');
|
|
178
|
+
const destDir = path.join(TEST_DIR, 'config');
|
|
179
|
+
const destPath = path.join(destDir, 'credentials.json');
|
|
180
|
+
|
|
181
|
+
// Create source file
|
|
182
|
+
const content = JSON.stringify({
|
|
183
|
+
installed: { client_id: 'id', client_secret: 'secret' }
|
|
184
|
+
});
|
|
185
|
+
fs.writeFileSync(sourcePath, content);
|
|
186
|
+
|
|
187
|
+
// Simulate installCredentials
|
|
188
|
+
fs.mkdirSync(destDir, { recursive: true });
|
|
189
|
+
fs.copyFileSync(sourcePath, destPath);
|
|
190
|
+
|
|
191
|
+
expect(fs.existsSync(destPath)).toBe(true);
|
|
192
|
+
expect(fs.readFileSync(destPath, 'utf8')).toBe(content);
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
describe('Platform Detection', () => {
|
|
197
|
+
it('should correctly identify darwin as macOS', () => {
|
|
198
|
+
const isMacOS = process.platform === 'darwin';
|
|
199
|
+
// This test documents the expected behavior
|
|
200
|
+
expect(typeof isMacOS).toBe('boolean');
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('should provide alternative instructions for non-macOS', () => {
|
|
204
|
+
const platform = 'linux';
|
|
205
|
+
const isMacOS = platform === 'darwin';
|
|
206
|
+
|
|
207
|
+
if (!isMacOS) {
|
|
208
|
+
// Should suggest cron as alternative
|
|
209
|
+
const cronExample = '*/5 * * * * /path/to/node /path/to/inbox check --quiet';
|
|
210
|
+
expect(cronExample).toContain('* * *');
|
|
211
|
+
expect(cronExample).toContain('check --quiet');
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import os from 'os';
|
|
5
|
+
|
|
6
|
+
const TEST_DIR = path.join(os.tmpdir(), 'inboxd-state-test-' + Date.now());
|
|
7
|
+
|
|
8
|
+
describe('State Management - Multi-Account', () => {
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
fs.mkdirSync(TEST_DIR, { recursive: true });
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
fs.rmSync(TEST_DIR, { recursive: true, force: true });
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe('Per-Account State Files', () => {
|
|
18
|
+
it('should generate unique state paths per account', () => {
|
|
19
|
+
const getStatePath = (account) => path.join(TEST_DIR, `state-${account}.json`);
|
|
20
|
+
|
|
21
|
+
expect(getStatePath('default')).toContain('state-default.json');
|
|
22
|
+
expect(getStatePath('work')).toContain('state-work.json');
|
|
23
|
+
expect(getStatePath('personal')).toContain('state-personal.json');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('should isolate state between accounts', () => {
|
|
27
|
+
const states = {
|
|
28
|
+
work: { seenEmailIds: [{ id: 'work1', timestamp: Date.now() }] },
|
|
29
|
+
personal: { seenEmailIds: [{ id: 'personal1', timestamp: Date.now() }] },
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// Write state files
|
|
33
|
+
for (const [account, state] of Object.entries(states)) {
|
|
34
|
+
const statePath = path.join(TEST_DIR, `state-${account}.json`);
|
|
35
|
+
fs.writeFileSync(statePath, JSON.stringify(state));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Read and verify isolation
|
|
39
|
+
const workState = JSON.parse(fs.readFileSync(path.join(TEST_DIR, 'state-work.json'), 'utf8'));
|
|
40
|
+
const personalState = JSON.parse(fs.readFileSync(path.join(TEST_DIR, 'state-personal.json'), 'utf8'));
|
|
41
|
+
|
|
42
|
+
expect(workState.seenEmailIds[0].id).toBe('work1');
|
|
43
|
+
expect(personalState.seenEmailIds[0].id).toBe('personal1');
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe('Seen Email Tracking', () => {
|
|
48
|
+
it('should track email IDs with timestamps', () => {
|
|
49
|
+
const now = Date.now();
|
|
50
|
+
const seenEntry = { id: 'email123', timestamp: now };
|
|
51
|
+
|
|
52
|
+
expect(seenEntry).toHaveProperty('id');
|
|
53
|
+
expect(seenEntry).toHaveProperty('timestamp');
|
|
54
|
+
expect(seenEntry.timestamp).toBeGreaterThan(0);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('should identify new vs seen emails', () => {
|
|
58
|
+
const seenIds = [
|
|
59
|
+
{ id: 'seen1', timestamp: Date.now() },
|
|
60
|
+
{ id: 'seen2', timestamp: Date.now() },
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
const incomingIds = ['seen1', 'new1', 'new2'];
|
|
64
|
+
|
|
65
|
+
const isEmailSeen = (id) => seenIds.some(item => item.id === id);
|
|
66
|
+
const newEmails = incomingIds.filter(id => !isEmailSeen(id));
|
|
67
|
+
|
|
68
|
+
expect(newEmails).toEqual(['new1', 'new2']);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('should prune old seen emails', () => {
|
|
72
|
+
const now = Date.now();
|
|
73
|
+
const sevenDaysAgo = now - (7 * 24 * 60 * 60 * 1000);
|
|
74
|
+
const tenDaysAgo = now - (10 * 24 * 60 * 60 * 1000);
|
|
75
|
+
|
|
76
|
+
const seenIds = [
|
|
77
|
+
{ id: 'recent', timestamp: now },
|
|
78
|
+
{ id: 'weekOld', timestamp: sevenDaysAgo + 1000 },
|
|
79
|
+
{ id: 'tooOld', timestamp: tenDaysAgo },
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
const cutoff = now - (7 * 24 * 60 * 60 * 1000);
|
|
83
|
+
const filtered = seenIds.filter(item => item.timestamp > cutoff);
|
|
84
|
+
|
|
85
|
+
expect(filtered).toHaveLength(2);
|
|
86
|
+
expect(filtered.map(i => i.id)).toContain('recent');
|
|
87
|
+
expect(filtered.map(i => i.id)).toContain('weekOld');
|
|
88
|
+
expect(filtered.map(i => i.id)).not.toContain('tooOld');
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
// Mock the state module
|
|
4
|
+
vi.mock('../src/state', () => {
|
|
5
|
+
let state = {
|
|
6
|
+
lastCheck: null,
|
|
7
|
+
seenEmailIds: [],
|
|
8
|
+
lastNotifiedAt: null,
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
return {
|
|
12
|
+
getState: vi.fn(() => ({ ...state })),
|
|
13
|
+
updateLastCheck: vi.fn(() => {
|
|
14
|
+
state.lastCheck = Date.now();
|
|
15
|
+
}),
|
|
16
|
+
markEmailsSeen: vi.fn((ids) => {
|
|
17
|
+
const now = Date.now();
|
|
18
|
+
ids.forEach((id) => {
|
|
19
|
+
if (!state.seenEmailIds.some((item) => item.id === id)) {
|
|
20
|
+
state.seenEmailIds.push({ id, timestamp: now });
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
}),
|
|
24
|
+
isEmailSeen: vi.fn((id) => {
|
|
25
|
+
return state.seenEmailIds.some((item) => item.id === id);
|
|
26
|
+
}),
|
|
27
|
+
clearOldSeenEmails: vi.fn(),
|
|
28
|
+
getNewEmailIds: vi.fn((ids) => {
|
|
29
|
+
return ids.filter((id) => !state.seenEmailIds.some((item) => item.id === id));
|
|
30
|
+
}),
|
|
31
|
+
__resetState: () => {
|
|
32
|
+
state = { lastCheck: null, seenEmailIds: [], lastNotifiedAt: null };
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe('State Management', () => {
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
vi.clearAllMocks();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('should track seen email IDs', async () => {
|
|
43
|
+
const { markEmailsSeen, getNewEmailIds } = await import('../src/state');
|
|
44
|
+
|
|
45
|
+
const emailIds = ['email1', 'email2', 'email3'];
|
|
46
|
+
|
|
47
|
+
// Initially none should be seen
|
|
48
|
+
const newIds = getNewEmailIds(emailIds);
|
|
49
|
+
expect(newIds).toEqual(emailIds);
|
|
50
|
+
|
|
51
|
+
// Mark some as seen
|
|
52
|
+
markEmailsSeen(['email1', 'email2']);
|
|
53
|
+
|
|
54
|
+
// Now only email3 should be new
|
|
55
|
+
const afterMark = getNewEmailIds(emailIds);
|
|
56
|
+
expect(afterMark).toEqual(['email3']);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('should update last check timestamp', async () => {
|
|
60
|
+
const { updateLastCheck } = await import('../src/state');
|
|
61
|
+
|
|
62
|
+
updateLastCheck();
|
|
63
|
+
|
|
64
|
+
expect(updateLastCheck).toHaveBeenCalled();
|
|
65
|
+
});
|
|
66
|
+
});
|
package/vitest.config.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { defineConfig } from 'vitest/config';
|
|
2
|
+
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
test: {
|
|
5
|
+
setupFiles: ['./tests/setup.js'],
|
|
6
|
+
globals: true,
|
|
7
|
+
environment: 'node',
|
|
8
|
+
// Ensure mocks in __mocks__ folder are used automatically
|
|
9
|
+
deps: {
|
|
10
|
+
interopDefault: true,
|
|
11
|
+
},
|
|
12
|
+
},
|
|
13
|
+
});
|
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
-
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
3
|
-
<plist version="1.0">
|
|
4
|
-
<dict>
|
|
5
|
-
<key>Label</key>
|
|
6
|
-
<string>com.danielparedes.inboxd</string>
|
|
7
|
-
|
|
8
|
-
<key>ProgramArguments</key>
|
|
9
|
-
<array>
|
|
10
|
-
<string>/usr/local/bin/node</string>
|
|
11
|
-
<string>/Users/danielparedes/Documents/Github/inboxd/src/cli.js</string>
|
|
12
|
-
<string>check</string>
|
|
13
|
-
<string>--quiet</string>
|
|
14
|
-
</array>
|
|
15
|
-
|
|
16
|
-
<key>WorkingDirectory</key>
|
|
17
|
-
<string>/Users/danielparedes/Documents/Github/inboxd</string>
|
|
18
|
-
|
|
19
|
-
<!-- Run every 5 minutes (300 seconds) -->
|
|
20
|
-
<key>StartInterval</key>
|
|
21
|
-
<integer>300</integer>
|
|
22
|
-
|
|
23
|
-
<!-- Run immediately when loaded -->
|
|
24
|
-
<key>RunAtLoad</key>
|
|
25
|
-
<true/>
|
|
26
|
-
|
|
27
|
-
<!-- Logging -->
|
|
28
|
-
<key>StandardOutPath</key>
|
|
29
|
-
<string>/tmp/inboxd.log</string>
|
|
30
|
-
<key>StandardErrorPath</key>
|
|
31
|
-
<string>/tmp/inboxd.error.log</string>
|
|
32
|
-
|
|
33
|
-
<!-- Environment variables -->
|
|
34
|
-
<key>EnvironmentVariables</key>
|
|
35
|
-
<dict>
|
|
36
|
-
<key>PATH</key>
|
|
37
|
-
<string>/usr/local/bin:/usr/bin:/bin</string>
|
|
38
|
-
</dict>
|
|
39
|
-
</dict>
|
|
40
|
-
</plist>
|
package/credentials.json
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"installed":{"client_id":"670632438932-arhht2o3bp94r0ug45o2accmu084eqfu.apps.googleusercontent.com","project_id":"daily-assistant-483211","auth_uri":"https://accounts.google.com/o/oauth2/auth","token_uri":"https://oauth2.googleapis.com/token","auth_provider_x509_cert_url":"https://www.googleapis.com/oauth2/v1/certs","client_secret":"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","redirect_uris":["http://localhost"]}}
|