Files
usenet-indexer/tests/unit/xover_client.spec.ts
T
Daan MeijerandClaude Opus 5.5 04f945055f
Test and deploy / test (push) Successful in 1m12s
Test and deploy / deploy (push) Successful in 1m37s
XoverClient times replies only, not idle time between commands
A seeding node that waited over two minutes for the seed API (restarting)
had its idle NNTP connection closed by its own timeout, and gave its range
back. Now the timeout runs only while a reply is awaited.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 13:40:15 +02:00

90 lines
4.0 KiB
TypeScript

import { test } from '@japa/runner'
import net from 'node:net'
import { deflateSync } from 'node:zlib'
import { XoverClient, parseOverviews } from '#lib/nntp/xover_client'
const LINES = [
'101\tShow.S01E01 - "a.mkv" yEnc (1/2)\tposter <p@x>\tMon, 05 Oct 2026 10:00:00 +0000\t<one@test>\t\t700000\t5000\tXref: news.example a.b.test:101',
'102\tShow.S01E01 - "a.mkv" yEnc (2/2)\tposter <p@x>\tMon, 05 Oct 2026 10:00:01 +0000\t<two@test>\t\t650000\t4600\tXref: news.example a.b.test:102 a.b.other:7',
]
/** A tiny NNTP server: plain replies, gzip after XFEATURE, 420 for 1-1, replies split into small chunks. */
function server(): Promise<{ port: number; close: () => void }> {
return new Promise((resolve) => {
const srv = net.createServer((sock) => {
let gzip = false
const send = (data: Buffer) => {
for (let i = 0; i < data.length; i += 7) sock.write(data.subarray(i, i + 7))
}
sock.write('200 welcome\r\n')
let pending = ''
sock.on('data', (chunk) => {
pending += chunk.toString()
let at: number
while ((at = pending.indexOf('\r\n')) !== -1) {
const line = pending.slice(0, at)
pending = pending.slice(at + 2)
if (line.startsWith('AUTHINFO USER')) sock.write('381 more\r\n')
else if (line.startsWith('AUTHINFO PASS')) sock.write('281 ok\r\n')
else if (line === 'XFEATURE COMPRESS GZIP') (gzip = true), sock.write('290 Feature Enabled\r\n')
else if (line.startsWith('GROUP')) sock.write('211 2 101 102 a.b.test\r\n')
else if (line === 'XOVER 1-1') sock.write('420 No Articles Selected\r\n')
else if (line.startsWith('XOVER')) {
const body = Buffer.from(LINES.join('\r\n') + '\r\n')
if (gzip) {
sock.write('224 Overview Information Follows [COMPRESS=GZIP]\r\n')
send(Buffer.concat([deflateSync(body), Buffer.from('.\r\n')]))
} else {
sock.write('224 Overview Information Follows\r\n')
send(Buffer.concat([body, Buffer.from('.\r\n')]))
}
} else if (line === 'QUIT') sock.end('205 bye\r\n')
}
})
})
srv.listen(0, '127.0.0.1', () => resolve({ port: (srv.address() as net.AddressInfo).port, close: () => srv.close() }))
})
}
test.group('nntp / XoverClient', () => {
test('parses overview lines into the fields nntp-js gives', ({ assert }) => {
const [[number, fields]] = parseOverviews(LINES[0] + '\r\n')
assert.equal(number, 101)
assert.equal(fields['message-id'], '<one@test>')
assert.equal(fields[':bytes'], '700000')
assert.equal(fields.xref, 'news.example a.b.test:101')
})
for (const compress of [false, true]) {
test(`fetches overviews ${compress ? 'gzip-compressed' : 'plain'}, and an empty range`, async ({ assert }) => {
const { port, close } = await server()
const client = await XoverClient.connect({ host: '127.0.0.1', port, user: 'u', password: 'p', compress })
try {
assert.equal(client.compressed, compress)
assert.deepEqual(await client.group('a.b.test'), { count: 2, low: 101, high: 102 })
const overviews = await client.xover(101, 102)
assert.deepEqual(overviews.map(([n]) => n), [101, 102])
assert.equal(overviews[1][1].subject, 'Show.S01E01 - "a.mkv" yEnc (2/2)')
assert.deepEqual(await client.xover(1, 1), [])
assert.lengthOf(await client.xover(101, 102), 2)
} finally {
client.close()
close()
}
})
}
test('idle time between commands does not count against the reply timeout', async ({ assert }) => {
const { port, close } = await server()
const client = await XoverClient.connect({ host: '127.0.0.1', port, user: 'u', password: 'p', compress: true, timeoutMs: 200 })
try {
await client.group('a.b.test')
await new Promise((resolve) => setTimeout(resolve, 500))
assert.lengthOf(await client.xover(101, 102), 2)
} finally {
client.close()
close()
}
})
})