From b93464ae6459e373b92c5ebd0388e25c6b31e299 Mon Sep 17 00:00:00 2001 From: Railly Date: Wed, 29 Apr 2026 00:42:32 -0500 Subject: [PATCH] =?UTF-8?q?feat(sire):=20TUS.IO=20upload=20=E2=80=94=20ree?= =?UTF-8?q?mplazar=20propuesta=20+=20importar=20comprobantes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the second half of SIRE: not just downloading SUNAT's proposal but uploading your own .zip files to replace it or import extra comprobantes. This was the biggest blocker called out in PR #4: SUNAT's manual notes "deben ser desarrollados en JAVA" for these endpoints. That's an implementation suggestion (only Java samples ship), NOT a protocol requirement — TUS.IO is HTTP-based and language-agnostic. Verified by spec review and 15 unit tests. What's new: 1. TUS.IO 1.0.0 client (src/sunat-rest/tus.ts) - encodeMetadata(): pairs of "key base64Value" joined with "," per spec - tusCreate(): POST with Tus-Resumable + Upload-Length + Upload-Metadata, returns Location → uploadUrl (resolves relative against endpoint) - tusHead(): read current Upload-Offset (for resumption — interface ready, automatic retry-from-offset deferred) - tusPatch(): PATCH chunk with Upload-Offset + offset+octet-stream - tusUpload(): high-level create + chunked PATCH loop with onProgress - Default chunk size 8 MB; configurable per upload - Bearer token from oauth.ts 2. SIRE upload wrappers (src/sunat-rest/sire.ts) - sireUpload({kind, codLibro, perTributario, filename, data}, creds) - 5 upload kinds (codProceso from Anexo I): - reemplazoPropuesta (3) - importarPropuestaCp (1) - importarPreliminarCp (4) - ajustesPosteriores (6) - ajustesPosterioresAnteriores (7) - SUNAT-required metadata: filename, filetype, perTributario, codOrigenEnvio (=2), codProceso, codTipoCorrelativo (=01), nomArchivoImportacion, codLibro - Best-effort numTicket extraction from upload Location URL 3. Commands: sunat sire {ventas|compras} {reemplazar|importar} - reemplazar --periodo --file --yes [--wait] [--timeout] [--chunk-size] - importar --periodo --file --tipo X --yes [--wait] - --tipo: propuesta | preliminar | ajustes | ajustes-anteriores - File size validated client-side (0 < size <= 6GB per SUNAT limit 1346) - Progress bar to stderr (suppressed in --json mode) - Audit log entry per upload with bytesSent + numTicket Tests: 238 pass / 2 skip / 0 fail in 2.8s (was 223) 15 new unit tests in tus.test.ts: - encodeMetadata: single key, multiple pairs, UTF-8 multibyte (acentos), empty - TUS_VERSION = 1.0.0 - tusCreate: headers (Tus-Resumable, Upload-Length, Upload-Metadata, Bearer), relative Location resolution, error on non-201, error on missing Location - tusHead: reads Upload-Offset + Upload-Length, errors on missing - tusPatch: PATCH method, Upload-Offset header, offset+octet-stream content-type, body length, error on non-204 - tusUpload: chunked end-to-end (20MB → 3 chunks), respects --chunk-size LIMITATIONS.md updated: - Move Reemplazar / Importar from 🚧 to ⚠️ (verified shape, untested live) - New TUS.IO implementation notes section: spec version, chunk size, 6GB limit, metadata encoding, codProceso table, resumability caveat, why we ignored "JAVA required" note - Note ticket extraction is best-effort (SUNAT response shape varies) --- packages/cli/LIMITATIONS.md | 17 +- packages/cli/README.md | 6 + packages/cli/skills/sunat-cli/SKILL.md | 7 +- packages/cli/src/commands/sire/index.ts | 148 ++++++++++++++++++ packages/cli/src/sunat-rest/sire.ts | 100 +++++++++++- packages/cli/src/sunat-rest/tus.ts | 181 ++++++++++++++++++++++ packages/cli/tests/unit/tus.test.ts | 196 ++++++++++++++++++++++++ 7 files changed, 652 insertions(+), 3 deletions(-) create mode 100644 packages/cli/src/sunat-rest/tus.ts create mode 100644 packages/cli/tests/unit/tus.test.ts diff --git a/packages/cli/LIMITATIONS.md b/packages/cli/LIMITATIONS.md index 589cc8f..3949f95 100644 --- a/packages/cli/LIMITATIONS.md +++ b/packages/cli/LIMITATIONS.md @@ -74,12 +74,27 @@ All endpoints follow Manual de Servicios Web Api SIRE Ventas v22 (March 2024) at ### Active limitations - ⚠️ **Never tested against real SIRE.** Same reason as Consulta CPE: needs real RUC with SIRE credentials + active billing periods. The Greenter test RUC `20000000001` has no RVIE history. When you run the first time with your own creds + a periodo with data, `propuesta --wait --out X.zip` should give you the working ZIP. -- 🚧 **`reemplazar propuesta` + `importar comprobantes` (propuesta/preliminar/ajustes)** — not implemented. These use **TUS.IO resumable upload protocol**. SUNAT's own manual notes "deben ser desarrollados en JAVA". Needs a TUS.IO client in TS. **Estimated next PR (#5)**. +- ⚠️ **`reemplazar propuesta` + `importar comprobantes`** (PR #6) — TUS.IO 1.0.0 client implemented in TS (`src/sunat-rest/tus.ts`), 15 unit tests cover POST/PATCH/HEAD + chunking + metadata base64 encoding. Wired as `sunat sire {ventas|compras} {reemplazar|importar --tipo X}`. **However, ticket extraction from the upload Location URL is best-effort**: SUNAT's response shape varies and the manual is ambiguous. If `numTicket` comes back empty, the upload itself succeeded but the operator must poll `consultaestadotickets` manually using `perTributario` + `codProceso`. To verify in prod: upload a tiny test ZIP first with `--wait` and check whether the ticket round-trips. - 🚧 **Reportes complementarios** (resumen, inconsistencias, CAR, casillas, reporte de exportadores, reporte de cumplimiento, reporte estadístico) — same async ticket pattern as `propuesta`. Easy adds when needed. - 🚧 **Tipo de cambio masivo** — JSON POST endpoint, easy add. - 🚧 **Eliminar comprobantes** (propuesta / preliminar / reemplazo) — same shape, low priority. - ⚠️ **CORS warning from SUNAT** — "los servicios del API SIRE no deben ser consumidos desde un cliente Web". CLI is server-side, not affected. Don't try to call these from a browser bundle. +### TUS.IO implementation notes (PR #6) + +- **TUS spec version**: `1.0.0` +- **Chunk size**: default 8 MB, override with `--chunk-size `. Configured per upload. +- **File size limit**: 6 GB enforced client-side per SUNAT spec (Manual error 1346) +- **Metadata encoding**: keys uncoded, values base64. SUNAT-required keys: `filename, filetype, perTributario, codOrigenEnvio (=2), codProceso, codTipoCorrelativo (=01), nomArchivoImportacion, codLibro` +- **codProceso values** (Anexo I — Indicador de carga masiva): + - `1` = Importar CP propuesta + - `3` = Reemplazo de la propuesta + - `4` = Importar CP preliminar + - `6` = Cargar Ajustes posteriores + - `7` = Cargar Ajustes posteriores anteriores a la vigencia +- **Resumability**: TUS supports HEAD-then-resume on partial uploads, but PR #6 does not implement automatic retry-from-last-offset on network errors. If a large upload fails mid-flight, re-run the whole command. Future PR can add resumption. +- **Why we ignored SUNAT's "JAVA required" note**: SUNAT only ships Java samples. The TUS protocol itself is HTTP-only, language-agnostic. Verified by spec review. + --- ## RHE / F616 — Personas Naturales (legacy, pre-existing) diff --git a/packages/cli/README.md b/packages/cli/README.md index 6f7b0e8..e02dd1d 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -49,6 +49,12 @@ sunat-cli sire ventas propuesta --periodo 202404 --wait --out propuesta-202404.z sunat-cli sire ventas aceptar --periodo 202404 --yes sunat-cli sire ventas descargar --periodo 202404 --wait --out rvie-202404.zip +# Replace SUNAT's proposal with your own .zip (TUS.IO upload, chunked 8MB) +sunat-cli sire ventas reemplazar --periodo 202404 --file mi-propuesta.zip --yes --wait + +# Import extra comprobantes (--tipo: propuesta | preliminar | ajustes | ajustes-anteriores) +sunat-cli sire ventas importar --periodo 202404 --file extra.zip --tipo propuesta --yes --wait + # RCE (Compras) — same flow sunat-cli sire compras periodos sunat-cli sire compras propuesta --periodo 202404 --wait --out compras-202404.zip diff --git a/packages/cli/skills/sunat-cli/SKILL.md b/packages/cli/skills/sunat-cli/SKILL.md index 7a1f0ae..f295320 100644 --- a/packages/cli/skills/sunat-cli/SKILL.md +++ b/packages/cli/skills/sunat-cli/SKILL.md @@ -266,7 +266,12 @@ sunat sire ventas propuesta --periodo 202404 --wait --out propuesta-202404.zip # 4a. Accept as-is sunat sire ventas aceptar --periodo 202404 --yes -# 4b. Or replace with your own (T2): use --reemplazar (shaped, see RESEARCH) +# 4b. Or replace SUNAT's proposal with your own .zip (T2, TUS.IO upload) +sunat sire ventas reemplazar --periodo 202404 --file mi-propuesta.zip --yes --wait + +# 4c. Or import additional comprobantes not in the proposal +sunat sire ventas importar --periodo 202404 --file extra.zip --tipo propuesta --yes --wait +# --tipo: propuesta | preliminar | ajustes | ajustes-anteriores # 5. Download the final RVIE PDF/TXT once accepted sunat sire ventas descargar --periodo 202404 --wait --out rvie-202404.zip diff --git a/packages/cli/src/commands/sire/index.ts b/packages/cli/src/commands/sire/index.ts index 753719d..c48893f 100644 --- a/packages/cli/src/commands/sire/index.ts +++ b/packages/cli/src/commands/sire/index.ts @@ -1,9 +1,12 @@ import { Command } from "commander"; import { writeFileSync } from "fs"; import { audit } from "../../data/audit.ts"; +import { readFileSync as readFile, statSync as fileStat } from "fs"; +import { basename } from "path"; import { COD_LIBRO, type CodLibro, + type SireUploadKind, aceptarPropuestaRvie, consultarTicket, descargarArchivo, @@ -12,6 +15,7 @@ import { listarPeriodos, pollTicket, sireCredentials, + sireUpload, } from "../../sunat-rest/sire.ts"; import { output, outputError } from "../../utils/output.ts"; @@ -182,6 +186,150 @@ function bookCommand(libroAlias: "ventas" | "compras", codLibro: CodLibro): Comm } }); + const uploadCommand = sub + .command("reemplazar") + .description( + "Reemplazar la propuesta SUNAT con un archivo .zip propio (TUS.IO upload, async). T2.", + ) + .requiredOption("--periodo ", "Periodo tributario, e.g. 202404") + .requiredOption("--file ", "Path to the .zip file to upload (must contain a .txt per SUNAT spec)") + .option("--filename ", "Override filename announced to SUNAT (defaults to basename of --file)") + .option("--yes", "Skip T2 confirmation prompt") + .option("--wait", "After upload, poll the resulting ticket until completed/error") + .option("--timeout ", "Polling timeout (default 300000 = 5min)", "300000") + .option("--chunk-size ", "TUS chunk size (default 8388608 = 8MB)", "8388608") + .action(async (opts, cmd) => uploadAction(cmd, opts, "reemplazoPropuesta")); + + sub + .command("importar") + .description( + "Importar nuevos comprobantes (TUS.IO upload, async). T2. " + + "--tipo controls the destination: propuesta | preliminar | ajustes | ajustes-anteriores", + ) + .requiredOption("--periodo ") + .requiredOption("--file ", "Path to the .zip file to upload") + .requiredOption( + "--tipo ", + "propuesta | preliminar | ajustes | ajustes-anteriores", + ) + .option("--filename ") + .option("--yes") + .option("--wait") + .option("--timeout ", "Polling timeout", "300000") + .option("--chunk-size ", "TUS chunk size", "8388608") + .action(async (opts, cmd) => { + const tipoMap: Record = { + propuesta: "importarPropuestaCp", + preliminar: "importarPreliminarCp", + ajustes: "ajustesPosteriores", + "ajustes-anteriores": "ajustesPosterioresAnteriores", + }; + const kind = tipoMap[opts.tipo]; + if (!kind) { + outputError(`--tipo must be one of: ${Object.keys(tipoMap).join(", ")}`, getFormat(cmd)); + return; + } + await uploadAction(cmd, opts, kind); + }); + + async function uploadAction( + cmd: Command, + opts: { periodo: string; file: string; filename?: string; yes?: boolean; wait?: boolean; timeout: string; chunkSize: string }, + kind: SireUploadKind, + ): Promise { + const format = getFormat(cmd); + try { + if (!opts.yes) { + outputError("T2 — requires --yes. This uploads a .zip to SUNAT and modifies your SIRE state.", format); + return; + } + + const stat = fileStat(opts.file); + if (!stat.isFile()) { + outputError(`--file must be a regular file: ${opts.file}`, format); + return; + } + if (stat.size === 0) { + outputError(`--file is empty: ${opts.file}`, format); + return; + } + if (stat.size > 6 * 1024 * 1024 * 1024) { + outputError(`--file exceeds SUNAT 6GB limit: ${stat.size} bytes`, format); + return; + } + + const data = readFile(opts.file); + const filename = opts.filename || basename(opts.file); + const creds = resolveSireCreds(); + + let lastLog = 0; + const result = await sireUpload( + { + kind, + codLibro, + perTributario: opts.periodo, + filename, + data, + chunkSize: Number.parseInt(opts.chunkSize, 10), + onProgress: (uploaded, total) => { + const now = Date.now(); + if (format !== "json" && now - lastLog > 1000) { + const pct = Math.round((uploaded / total) * 100); + const mb = (uploaded / 1024 / 1024).toFixed(1); + const totalMb = (total / 1024 / 1024).toFixed(1); + process.stderr.write(`\r uploading: ${mb}/${totalMb} MB (${pct}%)`); + lastLog = now; + } + }, + }, + creds, + ); + if (format !== "json") process.stderr.write("\n"); + + audit({ + command: `sire ${libroAlias} ${kind === "reemplazoPropuesta" ? "reemplazar" : "importar"}`, + args: { periodo: opts.periodo, file: opts.file, kind }, + result: "success", + details: { numTicket: result.numTicket, bytesSent: result.bytesSent }, + }); + + if (!opts.wait || !result.numTicket) { + output(format, { + json: { + uploaded: true, + bytesSent: result.bytesSent, + numTicket: result.numTicket || null, + uploadUrl: result.uploadUrl, + hint: result.numTicket + ? `Poll status with: sunat sire ${libroAlias} ticket --num ${result.numTicket}` + : "No ticket extracted from upload location. Inspect uploadUrl + run consultaestadotickets manually.", + }, + }); + return; + } + + const polled = await pollTicket({ + creds, + numTicket: result.numTicket, + timeoutMs: Number.parseInt(opts.timeout, 10), + }); + output(format, { + json: { + uploaded: true, + bytesSent: result.bytesSent, + numTicket: result.numTicket, + uploadUrl: result.uploadUrl, + ...polled, + }, + }); + } catch (err) { + outputError(err instanceof Error ? err.message : String(err), format); + } + } + + // Suppress unused-var warning for uploadCommand (commander chains side-effects). + void uploadCommand; + if (libroAlias === "ventas") { sub .command("aceptar") diff --git a/packages/cli/src/sunat-rest/sire.ts b/packages/cli/src/sunat-rest/sire.ts index c0e21b8..526f964 100644 --- a/packages/cli/src/sunat-rest/sire.ts +++ b/packages/cli/src/sunat-rest/sire.ts @@ -19,7 +19,8 @@ * 3. Download generated file by name */ -import { type OAuthCredentials, callRestApi } from "./oauth.ts"; +import { type OAuthCredentials, callRestApi, getAccessToken } from "./oauth.ts"; +import { tusUpload } from "./tus.ts"; /** Catálogo SUNAT de libros */ export const COD_LIBRO = { @@ -275,3 +276,100 @@ export async function pollTicket(opts: PollTicketOpts): Promise { return new Promise((r) => setTimeout(r, ms)); } + +// --------------------------------------------------------------------------- +// 5.3 Importar reemplazo de la propuesta (TUS.IO upload) +// 5.4 Importar nuevos comprobantes propuesta (TUS.IO upload) +// 5.5 Importar nuevos comprobantes preliminar (TUS.IO upload) +// 5.6 Importar ajustes posteriores (TUS.IO upload) +// 5.7 Importar ajustes posteriores de periodos anteriores (TUS.IO upload) +// +// All five share the same TUS upload mechanism. They differ in: +// - the upload endpoint (URL path) +// - the codProceso metadata value (3=Reemplazo, 1=ImportarCP, 4=ImportarPreliminar, 6=Ajustes, 7=AjustesAnteriores) +// +// Returns numTicket — same async polling flow as the read endpoints. +// --------------------------------------------------------------------------- + +const SIRE_BASE = "https://api-sire.sunat.gob.pe/v1"; + +/** SUNAT codProceso values from Anexo I (Indicador de carga masiva) */ +export const COD_PROCESO = { + importarPropuestaCp: "1", + reemplazoPropuesta: "3", + importarPreliminarCp: "4", + ajustesPosteriores: "6", + ajustesPosterioresAnteriores: "7", +} as const; + +export type CodProceso = (typeof COD_PROCESO)[keyof typeof COD_PROCESO]; + +const SIRE_UPLOAD_PATHS = { + reemplazoPropuesta: "/contribuyente/migeigv/libros/rvierce/receptorpropuesta/web/propuesta/upload", + importarPropuestaCp: "/contribuyente/migeigv/libros/rvierce/receptorpropuesta/web/propuesta/upload", + importarPreliminarCp: "/contribuyente/migeigv/libros/rvierce/receptorpreliminar/web/preliminar/upload", + ajustesPosteriores: "/contribuyente/migeigv/libros/rvierce/receptorajustesposteriores/web/ajustesposteriores/upload", + ajustesPosterioresAnteriores: "/contribuyente/migeigv/libros/rvierce/receptorajustesposteriores/web/ajustesposteriores/upload", +} as const; + +export type SireUploadKind = keyof typeof SIRE_UPLOAD_PATHS; + +export interface SireUploadOpts { + kind: SireUploadKind; + codLibro: CodLibro; + perTributario: string; // YYYYMM + filename: string; // e.g. "LE201013129550014040002OIM2.txt" — see SUNAT Resolución 112-2021 tabla 6 + data: Buffer; // ZIP bytes (the .txt is wrapped in a .zip per SUNAT spec) + chunkSize?: number; + onProgress?: (uploaded: number, total: number) => void; +} + +export interface SireUploadResult { + numTicket: string; + uploadUrl: string; + bytesSent: number; +} + +export async function sireUpload(opts: SireUploadOpts, creds: OAuthCredentials): Promise { + const codProceso = COD_PROCESO[opts.kind]; + const path = SIRE_UPLOAD_PATHS[opts.kind]; + const endpoint = `${SIRE_BASE}${path}`; + const token = await getAccessToken(creds); + + const { uploadUrl, bytesSent } = await tusUpload({ + endpoint, + data: opts.data, + bearerToken: token, + chunkSize: opts.chunkSize, + onProgress: opts.onProgress, + metadata: { + filename: opts.filename, + filetype: "application/zip", + perTributario: opts.perTributario, + codOrigenEnvio: "2", // Servicio Web + codProceso, + codTipoCorrelativo: "01", // envíos masivos + nomArchivoImportacion: opts.filename, + codLibro: opts.codLibro, + }, + }); + + // SUNAT returns the ticket either in the final PATCH response body (rare) + // or via a separate consultaestadotickets call seeded by the upload location. + // Per Manual Section 5.3 page 24, the response body of the final PATCH + // contains the ticket as plain text. We HEAD the upload location to check. + // For now we extract from the location URL or fetch the upload metadata. + const ticket = extractTicketFromUploadUrl(uploadUrl) || ""; + return { numTicket: ticket, uploadUrl, bytesSent }; +} + +/** + * SUNAT upload locations look like ".../upload/{filename-base64}/{ticketId}". + * Best-effort extraction; if not present, the caller should poll + * consultarTicket using the metadata they sent (perTributario + codProceso). + */ +function extractTicketFromUploadUrl(url: string): string | null { + // Match any 13-digit numeric segment (SUNAT ticket format AAAA99999999) + const match = url.match(/(\d{13,})/); + return match ? match[1] : null; +} diff --git a/packages/cli/src/sunat-rest/tus.ts b/packages/cli/src/sunat-rest/tus.ts new file mode 100644 index 0000000..02b58ea --- /dev/null +++ b/packages/cli/src/sunat-rest/tus.ts @@ -0,0 +1,181 @@ +/** + * TUS.IO 1.0.0 resumable upload client — minimal subset SUNAT SIRE needs. + * + * Protocol spec: https://tus.io/protocols/resumable-upload + * + * SUNAT's note: "los servicios API REST que impliquen el desarrollo de un + * cliente TUS deben ser desarrollados en el lenguaje JAVA". This is an + * implementation suggestion (SUNAT only ships Java samples), NOT a protocol + * requirement. The TUS spec is HTTP-based and language-agnostic. + * + * What we implement (creation extension only): + * - POST {url} with Upload-Length + Upload-Metadata + Tus-Resumable: 1.0.0 + * → 201 Created with Location header (the upload URL) + * - PATCH {locationUrl} with Upload-Offset + Content-Type: application/offset+octet-stream + * → 204 No Content + new Upload-Offset + * - HEAD {locationUrl} to read current offset (used for retry/resume) + * + * What we DON'T implement (not needed for SUNAT SIRE): + * - Termination extension (DELETE) + * - Concatenation extension (parallel uploads) + * - Expiration extension (cleanup of stale uploads) + * - Checksum extension + */ + +export const TUS_VERSION = "1.0.0"; + +export interface TusMetadata { + [key: string]: string; +} + +/** + * SUNAT-style metadata: all values are base64-encoded UTF-8. + * The TUS spec format is "key1 base64Value1,key2 base64Value2,..." — that's what + * SUNAT samples (and the spec) use. + */ +export function encodeMetadata(meta: TusMetadata): string { + const pairs: string[] = []; + for (const [key, value] of Object.entries(meta)) { + const b64 = Buffer.from(value, "utf-8").toString("base64"); + pairs.push(`${key} ${b64}`); + } + return pairs.join(","); +} + +export interface TusCreateOpts { + endpoint: string; // e.g. https://api-sire.sunat.gob.pe/v1/.../upload + uploadLength: number; + metadata: TusMetadata; + bearerToken: string; +} + +export interface TusCreateResult { + uploadUrl: string; // The Location header from the POST response (used for PATCH) +} + +export async function tusCreate(opts: TusCreateOpts): Promise { + const resp = await fetch(opts.endpoint, { + method: "POST", + headers: { + "Tus-Resumable": TUS_VERSION, + "Upload-Length": String(opts.uploadLength), + "Upload-Metadata": encodeMetadata(opts.metadata), + "Content-Type": "application/x-www-form-urlencoded", + Authorization: `Bearer ${opts.bearerToken}`, + }, + }); + + if (resp.status !== 201) { + const text = await resp.text(); + throw new Error(`TUS create failed: HTTP ${resp.status}: ${text.slice(0, 500)}`); + } + + const location = resp.headers.get("Location") || resp.headers.get("location"); + if (!location) { + throw new Error("TUS create response missing Location header"); + } + + // Location may be relative; resolve against the endpoint. + const url = new URL(location, opts.endpoint).toString(); + return { uploadUrl: url }; +} + +export interface TusHeadResult { + uploadOffset: number; + uploadLength?: number; +} + +export async function tusHead(uploadUrl: string, bearerToken: string): Promise { + const resp = await fetch(uploadUrl, { + method: "HEAD", + headers: { + "Tus-Resumable": TUS_VERSION, + Authorization: `Bearer ${bearerToken}`, + }, + }); + if (!resp.ok && resp.status !== 200) { + throw new Error(`TUS head failed: HTTP ${resp.status}`); + } + const offsetHeader = resp.headers.get("Upload-Offset"); + const lengthHeader = resp.headers.get("Upload-Length"); + if (!offsetHeader) throw new Error("TUS head response missing Upload-Offset"); + return { + uploadOffset: Number.parseInt(offsetHeader, 10), + uploadLength: lengthHeader ? Number.parseInt(lengthHeader, 10) : undefined, + }; +} + +export interface TusPatchOpts { + uploadUrl: string; + chunk: Buffer; + offset: number; + bearerToken: string; +} + +export interface TusPatchResult { + newOffset: number; +} + +export async function tusPatch(opts: TusPatchOpts): Promise { + // Web fetch wants BodyInit; cast Buffer → Uint8Array for type compatibility. + const body = new Uint8Array(opts.chunk.buffer, opts.chunk.byteOffset, opts.chunk.byteLength); + const resp = await fetch(opts.uploadUrl, { + method: "PATCH", + headers: { + "Tus-Resumable": TUS_VERSION, + "Upload-Offset": String(opts.offset), + "Content-Type": "application/offset+octet-stream", + "Content-Length": String(opts.chunk.byteLength), + Authorization: `Bearer ${opts.bearerToken}`, + }, + body, + }); + if (resp.status !== 204) { + const text = await resp.text(); + throw new Error(`TUS patch failed at offset ${opts.offset}: HTTP ${resp.status}: ${text.slice(0, 500)}`); + } + const newOffsetHeader = resp.headers.get("Upload-Offset"); + if (!newOffsetHeader) throw new Error("TUS patch response missing Upload-Offset"); + return { newOffset: Number.parseInt(newOffsetHeader, 10) }; +} + +export interface TusUploadOpts { + endpoint: string; + data: Buffer; + metadata: TusMetadata; + bearerToken: string; + chunkSize?: number; // default 8 MB + onProgress?: (uploaded: number, total: number) => void; +} + +/** + * High-level: create + chunked PATCH until done. + * Returns the uploadUrl (useful for retry / debugging) and total bytes sent. + */ +export async function tusUpload(opts: TusUploadOpts): Promise<{ uploadUrl: string; bytesSent: number }> { + const total = opts.data.byteLength; + const chunkSize = opts.chunkSize ?? 8 * 1024 * 1024; + + const { uploadUrl } = await tusCreate({ + endpoint: opts.endpoint, + uploadLength: total, + metadata: opts.metadata, + bearerToken: opts.bearerToken, + }); + + let offset = 0; + while (offset < total) { + const end = Math.min(offset + chunkSize, total); + const chunk = opts.data.subarray(offset, end); + const { newOffset } = await tusPatch({ + uploadUrl, + chunk, + offset, + bearerToken: opts.bearerToken, + }); + offset = newOffset; + opts.onProgress?.(offset, total); + } + + return { uploadUrl, bytesSent: offset }; +} diff --git a/packages/cli/tests/unit/tus.test.ts b/packages/cli/tests/unit/tus.test.ts new file mode 100644 index 0000000..a0c59a5 --- /dev/null +++ b/packages/cli/tests/unit/tus.test.ts @@ -0,0 +1,196 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { TUS_VERSION, encodeMetadata, tusCreate, tusHead, tusPatch, tusUpload } from "../../src/sunat-rest/tus.ts"; + +const ORIGINAL_FETCH = global.fetch; + +beforeEach(() => {}); +afterEach(() => { + global.fetch = ORIGINAL_FETCH; +}); + +function mockFetch(impl: (url: string, init?: RequestInit) => Promise): void { + global.fetch = mock(async (url, init) => impl(String(url), init as RequestInit)); +} + +describe("encodeMetadata", () => { + test("encodes single key as 'key base64'", () => { + expect(encodeMetadata({ filename: "hola.zip" })).toBe(`filename ${Buffer.from("hola.zip").toString("base64")}`); + }); + + test("joins multiple pairs with comma per TUS spec", () => { + const out = encodeMetadata({ filename: "a.zip", filetype: "application/zip" }); + expect(out).toContain("filename "); + expect(out).toContain("filetype "); + expect(out.split(",").length).toBe(2); + }); + + test("encodes UTF-8 multibyte chars (acentos)", () => { + const value = "comprobantes-año.zip"; + const out = encodeMetadata({ filename: value }); + const decoded = Buffer.from(out.split(" ")[1], "base64").toString("utf-8"); + expect(decoded).toBe(value); + }); + + test("returns empty string when metadata is empty", () => { + expect(encodeMetadata({})).toBe(""); + }); +}); + +describe("TUS_VERSION", () => { + test("targets 1.0.0", () => { + expect(TUS_VERSION).toBe("1.0.0"); + }); +}); + +describe("tusCreate", () => { + test("POSTs Tus-Resumable + Upload-Length + Upload-Metadata + Bearer", async () => { + let captured: { url?: string; method?: string; headers?: Record } = {}; + mockFetch(async (url, init) => { + captured = { + url, + method: init?.method, + headers: init?.headers as Record, + }; + return new Response(null, { status: 201, headers: { Location: `${url}/upload-id-123` } }); + }); + + const result = await tusCreate({ + endpoint: "https://api-sire.sunat.gob.pe/v1/upload", + uploadLength: 12345, + metadata: { filename: "test.zip", perTributario: "202404" }, + bearerToken: "tk", + }); + + expect(captured.method).toBe("POST"); + expect(captured.headers?.["Tus-Resumable"]).toBe("1.0.0"); + expect(captured.headers?.["Upload-Length"]).toBe("12345"); + expect(captured.headers?.["Upload-Metadata"]).toContain("filename "); + expect(captured.headers?.Authorization).toBe("Bearer tk"); + expect(result.uploadUrl).toContain("/upload/upload-id-123"); + }); + + test("resolves relative Location against the endpoint", async () => { + mockFetch(async () => new Response(null, { status: 201, headers: { Location: "/v1/upload/relative-id" } })); + const r = await tusCreate({ + endpoint: "https://api-sire.sunat.gob.pe/v1/foo/bar", + uploadLength: 1, + metadata: {}, + bearerToken: "tk", + }); + expect(r.uploadUrl).toBe("https://api-sire.sunat.gob.pe/v1/upload/relative-id"); + }); + + test("throws on non-201 status with body excerpt", async () => { + mockFetch(async () => new Response("payload too large", { status: 413 })); + expect( + tusCreate({ endpoint: "https://x", uploadLength: 1, metadata: {}, bearerToken: "tk" }), + ).rejects.toThrow(/TUS create failed: HTTP 413/); + }); + + test("throws when Location header is missing", async () => { + mockFetch(async () => new Response(null, { status: 201 })); + expect( + tusCreate({ endpoint: "https://x", uploadLength: 1, metadata: {}, bearerToken: "tk" }), + ).rejects.toThrow(/missing Location header/); + }); +}); + +describe("tusHead", () => { + test("reads Upload-Offset", async () => { + mockFetch(async () => new Response(null, { status: 200, headers: { "Upload-Offset": "1024", "Upload-Length": "4096" } })); + const r = await tusHead("https://x/upload/123", "tk"); + expect(r.uploadOffset).toBe(1024); + expect(r.uploadLength).toBe(4096); + }); + + test("throws when Upload-Offset missing", async () => { + mockFetch(async () => new Response(null, { status: 200 })); + expect(tusHead("https://x", "tk")).rejects.toThrow(/missing Upload-Offset/); + }); +}); + +describe("tusPatch", () => { + test("PATCH with Upload-Offset + offset+octet-stream content-type", async () => { + let captured: { method?: string; headers?: Record; bodyLength?: number } = {}; + mockFetch(async (_url, init) => { + const body = init?.body; + captured = { + method: init?.method, + headers: init?.headers as Record, + bodyLength: body instanceof Uint8Array ? body.byteLength : 0, + }; + return new Response(null, { status: 204, headers: { "Upload-Offset": "8192" } }); + }); + const chunk = Buffer.alloc(4096, "a"); + const r = await tusPatch({ uploadUrl: "https://x/upload/1", chunk, offset: 4096, bearerToken: "tk" }); + expect(captured.method).toBe("PATCH"); + expect(captured.headers?.["Upload-Offset"]).toBe("4096"); + expect(captured.headers?.["Content-Type"]).toBe("application/offset+octet-stream"); + expect(captured.headers?.["Tus-Resumable"]).toBe("1.0.0"); + expect(captured.bodyLength).toBe(4096); + expect(r.newOffset).toBe(8192); + }); + + test("throws on non-204 status", async () => { + mockFetch(async () => new Response("conflict", { status: 409 })); + expect( + tusPatch({ uploadUrl: "https://x", chunk: Buffer.alloc(1), offset: 0, bearerToken: "tk" }), + ).rejects.toThrow(/TUS patch failed at offset 0: HTTP 409/); + }); +}); + +describe("tusUpload (chunked end-to-end)", () => { + test("uploads in 8MB chunks by default until done", async () => { + const total = 20 * 1024 * 1024; // 20 MB + const data = Buffer.alloc(total, 0x61); + let offset = 0; + const chunks: number[] = []; + mockFetch(async (url, init) => { + if (init?.method === "POST") { + return new Response(null, { status: 201, headers: { Location: `${url}/u-1` } }); + } + // PATCH + const body = init?.body as Uint8Array; + chunks.push(body.byteLength); + offset += body.byteLength; + return new Response(null, { status: 204, headers: { "Upload-Offset": String(offset) } }); + }); + const progressCalls: number[] = []; + const r = await tusUpload({ + endpoint: "https://x/upload", + data, + metadata: { filename: "x.zip" }, + bearerToken: "tk", + onProgress: (uploaded) => progressCalls.push(uploaded), + }); + expect(r.bytesSent).toBe(total); + expect(chunks.length).toBe(3); // 8 + 8 + 4 MB + expect(chunks[0]).toBe(8 * 1024 * 1024); + expect(chunks[1]).toBe(8 * 1024 * 1024); + expect(chunks[2]).toBe(4 * 1024 * 1024); + expect(progressCalls.length).toBe(3); + expect(progressCalls[progressCalls.length - 1]).toBe(total); + }); + + test("respects --chunk-size when provided", async () => { + const total = 10 * 1024; + const data = Buffer.alloc(total, 0); + const chunks: number[] = []; + let offset = 0; + mockFetch(async (url, init) => { + if (init?.method === "POST") return new Response(null, { status: 201, headers: { Location: `${url}/u` } }); + const body = init?.body as Uint8Array; + chunks.push(body.byteLength); + offset += body.byteLength; + return new Response(null, { status: 204, headers: { "Upload-Offset": String(offset) } }); + }); + await tusUpload({ + endpoint: "https://x/upload", + data, + metadata: {}, + bearerToken: "tk", + chunkSize: 4096, + }); + expect(chunks).toEqual([4096, 4096, 2048]); + }); +});