Download server/api.test.ts from igorktech/pinned: direct link, hf CLI and curl.
- Browser
- Download file 25.4 kB
-
https://huggingface.co/spaces/igorktech/pinned/resolve/main/server/api.test.ts
- Command line
-
hf download hf://spaces/igorktech/pinned/server/api.test.ts
-
curl -L -o api.test.ts https://huggingface.co/spaces/igorktech/pinned/resolve/main/server/api.test.ts
25.4 kB
| // The REST and SSE API over HTTP, beside the canvas MCP, with a stand-in papers MCP: | |
| // node --test server/api.test.ts | |
| import { Client } from '@modelcontextprotocol/sdk/client/index.js' | |
| import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' | |
| import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' | |
| import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js' | |
| import assert from 'node:assert/strict' | |
| import { mkdtempSync, readFileSync, rmSync } from 'node:fs' | |
| import { createServer, type Server } from 'node:http' | |
| import type { AddressInfo } from 'node:net' | |
| import { tmpdir } from 'node:os' | |
| import { join } from 'node:path' | |
| import { after, before, test } from 'node:test' | |
| import { z } from 'zod' | |
| import { paperShapeId } from '../shared/ids.ts' | |
| import { defaultPaperCardProps, type PaperCardShape } from '../shared/shapes.ts' | |
| import { handleApi } from './api.ts' | |
| import { closeBoardDb, setBoardDbPath } from './boardDb.ts' | |
| import { handleCanvasMcp } from './canvasMcp.ts' | |
| import { streamCount } from './events.ts' | |
| import { textPdf } from './fixtures/textPdf.ts' | |
| import { createPapersClient, type PapersClient } from './papersClient.ts' | |
| import { setPdfDir } from './pdfProxy.ts' | |
| import { setUploadsDir } from './pdfSeed.ts' | |
| import { closeAllRooms, putShapes, readRoom, setRoomsDir } from './rooms.ts' | |
| const dir = mkdtempSync(join(tmpdir(), 'board-api-')) | |
| setRoomsDir(join(dir, 'rooms')) | |
| setBoardDbPath(join(dir, 'board.db')) | |
| setPdfDir(join(dir, 'pdfs')) | |
| setUploadsDir(join(dir, 'uploads')) | |
| // like discovery's papers MCP: records as structured content, unknown papers as tool errors | |
| const TIROSH = { | |
| paperId: 'pmid:27124452', | |
| ids: { pmid: '27124452', doi: '10.1126/science.aad0501' }, | |
| title: 'Dissecting the multicellular ecosystem of metastatic melanoma by single-cell RNA-seq', | |
| authors: ['Itay Tirosh', 'Benjamin Izar'], | |
| year: 2016, | |
| venue: 'Science', | |
| url: 'https://doi.org/10.1126/science.aad0501', | |
| abstract: 'To explore the distinct genotypic and phenotypic states of melanoma tumors, we applied single-cell RNA-seq.', | |
| openAccessPdf: 'http://127.0.0.1:9/tirosh.pdf', | |
| type: 'journal-article', | |
| } | |
| const SADE = { | |
| paperId: 'pmid:30388456', | |
| ids: { pmid: '30388456', doi: '10.1016/j.cell.2018.10.038' }, | |
| title: 'Defining T cell states associated with response to checkpoint immunotherapy in melanoma', | |
| year: 2018, | |
| } | |
| const records: Record<string, object> = { | |
| [TIROSH.paperId]: TIROSH, | |
| [SADE.paperId]: SADE, | |
| 'pmid:2': { paperId: 'pmid:2', title: 'No PDF', openAccessPdf: null }, | |
| } | |
| function papersServer() { | |
| const server = new McpServer({ name: 'papers', version: '0' }) | |
| const reply = (record: object | undefined, missing: string) => | |
| record | |
| ? { structuredContent: record as Record<string, unknown>, content: [{ type: 'text' as const, text: JSON.stringify(record) }] } | |
| : { isError: true, content: [{ type: 'text' as const, text: missing }] } | |
| server.registerTool('get_paper', { inputSchema: { paper_id: z.string() } }, async ({ paper_id }) => | |
| reply(records[paper_id], `Unknown paper ID ${paper_id}`) | |
| ) | |
| server.registerTool( | |
| 'resolve_seed', | |
| { inputSchema: { doi: z.string().optional(), pmid: z.string().optional(), title: z.string().optional() } }, | |
| async ({ doi, pmid, title }) => | |
| reply( | |
| Object.values(records).find((r) => (title ? (r as { title: string }).title === title : JSON.stringify(r).includes(`"${pmid ?? doi}"`))), | |
| `No paper for ${pmid ?? doi ?? title}` | |
| ) | |
| ) | |
| return server | |
| } | |
| let mcpHttp: Server | |
| let http: Server | |
| let base = '' | |
| let papers: PapersClient | |
| before(async () => { | |
| mcpHttp = createServer(async (req, res) => { | |
| const server = papersServer() | |
| const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true }) | |
| res.on('close', () => server.close()) | |
| await server.connect(transport) | |
| await transport.handleRequest(req, res) | |
| }) | |
| await new Promise<void>((resolve) => mcpHttp.listen(0, '127.0.0.1', resolve)) | |
| papers = createPapersClient(`http://127.0.0.1:${(mcpHttp.address() as AddressInfo).port}/mcp`) | |
| http = createServer((req, res) => { | |
| const url = new URL(req.url ?? '/', 'http://localhost') | |
| const canvas = url.pathname.match(/^\/api\/mcp\/canvas\/([^/]+)$/) | |
| if (canvas) handleCanvasMcp(req, res, canvas[1], papers) | |
| else handleApi(req, res, url, papers) | |
| }) | |
| await new Promise<void>((resolve) => http.listen(0, '127.0.0.1', resolve)) | |
| base = `http://127.0.0.1:${(http.address() as AddressInfo).port}` | |
| }) | |
| after(async () => { | |
| await papers.close() | |
| for (const server of [http, mcpHttp]) { | |
| server.closeAllConnections() | |
| await new Promise((resolve) => server.close(resolve)) | |
| } | |
| closeAllRooms() | |
| closeBoardDb() | |
| rmSync(dir, { recursive: true, force: true }) | |
| }) | |
| interface Options { | |
| member?: string | null | |
| body?: unknown | |
| headers?: Record<string, string> | |
| } | |
| async function call(method: string, path: string, { member = 'm_ana', body, headers = {} }: Options = {}) { | |
| const res = await fetch(`${base}/api/v1${path}`, { | |
| method, | |
| headers: { | |
| ...(member ? { authorization: `Bearer ${member}` } : {}), | |
| ...(body === undefined ? {} : { 'content-type': 'application/json' }), | |
| ...headers, | |
| }, | |
| body: body === undefined ? undefined : typeof body === 'string' ? body : JSON.stringify(body), | |
| }) | |
| const text = await res.text() | |
| return { status: res.status, headers: res.headers, body: text ? JSON.parse(text) : undefined } | |
| } | |
| const vote = (board: string, paperId: string, member: string, decision: string | null) => | |
| call('PUT', `/boards/${board}/papers/${encodeURIComponent(paperId)}/vote`, { member, body: { decision } }) | |
| let boards = 0 | |
| /** A board whose room already has paper cards, as an agent made them. */ | |
| function roomWith(...paperIds: string[]) { | |
| const board = `room${++boards}` | |
| putShapes( | |
| board, | |
| paperIds.map((paperId, i) => ({ | |
| id: paperShapeId(paperId), | |
| type: 'paper-card', | |
| x: i * 100, | |
| y: 0, | |
| props: defaultPaperCardProps(paperId, { title: `Paper ${paperId}`, kind: i === 0 && paperId.startsWith('seed') ? 'seed' : 'suggestion' }), | |
| meta: { createdBy: 'agent', onBehalfOf: 'm_ana', runId: null, paperId }, | |
| })) | |
| ) | |
| return board | |
| } | |
| const cardOf = (board: string, paperId: string) => readRoom(board, (txn) => txn.get(paperShapeId(paperId))) as PaperCardShape | |
| /** Reads an SSE response frame by frame. */ | |
| async function listen(path: string, headers: Record<string, string> = {}) { | |
| const abort = new AbortController() | |
| const res = await fetch(`${base}/api/v1${path}`, { headers, signal: abort.signal }) | |
| const frames: { id: number; event: string; data: any }[] = [] | |
| const reader = res.body!.getReader() | |
| const decoder = new TextDecoder() | |
| let buffer = '' | |
| void (async () => { | |
| try { | |
| for (let read = await reader.read(); !read.done; read = await reader.read()) { | |
| buffer += decoder.decode(read.value, { stream: true }) | |
| for (let end = buffer.indexOf('\n\n'); end >= 0; end = buffer.indexOf('\n\n')) { | |
| const fields = Object.fromEntries( | |
| buffer | |
| .slice(0, end) | |
| .split('\n') | |
| .filter((l) => !l.startsWith(':')) | |
| .map((l) => [l.slice(0, l.indexOf(': ')), l.slice(l.indexOf(': ') + 2)]) | |
| ) | |
| buffer = buffer.slice(end + 2) | |
| if (fields.data) frames.push({ id: Number(fields.id), event: fields.event, data: JSON.parse(fields.data) }) | |
| } | |
| } | |
| } catch { | |
| // aborted | |
| } | |
| })() | |
| const until = async (check: () => boolean, ms = 3000) => { | |
| for (const start = Date.now(); !check(); await new Promise((r) => setTimeout(r, 20))) { | |
| if (Date.now() - start > ms) throw new Error(`no such frame after ${ms} ms: ${JSON.stringify(frames.map((f) => f.event))}`) | |
| } | |
| } | |
| return { res, frames, until, close: () => abort.abort() } | |
| } | |
| async function canvasMcp(board: string, member = 'm_ana') { | |
| const client = new Client({ name: 'test', version: '0' }) | |
| const headers = { Authorization: `Bearer ${member}`, 'X-Run-Id': 'run_00112233aabb' } | |
| await client.connect(new StreamableHTTPClientTransport(new URL(`${base}/api/mcp/canvas/${board}`), { requestInit: { headers } })) | |
| const tool = async (name: string, args: Record<string, unknown> = {}) => | |
| JSON.parse(((await client.callTool({ name, arguments: args })) as { content: { text: string }[] }).content[0].text) | |
| return { client, tool } | |
| } | |
| test('boards: create one, list the caller’s, read and edit it', async () => { | |
| const anonymous = await call('POST', '/boards', { member: null, body: {} }) | |
| assert.equal(anonymous.status, 401) | |
| assert.deepEqual(anonymous.body, { error: { code: 'unauthorized', message: 'Send Authorization: Bearer <member ID>' } }) | |
| assert.equal(anonymous.headers.get('www-authenticate'), 'Bearer') | |
| const created = await call('POST', '/boards', { body: { title: 'Melanoma', question: 'What predicts response to checkpoint therapy?' } }) | |
| assert.equal(created.status, 201) | |
| const board = created.body | |
| assert.match(board.id, /^b_[a-z0-9]{10}$/) | |
| assert.equal(board.createdBy, 'm_ana') | |
| assert.deepEqual(board.goals.map((g: { id: string }) => g.id), ['new-findings', 'methods']) | |
| assert.deepEqual(board.members.map((m: { memberId: string }) => m.memberId), ['m_ana']) | |
| assert.equal(board.agentActive, false) | |
| assert.match(board.createdAt, /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/) | |
| assert.ok((await call('GET', '/boards')).body.some((b: { id: string }) => b.id === board.id)) | |
| assert.ok(!(await call('GET', '/boards', { member: 'm_eve' })).body.some((b: { id: string }) => b.id === board.id)) | |
| const goals = [{ id: 'evidence', label: 'Evidence' }] | |
| const edited = await call('PATCH', `/boards/${board.id}`, { body: { question: 'Which T cells respond?', goals } }) | |
| assert.equal(edited.status, 200) | |
| assert.equal(edited.body.title, 'Melanoma') | |
| const read = await call('GET', `/boards/${board.id}`, { member: null }) | |
| assert.equal(read.body.question, 'Which T cells respond?') | |
| assert.deepEqual(read.body.goals, goals) | |
| assert.equal(read.body.unvoted, undefined, 'caller-only counts need a bearer') | |
| }) | |
| test('opening an old room makes its board row and leaves the canvas as it was', async () => { | |
| const board = roomWith('seed:1', 'pmid:2') | |
| const before = readRoom(board, (txn) => [...txn.values()].length) | |
| const res = await call('GET', `/boards/${board}`) | |
| assert.equal(res.status, 200) | |
| assert.equal(res.body.id, board) | |
| assert.equal(res.body.seedCount, 1) | |
| assert.equal(res.body.unvoted, 1) | |
| assert.equal(readRoom(board, (txn) => [...txn.values()].length), before) | |
| }) | |
| test('bad requests get a JSON error with a code and the reason', async () => { | |
| const board = roomWith('pmid:1') | |
| const cases: [string, string, Options, number, string, RegExp][] = [ | |
| ['PUT', `/boards/${board}/papers/pmid%3A1/vote`, { body: { decision: 'maybe' } }, 400, 'invalid', /decision/], | |
| ['PUT', `/boards/${board}/papers/pmid%3A1/vote`, { body: 'not json' }, 400, 'bad_json', /JSON/], | |
| ['PUT', `/boards/${board}/papers/pmid%3A1/vote`, { body: { decision: 'keep', by: 'm_x' } }, 400, 'invalid', /by/], | |
| ['PUT', `/boards/${board}/papers/pmid%3A1/vote`, { member: 'ana', body: { decision: 'keep' } }, 401, 'unauthorized', /Bearer/], | |
| ['PUT', `/boards/${board}/papers/pmid%3A404/vote`, { body: { decision: 'keep' } }, 404, 'not_found', /No card for pmid:404/], | |
| ['GET', '/boards/bad%20board', {}, 400, 'bad_request', /board ID/], | |
| ['PATCH', `/boards/${board}`, { body: {} }, 400, 'invalid', /at least one/], | |
| ['PATCH', `/boards/${board}`, { body: { goals: [{ id: 'Bad Goal', label: 'x' }] } }, 400, 'invalid', /goals\.0\.id/], | |
| ['PUT', `/boards/${board}/removed-links`, { body: { from: 'pmid:1', to: 'pmid:2' } }, 400, 'invalid', /kind/], | |
| ['POST', `/boards/${board}/seeds`, { body: { pmid: 'abc' } }, 400, 'invalid', /PMID/], | |
| ['POST', `/boards/${board}/seen`, { body: { seq: -1 } }, 400, 'invalid', /seq/], | |
| ['GET', `/boards/${board}/papers?status=maybe`, {}, 400, 'invalid', /status/], | |
| ['GET', `/boards/${board}/activity?limit=0`, {}, 400, 'invalid', /limit/], | |
| ['GET', `/boards/${board}/events?after=x`, {}, 400, 'invalid', /seq/], | |
| ['GET', '/nope', {}, 404, 'not_found', /No route/], | |
| ] | |
| for (const [method, path, options, status, code, message] of cases) { | |
| const res = await call(method, path, options) | |
| assert.equal(res.status, status, `${method} ${path}: ${JSON.stringify(res.body)}`) | |
| assert.equal(res.body.error.code, code, `${method} ${path}`) | |
| assert.match(res.body.error.message, message, `${method} ${path}`) | |
| } | |
| const wrongMethod = await call('DELETE', `/boards/${board}`) | |
| assert.equal(wrongMethod.status, 405) | |
| assert.equal(wrongMethod.headers.get('allow'), 'GET, PATCH') | |
| assert.deepEqual(cardOf(board, 'pmid:1').props.keepBy, [], 'nothing was written') | |
| }) | |
| test('Keep and Discard change the card for everyone; the paper record shows the votes and the papers MCP’s fields', async () => { | |
| const board = roomWith(TIROSH.paperId, 'pmid:9') | |
| assert.deepEqual((await vote(board, TIROSH.paperId, 'm_ana', 'keep')).body, { status: 'kept', keepBy: ['m_ana'], discardBy: [] }) | |
| assert.equal(cardOf(board, TIROSH.paperId).props.status, 'kept') | |
| assert.deepEqual((await vote(board, TIROSH.paperId, 'm_ben', 'discard')).body, { | |
| status: 'contested', | |
| keepBy: ['m_ana'], | |
| discardBy: ['m_ben'], | |
| }) | |
| const card = cardOf(board, TIROSH.paperId).props | |
| assert.deepEqual([card.status, card.keepBy, card.discardBy], ['contested', ['m_ana'], ['m_ben']]) | |
| const paper = (await call('GET', `/boards/${board}/papers/${encodeURIComponent(TIROSH.paperId)}`, { member: null })).body | |
| for (const key of ['title', 'authors', 'year', 'venue', 'abstract', 'openAccessPdf', 'ids'] as const) { | |
| assert.deepEqual(paper[key], TIROSH[key], key) | |
| } | |
| assert.equal(paper.pdf, `/api/v1/boards/${board}/papers/pmid%3A27124452/pdf`) | |
| assert.deepEqual(paper.board, { | |
| kind: 'suggestion', | |
| status: 'contested', | |
| votes: { keep: ['m_ana'], discard: ['m_ben'] }, | |
| working: false, | |
| addedBy: 'agent', | |
| onBehalfOf: 'm_ana', | |
| runId: null, | |
| addedAt: paper.board.addedAt, | |
| }) | |
| assert.equal(paper.skipped, undefined) | |
| const list = (await call('GET', `/boards/${board}/papers`)).body | |
| assert.deepEqual(list.map((p: { paperId: string }) => p.paperId).sort(), [TIROSH.paperId, 'pmid:9']) | |
| const contested = (await call('GET', `/boards/${board}/papers?status=contested,kept`)).body | |
| assert.deepEqual(contested.map((p: { paperId: string; keepBy: string[] }) => [p.paperId, p.keepBy]), [[TIROSH.paperId, ['m_ana']]]) | |
| // the papers MCP doesn't know pmid:9: the board's part still comes back, and says what's missing | |
| const unknown = (await call('GET', `/boards/${board}/papers/pmid%3A9`)).body | |
| assert.equal(unknown.title, 'Paper pmid:9') | |
| assert.match(unknown.skipped[0], /no record for pmid:9/) | |
| }) | |
| test('SSE: votes and agent writes arrive as they happen, and a reconnect gets what it missed, once', async () => { | |
| const board = roomWith('pmid:1') | |
| const stream = await listen(`/boards/${board}/events`) | |
| assert.equal(stream.res.headers.get('content-type'), 'text/event-stream; charset=utf-8') | |
| assert.equal(stream.res.headers.get('x-accel-buffering'), 'no') | |
| const agent = await canvasMcp(board) | |
| assert.deepEqual((await agent.tool('add_papers', { papers: [{ paperId: 'pmid:2', title: 'Two', near: 'pmid:1' }] })).added, ['pmid:2']) | |
| await stream.until(() => stream.frames.some((f) => f.event === 'paper.added')) | |
| const added = stream.frames.find((f) => f.event === 'paper.added')! | |
| assert.deepEqual( | |
| { ...added.data, at: '' }, | |
| { | |
| seq: added.id, | |
| boardId: board, | |
| actor: 'agent', | |
| onBehalfOf: 'm_ana', | |
| runId: 'run_00112233aabb', | |
| type: 'paper.added', | |
| payload: { tool: 'add_papers', paperIds: ['pmid:2'] }, | |
| at: '', | |
| } | |
| ) | |
| await agent.tool('add_note', { text: 'Worth a look', paperId: 'pmid:2' }) | |
| await vote(board, 'pmid:2', 'm_ana', 'keep') | |
| await stream.until(() => stream.frames.some((f) => f.event === 'paper.voted')) | |
| assert.deepEqual(stream.frames.map((f) => f.event), ['paper.added', 'note.added', 'member.joined', 'paper.voted']) | |
| assert.equal(stream.frames.at(-1)!.data.payload.status, 'kept') | |
| stream.close() | |
| await agent.client.close() | |
| // Ben's vote happens while the stream is away | |
| const lastId = stream.frames.at(-1)!.id | |
| await vote(board, 'pmid:2', 'm_ben', 'discard') | |
| const back = await listen(`/boards/${board}/events`, { 'Last-Event-ID': String(lastId) }) | |
| await back.until(() => back.frames.some((f) => f.event === 'paper.voted')) | |
| await vote(board, 'pmid:1', 'm_ben', 'keep') | |
| await back.until(() => back.frames.filter((f) => f.event === 'paper.voted').length === 2) | |
| assert.deepEqual(back.frames.map((f) => [f.event, f.data.actor]), [ | |
| ['member.joined', 'm_ben'], | |
| ['paper.voted', 'm_ben'], | |
| ['paper.voted', 'm_ben'], | |
| ]) | |
| assert.ok(back.frames.every((f, i) => f.id > lastId && (i === 0 || f.id > back.frames[i - 1].id)), 'each once, in order') | |
| assert.equal(streamCount(board), 1, 'the first stream was cleaned up when it closed') | |
| back.close() | |
| await back.until(() => streamCount(board) === 0) | |
| const fromStart = await listen(`/boards/${board}/events?after=0`) | |
| await fromStart.until(() => fromStart.frames.length === 7) | |
| fromStart.close() | |
| const activity = (await call('GET', `/boards/${board}/activity`)).body | |
| assert.deepEqual(activity.map((e: { seq: number }) => e.seq), [...fromStart.frames].reverse().map((f) => f.id), 'newest first') | |
| assert.equal((await call('GET', `/boards/${board}/activity?after=${activity[1].seq}`)).body.length, 1) | |
| }) | |
| test('what people discard and the links they remove reach the agent through get_board', async () => { | |
| const board = roomWith('pmid:a', 'pmid:b') | |
| await vote(board, 'pmid:a', 'm_ben', 'discard') | |
| const removed = await call('PUT', `/boards/${board}/removed-links`, { body: { from: 'pmid:a', to: 'pmid:b', kind: 'cites' } }) | |
| assert.equal(removed.status, 204) | |
| const agent = await canvasMcp(board) | |
| const view = await agent.tool('get_board') | |
| assert.deepEqual(view.discardedIds, ['pmid:a']) | |
| assert.deepEqual(view.removedLinks, [{ from: 'pmid:a', to: 'pmid:b', kind: 'cites' }]) | |
| const linked = await agent.tool('link_papers', { links: [{ from: 'pmid:a', to: 'pmid:b', kind: 'cites' }] }) | |
| assert.deepEqual(linked.skipped, [{ from: 'pmid:a', to: 'pmid:b', kind: 'cites', reason: 'removed by a person' }]) | |
| await agent.client.close() | |
| const types = (await call('GET', `/boards/${board}/activity`)).body.map((e: { type: string }) => e.type) | |
| assert.ok(types.includes('link.removed')) | |
| }) | |
| test('members join and rename; "seen" clears the Catch me up count', async () => { | |
| const board = roomWith('pmid:1', 'pmid:2') | |
| const joined = await call('PUT', `/boards/${board}/members/me`, { body: { displayName: 'Ana' } }) | |
| assert.equal(joined.status, 200) | |
| assert.equal(joined.body.displayName, 'Ana') | |
| const renamed = await call('PUT', `/boards/${board}/members/me`, { body: { displayName: 'Ana K.' } }) | |
| assert.deepEqual(renamed.body, { ...joined.body, displayName: 'Ana K.' }) | |
| await vote(board, 'pmid:1', 'm_ben', 'keep') | |
| const before = (await call('GET', `/boards/${board}`)).body | |
| assert.equal(before.newSinceLastSeen, 2, "Ben joining and voting; Ana's own join doesn't count") | |
| assert.equal(before.unvoted, 2) | |
| assert.equal((await call('POST', `/boards/${board}/seen`, { body: { seq: before.lastSeq } })).status, 204) | |
| const after = (await call('GET', `/boards/${board}`)).body | |
| assert.equal(after.newSinceLastSeen, 0) | |
| assert.deepEqual(after.members.map((m: { memberId: string; displayName: string }) => [m.memberId, m.displayName]), [ | |
| ['m_ana', 'Ana K.'], | |
| ['m_ben', ''], | |
| ]) | |
| }) | |
| test('a seed by PMID becomes a seed card authored by the member; an unknown one is a 404', async () => { | |
| const board = roomWith('pmid:1') | |
| const res = await call('POST', `/boards/${board}/seeds`, { body: { pmid: '27124452' } }) | |
| assert.equal(res.status, 201) | |
| assert.equal(res.body.paperId, TIROSH.paperId) | |
| assert.equal(res.body.abstract, TIROSH.abstract) | |
| assert.deepEqual([res.body.board.kind, res.body.board.status, res.body.board.addedBy], ['seed', 'kept', 'm_ana']) | |
| const card = cardOf(board, TIROSH.paperId) | |
| assert.deepEqual(card.meta, { author: 'm_ana', paperId: TIROSH.paperId }) | |
| assert.deepEqual([card.props.title, card.props.year, card.props.type], [TIROSH.title, 2016, 'article']) | |
| assert.equal((await call('POST', `/boards/${board}/seeds`, { body: { pmid: '27124452' } })).status, 201, 'again: same card') | |
| assert.equal(readRoom(board, (txn) => [...txn.values()].filter((r) => r.typeName === 'shape').length), 2) | |
| const unknown = await call('POST', `/boards/${board}/seeds`, { body: { pmid: '404' } }) | |
| assert.equal(unknown.status, 404) | |
| assert.equal(unknown.body.error.code, 'paper_not_found') | |
| const types = (await call('GET', `/boards/${board}/activity`)).body.map((e: { type: string }) => e.type) | |
| assert.deepEqual(types.filter((t: string) => t === 'seed.added').length, 2) | |
| }) | |
| /** Posts a file as the Start page does: multipart, field "file". */ | |
| async function postFile(board: string, name: string, data: Uint8Array<ArrayBuffer>, type = 'application/pdf') { | |
| const form = new FormData() | |
| form.append('file', new Blob([data], { type }), name) | |
| const res = await fetch(`${base}/api/v1/boards/${board}/seeds`, { method: 'POST', headers: { authorization: 'Bearer m_ana' }, body: form }) | |
| return { status: res.status, body: await res.json() } | |
| } | |
| test('a PDF seed is found by its DOI, else by its title; without either it is a 422 that says what to do', async () => { | |
| const board = roomWith('pmid:1') | |
| const withDoi = textPdf([ | |
| { text: 'Research article', size: 9 }, | |
| { text: 'Dissecting the multicellular ecosystem', size: 18 }, | |
| { text: 'Science 352, 189 (2016). DOI: 10.1126/science.aad0501.', size: 8 }, | |
| ]) | |
| const byDoi = await postFile(board, 'tirosh.pdf', withDoi) | |
| assert.equal(byDoi.status, 201, JSON.stringify(byDoi.body)) | |
| assert.deepEqual([byDoi.body.paperId, byDoi.body.board.kind, byDoi.body.board.addedBy], [TIROSH.paperId, 'seed', 'm_ana']) | |
| assert.equal(cardOf(board, TIROSH.paperId).props.title, TIROSH.title) | |
| // kept with the uploads, where /api/uploads/<id> serves it | |
| const upload = byDoi.body.upload.replace('/api/uploads/', '') | |
| assert.deepEqual(new Uint8Array(readFileSync(join(dir, 'uploads', upload))), withDoi) | |
| assert.equal(readFileSync(join(dir, 'uploads', `${upload}.type`), 'utf8'), 'application/pdf') | |
| const manuscript = textPdf([ | |
| { text: 'Author manuscript', size: 9 }, | |
| { text: 'Defining T cell states associated with response', size: 18 }, | |
| { text: 'to checkpoint immunotherapy in melanoma', size: 18 }, | |
| { text: 'Moshe Sade-Feldman, Keren Yizhak', size: 11 }, | |
| ]) | |
| const byTitle = await postFile(board, 'sade-feldman.pdf', manuscript) | |
| assert.equal(byTitle.status, 201, JSON.stringify(byTitle.body)) | |
| assert.equal(byTitle.body.paperId, SADE.paperId) | |
| const notes = await postFile(board, 'notes.pdf', textPdf([{ text: 'Lab meeting notes', size: 12 }])) | |
| assert.deepEqual([notes.status, notes.body], [422, { error: { code: 'no_doi', message: "Couldn't find a DOI in notes.pdf. Paste its DOI instead." } }]) | |
| const garbled = await postFile(board, 'scan.pdf', new TextEncoder().encode('not a pdf')) | |
| assert.deepEqual([garbled.status, garbled.body.error.code], [422, 'bad_pdf']) | |
| const text = await postFile(board, 'refs.txt', new TextEncoder().encode('10.1126/science.aad0501'), 'text/plain') | |
| assert.deepEqual([text.status, text.body.error.code, text.body.error.message], [415, 'unsupported_type', "refs.txt isn't a PDF"]) | |
| const types = (await call('GET', `/boards/${board}/activity`)).body.map((e: { type: string }) => e.type) | |
| assert.equal(types.filter((t: string) => t === 'seed.added').length, 2, 'only the two found papers became seeds') | |
| assert.equal(readRoom(board, (txn) => [...txn.values()].filter((r) => r.typeName === 'shape').length), 3) | |
| }) | |
| test('Home’s counts: papers on the board and, for the caller, what’s new since they last looked', async () => { | |
| const board = roomWith('seed:1', 'pmid:2') | |
| await call('PUT', `/boards/${board}/members/me`, { body: { displayName: 'Ana' } }) | |
| const agent = await canvasMcp(board, 'm_ben') | |
| await agent.tool('add_papers', { papers: [{ paperId: 'pmid:3', title: 'Three', near: 'seed:1' }] }) | |
| await agent.client.close() | |
| const read = (await call('GET', `/boards/${board}`)).body | |
| assert.deepEqual([read.seedCount, read.paperCount, read.newCount], [1, 3, 1]) | |
| assert.equal(read.newCount, read.newSinceLastSeen) | |
| const listed = (await call('GET', '/boards')).body.find((b: { id: string }) => b.id === board) | |
| assert.deepEqual([listed.seedCount, listed.paperCount, listed.newCount], [1, 3, 1]) | |
| assert.equal((await call('GET', `/boards/${board}`, { member: null })).body.newCount, undefined, 'newCount needs a bearer') | |
| await call('POST', `/boards/${board}/seen`, { body: { seq: read.lastSeq } }) | |
| assert.equal((await call('GET', '/boards')).body.find((b: { id: string }) => b.id === board).newCount, 0) | |
| }) | |
| test('the PDF route refuses a link that isn’t https on a public host, and 404s without one', async () => { | |
| const board = roomWith(TIROSH.paperId, 'pmid:2') | |
| const refused = await call('GET', `/boards/${board}/papers/${encodeURIComponent(TIROSH.paperId)}/pdf`) | |
| assert.equal(refused.status, 502) | |
| assert.equal(refused.body.error.code, 'pdf_refused') | |
| assert.match(refused.body.error.message, /Only https/) | |
| const none = await call('GET', `/boards/${board}/papers/pmid%3A2/pdf`) | |
| assert.deepEqual([none.status, none.body.error.code], [404, 'no_pdf']) | |
| assert.equal((await call('GET', `/boards/${board}/papers/pmid%3A3/pdf`)).status, 404) | |
| }) | |