inboxd 1.0.11 → 1.0.12
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/.claude/skills/inbox-assistant/SKILL.md +99 -0
- package/CLAUDE.md +15 -0
- package/package.json +1 -1
- package/src/cli.js +364 -3
- package/src/gmail-monitor.js +364 -0
- package/src/sent-log.js +87 -0
- package/tests/gmail-monitor-patterns.test.js +232 -0
- package/tests/link-extraction.test.js +249 -0
- package/tests/older-than.test.js +127 -0
- package/tests/sent-log.test.js +142 -0
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
|
|
3
|
+
const { extractLinks, isValidUrl, decodeHtmlEntities, extractBody } = require('../src/gmail-monitor');
|
|
4
|
+
|
|
5
|
+
describe('Link Extraction', () => {
|
|
6
|
+
|
|
7
|
+
describe('isValidUrl', () => {
|
|
8
|
+
it('should accept http URLs', () => {
|
|
9
|
+
expect(isValidUrl('http://example.com')).toBe(true);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('should accept https URLs', () => {
|
|
13
|
+
expect(isValidUrl('https://example.com')).toBe(true);
|
|
14
|
+
expect(isValidUrl('https://example.com/path?query=1')).toBe(true);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('should reject javascript: URLs', () => {
|
|
18
|
+
expect(isValidUrl('javascript:void(0)')).toBe(false);
|
|
19
|
+
expect(isValidUrl('javascript:alert("hi")')).toBe(false);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('should reject data: URLs', () => {
|
|
23
|
+
expect(isValidUrl('data:text/html,<h1>Hello</h1>')).toBe(false);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('should reject mailto: URLs', () => {
|
|
27
|
+
expect(isValidUrl('mailto:test@example.com')).toBe(false);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('should reject tel: URLs', () => {
|
|
31
|
+
expect(isValidUrl('tel:+1234567890')).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('should reject file: URLs', () => {
|
|
35
|
+
expect(isValidUrl('file:///etc/passwd')).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('should handle null/undefined/empty', () => {
|
|
39
|
+
expect(isValidUrl(null)).toBe(false);
|
|
40
|
+
expect(isValidUrl(undefined)).toBe(false);
|
|
41
|
+
expect(isValidUrl('')).toBe(false);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('should be case insensitive', () => {
|
|
45
|
+
expect(isValidUrl('HTTP://EXAMPLE.COM')).toBe(true);
|
|
46
|
+
expect(isValidUrl('HTTPS://EXAMPLE.COM')).toBe(true);
|
|
47
|
+
expect(isValidUrl('JAVASCRIPT:void(0)')).toBe(false);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe('decodeHtmlEntities', () => {
|
|
52
|
+
it('should decode &', () => {
|
|
53
|
+
expect(decodeHtmlEntities('a&b')).toBe('a&b');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('should decode < and >', () => {
|
|
57
|
+
expect(decodeHtmlEntities('<tag>')).toBe('<tag>');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('should decode "', () => {
|
|
61
|
+
expect(decodeHtmlEntities('"hello"')).toBe('"hello"');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('should decode '', () => {
|
|
65
|
+
expect(decodeHtmlEntities('it's')).toBe("it's");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('should handle empty/null', () => {
|
|
69
|
+
expect(decodeHtmlEntities('')).toBe('');
|
|
70
|
+
expect(decodeHtmlEntities(null)).toBe('');
|
|
71
|
+
expect(decodeHtmlEntities(undefined)).toBe('');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('should decode multiple entities', () => {
|
|
75
|
+
expect(decodeHtmlEntities('a&b<c')).toBe('a&b<c');
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('extractLinks from HTML', () => {
|
|
80
|
+
it('should extract href from anchor tags', () => {
|
|
81
|
+
const html = '<a href="https://example.com">Click here</a>';
|
|
82
|
+
const links = extractLinks(html, 'text/html');
|
|
83
|
+
expect(links).toHaveLength(1);
|
|
84
|
+
expect(links[0].url).toBe('https://example.com');
|
|
85
|
+
expect(links[0].text).toBe('Click here');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('should extract multiple links', () => {
|
|
89
|
+
const html = `
|
|
90
|
+
<a href="https://example.com/1">Link 1</a>
|
|
91
|
+
<a href="https://example.com/2">Link 2</a>
|
|
92
|
+
`;
|
|
93
|
+
const links = extractLinks(html, 'text/html');
|
|
94
|
+
expect(links).toHaveLength(2);
|
|
95
|
+
expect(links[0].url).toBe('https://example.com/1');
|
|
96
|
+
expect(links[1].url).toBe('https://example.com/2');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('should deduplicate URLs', () => {
|
|
100
|
+
const html = `
|
|
101
|
+
<a href="https://example.com">First</a>
|
|
102
|
+
<a href="https://example.com">Second</a>
|
|
103
|
+
`;
|
|
104
|
+
const links = extractLinks(html, 'text/html');
|
|
105
|
+
expect(links).toHaveLength(1);
|
|
106
|
+
expect(links[0].text).toBe('First'); // First occurrence wins
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('should filter out javascript: URLs', () => {
|
|
110
|
+
const html = `
|
|
111
|
+
<a href="javascript:alert('hi')">JS</a>
|
|
112
|
+
<a href="https://real.com">Real</a>
|
|
113
|
+
`;
|
|
114
|
+
const links = extractLinks(html, 'text/html');
|
|
115
|
+
expect(links).toHaveLength(1);
|
|
116
|
+
expect(links[0].url).toBe('https://real.com');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('should filter out mailto: URLs', () => {
|
|
120
|
+
const html = `
|
|
121
|
+
<a href="mailto:test@example.com">Email</a>
|
|
122
|
+
<a href="https://real.com">Real</a>
|
|
123
|
+
`;
|
|
124
|
+
const links = extractLinks(html, 'text/html');
|
|
125
|
+
expect(links).toHaveLength(1);
|
|
126
|
+
expect(links[0].url).toBe('https://real.com');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('should decode HTML entities in URLs', () => {
|
|
130
|
+
const html = '<a href="https://example.com?a=1&b=2">Link</a>';
|
|
131
|
+
const links = extractLinks(html, 'text/html');
|
|
132
|
+
expect(links).toHaveLength(1);
|
|
133
|
+
expect(links[0].url).toBe('https://example.com?a=1&b=2');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('should handle links with extra attributes', () => {
|
|
137
|
+
const html = '<a class="btn" href="https://example.com" target="_blank">Link</a>';
|
|
138
|
+
const links = extractLinks(html, 'text/html');
|
|
139
|
+
expect(links).toHaveLength(1);
|
|
140
|
+
expect(links[0].url).toBe('https://example.com');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('should also extract plain URLs not in anchor tags', () => {
|
|
144
|
+
const html = '<p>Visit https://plain-url.com for more info</p>';
|
|
145
|
+
const links = extractLinks(html, 'text/html');
|
|
146
|
+
expect(links).toHaveLength(1);
|
|
147
|
+
expect(links[0].url).toBe('https://plain-url.com');
|
|
148
|
+
expect(links[0].text).toBe(null);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe('extractLinks from plain text', () => {
|
|
153
|
+
it('should extract URLs from plain text', () => {
|
|
154
|
+
const text = 'Check out https://example.com for more info';
|
|
155
|
+
const links = extractLinks(text, 'text/plain');
|
|
156
|
+
expect(links).toHaveLength(1);
|
|
157
|
+
expect(links[0].url).toBe('https://example.com');
|
|
158
|
+
expect(links[0].text).toBe(null);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('should handle URLs with trailing punctuation', () => {
|
|
162
|
+
const text = 'Visit https://example.com, or https://other.com.';
|
|
163
|
+
const links = extractLinks(text, 'text/plain');
|
|
164
|
+
expect(links).toHaveLength(2);
|
|
165
|
+
expect(links[0].url).toBe('https://example.com');
|
|
166
|
+
expect(links[1].url).toBe('https://other.com');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('should handle URLs with query strings', () => {
|
|
170
|
+
const text = 'Link: https://example.com/path?foo=bar&baz=qux';
|
|
171
|
+
const links = extractLinks(text, 'text/plain');
|
|
172
|
+
expect(links).toHaveLength(1);
|
|
173
|
+
expect(links[0].url).toBe('https://example.com/path?foo=bar&baz=qux');
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('should handle multiple URLs on same line', () => {
|
|
177
|
+
const text = 'See https://one.com and https://two.com';
|
|
178
|
+
const links = extractLinks(text, 'text/plain');
|
|
179
|
+
expect(links).toHaveLength(2);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('should return empty array for no links', () => {
|
|
183
|
+
const text = 'No links here, just plain text';
|
|
184
|
+
const links = extractLinks(text, 'text/plain');
|
|
185
|
+
expect(links).toHaveLength(0);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('should handle empty body', () => {
|
|
189
|
+
expect(extractLinks('', 'text/plain')).toHaveLength(0);
|
|
190
|
+
expect(extractLinks(null, 'text/plain')).toHaveLength(0);
|
|
191
|
+
expect(extractLinks(undefined, 'text/plain')).toHaveLength(0);
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
describe('extractBody with preferHtml option', () => {
|
|
196
|
+
it('should prefer text/plain by default', () => {
|
|
197
|
+
const payload = {
|
|
198
|
+
mimeType: 'multipart/alternative',
|
|
199
|
+
parts: [
|
|
200
|
+
{
|
|
201
|
+
mimeType: 'text/html',
|
|
202
|
+
body: { data: Buffer.from('<b>HTML</b>').toString('base64url') }
|
|
203
|
+
},
|
|
204
|
+
{
|
|
205
|
+
mimeType: 'text/plain',
|
|
206
|
+
body: { data: Buffer.from('Plain text').toString('base64url') }
|
|
207
|
+
}
|
|
208
|
+
]
|
|
209
|
+
};
|
|
210
|
+
const result = extractBody(payload);
|
|
211
|
+
expect(result.content).toBe('Plain text');
|
|
212
|
+
expect(result.type).toBe('text/plain');
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('should prefer text/html when preferHtml is true', () => {
|
|
216
|
+
const payload = {
|
|
217
|
+
mimeType: 'multipart/alternative',
|
|
218
|
+
parts: [
|
|
219
|
+
{
|
|
220
|
+
mimeType: 'text/html',
|
|
221
|
+
body: { data: Buffer.from('<b>HTML</b>').toString('base64url') }
|
|
222
|
+
},
|
|
223
|
+
{
|
|
224
|
+
mimeType: 'text/plain',
|
|
225
|
+
body: { data: Buffer.from('Plain text').toString('base64url') }
|
|
226
|
+
}
|
|
227
|
+
]
|
|
228
|
+
};
|
|
229
|
+
const result = extractBody(payload, { preferHtml: true });
|
|
230
|
+
expect(result.content).toBe('<b>HTML</b>');
|
|
231
|
+
expect(result.type).toBe('text/html');
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('should fallback to text/plain if no HTML available', () => {
|
|
235
|
+
const payload = {
|
|
236
|
+
mimeType: 'multipart/alternative',
|
|
237
|
+
parts: [
|
|
238
|
+
{
|
|
239
|
+
mimeType: 'text/plain',
|
|
240
|
+
body: { data: Buffer.from('Plain text').toString('base64url') }
|
|
241
|
+
}
|
|
242
|
+
]
|
|
243
|
+
};
|
|
244
|
+
const result = extractBody(payload, { preferHtml: true });
|
|
245
|
+
expect(result.content).toBe('Plain text');
|
|
246
|
+
expect(result.type).toBe('text/plain');
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
});
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
|
|
3
|
+
// Test the older-than parsing logic used in inbox analyze command
|
|
4
|
+
// We test the logic patterns directly since the function is not exported
|
|
5
|
+
|
|
6
|
+
describe('Older Than Duration Parsing', () => {
|
|
7
|
+
/**
|
|
8
|
+
* Parsing function matching the implementation in cli.js
|
|
9
|
+
* Gmail only supports days (d) for older_than, so we convert weeks/months to days
|
|
10
|
+
*/
|
|
11
|
+
function parseOlderThanDuration(duration) {
|
|
12
|
+
const match = duration.match(/^(\d+)([dwm])$/i);
|
|
13
|
+
if (!match) {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const value = parseInt(match[1], 10);
|
|
18
|
+
const unit = match[2].toLowerCase();
|
|
19
|
+
|
|
20
|
+
switch (unit) {
|
|
21
|
+
case 'd': // days
|
|
22
|
+
return `${value}d`;
|
|
23
|
+
case 'w': // weeks -> days
|
|
24
|
+
return `${value * 7}d`;
|
|
25
|
+
case 'm': // months (approximate as 30 days)
|
|
26
|
+
return `${value * 30}d`;
|
|
27
|
+
default:
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('Days parsing', () => {
|
|
33
|
+
it('should parse days correctly', () => {
|
|
34
|
+
expect(parseOlderThanDuration('30d')).toBe('30d');
|
|
35
|
+
expect(parseOlderThanDuration('7d')).toBe('7d');
|
|
36
|
+
expect(parseOlderThanDuration('1d')).toBe('1d');
|
|
37
|
+
expect(parseOlderThanDuration('90d')).toBe('90d');
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe('Weeks conversion', () => {
|
|
42
|
+
it('should convert weeks to days', () => {
|
|
43
|
+
expect(parseOlderThanDuration('2w')).toBe('14d');
|
|
44
|
+
expect(parseOlderThanDuration('1w')).toBe('7d');
|
|
45
|
+
expect(parseOlderThanDuration('4w')).toBe('28d');
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe('Months conversion', () => {
|
|
50
|
+
it('should convert months to days (approx 30)', () => {
|
|
51
|
+
expect(parseOlderThanDuration('1m')).toBe('30d');
|
|
52
|
+
expect(parseOlderThanDuration('3m')).toBe('90d');
|
|
53
|
+
expect(parseOlderThanDuration('6m')).toBe('180d');
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('Case insensitivity', () => {
|
|
58
|
+
it('should handle uppercase units', () => {
|
|
59
|
+
expect(parseOlderThanDuration('30D')).toBe('30d');
|
|
60
|
+
expect(parseOlderThanDuration('2W')).toBe('14d');
|
|
61
|
+
expect(parseOlderThanDuration('1M')).toBe('30d');
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe('Invalid formats', () => {
|
|
66
|
+
it('should return null for number without unit', () => {
|
|
67
|
+
expect(parseOlderThanDuration('30')).toBe(null);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('should return null for unit without number', () => {
|
|
71
|
+
expect(parseOlderThanDuration('d')).toBe(null);
|
|
72
|
+
expect(parseOlderThanDuration('days')).toBe(null);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('should return null for unsupported units', () => {
|
|
76
|
+
expect(parseOlderThanDuration('30h')).toBe(null); // hours not supported
|
|
77
|
+
expect(parseOlderThanDuration('30s')).toBe(null); // seconds not supported
|
|
78
|
+
expect(parseOlderThanDuration('30y')).toBe(null); // years not supported
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('should return null for empty/invalid strings', () => {
|
|
82
|
+
expect(parseOlderThanDuration('')).toBe(null);
|
|
83
|
+
expect(parseOlderThanDuration('abc')).toBe(null);
|
|
84
|
+
expect(parseOlderThanDuration('30 d')).toBe(null); // space not allowed
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe('Gmail Query Building for Older Than', () => {
|
|
90
|
+
/**
|
|
91
|
+
* Helper to simulate query building logic from cli.js analyze command
|
|
92
|
+
*/
|
|
93
|
+
function buildQuery(includeRead, olderThanDays) {
|
|
94
|
+
const baseQuery = includeRead ? '' : 'is:unread';
|
|
95
|
+
if (!olderThanDays) {
|
|
96
|
+
return baseQuery;
|
|
97
|
+
}
|
|
98
|
+
const olderThanQuery = `older_than:${olderThanDays}`;
|
|
99
|
+
return baseQuery ? `${baseQuery} ${olderThanQuery}` : olderThanQuery;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
it('should build correct query for unread + older_than', () => {
|
|
103
|
+
const query = buildQuery(false, '30d');
|
|
104
|
+
expect(query).toBe('is:unread older_than:30d');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('should build correct query for all + older_than', () => {
|
|
108
|
+
const query = buildQuery(true, '30d');
|
|
109
|
+
expect(query).toBe('older_than:30d');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('should build correct query for unread only (no older_than)', () => {
|
|
113
|
+
const query = buildQuery(false, null);
|
|
114
|
+
expect(query).toBe('is:unread');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('should build correct query for all (no older_than)', () => {
|
|
118
|
+
const query = buildQuery(true, null);
|
|
119
|
+
expect(query).toBe('');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('should work with week/month converted to days', () => {
|
|
123
|
+
// 2 weeks = 14 days
|
|
124
|
+
const query = buildQuery(false, '14d');
|
|
125
|
+
expect(query).toBe('is:unread older_than:14d');
|
|
126
|
+
});
|
|
127
|
+
});
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
|
|
5
|
+
// Test the logic patterns used by sent-log
|
|
6
|
+
// Similar structure to deletion-log.test.js
|
|
7
|
+
|
|
8
|
+
describe('Sent Log', () => {
|
|
9
|
+
const LOG_DIR = path.join(os.homedir(), '.config', 'inboxd');
|
|
10
|
+
const LOG_FILE = path.join(LOG_DIR, 'sent-log.json');
|
|
11
|
+
|
|
12
|
+
describe('Log entry structure', () => {
|
|
13
|
+
it('should have required fields for sent email tracking', () => {
|
|
14
|
+
const logEntry = {
|
|
15
|
+
sentAt: '2026-01-03T15:45:00.000Z',
|
|
16
|
+
account: 'dparedesi@uni.pe',
|
|
17
|
+
id: '19b84376ff5f5ed2',
|
|
18
|
+
threadId: '19b84376ff5f5ed2',
|
|
19
|
+
to: 'recipient@example.com',
|
|
20
|
+
subject: 'Test Subject',
|
|
21
|
+
bodyPreview: 'First 200 chars of body...',
|
|
22
|
+
replyToId: null,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
expect(logEntry).toHaveProperty('sentAt');
|
|
26
|
+
expect(logEntry).toHaveProperty('account');
|
|
27
|
+
expect(logEntry).toHaveProperty('id');
|
|
28
|
+
expect(logEntry).toHaveProperty('threadId');
|
|
29
|
+
expect(logEntry).toHaveProperty('to');
|
|
30
|
+
expect(logEntry).toHaveProperty('subject');
|
|
31
|
+
expect(logEntry).toHaveProperty('bodyPreview');
|
|
32
|
+
expect(logEntry).toHaveProperty('replyToId');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('should track reply-to for replies', () => {
|
|
36
|
+
const replyEntry = {
|
|
37
|
+
sentAt: new Date().toISOString(),
|
|
38
|
+
account: 'test',
|
|
39
|
+
id: 'new-id',
|
|
40
|
+
threadId: 'thread-id',
|
|
41
|
+
to: 'sender@example.com',
|
|
42
|
+
subject: 'Re: Original Subject',
|
|
43
|
+
bodyPreview: 'My reply...',
|
|
44
|
+
replyToId: 'original-message-id',
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
expect(replyEntry.replyToId).toBe('original-message-id');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('should have null replyToId for new messages', () => {
|
|
51
|
+
const newEmailEntry = {
|
|
52
|
+
sentAt: new Date().toISOString(),
|
|
53
|
+
account: 'test',
|
|
54
|
+
id: 'new-id',
|
|
55
|
+
threadId: 'thread-id',
|
|
56
|
+
to: 'recipient@example.com',
|
|
57
|
+
subject: 'New Subject',
|
|
58
|
+
bodyPreview: 'Body...',
|
|
59
|
+
replyToId: null,
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
expect(newEmailEntry.replyToId).toBeNull();
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe('Body preview logic', () => {
|
|
67
|
+
it('should truncate body to 200 chars', () => {
|
|
68
|
+
const longBody = 'A'.repeat(500);
|
|
69
|
+
const preview = longBody.substring(0, 200);
|
|
70
|
+
|
|
71
|
+
expect(preview).toHaveLength(200);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('should not truncate short body', () => {
|
|
75
|
+
const shortBody = 'Short message';
|
|
76
|
+
const preview = shortBody.substring(0, 200);
|
|
77
|
+
|
|
78
|
+
expect(preview).toBe('Short message');
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe('Log filtering', () => {
|
|
83
|
+
it('should filter entries by date range', () => {
|
|
84
|
+
const now = new Date();
|
|
85
|
+
const tenDaysAgo = new Date(now);
|
|
86
|
+
tenDaysAgo.setDate(tenDaysAgo.getDate() - 10);
|
|
87
|
+
const fortyDaysAgo = new Date(now);
|
|
88
|
+
fortyDaysAgo.setDate(fortyDaysAgo.getDate() - 40);
|
|
89
|
+
|
|
90
|
+
const entries = [
|
|
91
|
+
{ sentAt: now.toISOString(), id: 'recent' },
|
|
92
|
+
{ sentAt: tenDaysAgo.toISOString(), id: 'ten-days' },
|
|
93
|
+
{ sentAt: fortyDaysAgo.toISOString(), id: 'forty-days' },
|
|
94
|
+
];
|
|
95
|
+
|
|
96
|
+
const cutoff = new Date();
|
|
97
|
+
cutoff.setDate(cutoff.getDate() - 30);
|
|
98
|
+
|
|
99
|
+
const recentEntries = entries.filter((e) => new Date(e.sentAt) >= cutoff);
|
|
100
|
+
|
|
101
|
+
expect(recentEntries).toHaveLength(2);
|
|
102
|
+
expect(recentEntries.map(e => e.id)).toContain('recent');
|
|
103
|
+
expect(recentEntries.map(e => e.id)).toContain('ten-days');
|
|
104
|
+
expect(recentEntries.map(e => e.id)).not.toContain('forty-days');
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe('Log path', () => {
|
|
109
|
+
it('should use correct log directory path', () => {
|
|
110
|
+
expect(LOG_DIR).toContain('.config');
|
|
111
|
+
expect(LOG_DIR).toContain('inboxd');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('should use correct log file name', () => {
|
|
115
|
+
expect(LOG_FILE).toContain('sent-log.json');
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe('Empty log handling', () => {
|
|
120
|
+
it('should return empty array for empty log', () => {
|
|
121
|
+
const emptyLog = [];
|
|
122
|
+
expect(emptyLog).toHaveLength(0);
|
|
123
|
+
expect(Array.isArray(emptyLog)).toBe(true);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('should handle missing log file gracefully', () => {
|
|
127
|
+
// Simulate the readSentLog behavior when file doesn't exist
|
|
128
|
+
const readLogSafe = (fileExists, fileContent) => {
|
|
129
|
+
if (!fileExists) return [];
|
|
130
|
+
try {
|
|
131
|
+
return JSON.parse(fileContent);
|
|
132
|
+
} catch {
|
|
133
|
+
return [];
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
expect(readLogSafe(false, '')).toEqual([]);
|
|
138
|
+
expect(readLogSafe(true, 'invalid json')).toEqual([]);
|
|
139
|
+
expect(readLogSafe(true, '[]')).toEqual([]);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
});
|