273 lines
12 KiB
TypeScript
273 lines
12 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import type { ActiveChat } from '../packages/api-contracts/src/index.js';
|
|
import {
|
|
DEFAULT_CONVERSATION_FILTERS,
|
|
accountOptions,
|
|
conversationQueue,
|
|
filterCounts,
|
|
isConversationFilter,
|
|
isConversationSort,
|
|
isManualFlow,
|
|
isUnread,
|
|
isWaitingOnThem,
|
|
manualFlowLabel,
|
|
matchesFilter,
|
|
matchesSearch,
|
|
needsReply,
|
|
partitionConversations,
|
|
triageStatus,
|
|
type ConversationFilters,
|
|
} from '../apps/dashboard/src/features/conversations/conversation-triage.js';
|
|
|
|
function chat(overrides: Partial<ActiveChat> & { username: string }): ActiveChat {
|
|
return {
|
|
roomName: overrides.username,
|
|
roomId: `room-${overrides.username}`,
|
|
lastMessageTime: 1_000,
|
|
lastMessageSnippet: `snippet for ${overrides.username}`,
|
|
isFromUs: false,
|
|
unread: false,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function filters(overrides: Partial<ConversationFilters> = {}): ConversationFilters {
|
|
return { ...DEFAULT_CONVERSATION_FILTERS, ...overrides };
|
|
}
|
|
|
|
describe('conversation triage', () => {
|
|
describe('derived state', () => {
|
|
it('prefers application read state over the Matrix unread flag', () => {
|
|
expect(isUnread(chat({ username: 'a', unread: true, hasUnreadInbound: false }))).toBe(false);
|
|
expect(isUnread(chat({ username: 'a', unread: false, hasUnreadInbound: true }))).toBe(true);
|
|
});
|
|
|
|
it('falls back to the Matrix flag when the server sends no read state', () => {
|
|
expect(isUnread(chat({ username: 'a', unread: true }))).toBe(true);
|
|
expect(isUnread(chat({ username: 'a', unread: false }))).toBe(false);
|
|
});
|
|
|
|
it('treats an inbound newest message on an open conversation as needing a reply', () => {
|
|
expect(needsReply(chat({ username: 'a', isFromUs: false }))).toBe(true);
|
|
expect(needsReply(chat({ username: 'a', isFromUs: true }))).toBe(false);
|
|
});
|
|
|
|
it('stops a resolved or snoozed conversation from needing a reply', () => {
|
|
expect(needsReply(chat({ username: 'a', triageStatus: 'done' }))).toBe(false);
|
|
expect(needsReply(chat({ username: 'a', triageStatus: 'snoozed' }))).toBe(false);
|
|
});
|
|
|
|
it('separates waiting-on-them from needing a reply', () => {
|
|
const waiting = chat({ username: 'a', isFromUs: true });
|
|
expect(isWaitingOnThem(waiting)).toBe(true);
|
|
expect(needsReply(waiting)).toBe(false);
|
|
});
|
|
|
|
it('defaults a conversation with no stored state to open', () => {
|
|
expect(triageStatus(chat({ username: 'a' }))).toBe('open');
|
|
});
|
|
|
|
it('treats every handed-over reply-loop status as a manual conversation', () => {
|
|
for (const status of ['user-overridden', 'image-received', 'boundary', 'human-required'] as const) {
|
|
expect(isManualFlow(chat({ username: 'a', automationStatus: status }))).toBe(true);
|
|
}
|
|
});
|
|
|
|
it('treats a manual policy as manual even while the reply loop is still nominally active', () => {
|
|
expect(isManualFlow(chat({
|
|
username: 'a', automationStatus: 'active', automationPolicyMode: 'manual',
|
|
}))).toBe(true);
|
|
});
|
|
|
|
it('does not call an actively automated or never-automated conversation manual', () => {
|
|
expect(isManualFlow(chat({ username: 'a', automationStatus: 'active' }))).toBe(false);
|
|
expect(isManualFlow(chat({ username: 'b' }))).toBe(false);
|
|
});
|
|
|
|
it('explains why a conversation is being handled by hand', () => {
|
|
expect(manualFlowLabel(chat({ username: 'a', automationStatus: 'user-overridden' })))
|
|
.toBe('Manual — you replied');
|
|
expect(manualFlowLabel(chat({ username: 'b', automationStatus: 'image-received' })))
|
|
.toBe('Manual — image received');
|
|
expect(manualFlowLabel(chat({ username: 'c', automationStatus: 'active', automationPolicyMode: 'manual' })))
|
|
.toBe('Manual by policy');
|
|
expect(manualFlowLabel(chat({ username: 'd', automationStatus: 'killed' })))
|
|
.toBeNull();
|
|
expect(manualFlowLabel(chat({ username: 'e', automationStatus: 'active' }))).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('filters', () => {
|
|
const open = chat({ username: 'open', hasUnreadInbound: true });
|
|
const replied = chat({ username: 'replied', isFromUs: true });
|
|
const snoozed = chat({ username: 'snoozed', triageStatus: 'snoozed' });
|
|
const done = chat({ username: 'done', triageStatus: 'done' });
|
|
const deleted = chat({ username: 'deleted', recipientAccountStatus: 'deleted' });
|
|
const archived = chat({ username: 'archived', readOnly: true });
|
|
const seller = chat({ username: 'seller', sellerDetected: true });
|
|
|
|
it('assigns every conversation to one workflow queue', () => {
|
|
expect(conversationQueue(open)).toBe('needs-reply');
|
|
expect(conversationQueue(replied)).toBe('awaiting-reply');
|
|
expect(matchesFilter(snoozed, 'snoozed')).toBe(true);
|
|
expect(matchesFilter(done, 'done')).toBe(true);
|
|
expect(matchesFilter(seller, 'sellers')).toBe(true);
|
|
expect(matchesFilter(archived, 'archived')).toBe(true);
|
|
});
|
|
|
|
it('moves deleted and archived accounts out of actionable queues', () => {
|
|
expect(matchesFilter(deleted, 'needs-reply')).toBe(false);
|
|
expect(matchesFilter(deleted, 'account-deleted')).toBe(true);
|
|
expect(matchesFilter(archived, 'needs-reply')).toBe(false);
|
|
expect(matchesFilter(archived, 'archived')).toBe(true);
|
|
expect(needsReply(deleted)).toBe(false);
|
|
});
|
|
|
|
it('gives seller and archive outcomes precedence over status and direction', () => {
|
|
expect(conversationQueue(chat({ username: 's', sellerDetected: true, isFromUs: true }))).toBe('sellers');
|
|
expect(conversationQueue(chat({ username: 'a', readOnly: true, sellerDetected: true }))).toBe('archived');
|
|
expect(conversationQueue(chat({ username: 'd', recipientAccountStatus: 'deleted', sellerDetected: true })))
|
|
.toBe('account-deleted');
|
|
});
|
|
|
|
it('matches search against username, snippet and loaded message text', () => {
|
|
const conversation = chat({
|
|
username: 'alice',
|
|
lastMessageSnippet: 'about the weather',
|
|
messages: [{ sender: 'alice', text: 'I live in Bristol', timestamp: 1, isSelf: false }],
|
|
});
|
|
expect(matchesSearch(conversation, 'ALI')).toBe(true);
|
|
expect(matchesSearch(conversation, 'weather')).toBe(true);
|
|
expect(matchesSearch(conversation, 'bristol')).toBe(true);
|
|
expect(matchesSearch(conversation, 'nothing')).toBe(false);
|
|
expect(matchesSearch(conversation, ' ')).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('partitionConversations', () => {
|
|
it('sorts newest first by default', () => {
|
|
const result = partitionConversations([
|
|
chat({ username: 'old', lastMessageTime: 100 }),
|
|
chat({ username: 'new', lastMessageTime: 300 }),
|
|
chat({ username: 'mid', lastMessageTime: 200 }),
|
|
], filters());
|
|
|
|
expect(result.visible.map((item) => item.username)).toEqual(['new', 'mid', 'old']);
|
|
});
|
|
|
|
it('puts the longest-waiting unanswered conversation first when sorting by oldest unanswered', () => {
|
|
const result = partitionConversations([
|
|
chat({ username: 'recent-inbound', lastMessageTime: 300 }),
|
|
chat({ username: 'stale-inbound', lastMessageTime: 100 }),
|
|
], filters({ sort: 'oldest-unanswered' }));
|
|
|
|
expect(result.visible.map((item) => item.username))
|
|
.toEqual(['stale-inbound', 'recent-inbound']);
|
|
});
|
|
|
|
it('keeps sellers in their dedicated queue', () => {
|
|
const result = partitionConversations([
|
|
chat({ username: 'alice' }),
|
|
chat({ username: 'seller-one', sellerDetected: true }),
|
|
chat({ username: 'seller-two', sellerDetected: true }),
|
|
], filters({ filter: 'sellers' }));
|
|
|
|
expect(result.visible.map((item) => item.username)).toEqual(['seller-one', 'seller-two']);
|
|
});
|
|
|
|
it('applies unread, manual and image filters as secondary modifiers', () => {
|
|
const result = partitionConversations([
|
|
chat({ username: 'match', hasUnreadInbound: true, automationStatus: 'user-overridden', hasReceivedImages: true }),
|
|
chat({ username: 'read', hasUnreadInbound: false, automationStatus: 'user-overridden', hasReceivedImages: true }),
|
|
chat({ username: 'automatic', hasUnreadInbound: true, automationStatus: 'active', hasReceivedImages: true }),
|
|
], filters({ unreadOnly: true, manualOnly: true, imagesOnly: true }));
|
|
|
|
expect(result.visible.map((item) => item.username)).toEqual(['match']);
|
|
});
|
|
|
|
it('narrows to a single owning account', () => {
|
|
const result = partitionConversations([
|
|
chat({ username: 'a', owner: { accountId: 'one', redditUsername: 'bot-one' } }),
|
|
chat({ username: 'b', owner: { accountId: 'two', redditUsername: 'bot-two' } }),
|
|
], filters({ account: 'two' }));
|
|
|
|
expect(result.visible.map((item) => item.username)).toEqual(['b']);
|
|
});
|
|
|
|
it('does not mutate the input array order', () => {
|
|
const input = [
|
|
chat({ username: 'old', lastMessageTime: 100 }),
|
|
chat({ username: 'new', lastMessageTime: 300 }),
|
|
];
|
|
partitionConversations(input, filters());
|
|
|
|
expect(input.map((item) => item.username)).toEqual(['old', 'new']);
|
|
});
|
|
});
|
|
|
|
describe('filterCounts', () => {
|
|
it('counts mutually exclusive workflow queues', () => {
|
|
const counts = filterCounts([
|
|
chat({ username: 'unread-inbound', hasUnreadInbound: true }),
|
|
chat({ username: 'read-inbound', hasUnreadInbound: false }),
|
|
chat({ username: 'replied', isFromUs: true }),
|
|
chat({ username: 'snoozed', triageStatus: 'snoozed' }),
|
|
chat({ username: 'done', triageStatus: 'done' }),
|
|
chat({ username: 'seller', sellerDetected: true }),
|
|
chat({ username: 'deleted', recipientAccountStatus: 'deleted' }),
|
|
chat({ username: 'archived', readOnly: true }),
|
|
], filters());
|
|
|
|
expect(counts['needs-reply']).toBe(2);
|
|
expect(counts['awaiting-reply']).toBe(1);
|
|
expect(counts.sellers).toBe(1);
|
|
expect(counts.snoozed).toBe(1);
|
|
expect(counts.done).toBe(1);
|
|
expect(counts['account-deleted']).toBe(1);
|
|
expect(counts.archived).toBe(1);
|
|
});
|
|
|
|
it('respects the search term so a pill never promises rows the list will not show', () => {
|
|
const counts = filterCounts([
|
|
chat({ username: 'alice', hasUnreadInbound: true }),
|
|
chat({ username: 'bob', hasUnreadInbound: true }),
|
|
], filters({ search: 'alice' }));
|
|
|
|
expect(counts['needs-reply']).toBe(1);
|
|
});
|
|
|
|
it('applies secondary filters to every queue count', () => {
|
|
const counts = filterCounts([
|
|
chat({ username: 'alice', hasUnreadInbound: true }),
|
|
chat({ username: 'read', hasUnreadInbound: false }),
|
|
], filters({ unreadOnly: true }));
|
|
|
|
expect(counts['needs-reply']).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('accountOptions', () => {
|
|
it('lists distinct owners sorted by username', () => {
|
|
expect(accountOptions([
|
|
chat({ username: 'a', owner: { accountId: 'two', redditUsername: 'zoe' } }),
|
|
chat({ username: 'b', owner: { accountId: 'one', redditUsername: 'adam' } }),
|
|
chat({ username: 'c', owner: { accountId: 'one', redditUsername: 'adam' } }),
|
|
chat({ username: 'd' }),
|
|
])).toEqual([
|
|
{ accountId: 'one', redditUsername: 'adam' },
|
|
{ accountId: 'two', redditUsername: 'zoe' },
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('query-parameter guards', () => {
|
|
it('accepts only known filter and sort values', () => {
|
|
expect(isConversationFilter('needs-reply')).toBe(true);
|
|
expect(isConversationFilter('bogus')).toBe(false);
|
|
expect(isConversationFilter(null)).toBe(false);
|
|
expect(isConversationSort('oldest-unanswered')).toBe(true);
|
|
expect(isConversationSort('bogus')).toBe(false);
|
|
});
|
|
});
|
|
});
|