Files
trade-message-center/apps/server/test/onetalk-read-postgres.integration.test.ts
T

491 lines
18 KiB
TypeScript

// 验证 OneTalk 读取 SQL 的快照与 direct 边界
import assert from "node:assert/strict";
import { randomUUID } from "node:crypto";
import test from "node:test";
import postgres from "postgres";
import { createDatabase, type DatabaseConnection } from "../src/database/index.ts";
import { runMigrations } from "../src/database/migrate.ts";
import { createOneTalkReadRepository, createOneTalkReadService } from "../src/onetalk/index.ts";
const databaseUrl = process.env.TEST_DATABASE_URL?.trim();
const imageDimensionMigrationTimestamp = 1_789_102_776_000;
test(
"reads only direct snapshot facts with real latest-message and profile semantics",
{
skip: databaseUrl
? false
: "TEST_DATABASE_URL is not set; PostgreSQL read integration test not run",
timeout: 60_000,
},
async () => {
if (!databaseUrl) return;
await runMigrations(databaseUrl);
const database: DatabaseConnection = createDatabase(databaseUrl);
const client = postgres(databaseUrl, { max: 1 });
const channelAccountId = `read-integration-${randomUUID()}`;
const asOf = new Date("2026-09-03T00:00:00.000Z");
const afterAsOf = new Date("2026-09-04T00:00:00.000Z");
const scope = {
mindUserId: "integration-mind-user",
workspaceId: "integration-workspace",
channelAccountId,
};
const service = createOneTalkReadService(createOneTalkReadRepository(database.db), {
now: () => asOf,
});
try {
await client`
delete from drizzle.__drizzle_migrations
where created_at = ${imageDimensionMigrationTimestamp}
`;
await client`
insert into onetalk_conversation (
channel_account_id,
conversation_id,
conversation_kind,
history_complete,
first_discovered_at,
last_observed_at
) values (
${channelAccountId},
'migration-image',
'direct',
true,
${asOf},
${asOf}
)
`;
const legacyImage = {
version: 1,
kind: "image",
fileId: "migration-image-file",
extension: "jpg",
sizeBytes: 42_000,
width: 1280,
height: 720,
isOriginal: true,
md5: "a".repeat(32),
previewUrl: null,
urlScope: "onetalk_session",
};
const canonicalImage = {
version: 1,
kind: "image",
fileId: "migration-image-file",
extension: "jpg",
sizeBytes: 42_000,
isOriginal: true,
md5: "a".repeat(32),
previewUrl: null,
urlScope: "onetalk_session",
};
const imageWithOnlyWidth = {
...canonicalImage,
fileId: "image-with-only-width",
width: 800,
};
const imageWithOnlyHeight = {
...canonicalImage,
fileId: "image-with-only-height",
height: 600,
};
const imageWithoutDimensions = {
...canonicalImage,
fileId: "image-with-only-width",
};
const imageWithoutHeight = {
...canonicalImage,
fileId: "image-with-only-height",
};
const imageWithPreservedKey = {
...legacyImage,
fileId: "image-with-preserved-key",
retainedMigrationProbe: { source: "pre-v6" },
};
const preservedImage = {
...canonicalImage,
fileId: "image-with-preserved-key",
retainedMigrationProbe: { source: "pre-v6" },
};
const textSentinel = {
version: 1,
kind: "text",
text: "unmodified text",
width: 900,
height: 901,
};
for (const [conversationId, messageId, content] of [
["migration-image", "legacy-image", legacyImage],
["migration-preservation", "image-only-width", imageWithOnlyWidth],
["migration-preservation", "image-only-height", imageWithOnlyHeight],
["migration-preservation", "image-preserved-key", imageWithPreservedKey],
["migration-preservation", "text-sentinel", textSentinel],
] as const) {
await client`
insert into onetalk_message (
channel_account_id,
conversation_id,
message_id,
sender_id,
binding,
mind_user_id,
workspace_id,
device_id,
direction,
observation_type,
sent_at_ms,
content,
participant_ids,
read_status,
message_status,
unread_count,
first_observed_at,
last_observed_at
) values (
${channelAccountId},
${conversationId},
${messageId},
'sender-1',
'binding-1',
'mind-user-1',
'workspace-1',
'device-1',
'received',
'history',
1,
${client.json(content)},
${["sender-1", channelAccountId]},
1,
2,
0,
${asOf},
${asOf}
)
`;
}
await runMigrations(databaseUrl);
const migratedRows = await client<
{
content: Record<string, unknown>;
message_id: string;
}[]
>`
select message_id, content
from onetalk_message
where channel_account_id = ${channelAccountId}
order by message_id
`;
assert.deepEqual(migratedRows, [
{ message_id: "image-only-height", content: imageWithoutHeight },
{ message_id: "image-only-width", content: imageWithoutDimensions },
{ message_id: "image-preserved-key", content: preservedImage },
{ message_id: "legacy-image", content: canonicalImage },
{ message_id: "text-sentinel", content: textSentinel },
]);
const migratedHistory = await service.readHistory({
scope,
conversationId: "migration-image",
});
assert.equal(migratedHistory.status, "accepted");
if (migratedHistory.status !== "accepted") return;
assert.deepEqual(migratedHistory.messages[0]?.content, canonicalImage);
await runMigrations(databaseUrl);
const migrationRecords = await client<{ count: string }[]>`
select count(*) as count
from drizzle.__drizzle_migrations
where created_at = ${imageDimensionMigrationTimestamp}
`;
assert.equal(migrationRecords[0]?.count, "1");
for (const [conversationId, conversationKind, discoveredAt, lastMessageAtMs] of [
["direct-a", "direct", asOf, 300],
["direct-b", "direct", asOf, 250],
["direct-c", "direct", asOf, 200],
["group", "group", asOf, 400],
["unknown", null, asOf, 500],
["future-direct", "direct", afterAsOf, 600],
] as const) {
await client`
insert into onetalk_conversation (
channel_account_id,
conversation_id,
conversation_kind,
last_message_at_ms,
first_discovered_at,
last_observed_at
) values (
${channelAccountId},
${conversationId},
${conversationKind},
${lastMessageAtMs},
${discoveredAt},
${discoveredAt}
)
`;
}
await client`
insert into onetalk_contact_profile (
channel_account_id,
conversation_id,
ali_id,
name,
avatar_url,
observed_at_ms,
profile_fingerprint,
observation_status,
received_at
) values (
${channelAccountId},
'direct-a',
'ali-a',
'Alpha Profile',
'https://cdn.example.com/alpha.jpg',
100,
'profile-a',
'confirmed',
${asOf}
)
`;
await client`
insert into onetalk_contact_profile (
channel_account_id,
conversation_id,
ali_id,
name,
observed_at_ms,
profile_fingerprint,
observation_status,
received_at
) values (
${channelAccountId},
'direct-c',
'ali-c',
'Later Profile',
100,
'profile-c',
'confirmed',
${asOf}
)
`;
await client`
insert into onetalk_contact_profile (
channel_account_id,
conversation_id,
ali_id,
name,
observed_at_ms,
profile_fingerprint,
observation_status,
received_at
) values (
${channelAccountId},
'profile-only',
'ali-only',
'Profile without conversation',
100,
'profile-only',
'confirmed',
${asOf}
)
`;
for (const [messageId, sentAtMs, firstObservedAt] of [
["anchor-only", 100, asOf],
["latest-a", 200, asOf],
["latest-z", 200, asOf],
["after-snapshot", 900, afterAsOf],
] as const) {
await client`
insert into onetalk_message (
channel_account_id,
conversation_id,
message_id,
sender_id,
binding,
mind_user_id,
workspace_id,
device_id,
direction,
observation_type,
sent_at_ms,
content,
participant_ids,
read_status,
message_status,
unread_count,
first_observed_at,
last_observed_at
) values (
${channelAccountId},
'direct-a',
${messageId},
'sender-1',
'binding-1',
'mind-user-1',
'workspace-1',
'device-1',
'received',
'history',
${sentAtMs},
${client.json({ version: 1, kind: "text", text: "visible text" })},
${["sender-1", channelAccountId]},
1,
2,
0,
${firstObservedAt},
${firstObservedAt}
)
`;
}
await client`
update onetalk_conversation
set message_count = 4,
latest_message_id = 'anchor-only'
where channel_account_id = ${channelAccountId}
and conversation_id = 'direct-a'
`;
const firstPage = await service.listConversations({ scope, limit: 1 });
assert.equal(firstPage.status, "accepted");
if (firstPage.status !== "accepted") return;
assert.deepEqual(
firstPage.conversations.map(({ conversationId }) => conversationId),
["direct-a"],
);
assert.equal(firstPage.conversations[0]?.name, "Alpha Profile");
assert.equal(firstPage.page.hasMore, true);
assert.ok(firstPage.page.nextCursor);
await client`
insert into onetalk_contact_profile (
channel_account_id,
conversation_id,
ali_id,
name,
observed_at_ms,
profile_fingerprint,
observation_status,
received_at
) values (
${channelAccountId},
'direct-b',
'ali-b',
'Current second profile',
101,
'profile-b',
'confirmed',
${afterAsOf}
)
`;
const secondPage = await service.listConversations({
scope,
cursor: firstPage.page.nextCursor,
limit: 1,
});
assert.equal(secondPage.status, "accepted");
if (secondPage.status !== "accepted") return;
assert.deepEqual(
secondPage.conversations.map(({ conversationId }) => conversationId),
["direct-b"],
);
assert.equal(secondPage.conversations[0]?.name, "Current second profile");
assert.equal(secondPage.page.hasMore, true);
assert.ok(secondPage.page.nextCursor);
const filtered = await service.listConversations({
scope,
query: " alpha ",
limit: 10,
});
assert.equal(filtered.status, "accepted");
if (filtered.status !== "accepted") return;
assert.deepEqual(
filtered.conversations.map(({ conversationId }) => conversationId),
["direct-a"],
);
const profileFiltered = await service.listConversations({
scope,
query: "later",
limit: 1,
});
assert.equal(profileFiltered.status, "accepted");
if (profileFiltered.status !== "accepted") return;
assert.deepEqual(
profileFiltered.conversations.map(({ conversationId }) => conversationId),
["direct-c"],
);
assert.equal(profileFiltered.page.hasMore, false);
assert.equal(profileFiltered.page.nextCursor, null);
const detailWithoutProfile = await service.readConversation({
scope,
conversationId: "direct-b",
});
assert.equal(detailWithoutProfile.status, "accepted");
if (detailWithoutProfile.status !== "accepted") return;
assert.equal(detailWithoutProfile.conversation.avatarUrl, null);
const ordinaryHistory = await service.readHistory({
scope,
conversationId: "direct-a",
fromSentAtMs: 100,
toSentAtMs: 900,
limit: 2,
});
assert.equal(ordinaryHistory.status, "accepted");
if (ordinaryHistory.status !== "accepted") return;
assert.deepEqual(
ordinaryHistory.messages.map(({ messageId }) => messageId),
["latest-a", "latest-z"],
);
assert.equal(ordinaryHistory.page.hasMore, true);
assert.ok(ordinaryHistory.page.nextCursor);
const olderHistory = await service.readHistory({
scope,
conversationId: "direct-a",
fromSentAtMs: 100,
toSentAtMs: 900,
cursor: ordinaryHistory.page.nextCursor,
limit: 2,
});
assert.equal(olderHistory.status, "accepted");
if (olderHistory.status !== "accepted") return;
assert.deepEqual(
olderHistory.messages.map(({ messageId }) => messageId),
["anchor-only"],
);
assert.equal(olderHistory.page.hasMore, false);
assert.deepEqual(
await service.readHistory({
scope,
conversationId: "direct-a",
purpose: "communication_summary_read",
}),
{ status: "rejected", reason: "history_incomplete" },
);
} finally {
await runMigrations(databaseUrl);
await client`
delete from onetalk_message
where channel_account_id = ${channelAccountId}
`;
await client`
delete from onetalk_contact_profile
where channel_account_id = ${channelAccountId}
`;
await client`
delete from onetalk_conversation
where channel_account_id = ${channelAccountId}
`;
await client.end({ timeout: 5 });
await database.close();
}
},
);