bitrix-join.test.ts
Language TypeScript10.2 KB234 lines
/*** The Bitrix task join.** `Task.responsibleId` is Bitrix's numeric user id, stringified. Looking it up* as a roster display name (`personByName('1924')`) matches nothing and reads* "+ 0 tasks" on every row. A fixture that passed display names as* `responsibleId` would reproduce the shape that bug expects instead of the* shape Bitrix sends, and would pass: a fixture that agrees with a bug cannot* catch it.** These tests pin the contract at both ends: ids are numeric, and the join* attributes a real number of them.*/import { describe, expect, it } from 'vitest'import { FIXTURE_SNAPSHOT } from '../src/fixtures.js'import { headline, peopleBoard, type Snapshot } from '../src/collect.js'import { buildViewModel } from '../src/view-model.js'import { viewerFor } from '../src/viewer.js'import {MERIDIAN_PEOPLE,offRosterBitrixIds,personFromBitrix,uncoveredByBitrix,unpinnedBitrixPeople,type Person,} from '../../../config/meridian-people.js'import { partitionTasks } from '@meridian/bitrix-client'describe('responsibleId is an id, and the fixture must say so', () => {it('carries only numeric responsibleIds, never a display name', () => {// Ids in the fixture are numeric, as Bitrix sends them.for (const t of FIXTURE_SNAPSHOT.tasks.data) {if (t.responsibleId === null) continueexpect(t.responsibleId, `task ${t.id} "${t.title}"`).toMatch(/^\d+$/)}})it('never resolves a roster display name as a responsibleId', () => {for (const p of MERIDIAN_PEOPLE) expect(personFromBitrix(p.name)).toBeNull()})it('has a Bitrix id pinned for every roster person', () => {// An unpinned id silently zeroes that person's tasks, which is how this// started. unpinnedBitrixPeople() exists so it cannot go unnoticed again.expect(unpinnedBitrixPeople()).toEqual([])})})describe('the join actually attributes tasks', () => {const board = peopleBoard(FIXTURE_SNAPSHOT)it('attributes a non-zero number of real tasks', () => {// The bug was not a wrong count, it was zero. Assert the floor.expect(board.taskJoin.attributed).toBeGreaterThan(0)expect(board.taskJoin.real).toBeGreaterThan(board.taskJoin.attributed)})it('puts tasks on the specific people who hold them', () => {const by = new Map(board.rows.map((r) => [r.name, r.taskCount]))expect(by.get('Ines Carvalho')).toBe(2)expect(by.get('Theo Bassett')).toBe(1)expect(by.get('Marco Ibáñez')).toBe(1)expect(by.get('Sam Whitaker')).toBe(1)})it('gives her a measured zero now that her id is pinned, not Hazel’s task', () => {// With her own id pinned, Rosa's count is a real zero against this// fixture: she holds no open tasks in it, which is a fact rather than an// absence. Hazel's one task stays off her row, because the id did not// transfer (see the test below).const by = new Map(board.rows.map((r) => [r.name, r.taskCount]))expect(by.get('Rosa Galván')).toBe(0)})it('reports Hazel’s old Bitrix id as an off-roster holder rather than losing it', () => {// Her id was deliberately not moved to Rosa; it is an account, not a// seat. The task held by it does not vanish: it lands in offRoster, which// is "held by someone not on this roster" and is reported as such.expect(board.taskJoin.offRoster.map((o) => o.id)).toContain('2736')})it('reaches My Day, not just the board', () => {// peopleBoard and myDay resolved the join independently, so both had to be// fixed. Counting a task and showing it are different steps.const ines = MERIDIAN_PEOPLE.find((p) => p.name === 'Ines Carvalho')!const vm = buildViewModel(FIXTURE_SNAPSHOT, headline(FIXTURE_SNAPSHOT), [], '2026-08-12', viewerFor(ines.email))const card = vm.myDays.find((d) => d.person.name === 'Ines Carvalho')!expect(card.tasks).toBe(2)expect(card.items.filter((i) => i.meta.startsWith('Bitrix task'))).toHaveLength(2)})it('accounts for every real task: attributed + off-roster + unowned', () => {const { real } = partitionTasks(FIXTURE_SNAPSHOT.tasks.data)const unowned = real.filter((t) => !t.responsibleId).lengthconst offRoster = board.taskJoin.offRoster.reduce((n, o) => n + o.tasks, 0)expect(board.taskJoin.attributed + offRoster + unowned).toBe(real.length)})})describe('an off-roster holder is reported, not chased and not hidden', () => {const board = peopleBoard(FIXTURE_SNAPSHOT)it('counts off-roster real tasks rather than absorbing them', () => {// Almost every Bitrix account is out of scope, so this is expected and is// not a coverage gap. But it is stated, because most of the// live real tasks sit here and the board deliberately shows none of them.expect(board.taskJoin.offRoster.length).toBeGreaterThan(0)for (const o of board.taskJoin.offRoster) expect(o.id).toMatch(/^\d+$/)})it('puts an off-roster task on nobody', () => {const vm = buildViewModel(FIXTURE_SNAPSHOT, headline(FIXTURE_SNAPSHOT), [], '2026-08-12')for (const d of vm.myDays) {expect(d.items.map((i) => i.what)).not.toContain('Fix the old quote form (April, 2024)')}})it('separates "held by someone off the roster" from "held by nobody"', () => {// Different facts. One is out of scope by decision; the other means nobody// has been asked to do it. Merging them loses the second entirely.const ids = board.taskJoin.offRoster.map((o) => o.id)expect(ids).not.toContain('')expect(ids.every(Boolean)).toBe(true)})it('does not count junk as off-roster', () => {// 20 junk rows are held by a departed person and a meeting room. Counting// them here would make an expected number look alarming.const offRoster = board.taskJoin.offRoster.reduce((n, o) => n + o.tasks, 0)expect(offRoster).toBeLessThan(board.taskJoin.junk)expect(board.taskJoin.offRoster.map((o) => o.id)).not.toContain('26')expect(board.taskJoin.offRoster.map((o) => o.id)).not.toContain('196')})})describe('two ways Bitrix can fail to see a person, and they are different', () => {// Synthetic rosters on purpose. Both states are transient (whoever is// mid-onboarding today is pinned tomorrow), so testing them against the live// roster means the test asserts nothing the moment the gap closes.const person = (over: Partial<Person>): Person => ({name: 'Test Person',role: 'Client Success',plinthAccounts: [],coveredBy: [],...over,})it('separates "claims Bitrix, has no id" from "Bitrix does not cover them"', () => {// The first is a bug: the board says Bitrix covers them, so their tasks// render as a number, and the number is silently zero. The second is a// coverage fact: nothing claims to see them, so their count reads as not// tracked. Collapsing the two loses the bug.const claims = person({ name: 'Claims Coverage', coveredBy: ['bitrix-tasks'] })const uncovered = person({ name: 'Nobody Covers' })const pinned = person({ name: 'Pinned', bitrixUserId: 42, coveredBy: ['bitrix-tasks'] })expect(uncoveredByBitrix([claims, uncovered, pinned])).toEqual(['Nobody Covers'])})it('reports nobody as uncovered once their id is pinned', () => {const pinned = person({ name: 'Pinned', bitrixUserId: 42, coveredBy: ['bitrix-tasks'] })expect(uncoveredByBitrix([pinned])).toEqual([])})it('every roster person is now on one side or the other, with nobody in limbo', () => {// A person with an id who does not claim coverage would be an id nothing// uses; a person with neither is reported by uncoveredByBitrix. Both are// visible states; this asserts nobody is in the third, silent one.for (const p of MERIDIAN_PEOPLE) {const covered = p.coveredBy.includes('bitrix-tasks')expect(covered ? Boolean(p.bitrixUserId) : true, `${p.name}`).toBe(true)}})})describe('Data health states the count, not the method', () => {// Mode matters here. sourceStatuses() rewrites every pill on a fixture run, so// a sample board cannot claim live join numbers. That rewrite is deliberate// and is asserted elsewhere. The join note only exists on a live snapshot, so// these tests use one.const LIVE: Snapshot = { ...FIXTURE_SNAPSHOT, mode: 'live', fixtureCapturedAt: null }const noteFor = (snap: Snapshot): string => {const vm = buildViewModel(snap, headline(snap), [], '2026-08-12')return vm.sources.find((s) => s.name === 'Bitrix tasks')!.note}it('reports how many attributed, out of how many', () => {const board = peopleBoard(LIVE)const note = noteFor(LIVE)expect(note).toContain(`${board.taskJoin.attributed} of ${board.taskJoin.real}`)expect(note).toContain(String(board.taskJoin.junk))})it('counts the pinned ids rather than claiming a number', () => {// Counted, not asserted: "all roster ids are pinned" as a fixed sentence// would make a claim about somebody the page had never looked up.expect(noteFor(LIVE)).toMatch(/\d+ of \d+ roster ids are pinned/)expect(noteFor(LIVE)).not.toContain('All seven')})it('says off-roster holders are expected rather than a gap', () => {// The wording is not the assertion; the presence of the count is. An// off-roster number reported as a warning would be a permanent false alarm// about dozens of accounts nobody intends to show.expect(noteFor(LIVE)).toMatch(/outside the roster/i)})it('no longer claims the join works by describing how it matches', () => {// Numbers, not a description of the method: a description can read as// "fine" on a join that attributed nothing.expect(noteFor(LIVE)).not.toMatch(/by display name/i)})it('reports a dead source as dead rather than as a zero join', () => {const down: Snapshot = { ...LIVE, tasks: { ok: false, data: [], error: 'HTTP 401' } }const note = noteFor(down)expect(note).toContain('401')expect(note).not.toMatch(/0 of 0/)})})describe('offRosterBitrixIds', () => {it('groups by id and sorts by volume', () => {expect(offRosterBitrixIds(['26', '4', '26', '26', '4'])).toEqual([{ id: '26', tasks: 3 },{ id: '4', tasks: 2 },])})it('ignores roster ids, nulls and blanks', () => {expect(offRosterBitrixIds(['1924', null, undefined, ' ', '7108'])).toEqual([])})})