Files
jackwener__opencli/clis/stackoverflow/stackoverflow.test.js
jakevin c1a4bd3b7e feat(stackoverflow): surface question_id on listings + new read <id> (#1293)
* feat(stackoverflow): surface question_id + metadata on listings, add `read <id>`

Agent-native gap: all 4 stackoverflow listings (`hot`, `search`,
`unanswered`, `bounties`) only emitted `[title, score, answers, url]`,
which means an agent could see a hot question but had no `id` to round-
trip into a body read, no `tags` to filter by topic, no `views` to gauge
demand, and no `is_answered` / `creation_date` / `author` to triage.
There also wasn't a `read` adapter, so reading a SO question through
opencli was impossible.

Listings (`hot` / `search` / `bounties` / `unanswered`):
- Add `rank`, `id` (question_id), `views`, `is_answered` (skipped on
  `unanswered` since always false), `tags` (joined), `author`
  (owner.display_name), `creation_date` columns.
- Pass `pagesize` to the upstream API instead of fetching the default
  page and trimming locally.

New `stackoverflow read <id>`:
- 4-call fan-out against the public Stack Exchange API
  (`/questions/{id}` + `/questions/{id}/comments` +
  `/questions/{id}/answers` + batched `/answers/a;b;c/comments`).
- Returns `POST` + `Q-COMMENT` + `ANSWER` + `A-COMMENT` rows mirroring
  the `hackernews read` and `lobsters read` shape.
- Accepted answer is always surfaced first and tagged `accepted='true'`;
  remaining answers follow in descending vote order, capped by
  `--answers-limit`.
- HTML body cleanup: tags stripped, `<pre><code>` preserved, `<code>`
  inline-fenced, `<li>` rendered as `- `, comments indented with `> `.
- Entity decoding: a shared `decodeEntities` handles named (incl.
  `&hellip;`/`&copy;`/etc), decimal (`&#246;`), and hex (`&#x27;`)
  forms, applied to both bodies AND `display_name` (otherwise users
  like `Jonas K&#246;lker` come through mojibaked).
- Typed fail-fast: `ArgumentError` for non-numeric id and
  `--max-length < 100` (with no-fetch assertion); `EmptyResultError`
  when `items` is empty; `CommandExecutionError` for HTTP non-2xx and
  for Stack Exchange's in-band `error_id` envelopes (throttle / quota).
  No silent clamps anywhere.

Tests: 14 vitest assertions
- 4 listing column-shape (incl. `unanswered` skipping `is_answered` and
  `bounties` keeping its `bounty` column position)
- 10 read-adapter cases: registration / args / strategy + 3 typed-error
  fail-fast paths (with no-fetch assertion on the pre-fetch ones) + the
  full POST/Q-COMMENT/ANSWER/A-COMMENT row order with accepted-first +
  the answer-comments fetch verified to batch ids semicolon-joined +
  HTML entity decoding (named/decimal/hex) on both body and display_name
  + answers-limit honored when there are more answers than the cap.

Live verification:
- `stackoverflow hot --limit 2` → `id`/`tags`/`views`/`is_answered`/
  `author` populated.
- `stackoverflow search "async await" --limit 1`,
  `stackoverflow unanswered --limit 1` → same shape.
- `stackoverflow read 79935770` and the very-long classic question
  `stackoverflow read 11227809 --answers-limit 1 --comments-limit 2`
  → produces the threaded POST/Q-COMMENT/ANSWER/A-COMMENT structure
  with proper entity decoding (`Jonas Kölker` reads correctly).
- `stackoverflow read not-numeric` → exits with `ARGUMENT`.
- `stackoverflow read 999999999` → exits with `EMPTY_RESULT`.

* fix(stackoverflow): wrap fetch/json/coerce paths in typed errors

Apply the 3 lessons from PR #1292 (devto) review at merge time, before
B-group hits this PR:

1. CLI args may arrive as strings (e.g. `--max-length 50` → `'50'`).
   The bare `Number.isInteger(value)` in `requirePositiveInt` /
   `requireMinInt` would accept negative-but-coerced numbers and reject
   string-form integers. Now the helpers `coerceInt` first then validate,
   and the rejection message echoes the raw input via `JSON.stringify`.

2. `await fetch(url)` and `await res.json()` were not wrapped — a network
   blip would surface as a raw `TypeError` and a maintenance HTML page
   would surface as a raw `SyntaxError`. Both are now caught and rethrown
   as `CommandExecutionError` with hints, matching the in-band error_id
   path.

Tests: +3 cases (17 total)
- fetch network failure → CommandExecutionError
- malformed JSON body → CommandExecutionError
- string-form max-length "50" / "abc" rejected with ArgumentError before
  fetching

* fix(stackoverflow): avoid partial read fanout
2026-05-04 19:10:50 +08:00

347 lines
16 KiB
JavaScript

import { afterEach, describe, expect, it, vi } from 'vitest';
import { getRegistry } from '@jackwener/opencli/registry';
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
import './hot.js';
import './search.js';
import './unanswered.js';
import './bounties.js';
import './read.js';
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe('stackoverflow listing adapters surface question_id/tags/views/owner', () => {
it('stackoverflow/hot has the agent-native column shape', () => {
const cmd = getRegistry().get('stackoverflow/hot');
expect(cmd?.columns).toEqual([
'rank', 'id', 'title', 'score', 'answers', 'views',
'is_answered', 'tags', 'author', 'creation_date', 'url',
]);
const mapStep = cmd?.pipeline?.find((step) => step.map);
expect(mapStep?.map).toMatchObject({
id: '${{ item.question_id }}',
views: '${{ item.view_count }}',
is_answered: '${{ item.is_answered }}',
author: '${{ item.owner.display_name }}',
creation_date: '${{ item.creation_date }}',
});
});
it('stackoverflow/search has the agent-native column shape', () => {
const cmd = getRegistry().get('stackoverflow/search');
expect(cmd?.columns).toEqual([
'rank', 'id', 'title', 'score', 'answers', 'views',
'is_answered', 'tags', 'author', 'creation_date', 'url',
]);
const mapStep = cmd?.pipeline?.find((step) => step.map);
expect(mapStep?.map).toMatchObject({
id: '${{ item.question_id }}',
views: '${{ item.view_count }}',
});
});
it('stackoverflow/unanswered drops is_answered (always false) but keeps the rest', () => {
const cmd = getRegistry().get('stackoverflow/unanswered');
expect(cmd?.columns).toEqual([
'rank', 'id', 'title', 'score', 'answers', 'views',
'tags', 'author', 'creation_date', 'url',
]);
expect(cmd?.columns).not.toContain('is_answered');
});
it('stackoverflow/bounties keeps the bounty column at the front', () => {
const cmd = getRegistry().get('stackoverflow/bounties');
expect(cmd?.columns).toEqual([
'rank', 'id', 'bounty', 'title', 'score', 'answers', 'views',
'is_answered', 'tags', 'author', 'creation_date', 'url',
]);
const mapStep = cmd?.pipeline?.find((step) => step.map);
expect(mapStep?.map).toMatchObject({
id: '${{ item.question_id }}',
bounty: '${{ item.bounty_amount }}',
});
});
});
describe('stackoverflow/read adapter', () => {
const cmd = getRegistry().get('stackoverflow/read');
it('registers the question/answer/comment row shape', () => {
expect(cmd?.columns).toEqual(['type', 'author', 'score', 'accepted', 'text']);
});
it('takes a positional id plus tunable answers-limit/comments-limit/max-length', () => {
const argNames = (cmd?.args || []).map((a) => a.name);
expect(argNames).toEqual(['id', 'answers-limit', 'comments-limit', 'max-length']);
const idArg = cmd?.args?.find((a) => a.name === 'id');
expect(idArg?.required).toBe(true);
expect(idArg?.positional).toBe(true);
});
it('uses the public Stack Exchange API (no browser, public strategy)', () => {
expect(cmd?.browser).toBe(false);
expect(cmd?.strategy).toBe('public');
});
it('fails fast with ArgumentError for non-numeric id before fetching', async () => {
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
await expect(cmd.func({ id: 'not-a-number', 'answers-limit': 10, 'comments-limit': 5, 'max-length': 4000 }))
.rejects.toThrow(ArgumentError);
expect(fetchMock).not.toHaveBeenCalled();
});
it('fails fast with ArgumentError for max-length below 100 before fetching', async () => {
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
await expect(cmd.func({ id: '12345', 'answers-limit': 10, 'comments-limit': 5, 'max-length': 50 }))
.rejects.toThrow(ArgumentError);
expect(fetchMock).not.toHaveBeenCalled();
});
it('fails fast with EmptyResultError when the question lookup returns empty items', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
new Response(JSON.stringify({ items: [] }), { status: 200 }),
));
await expect(cmd.func({ id: '99999999', 'answers-limit': 10, 'comments-limit': 5, 'max-length': 4000 }))
.rejects.toThrow(EmptyResultError);
});
it('surfaces Stack Exchange API throttle / quota errors as CommandExecutionError', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
new Response(JSON.stringify({ error_id: 502, error_name: 'throttle_violation', error_message: 'too fast' }), { status: 200 }),
));
await expect(cmd.func({ id: '12345', 'answers-limit': 10, 'comments-limit': 5, 'max-length': 4000 }))
.rejects.toThrow(CommandExecutionError);
});
it('wraps fetch network failures in CommandExecutionError (not raw TypeError)', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('fetch failed')));
await expect(cmd.func({ id: '12345', 'answers-limit': 10, 'comments-limit': 5, 'max-length': 4000 }))
.rejects.toThrow(CommandExecutionError);
});
it('wraps malformed JSON responses in CommandExecutionError (not raw SyntaxError)', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
new Response('<!DOCTYPE html><html>maintenance</html>', { status: 200 }),
));
await expect(cmd.func({ id: '12345', 'answers-limit': 10, 'comments-limit': 5, 'max-length': 4000 }))
.rejects.toThrow(CommandExecutionError);
});
it('coerces and validates string-form numeric args (e.g. "50" not Number(50))', async () => {
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
// String "50" should still be rejected because it's < 100
await expect(cmd.func({ id: '12345', 'answers-limit': 10, 'comments-limit': 5, 'max-length': '50' }))
.rejects.toThrow(ArgumentError);
expect(fetchMock).not.toHaveBeenCalled();
// String "abc" should also be rejected
await expect(cmd.func({ id: '12345', 'answers-limit': 10, 'comments-limit': 5, 'max-length': 'abc' }))
.rejects.toThrow(ArgumentError);
expect(fetchMock).not.toHaveBeenCalled();
});
it('rejects answer/comment limits above Stack Exchange pagesize max before fetching', async () => {
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
await expect(cmd.func({ id: '12345', 'answers-limit': '101', 'comments-limit': 5, 'max-length': 4000 }))
.rejects.toThrow(ArgumentError);
await expect(cmd.func({ id: '12345', 'answers-limit': 10, 'comments-limit': '101', 'max-length': 4000 }))
.rejects.toThrow(ArgumentError);
expect(fetchMock).not.toHaveBeenCalled();
});
it('builds POST + Q-COMMENT + ANSWER + A-COMMENT rows, accepted answer first', async () => {
const question = {
items: [{
question_id: 1,
title: 'Why?',
body: '<p>Question body</p>',
score: 10,
link: 'https://example.com/q/1',
owner: { display_name: 'asker' },
}],
};
const qComments = {
items: [
{ score: 2, owner: { display_name: 'qc1' }, body: '<p>q comment one</p>' },
{ score: 1, owner: { display_name: 'qc2' }, body: '<p>q comment two</p>' },
],
};
const answers = {
items: [
{ answer_id: 100, score: 5, is_accepted: false, owner: { display_name: 'low' }, body: '<p>low score answer</p>' },
{ answer_id: 200, score: 50, is_accepted: true, owner: { display_name: 'winner' }, body: '<p>accepted answer</p>' },
],
};
const answerComments = {
items: [
{ post_id: 200, score: 1, owner: { display_name: 'ac1' }, body: '<p>comment on accepted</p>' },
{ post_id: 100, score: 0, owner: { display_name: 'ac2' }, body: '<p>comment on low</p>' },
],
};
const fetchMock = vi.fn()
.mockResolvedValueOnce(new Response(JSON.stringify(question), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify(qComments), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify(answers), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify(answerComments), { status: 200 }));
vi.stubGlobal('fetch', fetchMock);
const rows = await cmd.func({ id: '1', 'answers-limit': 10, 'comments-limit': 5, 'max-length': 4000 });
expect(rows.map((r) => [r.type, r.author, r.accepted])).toEqual([
['POST', 'asker', ''],
['Q-COMMENT', 'qc1', ''],
['Q-COMMENT', 'qc2', ''],
['ANSWER', 'winner', 'true'], // accepted comes FIRST
['A-COMMENT', 'ac1', ''],
['ANSWER', 'low', ''],
['A-COMMENT', 'ac2', ''],
]);
// Verify the answer-comments fetch batched both answer ids
const ansCommentsCall = fetchMock.mock.calls[3][0];
expect(ansCommentsCall).toContain('/answers/200;100/comments');
expect(fetchMock.mock.calls[1][0]).toContain('pagesize=5');
expect(fetchMock.mock.calls[2][0]).toContain('pagesize=10');
expect(fetchMock.mock.calls[3][0]).toContain('pagesize=10');
});
it('fetches accepted answer separately when it is missing from the votes page', async () => {
const question = {
items: [{
question_id: 1,
accepted_answer_id: 999,
title: 'Why?',
body: '<p>Question body</p>',
score: 10,
link: 'https://example.com/q/1',
owner: { display_name: 'asker' },
}],
};
const answers = {
items: [
{ answer_id: 100, score: 50, is_accepted: false, owner: { display_name: 'top-voted' }, body: '<p>top voted answer</p>' },
],
};
const acceptedAnswer = {
items: [
{ answer_id: 999, score: 1, is_accepted: true, owner: { display_name: 'accepted' }, body: '<p>accepted answer</p>' },
],
};
const fetchMock = vi.fn()
.mockResolvedValueOnce(new Response(JSON.stringify(question), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify({ items: [] }), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify(answers), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify(acceptedAnswer), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify({ items: [] }), { status: 200 }));
vi.stubGlobal('fetch', fetchMock);
const rows = await cmd.func({ id: '1', 'answers-limit': 2, 'comments-limit': 5, 'max-length': 4000 });
expect(rows.filter((r) => r.type === 'ANSWER').map((r) => [r.author, r.accepted])).toEqual([
['accepted', 'true'],
['top-voted', ''],
]);
expect(fetchMock.mock.calls[3][0]).toContain('/answers/999?');
expect(fetchMock.mock.calls[4][0]).toContain('/answers/999;100/comments');
expect(fetchMock.mock.calls[4][0]).toContain('pagesize=10');
});
it('fails fast when batched answer comments would be partial', async () => {
const question = {
items: [{
question_id: 1,
title: 'Why?',
body: '<p>Question body</p>',
score: 10,
link: 'https://example.com/q/1',
owner: { display_name: 'asker' },
}],
};
const answers = {
items: [
{ answer_id: 100, score: 50, is_accepted: false, owner: { display_name: 'a1' }, body: '<p>one</p>' },
{ answer_id: 200, score: 40, is_accepted: false, owner: { display_name: 'a2' }, body: '<p>two</p>' },
],
};
const answerComments = {
has_more: true,
items: [
{ post_id: 100, score: 1, owner: { display_name: 'c1' }, body: '<p>comment on first</p>' },
],
};
const fetchMock = vi.fn()
.mockResolvedValueOnce(new Response(JSON.stringify(question), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify({ items: [] }), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify(answers), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify(answerComments), { status: 200 }));
vi.stubGlobal('fetch', fetchMock);
await expect(cmd.func({ id: '1', 'answers-limit': 2, 'comments-limit': 1, 'max-length': 4000 }))
.rejects.toThrow(CommandExecutionError);
});
it('decodes HTML entities in body and display_name (named, decimal, hex)', async () => {
const question = {
items: [{
question_id: 1,
title: 't',
body: '<p>price &lt; &amp; &hellip; &#246; &#x27;ok&#x27;</p>',
score: 0,
link: '',
owner: { display_name: 'Jonas K&#246;lker' },
}],
};
const fetchMock = vi.fn()
.mockResolvedValueOnce(new Response(JSON.stringify(question), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify({ items: [] }), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify({ items: [] }), { status: 200 }));
vi.stubGlobal('fetch', fetchMock);
const rows = await cmd.func({ id: '1', 'answers-limit': 10, 'comments-limit': 5, 'max-length': 4000 });
expect(rows[0].author).toBe('Jonas Kölker');
expect(rows[0].text).toContain('price < & … ö \'ok\'');
});
it('respects answers-limit when there are more answers than the cap', async () => {
const question = {
items: [{
question_id: 1, title: 't', body: '', score: 0, link: '',
owner: { display_name: 'a' },
}],
};
const answers = {
items: [
{ answer_id: 100, score: 5, is_accepted: false, owner: { display_name: 'a1' }, body: '' },
{ answer_id: 200, score: 4, is_accepted: false, owner: { display_name: 'a2' }, body: '' },
{ answer_id: 300, score: 3, is_accepted: false, owner: { display_name: 'a3' }, body: '' },
],
};
const fetchMock = vi.fn()
.mockResolvedValueOnce(new Response(JSON.stringify(question), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify({ items: [] }), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify(answers), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify({ items: [] }), { status: 200 }));
vi.stubGlobal('fetch', fetchMock);
const rows = await cmd.func({ id: '1', 'answers-limit': 2, 'comments-limit': 5, 'max-length': 4000 });
const answerRows = rows.filter((r) => r.type === 'ANSWER');
expect(answerRows.map((r) => r.author)).toEqual(['a1', 'a2']);
});
});