Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"!apps/web/src/routeTree.gen.ts",
"!apps/web/src/builder/model-accounts/marks",
"!packages/connectors/src/*/logo.svg",
"!packages/connectors/src/*/recordings",
"!packages/app/starter",
"!packages/app/skills"
]
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"removals": "node scripts/removals.ts",
"record": "node scripts/record.ts",
"record-provider": "node scripts/record-provider.ts",
"record-connector": "node scripts/record-connector.ts",
"openapi": "node scripts/emit-openapi.ts",
"radius": "node scripts/check-radius.ts",
"boundaries": "node scripts/check-boundaries.ts",
Expand Down
6 changes: 4 additions & 2 deletions packages/connectors/src/kernel/conformance-list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import type { ListRead, Operation } from './operation.ts'
import { listInputNames, valueAt } from './operation.ts'
import { Rows } from './rows.ts'

/** The listing's reading by the fixed input of a probe: the paging and changed-since names leave it, and the copy takes the rest, keyed by `key`, a field the double's rows have. */
/** The listing's reading by the fixed input of a probe: the paging and changed-since names leave it, and the copy takes the rest, keyed by `key`, a scalar field the double's rows have. */
function walkDefinition(
operation: Operation<unknown, unknown>,
list: ListRead,
Expand Down Expand Up @@ -84,7 +84,9 @@ async function walkList(
}>,
): Promise<void> {
const { operation, list, first } = a
const key = Object.keys(first.linhas[0] ?? {})[0]
const key = Object.entries(first.linhas[0] ?? {}).find(
([, value]) => typeof value === 'string' || typeof value === 'number',
)?.[0]
if (key === undefined) {
assert.equal(first.total ?? 0, 0, `${operation.id} lacks rows the total counts`)
return
Expand Down
41 changes: 39 additions & 2 deletions packages/connectors/src/mercado-livre/fake-data.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { rowsOf } from './recordings.ts'

// Invented values only (S40). The buyer's name, e-mail, phone, address and CPF are here on
// purpose: the kit must drop them (`operations.test.ts`, "sem dados do comprador").

Expand All @@ -9,9 +11,10 @@ export const REDIRECT_URI = 'https://hub.exemplo.test/integrations/return'

export const SELLER = Object.freeze({ ref: '123456789', label: 'PADARIASOL' })

/** More than the 1,000 an offset search reaches: only a scan reads them all. */
export const ITEMS = Array.from(
{ length: 12 },
(_, index) => `MLB10000000${String(index + 1).padStart(2, '0')}`,
{ length: 1300 },
(_, index) => `MLB1000000${String(index + 1).padStart(4, '0')}`,
)
export const ORDER = 2000000000001
export const PACK = 2000000000099
Expand Down Expand Up @@ -49,6 +52,40 @@ export const ORDER_BODY = {
],
}

let ordersKept: readonly (Record<string, unknown> & { date_last_updated: string })[] | undefined

/** 120 orders, each shaped as a recorded one with the planted buyer; each changed a minute after the one before, so a changed-since filter keeps the newest. */
export function ordersOf() {
const recorded = rowsOf('pedidos-alterados', 'results')
ordersKept ??= Array.from({ length: 120 }, (_, index) => ({
...recorded[index % recorded.length],
buyer: BUYER,
id: ORDER + index,
date_last_updated: new Date(Date.UTC(2026, 8, 10, 13, index)).toISOString(),
}))
return ordersKept
}

/** The rows of a scan's page: its `scroll_id` carries where the next page starts (`<route>-<offset>`). */
export function scanOf<T>(rows: readonly T[], query: URLSearchParams, route: string) {
const scroll = query.get('scroll_id')
const offset = scroll === null ? 0 : Number(scroll.slice(route.length + 1))
const limit = Number(query.get('limit') ?? 50)
const page = rows.slice(offset, offset + limit)
return { rows: page, scrollId: `${route}-${offset + page.length}`, total: rows.length, limit }
}

/** A scan's cursor the double never gave. */
export function badScroll(query: URLSearchParams, route: string): boolean {
const scroll = query.get('scroll_id')
return scroll !== null && !new RegExp(`^${route}-\\d+$`).test(scroll)
}

export const BAD_SCROLL = {
status: 400,
body: { message: 'invalid scroll_id', error: 'bad_request', status: 400 },
}

export function itemBody(id: string, index: number) {
return {
id,
Expand Down
50 changes: 34 additions & 16 deletions packages/connectors/src/mercado-livre/fake-routes-more.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { Route } from './fake-data.ts'
import {
BAD_SCROLL,
BUYER,
badScroll,
CLAIM,
ITEMS,
ORDER,
Expand All @@ -9,23 +11,26 @@ import {
paging,
SELLER,
SELLER_PATH,
scanOf,
} from './fake-data.ts'
import { envelope, rowsOf } from './recordings.ts'

const QUESTIONS = Array.from({ length: 12 }, (_, index) => ({
id: 9000000001 + index,
text: `Tem na cor azul? (${index + 1})`,
status: 'UNANSWERED',
date_created: '2026-09-11T09:00:00.000-03:00',
item_id: ITEMS[0],
seller_id: Number(SELLER.ref),
from: {
id: BUYER.id,
answered_questions: 3,
nickname: BUYER.nickname,
email: BUYER.email,
},
answer: null,
}))
let questionsKept: readonly Record<string, unknown>[] | undefined

/** 12 questions shaped as the recorded one, every fourth answered as it was; the asker's name and e-mail are planted for the kit to drop. */
function questionsOf() {
const recorded = rowsOf('perguntas-varredura', 'questions')
questionsKept ??= Array.from({ length: 12 }, (_, index) => ({
...recorded[0],
id: 9000000001 + index,
text: `Tem na cor azul? (${index + 1})`,
...(index % 4 === 3 ? {} : { status: 'UNANSWERED', answer: null }),
item_id: ITEMS[0],
seller_id: Number(SELLER.ref),
from: { id: BUYER.id, answered_questions: 3, nickname: BUYER.nickname, email: BUYER.email },
}))
return questionsKept
}

const PERIODS = Array.from({ length: 14 }, (_, index) => {
const month = String(index + 1).padStart(2, '0')
Expand All @@ -50,7 +55,20 @@ export const MORE_ROUTES: readonly Route[] = [
group: 'perguntas',
pattern: /^\/questions\/search$/,
answer: ({ query }) => {
const page = pageOf(QUESTIONS, query, 50)
if (query.get('search_type') === 'scan') {
if (badScroll(query, 'questions')) return BAD_SCROLL
const scan = scanOf(questionsOf(), query, 'questions')
return {
body: {
...envelope('perguntas-varredura', 'questions'),
total: scan.total,
limit: scan.limit,
questions: scan.rows,
scroll_id: scan.scrollId,
},
}
}
const page = pageOf(questionsOf(), query, 50)
return {
body: {
total: page.total,
Expand Down
36 changes: 33 additions & 3 deletions packages/connectors/src/mercado-livre/fake-routes.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,22 @@
import type { Route } from './fake-data.ts'
import {
BAD_SCROLL,
BUYER,
badScroll,
ITEMS,
itemBody,
ORDER,
ORDER_BODY,
ordersOf,
pageOf,
paging,
SELLER,
SELLER_PATH,
SHIPMENT,
scanOf,
} from './fake-data.ts'
import { MORE_ROUTES } from './fake-routes-more.ts'
import { envelope } from './recordings.ts'

/** The routes of the double, by permission group; the order is the order they are tried. */
export const ROUTES: readonly Route[] = [
Expand All @@ -20,6 +25,19 @@ export const ROUTES: readonly Route[] = [
group: 'anuncios',
pattern: new RegExp(`^/users/${SELLER_PATH}/items/search$`),
answer: ({ query }) => {
if (query.get('search_type') === 'scan') {
if (badScroll(query, 'items')) return BAD_SCROLL
const scan = scanOf(ITEMS, query, 'items')
return {
body: {
...envelope('anuncios-varredura-1', 'results'),
seller_id: SELLER.ref,
results: scan.rows,
scroll_id: scan.scrollId,
paging: { total: scan.total, limit: scan.limit },
},
}
}
const page = pageOf(ITEMS, query, 50)
return {
body: {
Expand Down Expand Up @@ -84,9 +102,21 @@ export const ROUTES: readonly Route[] = [
name: 'pedidos',
group: 'vendas',
pattern: /^\/orders\/search$/,
answer: ({ query }) => ({
body: { results: [ORDER_BODY], paging: paging(1, query), sort: { id: 'date_desc' } },
}),
answer: ({ query }) => {
const from = query.get('order.date_last_updated.from')
const changed =
from === null
? ordersOf()
: ordersOf().filter((order) => Date.parse(order.date_last_updated) >= Date.parse(from))
const page = pageOf(changed, query, 50)
return {
body: {
...envelope('pedidos-alterados', 'results'),
results: page.rows,
paging: paging(page.total, query),
},
}
},
},
{
name: 'pedido',
Expand Down
85 changes: 84 additions & 1 deletion packages/connectors/src/mercado-livre/fake.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
import assert from 'node:assert/strict'
import { test } from 'node:test'
import { z } from 'zod'
import { SENT_AT } from '../kernel/conformance-declared.ts'
import { answerOf } from '../kernel/conformance-session.ts'
import { refreshRequest } from '../kernel/oauth.ts'
import { requestOf } from '../kernel/operation.ts'
import { toRequest } from '../kernel/request.ts'
import { mercadoLivre } from './definition.ts'
import { mercadoLivreFake } from './fake.ts'
import { authorize, getWith, grant } from './test-session.ts'
import type { Case } from './recordings.ts'
import { recording } from './recordings.ts'
import { ACCOUNT, authorize, getWith, grant } from './test-session.ts'

const auth = mercadoLivre.auth

Expand Down Expand Up @@ -93,3 +100,79 @@ test('expireAccess dá 401 nas chaves de acesso emitidas, e uma renovação dá
if (renewed.kind !== 'OK') return assert.fail('renewal')
assert.equal(await read(renewed.accessToken), 200)
})

/** Each path of a body (`results[].buyer.id`) with the JSON types seen there, across every element of an array. */
function shapeOf(value: unknown, at = '', found = new Map<string, Set<string>>()) {
const type = value === null ? 'null' : Array.isArray(value) ? 'array' : typeof value
found.set(at, (found.get(at) ?? new Set()).add(type))
if (Array.isArray(value)) for (const item of value) shapeOf(item, `${at}[]`, found)
else if (typeof value === 'object' && value !== null)
for (const [key, item] of Object.entries(value)) shapeOf(item, `${at}.${key}`, found)
return found
}

/** What the double plants for the kit to drop (`operations.test.ts`, "sem dados do comprador"): the service never sends it on these routes. */
const PLANTED = ['.results[].buyer.', '.questions[].from.']

const SCAN = { scan: true } as const

function bodyOf(answered: { body?: unknown }) {
return z.record(z.string(), z.unknown()).parse(answered.body)
}

/** The route with the account's own id left out: the recording carries an invented one. */
function accountless(path: string) {
return path.replace(/\/users\/[^/]+/, '/users/:account')
}

test('as respostas gravadas: o dublê responde as quatro formas com as chaves, os tipos e a rota do serviço real', async () => {
const fake = mercadoLivreFake()
const granted = await authorize(fake)
if (granted.kind !== 'OK') return assert.fail('authorize')
const token = granted.accessToken
async function ask(id: string, input: Record<string, unknown>) {
const operation = mercadoLivre.operations.find((each) => each.id === id) ?? assert.fail(id)
const request = requestOf(operation, input, ACCOUNT, SENT_AT)
const answered = await answerOf(await fake.handle(toRequest(mercadoLivre, request, token)))
return { request, answered }
}
const first = await ask('anuncios', { ...SCAN, limit: 100 })
const scroll = z.string().parse(bodyOf(first.answered).scroll_id)
let last = first
for (let pages = 0; pages < 100; pages++) {
const results = z.array(z.unknown()).parse(bodyOf(last.answered).results)
if (results.length === 0 && pages > 0) break
last = await ask('anuncios', {
scrollId: z.string().parse(bodyOf(last.answered).scroll_id),
limit: 100,
})
}
assert.notEqual(scroll, '')
const cases: [Case, Awaited<ReturnType<typeof ask>>][] = [
[
'pedidos-alterados',
await ask('pedidos', { offset: 0, limit: 50, changedFrom: '2026-09-10T00:00:00.000-03:00' }),
],
['anuncios-varredura-1', first],
['anuncios-varredura-2', last],
['perguntas-varredura', await ask('perguntas', { ...SCAN, limit: 50 })],
]
for (const [name, { request, answered }] of cases) {
const real = recording(name)
assert.equal(accountless(request.path), accountless(real.route.path), `${name}: a rota`)
assert.deepEqual(Object.keys(request.query).sort(), real.route.query, `${name}: o pedido`)
assert.equal(answered.status, real.status, name)
const given = shapeOf(bodyOf(answered))
const recorded = shapeOf(real.body)
for (const [at, types] of recorded)
for (const type of types)
assert.ok(given.get(at)?.has(type), `${name}: o dublê não manda ${at} como ${type}`)
for (const at of given.keys())
assert.ok(
recorded.has(at) || PLANTED.some((planted) => at.startsWith(planted)),
`${name}: o serviço real não manda ${at}`,
)
}
assert.deepEqual(bodyOf(last.answered).results, [], 'a varredura acaba numa página sem linhas')
assert.equal(typeof bodyOf(last.answered).scroll_id, 'string', '...que ainda traz o scroll_id')
})
10 changes: 10 additions & 0 deletions packages/connectors/src/mercado-livre/mercado-livre.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { authorizeUrl, codeRequest, refreshRequest } from '../kernel/oauth.ts'
import { AccountRef, requestOf } from '../kernel/operation.ts'
import { toRequest } from '../kernel/request.ts'
import { Rows } from '../kernel/rows.ts'
import { readSkill } from '../kernel/skill.ts'
import { mercadoLivre } from './definition.ts'
import { mercadoLivreFake } from './fake.ts'
import { docsOf } from './operations/shared.ts'
Expand Down Expand Up @@ -145,6 +146,15 @@ test('só anuncios e perguntas nomeiam o cursor de varredura, no resumo, na entr
}
})

test('a skill diz o mesmo que os resumos: a varredura passa de mil e o filtro de alteração existe', () => {
const { references } = readSkill(new URL('./skill/', import.meta.url))
for (const name of ['anuncios.md', 'perguntas.md'])
assert.match(references[name] ?? '', /`scan: true`.*`scrollId`/s, name)
assert.match(references['vendas.md'] ?? '', /`changedFrom`/)
for (const [name, text] of Object.entries(references))
assert.doesNotMatch(text, /no way past 1000/, name)
})

test('o conector declara que o Mercado Livre conta o limite pelo aplicativo', () => {
assert.deepEqual(mercadoLivre.callLimit, { countedBy: 'APP' })
})
Expand Down
Loading
Loading