@pingagent/sdk 0.1.7 → 0.1.9

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pingagent/sdk",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "license": "MIT",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -15,10 +15,18 @@
15
15
  "bin": {
16
16
  "pingagent": "bin/pingagent.js"
17
17
  },
18
+ "files": [
19
+ "dist",
20
+ "bin/pingagent.js"
21
+ ],
18
22
  "exports": {
19
23
  ".": {
20
24
  "types": "./dist/index.d.ts",
21
25
  "import": "./dist/index.js"
26
+ },
27
+ "./web-server": {
28
+ "types": "./dist/web-server.d.ts",
29
+ "import": "./dist/web-server.js"
22
30
  }
23
31
  },
24
32
  "dependencies": {
@@ -26,9 +34,9 @@
26
34
  "commander": "^13.0.0",
27
35
  "uuid": "^11.0.0",
28
36
  "ws": "^8.0.0",
29
- "@pingagent/schemas": "0.1.1",
37
+ "@pingagent/protocol": "0.1.1",
30
38
  "@pingagent/a2a": "0.1.1",
31
- "@pingagent/protocol": "0.1.1"
39
+ "@pingagent/schemas": "0.1.1"
32
40
  },
33
41
  "devDependencies": {
34
42
  "@types/better-sqlite3": "^7.6.0",
@@ -1,225 +0,0 @@
1
- import { describe, it, expect, beforeAll, afterAll } from 'vitest';
2
- import * as fs from 'node:fs';
3
- import * as os from 'node:os';
4
- import * as path from 'node:path';
5
- import { spawnSync, spawn } from 'node:child_process';
6
- import * as http from 'node:http';
7
- import * as net from 'node:net';
8
-
9
- const SDK_DIR = path.resolve(__dirname, '..');
10
- const CLI_BIN = path.join(SDK_DIR, 'bin', 'pingagent.js');
11
-
12
- function runCli(args: string[], env: Record<string, string> = {}): { stdout: string; stderr: string; status: number } {
13
- const result = spawnSync('node', [CLI_BIN, ...args], {
14
- cwd: SDK_DIR,
15
- encoding: 'utf-8',
16
- env: { ...process.env, ...env },
17
- stdio: ['ignore', 'pipe', 'pipe'],
18
- });
19
- return {
20
- stdout: result.stdout ?? '',
21
- stderr: result.stderr ?? '',
22
- status: result.status ?? -1,
23
- };
24
- }
25
-
26
- /** Use when the CLI will hit a server in this process (spawnSync would block the event loop). */
27
- function runCliAsync(
28
- args: string[],
29
- env: Record<string, string> = {},
30
- ): Promise<{ stdout: string; stderr: string; status: number }> {
31
- return new Promise((resolve) => {
32
- const child = spawn('node', [CLI_BIN, ...args], {
33
- cwd: SDK_DIR,
34
- env: { ...process.env, ...env },
35
- stdio: ['ignore', 'pipe', 'pipe'],
36
- });
37
- let stdout = '';
38
- let stderr = '';
39
- child.stdout?.on('data', (ch: Buffer) => { stdout += ch.toString(); });
40
- child.stderr?.on('data', (ch: Buffer) => { stderr += ch.toString(); });
41
- child.on('close', (code, signal) => {
42
- resolve({
43
- stdout,
44
- stderr,
45
- status: code ?? (signal ? -1 : 0),
46
- });
47
- });
48
- });
49
- }
50
-
51
- describe('CLI', () => {
52
- describe('help and version', () => {
53
- it('--help exits 0 and prints usage', () => {
54
- const { stdout, status } = runCli(['--help']);
55
- expect(status).toBe(0);
56
- expect(stdout).toContain('PingAgent');
57
- expect(stdout).toContain('init');
58
- expect(stdout).toContain('status');
59
- expect(stdout).toContain('--identity-dir');
60
- });
61
-
62
- it('-V prints version', () => {
63
- const { stdout, status } = runCli(['-V']);
64
- expect(status).toBe(0);
65
- expect(stdout.trim()).toMatch(/^\d+\.\d+\.\d+$/);
66
- });
67
- });
68
-
69
- describe('status without identity', () => {
70
- it('prints "No identity found" when no identity', () => {
71
- const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pingagent-cli-'));
72
- const identityPath = path.join(tmpDir, 'identity.json');
73
- const { stdout, status } = runCli(['status'], {
74
- PINGAGENT_IDENTITY_PATH: identityPath,
75
- });
76
- fs.rmSync(tmpDir, { recursive: true, force: true });
77
- expect(status).toBe(0);
78
- expect(stdout).toContain('No identity found');
79
- });
80
-
81
- it('--identity-dir uses that dir for identity and store', () => {
82
- const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pingagent-cli-dir-'));
83
- const { stdout, status } = runCli(['--identity-dir', tmpDir, 'status']);
84
- fs.rmSync(tmpDir, { recursive: true, force: true });
85
- expect(status).toBe(0);
86
- expect(stdout).toContain('No identity found');
87
- });
88
- });
89
-
90
- describe('contacts and history (local store only)', () => {
91
- it('contacts list with --identity-dir uses isolated store', () => {
92
- const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pingagent-cli-contacts-'));
93
- try {
94
- const { stdout, status } = runCli(['--identity-dir', tmpDir, 'contacts', 'list']);
95
- expect(status).toBe(0);
96
- expect(stdout).toContain('No contacts found');
97
- const storePath = path.join(tmpDir, 'store.db');
98
- expect(fs.existsSync(storePath)).toBe(true);
99
- } finally {
100
- fs.rmSync(tmpDir, { recursive: true, force: true });
101
- }
102
- });
103
-
104
- it('contacts add then list with isolated store', () => {
105
- const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pingagent-cli-add-'));
106
- try {
107
- let r = runCli(['--identity-dir', tmpDir, 'contacts', 'add', 'did:agent:test123', '--name', 'Test'], {});
108
- expect(r.status).toBe(0);
109
- r = runCli(['--identity-dir', tmpDir, 'contacts', 'list']);
110
- expect(r.status).toBe(0);
111
- expect(r.stdout).toContain('Test');
112
- expect(r.stdout).toContain('did:agent:test123');
113
- } finally {
114
- fs.rmSync(tmpDir, { recursive: true, force: true });
115
- }
116
- });
117
-
118
- it('history conversations with --identity-dir uses isolated store', () => {
119
- const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pingagent-cli-hist-'));
120
- try {
121
- const { stdout, status } = runCli(['--identity-dir', tmpDir, 'history', 'conversations']);
122
- expect(status).toBe(0);
123
- expect(stdout).toContain('No local history');
124
- } finally {
125
- fs.rmSync(tmpDir, { recursive: true, force: true });
126
- }
127
- });
128
- });
129
-
130
- describe('init with mock server', () => {
131
- let server: http.Server;
132
- let serverUrl: string;
133
-
134
- beforeAll(async () => {
135
- server = http.createServer((req, res) => {
136
- if (req.method === 'POST' && req.url === '/v1/agent/register') {
137
- let body = '';
138
- req.on('data', (ch) => { body += ch; });
139
- req.on('end', () => {
140
- res.writeHead(200, { 'Content-Type': 'application/json' });
141
- res.end(
142
- JSON.stringify({
143
- ok: true,
144
- data: {
145
- did: 'did:agent:mock123',
146
- access_token: 'mock_token',
147
- expires_ms: 3600000,
148
- mode: 'ghost',
149
- },
150
- }),
151
- );
152
- });
153
- return;
154
- }
155
- if (req.method === 'GET' && req.url === '/v1/subscription') {
156
- res.writeHead(200, { 'Content-Type': 'application/json' });
157
- res.end(
158
- JSON.stringify({
159
- ok: true,
160
- data: {
161
- tier: 'ghost',
162
- limits: { relay_per_day: 200, max_group_size: 0, artifact_storage_mb: 100, audit_export_allowed: false, alias_limit: 0 },
163
- usage: { relay_today: 0, relay_limit: 200, artifact_bytes: 0, artifact_limit_bytes: 104857600, alias_count: 0, alias_limit: 0 },
164
- has_stripe_customer: false,
165
- },
166
- }),
167
- );
168
- return;
169
- }
170
- res.writeHead(404);
171
- res.end();
172
- });
173
- await new Promise<void>((resolve) => {
174
- server.listen(0, '127.0.0.1', () => {
175
- const a = server.address() as net.AddressInfo;
176
- serverUrl = `http://127.0.0.1:${a.port}`;
177
- resolve();
178
- });
179
- });
180
- }, 10_000);
181
-
182
- afterAll(async () => {
183
- await new Promise<void>((resolve) => {
184
- server.close(() => resolve());
185
- });
186
- }, 5_000);
187
-
188
- it('init creates identity and store in identity-dir', async () => {
189
- const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pingagent-cli-init-'));
190
- try {
191
- const { stdout, status } = await runCliAsync(['--identity-dir', tmpDir, 'init', '--server', serverUrl]);
192
- expect(status).toBe(0);
193
- expect(stdout).toContain('PingAgent Chat Setup');
194
- expect(stdout).toContain('Ready!');
195
- // CLI prints server-returned DID; saved identity uses keypair-derived DID
196
- expect(stdout).toMatch(/did:agent:[A-Za-z0-9_-]+/);
197
-
198
- const identityPath = path.join(tmpDir, 'identity.json');
199
- expect(fs.existsSync(identityPath)).toBe(true);
200
- const identity = JSON.parse(fs.readFileSync(identityPath, 'utf-8'));
201
- expect(identity.did).toMatch(/^did:agent:/);
202
-
203
- const { stdout: statusOut, status: statusCode } = await runCliAsync(['--identity-dir', tmpDir, 'status']);
204
- expect(statusCode).toBe(0);
205
- expect(statusOut).toMatch(/DID: did:agent:/);
206
- expect(statusOut).toContain('Tier: ghost');
207
- } finally {
208
- fs.rmSync(tmpDir, { recursive: true, force: true });
209
- }
210
- }, 15_000);
211
-
212
- it('init idempotent: second init reports already exists', async () => {
213
- const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pingagent-cli-init2-'));
214
- try {
215
- await runCliAsync(['--identity-dir', tmpDir, 'init', '--server', serverUrl]);
216
- const { stdout, status } = await runCliAsync(['--identity-dir', tmpDir, 'init', '--server', serverUrl]);
217
- expect(status).toBe(0);
218
- expect(stdout).toContain('Identity already exists');
219
- expect(stdout).toMatch(/did:agent:[A-Za-z0-9_-]+/);
220
- } finally {
221
- fs.rmSync(tmpDir, { recursive: true, force: true });
222
- }
223
- }, 15_000);
224
- });
225
- });
@@ -1,47 +0,0 @@
1
- import { describe, it, expect } from 'vitest';
2
- import * as fs from 'node:fs';
3
- import * as os from 'node:os';
4
- import * as path from 'node:path';
5
- import { generateIdentity, saveIdentity, loadIdentity } from '../src/identity.js';
6
-
7
- describe('Identity Manager', () => {
8
- it('generates a valid identity', () => {
9
- const id = generateIdentity();
10
- expect(id.did).toMatch(/^did:agent:/);
11
- expect(id.deviceId).toMatch(/^dev_/);
12
- expect(id.publicKey.length).toBe(32);
13
- expect(id.privateKey.length).toBe(32);
14
- });
15
-
16
- it('saves and loads identity', () => {
17
- const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pingagent-test-'));
18
- const idPath = path.join(tmpDir, 'identity.json');
19
-
20
- const id = generateIdentity();
21
- saveIdentity(id, { serverUrl: 'http://localhost:8787', mode: 'ghost' }, idPath);
22
-
23
- const loaded = loadIdentity(idPath);
24
- expect(loaded.did).toBe(id.did);
25
- expect(loaded.deviceId).toBe(id.deviceId);
26
- expect(loaded.serverUrl).toBe('http://localhost:8787');
27
- expect(loaded.mode).toBe('ghost');
28
- expect(loaded.publicKey).toEqual(id.publicKey);
29
- expect(loaded.privateKey).toEqual(id.privateKey);
30
-
31
- fs.rmSync(tmpDir, { recursive: true });
32
- });
33
-
34
- it('sets file permissions to 600', () => {
35
- const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pingagent-test-'));
36
- const idPath = path.join(tmpDir, 'identity.json');
37
-
38
- const id = generateIdentity();
39
- saveIdentity(id, {}, idPath);
40
-
41
- const stats = fs.statSync(idPath);
42
- const mode = (stats.mode & 0o777).toString(8);
43
- expect(mode).toBe('600');
44
-
45
- fs.rmSync(tmpDir, { recursive: true });
46
- });
47
- });
@@ -1,332 +0,0 @@
1
- import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
- import * as fs from 'node:fs';
3
- import * as os from 'node:os';
4
- import * as path from 'node:path';
5
- import { LocalStore } from '../src/store.js';
6
- import { ContactManager, type Contact } from '../src/contacts.js';
7
- import { HistoryManager, type StoredMessage } from '../src/history.js';
8
-
9
- function createTempStore(): { store: LocalStore; dbPath: string; cleanup: () => void } {
10
- const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pingagent-store-test-'));
11
- const dbPath = path.join(tmpDir, 'store.db');
12
- const store = new LocalStore(dbPath);
13
- return { store, dbPath, cleanup: () => { store.close(); fs.rmSync(tmpDir, { recursive: true }); } };
14
- }
15
-
16
- describe('LocalStore', () => {
17
- let store: LocalStore;
18
- let cleanup: () => void;
19
-
20
- beforeEach(() => {
21
- const s = createTempStore();
22
- store = s.store;
23
- cleanup = s.cleanup;
24
- });
25
-
26
- afterEach(() => cleanup());
27
-
28
- it('creates tables on initialization', () => {
29
- const db = store.getDb();
30
- const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name").all() as Array<{ name: string }>;
31
- const names = tables.map(t => t.name);
32
- expect(names).toContain('contacts');
33
- expect(names).toContain('messages');
34
- expect(names).toContain('sync_state');
35
- });
36
-
37
- it('survives open/close/reopen', () => {
38
- const db = store.getDb();
39
- db.prepare("INSERT INTO contacts (did, trusted, added_at) VALUES ('did:agent:test', 1, 100)").run();
40
- const dbPath = db.name;
41
- store.close();
42
-
43
- const store2 = new LocalStore(dbPath);
44
- const row = store2.getDb().prepare("SELECT did FROM contacts").get() as { did: string };
45
- expect(row.did).toBe('did:agent:test');
46
- store2.close();
47
- });
48
-
49
- it('uses WAL journal mode', () => {
50
- const mode = store.getDb().pragma('journal_mode', { simple: true }) as string;
51
- expect(mode).toBe('wal');
52
- });
53
- });
54
-
55
- describe('ContactManager', () => {
56
- let store: LocalStore;
57
- let cm: ContactManager;
58
- let cleanup: () => void;
59
-
60
- beforeEach(() => {
61
- const s = createTempStore();
62
- store = s.store;
63
- cm = new ContactManager(store);
64
- cleanup = s.cleanup;
65
- });
66
-
67
- afterEach(() => cleanup());
68
-
69
- it('adds and retrieves a contact', () => {
70
- const contact = cm.add({ did: 'did:agent:alice', alias: 'alice', trusted: true });
71
- expect(contact.did).toBe('did:agent:alice');
72
- expect(contact.alias).toBe('alice');
73
- expect(contact.trusted).toBe(true);
74
- expect(contact.added_at).toBeGreaterThan(0);
75
-
76
- const fetched = cm.get('did:agent:alice');
77
- expect(fetched).not.toBeNull();
78
- expect(fetched!.alias).toBe('alice');
79
- expect(fetched!.trusted).toBe(true);
80
- });
81
-
82
- it('upserts on duplicate DID', () => {
83
- cm.add({ did: 'did:agent:bob', alias: 'bob1', trusted: false });
84
- cm.add({ did: 'did:agent:bob', alias: 'bob2', trusted: true });
85
- const fetched = cm.get('did:agent:bob');
86
- expect(fetched!.alias).toBe('bob2');
87
- expect(fetched!.trusted).toBe(true);
88
- });
89
-
90
- it('removes a contact', () => {
91
- cm.add({ did: 'did:agent:charlie', trusted: false });
92
- expect(cm.remove('did:agent:charlie')).toBe(true);
93
- expect(cm.get('did:agent:charlie')).toBeNull();
94
- expect(cm.remove('did:agent:nonexistent')).toBe(false);
95
- });
96
-
97
- it('updates partial fields', () => {
98
- cm.add({ did: 'did:agent:dave', alias: 'dave', notes: 'old', trusted: false });
99
- const updated = cm.update('did:agent:dave', { notes: 'new', trusted: true });
100
- expect(updated!.notes).toBe('new');
101
- expect(updated!.trusted).toBe(true);
102
- expect(updated!.alias).toBe('dave');
103
- });
104
-
105
- it('returns null when updating nonexistent contact', () => {
106
- const result = cm.update('did:agent:ghost', { alias: 'x' });
107
- expect(result).toBeNull();
108
- });
109
-
110
- it('lists contacts with filters', () => {
111
- cm.add({ did: 'did:agent:a', trusted: true, tags: ['dev'] });
112
- cm.add({ did: 'did:agent:b', trusted: false, tags: ['ops'] });
113
- cm.add({ did: 'did:agent:c', trusted: true, tags: ['dev', 'ops'] });
114
-
115
- expect(cm.list()).toHaveLength(3);
116
- expect(cm.list({ trusted: true })).toHaveLength(2);
117
- expect(cm.list({ trusted: false })).toHaveLength(1);
118
- expect(cm.list({ tag: 'dev' })).toHaveLength(2);
119
- expect(cm.list({ tag: 'ops' })).toHaveLength(2);
120
- expect(cm.list({ limit: 2 })).toHaveLength(2);
121
- });
122
-
123
- it('searches across did, alias, display_name, notes', () => {
124
- cm.add({ did: 'did:agent:searchme', trusted: false });
125
- cm.add({ did: 'did:agent:x', alias: 'findable', trusted: false });
126
- cm.add({ did: 'did:agent:y', display_name: 'Mr Find', trusted: false });
127
- cm.add({ did: 'did:agent:z', notes: 'hard to find', trusted: false });
128
-
129
- expect(cm.search('find')).toHaveLength(3);
130
- expect(cm.search('searchme')).toHaveLength(1);
131
- expect(cm.search('nothing')).toHaveLength(0);
132
- });
133
-
134
- it('exports and imports JSON', () => {
135
- cm.add({ did: 'did:agent:e1', alias: 'export1', trusted: true, tags: ['a'] });
136
- cm.add({ did: 'did:agent:e2', alias: 'export2', trusted: false });
137
-
138
- const json = cm.export('json');
139
- const parsed = JSON.parse(json);
140
- expect(parsed).toHaveLength(2);
141
-
142
- const s2 = createTempStore();
143
- const cm2 = new ContactManager(s2.store);
144
- const result = cm2.import(json, 'json');
145
- expect(result.imported).toBe(2);
146
- expect(result.skipped).toBe(0);
147
- expect(cm2.list()).toHaveLength(2);
148
- s2.cleanup();
149
- });
150
-
151
- it('exports and imports CSV', () => {
152
- cm.add({ did: 'did:agent:csv1', alias: 'c1', display_name: 'CSV One', trusted: true, tags: ['x'] });
153
- const csv = cm.export('csv');
154
- expect(csv).toContain('did,alias,display_name');
155
- expect(csv).toContain('did:agent:csv1');
156
-
157
- const s2 = createTempStore();
158
- const cm2 = new ContactManager(s2.store);
159
- const result = cm2.import(csv, 'csv');
160
- expect(result.imported).toBe(1);
161
- const fetched = cm2.get('did:agent:csv1');
162
- expect(fetched).not.toBeNull();
163
- expect(fetched!.alias).toBe('c1');
164
- s2.cleanup();
165
- });
166
- });
167
-
168
- describe('HistoryManager', () => {
169
- let store: LocalStore;
170
- let hm: HistoryManager;
171
- let cleanup: () => void;
172
-
173
- beforeEach(() => {
174
- const s = createTempStore();
175
- store = s.store;
176
- hm = new HistoryManager(store);
177
- cleanup = s.cleanup;
178
- });
179
-
180
- afterEach(() => cleanup());
181
-
182
- function makeMsg(overrides: Partial<StoredMessage> = {}): StoredMessage {
183
- return {
184
- conversation_id: 'c_dm_test',
185
- message_id: `m_${Math.random().toString(36).slice(2)}`,
186
- seq: 1,
187
- sender_did: 'did:agent:sender',
188
- schema: 'pingagent.text@1',
189
- payload: { text: 'hello' },
190
- ts_ms: Date.now(),
191
- direction: 'received',
192
- ...overrides,
193
- };
194
- }
195
-
196
- it('saves and lists messages', () => {
197
- const msgs = [
198
- makeMsg({ seq: 1, message_id: 'm_1' }),
199
- makeMsg({ seq: 2, message_id: 'm_2' }),
200
- makeMsg({ seq: 3, message_id: 'm_3' }),
201
- ];
202
- const count = hm.save(msgs);
203
- expect(count).toBe(3);
204
-
205
- const listed = hm.list('c_dm_test');
206
- expect(listed).toHaveLength(3);
207
- expect(listed[0].seq).toBe(1);
208
- expect(listed[2].seq).toBe(3);
209
- });
210
-
211
- it('upserts on duplicate message_id', () => {
212
- hm.save([makeMsg({ message_id: 'm_dup', seq: 1, payload: { text: 'v1' } })]);
213
- hm.save([makeMsg({ message_id: 'm_dup', seq: 2, payload: { text: 'v2' } })]);
214
-
215
- const listed = hm.list('c_dm_test');
216
- expect(listed).toHaveLength(1);
217
- expect(listed[0].seq).toBe(2);
218
- expect(listed[0].payload.text).toBe('v2');
219
- });
220
-
221
- it('lists with pagination (beforeSeq, afterSeq, limit)', () => {
222
- const msgs = Array.from({ length: 10 }, (_, i) =>
223
- makeMsg({ seq: i + 1, message_id: `m_${i + 1}` }),
224
- );
225
- hm.save(msgs);
226
-
227
- expect(hm.list('c_dm_test', { limit: 3 })).toHaveLength(3);
228
- expect(hm.list('c_dm_test', { afterSeq: 7 })).toHaveLength(3);
229
- expect(hm.list('c_dm_test', { beforeSeq: 4 })).toHaveLength(3);
230
- });
231
-
232
- it('searches by payload content', () => {
233
- hm.save([
234
- makeMsg({ message_id: 'm_s1', payload: { text: 'fix the bug' } }),
235
- makeMsg({ message_id: 'm_s2', payload: { text: 'deploy the app' } }),
236
- makeMsg({ message_id: 'm_s3', payload: { title: 'important bug fix' } }),
237
- ]);
238
-
239
- const results = hm.search('bug');
240
- expect(results).toHaveLength(2);
241
- expect(hm.search('deploy')).toHaveLength(1);
242
- expect(hm.search('nonexistent')).toHaveLength(0);
243
- });
244
-
245
- it('searches scoped to a conversation', () => {
246
- hm.save([
247
- makeMsg({ message_id: 'm_c1', conversation_id: 'c_dm_a', payload: { text: 'hello' } }),
248
- makeMsg({ message_id: 'm_c2', conversation_id: 'c_dm_b', payload: { text: 'hello there' } }),
249
- ]);
250
-
251
- expect(hm.search('hello')).toHaveLength(2);
252
- expect(hm.search('hello', { conversationId: 'c_dm_a' })).toHaveLength(1);
253
- });
254
-
255
- it('deletes all messages for a conversation', () => {
256
- hm.save([
257
- makeMsg({ message_id: 'm_d1', conversation_id: 'c_dm_del' }),
258
- makeMsg({ message_id: 'm_d2', conversation_id: 'c_dm_del' }),
259
- makeMsg({ message_id: 'm_d3', conversation_id: 'c_dm_keep' }),
260
- ]);
261
-
262
- const deleted = hm.delete('c_dm_del');
263
- expect(deleted).toBe(2);
264
- expect(hm.list('c_dm_del')).toHaveLength(0);
265
- expect(hm.list('c_dm_keep')).toHaveLength(1);
266
- });
267
-
268
- it('lists conversations with aggregates', () => {
269
- hm.save([
270
- makeMsg({ message_id: 'm_lc1', conversation_id: 'c_dm_x', ts_ms: 1000 }),
271
- makeMsg({ message_id: 'm_lc2', conversation_id: 'c_dm_x', ts_ms: 2000 }),
272
- makeMsg({ message_id: 'm_lc3', conversation_id: 'c_dm_y', ts_ms: 3000 }),
273
- ]);
274
-
275
- const convos = hm.listConversations();
276
- expect(convos).toHaveLength(2);
277
-
278
- const x = convos.find(c => c.conversation_id === 'c_dm_x')!;
279
- expect(x.message_count).toBe(2);
280
- expect(x.last_message_at).toBe(2000);
281
-
282
- const y = convos.find(c => c.conversation_id === 'c_dm_y')!;
283
- expect(y.message_count).toBe(1);
284
- });
285
-
286
- it('manages sync state', () => {
287
- expect(hm.getLastSyncedSeq('c_dm_sync')).toBe(0);
288
-
289
- hm.setLastSyncedSeq('c_dm_sync', 42);
290
- expect(hm.getLastSyncedSeq('c_dm_sync')).toBe(42);
291
-
292
- hm.setLastSyncedSeq('c_dm_sync', 100);
293
- expect(hm.getLastSyncedSeq('c_dm_sync')).toBe(100);
294
- });
295
-
296
- it('exports JSON', () => {
297
- hm.save([
298
- makeMsg({ message_id: 'm_ej1', conversation_id: 'c_dm_exp', seq: 1 }),
299
- makeMsg({ message_id: 'm_ej2', conversation_id: 'c_dm_exp', seq: 2 }),
300
- ]);
301
-
302
- const json = hm.export({ conversationId: 'c_dm_exp', format: 'json' });
303
- const parsed = JSON.parse(json);
304
- expect(parsed).toHaveLength(2);
305
- expect(parsed[0].message_id).toBe('m_ej1');
306
- });
307
-
308
- it('exports CSV', () => {
309
- hm.save([makeMsg({ message_id: 'm_ec1', conversation_id: 'c_dm_exp2' })]);
310
- const csv = hm.export({ conversationId: 'c_dm_exp2', format: 'csv' });
311
- expect(csv).toContain('conversation_id,message_id');
312
- expect(csv).toContain('m_ec1');
313
- });
314
-
315
- it('exports all conversations when no conversationId specified', () => {
316
- hm.save([
317
- makeMsg({ message_id: 'm_all1', conversation_id: 'c_dm_1' }),
318
- makeMsg({ message_id: 'm_all2', conversation_id: 'c_dm_2' }),
319
- ]);
320
- const json = hm.export({ format: 'json' });
321
- const parsed = JSON.parse(json);
322
- expect(parsed).toHaveLength(2);
323
- });
324
-
325
- it('delete also clears sync state', () => {
326
- hm.save([makeMsg({ message_id: 'm_ss', conversation_id: 'c_dm_ss' })]);
327
- hm.setLastSyncedSeq('c_dm_ss', 50);
328
-
329
- hm.delete('c_dm_ss');
330
- expect(hm.getLastSyncedSeq('c_dm_ss')).toBe(0);
331
- });
332
- });