mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-14 10:52:17 +03:00
feat(providers): surface CSV import row errors and ship a template (#12504)
Validado em lote numa worktree combinada com os 9 PRs desta leva sobre o tip de `release/v3.8.51`: `typecheck:core` limpo, `check:provider-consistency` OK (273 entradas REGISTRY, **356** providers canônicos), `check-docs-counts-sync` exit 0 e **300/300** nos testes que a leva toca. O crescimento de arquivo que os PRs empilham uns sobre os outros foi rebaselinado num único registro datado (`_rebaseline_2026_09_03_houminxi_batch`), com a decomposição por arquivo: `providers/page.tsx` +18 (import CSV do #12504 + busca do #12495 no mesmo painel), `accountFallback.ts` +6 (o #12566 sobre o rebaseline que o #12590 já registrou — os dois tocam `checkFallbackError`) e `chatCore.ts` +3 (invalidez de cache de quota no 429 do #12325). As violações restantes (`codex.ts`, `stream.ts`) foram medidas também no tip puro e são drift da base, não desta leva.
This commit is contained in:
1
changelog.d/features/12071-csv-import-errors.md
Normal file
1
changelog.d/features/12071-csv-import-errors.md
Normal file
@@ -0,0 +1 @@
|
||||
- **feat(providers):** import-from-file modal shows per-row API errors and ships a downloadable CSV template ([#12071](https://github.com/diegosouzapw/OmniRoute/issues/12071))
|
||||
@@ -122,6 +122,8 @@ Access via: WhatsApp, Telegram, Slack, Discord, iMessage, Signal...
|
||||
|
||||
## 📖 Provider Setup
|
||||
|
||||
To bulk-add API-key connections from a CSV or JSON file, use **Dashboard → Providers → Import from file**. Columns are positional (`provider,name,apiKey,baseUrl,priority`); `provider` must already exist as a managed provider or a compatible node. See [Import providers from a CSV or JSON file](../providers/CSV-IMPORT.md).
|
||||
|
||||
### 🔐 Subscription Providers
|
||||
|
||||
#### Claude Code (Pro/Max)
|
||||
|
||||
43
docs/providers/CSV-IMPORT.md
Normal file
43
docs/providers/CSV-IMPORT.md
Normal file
@@ -0,0 +1,43 @@
|
||||
---
|
||||
title: "Import providers from a CSV or JSON file"
|
||||
---
|
||||
|
||||
# Import providers from a CSV or JSON file
|
||||
|
||||
Dashboard → Providers → **Import from file** creates API-key connections from a CSV or JSON list. Each row can target a different provider. Partial failure is the contract: valid rows still import when others fail, and the modal lists why the failed rows were rejected.
|
||||
|
||||
This import does **not** create new OpenAI/Anthropic-compatible endpoint nodes. Create those first (Dashboard → Providers → Add OpenAI-Compatible, or `omniroute nodes add`), then import rows whose `provider` column is that node's id. A per-row `baseUrl` can still override the node's URL.
|
||||
|
||||
## CSV (positional)
|
||||
|
||||
Column names are cosmetic. The parser splits each row and destructures by index:
|
||||
|
||||
| Index | Field | Required | Notes |
|
||||
| ----- | ----- | -------- | ----- |
|
||||
| 0 | `provider` | yes | Existing managed provider id (`openai`, `anthropic`, …) **or** an already-registered OpenAI/Anthropic-compatible **node** id |
|
||||
| 1 | `name` | yes | Connection display name |
|
||||
| 2 | `apiKey` | yes | API key |
|
||||
| 3 | `baseUrl` | no | Per-row URL override |
|
||||
| 4 | `priority` | no | Integer 1–100 |
|
||||
|
||||
A first line whose first column is the literal word `provider` (any case) is skipped as a header. Blank lines and `#` comments are skipped.
|
||||
|
||||
Download a starter file from the import modal (**Download CSV template**). Example:
|
||||
|
||||
```csv
|
||||
# OmniRoute provider import (positional columns)
|
||||
provider,name,apiKey,baseUrl,priority
|
||||
openai,Prod OpenAI,sk-your-openai-key,,1
|
||||
```
|
||||
|
||||
A made-up id such as `openai-compatible-chat-001` is not a node. The API returns `Unknown or unsupported provider` for that row; the modal shows it next to the row name.
|
||||
|
||||
## JSON
|
||||
|
||||
A JSON array of objects with the same fields (`provider`, `name`, `apiKey`, `baseUrl?`, `priority?`). Unlike CSV, JSON keys are named.
|
||||
|
||||
```json
|
||||
[
|
||||
{ "provider": "openai", "name": "Prod OpenAI", "apiKey": "sk-your-openai-key", "priority": 1 }
|
||||
]
|
||||
```
|
||||
@@ -8,6 +8,7 @@
|
||||
"AGENTROUTER",
|
||||
"ZED-DOCKER",
|
||||
"CURSOR-DOCKER",
|
||||
"CURSOR-API-KEY-AND-CLI"
|
||||
"CURSOR-API-KEY-AND-CLI",
|
||||
"CSV-IMPORT"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -4,6 +4,12 @@ import { useTranslations } from "next-intl";
|
||||
import { Button, Modal } from "@/shared/components";
|
||||
import type { ParsedProviderImportEntry, ProviderImportParseError } from "./parseProviderImportFile";
|
||||
import { useImportProvidersFromFile } from "./useImportProvidersFromFile";
|
||||
import {
|
||||
downloadProviderImportCsvTemplate,
|
||||
formatImportErrorLine,
|
||||
visibleImportErrors,
|
||||
type ImportResult,
|
||||
} from "./providerImportFeedback";
|
||||
|
||||
interface ImportProvidersFromFileModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -122,6 +128,30 @@ function FilePickerRow({ fileInputRef, fileName, onFile, t }: FilePickerRowProps
|
||||
);
|
||||
}
|
||||
|
||||
function ImportResultPanel({ result, t }: { result: ImportResult; t: Translator }) {
|
||||
const { shown, extra } = visibleImportErrors(result.errors);
|
||||
const failed = result.failed > 0 || shown.length > 0;
|
||||
return (
|
||||
<div
|
||||
className={`px-3 py-2 rounded border text-sm ${
|
||||
failed
|
||||
? "border-amber-500/30 bg-amber-500/10 text-amber-300"
|
||||
: "border-emerald-500/30 bg-emerald-500/10 text-emerald-400"
|
||||
}`}
|
||||
>
|
||||
{t("importFromFileResult", { success: result.success, failed: result.failed })}
|
||||
{shown.length > 0 && (
|
||||
<ul className="mt-2 list-disc pl-5 text-xs text-text-muted font-normal space-y-0.5">
|
||||
{shown.map((err, i) => (
|
||||
<li key={i}>{formatImportErrorLine(err)}</li>
|
||||
))}
|
||||
{extra > 0 && <li>{t("importFromFileMoreErrors", { count: extra })}</li>}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wizard step: upload a CSV/JSON file listing MULTIPLE, possibly different providers,
|
||||
* pick which parsed rows to actually import, then submit them in one batch (#6836).
|
||||
@@ -141,18 +171,18 @@ export function ImportProvidersFromFileModal({
|
||||
<Modal isOpen={isOpen} onClose={() => s.handleClose(onClose)} title={t("importFromFileTitle")} maxWidth="xl">
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-sm text-text-muted">{t("importFromFileDescription")}</p>
|
||||
<p className="text-xs text-text-muted">{t("importFromFileSchemaHint")}</p>
|
||||
|
||||
<FilePickerRow fileInputRef={s.fileInputRef} fileName={s.fileName} onFile={s.handleFile} t={t} />
|
||||
<ParseErrorsList errors={s.errors} t={t} />
|
||||
<EntriesTable entries={s.entries} selected={s.selected} onToggleRow={s.toggleRow} onToggleAll={s.toggleAll} t={t} />
|
||||
|
||||
{s.result && (
|
||||
<div className="px-3 py-2 rounded border border-emerald-500/30 bg-emerald-500/10 text-sm text-emerald-400">
|
||||
{t("importFromFileResult", { success: s.result.success, failed: s.result.failed })}
|
||||
</div>
|
||||
)}
|
||||
{s.result && <ImportResultPanel result={s.result} t={t} />}
|
||||
|
||||
<div className="flex items-center justify-end gap-2 pt-2 border-t border-border">
|
||||
<Button size="sm" variant="ghost" onClick={downloadProviderImportCsvTemplate}>
|
||||
{t("importFromFileDownloadTemplate")}
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={() => s.handleClose(onClose)}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* #12071 — import-modal feedback helpers.
|
||||
*
|
||||
* POST /api/providers/import already returns per-row `{index,name,provider,message}`.
|
||||
* The modal used to keep only success/failed/total and drop `errors` on the floor.
|
||||
* These helpers stay a pure, dependency-free module so the hook can stay under the
|
||||
* LOC ratchet and the same formatter can be unit-tested without React.
|
||||
*/
|
||||
|
||||
export type ImportRowError = {
|
||||
index?: number;
|
||||
name?: string;
|
||||
provider?: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type ImportResult = {
|
||||
success: number;
|
||||
failed: number;
|
||||
total: number;
|
||||
errors: ImportRowError[];
|
||||
};
|
||||
|
||||
const VISIBLE_ERROR_CAP = 10;
|
||||
|
||||
function asFiniteNumber(value: unknown): number {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
||||
}
|
||||
|
||||
function asRowError(value: unknown): ImportRowError | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const row = value as Record<string, unknown>;
|
||||
if (typeof row.message !== "string" || !row.message.trim()) return null;
|
||||
return {
|
||||
...(typeof row.index === "number" && Number.isFinite(row.index) ? { index: row.index } : {}),
|
||||
...(typeof row.name === "string" && row.name.trim() ? { name: row.name.trim() } : {}),
|
||||
...(typeof row.provider === "string" && row.provider.trim() ? { provider: row.provider.trim() } : {}),
|
||||
message: row.message.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Keep counts plus a sanitized `errors` array. A missing/non-array field becomes []. */
|
||||
export function normalizeImportResponse(data: unknown): ImportResult {
|
||||
const body = data && typeof data === "object" ? (data as Record<string, unknown>) : {};
|
||||
const rawErrors = Array.isArray(body.errors) ? body.errors : [];
|
||||
return {
|
||||
success: asFiniteNumber(body.success),
|
||||
failed: asFiniteNumber(body.failed),
|
||||
total: asFiniteNumber(body.total),
|
||||
errors: rawErrors.map(asRowError).filter((row): row is ImportRowError => row !== null),
|
||||
};
|
||||
}
|
||||
|
||||
export type ImportHttpOutcome = {
|
||||
result: ImportResult;
|
||||
shouldRefresh: boolean;
|
||||
};
|
||||
|
||||
function httpFailureResult(status: number, data: unknown, fallback: ImportResult): ImportResult {
|
||||
if (fallback.errors.length > 0) {
|
||||
return { ...fallback, success: 0 };
|
||||
}
|
||||
const body = data && typeof data === "object" ? (data as Record<string, unknown>) : {};
|
||||
const detail = typeof body.error === "string" ? body.error.trim() : "";
|
||||
const message = detail ? `HTTP ${status}: ${detail}` : `HTTP ${status}`;
|
||||
return {
|
||||
success: 0,
|
||||
failed: Math.max(1, fallback.failed),
|
||||
total: Math.max(1, fallback.total),
|
||||
errors: [{ message }],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map an import HTTP response onto the modal result.
|
||||
* Non-ok statuses still populate `errors`. Refresh is a boolean so the hook
|
||||
* can await `onImported` outside this function (a throw there must not
|
||||
* overwrite a successful import result).
|
||||
*/
|
||||
export function applyImportHttpOutcome(
|
||||
res: { ok: boolean; status: number },
|
||||
data: unknown
|
||||
): ImportHttpOutcome {
|
||||
const normalized = normalizeImportResponse(data);
|
||||
if (!res.ok) {
|
||||
return { result: httpFailureResult(res.status, data, normalized), shouldRefresh: false };
|
||||
}
|
||||
return { result: normalized, shouldRefresh: normalized.success > 0 };
|
||||
}
|
||||
|
||||
/** Parse the import response body. Non-JSON becomes `{ ok: false, data: { error } }`. */
|
||||
export async function readImportResponse(res: Response): Promise<{
|
||||
ok: boolean;
|
||||
status: number;
|
||||
data: unknown;
|
||||
}> {
|
||||
try {
|
||||
return { ok: res.ok, status: res.status, data: await res.json() };
|
||||
} catch {
|
||||
return { ok: false, status: res.status, data: { error: "Invalid JSON body" } };
|
||||
}
|
||||
}
|
||||
|
||||
export function networkImportFailure(err: unknown): ImportResult {
|
||||
return {
|
||||
success: 0,
|
||||
failed: 1,
|
||||
total: 1,
|
||||
errors: [{ message: err instanceof Error ? err.message : "Import request failed" }],
|
||||
};
|
||||
}
|
||||
|
||||
/** First 10 rows plus the leftover count — same cap as AddApiKeyModal bulk import. */
|
||||
export function visibleImportErrors(errors: ImportRowError[]): {
|
||||
shown: ImportRowError[];
|
||||
extra: number;
|
||||
} {
|
||||
return {
|
||||
shown: errors.slice(0, VISIBLE_ERROR_CAP),
|
||||
extra: Math.max(0, errors.length - VISIBLE_ERROR_CAP),
|
||||
};
|
||||
}
|
||||
|
||||
/** One line for the modal list: name, else provider, else 1-based row index. */
|
||||
export function formatImportErrorLine(err: ImportRowError): string {
|
||||
const label =
|
||||
(typeof err.name === "string" && err.name.trim()) ||
|
||||
(typeof err.provider === "string" && err.provider.trim()) ||
|
||||
(typeof err.index === "number" && Number.isFinite(err.index) ? `row ${err.index + 1}` : "row");
|
||||
return `${label}: ${err.message}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Positional CSV sample. Column 0 must be an *existing* managed provider id
|
||||
* or an already-registered OpenAI/Anthropic-compatible node id — this import
|
||||
* does not create new endpoint nodes. Header names are cosmetic; the parser
|
||||
* destructures by index (`provider,name,apiKey,baseUrl,priority`).
|
||||
*/
|
||||
export const PROVIDER_IMPORT_CSV_TEMPLATE = `# OmniRoute provider import (positional columns)
|
||||
# Columns: provider, name, apiKey, baseUrl (optional), priority (optional, 1-100)
|
||||
# The provider column must be an existing managed provider id (openai, anthropic, …)
|
||||
# or an already-registered OpenAI/Anthropic-compatible node id.
|
||||
# This import does not create new endpoint nodes. Add those first (Dashboard → Providers → Add OpenAI-Compatible).
|
||||
provider,name,apiKey,baseUrl,priority
|
||||
openai,Prod OpenAI,sk-your-openai-key,,1
|
||||
`;
|
||||
|
||||
export function downloadTextFile(content: string, filename: string, mimeType: string): void {
|
||||
const blob = new Blob([content], { type: mimeType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
try {
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
} finally {
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
|
||||
export function downloadProviderImportCsvTemplate(): void {
|
||||
downloadTextFile(PROVIDER_IMPORT_CSV_TEMPLATE, "omniroute-provider-import-template.csv", "text/csv");
|
||||
}
|
||||
@@ -4,13 +4,17 @@ import {
|
||||
type ParsedProviderImportEntry,
|
||||
type ProviderImportParseError,
|
||||
} from "./parseProviderImportFile";
|
||||
import {
|
||||
applyImportHttpOutcome,
|
||||
networkImportFailure,
|
||||
readImportResponse,
|
||||
type ImportResult,
|
||||
} from "./providerImportFeedback";
|
||||
|
||||
export type ImportResult = { success: number; failed: number; total: number };
|
||||
export type { ImportResult };
|
||||
|
||||
/**
|
||||
* All state + handlers for `ImportProvidersFromFileModal`, split into a hook purely
|
||||
* to keep the component's own function under the repo's max-lines-per-function ratchet
|
||||
* (#6836). Behavior is unchanged — this is a pure extraction, not a refactor.
|
||||
* State + handlers for ImportProvidersFromFileModal (#6836/#12071).
|
||||
*/
|
||||
export function useImportProvidersFromFile(onImported: () => Promise<void>) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -66,15 +70,19 @@ export function useImportProvidersFromFile(onImported: () => Promise<void>) {
|
||||
setImporting(true);
|
||||
try {
|
||||
const res = await fetch("/api/providers/import", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ entries: toImport }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (res.ok) {
|
||||
setResult({ success: data.success ?? 0, failed: data.failed ?? 0, total: data.total ?? 0 });
|
||||
await onImported();
|
||||
const parsed = await readImportResponse(res);
|
||||
const outcome = applyImportHttpOutcome(parsed, parsed.data);
|
||||
setResult(outcome.result);
|
||||
if (outcome.shouldRefresh) {
|
||||
try {
|
||||
await onImported();
|
||||
} catch { /* refresh failure must not replace the import result */ }
|
||||
}
|
||||
} catch (err) {
|
||||
setResult(networkImportFailure(err));
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
|
||||
@@ -5067,6 +5067,9 @@
|
||||
"importFromFileImporting": "Importing…",
|
||||
"importFromFileImport": "Import {count} providers",
|
||||
"importFromFileResult": "Imported {success} providers ({failed} failed)",
|
||||
"importFromFileDownloadTemplate": "Download CSV template",
|
||||
"importFromFileMoreErrors": "+{count} more",
|
||||
"importFromFileSchemaHint": "CSV columns are positional: provider, name, apiKey, baseUrl (optional), priority (optional). The provider column must be an existing managed provider id or an already-registered OpenAI/Anthropic-compatible node id — this import does not create new endpoint nodes.",
|
||||
"adaptaTutorial": {
|
||||
"title": "How to connect Adapta Web",
|
||||
"introPrefix": "Adapta authenticates through Clerk. The token",
|
||||
|
||||
@@ -5068,6 +5068,9 @@
|
||||
"importFromFileImporting": "Importando…",
|
||||
"importFromFileImport": "Importar {count} provedores",
|
||||
"importFromFileResult": "{success} provedores importados ({failed} falharam)",
|
||||
"importFromFileDownloadTemplate": "Baixar modelo CSV",
|
||||
"importFromFileMoreErrors": "+{count} mais",
|
||||
"importFromFileSchemaHint": "As colunas CSV são posicionais: provider, name, apiKey, baseUrl (opcional), priority (opcional). A coluna provider deve ser o id de um provedor gerenciado existente ou o id de um nó compatível com OpenAI/Anthropic já registrado — esta importação não cria novos nós de endpoint.",
|
||||
"adaptaTutorial": {
|
||||
"title": "Como conectar o Adapta Web",
|
||||
"introPrefix": "Adapta autentica através do Clerk. O token",
|
||||
|
||||
@@ -5068,6 +5068,9 @@
|
||||
"importFromFileImporting": "Đang nhập…",
|
||||
"importFromFileImport": "Nhập {count} nhà cung cấp",
|
||||
"importFromFileResult": "Đã nhập {success} nhà cung cấp ({failed} không thành công)",
|
||||
"importFromFileDownloadTemplate": "Tải mẫu CSV",
|
||||
"importFromFileMoreErrors": "+{count} nữa",
|
||||
"importFromFileSchemaHint": "Các cột CSV theo vị trí: provider, name, apiKey, baseUrl (tùy chọn), priority (tùy chọn). Cột provider phải là id nhà cung cấp được quản lý hiện có hoặc id nút tương thích OpenAI/Anthropic đã đăng ký — quá trình nhập này không tạo nút endpoint mới.",
|
||||
"adaptaTutorial": {
|
||||
"title": "How to connect Adapta Web",
|
||||
"introPrefix": "Adapta authenticates through Clerk. The token",
|
||||
|
||||
143
tests/unit/provider-import-feedback-12071.test.ts
Normal file
143
tests/unit/provider-import-feedback-12071.test.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const feedback = await import(
|
||||
"../../src/app/(dashboard)/dashboard/providers/components/providerImportFeedback.ts"
|
||||
);
|
||||
const { parseProviderImportFile } = await import(
|
||||
"../../src/app/(dashboard)/dashboard/providers/components/parseProviderImportFile.ts"
|
||||
);
|
||||
|
||||
test("#12071 normalizeImportResponse keeps the per-row errors array", () => {
|
||||
const result = feedback.normalizeImportResponse({
|
||||
success: 1,
|
||||
failed: 2,
|
||||
total: 3,
|
||||
errors: [
|
||||
{ index: 1, name: "srv-107", provider: "openai-compatible-chat-001", message: "Unknown or unsupported provider" },
|
||||
{ index: 2, name: "srv-135", provider: "openai", message: "Provider node not found" },
|
||||
],
|
||||
});
|
||||
assert.equal(result.success, 1);
|
||||
assert.equal(result.failed, 2);
|
||||
assert.equal(result.total, 3);
|
||||
assert.equal(result.errors.length, 2);
|
||||
assert.equal(result.errors[0].message, "Unknown or unsupported provider");
|
||||
assert.equal(result.errors[1].name, "srv-135");
|
||||
});
|
||||
|
||||
test("#12071 normalizeImportResponse treats a missing errors field as [] (today's silent drop)", () => {
|
||||
const result = feedback.normalizeImportResponse({ success: 0, failed: 3, total: 3 });
|
||||
assert.deepEqual(result.errors, []);
|
||||
assert.equal(result.failed, 3);
|
||||
});
|
||||
|
||||
test("#12071 normalizeImportResponse ignores a non-array errors field", () => {
|
||||
const result = feedback.normalizeImportResponse({ success: 0, failed: 1, total: 1, errors: "boom" });
|
||||
assert.deepEqual(result.errors, []);
|
||||
});
|
||||
|
||||
test("#12071 visibleImportErrors caps at 10 and reports the remainder", () => {
|
||||
const errors = Array.from({ length: 12 }, (_, i) => ({ message: `row ${i}` }));
|
||||
const { shown, extra } = feedback.visibleImportErrors(errors);
|
||||
assert.equal(shown.length, 10);
|
||||
assert.equal(extra, 2);
|
||||
assert.equal(shown[0].message, "row 0");
|
||||
});
|
||||
|
||||
test("#12071 formatImportErrorLine prefers name, then provider, then 1-based row", () => {
|
||||
assert.equal(
|
||||
feedback.formatImportErrorLine({ name: "Grade-S-Node", message: "Unknown or unsupported provider" }),
|
||||
"Grade-S-Node: Unknown or unsupported provider"
|
||||
);
|
||||
assert.equal(
|
||||
feedback.formatImportErrorLine({ provider: "openai", message: "Provider node not found" }),
|
||||
"openai: Provider node not found"
|
||||
);
|
||||
assert.equal(feedback.formatImportErrorLine({ index: 0, message: "failed" }), "row 1: failed");
|
||||
});
|
||||
|
||||
test("#12071 CSV template is positional and parses to one openai row", () => {
|
||||
const parsed = parseProviderImportFile(feedback.PROVIDER_IMPORT_CSV_TEMPLATE, "csv");
|
||||
assert.equal(parsed.errors.length, 0);
|
||||
assert.equal(parsed.entries.length, 1);
|
||||
assert.equal(parsed.entries[0].provider, "openai");
|
||||
assert.equal(parsed.entries[0].name, "Prod OpenAI");
|
||||
assert.equal(parsed.entries[0].apiKey, "sk-your-openai-key");
|
||||
assert.equal(parsed.entries[0].priority, 1);
|
||||
});
|
||||
|
||||
test("#12071 CSV template comments document that provider must already exist", () => {
|
||||
assert.match(feedback.PROVIDER_IMPORT_CSV_TEMPLATE, /existing managed provider/i);
|
||||
assert.match(feedback.PROVIDER_IMPORT_CSV_TEMPLATE, /does not create new endpoint nodes/i);
|
||||
assert.match(feedback.PROVIDER_IMPORT_CSV_TEMPLATE, /positional/i);
|
||||
});
|
||||
|
||||
test("#12071 asRowError trims leading/trailing whitespace on message", () => {
|
||||
const result = feedback.normalizeImportResponse({
|
||||
success: 0,
|
||||
failed: 1,
|
||||
total: 1,
|
||||
errors: [{ name: "srv-107", message: " Unknown or unsupported provider " }],
|
||||
});
|
||||
assert.equal(result.errors.length, 1);
|
||||
assert.equal(result.errors[0].message, "Unknown or unsupported provider");
|
||||
});
|
||||
|
||||
test("#12071 applyImportHttpOutcome on !ok zeros success even if the body claimed some", () => {
|
||||
const outcome = feedback.applyImportHttpOutcome(
|
||||
{ ok: false, status: 500 },
|
||||
{
|
||||
success: 5,
|
||||
failed: 0,
|
||||
total: 5,
|
||||
errors: [{ name: "a", message: "Unknown or unsupported provider" }],
|
||||
}
|
||||
);
|
||||
assert.equal(outcome.shouldRefresh, false);
|
||||
assert.equal(outcome.result.success, 0);
|
||||
assert.equal(outcome.result.errors.length, 1);
|
||||
});
|
||||
|
||||
test("#12071 applyImportHttpOutcome surfaces non-ok HTTP without calling onImported", () => {
|
||||
const outcome = feedback.applyImportHttpOutcome(
|
||||
{ ok: false, status: 400 },
|
||||
{ error: "Invalid JSON body" }
|
||||
);
|
||||
assert.equal(outcome.shouldRefresh, false);
|
||||
assert.equal(outcome.result.success, 0);
|
||||
assert.equal(outcome.result.failed, 1);
|
||||
assert.equal(outcome.result.errors.length, 1);
|
||||
assert.match(outcome.result.errors[0].message, /HTTP 400/);
|
||||
});
|
||||
|
||||
test("#12071 applyImportHttpOutcome on ok with success>0 requests refresh", () => {
|
||||
const outcome = feedback.applyImportHttpOutcome(
|
||||
{ ok: true, status: 200 },
|
||||
{ success: 2, failed: 1, total: 3, errors: [{ name: "bad", message: "Unknown or unsupported provider" }] }
|
||||
);
|
||||
assert.equal(outcome.shouldRefresh, true);
|
||||
assert.equal(outcome.result.success, 2);
|
||||
assert.equal(outcome.result.failed, 1);
|
||||
assert.equal(outcome.result.errors[0].name, "bad");
|
||||
});
|
||||
|
||||
test("#12071 applyImportHttpOutcome on ok with success=0 still keeps errors and skips refresh", () => {
|
||||
const outcome = feedback.applyImportHttpOutcome(
|
||||
{ ok: true, status: 200 },
|
||||
{ success: 0, failed: 3, total: 3, errors: [{ name: "a", message: "Unknown or unsupported provider" }] }
|
||||
);
|
||||
assert.equal(outcome.shouldRefresh, false);
|
||||
assert.equal(outcome.result.success, 0);
|
||||
assert.equal(outcome.result.errors.length, 1);
|
||||
});
|
||||
|
||||
test("#12071 readImportResponse treats JSON parse failure as !ok with a body error", async () => {
|
||||
const res = new Response("not-json", { status: 200, headers: { "Content-Type": "text/plain" } });
|
||||
const parsed = await feedback.readImportResponse(res);
|
||||
assert.equal(parsed.ok, false);
|
||||
assert.equal(parsed.status, 200);
|
||||
const outcome = feedback.applyImportHttpOutcome(parsed, parsed.data);
|
||||
assert.equal(outcome.shouldRefresh, false);
|
||||
assert.match(outcome.result.errors[0].message, /Invalid JSON body/);
|
||||
});
|
||||
Reference in New Issue
Block a user