nosible 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +438 -0
  2. package/dist/index.cjs +1841 -0
  3. package/dist/index.cjs.map +29 -0
  4. package/dist/index.js +1815 -0
  5. package/dist/index.js.map +29 -0
  6. package/package.json +63 -0
  7. package/src/api/api.test.ts +366 -0
  8. package/src/api/index.ts +179 -0
  9. package/src/api/schemas.ts +152 -0
  10. package/src/client.test.ts +685 -0
  11. package/src/client.ts +762 -0
  12. package/src/index.ts +4 -0
  13. package/src/scrape/types.ts +119 -0
  14. package/src/scrape/webPageData.test.ts +302 -0
  15. package/src/scrape/webPageData.ts +103 -0
  16. package/src/search/analyze.test.ts +396 -0
  17. package/src/search/analyze.ts +151 -0
  18. package/src/search/bulkSearch.ts +62 -0
  19. package/src/search/result.test.ts +423 -0
  20. package/src/search/result.ts +391 -0
  21. package/src/search/result.types.ts +32 -0
  22. package/src/search/resultFactory.ts +21 -0
  23. package/src/search/resultSet.io.test.ts +320 -0
  24. package/src/search/resultSet.test.ts +368 -0
  25. package/src/search/resultSet.ts +387 -0
  26. package/src/search/resultSet.types.ts +3 -0
  27. package/src/search/search.test.ts +299 -0
  28. package/src/search/search.ts +187 -0
  29. package/src/search/searchSet.io.test.ts +321 -0
  30. package/src/search/searchSet.ts +122 -0
  31. package/src/search/sqlFilter.test.ts +129 -0
  32. package/src/search/sqlFilter.ts +147 -0
  33. package/src/test-utils/mocks.ts +159 -0
  34. package/src/topicTrend/topicTrend.ts +53 -0
  35. package/src/utils/browser.test.ts +209 -0
  36. package/src/utils/browser.ts +21 -0
  37. package/src/utils/fernet.ts +47 -0
  38. package/src/utils/file.test.ts +81 -0
  39. package/src/utils/file.ts +195 -0
  40. package/src/utils/index.ts +7 -0
  41. package/src/utils/llm.test.ts +279 -0
  42. package/src/utils/llm.ts +244 -0
  43. package/src/utils/userPlan.test.ts +332 -0
  44. package/src/utils/userPlan.ts +211 -0
@@ -0,0 +1,129 @@
1
+ import {describe, expect, test} from "bun:test";
2
+ import {generateSqlFilter} from "./sqlFilter";
3
+
4
+ describe("generateSqlFilter", () => {
5
+ test("generates basic SQL without filters", () => {
6
+ const sql = generateSqlFilter({});
7
+ expect(sql).toBe(null);
8
+ });
9
+
10
+ test("includes published date range", () => {
11
+ const start = new Date("2023-01-01T00:00:00Z");
12
+ const end = new Date("2023-12-31T23:59:59Z");
13
+ const sql = generateSqlFilter({
14
+ publishStart: start,
15
+ publishEnd: end,
16
+ });
17
+ expect(sql).toBe(
18
+ "SELECT loc FROM engine WHERE published >= '2023-01-01T00:00:00.000Z' AND published <= '2023-12-31T23:59:59.000Z'"
19
+ );
20
+ });
21
+
22
+ test("includes published start only", () => {
23
+ const start = new Date("2023-01-01T00:00:00Z");
24
+ const sql = generateSqlFilter({publishStart: start});
25
+ expect(sql).toBe(
26
+ "SELECT loc FROM engine WHERE published >= '2023-01-01T00:00:00.000Z'"
27
+ );
28
+ });
29
+
30
+ test("includes published end only", () => {
31
+ const end = new Date("2023-12-31T23:59:59Z");
32
+ const sql = generateSqlFilter({publishEnd: end});
33
+ expect(sql).toBe(
34
+ "SELECT loc FROM engine WHERE published <= '2023-12-31T23:59:59.000Z'"
35
+ );
36
+ });
37
+
38
+ test("includes visited date range", () => {
39
+ const start = new Date("2023-01-01T00:00:00Z");
40
+ const end = new Date("2023-12-31T23:59:59Z");
41
+ const sql = generateSqlFilter({
42
+ visitedStart: start,
43
+ visitedEnd: end,
44
+ });
45
+ expect(sql).toBe(
46
+ "SELECT loc FROM engine WHERE visited >= '2023-01-01T00:00:00.000Z' AND visited <= '2023-12-31T23:59:59.000Z'"
47
+ );
48
+ });
49
+
50
+ test("includes certain true", () => {
51
+ const sql = generateSqlFilter({certain: true});
52
+ expect(sql).toBe("SELECT loc FROM engine WHERE certain = TRUE");
53
+ });
54
+
55
+ test("includes certain false", () => {
56
+ const sql = generateSqlFilter({certain: false});
57
+ expect(sql).toBe("SELECT loc FROM engine WHERE certain = FALSE");
58
+ });
59
+
60
+ test("includes netlocs with variants", () => {
61
+ const sql = generateSqlFilter({
62
+ includeNetlocs: ["example.com", "www.test.com"],
63
+ });
64
+ expect(sql).toBe(
65
+ "SELECT loc FROM engine WHERE netloc IN ('example.com', 'test.com', 'www.example.com', 'www.test.com')"
66
+ );
67
+ });
68
+
69
+ test("excludes netlocs with variants", () => {
70
+ const sql = generateSqlFilter({excludeNetlocs: ["example.com"]});
71
+ expect(sql).toBe(
72
+ "SELECT loc FROM engine WHERE netloc NOT IN ('example.com', 'www.example.com')"
73
+ );
74
+ });
75
+
76
+ test("includes companies", () => {
77
+ const sql = generateSqlFilter({includeCompanies: ["GOOG", "AAPL"]});
78
+ expect(sql).toBe(
79
+ "SELECT loc FROM engine WHERE (companies IS NOT NULL AND (ARRAY_CONTAINS(companies, 'GOOG') OR ARRAY_CONTAINS(companies, 'AAPL')))"
80
+ );
81
+ });
82
+
83
+ test("excludes companies", () => {
84
+ const sql = generateSqlFilter({excludeCompanies: ["GOOG"]});
85
+ expect(sql).toBe(
86
+ "SELECT loc FROM engine WHERE (companies IS NULL OR NOT (ARRAY_CONTAINS(companies, 'GOOG')))"
87
+ );
88
+ });
89
+
90
+ test("includes docs", () => {
91
+ const sql = generateSqlFilter({includeDocs: ["hash1", "hash2"]});
92
+ expect(sql).toBe(
93
+ "SELECT loc FROM engine WHERE doc_hash IN ('hash1', 'hash2')"
94
+ );
95
+ });
96
+
97
+ test("excludes docs", () => {
98
+ const sql = generateSqlFilter({excludeDocs: ["hash1"]});
99
+ expect(sql).toBe("SELECT loc FROM engine WHERE doc_hash NOT IN ('hash1')");
100
+ });
101
+
102
+ test("combines multiple filters", () => {
103
+ const sql = generateSqlFilter({
104
+ publishStart: new Date("2023-01-01T00:00:00Z"),
105
+ certain: true,
106
+ includeNetlocs: ["example.com"],
107
+ includeCompanies: ["GOOG"],
108
+ });
109
+ expect(sql).toContain("published >= '2023-01-01T00:00:00.000Z'");
110
+ expect(sql).toContain("certain = TRUE");
111
+ expect(sql).toContain("netloc IN ('example.com', 'www.example.com')");
112
+ expect(sql).toContain(
113
+ "companies IS NOT NULL AND (ARRAY_CONTAINS(companies, 'GOOG'))"
114
+ );
115
+ expect(sql).toMatch(/^SELECT loc FROM engine WHERE/);
116
+ });
117
+
118
+ test("throws error for too many include_netlocs", () => {
119
+ const many = Array.from({length: 51}, (_, i) => `netloc${i}`);
120
+ expect(() => generateSqlFilter({includeNetlocs: many})).toThrow("Too big");
121
+ });
122
+
123
+ test("throws error for too many exclude_companies", () => {
124
+ const many = Array.from({length: 51}, (_, i) => `company${i}`);
125
+ expect(() => generateSqlFilter({excludeCompanies: many})).toThrow(
126
+ "Too big"
127
+ );
128
+ });
129
+ });
@@ -0,0 +1,147 @@
1
+ import z from "zod";
2
+
3
+ export const sqlFilterSchema = z.object({
4
+ publishStart: z.date().optional(),
5
+ publishEnd: z.date().optional(),
6
+ visitedStart: z.date().optional(),
7
+ visitedEnd: z.date().optional(),
8
+ certain: z.boolean().optional(),
9
+ includeNetlocs: z.array(z.string()).max(50).optional(),
10
+ excludeNetlocs: z.array(z.string()).max(50).optional(),
11
+ includeCompanies: z.array(z.string()).max(50).optional(),
12
+ excludeCompanies: z.array(z.string()).max(50).optional(),
13
+ includeDocs: z.array(z.string()).max(50).optional(),
14
+ excludeDocs: z.array(z.string()).max(50).optional(),
15
+ });
16
+
17
+ export type SqlFilter = z.infer<typeof sqlFilterSchema>;
18
+
19
+ export function generateSqlFilter(input: SqlFilter): string | null {
20
+ const validatedInput = sqlFilterSchema.parse(input);
21
+ const {
22
+ publishStart,
23
+ publishEnd,
24
+ visitedStart,
25
+ visitedEnd,
26
+ certain,
27
+ includeNetlocs,
28
+ excludeNetlocs,
29
+ includeCompanies,
30
+ excludeCompanies,
31
+ includeDocs,
32
+ excludeDocs,
33
+ } = validatedInput;
34
+ const sql: string[] = ["SELECT loc FROM engine"];
35
+ const clauses: string[] = [];
36
+
37
+ // Published date range
38
+ if (publishStart || publishEnd) {
39
+ if (publishStart && publishEnd) {
40
+ clauses.push(
41
+ `published >= '${publishStart.toISOString()}' AND published <= '${publishEnd.toISOString()}'`
42
+ );
43
+ } else if (publishStart) {
44
+ clauses.push(`published >= '${publishStart.toISOString()}'`);
45
+ } else if (publishEnd) {
46
+ clauses.push(`published <= '${publishEnd.toISOString()}'`);
47
+ }
48
+ }
49
+
50
+ // Visited date range
51
+ if (visitedStart || visitedEnd) {
52
+ if (visitedStart && visitedEnd) {
53
+ clauses.push(
54
+ `visited >= '${visitedStart.toISOString()}' AND visited <= '${visitedEnd.toISOString()}'`
55
+ );
56
+ } else if (visitedStart) {
57
+ clauses.push(`visited >= '${visitedStart.toISOString()}'`);
58
+ } else if (visitedEnd) {
59
+ clauses.push(`visited <= '${visitedEnd.toISOString()}'`);
60
+ }
61
+ }
62
+
63
+ // Date certainty filter
64
+ if (certain === true) {
65
+ clauses.push("certain = TRUE");
66
+ } else if (certain === false) {
67
+ clauses.push("certain = FALSE");
68
+ }
69
+
70
+ // Include netlocs with both www/non-www variants
71
+ if (includeNetlocs) {
72
+ const variants = new Set<string>();
73
+ for (const n of includeNetlocs) {
74
+ variants.add(n);
75
+ if (n.startsWith("www.")) {
76
+ variants.add(n.slice(4));
77
+ } else {
78
+ variants.add("www." + n);
79
+ }
80
+ }
81
+ const inList = Array.from(variants)
82
+ .sort()
83
+ .map((v) => `'${v}'`)
84
+ .join(", ");
85
+ clauses.push(`netloc IN (${inList})`);
86
+ }
87
+
88
+ // Exclude netlocs with both www/non-www variants
89
+ if (excludeNetlocs) {
90
+ const variants = new Set<string>();
91
+ for (const n of excludeNetlocs) {
92
+ variants.add(n);
93
+ if (n.startsWith("www.")) {
94
+ variants.add(n.slice(4));
95
+ } else {
96
+ variants.add("www." + n);
97
+ }
98
+ }
99
+ const exList = Array.from(variants)
100
+ .sort()
101
+ .map((v) => `'${v}'`)
102
+ .join(", ");
103
+ clauses.push(`netloc NOT IN (${exList})`);
104
+ }
105
+
106
+ // Include companies
107
+ if (includeCompanies) {
108
+ const companyList = includeCompanies
109
+ .map((c) => `ARRAY_CONTAINS(companies, '${c}')`)
110
+ .join(" OR ");
111
+ clauses.push(`(companies IS NOT NULL AND (${companyList}))`);
112
+ }
113
+
114
+ // Exclude companies
115
+ if (excludeCompanies) {
116
+ const companyList = excludeCompanies
117
+ .map((c) => `ARRAY_CONTAINS(companies, '${c}')`)
118
+ .join(" OR ");
119
+ clauses.push(`(companies IS NULL OR NOT (${companyList}))`);
120
+ }
121
+
122
+ // Include docs
123
+ if (includeDocs) {
124
+ const docHashes = includeDocs.map((doc) => `'${doc}'`).join(", ");
125
+ clauses.push(`doc_hash IN (${docHashes})`);
126
+ }
127
+
128
+ // Exclude docs
129
+ if (excludeDocs) {
130
+ const docHashes = excludeDocs.map((doc) => `'${doc}'`).join(", ");
131
+ clauses.push(`doc_hash NOT IN (${docHashes})`);
132
+ }
133
+
134
+ // Join everything
135
+ if (clauses.length > 0) {
136
+ sql.push("WHERE " + clauses.join(" AND "));
137
+ }
138
+
139
+ if (sql.length === 1) {
140
+ return null;
141
+ }
142
+
143
+ const sqlFilter = sql.join(" ");
144
+
145
+ // TODO: Validate SQL
146
+ return sqlFilter;
147
+ }
@@ -0,0 +1,159 @@
1
+ import {vi} from "bun:test";
2
+ import type {
3
+ SearchResultSemantics,
4
+ SearchResponse,
5
+ SearchQuery,
6
+ } from "../api/schemas";
7
+
8
+ /**
9
+ * Shared mock objects and utilities for testing
10
+ * This file avoids circular dependencies by only importing types, not classes
11
+ */
12
+
13
+ // Mock semantics data
14
+ export const mockSemantics: SearchResultSemantics = {
15
+ origin_shard: 1,
16
+ chunks_total: 10,
17
+ chunks_matched: 5,
18
+ chunks_kept: 3,
19
+ similarity: 0.85,
20
+ bitstring: "101010",
21
+ };
22
+
23
+ // Mock search query
24
+ export const mockSearchQuery: SearchQuery = {
25
+ instruction: "Search for relevant articles",
26
+ question: "artificial intelligence machine learning",
27
+ expansions: ["AI", "ML", "neural networks"],
28
+ brand_safety: null,
29
+ language: null,
30
+ continent: null,
31
+ region: null,
32
+ country: null,
33
+ sector: null,
34
+ industry_group: null,
35
+ industry: null,
36
+ sub_industry: null,
37
+ iab_tier_1: null,
38
+ iab_tier_2: null,
39
+ iab_tier_3: null,
40
+ iab_tier_4: null,
41
+ algorithm: "hybrid-2",
42
+ n_results: 10,
43
+ n_contextify: 256,
44
+ n_probes: 5,
45
+ min_similarity: 0.7,
46
+ pass_similarity: 0.5,
47
+ must_exclude: [],
48
+ must_include: [],
49
+ shard_selector: "default",
50
+ shard_reranker: "default",
51
+ search_lang: "en",
52
+ search_words: {},
53
+ search_intents: {},
54
+ companies: [],
55
+ };
56
+
57
+ // Mock search response
58
+ export const mockSearchResponse: SearchResponse = {
59
+ url_hash: "abc123",
60
+ url: "https://example.com/article",
61
+ netloc: "example.com",
62
+ published: "2023-01-15",
63
+ visited: "2023-01-16",
64
+ language: "en",
65
+ author: "John Doe",
66
+ title: "Test Article Title",
67
+ description: "This is a test article description",
68
+ content:
69
+ "This is the full content of the test article. It contains multiple sentences and provides comprehensive information about the topic being discussed.",
70
+ best_chunk: "This is the most relevant chunk of content from the article.",
71
+ semantics: mockSemantics,
72
+ };
73
+
74
+ // Additional mock search responses for testing multiple results
75
+ export const mockSearchResponse2: SearchResponse = {
76
+ url_hash: "def456",
77
+ url: "https://example.com/article2",
78
+ netloc: "example.com",
79
+ published: "2023-02-20",
80
+ visited: "2023-02-21",
81
+ language: "en",
82
+ author: "Jane Smith",
83
+ title: "Test Article 2",
84
+ description: "This is test article 2",
85
+ content:
86
+ "This is the full content of test article 2 about artificial intelligence.",
87
+ best_chunk: "Most relevant chunk from article 2.",
88
+ semantics: {
89
+ ...mockSemantics,
90
+ origin_shard: 2,
91
+ similarity: 0.92,
92
+ },
93
+ };
94
+
95
+ // Combined result (search query + search response)
96
+ export const mockResult = {
97
+ ...mockSearchQuery,
98
+ ...mockSearchResponse,
99
+ };
100
+
101
+ /**
102
+ * Factory function to create a mock NosibleClient
103
+ * Returns an object that can be cast to NosibleClient type in tests
104
+ */
105
+ export const createMockClient = () => ({
106
+ fastSearch: vi.fn(),
107
+ scrapeUrl: vi.fn(),
108
+ bulkSearch: vi.fn(),
109
+ aiSearch: vi.fn(),
110
+ fastSearches: vi.fn(),
111
+ topicTrend: vi.fn(),
112
+ llmClient: {
113
+ chat: {
114
+ completions: {
115
+ create: vi.fn(),
116
+ },
117
+ },
118
+ },
119
+ expansionsModel: "openai/gpt-4o",
120
+ });
121
+
122
+ /**
123
+ * Create mock search response variations
124
+ */
125
+ export const createMockSearchResponse = (
126
+ overrides: Partial<SearchResponse>
127
+ ): SearchResponse => ({
128
+ ...mockSearchResponse,
129
+ ...overrides,
130
+ });
131
+
132
+ /**
133
+ * Create mock semantics variations
134
+ */
135
+ export const createMockSemantics = (
136
+ overrides: Partial<SearchResultSemantics>
137
+ ): SearchResultSemantics => ({
138
+ ...mockSemantics,
139
+ ...overrides,
140
+ });
141
+
142
+ /**
143
+ * Create multiple mock search responses for testing result sets
144
+ */
145
+ export const createMockSearchResponses = (count: number): SearchResponse[] => {
146
+ return Array.from({length: count}, (_, i) => ({
147
+ ...mockSearchResponse,
148
+ url_hash: `hash${i + 1}`,
149
+ url: `https://example.com/article${i + 1}`,
150
+ title: `Test Article ${i + 1}`,
151
+ description: `This is test article ${i + 1}`,
152
+ content: `This is the full content of test article ${i + 1}.`,
153
+ semantics: {
154
+ ...mockSemantics,
155
+ origin_shard: i + 1,
156
+ similarity: 0.85 - i * 0.05,
157
+ },
158
+ }));
159
+ };
@@ -0,0 +1,53 @@
1
+ export interface TopicTrendRes {
2
+ message: string;
3
+ query: Query;
4
+ response: {[key: string]: number};
5
+ }
6
+
7
+ export interface Query {
8
+ query: string;
9
+ brand_safety: null;
10
+ language: null;
11
+ continent: null;
12
+ region: null;
13
+ country: null;
14
+ sector: null;
15
+ industry_group: null;
16
+ industry: null;
17
+ sub_industry: null;
18
+ iab_tier_1: null;
19
+ iab_tier_2: null;
20
+ iab_tier_3: null;
21
+ iab_tier_4: null;
22
+ n_probes: number;
23
+ shard_selector: string;
24
+ shard_reranker: string;
25
+ search_lang: string;
26
+ search_words: SearchWords;
27
+ }
28
+
29
+ export interface SearchWords {
30
+ [key: string]: number;
31
+ }
32
+
33
+ // TODO: How to handle when API return 500 error
34
+
35
+ // TODO: Add logic to allow user to pass in start and end date and then remove these from the response
36
+ export class TopicTrend {
37
+ data: TopicTrendRes;
38
+
39
+ constructor(data: TopicTrendRes) {
40
+ this.data = data;
41
+ }
42
+
43
+ peakDate(): Date {
44
+ const maxValue = Math.max(...Object.values(this.data.response));
45
+ const date = Object.keys(this.data.response).find(
46
+ (key) => this.data.response[key] === maxValue || undefined
47
+ );
48
+ if (!date) {
49
+ throw new Error("No peak date found");
50
+ }
51
+ return new Date(date);
52
+ }
53
+ }
@@ -0,0 +1,209 @@
1
+ import {describe, test, expect, beforeEach, afterEach, mock} from "bun:test";
2
+ import {downloadBlob} from "./browser";
3
+
4
+ describe("downloadBlob", () => {
5
+ // Store original globals
6
+ let originalBlob: typeof Blob;
7
+ let originalURL: typeof URL;
8
+ let originalDocument: Document;
9
+
10
+ // Mock objects
11
+ let mockAnchor: any;
12
+ let mockBlob: any;
13
+ let mockObjectURL: string;
14
+ let createElementSpy: any;
15
+ let appendChildSpy: any;
16
+ let removeChildSpy: any;
17
+ let clickSpy: any;
18
+ let createObjectURLSpy: any;
19
+ let revokeObjectURLSpy: any;
20
+
21
+ beforeEach(() => {
22
+ // Save originals
23
+ originalBlob = globalThis.Blob;
24
+ originalURL = globalThis.URL;
25
+ originalDocument = globalThis.document as any;
26
+
27
+ // Setup mock anchor element
28
+ clickSpy = mock(() => {});
29
+ mockAnchor = {
30
+ href: "",
31
+ download: "",
32
+ click: clickSpy,
33
+ };
34
+
35
+ // Setup mock document
36
+ createElementSpy = mock(() => mockAnchor);
37
+ appendChildSpy = mock(() => {});
38
+ removeChildSpy = mock(() => {});
39
+
40
+ (globalThis as any).document = {
41
+ createElement: createElementSpy,
42
+ body: {
43
+ appendChild: appendChildSpy,
44
+ removeChild: removeChildSpy,
45
+ },
46
+ };
47
+
48
+ // Setup mock Blob
49
+ mockBlob = {type: ""};
50
+ (globalThis as any).Blob = mock((content: any[], options: any) => {
51
+ mockBlob.type = options.type;
52
+ return mockBlob;
53
+ });
54
+
55
+ // Setup mock URL
56
+ mockObjectURL = "blob:mock-url-12345";
57
+ createObjectURLSpy = mock(() => mockObjectURL);
58
+ revokeObjectURLSpy = mock(() => {});
59
+
60
+ (globalThis as any).URL = {
61
+ createObjectURL: createObjectURLSpy,
62
+ revokeObjectURL: revokeObjectURLSpy,
63
+ };
64
+ });
65
+
66
+ afterEach(() => {
67
+ // Restore originals
68
+ (globalThis as any).Blob = originalBlob;
69
+ (globalThis as any).URL = originalURL;
70
+ (globalThis as any).document = originalDocument;
71
+ });
72
+
73
+ test("should create a blob with correct data and MIME type", () => {
74
+ const testData = '{"name": "test"}';
75
+ const mimeType = "application/json";
76
+ const fileName = "test.json";
77
+
78
+ downloadBlob(testData, mimeType, fileName);
79
+
80
+ expect(globalThis.Blob).toHaveBeenCalledWith([testData], {type: mimeType});
81
+ });
82
+
83
+ test("should create an anchor element", () => {
84
+ downloadBlob("test data", "text/plain", "test.txt");
85
+
86
+ expect(createElementSpy).toHaveBeenCalledWith("a");
87
+ });
88
+
89
+ test("should set correct href and download attributes on anchor", () => {
90
+ const fileName = "data.csv";
91
+ const mimeType = "text/csv";
92
+ const data = "col1,col2\nval1,val2";
93
+
94
+ downloadBlob(data, mimeType, fileName);
95
+
96
+ expect(mockAnchor.href).toBe(mockObjectURL);
97
+ expect(mockAnchor.download).toBe(fileName);
98
+ });
99
+
100
+ test("should append anchor to body, click it, then remove it", () => {
101
+ downloadBlob("test", "text/plain", "test.txt");
102
+
103
+ expect(appendChildSpy).toHaveBeenCalledWith(mockAnchor);
104
+ expect(clickSpy).toHaveBeenCalled();
105
+ expect(removeChildSpy).toHaveBeenCalledWith(mockAnchor);
106
+ });
107
+
108
+ test("should create and revoke object URL", () => {
109
+ downloadBlob("test", "text/plain", "test.txt");
110
+
111
+ expect(createObjectURLSpy).toHaveBeenCalledWith(mockBlob);
112
+ expect(revokeObjectURLSpy).toHaveBeenCalledWith(mockObjectURL);
113
+ });
114
+
115
+ test("should handle JSON data correctly", () => {
116
+ const jsonData = JSON.stringify({users: [{id: 1, name: "Alice"}]});
117
+ const mimeType = "application/json";
118
+ const fileName = "users.json";
119
+
120
+ downloadBlob(jsonData, mimeType, fileName);
121
+
122
+ expect(globalThis.Blob).toHaveBeenCalledWith([jsonData], {type: mimeType});
123
+ expect(mockAnchor.download).toBe(fileName);
124
+ });
125
+
126
+ test("should handle CSV data correctly", () => {
127
+ const csvData = "name,age\nJohn,30\nJane,25";
128
+ const mimeType = "text/csv";
129
+ const fileName = "data.csv";
130
+
131
+ downloadBlob(csvData, mimeType, fileName);
132
+
133
+ expect(globalThis.Blob).toHaveBeenCalledWith([csvData], {type: mimeType});
134
+ expect(mockAnchor.download).toBe(fileName);
135
+ });
136
+
137
+ test("should handle empty data", () => {
138
+ const emptyData = "";
139
+ const mimeType = "text/plain";
140
+ const fileName = "empty.txt";
141
+
142
+ downloadBlob(emptyData, mimeType, fileName);
143
+
144
+ expect(globalThis.Blob).toHaveBeenCalledWith([emptyData], {type: mimeType});
145
+ expect(clickSpy).toHaveBeenCalled();
146
+ });
147
+
148
+ test("should handle special characters in filename", () => {
149
+ const fileName = "test file (1) [copy].txt";
150
+
151
+ downloadBlob("data", "text/plain", fileName);
152
+
153
+ expect(mockAnchor.download).toBe(fileName);
154
+ });
155
+
156
+ test("should execute operations in correct order", () => {
157
+ const callOrder: string[] = [];
158
+
159
+ const orderClickSpy = mock(() => {
160
+ callOrder.push("click");
161
+ });
162
+ const orderAnchor = {
163
+ href: "",
164
+ download: "",
165
+ click: orderClickSpy,
166
+ };
167
+
168
+ createElementSpy = mock(() => {
169
+ callOrder.push("createElement");
170
+ return orderAnchor;
171
+ });
172
+ appendChildSpy = mock(() => {
173
+ callOrder.push("appendChild");
174
+ });
175
+ removeChildSpy = mock(() => {
176
+ callOrder.push("removeChild");
177
+ });
178
+ createObjectURLSpy = mock(() => {
179
+ callOrder.push("createObjectURL");
180
+ return mockObjectURL;
181
+ });
182
+ revokeObjectURLSpy = mock(() => {
183
+ callOrder.push("revokeObjectURL");
184
+ });
185
+
186
+ (globalThis as any).document = {
187
+ createElement: createElementSpy,
188
+ body: {
189
+ appendChild: appendChildSpy,
190
+ removeChild: removeChildSpy,
191
+ },
192
+ };
193
+ (globalThis as any).URL = {
194
+ createObjectURL: createObjectURLSpy,
195
+ revokeObjectURL: revokeObjectURLSpy,
196
+ };
197
+
198
+ downloadBlob("test", "text/plain", "test.txt");
199
+
200
+ expect(callOrder).toEqual([
201
+ "createObjectURL",
202
+ "createElement",
203
+ "appendChild",
204
+ "click",
205
+ "removeChild",
206
+ "revokeObjectURL",
207
+ ]);
208
+ });
209
+ });