From 4e9a69f4f72050cde1b8a68240f399fac78ef336 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?shravan=20=7C=7C=20=E0=A4=B6=E0=A5=8D=E0=A4=B0van?= Date: Sun, 1 Nov 2026 16:25:14 +0530 Subject: [PATCH 1/7] test: add portfolio import regression coverage --- src/__tests__/portfolio-import.test.ts | 200 +++++++++++++++++++++++++ 1 file changed, 200 insertions(+) create mode 100644 src/__tests__/portfolio-import.test.ts diff --git a/src/__tests__/portfolio-import.test.ts b/src/__tests__/portfolio-import.test.ts new file mode 100644 index 0000000..070e1b3 --- /dev/null +++ b/src/__tests__/portfolio-import.test.ts @@ -0,0 +1,200 @@ +import { afterEach, describe, expect, mock, test } from "bun:test"; + +const validDomainId = "11111111-1111-4111-8111-111111111111"; +const validServerId = "22222222-2222-4222-8222-222222222222"; +const secondServerId = "33333333-3333-4333-8333-333333333333"; + +const repository = { + findDomainsByUserId: mock(async (_userId: string) => [] as any[]), + findDomainByHostnameForUser: mock(async (_hostname: string, _userId: string) => undefined as any), + findDomainByIdForUser: mock(async (_id: string, _userId: string) => undefined as any), + insertDomain: mock(async (_row: any) => ({ id: validDomainId })), + updateDomainForUser: mock(async (_id: string, _userId: string, _row: any) => ({ id: validDomainId })), + findInfrastructureServerByIdForUser: mock(async (_id: string, _userId: string) => undefined as any), + findInfrastructureServersByUserId: mock(async (_userId: string) => [] as any[]), + insertInfrastructureServer: mock(async (row: any) => ({ id: row.id })), + updateInfrastructureServerForUser: mock(async (_id: string, _userId: string, _row: any) => ({ id: validServerId })), +}; + +mock.module("../repositories/domain.repository", () => ({ + findDomainsByUserId: repository.findDomainsByUserId, + findDomainByHostnameForUser: repository.findDomainByHostnameForUser, + findDomainByIdForUser: repository.findDomainByIdForUser, + insertDomain: repository.insertDomain, + updateDomainForUser: repository.updateDomainForUser, +})); +mock.module("../repositories/infrastructureServer.repository", () => ({ + findInfrastructureServerByIdForUser: repository.findInfrastructureServerByIdForUser, + findInfrastructureServersByUserId: repository.findInfrastructureServersByUserId, + insertInfrastructureServer: repository.insertInfrastructureServer, + updateInfrastructureServerForUser: repository.updateInfrastructureServerForUser, +})); + +const { importDomainsPortfolio } = await import("../services/domainPortfolio.service"); +const { importInfrastructureServersPortfolio } = await import("../services/infrastructureServerPortfolio.service"); +const { parseCsv } = await import("../portfolio/csv"); + +function resetMocks() { + for (const fn of Object.values(repository)) fn.mockClear(); + repository.findDomainsByUserId.mockResolvedValue([]); + repository.findDomainByHostnameForUser.mockResolvedValue(undefined); + repository.findDomainByIdForUser.mockResolvedValue(undefined); + repository.insertDomain.mockImplementation(async (row: any) => ({ id: row.id })); + repository.updateDomainForUser.mockResolvedValue({ id: validDomainId }); + repository.findInfrastructureServerByIdForUser.mockResolvedValue(undefined); + repository.findInfrastructureServersByUserId.mockResolvedValue([]); + repository.insertInfrastructureServer.mockImplementation(async (row: any) => ({ id: row.id })); + repository.updateInfrastructureServerForUser.mockResolvedValue({ id: validServerId }); +} + +afterEach(resetMocks); + +const domain = (hostname: string, id = validDomainId) => ({ id, hostname }); +const server = (id = validServerId, name = "app") => ({ id, provider: "AWS", name }); + +describe("portfolio CSV parser", () => { + test("rejects unterminated quotes", () => { + expect(() => parseCsv('hostname,notes\\nexample.com,"unfinished')).toThrow(/unterminated/i); + }); + + test("rejects quotes embedded in unquoted fields", () => { + expect(() => parseCsv('hostname,notes\\nexample.com,bad"quote')).toThrow(/quote inside/i); + }); + + test("rejects characters after a closing quote", () => { + expect(() => parseCsv('hostname,notes\\nexample.com,"valid"x')).toThrow(/after closing quote/i); + }); +}); + +describe("domain portfolio imports", () => { + test("rejects duplicate IDs and normalized hostnames in JSON payloads", async () => { + const duplicateId = await importDomainsPortfolio("user-1", { + format: "json", + domains: [domain("one.example"), domain("two.example")], + }); + // IDs in imported records are also required to be unique within one payload. + expect(duplicateId.status).toBe(400); + + const duplicateHost = await importDomainsPortfolio("user-1", { + format: "json", + domains: [domain("Example.com", validDomainId), domain("example.com.", secondServerId)], + }); + expect(duplicateHost.status).toBe(200); + expect((duplicateHost.body as any).errors).toHaveLength(1); + }); + + test("rejects duplicate CSV records using normalized hostname", async () => { + const csv = [ + "id,hostname", + `${validDomainId},example.com`, + `${secondServerId},EXAMPLE.COM.`, + ].join("\\n"); + const result = await importDomainsPortfolio("user-1", { format: "csv", csv }); + expect(result.status).toBe(200); + expect((result.body as any).errors).toHaveLength(1); + expect(repository.insertDomain).toHaveBeenCalledTimes(1); + }); + + test("reports malformed CSV as a client error, not an unhandled exception", async () => { + const result = await importDomainsPortfolio("user-1", { + format: "csv", + csv: 'hostname,notes\\nexample.com,"unterminated', + }); + expect(result.status).toBe(400); + }); + + test("rejects invalid UUIDs", async () => { + const result = await importDomainsPortfolio("user-1", { + format: "json", + domains: [domain("example.com", "not-a-uuid")], + }); + expect(result.status).toBe(400); + expect(repository.insertDomain).not.toHaveBeenCalled(); + }); + + test("dry-run and actual import report the same plan", async () => { + const input = { format: "json", onDuplicateHostname: "error", domains: [domain("one.example"), domain("two.example", secondServerId)] }; + const dry = await importDomainsPortfolio("user-1", { ...input, dryRun: true }); + expect(repository.insertDomain).not.toHaveBeenCalled(); + const actual = await importDomainsPortfolio("user-1", { ...input, dryRun: false }); + expect({ + created: (dry.body as any).created, + updated: (dry.body as any).updated, + skipped: (dry.body as any).skipped, + errors: (dry.body as any).errors, + }).toEqual({ + created: (actual.body as any).created, + updated: (actual.body as any).updated, + skipped: (actual.body as any).skipped, + errors: (actual.body as any).errors, + }); + expect(repository.insertDomain).toHaveBeenCalledTimes(2); + }); +}); + +describe("infrastructure server portfolio imports", () => { + test("rejects duplicate server IDs in JSON payloads", async () => { + const result = await importInfrastructureServersPortfolio("user-1", { + format: "json", + servers: [server(), server()], + }); + expect(result.status).toBe(200); + expect((result.body as any).errors).toHaveLength(1); + expect(repository.insertInfrastructureServer).toHaveBeenCalledTimes(1); + }); + + test("allows different server IDs to share a hostname-free server name", async () => { + const result = await importInfrastructureServersPortfolio("user-1", { + format: "json", + servers: [server(validServerId, "app"), server(secondServerId, "app")], + }); + expect(result.status).toBe(200); + expect((result.body as any).created).toBe(2); + }); + + test("rejects invalid UUIDs", async () => { + const result = await importInfrastructureServersPortfolio("user-1", { + format: "json", + servers: [server("invalid")], + }); + expect(result.status).toBe(400); + expect(repository.insertInfrastructureServer).not.toHaveBeenCalled(); + }); + + test("dry-run and actual import report the same plan", async () => { + const input = { format: "json", servers: [server(), server(secondServerId, "worker")] }; + const dry = await importInfrastructureServersPortfolio("user-1", { ...input, dryRun: true }); + expect(repository.insertInfrastructureServer).not.toHaveBeenCalled(); + const actual = await importInfrastructureServersPortfolio("user-1", { ...input, dryRun: false }); + expect({ + created: (dry.body as any).created, + updated: (dry.body as any).updated, + skipped: (dry.body as any).skipped, + errors: (dry.body as any).errors, + }).toEqual({ + created: (actual.body as any).created, + updated: (actual.body as any).updated, + skipped: (actual.body as any).skipped, + errors: (actual.body as any).errors, + }); + expect(repository.insertInfrastructureServer).toHaveBeenCalledTimes(2); + }); + + test("imports servers with linked domains resolved from linkedHostnames", async () => { + repository.findDomainsByUserId.mockResolvedValue([ + { id: validDomainId, hostname: "example.com" }, + { id: secondServerId, hostname: "api.example.com" }, + ] as any); + const result = await importInfrastructureServersPortfolio("user-1", { + format: "json", + servers: [{ + ...server(), + linkedHostnames: ["EXAMPLE.COM.", "api.example.com"], + }], + }); + expect(result.status).toBe(200); + expect(repository.insertInfrastructureServer).toHaveBeenCalledWith( + expect.objectContaining({ linkedDomainIds: [validDomainId, secondServerId] }), + ); + }); +}); From dd7cf707926c8f374cf41212023f0319e3ac5dad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?shravan=20=7C=7C=20=E0=A4=B6=E0=A5=8D=E0=A4=B0van?= Date: Sun, 1 Nov 2026 16:25:49 +0530 Subject: [PATCH 2/7] fix: handle malformed CSV and duplicate domain IDs --- src/services/domainPortfolio.service.ts | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/src/services/domainPortfolio.service.ts b/src/services/domainPortfolio.service.ts index cd09b95..a749014 100644 --- a/src/services/domainPortfolio.service.ts +++ b/src/services/domainPortfolio.service.ts @@ -80,14 +80,14 @@ function rowToExportRecord(row: unknown, index: number): return { ok: false, error: `Row ${rowNum}: registrarName must be a string or null` }; } const regErr = validateRegistrarName(registrarName ?? null); - if (regErr) return { ok: false, error: `Row ${rowNum}: ${regErr}` }; + if (regErr) return { ok: false, error: `Row ${rowNum}: ${regErr}`); const notesRaw = o.notes; if (notesRaw !== undefined && notesRaw !== null && typeof notesRaw !== "string") { return { ok: false, error: `Row ${rowNum}: notes must be a string or null` }; } const notesErr = validateNotes(typeof notesRaw === "string" ? notesRaw : null); - if (notesErr) return { ok: false, error: `Row ${rowNum}: ${notesErr}` }; + if (notesErr) return { ok: false, error: `Row ${rowNum}: ${notesErr}`); const expiresParsed = parseOptionalExpiresAt(o.expiresAt); if (!expiresParsed.ok) return { ok: false, error: `Row ${rowNum}: ${expiresParsed.error}` }; @@ -253,7 +253,12 @@ export async function importDomainsPortfolio(userId: string | null, body: unknow if (typeof b.csv !== "string") { return { status: 400, body: { error: "csv must be a string when format is csv" } }; } - const table = parseCsv(b.csv.trim()); + let table: string[][]; + try { + table = parseCsv(b.csv.trim()); + } catch (e) { + return { status: 400, body: { error: e instanceof Error ? e.message : "Malformed CSV" } }; + } if (table.length < 2) { return { status: 400, body: { error: "CSV must include a header row and at least one data row" } }; } @@ -316,8 +321,8 @@ export async function importDomainsPortfolio(userId: string | null, body: unknow let skipped = 0; try { - // Track the state this import has planned/applied so dry runs and real imports - // agree when multiple rows target the same hostname or ID. + // Track IDs and hostnames encountered in this batch so repeated records + // follow the same duplicate policy during dry-runs and actual imports. const plannedByHostname = new Map(); const plannedById = new Map(); @@ -327,20 +332,16 @@ export async function importDomainsPortfolio(userId: string | null, body: unknow const dbByHostname = await findDomainByHostnameForUser(rec.hostname, userId); const dbById = rec.id ? await findDomainByIdForUser(rec.id, userId) : undefined; const plannedHostnameId = plannedByHostname.get(rec.hostname); - const plannedIdId = rec.id ? plannedById.get(rec.id) : undefined; + const plannedIdId = plannedById.get(rec.id); const byHostname = dbByHostname ?? (plannedHostnameId ? { id: plannedHostnameId } : undefined); const byId = dbById ?? (plannedIdId ? { id: plannedIdId } : undefined); if (byId && byHostname && byId.id !== byHostname.id) { - errors.push({ - row: rowNum, - message: "Import id and hostname refer to different existing records", - }); + errors.push({ row: rowNum, message: "Import id and hostname refer to different existing records" }); continue; } const existing = byId ?? byHostname; - if (existing) { if (dupPolicy === "skip") { skipped += 1; From 96300b88dfda60b04c273dc4c12d30c2e8af57a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?shravan=20=7C=7C=20=E0=A4=B6=E0=A5=8D=E0=A4=B0van?= Date: Sun, 1 Nov 2026 16:26:01 +0530 Subject: [PATCH 3/7] fix: return validation errors for malformed server CSV --- src/services/infrastructureServerPortfolio.service.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/services/infrastructureServerPortfolio.service.ts b/src/services/infrastructureServerPortfolio.service.ts index 6a4da12..f89b585 100644 --- a/src/services/infrastructureServerPortfolio.service.ts +++ b/src/services/infrastructureServerPortfolio.service.ts @@ -405,7 +405,12 @@ export async function importInfrastructureServersPortfolio(userId: string | null if (typeof b.csv !== "string") { return { status: 400, body: { error: "csv must be a string when format is csv" } }; } - const table = parseCsv(b.csv.trim()); + let table: string[][]; + try { + table = parseCsv(b.csv.trim()); + } catch (e) { + return { status: 400, body: { error: e instanceof Error ? e.message : "Malformed CSV" } }; + } if (table.length < 2) { return { status: 400, body: { error: "CSV must include a header row and at least one data row" } }; } From 83c8c43c863f4e1b6111a0b85e748126572e95c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?shravan=20=7C=7C=20=E0=A4=B6=E0=A5=8D=E0=A4=B0van?= Date: Sun, 1 Nov 2026 16:26:09 +0530 Subject: [PATCH 4/7] test: add bun test script --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 1cf59f6..17ca9d7 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,8 @@ "start": "bun src/index.ts", "version:patch": "npm version patch --no-git-tag-version", "version:minor": "npm version minor --no-git-tag-version", - "version:major": "npm version major --no-git-tag-version" + "version:major": "npm version major --no-git-tag-version", + "test": "bun test" }, "dependencies": { "@elysiajs/swagger": "^1.3.1", From 3ffe52e73429d96f2af28cd06caa9129a6b534a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?shravan=20=7C=7C=20=E0=A4=B6=E0=A5=8D=E0=A4=B0van?= Date: Sun, 1 Nov 2026 16:26:20 +0530 Subject: [PATCH 5/7] test: cover duplicate domain IDs and CSV rows --- src/__tests__/portfolio-import.test.ts | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/src/__tests__/portfolio-import.test.ts b/src/__tests__/portfolio-import.test.ts index 070e1b3..0288dc8 100644 --- a/src/__tests__/portfolio-import.test.ts +++ b/src/__tests__/portfolio-import.test.ts @@ -70,19 +70,36 @@ describe("domain portfolio imports", () => { test("rejects duplicate IDs and normalized hostnames in JSON payloads", async () => { const duplicateId = await importDomainsPortfolio("user-1", { format: "json", - domains: [domain("one.example"), domain("two.example")], + domains: [domain("one.example", validDomainId), domain("two.example", validDomainId)], }); - // IDs in imported records are also required to be unique within one payload. - expect(duplicateId.status).toBe(400); + expect(duplicateId.status).toBe(200); + expect((duplicateId.body as any).created).toBe(1); + expect((duplicateId.body as any).errors).toHaveLength(1); + resetMocks(); const duplicateHost = await importDomainsPortfolio("user-1", { format: "json", domains: [domain("Example.com", validDomainId), domain("example.com.", secondServerId)], }); expect(duplicateHost.status).toBe(200); + expect((duplicateHost.body as any).created).toBe(1); expect((duplicateHost.body as any).errors).toHaveLength(1); }); + test("rejects duplicate CSV records by ID and normalized hostname", async () => { + const csv = [ + "id,hostname", + `${validDomainId},example.com`, + `${validDomainId},other.example`, + `${secondServerId},EXAMPLE.COM.`, + ].join("\\n"); + const result = await importDomainsPortfolio("user-1", { format: "csv", csv }); + expect(result.status).toBe(200); + expect((result.body as any).created).toBe(1); + expect((result.body as any).errors).toHaveLength(2); + expect(repository.insertDomain).toHaveBeenCalledTimes(1); + }); + test("rejects duplicate CSV records using normalized hostname", async () => { const csv = [ "id,hostname", From 145a5b423b77ae6da8b7b7a3150b041489c19b72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?shravan=20=7C=7C=20=E0=A4=B6=E0=A5=8D=E0=A4=B0van?= Date: Sun, 1 Nov 2026 16:30:14 +0530 Subject: [PATCH 6/7] fix: correct domain portfolio validation syntax --- src/services/domainPortfolio.service.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/services/domainPortfolio.service.ts b/src/services/domainPortfolio.service.ts index a749014..e8e9f73 100644 --- a/src/services/domainPortfolio.service.ts +++ b/src/services/domainPortfolio.service.ts @@ -80,14 +80,14 @@ function rowToExportRecord(row: unknown, index: number): return { ok: false, error: `Row ${rowNum}: registrarName must be a string or null` }; } const regErr = validateRegistrarName(registrarName ?? null); - if (regErr) return { ok: false, error: `Row ${rowNum}: ${regErr}`); + if (regErr) return { ok: false, error: `Row ${rowNum}: ${regErr}` }; const notesRaw = o.notes; if (notesRaw !== undefined && notesRaw !== null && typeof notesRaw !== "string") { return { ok: false, error: `Row ${rowNum}: notes must be a string or null` }; } const notesErr = validateNotes(typeof notesRaw === "string" ? notesRaw : null); - if (notesErr) return { ok: false, error: `Row ${rowNum}: ${notesErr}`); + if (notesErr) return { ok: false, error: `Row ${rowNum}: ${notesErr}` }; const expiresParsed = parseOptionalExpiresAt(o.expiresAt); if (!expiresParsed.ok) return { ok: false, error: `Row ${rowNum}: ${expiresParsed.error}` }; From 09f2ecd256bc7bb9599c81d2f6ca0d961a91127b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?shravan=20=7C=7C=20=E0=A4=B6=E0=A5=8D=E0=A4=B0van?= Date: Sun, 1 Nov 2026 16:30:24 +0530 Subject: [PATCH 7/7] test: use actual newlines in CSV fixtures --- src/__tests__/portfolio-import.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/__tests__/portfolio-import.test.ts b/src/__tests__/portfolio-import.test.ts index 0288dc8..c1c3cd2 100644 --- a/src/__tests__/portfolio-import.test.ts +++ b/src/__tests__/portfolio-import.test.ts @@ -54,15 +54,15 @@ const server = (id = validServerId, name = "app") => ({ id, provider: "AWS", nam describe("portfolio CSV parser", () => { test("rejects unterminated quotes", () => { - expect(() => parseCsv('hostname,notes\\nexample.com,"unfinished')).toThrow(/unterminated/i); + expect(() => parseCsv('hostname,notes\nexample.com,"unfinished')).toThrow(/unterminated/i); }); test("rejects quotes embedded in unquoted fields", () => { - expect(() => parseCsv('hostname,notes\\nexample.com,bad"quote')).toThrow(/quote inside/i); + expect(() => parseCsv('hostname,notes\nexample.com,bad"quote')).toThrow(/quote inside/i); }); test("rejects characters after a closing quote", () => { - expect(() => parseCsv('hostname,notes\\nexample.com,"valid"x')).toThrow(/after closing quote/i); + expect(() => parseCsv('hostname,notes\nexample.com,"valid"x')).toThrow(/after closing quote/i); }); }); @@ -92,7 +92,7 @@ describe("domain portfolio imports", () => { `${validDomainId},example.com`, `${validDomainId},other.example`, `${secondServerId},EXAMPLE.COM.`, - ].join("\\n"); + ].join("\n"); const result = await importDomainsPortfolio("user-1", { format: "csv", csv }); expect(result.status).toBe(200); expect((result.body as any).created).toBe(1); @@ -105,7 +105,7 @@ describe("domain portfolio imports", () => { "id,hostname", `${validDomainId},example.com`, `${secondServerId},EXAMPLE.COM.`, - ].join("\\n"); + ].join("\n"); const result = await importDomainsPortfolio("user-1", { format: "csv", csv }); expect(result.status).toBe(200); expect((result.body as any).errors).toHaveLength(1); @@ -115,7 +115,7 @@ describe("domain portfolio imports", () => { test("reports malformed CSV as a client error, not an unhandled exception", async () => { const result = await importDomainsPortfolio("user-1", { format: "csv", - csv: 'hostname,notes\\nexample.com,"unterminated', + csv: 'hostname,notes\nexample.com,"unterminated', }); expect(result.status).toBe(400); });