mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-30 21:38:42 +01:00
test(server): prove publication commit wakes and paged voice recovery
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
04a27268f5
commit
fe5e251319
2 files changed
+74
No files matched your search
@@ -169,3 +169,28 @@ intent after call termination, nested producer rollback/commit, real question
|
||||
retrieval, authority restoration, and drain/resume without passive SQL. Final
|
||||
server typechecking also passed. The earlier retained-fixture failures remain
|
||||
recorded above; shared publication-path and broader acceptance work remains open.
|
||||
|
||||
## Publication-path and broader verification
|
||||
|
||||
Both real chat-service paths now have a commit-boundary regression: normal
|
||||
publication and operator mark-delivered. The tests hold the real final transaction
|
||||
before its voice notification, wait until the early enqueue wake is fully idle,
|
||||
and then verify one send after publication. Disabling only the final notification
|
||||
made both tests fail with zero sends; restoring it passed both. A 105-action real
|
||||
PostgreSQL case also proves startup inventory paging and bounded action recovery.
|
||||
|
||||
Workspace typechecking passed. Seven shared access/connection/settings suites
|
||||
passed all 651 tests. A combined full voice/startup/parent-recovery run used one
|
||||
external database: voice/startup passed 123 tests, but ten parent recovery tests
|
||||
observed the voice suite's intentionally retained sessions and failed their global
|
||||
quiet assertions. This is a test invocation isolation error, not accepted evidence
|
||||
for parent recovery. The parent suite is being rerun against its separate database.
|
||||
Do not combine these two suites under one external database URL.
|
||||
|
||||
The 107 full voice cases now include real publication and operator producers,
|
||||
105 abandoned sends, restart, rollback/outer commit, drain, authority restoration,
|
||||
question barriers and no-resend outcomes. Full workspace build and current-head
|
||||
PR CI/reviews remain required. The local all-suite command has the previously
|
||||
recorded embedded-PostgreSQL initialization limitation; targeted database suites
|
||||
run on real isolated PostgreSQL instead. Do not count skipped or uninitialized
|
||||
database tests as passes.
|
||||
@@ -1,3 +1,4 @@
|
||||
import * as voiceReplyNotifications from "../services/voice/voice-reply-notifications.js";
|
||||
import { voiceReplySchedule } from "../services/voice/voice-reply-schedule.js";
|
||||
import { registerVoiceReplyWork } from "../services/voice/voice-reply-work.js";
|
||||
import { pushVoiceReplies } from "../services/voice/voice-reply-push.js";
|
||||
@@ -816,6 +817,54 @@ const support = external ? { supported: true } : await getEmbeddedPostgresTestSu
|
||||
await worker.quiet(); expect(worker.onError).not.toHaveBeenCalled();
|
||||
} finally { await worker.coordinator.stop(); vi.useRealTimers(); }
|
||||
});
|
||||
it.each(["normal publication", "operator resolution"] as const)("wakes phone delivery from actual %s after its early enqueue has gone idle", async path => {
|
||||
const f = await pushFixture();
|
||||
await db.update(chatPublications).set({ state: path === "normal publication" ? "pending" : "delivery_unknown" })
|
||||
.where(eq(chatPublications.id, f.publication.id));
|
||||
const chat = chatChannelService(db, { heartbeat: { wakeup: vi.fn() } });
|
||||
vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"], shouldAdvanceTime: true });
|
||||
const worker = await replyWorker(f);
|
||||
let release!: () => void, reached!: () => void;
|
||||
const barrier = new Promise<void>(resolve => { release = resolve; });
|
||||
const entered = new Promise<void>(resolve => { reached = resolve; });
|
||||
const notify = voiceReplyNotifications.notifyVoicePublication;
|
||||
const notification = vi.spyOn(voiceReplyNotifications, "notifyVoicePublication").mockImplementationOnce(async (...args) => {
|
||||
reached(); await barrier; return notify(...args);
|
||||
});
|
||||
let publishing: Promise<unknown> | undefined;
|
||||
try {
|
||||
await worker.settled(); await worker.quiet(2000);
|
||||
publishing = path === "normal publication"
|
||||
? chat.processPendingPublications(25, { maintenance: false })
|
||||
: chat.resolvePublication(f.endpointId, f.publication.id, "mark_delivered", f.callerId);
|
||||
await Promise.race([entered, publishing.then(() => { throw new Error("Publication ended before its final commit boundary"); })]);
|
||||
// Hold the real final publication transaction before its voice intent.
|
||||
// An earlier enqueue wake can fully drain while the row is not published.
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
expect(f.transport.sendCallMessage).not.toHaveBeenCalled();
|
||||
expect(worker.coordinator.nextWakeAt()).toBeNull();
|
||||
release(); await publishing; await worker.settled();
|
||||
expect((await db.select().from(chatPublications).where(eq(chatPublications.id, f.publication.id)))[0].state).toBe("published");
|
||||
expect(f.transport.sendCallMessage).toHaveBeenCalledOnce(); await worker.quiet(); expect(worker.onError).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
release(); await publishing; notification.mockRestore(); await worker.coordinator.stop(); await chat.shutdown(); vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
it("reconstructs and quarantines every abandoned phone dispatch across inventory and work batches", async () => {
|
||||
const f = await pushFixture();
|
||||
await db.update(chatVoiceSessions).set({ state: "ended", endedAt: new Date() }).where(eq(chatVoiceSessions.id, f.session.id));
|
||||
await db.insert(chatActions).values(Array.from({ length: 105 }, () => ({ companyId: f.companyId, endpointId: f.endpointId,
|
||||
conversationId: f.conversationId, kind: "speko_voice_reply_push", providerActionId: randomUUID(), status: "dispatching",
|
||||
payload: { sessionId: f.session.id, replyId: randomUUID() }, updatedAt: new Date(Date.now() - 60_000) })));
|
||||
vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"], shouldAdvanceTime: true });
|
||||
const worker = await replyWorker(f);
|
||||
try {
|
||||
await worker.settled();
|
||||
const actions = await db.select().from(chatActions).where(eq(chatActions.endpointId, f.endpointId));
|
||||
expect(actions).toHaveLength(105); expect(actions.every(action => action.status === "unknown")).toBe(true);
|
||||
expect(f.transport.sendCallMessage).not.toHaveBeenCalled(); await worker.quiet(); expect(worker.onError).not.toHaveBeenCalled();
|
||||
} finally { await worker.coordinator.stop(); vi.useRealTimers(); }
|
||||
});
|
||||
it("notifies phone questions once and waits for authorized retrieval before later answers", async () => {
|
||||
const f = await pushFixture(), interactionId = randomUUID();
|
||||
await db.insert(issueThreadInteractions).values({id: interactionId, companyId: f.companyId, issueId: f.issueId, kind: "ask_user_questions", status: "pending", addresseeUserId: f.callerId, effectiveResolverPolicy: "addressee_only", payload: {version: 1, questions: [{id: "color", prompt: "Which color?", selectionMode: "single", required: true, options: [{id: "cobalt", label: "Cobalt"}]}]}});
|
||||
|
||||
Reference in new issue
Block a user