blob: b8595a3998d7261bddb4d9d7e4b9d964139db888 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { DatabaseSync } from 'node:sqlite';
import { mkdtempSync, mkdirSync, rmSync, statSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { MessageCache, hash } from '../src/message-cache.js';
import { MessageSync } from '../src/message-sync.js';
import { messageObject } from '../src/adapter.js';
const config = {
instanceId: 'local',
accountId: 'user',
appId: 'app',
containers: [],
startTime: 0,
};
const key = Buffer.alloc(32, 7).toString('base64url');
const caller = () => ({ invocation: { abortSignal: new AbortController().signal } });
const raw = (id: string, time = 2000, content = id, chat = 'chat') => ({
message_id: id,
chat_id: chat,
create_time: String(time),
msg_type: 'text',
body: { content },
});
// Deliberately independent DDL: tests open a pre-existing local format, not a cache created by the implementation.
const ddl = `CREATE TABLE sync_jobs(id TEXT PRIMARY KEY,request TEXT NOT NULL,status TEXT NOT NULL,payload TEXT NOT NULL);
CREATE TABLE sync_chats(job TEXT NOT NULL,id TEXT NOT NULL,payload TEXT NOT NULL,PRIMARY KEY(job,id));
CREATE TABLE originals(id TEXT NOT NULL,revision TEXT NOT NULL,object TEXT NOT NULL,body TEXT NOT NULL,PRIMARY KEY(id,revision));
CREATE TABLE sync_members(job TEXT NOT NULL,id TEXT NOT NULL,revision TEXT NOT NULL,PRIMARY KEY(job,id));
CREATE TABLE sync_tokens(job TEXT NOT NULL,kind TEXT NOT NULL,token TEXT NOT NULL,PRIMARY KEY(job,kind,token));
CREATE TABLE publications(seq INTEGER PRIMARY KEY AUTOINCREMENT,id TEXT NOT NULL,revision TEXT NOT NULL,created_at INTEGER NOT NULL,kind TEXT NOT NULL,object TEXT NOT NULL);
CREATE INDEX publication_object ON publications(id,seq);
CREATE TABLE heads(id TEXT PRIMARY KEY,seq INTEGER NOT NULL);`;
function fixture(t: any) {
const root = mkdtempSync(join(tmpdir(), 'feishu-local-format-'));
const dir = join(root, hash([config.instanceId, config.accountId, config.appId]));
mkdirSync(dir);
const path = join(dir, 'messages.sqlite');
const db = new DatabaseSync(path);
db.exec(ddl);
const request = JSON.stringify({ startTime: 1, endTime: 10, containers: [] });
const payload = {
phase: 'history',
chatsPage: null,
searchPage: null,
pages: 3,
startedAt: 100,
updatedAt: 200,
finishedAt: null,
error: 'interrupted',
};
db.prepare('INSERT INTO sync_jobs VALUES(?,?,?,?)').run(
'pending',
request,
'incomplete',
JSON.stringify(payload),
);
db.prepare('INSERT INTO sync_jobs VALUES(?,?,?,?)').run(
'done',
request,
'complete',
JSON.stringify({ ...payload, phase: 'complete', finishedAt: 150 }),
);
db.prepare('INSERT INTO sync_chats VALUES(?,?,?)').run(
'pending',
'chat',
JSON.stringify({ page: 'page-two', done: false }),
);
db.prepare('INSERT INTO sync_tokens VALUES(?,?,?)').run('pending', 'chat:chat', 'page-two');
const m = raw('existing'),
o = messageObject(m);
db.prepare('INSERT INTO originals VALUES(?,?,?,?)').run(
o.id,
o.revision,
JSON.stringify(o),
JSON.stringify(m),
);
db.prepare('INSERT INTO sync_members VALUES(?,?,?)').run('pending', o.id, o.revision);
db.prepare('INSERT INTO publications(id,revision,created_at,kind,object) VALUES(?,?,?,?,?)').run(
o.id,
o.revision,
2000,
o.kind,
JSON.stringify(o),
);
db.prepare('INSERT INTO heads VALUES(?,?)').run(o.id, 1);
db.close();
const cache = new MessageCache(root, config, key);
t.after(() => {
cache.close();
rmSync(root, { recursive: true, force: true });
});
return { root, path, cache, o, m };
}
test('opens the exact local schema, reads published originals and resumes existing chat progress before reusing completed jobs', async (t) => {
const { cache, path, o, m } = fixture(t);
assert.equal(statSync(path).mode & 0o777, 0o600);
const source = cache.source('feishu.local.messages', async () => {});
assert.deepEqual(((await source.read(o, caller())) as any).content, m);
const calls: any[] = [];
const sync = new MessageSync(
cache,
async (_method, path, params) => {
calls.push({ path, params });
if (path.endsWith('/search')) return { items: [] };
assert.equal(path, '/im/v1/messages');
assert.equal(params.page_token, 'page-two');
return { items: [raw('new', 3000)] };
},
async () => {},
);
const result = await sync.sync({ startTime: 1, endTime: 10 }, caller());
assert.equal(result.jobId, 'pending');
assert.equal(result.status, 'complete');
assert.equal(result.discoveredChats, 1);
assert.equal(result.completedChats, 1);
assert.equal(result.downloadedMessages, 2);
assert.equal(result.pages, 5);
assert.equal(result.startedAt, 100);
assert.ok(result.finishedAt);
assert.equal(cache.boundary(), 2, 'unchanged existing version is not republished');
assert.equal(calls.length, 2, 'no chat discovery or completed history is redownloaded');
assert.deepEqual(
cache.db.prepare('SELECT request FROM sync_jobs WHERE id=?').get('pending')!.request,
JSON.stringify({ startTime: 1, endTime: 10, containers: [] }),
);
assert.deepEqual(
Object.keys(
JSON.parse(
String(
cache.db.prepare('SELECT payload FROM sync_jobs WHERE id=?').get('pending')!.payload,
),
),
),
['phase', 'chatsPage', 'searchPage', 'pages', 'startedAt', 'updatedAt', 'finishedAt', 'error'],
);
const complete = await sync.sync({ startTime: 1, endTime: 10 }, caller());
assert.equal(complete.reused, true);
assert.equal(calls.length, 2);
assert.equal(
cache.db
.prepare(
"SELECT COUNT(*) n FROM sqlite_master WHERE type='table' AND name IN ('versions','staged','published','meta')",
)
.get()!.n,
0,
);
assert.equal((await source.enumerate(undefined, caller())).items.length, 2);
assert.equal(sync.status().jobId, 'done', 'without id only the newest created job is returned');
assert.equal(sync.status('pending').jobId, 'pending');
});
test('publication is per changed message version and atomic with job completion; partial originals remain unreadable', async (t) => {
const { cache, o } = fixture(t);
const source = cache.source('local', async () => {});
const job = cache.begin(10, 20, true),
m = raw('unpublished', 11000);
cache.page(job, [m, raw('existing', 2000, 'changed')]);
assert.equal((await source.read(messageObject(m), caller())).status, 'unavailable');
assert.equal((await source.query({}, caller())).items.length, 1);
cache.db.exec(
"CREATE TRIGGER fail_complete BEFORE UPDATE ON sync_jobs WHEN NEW.status='complete' BEGIN SELECT RAISE(ABORT,'synthetic write failure'); END",
);
assert.throws(() => cache.publish(job), /synthetic write failure/);
assert.equal(cache.boundary(), 1);
assert.equal(cache.job(job.id).status, 'incomplete');
cache.db.exec('DROP TRIGGER fail_complete');
cache.publish(cache.job(job.id));
assert.equal(cache.boundary(), 3);
assert.equal(cache.job(job.id).status, 'complete');
assert.equal(
cache.db.prepare("SELECT created_at FROM publications WHERE id='unpublished'").get()!
.created_at,
11000,
);
assert.equal(
(await source.read(o, caller())).status,
'ok',
'old published version still resolves',
);
const again = cache.begin(20, 30, true);
cache.page(again, [m]);
cache.publish(again);
assert.equal(cache.boundary(), 3);
});
test('frozen highWater pagination uses creation time then ID, enforces scope everywhere and requires an initial sync', async (t) => {
const root = mkdtempSync(join(tmpdir(), 'feishu-paging-'));
let cache = new MessageCache(root, config, key);
t.after(() => {
cache.close();
rmSync(root, { recursive: true, force: true });
});
let source = cache.source('local', async () => {});
const sync = new MessageSync(
cache,
async () => ({ items: [] }),
async () => {},
);
assert.deepEqual(sync.status(), {
configured: true,
synchronized: false,
source: 'feishu.local.messages',
});
await assert.rejects(source.query({}, caller()), /FeishuSync first/);
const job = cache.begin(1, 20);
cache.page(job, [
raw('z', 1000),
raw('b', 2000),
raw('a', 2000),
raw('outside', 15000, 'outside', 'other'),
]);
cache.publish(job);
const page = await source.query({ limit: 1 }, caller());
assert.equal(page.items[0].id, 'z');
assert.deepEqual(Object.keys(cache.decode(page.next!)), ['hash', 'highWater', 'offset']);
const high = cache.boundary();
const update = cache.begin(1, 20, true);
cache.page(update, [raw('new-earlier', 500), raw('a', 2000, 'updated')]);
cache.publish(update);
cache.close();
cache = new MessageCache(root, config, key);
source = cache.source('local', async () => {});
const next = await source.query({ limit: 1, cursor: page.next }, caller());
assert.equal(next.items[0].id, 'a');
assert.equal(cache.decode(next.next!).highWater, high);
assert.equal(((await source.read(next.items[0], caller())) as any).content.body.content, 'a');
await assert.rejects(source.query({ limit: 2, cursor: page.next }, caller()), /query changed/);
const restricted = new MessageCache(
root,
{ ...config, startTime: 1, endTime: 10, containers: [{ type: 'chat', id: 'chat' }] },
key,
);
try {
const limited = restricted.source('limited', async () => {});
assert.deepEqual(
(await limited.query({}, caller())).items.map((o) => o.id),
['z', 'a', 'b'],
);
const outside = messageObject(raw('outside', 15000, 'outside', 'other'));
assert.deepEqual(await limited.authorize([outside], caller()), []);
assert.equal((await limited.read(outside, caller())).status, 'unavailable');
assert.equal((await limited.query({ chatId: 'other' }, caller())).items.length, 0);
await assert.rejects(
limited.query({ limit: 1, cursor: page.next }, caller()),
/scope or query changed/,
);
} finally {
restricted.close();
}
const many = cache.begin(1, 20, true);
cache.page(
many,
Array.from({ length: 510 }, (_, i) => raw('m' + String(i).padStart(3, '0'), 4000 + i)),
);
cache.publish(many);
assert.equal((await source.query({}, caller())).items.length, 50);
const enumeration = await source.enumerate(undefined, caller());
assert.equal(enumeration.items.length, 500);
assert.ok(enumeration.next);
});
test('persisted repeated chat tokens reject only that page and leave job resumable', async (t) => {
const { cache } = fixture(t);
const sync = new MessageSync(
cache,
async () => ({ items: [raw('discarded')], has_more: true, page_token: 'page-two' }),
async () => {},
);
const result = await sync.sync({ startTime: 1, endTime: 10 }, caller());
assert.equal(result.status, 'incomplete');
assert.match(result.error!, /Repeated/);
assert.equal(cache.has('pending', 'discarded'), false);
assert.equal(cache.job('pending').pages, 3);
assert.equal(cache.nextChat('pending').page, 'page-two');
});
for (const phase of ['discover', 'search', 'publish']) {
test(`local ${phase} phase resumes its persisted cursor without repeating earlier phases`, async (t) => {
const { cache } = fixture(t);
const row = cache.db.prepare('SELECT payload FROM sync_jobs WHERE id=?').get('pending')!;
const payload = {
...JSON.parse(String(row.payload)),
phase,
chatsPage: phase === 'discover' ? 'saved-discovery' : null,
searchPage: phase === 'search' ? 'saved-search' : null,
};
cache.db
.prepare('UPDATE sync_jobs SET payload=? WHERE id=?')
.run(JSON.stringify(payload), 'pending');
const calls: string[] = [];
const sync = new MessageSync(
cache,
async (_method, path, params) => {
calls.push(path);
if (path.endsWith('/chats')) {
assert.equal(phase, 'discover');
assert.equal(params.types, 'p2p,group');
assert.equal(params.page_token, 'saved-discovery');
return { items: [] };
}
if (path.endsWith('/search')) {
if (phase === 'search') assert.equal(params.page_token, 'saved-search');
return { items: [] };
}
assert.equal(phase, 'discover');
assert.equal(params.page_token, 'page-two');
return { items: [] };
},
async () => {},
);
assert.equal((await sync.sync({ startTime: 1, endTime: 10 }, caller())).status, 'complete');
assert.deepEqual(
calls,
phase === 'discover'
? ['/im/v1/chats', '/im/v1/messages', '/im/v1/messages/search']
: phase === 'search'
? ['/im/v1/messages/search']
: [],
);
assert.equal(cache.boundary(), 1, 'unchanged existing originals are not published again');
});
}
test('activity observation is permission checked, published-only and never reads original bodies', async (t) => {
const { cache } = fixture(t);
let allowed = true,
checks = 0;
const source = cache.source('feishu.local.messages', async () => {
checks++;
if (!allowed) throw Error('permission denied');
});
const first = await source.activityToken(caller());
assert.equal(typeof first, 'string');
cache.db.prepare('INSERT INTO sync_members VALUES(?,?,?)').run('pending', 'staged-only', '1');
assert.equal(await source.activityToken(caller()), first);
cache.db
.prepare('INSERT INTO publications(id,revision,created_at,kind,object) VALUES(?,?,?,?,?)')
.run('published-new', '1', 4000, 'message', '{}');
assert.notEqual(await source.activityToken(caller()), first);
allowed = false;
await assert.rejects(source.activityToken(caller()), /permission denied/);
assert.equal(checks, 4);
});