Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions packages/zulip/adapter-events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -720,6 +720,102 @@ effectTest(
{ layer: TestContext.TestContext },
)

// Zulip serves GET /users from a realm-wide cache that a concurrent user
// creation can leave without the new user until the next change to any user.
// GET /users?user_ids=… reads the database, so it still knows them.
const seedStaleUserList = (
stub: StubHttpClient,
cached: ReadonlyArray<unknown>,
byIds: ReadonlyArray<unknown>,
): Effect.Effect<void> =>
stub.respondSequence('GET', '/api/v1/users', [
{ body: { result: 'success', members: cached } },
{ body: { result: 'success', members: byIds } },
])

effectTest(
'inbox.events names a sender the cached user list has not caught up with',
() =>
Effect.gen(function* () {
const stub = yield* makeStubHttpClient
const adapter = yield* buildAdapter(stub)
yield* seedRegister(stub)
const pinnedBot = {
user_id: 777,
email: 'pinned-bot@example.com',
full_name: 'pinned-bot',
is_bot: true,
is_active: true,
role: 400,
}
yield* seedStaleUserList(stub, [HERMES, MAINTAINER], [pinnedBot])
yield* stub.respondSequence('GET', '/api/v1/events', [
{
body: {
result: 'success',
events: [
messageEvent(
5,
aZulipMessage({
sender_id: pinnedBot.user_id,
sender_full_name: pinnedBot.full_name,
}),
),
],
},
},
{ hang: true },
])
const queue = yield* eventQueue(adapter)
const posted = yield* Queue.take(queue)
expect(posted.kind).toBe('message-posted')
if (posted.kind === 'message-posted') {
expect(posted.message.sender.kind).toBe('agent')
}
}),
{ layer: TestContext.TestContext },
)

effectTest(
'inbox.events wakes the seat on its own mention while the cached user list lacks it',
() =>
Effect.gen(function* () {
const stub = yield* makeStubHttpClient
const adapter = yield* buildAdapter(stub)
yield* seedRegister(stub)
yield* seedStaleUserList(stub, [MAINTAINER], [HERMES])
yield* stub.respond('GET', '/api/v1/messages', {
body: {
result: 'success',
messages: [
{
id: 300,
content:
'<p><span class="user-mention" data-user-id="9">@hermes-agent</span> listen</p>',
},
],
},
})
yield* stub.respondSequence('GET', '/api/v1/events', [
{
body: {
result: 'success',
events: [
messageEvent(7, aZulipMessage({ id: 300, content: '@**hermes-agent** listen' })),
],
},
},
{ hang: true },
])
const queue = yield* eventQueue(adapter)
const mention = yield* Queue.take(queue).pipe(
Effect.repeat({ until: (e: InboundEvent) => e.kind === 'mention-received' }),
)
expect(mention.kind).toBe('mention-received')
}),
{ layer: TestContext.TestContext },
)

effectTest(
'inbox.events ignores Zulip heartbeat events but advances last_event_id',
() =>
Expand Down
117 changes: 117 additions & 0 deletions packages/zulip/adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -778,6 +778,11 @@ const seedSendMessage = (stub: StubHttpClient, id: number): Effect.Effect<void>
})
})

// A mention the directory cannot place is put to Zulip's renderer before the
// write is refused, so a test that expects a refusal seeds the rendering.
const seedRender = (stub: StubHttpClient, rendered: string): Effect.Effect<void> =>
stub.respond('POST', '/api/v1/messages/render', { body: { result: 'success', rendered } })

effectTest(
'publisher.post sends type=channel + to=channel.name + content; defaults topic to "(no topic)" when no thread',
() =>
Expand Down Expand Up @@ -1213,6 +1218,7 @@ effectTest('publisher.post fails with a tagged UnresolvedMention on a dead menti
Effect.gen(function* () {
const stub = yield* makeStubHttpClient
yield* seedSendMessage(stub, 200)
yield* seedRender(stub, '<p>decision for @<strong>Alice Example</strong> to make</p>')
const adapter = yield* buildAdapter(stub)
// The directory holds HERMES only; @**Graeme Foster** resolves to nobody.
const error = yield* Effect.flip(
Expand Down Expand Up @@ -1250,6 +1256,10 @@ test.each([
Effect.gen(function* () {
const stub = yield* makeStubHttpClient
yield* seedSendMessage(stub, 203)
yield* seedRender(
stub,
'<p><span class="user-mention silent" data-user-id="42">cc-myproject-abcdef12</span> your answer</p>',
)
const adapter = yield* buildAdapter(stub)
yield* seedUsers(stub, [
HERMES,
Expand All @@ -1276,6 +1286,112 @@ test.each([
),
)

// Zulip serves GET /users from a realm-wide cache, and a GET /users that lands
// while a user is being created re-caches the list without them until the next
// change to any user. Zulip's mention renderer and GET /users?user_ids=… read
// the database, so they still know the new user.
const PINNED = {
user_id: 777,
email: 'pinned-bot@example.com',
full_name: 'pinned-bot',
is_bot: true,
is_active: true,
role: 400,
} as const

const pinnedIdentity: Identity = {
id: decodeIdentityIdSync('777'),
name: decodeDisplayNameSync('pinned-bot'),
kind: 'agent',
}

const PINNED_SPAN = '<span class="user-mention" data-user-id="777">@pinned-bot</span>'

const seedStaleUserList = (
stub: StubHttpClient,
cached: ReadonlyArray<unknown>,
byIds: ReadonlyArray<unknown>,
): Effect.Effect<void> =>
stub.respondSequence('GET', '/api/v1/users', [
{ body: { result: 'success', members: cached } },
{ body: { result: 'success', members: byIds } },
])

const userListReads = (stub: StubHttpClient) =>
stub.captured.pipe(
Effect.map((reqs) =>
reqs.filter((r) => r.method === 'GET' && r.url.pathname === '/api/v1/users'),
),
)

effectTest('identity.resolve finds a bot the cached user list has not caught up with', () =>
Effect.gen(function* () {
const stub = yield* makeStubHttpClient
const adapter = yield* zulipAdapter(stub, yield* makeConfig())
yield* seedStaleUserList(stub, [HERMES], [PINNED])
yield* seedRender(stub, `<p>${PINNED_SPAN}</p>`)
const resolved = yield* adapter.identity.resolve(decodeDisplayNameSync('pinned-bot'))
expect(resolved).toEqual(Option.some(pinnedIdentity))
const reads = yield* userListReads(stub)
expect(reads.at(-1)?.url.searchParams.get('user_ids')).toBe('[777]')
}),
)

effectTest('identity.resolve answers none without a second read when the realm knows no one', () =>
Effect.gen(function* () {
const stub = yield* makeStubHttpClient
const adapter = yield* zulipAdapter(stub, yield* makeConfig())
yield* seedUsers(stub, [HERMES])
yield* seedRender(stub, '<p>@<strong>nobody</strong></p>')
const resolved = yield* adapter.identity.resolve(decodeDisplayNameSync('nobody'))
expect(resolved).toEqual(Option.none())
expect(yield* userListReads(stub)).toHaveLength(1)
}),
)

effectTest(
'publisher.post delivers a mention of a bot the cached user list has not caught up with',
() =>
Effect.gen(function* () {
const stub = yield* makeStubHttpClient
yield* seedSendMessage(stub, 204)
const adapter = yield* buildAdapter(stub)
yield* seedStaleUserList(stub, [HERMES], [PINNED])
yield* seedRender(stub, `<p>${PINNED_SPAN} hello</p>`)
yield* adapter.publisher.post(
generalChannel.name,
decodeMessageBodySync('@**pinned-bot** hello'),
)
const params = new URLSearchParams(
(yield* findRequest(stub, 'POST', '/api/v1/messages')).body,
)
expect(params.get('content')).toBe('@**pinned-bot** hello')
}),
)

effectTest('history.readChannel names a sender the cached user list has not caught up with', () =>
Effect.gen(function* () {
const stub = yield* makeStubHttpClient
const adapter = yield* buildAdapter(stub)
yield* seedStaleUserList(stub, [HERMES], [PINNED])
yield* seedMessages(stub, [
{
id: 9001,
sender_id: PINNED.user_id,
sender_full_name: PINNED.full_name,
stream_id: 1234,
display_recipient: 'general',
subject: 'office test1',
content: 'listening',
timestamp: 1790860477,
reactions: [],
},
])
const messages = yield* adapter.history.readChannel(generalChannel.name, { limit: 50 })
expect(messages[0]?.sender).toEqual(pinnedIdentity)
}),
)

// A dead form written as an example inside a code span is literal text Zulip
// never delivers as a mention — validating it would block agents from
// discussing mention discipline. It must post verbatim, not fail.
Expand Down Expand Up @@ -1314,6 +1430,7 @@ effectTest('publisher.edit fails with a tagged UnresolvedMention on a dead menti
Effect.gen(function* () {
const stub = yield* makeStubHttpClient
yield* stub.respond('PATCH', '/api/v1/messages/42', { body: { result: 'success' } })
yield* seedRender(stub, '<p>now pinging @<strong>Alice Example</strong></p>')
const adapter = yield* buildAdapter(stub)
const target: MessageRef = {
id: decodeMessageIdSync('42'),
Expand Down
Loading