Initial Commit
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
# `createChatDbFixture`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Builds a real `chat.db`-shaped SQLite database on a temp path using
|
||||
the structural `SqliteReader` seam. This is the foundation of the
|
||||
test strategy: every watcher / adapter test runs against a real
|
||||
`bun:sqlite` database whose schema mirrors Apple's Messages app,
|
||||
populated via the helpers returned by this factory. There are no
|
||||
mocks anywhere in the call chain — the SQL the watcher executes in
|
||||
production runs unchanged against the fixture.
|
||||
|
||||
## Features
|
||||
|
||||
- DDL covers the six tables the watcher reads:
|
||||
`message`, `handle`, `chat`, `chat_message_join`, `attachment`,
|
||||
`message_attachment_join` — with the exact column names + types
|
||||
Apple's chat.db uses.
|
||||
- Helpers return the inserted ROWID via a `SELECT ROWID ORDER BY
|
||||
ROWID DESC LIMIT 1` so tests can wire foreign-key joins
|
||||
deterministically without depending on `last_insert_rowid()`
|
||||
semantics across reader implementations.
|
||||
- `insertMessage(row, chatId)` writes both the message and its
|
||||
`chat_message_join` row so the watcher's `JOIN cmj` query finds
|
||||
the chat without extra wiring.
|
||||
- `insertAttachment(row, messageId)` writes both the attachment and
|
||||
its `message_attachment_join` row.
|
||||
- `close()` closes the handle so the temp file can be deleted by the
|
||||
caller's `afterEach`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
createBunSqliteReader,
|
||||
createChatDbFixture,
|
||||
} from "@suaveplan/imessage-adapter";
|
||||
|
||||
const reader = createBunSqliteReader();
|
||||
const fixture = createChatDbFixture(reader, join(tmpdir(), "chat.db"));
|
||||
const chatId = fixture.insertChat({
|
||||
guid: "iMessage;+;+15551234567",
|
||||
chat_identifier: "+15551234567",
|
||||
display_name: null,
|
||||
group_id: null,
|
||||
service_name: "iMessage",
|
||||
is_archived: 0,
|
||||
});
|
||||
fixture.close();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
const handleId = fixture.insertHandle({
|
||||
id: "+15551234567",
|
||||
country: "us",
|
||||
service: "iMessage",
|
||||
uncanonicalized_id: null,
|
||||
});
|
||||
const messageId = fixture.insertMessage({
|
||||
guid: "m-1",
|
||||
text: "Hello",
|
||||
handle_id: handleId,
|
||||
service: "iMessage",
|
||||
date: 700_000_000,
|
||||
date_read: 0,
|
||||
date_delivered: 0,
|
||||
is_from_me: 0,
|
||||
is_read: 1,
|
||||
is_delivered: 1,
|
||||
is_sent: 0,
|
||||
cache_has_attachments: 1,
|
||||
associated_message_guid: null,
|
||||
associated_message_type: 0,
|
||||
balloon_bundle_id: null,
|
||||
group_title: null,
|
||||
}, chatId);
|
||||
fixture.insertAttachment({
|
||||
guid: "a-1",
|
||||
filename: "/tmp/photo.jpg",
|
||||
mime_type: "image/jpeg",
|
||||
total_bytes: 1024,
|
||||
transfer_name: "photo.jpg",
|
||||
}, messageId);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `createChatDbFixture(reader, path): ChatDbFixture` — DDLs the schema
|
||||
and returns the helpers.
|
||||
- `ChatDbFixture.handle: SqliteHandle` — exposed for advanced custom
|
||||
inserts.
|
||||
- `insertHandle(row): number` — returns the new ROWID.
|
||||
- `insertChat(row): number` — returns the new ROWID.
|
||||
- `insertMessage(row, chatId?): number` — wires the
|
||||
`chat_message_join` row when `chatId` is supplied.
|
||||
- `insertAttachment(row, messageId): number` — wires the
|
||||
`message_attachment_join` row.
|
||||
- `close(): void` — closes the handle.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The factory is server-tier-only because it requires a real SQLite
|
||||
implementation, but it accepts any `SqliteReader` so a future
|
||||
in-process SQLite-WASM reader could also drive the fixture. The
|
||||
schema uses `AUTOINCREMENT` so insertion order maps to monotonically
|
||||
increasing ROWIDs, which the watcher's `ROWID > lastRowId` cursor
|
||||
relies on for incremental polling.
|
||||
@@ -0,0 +1,127 @@
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from "@suaveplan/testing/runner";
|
||||
import { createBunSqliteReader } from "../sqlite/bun-sqlite-reader.js";
|
||||
import { createChatDbFixture } from "./chat-db-fixture.js";
|
||||
|
||||
let dir: string;
|
||||
const reader = createBunSqliteReader();
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), "imsg-fix-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("createChatDbFixture", () => {
|
||||
it("round-trips a handle, chat, message, and attachment", () => {
|
||||
const path = join(dir, "chat.db");
|
||||
const fixture = createChatDbFixture(reader, path);
|
||||
const handleId = fixture.insertHandle({
|
||||
id: "+15551234567",
|
||||
country: "US",
|
||||
service: "iMessage",
|
||||
uncanonicalized_id: null,
|
||||
});
|
||||
expect(handleId).toBe(1);
|
||||
|
||||
const chatId = fixture.insertChat({
|
||||
guid: "chat-g",
|
||||
chat_identifier: "+15551234567",
|
||||
display_name: null,
|
||||
group_id: null,
|
||||
service_name: "iMessage",
|
||||
is_archived: 0,
|
||||
});
|
||||
expect(chatId).toBe(1);
|
||||
|
||||
const msgId = fixture.insertMessage(
|
||||
{
|
||||
guid: "m-1",
|
||||
text: "hi",
|
||||
handle_id: handleId,
|
||||
service: "iMessage",
|
||||
date: 700_000_000,
|
||||
date_read: 0,
|
||||
date_delivered: 0,
|
||||
is_from_me: 0,
|
||||
is_read: 1,
|
||||
is_delivered: 1,
|
||||
is_sent: 0,
|
||||
cache_has_attachments: 1,
|
||||
associated_message_guid: null,
|
||||
associated_message_type: 0,
|
||||
balloon_bundle_id: null,
|
||||
group_title: null,
|
||||
},
|
||||
chatId
|
||||
);
|
||||
expect(msgId).toBe(1);
|
||||
|
||||
fixture.insertAttachment(
|
||||
{
|
||||
guid: "a-1",
|
||||
filename: "/tmp/p.jpg",
|
||||
mime_type: "image/jpeg",
|
||||
total_bytes: 1024,
|
||||
transfer_name: "p.jpg",
|
||||
},
|
||||
msgId
|
||||
);
|
||||
|
||||
const messageRow = fixture.handle
|
||||
.prepare("SELECT text FROM message WHERE ROWID = ?")
|
||||
.get(msgId);
|
||||
expect((messageRow as { text: string }).text).toBe("hi");
|
||||
|
||||
const joinedRow = fixture.handle
|
||||
.prepare(
|
||||
`SELECT a.mime_type FROM attachment a
|
||||
JOIN message_attachment_join maj ON a.ROWID = maj.attachment_id
|
||||
WHERE maj.message_id = ?`
|
||||
)
|
||||
.get(msgId);
|
||||
expect((joinedRow as { mime_type: string }).mime_type).toBe(
|
||||
"image/jpeg"
|
||||
);
|
||||
|
||||
fixture.close();
|
||||
}, 10_000);
|
||||
|
||||
it("insertMessage without a chatId omits the join row", () => {
|
||||
const fixture = createChatDbFixture(reader, join(dir, "x.db"));
|
||||
const id = fixture.insertMessage({
|
||||
guid: "m-2",
|
||||
text: null,
|
||||
handle_id: 0,
|
||||
service: "iMessage",
|
||||
date: 0,
|
||||
date_read: 0,
|
||||
date_delivered: 0,
|
||||
is_from_me: 0,
|
||||
is_read: 0,
|
||||
is_delivered: 0,
|
||||
is_sent: 0,
|
||||
cache_has_attachments: 0,
|
||||
associated_message_guid: null,
|
||||
associated_message_type: 0,
|
||||
balloon_bundle_id: null,
|
||||
group_title: null,
|
||||
});
|
||||
expect(id).toBe(1);
|
||||
const joinRow = fixture.handle
|
||||
.prepare("SELECT count(*) AS n FROM chat_message_join")
|
||||
.get();
|
||||
expect((joinRow as { n: number }).n).toBe(0);
|
||||
fixture.close();
|
||||
}, 10_000);
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
import type {
|
||||
AttachmentRow,
|
||||
ChatRow,
|
||||
HandleRow,
|
||||
IMessageRow,
|
||||
} from "../types/imessage.types.js";
|
||||
import type { SqliteHandle, SqliteReader } from "../types/seams.types.js";
|
||||
|
||||
const CREATE_SCHEMA_SQL = `
|
||||
CREATE TABLE IF NOT EXISTS message (
|
||||
ROWID INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
guid TEXT NOT NULL,
|
||||
text TEXT,
|
||||
handle_id INTEGER NOT NULL,
|
||||
service TEXT NOT NULL,
|
||||
date INTEGER NOT NULL,
|
||||
date_read INTEGER NOT NULL,
|
||||
date_delivered INTEGER NOT NULL,
|
||||
is_from_me INTEGER NOT NULL,
|
||||
is_read INTEGER NOT NULL,
|
||||
is_delivered INTEGER NOT NULL,
|
||||
is_sent INTEGER NOT NULL,
|
||||
cache_has_attachments INTEGER NOT NULL,
|
||||
associated_message_guid TEXT,
|
||||
associated_message_type INTEGER NOT NULL,
|
||||
balloon_bundle_id TEXT,
|
||||
group_title TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS handle (
|
||||
ROWID INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
id TEXT NOT NULL,
|
||||
country TEXT,
|
||||
service TEXT NOT NULL,
|
||||
uncanonicalized_id TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS chat (
|
||||
ROWID INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
guid TEXT NOT NULL,
|
||||
chat_identifier TEXT NOT NULL,
|
||||
display_name TEXT,
|
||||
group_id TEXT,
|
||||
service_name TEXT NOT NULL,
|
||||
is_archived INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS chat_message_join (
|
||||
chat_id INTEGER NOT NULL,
|
||||
message_id INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS attachment (
|
||||
ROWID INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
guid TEXT NOT NULL,
|
||||
filename TEXT,
|
||||
mime_type TEXT,
|
||||
total_bytes INTEGER NOT NULL,
|
||||
transfer_name TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS message_attachment_join (
|
||||
message_id INTEGER NOT NULL,
|
||||
attachment_id INTEGER NOT NULL
|
||||
);
|
||||
`;
|
||||
|
||||
export interface ChatDbFixture {
|
||||
readonly handle: SqliteHandle;
|
||||
insertHandle(row: Omit<HandleRow, "ROWID">): number;
|
||||
insertChat(row: Omit<ChatRow, "ROWID">): number;
|
||||
insertMessage(row: Omit<IMessageRow, "ROWID">, chatId?: number): number;
|
||||
insertAttachment(
|
||||
row: Omit<AttachmentRow, "ROWID">,
|
||||
messageId: number
|
||||
): number;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a real chat.db-shaped SQLite database on `path` using the
|
||||
* supplied reader. Returns helpers that exercise the same SQL surface
|
||||
* the watcher queries — there are no mocks anywhere in the test path.
|
||||
*/
|
||||
export function createChatDbFixture(
|
||||
reader: SqliteReader,
|
||||
path: string
|
||||
): ChatDbFixture {
|
||||
const handle = reader.open(path, { readonly: false });
|
||||
handle.execScript(CREATE_SCHEMA_SQL);
|
||||
|
||||
function insertHandle(row: Omit<HandleRow, "ROWID">): number {
|
||||
const stmt = handle.prepare(
|
||||
`INSERT INTO handle (id, country, service, uncanonicalized_id)
|
||||
VALUES (?, ?, ?, ?)`
|
||||
);
|
||||
stmt.run(row.id, row.country, row.service, row.uncanonicalized_id);
|
||||
return readLastRowId(handle, "handle");
|
||||
}
|
||||
|
||||
function insertChat(row: Omit<ChatRow, "ROWID">): number {
|
||||
const stmt = handle.prepare(
|
||||
`INSERT INTO chat (guid, chat_identifier, display_name, group_id,
|
||||
service_name, is_archived)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`
|
||||
);
|
||||
stmt.run(
|
||||
row.guid,
|
||||
row.chat_identifier,
|
||||
row.display_name,
|
||||
row.group_id,
|
||||
row.service_name,
|
||||
row.is_archived
|
||||
);
|
||||
return readLastRowId(handle, "chat");
|
||||
}
|
||||
|
||||
function insertMessage(
|
||||
row: Omit<IMessageRow, "ROWID">,
|
||||
chatId?: number
|
||||
): number {
|
||||
const stmt = handle.prepare(
|
||||
`INSERT INTO message (
|
||||
guid, text, handle_id, service, date, date_read, date_delivered,
|
||||
is_from_me, is_read, is_delivered, is_sent,
|
||||
cache_has_attachments, associated_message_guid,
|
||||
associated_message_type, balloon_bundle_id, group_title
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`
|
||||
);
|
||||
stmt.run(
|
||||
row.guid,
|
||||
row.text,
|
||||
row.handle_id,
|
||||
row.service,
|
||||
row.date,
|
||||
row.date_read,
|
||||
row.date_delivered,
|
||||
row.is_from_me,
|
||||
row.is_read,
|
||||
row.is_delivered,
|
||||
row.is_sent,
|
||||
row.cache_has_attachments,
|
||||
row.associated_message_guid,
|
||||
row.associated_message_type,
|
||||
row.balloon_bundle_id,
|
||||
row.group_title
|
||||
);
|
||||
const messageId = readLastRowId(handle, "message");
|
||||
if (chatId !== undefined) {
|
||||
const join = handle.prepare(
|
||||
"INSERT INTO chat_message_join (chat_id, message_id) VALUES (?, ?)"
|
||||
);
|
||||
join.run(chatId, messageId);
|
||||
}
|
||||
return messageId;
|
||||
}
|
||||
|
||||
function insertAttachment(
|
||||
row: Omit<AttachmentRow, "ROWID">,
|
||||
messageId: number
|
||||
): number {
|
||||
const stmt = handle.prepare(
|
||||
`INSERT INTO attachment (guid, filename, mime_type, total_bytes, transfer_name)
|
||||
VALUES (?, ?, ?, ?, ?)`
|
||||
);
|
||||
stmt.run(
|
||||
row.guid,
|
||||
row.filename,
|
||||
row.mime_type,
|
||||
row.total_bytes,
|
||||
row.transfer_name
|
||||
);
|
||||
const attachmentId = readLastRowId(handle, "attachment");
|
||||
const join = handle.prepare(
|
||||
"INSERT INTO message_attachment_join (message_id, attachment_id) VALUES (?, ?)"
|
||||
);
|
||||
join.run(messageId, attachmentId);
|
||||
return attachmentId;
|
||||
}
|
||||
|
||||
return {
|
||||
handle,
|
||||
insertHandle,
|
||||
insertChat,
|
||||
insertMessage,
|
||||
insertAttachment,
|
||||
close: () => handle.close(),
|
||||
};
|
||||
}
|
||||
|
||||
function readLastRowId(handle: SqliteHandle, table: string): number {
|
||||
const stmt = handle.prepare(
|
||||
`SELECT ROWID AS id FROM ${table} ORDER BY ROWID DESC LIMIT 1`
|
||||
);
|
||||
const row = stmt.get() as { id: number };
|
||||
return row.id;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { ChatDbFixture } from "./chat-db-fixture.js";
|
||||
export { createChatDbFixture } from "./chat-db-fixture.js";
|
||||
Reference in New Issue
Block a user