mirror of
https://github.com/vercel/chat.git
synced 2026-09-14 18:32:29 +08:00
846 lines
24 KiB
TypeScript
846 lines
24 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import {
|
|
createMockAdapter,
|
|
createMockState,
|
|
createTestMessage,
|
|
} from "./mock-adapter";
|
|
import { ThreadImpl } from "./thread";
|
|
import type { Adapter, Message } from "./types";
|
|
|
|
describe("ThreadImpl", () => {
|
|
describe("Per-thread state", () => {
|
|
let thread: ThreadImpl<{ aiMode?: boolean; counter?: number }>;
|
|
let mockAdapter: Adapter;
|
|
let mockState: ReturnType<typeof createMockState>;
|
|
|
|
beforeEach(() => {
|
|
mockAdapter = createMockAdapter();
|
|
mockState = createMockState();
|
|
|
|
thread = new ThreadImpl({
|
|
id: "slack:C123:1234.5678",
|
|
adapter: mockAdapter,
|
|
channelId: "C123",
|
|
stateAdapter: mockState,
|
|
});
|
|
});
|
|
|
|
it("should return null when no state has been set", async () => {
|
|
const state = await thread.state;
|
|
expect(state).toBeNull();
|
|
});
|
|
|
|
it("should return stored state", async () => {
|
|
// Pre-populate state in cache
|
|
mockState.cache.set("thread-state:slack:C123:1234.5678", {
|
|
aiMode: true,
|
|
});
|
|
|
|
const state = await thread.state;
|
|
expect(state).toEqual({ aiMode: true });
|
|
});
|
|
|
|
it("should set state and retrieve it", async () => {
|
|
await thread.setState({ aiMode: true });
|
|
|
|
const state = await thread.state;
|
|
expect(state).toEqual({ aiMode: true });
|
|
});
|
|
|
|
it("should merge state by default", async () => {
|
|
// Set initial state
|
|
await thread.setState({ aiMode: true });
|
|
|
|
// Set additional state - should merge
|
|
await thread.setState({ counter: 5 });
|
|
|
|
const state = await thread.state;
|
|
expect(state).toEqual({ aiMode: true, counter: 5 });
|
|
});
|
|
|
|
it("should overwrite existing keys when merging", async () => {
|
|
await thread.setState({ aiMode: true, counter: 1 });
|
|
await thread.setState({ counter: 10 });
|
|
|
|
const state = await thread.state;
|
|
expect(state).toEqual({ aiMode: true, counter: 10 });
|
|
});
|
|
|
|
it("should replace entire state when replace option is true", async () => {
|
|
await thread.setState({ aiMode: true, counter: 5 });
|
|
await thread.setState({ counter: 10 }, { replace: true });
|
|
|
|
const state = await thread.state;
|
|
expect(state).toEqual({ counter: 10 });
|
|
expect((state as { aiMode?: boolean }).aiMode).toBeUndefined();
|
|
});
|
|
|
|
it("should use correct key prefix for state storage", async () => {
|
|
await thread.setState({ aiMode: true });
|
|
|
|
expect(mockState.set).toHaveBeenCalledWith(
|
|
"thread-state:slack:C123:1234.5678",
|
|
{ aiMode: true },
|
|
expect.any(Number), // TTL
|
|
);
|
|
});
|
|
|
|
it("should call get with correct key", async () => {
|
|
await thread.state;
|
|
|
|
expect(mockState.get).toHaveBeenCalledWith(
|
|
"thread-state:slack:C123:1234.5678",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("Streaming", () => {
|
|
let thread: ThreadImpl;
|
|
let mockAdapter: Adapter;
|
|
let mockState: ReturnType<typeof createMockState>;
|
|
|
|
beforeEach(() => {
|
|
mockAdapter = createMockAdapter();
|
|
mockState = createMockState();
|
|
|
|
thread = new ThreadImpl({
|
|
id: "slack:C123:1234.5678",
|
|
adapter: mockAdapter,
|
|
channelId: "C123",
|
|
stateAdapter: mockState,
|
|
});
|
|
});
|
|
|
|
// Helper to create an async iterable from an array of chunks
|
|
async function* createTextStream(
|
|
chunks: string[],
|
|
delayMs = 0,
|
|
): AsyncIterable<string> {
|
|
for (const chunk of chunks) {
|
|
if (delayMs > 0) {
|
|
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
}
|
|
yield chunk;
|
|
}
|
|
}
|
|
|
|
it("should use adapter native streaming when available", async () => {
|
|
const mockStream = vi.fn().mockResolvedValue({
|
|
id: "msg-stream",
|
|
threadId: "t1",
|
|
raw: "Hello World",
|
|
});
|
|
mockAdapter.stream = mockStream;
|
|
|
|
const textStream = createTextStream(["Hello", " ", "World"]);
|
|
await thread.post(textStream);
|
|
|
|
expect(mockStream).toHaveBeenCalledWith(
|
|
"slack:C123:1234.5678",
|
|
expect.any(Object), // The async iterable
|
|
expect.any(Object), // Stream options
|
|
);
|
|
// Should NOT call postMessage for fallback
|
|
expect(mockAdapter.postMessage).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("should fall back to post+edit when adapter has no native streaming", async () => {
|
|
// Ensure no stream method
|
|
delete mockAdapter.stream;
|
|
|
|
const textStream = createTextStream(["Hello", " ", "World"]);
|
|
await thread.post(textStream);
|
|
|
|
// Should post initial placeholder
|
|
expect(mockAdapter.postMessage).toHaveBeenCalledWith(
|
|
"slack:C123:1234.5678",
|
|
"...",
|
|
);
|
|
// Should edit with final content
|
|
expect(mockAdapter.editMessage).toHaveBeenLastCalledWith(
|
|
"slack:C123:1234.5678",
|
|
"msg-1",
|
|
"Hello World",
|
|
);
|
|
});
|
|
|
|
it("should accumulate text chunks during streaming", async () => {
|
|
delete mockAdapter.stream;
|
|
|
|
const textStream = createTextStream([
|
|
"This ",
|
|
"is ",
|
|
"a ",
|
|
"test ",
|
|
"message.",
|
|
]);
|
|
const result = await thread.post(textStream);
|
|
|
|
// Final edit should have all accumulated text
|
|
expect(mockAdapter.editMessage).toHaveBeenLastCalledWith(
|
|
"slack:C123:1234.5678",
|
|
"msg-1",
|
|
"This is a test message.",
|
|
);
|
|
expect(result.text).toBe("This is a test message.");
|
|
});
|
|
|
|
it("should throttle edits to avoid rate limits", async () => {
|
|
vi.useFakeTimers();
|
|
delete mockAdapter.stream;
|
|
|
|
// Create a stream that yields chunks over time
|
|
const chunks = ["A", "B", "C", "D", "E"];
|
|
let chunkIndex = 0;
|
|
const textStream: AsyncIterable<string> = {
|
|
[Symbol.asyncIterator]() {
|
|
return {
|
|
async next() {
|
|
if (chunkIndex < chunks.length) {
|
|
const value = chunks[chunkIndex++];
|
|
return { value, done: false };
|
|
}
|
|
return { value: undefined, done: true };
|
|
},
|
|
};
|
|
},
|
|
};
|
|
|
|
const postPromise = thread.post(textStream);
|
|
|
|
// Initially should just post
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
expect(mockAdapter.postMessage).toHaveBeenCalledTimes(1);
|
|
|
|
// Advance time and let stream complete
|
|
await vi.advanceTimersByTimeAsync(2000);
|
|
await postPromise;
|
|
|
|
// Should have final edit
|
|
expect(mockAdapter.editMessage).toHaveBeenLastCalledWith(
|
|
"slack:C123:1234.5678",
|
|
"msg-1",
|
|
"ABCDE",
|
|
);
|
|
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("should return SentMessage with edit and delete capabilities", async () => {
|
|
delete mockAdapter.stream;
|
|
|
|
const textStream = createTextStream(["Hello"]);
|
|
const result = await thread.post(textStream);
|
|
|
|
expect(result.id).toBe("msg-1");
|
|
expect(typeof result.edit).toBe("function");
|
|
expect(typeof result.delete).toBe("function");
|
|
expect(typeof result.addReaction).toBe("function");
|
|
expect(typeof result.removeReaction).toBe("function");
|
|
});
|
|
|
|
it("should handle empty stream", async () => {
|
|
delete mockAdapter.stream;
|
|
|
|
const textStream = createTextStream([]);
|
|
await thread.post(textStream);
|
|
|
|
// Should post initial placeholder
|
|
expect(mockAdapter.postMessage).toHaveBeenCalledWith(
|
|
"slack:C123:1234.5678",
|
|
"...",
|
|
);
|
|
// Should edit with empty string (final content)
|
|
expect(mockAdapter.editMessage).toHaveBeenLastCalledWith(
|
|
"slack:C123:1234.5678",
|
|
"msg-1",
|
|
"",
|
|
);
|
|
});
|
|
|
|
it("should pass stream options from current message context", async () => {
|
|
const mockStream = vi.fn().mockResolvedValue({
|
|
id: "msg-stream",
|
|
threadId: "t1",
|
|
raw: "Hello",
|
|
});
|
|
mockAdapter.stream = mockStream;
|
|
|
|
// Create thread with current message context
|
|
const threadWithContext = new ThreadImpl({
|
|
id: "slack:C123:1234.5678",
|
|
adapter: mockAdapter,
|
|
channelId: "C123",
|
|
stateAdapter: mockState,
|
|
currentMessage: {
|
|
id: "original-msg",
|
|
threadId: "slack:C123:1234.5678",
|
|
text: "test",
|
|
formatted: { type: "root", children: [] },
|
|
raw: { team_id: "T123" },
|
|
author: {
|
|
userId: "U456",
|
|
userName: "user",
|
|
fullName: "Test User",
|
|
isBot: false,
|
|
isMe: false,
|
|
},
|
|
metadata: { dateSent: new Date(), edited: false },
|
|
attachments: [],
|
|
},
|
|
});
|
|
|
|
const textStream = createTextStream(["Hello"]);
|
|
await threadWithContext.post(textStream);
|
|
|
|
expect(mockStream).toHaveBeenCalledWith(
|
|
"slack:C123:1234.5678",
|
|
expect.any(Object),
|
|
expect.objectContaining({
|
|
recipientUserId: "U456",
|
|
recipientTeamId: "T123",
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("allMessages iterator", () => {
|
|
let thread: ThreadImpl;
|
|
let mockAdapter: Adapter;
|
|
let mockState: ReturnType<typeof createMockState>;
|
|
|
|
beforeEach(() => {
|
|
mockAdapter = createMockAdapter();
|
|
mockState = createMockState();
|
|
|
|
thread = new ThreadImpl({
|
|
id: "slack:C123:1234.5678",
|
|
adapter: mockAdapter,
|
|
channelId: "C123",
|
|
stateAdapter: mockState,
|
|
});
|
|
});
|
|
|
|
it("should iterate through all messages in chronological order", async () => {
|
|
const messages = [
|
|
createTestMessage("msg-1", "First message"),
|
|
createTestMessage("msg-2", "Second message"),
|
|
createTestMessage("msg-3", "Third message"),
|
|
];
|
|
|
|
(mockAdapter.fetchMessages as ReturnType<typeof vi.fn>).mockResolvedValue(
|
|
{
|
|
messages,
|
|
nextCursor: undefined,
|
|
},
|
|
);
|
|
|
|
const collected: Message[] = [];
|
|
for await (const msg of thread.allMessages) {
|
|
collected.push(msg);
|
|
}
|
|
|
|
expect(collected).toHaveLength(3);
|
|
expect(collected[0].text).toBe("First message");
|
|
expect(collected[1].text).toBe("Second message");
|
|
expect(collected[2].text).toBe("Third message");
|
|
});
|
|
|
|
it("should use forward direction for pagination", async () => {
|
|
(mockAdapter.fetchMessages as ReturnType<typeof vi.fn>).mockResolvedValue(
|
|
{
|
|
messages: [],
|
|
nextCursor: undefined,
|
|
},
|
|
);
|
|
|
|
// Consume the iterator
|
|
for await (const _msg of thread.allMessages) {
|
|
// No messages
|
|
}
|
|
|
|
expect(mockAdapter.fetchMessages).toHaveBeenCalledWith(
|
|
"slack:C123:1234.5678",
|
|
expect.objectContaining({
|
|
direction: "forward",
|
|
limit: 100,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("should handle pagination across multiple pages", async () => {
|
|
const page1 = [
|
|
createTestMessage("msg-1", "Page 1 - Message 1"),
|
|
createTestMessage("msg-2", "Page 1 - Message 2"),
|
|
];
|
|
const page2 = [
|
|
createTestMessage("msg-3", "Page 2 - Message 1"),
|
|
createTestMessage("msg-4", "Page 2 - Message 2"),
|
|
];
|
|
const page3 = [createTestMessage("msg-5", "Page 3 - Message 1")];
|
|
|
|
let callCount = 0;
|
|
(
|
|
mockAdapter.fetchMessages as ReturnType<typeof vi.fn>
|
|
).mockImplementation(async (_threadId, options) => {
|
|
callCount++;
|
|
if (callCount === 1) {
|
|
expect(options?.cursor).toBeUndefined();
|
|
return { messages: page1, nextCursor: "cursor-1" };
|
|
}
|
|
if (callCount === 2) {
|
|
expect(options?.cursor).toBe("cursor-1");
|
|
return { messages: page2, nextCursor: "cursor-2" };
|
|
}
|
|
expect(options?.cursor).toBe("cursor-2");
|
|
return { messages: page3, nextCursor: undefined };
|
|
});
|
|
|
|
const collected: Message[] = [];
|
|
for await (const msg of thread.allMessages) {
|
|
collected.push(msg);
|
|
}
|
|
|
|
expect(collected).toHaveLength(5);
|
|
expect(collected.map((m) => m.text)).toEqual([
|
|
"Page 1 - Message 1",
|
|
"Page 1 - Message 2",
|
|
"Page 2 - Message 1",
|
|
"Page 2 - Message 2",
|
|
"Page 3 - Message 1",
|
|
]);
|
|
expect(mockAdapter.fetchMessages).toHaveBeenCalledTimes(3);
|
|
});
|
|
|
|
it("should handle empty thread", async () => {
|
|
(mockAdapter.fetchMessages as ReturnType<typeof vi.fn>).mockResolvedValue(
|
|
{
|
|
messages: [],
|
|
nextCursor: undefined,
|
|
},
|
|
);
|
|
|
|
const collected: Message[] = [];
|
|
for await (const msg of thread.allMessages) {
|
|
collected.push(msg);
|
|
}
|
|
|
|
expect(collected).toHaveLength(0);
|
|
expect(mockAdapter.fetchMessages).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("should stop when nextCursor is undefined", async () => {
|
|
const messages = [createTestMessage("msg-1", "Single message")];
|
|
|
|
(mockAdapter.fetchMessages as ReturnType<typeof vi.fn>).mockResolvedValue(
|
|
{
|
|
messages,
|
|
nextCursor: undefined,
|
|
},
|
|
);
|
|
|
|
const collected: Message[] = [];
|
|
for await (const msg of thread.allMessages) {
|
|
collected.push(msg);
|
|
}
|
|
|
|
expect(collected).toHaveLength(1);
|
|
expect(mockAdapter.fetchMessages).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("should stop when empty page is returned with cursor", async () => {
|
|
// Edge case: adapter returns a cursor but no messages (shouldn't happen, but be defensive)
|
|
(mockAdapter.fetchMessages as ReturnType<typeof vi.fn>).mockResolvedValue(
|
|
{
|
|
messages: [],
|
|
nextCursor: "some-cursor", // Cursor present but no messages
|
|
},
|
|
);
|
|
|
|
const collected: Message[] = [];
|
|
for await (const msg of thread.allMessages) {
|
|
collected.push(msg);
|
|
}
|
|
|
|
expect(collected).toHaveLength(0);
|
|
expect(mockAdapter.fetchMessages).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("should allow breaking out of iteration early", async () => {
|
|
const page1 = [
|
|
createTestMessage("msg-1", "Message 1"),
|
|
createTestMessage("msg-2", "Message 2"),
|
|
];
|
|
|
|
(mockAdapter.fetchMessages as ReturnType<typeof vi.fn>).mockResolvedValue(
|
|
{
|
|
messages: page1,
|
|
nextCursor: "more-available",
|
|
},
|
|
);
|
|
|
|
const collected: Message[] = [];
|
|
for await (const msg of thread.allMessages) {
|
|
collected.push(msg);
|
|
if (msg.id === "msg-1") {
|
|
break; // Break after first message
|
|
}
|
|
}
|
|
|
|
expect(collected).toHaveLength(1);
|
|
expect(collected[0].id).toBe("msg-1");
|
|
// Should only fetch once since we broke early within first page
|
|
expect(mockAdapter.fetchMessages).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("should be reusable (can iterate multiple times)", async () => {
|
|
const messages = [createTestMessage("msg-1", "Test message")];
|
|
|
|
(mockAdapter.fetchMessages as ReturnType<typeof vi.fn>).mockResolvedValue(
|
|
{
|
|
messages,
|
|
nextCursor: undefined,
|
|
},
|
|
);
|
|
|
|
// First iteration
|
|
const first: Message[] = [];
|
|
for await (const msg of thread.allMessages) {
|
|
first.push(msg);
|
|
}
|
|
|
|
// Second iteration
|
|
const second: Message[] = [];
|
|
for await (const msg of thread.allMessages) {
|
|
second.push(msg);
|
|
}
|
|
|
|
expect(first).toHaveLength(1);
|
|
expect(second).toHaveLength(1);
|
|
expect(mockAdapter.fetchMessages).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|
|
|
|
describe("refresh", () => {
|
|
let thread: ThreadImpl;
|
|
let mockAdapter: Adapter;
|
|
let mockState: ReturnType<typeof createMockState>;
|
|
|
|
beforeEach(() => {
|
|
mockAdapter = createMockAdapter();
|
|
mockState = createMockState();
|
|
|
|
thread = new ThreadImpl({
|
|
id: "slack:C123:1234.5678",
|
|
adapter: mockAdapter,
|
|
channelId: "C123",
|
|
stateAdapter: mockState,
|
|
});
|
|
});
|
|
|
|
it("should update recentMessages from API", async () => {
|
|
const messages = [
|
|
createTestMessage("msg-1", "Recent 1"),
|
|
createTestMessage("msg-2", "Recent 2"),
|
|
];
|
|
|
|
(mockAdapter.fetchMessages as ReturnType<typeof vi.fn>).mockResolvedValue(
|
|
{
|
|
messages,
|
|
nextCursor: undefined,
|
|
},
|
|
);
|
|
|
|
expect(thread.recentMessages).toHaveLength(0);
|
|
|
|
await thread.refresh();
|
|
|
|
expect(thread.recentMessages).toHaveLength(2);
|
|
expect(thread.recentMessages[0].text).toBe("Recent 1");
|
|
expect(thread.recentMessages[1].text).toBe("Recent 2");
|
|
});
|
|
|
|
it("should fetch with limit of 50", async () => {
|
|
(mockAdapter.fetchMessages as ReturnType<typeof vi.fn>).mockResolvedValue(
|
|
{
|
|
messages: [],
|
|
nextCursor: undefined,
|
|
},
|
|
);
|
|
|
|
await thread.refresh();
|
|
|
|
expect(mockAdapter.fetchMessages).toHaveBeenCalledWith(
|
|
"slack:C123:1234.5678",
|
|
{ limit: 50 },
|
|
);
|
|
});
|
|
|
|
it("should use default (backward) direction", async () => {
|
|
(mockAdapter.fetchMessages as ReturnType<typeof vi.fn>).mockResolvedValue(
|
|
{
|
|
messages: [],
|
|
nextCursor: undefined,
|
|
},
|
|
);
|
|
|
|
await thread.refresh();
|
|
|
|
// refresh() doesn't specify direction, so adapter uses its default (backward)
|
|
expect(mockAdapter.fetchMessages).toHaveBeenCalledWith(
|
|
"slack:C123:1234.5678",
|
|
{ limit: 50 },
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("fetchMessages direction behavior", () => {
|
|
let thread: ThreadImpl;
|
|
let mockAdapter: Adapter;
|
|
let mockState: ReturnType<typeof createMockState>;
|
|
|
|
beforeEach(() => {
|
|
mockAdapter = createMockAdapter();
|
|
mockState = createMockState();
|
|
|
|
thread = new ThreadImpl({
|
|
id: "slack:C123:1234.5678",
|
|
adapter: mockAdapter,
|
|
channelId: "C123",
|
|
stateAdapter: mockState,
|
|
});
|
|
});
|
|
|
|
it("should pass direction option to adapter", async () => {
|
|
(mockAdapter.fetchMessages as ReturnType<typeof vi.fn>).mockResolvedValue(
|
|
{
|
|
messages: [],
|
|
nextCursor: undefined,
|
|
},
|
|
);
|
|
|
|
// Test that allMessages passes forward direction
|
|
for await (const _msg of thread.allMessages) {
|
|
// No messages
|
|
}
|
|
|
|
const call = (mockAdapter.fetchMessages as ReturnType<typeof vi.fn>).mock
|
|
.calls[0];
|
|
expect(call[1]).toEqual(
|
|
expect.objectContaining({
|
|
direction: "forward",
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("concurrent iteration safety", () => {
|
|
let thread: ThreadImpl;
|
|
let mockAdapter: Adapter;
|
|
let mockState: ReturnType<typeof createMockState>;
|
|
|
|
beforeEach(() => {
|
|
mockAdapter = createMockAdapter();
|
|
mockState = createMockState();
|
|
|
|
thread = new ThreadImpl({
|
|
id: "slack:C123:1234.5678",
|
|
adapter: mockAdapter,
|
|
channelId: "C123",
|
|
stateAdapter: mockState,
|
|
});
|
|
});
|
|
|
|
it("should handle concurrent iterations independently", async () => {
|
|
let callCount = 0;
|
|
(
|
|
mockAdapter.fetchMessages as ReturnType<typeof vi.fn>
|
|
).mockImplementation(async () => {
|
|
callCount++;
|
|
// Return different data for each call to prove independence
|
|
return {
|
|
messages: [
|
|
createTestMessage(`msg-${callCount}`, `Call ${callCount}`),
|
|
],
|
|
nextCursor: undefined,
|
|
};
|
|
});
|
|
|
|
// Start two concurrent iterations
|
|
const results = await Promise.all([
|
|
(async () => {
|
|
const msgs: Message[] = [];
|
|
for await (const msg of thread.allMessages) {
|
|
msgs.push(msg);
|
|
}
|
|
return msgs;
|
|
})(),
|
|
(async () => {
|
|
const msgs: Message[] = [];
|
|
for await (const msg of thread.allMessages) {
|
|
msgs.push(msg);
|
|
}
|
|
return msgs;
|
|
})(),
|
|
]);
|
|
|
|
// Each iteration should have its own messages
|
|
expect(results[0]).toHaveLength(1);
|
|
expect(results[1]).toHaveLength(1);
|
|
// They should have fetched independently
|
|
expect(mockAdapter.fetchMessages).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("should not share cursor state between iterations", async () => {
|
|
const cursors: (string | undefined)[] = [];
|
|
(
|
|
mockAdapter.fetchMessages as ReturnType<typeof vi.fn>
|
|
).mockImplementation(async (_threadId, options) => {
|
|
cursors.push(options?.cursor);
|
|
return {
|
|
messages: [createTestMessage("msg-1", "Test")],
|
|
nextCursor: undefined,
|
|
};
|
|
});
|
|
|
|
// Two sequential iterations
|
|
for await (const _msg of thread.allMessages) {
|
|
// Consume
|
|
}
|
|
for await (const _msg of thread.allMessages) {
|
|
// Consume
|
|
}
|
|
|
|
// Both iterations should start with undefined cursor
|
|
expect(cursors).toEqual([undefined, undefined]);
|
|
});
|
|
});
|
|
|
|
describe("postEphemeral", () => {
|
|
let thread: ThreadImpl;
|
|
let mockAdapter: Adapter;
|
|
let mockState: ReturnType<typeof createMockState>;
|
|
|
|
beforeEach(() => {
|
|
mockAdapter = createMockAdapter();
|
|
mockState = createMockState();
|
|
|
|
thread = new ThreadImpl({
|
|
id: "slack:C123:1234.5678",
|
|
adapter: mockAdapter,
|
|
channelId: "C123",
|
|
stateAdapter: mockState,
|
|
});
|
|
});
|
|
|
|
it("should use adapter postEphemeral when available", async () => {
|
|
const mockPostEphemeral = vi.fn().mockResolvedValue({
|
|
id: "ephemeral-1",
|
|
threadId: "slack:C123:1234.5678",
|
|
usedFallback: false,
|
|
raw: {},
|
|
});
|
|
mockAdapter.postEphemeral = mockPostEphemeral;
|
|
|
|
const result = await thread.postEphemeral("U456", "Secret message", {
|
|
fallbackToDM: true,
|
|
});
|
|
|
|
expect(mockPostEphemeral).toHaveBeenCalledWith(
|
|
"slack:C123:1234.5678",
|
|
"U456",
|
|
"Secret message",
|
|
);
|
|
expect(result).toEqual({
|
|
id: "ephemeral-1",
|
|
threadId: "slack:C123:1234.5678",
|
|
usedFallback: false,
|
|
raw: {},
|
|
});
|
|
});
|
|
|
|
it("should extract userId from Author object", async () => {
|
|
const mockPostEphemeral = vi.fn().mockResolvedValue({
|
|
id: "ephemeral-1",
|
|
threadId: "slack:C123:1234.5678",
|
|
usedFallback: false,
|
|
raw: {},
|
|
});
|
|
mockAdapter.postEphemeral = mockPostEphemeral;
|
|
|
|
const author = {
|
|
userId: "U789",
|
|
userName: "testuser",
|
|
fullName: "Test User",
|
|
isBot: false as const,
|
|
isMe: false as const,
|
|
};
|
|
|
|
await thread.postEphemeral(author, "Secret message", {
|
|
fallbackToDM: true,
|
|
});
|
|
|
|
expect(mockPostEphemeral).toHaveBeenCalledWith(
|
|
"slack:C123:1234.5678",
|
|
"U789",
|
|
"Secret message",
|
|
);
|
|
});
|
|
|
|
it("should fallback to DM when adapter has no postEphemeral and fallbackToDM is true", async () => {
|
|
// Ensure no postEphemeral method
|
|
delete mockAdapter.postEphemeral;
|
|
|
|
const result = await thread.postEphemeral("U456", "Secret message", {
|
|
fallbackToDM: true,
|
|
});
|
|
|
|
// Should open DM
|
|
expect(mockAdapter.openDM).toHaveBeenCalledWith("U456");
|
|
// Should post message to DM thread
|
|
expect(mockAdapter.postMessage).toHaveBeenCalledWith(
|
|
"slack:DU456:",
|
|
"Secret message",
|
|
);
|
|
// Should return with usedFallback: true
|
|
expect(result).toEqual({
|
|
id: "msg-1",
|
|
threadId: "slack:DU456:",
|
|
usedFallback: true,
|
|
raw: {},
|
|
});
|
|
});
|
|
|
|
it("should return null when adapter has no postEphemeral and fallbackToDM is false", async () => {
|
|
// Ensure no postEphemeral method
|
|
delete mockAdapter.postEphemeral;
|
|
|
|
const result = await thread.postEphemeral("U456", "Secret message", {
|
|
fallbackToDM: false,
|
|
});
|
|
|
|
// Should not open DM or post message
|
|
expect(mockAdapter.openDM).not.toHaveBeenCalled();
|
|
expect(mockAdapter.postMessage).not.toHaveBeenCalled();
|
|
// Should return null
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it("should return null when adapter has no postEphemeral or openDM", async () => {
|
|
// Remove both methods
|
|
delete mockAdapter.postEphemeral;
|
|
delete mockAdapter.openDM;
|
|
|
|
const result = await thread.postEphemeral("U456", "Secret message", {
|
|
fallbackToDM: true,
|
|
});
|
|
|
|
// Should return null since no fallback is possible
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
// Note: Streaming is prevented at the type level - postEphemeral accepts
|
|
// AdapterPostableMessage | CardJSXElement which excludes AsyncIterable<string>
|
|
});
|
|
});
|