import { describe, expect, it } from 'vitest' import { BOARD_SEARCH_MAX_TERMS, BOARD_SEARCH_PAGE_SIZE, BoardSearchRow, compareBoardSearchOrder, getBoardSearchResults, isAfterBoardSearchCursor, parseSearchBoardsInput, } from './boardTools' function parsedJson(result: ReturnType) { const block = result.content[0] if (block.type !== 'text') throw new Error('Expected a text block') return JSON.parse(block.text) } function makeRow(overrides: Partial = {}): BoardSearchRow { return { id: 'board-1', name: 'Roadmap', // A board the caller made: arrival and creation are the same moment. arrivedAt: 1_700_000_000_000, createdAt: 1_700_000_000_000, updatedAt: 1_700_000_500_000, workspaceName: 'Design', source: 'owned' as const, ...overrides, } } function makePage(count: number): BoardSearchRow[] { return Array.from({ length: count }, (_, index) => makeRow({ id: `board-${index}`, arrivedAt: 1_700_000_000_000 - index, createdAt: 1_700_000_000_000 - index, }) ) } describe('parseSearchBoardsInput', () => { // Listing the newest boards is the tool's other job: it is the only way for a model to answer // "what boards do I have?" without being handed an id first. it('treats a missing query as no filter', () => { expect(parseSearchBoardsInput({})).toEqual({ terms: [], cursor: null }) }) // The wire-legal call a model makes by following the tool's own description literally: this is // the only tool with `required: []`, so omitted `arguments` is a legitimate "list my newest // boards", not a malformed call that should hit `requireArgumentsObject(undefined)`. it('treats wholly omitted arguments the same as an empty object', () => { expect(parseSearchBoardsInput(undefined)).toEqual(parseSearchBoardsInput({})) }) it('treats a whitespace-only query as no filter', () => { expect(parseSearchBoardsInput({ query: ' ' }).terms).toEqual([]) }) // Terms are ANDed, so word order in the query need not match word order in the name. it('splits a query into terms on whitespace', () => { expect(parseSearchBoardsInput({ query: 'design system' }).terms).toEqual(['design', 'system']) }) it('refuses a non-string query', () => { expect(() => parseSearchBoardsInput({ query: 12 })).toThrow('query must be a string') }) // A name is a name, not a document: a pasted paragraph is a model mistake, and every term // becomes its own ILIKE. it('refuses an over-long query', () => { expect(() => parseSearchBoardsInput({ query: 'x'.repeat(201) })).toThrow('200 characters') }) // A query length limit alone does not bound term count, since whitespace is cheap: each term // becomes its own unindexable ILIKE over an already-unbounded scan. it('refuses more than the maximum number of terms', () => { const query = Array.from({ length: BOARD_SEARCH_MAX_TERMS + 1 }, (_, i) => `t${i}`).join(' ') expect(() => parseSearchBoardsInput({ query })).toThrow( `${BOARD_SEARCH_MAX_TERMS} words or fewer` ) }) it('accepts exactly the maximum number of terms', () => { const query = Array.from({ length: BOARD_SEARCH_MAX_TERMS }, (_, i) => `t${i}`).join(' ') expect(parseSearchBoardsInput({ query }).terms).toHaveLength(BOARD_SEARCH_MAX_TERMS) }) it('refuses arguments that are not an object', () => { expect(() => parseSearchBoardsInput('boards')).toThrow('Tool arguments must be an object') }) // The cursor is opaque so that its scheme can change; a model must only ever hand back one it // was given. it('round-trips a cursor from a previous result', () => { const first = parsedJson(getBoardSearchResults(makePage(BOARD_SEARCH_PAGE_SIZE + 1), [])) expect(parseSearchBoardsInput({ cursor: first.nextCursor }).cursor).toEqual({ arrivedAt: 1_700_000_000_000 - (BOARD_SEARCH_PAGE_SIZE - 1), id: `board-${BOARD_SEARCH_PAGE_SIZE - 1}`, }) }) // A cursor is a position in one query's results. Carried onto a different query it would point // at row 20 of a set the model never saw the start of, with nothing in the result to say so. it('continues a cursor only under the query that minted it', () => { const page = parsedJson(getBoardSearchResults(makePage(BOARD_SEARCH_PAGE_SIZE + 1), ['design'])) expect( parseSearchBoardsInput({ query: 'design', cursor: page.nextCursor }).cursor ).not.toBeNull() expect(() => parseSearchBoardsInput({ query: 'roadmap', cursor: page.nextCursor })).toThrow( 'cursor is from a different query' ) expect(() => parseSearchBoardsInput({ cursor: page.nextCursor })).toThrow( 'cursor is from a different query' ) }) // Matching is `ilike` and terms are split on whitespace before they are ever compared, so // neither case nor spacing changed which boards the cursor was pointing into. it('treats a requery that differs only in case or spacing as the same query', () => { const page = parsedJson( getBoardSearchResults(makePage(BOARD_SEARCH_PAGE_SIZE + 1), ['Design', 'System']) ) expect( parseSearchBoardsInput({ query: ' design system ', cursor: page.nextCursor }).cursor ).not.toBeNull() }) // The other direction of the same rule: "list my newest boards" is a query too, and a cursor // into it does not carry over to a search. it('refuses a no-query cursor once a query is added', () => { const page = parsedJson(getBoardSearchResults(makePage(BOARD_SEARCH_PAGE_SIZE + 1), [])) expect(() => parseSearchBoardsInput({ query: 'design', cursor: page.nextCursor })).toThrow( 'cursor is from a different query' ) }) // Fixture board ids in the eval harness are arbitrary strings, and `btoa` throws outside Latin-1. // That throw would happen inside the route's try block and reach a model as a database failure. it('round-trips an id that btoa alone could not encode', () => { // The cursor is minted from the last row *on* the page, so the odd id has to sit there. const rows = makePage(BOARD_SEARCH_PAGE_SIZE + 1) rows[BOARD_SEARCH_PAGE_SIZE - 1] = { ...rows[BOARD_SEARCH_PAGE_SIZE - 1], id: 'brädå:1' } const result = parsedJson(getBoardSearchResults(rows, [])) expect(parseSearchBoardsInput({ cursor: result.nextCursor }).cursor).toEqual({ arrivedAt: 1_700_000_000_000 - (BOARD_SEARCH_PAGE_SIZE - 1), id: 'brädå:1', }) }) // Every fixture below carries the trailing `:` that makes it a three-field cursor. Without it the // field-count check refuses them first, and the guard each case names is never reached — which is // what happened when the cursor grew its query field and these were not revisited: deleting the // timestamp validation outright left all of them green. // // Nothing downstream can act on a malformed cursor, and silently starting from the first page // would look to a model like the last page repeating itself. it('refuses a cursor it did not issue', () => { expect(() => parseSearchBoardsInput({ cursor: 'not-a-cursor' })).toThrow('cursor is not valid') expect(() => parseSearchBoardsInput({ cursor: btoa('nonsense') })).toThrow( 'cursor is not valid' ) expect(() => parseSearchBoardsInput({ cursor: 12 })).toThrow('cursor must be a string') expect(() => parseSearchBoardsInput({ cursor: btoa('1:%zz:') })).toThrow('cursor is not valid') }) // `Number.isInteger` admits 1e300, which binds as an out-of-range int8 and makes Postgres throw: // caller garbage would reach a model as "the board database could not be reached". it('refuses a sort key no timestamp could be', () => { expect(() => parseSearchBoardsInput({ cursor: btoa('1e300:board-1:') })).toThrow( 'cursor is not valid' ) expect(() => parseSearchBoardsInput({ cursor: btoa('-1:board-1:') })).toThrow( 'cursor is not valid' ) }) // `Number('')` is 0, which passes `Number.isSafeInteger(0)`: the forged cursor `btoa(":id:")` // would otherwise decode to `{createdAt: 0, id: 'id'}` and seek strictly below epoch forever, // with no signal to the model that its cursor was the problem. it('refuses a cursor with an empty timestamp half', () => { expect(() => parseSearchBoardsInput({ cursor: btoa(':board-1:') })).toThrow( 'cursor is not valid' ) }) // The digit check admits any run of digits, including one past MAX_SAFE_INTEGER. That binds as an // out-of-range int8 and makes Postgres throw, so caller garbage would reach a model as "the board // database could not be reached" rather than as a bad cursor. This is the case // `Number.isSafeInteger` is there for: `1e300` is refused a line earlier, for not being digits. it('refuses an all-digit timestamp too large to be a safe integer', () => { expect(() => parseSearchBoardsInput({ cursor: btoa('99999999999999999999:board-1:') })).toThrow( 'cursor is not valid' ) }) // The id half is what makes "where the page ended" a single point rather than a range. Empty, the // cursor would seek on createdAt alone, and every board sharing that timestamp would be re-served // or skipped at the boundary. it('refuses a cursor with an empty id half', () => { expect(() => parseSearchBoardsInput({ cursor: btoa('1::') })).toThrow('cursor is not valid') }) // Every other string form `Number()` coerces to a finite value but a plain-digit timestamp // never is. it.each(['1e3', ' 5', '+5', 'Infinity'])( 'refuses a timestamp half of %j, which Number() would otherwise accept', (timestampPart) => { expect(() => parseSearchBoardsInput({ cursor: btoa(`${timestampPart}:board-1:`) })).toThrow( 'cursor is not valid' ) } ) }) // The one statement of result order. The SQL in searchBoards.ts mirrors this, and the eval harness // pages through fixtures with it — if the two disagree, an eval stops being evidence. describe('compareBoardSearchOrder', () => { it('puts the newest-created board first', () => { expect( compareBoardSearchOrder({ arrivedAt: 2, id: 'a' }, { arrivedAt: 1, id: 'a' }) ).toBeLessThan(0) }) // Boards created in one batch share a createdAt. Without a unique tiebreaker the cursor cannot // say where a page ended inside that group. it('breaks a tie on id, descending', () => { expect( compareBoardSearchOrder({ arrivedAt: 0, id: 'b' }, { arrivedAt: 0, id: 'a' }) ).toBeLessThan(0) expect(compareBoardSearchOrder({ arrivedAt: 0, id: 'a' }, { arrivedAt: 0, id: 'a' })).toBe(0) }) it('sorts an older board after a newer one whatever their ids', () => { expect( compareBoardSearchOrder({ arrivedAt: 0, id: 'z' }, { arrivedAt: 1, id: 'a' }) ).toBeGreaterThan(0) }) }) describe('isAfterBoardSearchCursor', () => { it('accepts only rows that sort after the cursor', () => { const cursor = { arrivedAt: 10, id: 'm' } expect(isAfterBoardSearchCursor({ arrivedAt: 9, id: 'z' }, cursor)).toBe(true) expect(isAfterBoardSearchCursor({ arrivedAt: 11, id: 'a' }, cursor)).toBe(false) }) // The point of ordering on arrival rather than creation: a board somebody shared with you this // morning leads the list, however long ago its owner made it. Under a createdAt sort it would land // below everything you have made since, which for a long-lived board is the bottom of the page. it('puts a recently shared old board above a board made earlier today', () => { const sharedThisMorning = { arrivedAt: 1_700_000_000_000, id: 'shared' } const mineFromLastWeek = { arrivedAt: 1_699_000_000_000, id: 'mine' } expect(compareBoardSearchOrder(sharedThisMorning, mineFromLastWeek)).toBeLessThan(0) }) // Descending order means "after" is "less than". Reversed, a page returns itself forever. it('excludes the cursor row itself and takes the tie on id', () => { const cursor = { arrivedAt: 10, id: 'm' } expect(isAfterBoardSearchCursor(cursor, cursor)).toBe(false) expect(isAfterBoardSearchCursor({ arrivedAt: 10, id: 'l' }, cursor)).toBe(true) expect(isAfterBoardSearchCursor({ arrivedAt: 10, id: 'n' }, cursor)).toBe(false) }) }) describe('getBoardSearchResults', () => { it('shapes a board for the model', () => { const result = parsedJson(getBoardSearchResults([makeRow()], [])) expect(result).toEqual({ boardCount: 1, boards: [ { boardId: 'board-1', name: 'Roadmap', // Same instant for a board the caller made; they diverge only for a shared one. addedAt: '2023-11-14T22:13:20.000Z', createdAt: '2023-11-14T22:13:20.000Z', updatedAt: '2023-11-14T22:21:40.000Z', source: 'owned', }, ], }) }) // "My workspace" on every row of the common case is noise; on a shared workspace's board it is // the thing that identifies where the board lives. it('names the workspace only on boards outside the caller’s own', () => { const result = parsedJson(getBoardSearchResults([makeRow({ source: 'workspace' })], [])) expect(result.boards[0]).toMatchObject({ source: 'workspace', workspaceName: 'Design' }) }) // A link-shared board is owned by a workspace the caller is not in. Reporting its name would hand // somebody who was given a link the name of an organisation they have no seat in. it('withholds the workspace name from a link-shared board', () => { const result = parsedJson(getBoardSearchResults([makeRow({ source: 'shared' })], [])) expect(result.boards[0].source).toBe('shared') expect(result.boards[0]).not.toHaveProperty('workspaceName') }) // An unnamed board is titled by its creation date on tldraw.com, in the viewer's locale and // timezone. A stand-in title invented here would be one the caller never sees on their own // screen, and one no query could match, since the column holds ''. it('reports a blank name as blank rather than inventing one', () => { const result = parsedJson(getBoardSearchResults([makeRow({ name: ' ' })], [])) expect(result.boards[0].name).toBe('') }) // The query asks for one row more than a page so that "is there another page" needs no second // count. The extra row must never be shown. it('serves one page and a cursor from the surplus row', () => { const result = parsedJson(getBoardSearchResults(makePage(BOARD_SEARCH_PAGE_SIZE + 1), [])) expect(result.boardCount).toBe(BOARD_SEARCH_PAGE_SIZE) expect(result.boards).toHaveLength(BOARD_SEARCH_PAGE_SIZE) expect(result.nextCursor).toEqual(expect.any(String)) expect(result.boards.at(-1).boardId).toBe(`board-${BOARD_SEARCH_PAGE_SIZE - 1}`) }) // A cursor on the last page would have a model fetch an empty page to discover it had finished. it('omits the cursor on the last page', () => { const result = parsedJson(getBoardSearchResults(makePage(BOARD_SEARCH_PAGE_SIZE), [])) expect(result.boardCount).toBe(BOARD_SEARCH_PAGE_SIZE) expect(result.nextCursor).toBeUndefined() }) // An empty result is a normal result. Models treat isError as failure and retry it. it('returns an empty result rather than an error', () => { const result = getBoardSearchResults([], []) expect(result.isError).toBeUndefined() expect(parsedJson(result)).toEqual({ boardCount: 0, boards: [] }) }) })