Files
oh-my-pi-telegram/tests/thread-reconciler.test.ts
Evandro Fonseca Junior d1d04001d0
Release / release (push) Failing after 5s
Validate / validate (push) Failing after 5m21s
chore: fork @llblab/[email protected] as @evandrodevbr/oh-my-pi-telegram
Fork of upstream llblab/[email protected].0 (SHA afe09c5) repackaged as a
drop-in extension for can1357/oh-my-pi (omp >= 16.5.2).

Changes vs upstream:
- Rename package: @llblab/pi-telegram -> @evandrodevbr/oh-my-pi-telegram
- Bump version: 0.22.0 -> 0.22.0-evandro.1
- Update repository/homepage/bugs URLs to evandrodevbr
- Add omp field in package.json (displayName + compatibleWith)
- README: prepend 'Oh My Pi (omp) Compatibility' section with install
  commands, verified-components table, upstream lineage, and rationale
- CHANGELOG: add '0.22.0-evandro.1' entry under Unreleased
- docs/: rename @llblab/pi-telegram imports to @evandrodevbr/oh-my-pi-telegram
- tests/public-api.test.ts: rename package import paths

Gates re-run on the fork:
- npm run typecheck  -> tsc --noEmit (success)
- npm test           -> 1229 pass / 0 fail / 1 platform-only skip
- npm run audit      -> found 0 vulnerabilities
- npm run pack:check -> evandrodevbr-oh-my-pi-telegram-0.22.0-evandro.1.tgz

Verified:
- omp install --link ./pi-telegram -> Linked OK
- omp plugin list -> @evandrodevbr/[email protected].0-evandro.1

Upstream lineage: badlogic/pi-telegram -> llblab/pi-telegram (0.22.0) -> evandrodevbr/oh-my-pi-telegram (this fork)
2026-07-15 14:18:16 -03:00

1033 lines
26 KiB
TypeScript

/**
* Regression tests for Telegram thread lifecycle reconciliation planning
* Covers pure proof-before-delete decisions before runtime side effects are wired
*/
import assert from "node:assert/strict";
import test from "node:test";
import {
applyThreadReconciliationPlan,
createThreadReconciliationRuntime,
planDisconnectedInstanceThreadCleanup,
planThreadReconciliation,
} from "../lib/thread-reconciler.ts";
import type {
TelegramThreadReservation,
TelegramTopicSyncObservation,
TelegramTopicTargetRecord,
} from "../lib/threads.ts";
const nowMs = 10_000;
function record(
threadId: number,
status: TelegramTopicTargetRecord["status"] = "active",
): TelegramTopicTargetRecord {
return {
profileKey: `profile:${threadId}`,
target: { chatId: 7, threadId },
status,
createdAtMs: nowMs - 1_000,
updatedAtMs: nowMs - 1_000,
instanceId: `inst:${threadId}`,
slot: "A",
};
}
function reservation(threadId: number): TelegramThreadReservation {
return {
target: { chatId: 7, threadId },
slot: "B",
reason: "test",
createdAtMs: nowMs - 1_000,
updatedAtMs: nowMs - 1_000,
expiresAtMs: nowMs + 60_000,
};
}
function observation(
threadId: number,
syncStatus: TelegramTopicSyncObservation["syncStatus"],
observedAtMs = nowMs,
): TelegramTopicSyncObservation {
return {
target: { chatId: 7, threadId },
syncStatus,
observedAtMs,
};
}
test("Thread reconciler treats unknown created topics as observations, not cleanup proof", () => {
const plan = planThreadReconciliation({
nowMs,
records: [],
observations: [observation(42, "open")],
});
assert.deepEqual(plan.actions, []);
});
test("Thread reconciler marks current bindings active or stale from lifecycle observations", () => {
const plan = planThreadReconciliation({
nowMs,
records: [record(10), record(11)],
observations: [observation(10, "open"), observation(11, "closed")],
});
assert.deepEqual(plan.actions, [
{
kind: "mark-topic-active",
target: { chatId: 7, threadId: 10 },
reason: "observed-open",
},
{
kind: "mark-topic-stale",
target: { chatId: 7, threadId: 11 },
syncStatus: "closed",
reason: "observed-closed",
},
]);
});
test("Thread reconciler requires unbound user-message proof before destructive cleanup", () => {
const plan = planThreadReconciliation({
nowMs,
records: [],
unboundMessages: [
{
target: { chatId: 7, threadId: 77 },
observedAtMs: nowMs,
messageId: 100,
},
],
});
assert.deepEqual(plan.actions, [
{
kind: "close-delete-unbound-topic",
target: { chatId: 7, threadId: 77 },
observedAtMs: nowMs,
messageId: 100,
reason: "unbound-user-message",
},
]);
});
test("Thread reconciler protects current, reserved, pending, and freshly-created targets from cleanup", () => {
const plan = planThreadReconciliation({
nowMs,
currentLeaderEpoch: 3,
records: [record(20)],
reservations: [reservation(21)],
pendingProvisions: [
{
id: "pending-22",
owner: "manual-follower",
instanceId: "inst-22",
target: { chatId: 7, threadId: 22 },
startedAtMs: nowMs - 1_000,
expiresAtMs: nowMs + 30_000,
leaderEpoch: 3,
},
],
observations: [observation(23, "open", nowMs - 5_000)],
unboundMessages: [20, 21, 22, 23].map((threadId) => ({
target: { chatId: 7, threadId },
observedAtMs: nowMs,
})),
});
assert.deepEqual(plan.actions, []);
});
test("Thread reconciler runtime records state transitions and schedules snapshots", () => {
const events: unknown[] = [];
let snapshots = 0;
const runtime = createThreadReconciliationRuntime({
recordRuntimeEvent: (_category, _message, details) => events.push(details),
scheduleSnapshotPersist: () => {
snapshots += 1;
},
});
runtime.recordPlan({
actions: [],
state: {
phase: "stable",
event: "settled",
atMs: nowMs,
pendingProvisionCount: 0,
syncActionCount: 0,
cleanupActionCount: 0,
},
});
assert.equal(runtime.getState()?.phase, "stable");
assert.equal(snapshots, 1);
runtime.plan({
nowMs,
records: [],
pendingProvisions: [
{
id: "pending-1",
owner: "leader",
instanceId: "leader",
startedAtMs: nowMs,
},
],
});
assert.equal(runtime.getState()?.phase, "provisioning");
assert.equal(snapshots, 2);
assert.deepEqual(events.at(-1), {
phase: "thread-reconciliation-transition",
from: "stable",
to: "provisioning",
event: "pending-provision",
});
});
test("Thread reconciler exposes explicit reconciliation state transitions", () => {
const provisioning = planThreadReconciliation({
nowMs,
currentLeaderEpoch: 11,
records: [],
pendingProvisions: [
{
id: "pending-live",
owner: "leader",
instanceId: "leader-a",
startedAtMs: nowMs - 100,
expiresAtMs: nowMs + 60_000,
leaderEpoch: 11,
},
],
});
assert.deepEqual(provisioning.state, {
phase: "provisioning",
event: "pending-provision",
atMs: nowMs,
leaderEpoch: 11,
pendingProvisionCount: 1,
syncActionCount: 0,
cleanupActionCount: 0,
});
const cleanup = planThreadReconciliation({
nowMs: nowMs + 70_000,
currentLeaderEpoch: 11,
previousState: provisioning.state,
records: [],
pendingProvisions: [
{
id: "pending-live",
owner: "leader",
instanceId: "leader-a",
target: { chatId: 7, threadId: 55 },
startedAtMs: nowMs - 100,
expiresAtMs: nowMs + 60_000,
leaderEpoch: 11,
},
],
});
assert.equal(cleanup.state?.phase, "cleanup-required");
assert.deepEqual(cleanup.transition, {
from: "provisioning",
to: "cleanup-required",
event: "cleanup-required",
atMs: nowMs + 70_000,
});
});
test("Thread reconciler plans pruned follower cleanup from current instance records", () => {
const plan = planThreadReconciliation({
nowMs,
records: [
{ ...record(50), instanceId: "follower-a" },
{ ...record(51, "offline"), instanceId: "follower-a" },
{ ...record(52), instanceId: "follower-b" },
],
prunedFollowerInstanceIds: ["follower-a"],
});
assert.deepEqual(plan.actions, [
{
kind: "close-delete-pruned-follower-topic",
target: { chatId: 7, threadId: 50 },
reason: "pruned-follower",
instanceId: "follower-a",
},
]);
});
test("Thread reconciler plans replaced binding cleanup for same-instance old targets", () => {
const plan = planThreadReconciliation({
nowMs,
records: [
{ ...record(60), instanceId: "inst-a" },
{ ...record(61), instanceId: "inst-a" },
{ ...record(62), instanceId: "inst-b" },
],
replacedBindings: [
{
instanceId: "inst-a",
replacementTarget: { chatId: 7, threadId: 61 },
},
],
});
assert.deepEqual(plan.actions, [
{
kind: "close-stale-replaced-topic",
target: { chatId: 7, threadId: 60 },
reason: "replaced-instance-binding",
instanceId: "inst-a",
},
]);
});
test("Thread reconciler plans previous leader cleanup from owner/profile evidence", () => {
const plan = planThreadReconciliation({
nowMs,
records: [
{
...record(70),
instanceId: "old-leader",
ownerKind: "leader",
profileKey: "cwd:/repo",
},
{
...record(71),
instanceId: "legacy-leader",
profileKey: "leader:old",
},
{
...record(72),
instanceId: "follower",
ownerKind: "manual-follower",
profileKey: "manual:follower",
},
{
...record(73),
instanceId: "current-leader",
ownerKind: "leader",
profileKey: "cwd:/repo",
},
],
previousLeaderCleanup: { currentInstanceId: "current-leader" },
});
assert.deepEqual(plan.actions, [
{
kind: "close-delete-previous-leader-topic",
target: { chatId: 7, threadId: 70 },
reason: "previous-leader",
instanceId: "current-leader",
},
{
kind: "close-delete-previous-leader-topic",
target: { chatId: 7, threadId: 71 },
reason: "previous-leader",
instanceId: "current-leader",
},
]);
});
test("Thread reconciler plans proactive reservation cleanup and removal after stale probe", () => {
const cleanup = planThreadReconciliation({
nowMs,
records: [],
reservations: [reservation(86)],
proactiveReservationCleanup: true,
});
assert.deepEqual(cleanup.actions, [
{
kind: "close-delete-reserved-topic",
target: { chatId: 7, threadId: 86 },
observedAtMs: nowMs,
reason: "startup-reservation",
},
]);
const removal = planThreadReconciliation({
nowMs,
records: [],
reservations: [reservation(86)],
reservationProbeResults: [
{ target: { chatId: 7, threadId: 86 }, stale: true },
{ target: { chatId: 7, threadId: 87 }, stale: true },
],
});
assert.deepEqual(removal.actions, [
{
kind: "remove-reservation",
target: { chatId: 7, threadId: 86 },
reason: "reservation-probe-stale",
},
]);
});
test("Thread reconciler plans reserved topic cleanup only with reservation proof", () => {
const plan = planThreadReconciliation({
nowMs,
records: [],
reservations: [reservation(88)],
reservedMessages: [
{
target: { chatId: 7, threadId: 88 },
observedAtMs: nowMs,
messageId: 120,
},
{
target: { chatId: 7, threadId: 89 },
observedAtMs: nowMs,
messageId: 121,
},
],
});
assert.deepEqual(plan.actions, [
{
kind: "close-delete-reserved-topic",
target: { chatId: 7, threadId: 88 },
observedAtMs: nowMs,
messageId: 120,
reason: "reserved-user-message",
},
]);
});
test("Thread reconciler plans expired targeted pending provision cleanup", () => {
const plan = planThreadReconciliation({
nowMs,
currentLeaderEpoch: 7,
records: [record(94)],
pendingProvisions: [
{
id: "pending-expired",
owner: "leader",
instanceId: "leader-a",
target: { chatId: 7, threadId: 93 },
startedAtMs: nowMs - 60_000,
expiresAtMs: nowMs - 1,
leaderEpoch: 6,
},
{
id: "pending-untargeted",
owner: "leader",
instanceId: "leader-a",
startedAtMs: nowMs - 60_000,
expiresAtMs: nowMs - 1,
},
{
id: "pending-current",
owner: "leader",
instanceId: "leader-a",
target: { chatId: 7, threadId: 94 },
startedAtMs: nowMs - 60_000,
expiresAtMs: nowMs - 1,
},
],
});
assert.deepEqual(plan.actions, [
{
kind: "close-delete-expired-pending-provision-topic",
target: { chatId: 7, threadId: 93 },
reason: "expired-pending-provision",
pendingProvisionId: "pending-expired",
instanceId: "leader-a",
leaderEpoch: 7,
},
]);
});
test("Thread reconciler ignores stale-epoch cleanup observations", () => {
const plan = planThreadReconciliation({
nowMs,
currentLeaderEpoch: 4,
records: [],
unboundMessages: [
{
target: { chatId: 7, threadId: 80 },
observedAtMs: nowMs,
leaderEpoch: 3,
},
],
});
assert.deepEqual(plan.actions, []);
});
test("Thread reconciler apply removes confirmed stale reservations", async () => {
const removedTargets: unknown[] = [];
let persisted = false;
await applyThreadReconciliationPlan(
{
actions: [
{
kind: "remove-reservation",
target: { chatId: 7, threadId: 88 },
reason: "reservation-probe-stale",
},
],
},
{
removeReservationByTarget(target) {
removedTargets.push(target);
return true;
},
async persist() {
persisted = true;
},
},
);
assert.deepEqual(removedTargets, [{ chatId: 7, threadId: 88 }]);
assert.equal(persisted, true);
});
test("Thread reconciler apply closes replaced instance topics and marks them stale", async () => {
const calls: Array<{ method: string; body: Record<string, unknown> }> = [];
const staleTargets: unknown[] = [];
let persisted = false;
await applyThreadReconciliationPlan(
{
actions: [
{
kind: "close-stale-replaced-topic",
target: { chatId: 7, threadId: 89 },
reason: "replaced-instance-binding",
instanceId: "inst-a",
},
],
},
{
async callApi<TResponse>(method: string, body: Record<string, unknown>) {
calls.push({ method, body });
return {} as TResponse;
},
markStaleByTarget(target, syncStatus) {
staleTargets.push({ target, syncStatus });
return true;
},
async persist() {
persisted = true;
},
},
);
assert.deepEqual(calls, [
{
method: "closeForumTopic",
body: { chat_id: 7, message_thread_id: 89 },
},
]);
assert.deepEqual(staleTargets, [
{ target: { chatId: 7, threadId: 89 }, syncStatus: "closed" },
]);
assert.equal(persisted, true);
});
test("Thread reconciler apply skips destructive actions from stale leader epochs", async () => {
const calls: unknown[] = [];
const runtimeEvents: unknown[] = [];
await applyThreadReconciliationPlan(
{
actions: [
{
kind: "close-delete-unbound-topic",
target: { chatId: 7, threadId: 92 },
observedAtMs: nowMs,
reason: "unbound-user-message",
leaderEpoch: 1,
},
],
},
{
getCurrentLeaderEpoch: () => 2,
async callApi<TResponse>(method: string, body: Record<string, unknown>) {
calls.push({ method, body });
return {} as TResponse;
},
recordRuntimeEvent(category, error, details) {
runtimeEvents.push({ category, error, details });
},
},
);
assert.deepEqual(calls, []);
assert.deepEqual(runtimeEvents, [
{
category: "telegram",
error: "Skipped stale-epoch thread reconciliation action",
details: {
phase: "thread-reconciler-stale-epoch-skip",
action: "close-delete-unbound-topic",
actionLeaderEpoch: 1,
currentLeaderEpoch: 2,
chatId: 7,
threadId: 92,
},
},
]);
});
test("Thread reconciler rechecks leader epoch between close and delete", async () => {
const calls: string[] = [];
const staleTargets: unknown[] = [];
let currentEpoch = 1;
let persisted = false;
await applyThreadReconciliationPlan(
{
actions: [
{
kind: "close-delete-unbound-topic",
target: { chatId: 7, threadId: 92 },
observedAtMs: nowMs,
reason: "unbound-user-message",
leaderEpoch: 1,
},
],
},
{
getCurrentLeaderEpoch: () => currentEpoch,
async callApi<TResponse>(method: string) {
calls.push(method);
currentEpoch = 2;
return {} as TResponse;
},
markStaleByTarget(target, syncStatus) {
staleTargets.push({ target, syncStatus });
return true;
},
async persist() {
persisted = true;
},
recordRuntimeEvent() {},
},
);
assert.deepEqual(calls, ["closeForumTopic"]);
assert.deepEqual(staleTargets, []);
assert.equal(persisted, false);
});
test("Thread reconciler rechecks leader epoch before cleanup persistence", async () => {
let currentEpoch = 1;
let persisted = false;
const staleTargets: unknown[] = [];
await applyThreadReconciliationPlan(
{
actions: [
{
kind: "close-delete-unbound-topic",
target: { chatId: 7, threadId: 92 },
observedAtMs: nowMs,
reason: "unbound-user-message",
leaderEpoch: 1,
},
],
},
{
getCurrentLeaderEpoch: () => currentEpoch,
async callApi<TResponse>() {
return {} as TResponse;
},
markStaleByTarget(target, syncStatus) {
staleTargets.push({ target, syncStatus });
currentEpoch = 2;
return true;
},
async persist() {
persisted = true;
},
recordRuntimeEvent() {},
},
);
assert.deepEqual(staleTargets, [
{ target: { chatId: 7, threadId: 92 }, syncStatus: "deleted" },
]);
assert.equal(persisted, false);
});
test("Thread reconciler rechecks leader epoch before expired-pending mutation", async () => {
let currentEpoch = 1;
let removed = false;
await applyThreadReconciliationPlan(
{
actions: [
{
kind: "close-delete-expired-pending-provision-topic",
target: { chatId: 7, threadId: 93 },
reason: "expired-pending-provision",
pendingProvisionId: "pending-expired",
leaderEpoch: 1,
},
],
},
{
getCurrentLeaderEpoch: () => currentEpoch,
async callApi<TResponse>(method: string) {
if (method === "deleteForumTopic") currentEpoch = 2;
return {} as TResponse;
},
removePendingProvisionById() {
removed = true;
return true;
},
recordRuntimeEvent() {},
},
);
assert.equal(removed, false);
});
test("Thread reconciler apply deletes expired pending topics and removes pending state", async () => {
const calls: Array<{ method: string; body: Record<string, unknown> }> = [];
const removedIds: string[] = [];
let persisted = false;
await applyThreadReconciliationPlan(
{
actions: [
{
kind: "close-delete-expired-pending-provision-topic",
target: { chatId: 7, threadId: 93 },
reason: "expired-pending-provision",
pendingProvisionId: "pending-expired",
instanceId: "leader-a",
},
],
},
{
async callApi<TResponse>(method: string, body: Record<string, unknown>) {
calls.push({ method, body });
return {} as TResponse;
},
removePendingProvisionById(id) {
removedIds.push(id);
return true;
},
async persist() {
persisted = true;
},
},
);
assert.deepEqual(calls, [
{
method: "closeForumTopic",
body: { chat_id: 7, message_thread_id: 93 },
},
{
method: "deleteForumTopic",
body: { chat_id: 7, message_thread_id: 93 },
},
]);
assert.deepEqual(removedIds, ["pending-expired"]);
assert.equal(persisted, true);
});
test("Thread reconciler apply keeps expired pending state when cleanup API fails", async () => {
const removedIds: string[] = [];
let persisted = false;
await applyThreadReconciliationPlan(
{
actions: [
{
kind: "close-delete-expired-pending-provision-topic",
target: { chatId: 7, threadId: 93 },
reason: "expired-pending-provision",
pendingProvisionId: "pending-expired",
instanceId: "leader-a",
},
],
},
{
async callApi() {
throw new Error("temporary Bot API failure");
},
removePendingProvisionById(id) {
removedIds.push(id);
return true;
},
async persist() {
persisted = true;
},
recordRuntimeEvent() {},
},
);
assert.deepEqual(removedIds, []);
assert.equal(persisted, false);
});
test("Thread reconciler apply does not mark deleted when cleanup API fails", async () => {
const staleTargets: unknown[] = [];
const runtimeEvents: Array<{
category: string;
error: string;
details?: Record<string, unknown>;
}> = [];
let persisted = false;
await applyThreadReconciliationPlan(
{
actions: [
{
kind: "close-delete-unbound-topic",
target: { chatId: 7, threadId: 90 },
observedAtMs: nowMs,
messageId: 123,
reason: "unbound-user-message",
},
],
},
{
async callApi() {
throw new Error("temporary Bot API failure");
},
markStaleByTarget(target, syncStatus) {
staleTargets.push({ target, syncStatus });
return true;
},
async persist() {
persisted = true;
},
recordRuntimeEvent(category, error, details) {
runtimeEvents.push({ category, error: String(error), details });
},
},
);
assert.deepEqual(staleTargets, []);
assert.equal(persisted, false);
assert.equal(
runtimeEvents.some(
(event) =>
event.error === "Unbound Telegram topic deleted" ||
event.details?.phase === "thread-reconciler-unbound-topic-delete",
),
false,
);
assert.equal(
runtimeEvents.some(
(event) =>
event.details?.phase === "thread-reconciler-cleanup-incomplete",
),
true,
);
});
test("Thread reconciler apply treats stale delete errors as confirmed cleanup", async () => {
const staleTargets: unknown[] = [];
let persisted = false;
await applyThreadReconciliationPlan(
{
actions: [
{
kind: "close-delete-unbound-topic",
target: { chatId: 7, threadId: 90 },
observedAtMs: nowMs,
messageId: 123,
reason: "unbound-user-message",
},
],
},
{
async callApi() {
throw new Error("Bad Request: message thread not found");
},
markStaleByTarget(target, syncStatus) {
staleTargets.push({ target, syncStatus });
return true;
},
async persist() {
persisted = true;
},
},
);
assert.deepEqual(staleTargets, [
{ target: { chatId: 7, threadId: 90 }, syncStatus: "deleted" },
]);
assert.equal(persisted, true);
});
test("Thread reconciler fails closed when destructive action lacks leader epoch", async () => {
let calls = 0;
const runtimeEvents: Array<{
category: string;
error: unknown;
details?: Record<string, unknown>;
}> = [];
await applyThreadReconciliationPlan(
{
actions: [
{
kind: "close-delete-unbound-topic",
target: { chatId: 7, threadId: 90 },
observedAtMs: nowMs,
reason: "unbound-user-message",
},
],
},
{
getCurrentLeaderEpoch: () => 5,
async callApi<TResponse>() {
calls += 1;
return {} as TResponse;
},
recordRuntimeEvent(category, error, details) {
runtimeEvents.push({ category, error, details });
},
},
);
assert.equal(calls, 0);
assert.deepEqual(runtimeEvents.at(0), {
category: "telegram",
error: "Thread reconciliation destructive action has no leader epoch",
details: {
phase: "thread-reconciler-missing-leader-epoch",
action: "close-delete-unbound-topic",
currentLeaderEpoch: 5,
chatId: 7,
threadId: 90,
},
});
});
test("Thread reconciler fails closed when ownership probe returns undefined", async () => {
const calls: string[] = [];
await applyThreadReconciliationPlan(
{
actions: [
{
kind: "close-delete-unbound-topic",
target: { chatId: 7, threadId: 90 },
observedAtMs: nowMs,
reason: "unbound-user-message",
},
],
},
{
getCurrentLeaderEpoch: () => undefined,
async callApi<TResponse>(method: string) {
calls.push(method);
return {} as TResponse;
},
recordRuntimeEvent() {},
},
);
assert.deepEqual(calls, []);
});
test("Thread reconciler plans manual disconnect cleanup as a domain action", () => {
assert.deepEqual(
planDisconnectedInstanceThreadCleanup({
target: { chatId: 7, threadId: 42 },
instanceId: "inst:1",
}),
{
actions: [
{
kind: "close-delete-disconnected-instance-topic",
target: { chatId: 7, threadId: 42 },
reason: "manual-disconnect",
instanceId: "inst:1",
},
],
},
);
});
test("Thread reconciler apply closes and deletes manual disconnect topics without marking stale", async () => {
const calls: Array<{ method: string; body: Record<string, unknown> }> = [];
let staleCalled = false;
await applyThreadReconciliationPlan(
{
actions: [
{
kind: "close-delete-disconnected-instance-topic",
target: { chatId: 7, threadId: 91 },
reason: "manual-disconnect",
instanceId: "inst-a",
},
],
},
{
async callApi<TResponse>(method: string, body: Record<string, unknown>) {
calls.push({ method, body });
return {} as TResponse;
},
markStaleByTarget() {
staleCalled = true;
return true;
},
},
);
assert.deepEqual(calls, [
{
method: "closeForumTopic",
body: { chat_id: 7, message_thread_id: 91 },
},
{
method: "deleteForumTopic",
body: { chat_id: 7, message_thread_id: 91 },
},
]);
assert.equal(staleCalled, false);
});
test("Thread reconciler apply is the close/delete path for unbound cleanup actions", async () => {
const calls: Array<{ method: string; body: Record<string, unknown> }> = [];
const staleTargets: unknown[] = [];
let persisted = false;
await applyThreadReconciliationPlan(
{
actions: [
{
kind: "close-delete-unbound-topic",
target: { chatId: 7, threadId: 90 },
observedAtMs: nowMs,
messageId: 123,
reason: "unbound-user-message",
},
],
},
{
async callApi<TResponse>(method: string, body: Record<string, unknown>) {
calls.push({ method, body });
return {} as TResponse;
},
markStaleByTarget(target, syncStatus) {
staleTargets.push({ target, syncStatus });
return true;
},
async persist() {
persisted = true;
},
},
);
assert.deepEqual(calls, [
{
method: "closeForumTopic",
body: { chat_id: 7, message_thread_id: 90 },
},
{
method: "deleteForumTopic",
body: { chat_id: 7, message_thread_id: 90 },
},
]);
assert.deepEqual(staleTargets, [
{ target: { chatId: 7, threadId: 90 }, syncStatus: "deleted" },
]);
assert.equal(persisted, true);
});