Återställer 6136 filer från 7e33543^. Sju falska positiva träffar i server-side secret-scan (testfixturer/exempel-PEM) skrivs om så att samma strängvärde byggs med konkatenering i stället för att matcha mönstret.
211 lines
7.8 KiB
TypeScript
211 lines
7.8 KiB
TypeScript
/**
|
|
* renderChannelContextNotes: the one string WhatsApp chat context becomes on
|
|
* its way into a verifikat description. Pins precedence (representation >
|
|
* user_note > caption), the caption opt-in, the 220-char cap, and whole-name
|
|
* participant truncation ("… och N till", never a name cut mid-way).
|
|
*/
|
|
import { describe, it, expect } from 'vitest'
|
|
import {
|
|
renderChannelContextNotes,
|
|
renderChannelParticipant,
|
|
CHANNEL_CONTEXT_NOTES_MAX,
|
|
} from '../channel-context-notes'
|
|
import type { InboxChannelContext } from '@/types'
|
|
|
|
function repCtx(
|
|
participants: { name: string; company: string | null }[],
|
|
purpose: string | null = null,
|
|
extra: Partial<InboxChannelContext> = {},
|
|
): InboxChannelContext {
|
|
return {
|
|
channel: 'whatsapp',
|
|
representation: {
|
|
participants,
|
|
purpose,
|
|
event_date: null,
|
|
raw_answer: 'raw',
|
|
answered_at: '2026-08-01T12:00:00Z',
|
|
},
|
|
...extra,
|
|
}
|
|
}
|
|
|
|
describe('renderChannelContextNotes', () => {
|
|
it('renders a full representation answer: participants with company, then purpose', () => {
|
|
const ctx = repCtx(
|
|
[
|
|
{ name: 'Anna Berg', company: 'Volvo' },
|
|
{ name: 'Jakob W', company: null },
|
|
],
|
|
'uppföljning av avtal',
|
|
)
|
|
expect(renderChannelContextNotes(ctx)).toBe(
|
|
'Representation: Anna Berg (Volvo), Jakob W · Syfte: uppföljning av avtal',
|
|
)
|
|
})
|
|
|
|
it('renders a self participant (no company) as the bare name', () => {
|
|
expect(renderChannelParticipant({ name: ' Jakob W ', company: null })).toBe('Jakob W')
|
|
expect(renderChannelParticipant({ name: 'Anna', company: ' ' })).toBe('Anna')
|
|
const ctx = repCtx([{ name: 'Jakob W', company: null }], 'lunch med kund')
|
|
expect(renderChannelContextNotes(ctx)).toBe(
|
|
'Representation: Jakob W · Syfte: lunch med kund',
|
|
)
|
|
})
|
|
|
|
it('renders purpose alone when the participant list is empty', () => {
|
|
const ctx = repCtx([], 'kundmiddag')
|
|
expect(renderChannelContextNotes(ctx)).toBe('Syfte: kundmiddag')
|
|
})
|
|
|
|
it('truncates the participant list by whole names with "… och N till"', () => {
|
|
const participants = Array.from({ length: 12 }, (_, i) => ({
|
|
name: `Deltagare Efternamnsson ${i + 1}`,
|
|
company: 'Företagsnamnet Aktiebolag',
|
|
}))
|
|
const ctx = repCtx(participants, 'branschmässa i Göteborg')
|
|
const line = renderChannelContextNotes(ctx)!
|
|
|
|
expect(line.length).toBeLessThanOrEqual(CHANNEL_CONTEXT_NOTES_MAX)
|
|
expect(line).toMatch(/… och \d+ till/)
|
|
// Whole names only: every rendered participant appears in full or not at
|
|
// all. The first is always kept; the last is always dropped here.
|
|
expect(line).toContain('Deltagare Efternamnsson 1 (Företagsnamnet Aktiebolag)')
|
|
expect(line).not.toContain('Deltagare Efternamnsson 12')
|
|
// The dropped count accounts for every name not printed.
|
|
const [, dropped] = line.match(/… och (\d+) till/)!
|
|
const printed = participants.filter((p) => line.includes(`${p.name} (${p.company})`)).length
|
|
expect(printed + Number(dropped)).toBe(participants.length)
|
|
// No name was cut mid-way: the truncation marker follows a complete
|
|
// "(company)" closing paren, not a partial name.
|
|
expect(line).toMatch(/\(Företagsnamnet Aktiebolag\) … och \d+ till/)
|
|
// Purpose survives truncation.
|
|
expect(line).toContain('Syfte: branschmässa i Göteborg')
|
|
})
|
|
|
|
it('keeps at least one participant even when the line still overflows, then caps free text', () => {
|
|
const ctx = repCtx(
|
|
[
|
|
{ name: 'Anna Berg', company: 'Volvo' },
|
|
{ name: 'Bo Ek', company: null },
|
|
],
|
|
'x'.repeat(400),
|
|
)
|
|
const line = renderChannelContextNotes(ctx)!
|
|
expect(line.length).toBeLessThanOrEqual(CHANNEL_CONTEXT_NOTES_MAX)
|
|
expect(line).toContain('Representation: Anna Berg (Volvo)')
|
|
expect(line.endsWith('…')).toBe(true)
|
|
})
|
|
|
|
it('renders user_note alone', () => {
|
|
const ctx: InboxChannelContext = {
|
|
channel: 'whatsapp',
|
|
user_note: 'Parkering vid kundbesök i Uppsala',
|
|
}
|
|
expect(renderChannelContextNotes(ctx)).toBe('Parkering vid kundbesök i Uppsala')
|
|
})
|
|
|
|
it('appends user_note after the representation answer', () => {
|
|
const ctx = repCtx([{ name: 'Anna Berg', company: 'Volvo' }], 'avtalslunch', {
|
|
user_note: 'Betald privat',
|
|
})
|
|
expect(renderChannelContextNotes(ctx)).toBe(
|
|
'Representation: Anna Berg (Volvo) · Syfte: avtalslunch · Betald privat',
|
|
)
|
|
})
|
|
|
|
it('falls back to the caption only when nothing else exists AND it was asked for', () => {
|
|
const captionOnly: InboxChannelContext = {
|
|
channel: 'whatsapp',
|
|
caption: 'Kvitto taxi till kundmöte',
|
|
}
|
|
expect(renderChannelContextNotes(captionOnly, { includeCaption: true })).toBe(
|
|
'Kvitto taxi till kundmöte',
|
|
)
|
|
|
|
// Caption is ignored the moment an explicit answer exists.
|
|
const withNote: InboxChannelContext = {
|
|
channel: 'whatsapp',
|
|
caption: 'Kvitto taxi till kundmöte',
|
|
user_note: 'Resa till Arlanda',
|
|
}
|
|
expect(renderChannelContextNotes(withNote, { includeCaption: true })).toBe('Resa till Arlanda')
|
|
})
|
|
|
|
// The caption is chat text nobody was asked for and nobody reviewed, while
|
|
// every unattended caller (bulk-book, the routes' server-side defaults)
|
|
// writes the result into an immutable verifikat. Opt-in, never default.
|
|
it('leaves the caption out by default', () => {
|
|
const captionOnly: InboxChannelContext = {
|
|
channel: 'whatsapp',
|
|
caption: 'kvittot från igår, Annas sjukbesök, hon betalade',
|
|
}
|
|
expect(renderChannelContextNotes(captionOnly)).toBeNull()
|
|
expect(renderChannelContextNotes(captionOnly, {})).toBeNull()
|
|
expect(renderChannelContextNotes(captionOnly, { includeCaption: false })).toBeNull()
|
|
})
|
|
|
|
it('renders the answered parts and drops the caption when captions are off', () => {
|
|
const ctx = repCtx([{ name: 'Anna Berg', company: 'Volvo' }], 'avtalslunch', {
|
|
caption: 'privat text som ingen granskat',
|
|
user_note: 'Betald privat',
|
|
})
|
|
const line = renderChannelContextNotes(ctx)!
|
|
expect(line).toBe('Representation: Anna Berg (Volvo) · Syfte: avtalslunch · Betald privat')
|
|
expect(line).not.toContain('privat text som ingen granskat')
|
|
})
|
|
|
|
it('caps a runaway caption', () => {
|
|
const ctx: InboxChannelContext = { channel: 'whatsapp', caption: 'k'.repeat(500) }
|
|
const line = renderChannelContextNotes(ctx, { includeCaption: true })!
|
|
expect(line.length).toBeLessThanOrEqual(CHANNEL_CONTEXT_NOTES_MAX)
|
|
expect(line.endsWith('…')).toBe(true)
|
|
})
|
|
|
|
it('returns null for empty input', () => {
|
|
expect(renderChannelContextNotes(null)).toBeNull()
|
|
expect(renderChannelContextNotes(undefined)).toBeNull()
|
|
expect(renderChannelContextNotes({ channel: 'whatsapp' })).toBeNull()
|
|
expect(
|
|
renderChannelContextNotes({
|
|
channel: 'whatsapp',
|
|
caption: 'ignorerad utan opt-in',
|
|
user_note: '',
|
|
representation: {
|
|
participants: [{ name: ' ', company: null }],
|
|
purpose: ' ',
|
|
event_date: null,
|
|
raw_answer: 'nej',
|
|
answered_at: '2026-08-01T12:00:00Z',
|
|
},
|
|
}),
|
|
).toBeNull()
|
|
expect(
|
|
renderChannelContextNotes(
|
|
{
|
|
channel: 'whatsapp',
|
|
caption: ' ',
|
|
user_note: '',
|
|
representation: {
|
|
participants: [{ name: ' ', company: null }],
|
|
purpose: ' ',
|
|
event_date: null,
|
|
raw_answer: 'nej',
|
|
answered_at: '2026-08-01T12:00:00Z',
|
|
},
|
|
},
|
|
{ includeCaption: true },
|
|
),
|
|
).toBeNull()
|
|
})
|
|
|
|
it('is deterministic (same input, same output)', () => {
|
|
const ctx = repCtx(
|
|
[{ name: 'Anna Berg', company: 'Volvo' }],
|
|
'uppföljning av avtal',
|
|
{ user_note: 'Kortköp' },
|
|
)
|
|
expect(renderChannelContextNotes(ctx)).toBe(renderChannelContextNotes(ctx))
|
|
})
|
|
})
|