diff --git a/test/kijiji-core.test.ts b/test/kijiji-core.test.ts new file mode 100644 index 0000000..62a8f5b --- /dev/null +++ b/test/kijiji-core.test.ts @@ -0,0 +1,162 @@ +import { describe, test, expect } from "bun:test"; +import { + resolveLocationId, + resolveCategoryId, + buildSearchUrl, + HttpError, + NetworkError, + ParseError, + RateLimitError, + ValidationError +} from "../src/kijiji"; + +describe("Location and Category Resolution", () => { + describe("resolveLocationId", () => { + test("should return numeric IDs as-is", () => { + expect(resolveLocationId(1700272)).toBe(1700272); + expect(resolveLocationId(0)).toBe(0); + }); + + test("should resolve string location names", () => { + expect(resolveLocationId("canada")).toBe(0); + expect(resolveLocationId("ontario")).toBe(9004); + expect(resolveLocationId("toronto")).toBe(1700273); + expect(resolveLocationId("gta")).toBe(1700272); + }); + + test("should handle case insensitive matching", () => { + expect(resolveLocationId("Canada")).toBe(0); + expect(resolveLocationId("ONTARIO")).toBe(9004); + }); + + test("should default to Canada for unknown locations", () => { + expect(resolveLocationId("unknown")).toBe(0); + expect(resolveLocationId("")).toBe(0); + }); + + test("should handle undefined input", () => { + expect(resolveLocationId(undefined)).toBe(0); + }); + }); + + describe("resolveCategoryId", () => { + test("should return numeric IDs as-is", () => { + expect(resolveCategoryId(132)).toBe(132); + expect(resolveCategoryId(0)).toBe(0); + }); + + test("should resolve string category names", () => { + expect(resolveCategoryId("all")).toBe(0); + expect(resolveCategoryId("phones")).toBe(132); + expect(resolveCategoryId("electronics")).toBe(29659001); + expect(resolveCategoryId("buy-sell")).toBe(10); + }); + + test("should handle case insensitive matching", () => { + expect(resolveCategoryId("All")).toBe(0); + expect(resolveCategoryId("PHONES")).toBe(132); + }); + + test("should default to all categories for unknown categories", () => { + expect(resolveCategoryId("unknown")).toBe(0); + expect(resolveCategoryId("")).toBe(0); + }); + + test("should handle undefined input", () => { + expect(resolveCategoryId(undefined)).toBe(0); + }); + }); +}); + +describe("URL Construction", () => { + describe("buildSearchUrl", () => { + test("should build basic search URL", () => { + const url = buildSearchUrl("iphone", { + location: 1700272, + category: 132, + sortBy: 'relevancy', + sortOrder: 'desc', + }); + + expect(url).toContain("b-buy-sell/canada/iphone/k0c132l1700272"); + expect(url).toContain("sort=relevancyDesc"); + expect(url).toContain("order=DESC"); + }); + + test("should handle pagination", () => { + const url = buildSearchUrl("iphone", { + location: 1700272, + category: 132, + page: 2, + }); + + expect(url).toContain("&page=2"); + }); + + test("should handle different sort options", () => { + const dateUrl = buildSearchUrl("iphone", { + sortBy: 'date', + sortOrder: 'asc', + }); + expect(dateUrl).toContain("sort=DATE"); + expect(dateUrl).toContain("order=ASC"); + + const priceUrl = buildSearchUrl("iphone", { + sortBy: 'price', + sortOrder: 'desc', + }); + expect(priceUrl).toContain("sort=PRICE"); + expect(priceUrl).toContain("order=DESC"); + }); + + test("should handle string location/category inputs", () => { + const url = buildSearchUrl("iphone", { + location: "toronto", + category: "phones", + }); + + expect(url).toContain("k0c132l1700273"); // phones + toronto + }); + }); +}); + +describe("Error Classes", () => { + test("HttpError should store status and URL", () => { + const error = new HttpError("Not found", 404, "https://example.com"); + expect(error.message).toBe("Not found"); + expect(error.status).toBe(404); + expect(error.url).toBe("https://example.com"); + expect(error.name).toBe("HttpError"); + }); + + test("NetworkError should store URL and cause", () => { + const cause = new Error("Connection failed"); + const error = new NetworkError("Network error", "https://example.com", cause); + expect(error.message).toBe("Network error"); + expect(error.url).toBe("https://example.com"); + expect(error.cause).toBe(cause); + expect(error.name).toBe("NetworkError"); + }); + + test("ParseError should store data", () => { + const data = { invalid: "json" }; + const error = new ParseError("Invalid JSON", data); + expect(error.message).toBe("Invalid JSON"); + expect(error.data).toBe(data); + expect(error.name).toBe("ParseError"); + }); + + test("RateLimitError should store URL and reset time", () => { + const error = new RateLimitError("Rate limited", "https://example.com", 60); + expect(error.message).toBe("Rate limited"); + expect(error.url).toBe("https://example.com"); + expect(error.resetTime).toBe(60); + expect(error.name).toBe("RateLimitError"); + }); + + test("ValidationError should work without field", () => { + const error = new ValidationError("Invalid value"); + expect(error.message).toBe("Invalid value"); + expect(error.name).toBe("ValidationError"); + }); +}); \ No newline at end of file diff --git a/test/kijiji-integration.test.ts b/test/kijiji-integration.test.ts new file mode 100644 index 0000000..32a2704 --- /dev/null +++ b/test/kijiji-integration.test.ts @@ -0,0 +1,337 @@ +import { describe, test, expect, beforeEach, afterEach, mock } from "bun:test"; +import { extractApolloState, parseSearch, parseDetailedListing } from "../src/kijiji"; + +// Mock fetch globally +const originalFetch = global.fetch; + +describe("HTML Parsing Integration", () => { + beforeEach(() => { + // Mock fetch for all tests + global.fetch = mock(() => { + throw new Error("fetch should be mocked in individual tests"); + }); + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + describe("extractApolloState", () => { + test("should extract Apollo state from valid HTML", () => { + const mockHtml = ''; + + const result = extractApolloState(mockHtml); + expect(result).toEqual({ + ROOT_QUERY: { test: "value" } + }); + }); + + test("should return null for HTML without Apollo state", () => { + const mockHtml = 'No data here'; + const result = extractApolloState(mockHtml); + expect(result).toBeNull(); + }); + + test("should return null for malformed JSON", () => { + const mockHtml = ''; + + const result = extractApolloState(mockHtml); + expect(result).toBeNull(); + }); + + test("should handle missing __NEXT_DATA__ element", () => { + const mockHtml = '
Content
'; + const result = extractApolloState(mockHtml); + expect(result).toBeNull(); + }); + }); + + describe("parseSearch", () => { + test("should parse search results from HTML", () => { + const mockHtml = ` + + + + `; + + const results = parseSearch(mockHtml, "https://www.kijiji.ca"); + expect(results).toHaveLength(2); + expect(results[0]).toEqual({ + name: "iPhone 13 Pro", + listingLink: "https://www.kijiji.ca/v-iphone/k0l0" + }); + expect(results[1]).toEqual({ + name: "Samsung Galaxy", + listingLink: "https://www.kijiji.ca/v-samsung/k0l0" + }); + }); + + test("should handle absolute URLs", () => { + const mockHtml = ` + + + + `; + + const results = parseSearch(mockHtml, "https://www.kijiji.ca"); + expect(results[0].listingLink).toBe("https://www.kijiji.ca/v-iphone/k0l0"); + }); + + test("should filter out invalid listings", () => { + const mockHtml = ` + + + + `; + + const results = parseSearch(mockHtml, "https://www.kijiji.ca"); + expect(results).toHaveLength(1); + expect(results[0].name).toBe("iPhone 13 Pro"); + }); + + test("should return empty array for invalid HTML", () => { + const results = parseSearch("Invalid", "https://www.kijiji.ca"); + expect(results).toEqual([]); + }); + }); + + describe("parseDetailedListing", () => { + test("should parse detailed listing with all fields", async () => { + const mockHtml = ` + + + + `; + + const result = await parseDetailedListing(mockHtml, "https://www.kijiji.ca"); + expect(result).toEqual({ + url: "https://www.kijiji.ca/v-iphone-13-pro/k0l0", + title: "iPhone 13 Pro 256GB", + description: "Excellent condition iPhone 13 Pro", + listingPrice: { + amountFormatted: "$800.00", + cents: 80000, + currency: "CAD" + }, + listingType: "OFFER", + listingStatus: "ACTIVE", + creationDate: "2024-01-15T10:00:00.000Z", + endDate: "2025-01-15T10:00:00.000Z", + numberOfViews: 150, + address: "Toronto, ON", + images: [ + "https://media.kijiji.ca/api/v1/image1.jpg", + "https://media.kijiji.ca/api/v1/image2.jpg" + ], + categoryId: 132, + adSource: "ORGANIC", + flags: { + topAd: false, + priceDrop: true + }, + attributes: { + forsaleby: ["ownr"], + phonecarrier: ["unlocked"] + }, + location: { + id: 1700273, + name: "Toronto", + coordinates: { + latitude: 43.6532, + longitude: -79.3832 + } + }, + sellerInfo: { + posterId: "user123", + rating: 4.8 + } + }); + }); + + test("should return null for contact-based pricing", async () => { + const mockHtml = ` + + + + `; + + const result = await parseDetailedListing(mockHtml, "https://www.kijiji.ca"); + expect(result).toBeNull(); + }); + + test("should handle missing optional fields", async () => { + const mockHtml = ` + + + + `; + + const result = await parseDetailedListing(mockHtml, "https://www.kijiji.ca"); + expect(result).toEqual({ + url: "https://www.kijiji.ca/v-iphone/k0l0", + title: "iPhone 13", + description: undefined, + listingPrice: { + amountFormatted: "$500.00", + cents: 50000, + currency: undefined + }, + listingType: undefined, + listingStatus: undefined, + creationDate: undefined, + endDate: undefined, + numberOfViews: undefined, + address: null, + images: [], + categoryId: 0, + adSource: "UNKNOWN", + flags: { + topAd: false, + priceDrop: false + }, + attributes: {}, + location: { + id: 0, + name: "Unknown", + coordinates: undefined + }, + sellerInfo: undefined + }); + }); + }); +}); \ No newline at end of file diff --git a/test/kijiji-utils.test.ts b/test/kijiji-utils.test.ts new file mode 100644 index 0000000..0a77713 --- /dev/null +++ b/test/kijiji-utils.test.ts @@ -0,0 +1,54 @@ +import { describe, test, expect, beforeEach, afterEach } from "bun:test"; +import { slugify, formatCentsToCurrency } from "../src/kijiji"; + +describe("Utility Functions", () => { + describe("slugify", () => { + test("should convert basic strings to slugs", () => { + expect(slugify("Hello World")).toBe("hello-world"); + expect(slugify("iPhone 13 Pro")).toBe("iphone-13-pro"); + }); + + test("should handle special characters", () => { + expect(slugify("Café & Restaurant")).toBe("cafe-restaurant"); + expect(slugify("100% New")).toBe("100-new"); + }); + + test("should handle empty and edge cases", () => { + expect(slugify("")).toBe(""); + expect(slugify(" ")).toBe("-"); + expect(slugify("---")).toBe("-"); + }); + + test("should preserve numbers and valid characters", () => { + expect(slugify("iPhone 13")).toBe("iphone-13"); + expect(slugify("item123")).toBe("item123"); + }); + }); + + describe("formatCentsToCurrency", () => { + test("should format valid cent values", () => { + expect(formatCentsToCurrency(100)).toBe("$1.00"); + expect(formatCentsToCurrency(1999)).toBe("$19.99"); + expect(formatCentsToCurrency(0)).toBe("$0.00"); + }); + + test("should handle string inputs", () => { + expect(formatCentsToCurrency("100")).toBe("$1.00"); + expect(formatCentsToCurrency("1999")).toBe("$19.99"); + }); + + test("should handle null/undefined inputs", () => { + expect(formatCentsToCurrency(null)).toBe(""); + expect(formatCentsToCurrency(undefined)).toBe(""); + }); + + test("should handle invalid inputs", () => { + expect(formatCentsToCurrency("invalid")).toBe(""); + expect(formatCentsToCurrency(Number.NaN)).toBe(""); + }); + + test("should use en-US locale formatting", () => { + expect(formatCentsToCurrency(123456)).toBe("$1,234.56"); + }); + }); +}); \ No newline at end of file