import { CouchDBChangesStream, CouchDBChange, CouchDBChangesOptions, } from "../src/CouchDBChangesStream"; import * as fc from "fast-check"; type NestedDocument = { _id: string; _rev: string; type: string; data: { nested: { field: string; value: number; }; }; }; type SimpleDocument = { _id: string; _rev: string; type: string; }; function createMockFetch( changes: CouchDBChange[], ): jest.Mock> { return jest.fn(async () => { const responseStream = changes .map((change) => JSON.stringify(change) + "\n") .join(""); const mockResponse: Partial = { ok: true, status: 200, body: new ReadableStream({ start(controller) { controller.enqueue(new TextEncoder().encode(responseStream)); controller.close(); }, }), }; return mockResponse as Response; }); } describe("CouchDBChangesStream", () => { it("returns changes in the correct order and type", async () => { const changes: CouchDBChange[] = [ { seq: 1, id: "doc1", changes: [{ rev: "1-abc" }], deleted: false }, { seq: 2, id: "doc2", changes: [{ rev: "1-def" }], deleted: false }, ]; const mockFetch = createMockFetch(changes); const stream = new CouchDBChangesStream("http://mock-couchdb", { feed: "continuous", fetch: mockFetch, }); const result: CouchDBChange[] = []; for await (const change of stream) { result.push(change); } expect(mockFetch).toHaveBeenCalledTimes(1); expect(result).toHaveLength(changes.length); expect(result.map((r) => r.seq)).toEqual(changes.map((c) => c.seq)); }); it("ensures pause between processing changes", async () => { const changes: CouchDBChange[] = [ { seq: 1, id: "doc1", changes: [{ rev: "1-abc" }], deleted: false }, { seq: 2, id: "doc2", changes: [{ rev: "1-def" }], deleted: false }, ]; const mockFetch = createMockFetch(changes); const stream = new CouchDBChangesStream("http://mock-couchdb", { feed: "continuous", fetch: mockFetch, }); const result: CouchDBChange[] = []; let processing = false; for await (const change of stream) { expect(processing).toBe(false); processing = true; // Simulate long processing time await new Promise((resolve) => setTimeout(resolve, 100)); result.push(change); processing = false; if (result.length === changes.length) { stream.stop(); } } expect(result).toHaveLength(changes.length); }); it("correctly stops when stop() or AbortController is called", async () => { const changes: CouchDBChange[] = [ { seq: 1, id: "doc1", changes: [{ rev: "1-abc" }], deleted: false }, { seq: 2, id: "doc2", changes: [{ rev: "1-def" }], deleted: false }, ]; const mockFetch = createMockFetch(changes); const stream = new CouchDBChangesStream("http://mock-couchdb", { feed: "continuous", fetch: mockFetch, }); const result: CouchDBChange[] = []; const iterator = stream[Symbol.asyncIterator](); // Read first change const next1 = await iterator.next(); expect(next1.done).toBe(false); result.push(next1.value); // Call stop() stream.stop(); // Attempt to read the next change const next2 = await iterator.next(); expect(next2.done).toBe(true); expect(result).toHaveLength(1); }); it("stops the stream when AbortController is aborted", async () => { const changes: CouchDBChange[] = [ { seq: 1, id: "doc1", changes: [{ rev: "1-abc" }], deleted: false }, { seq: 2, id: "doc2", changes: [{ rev: "1-def" }], deleted: false }, ]; const abortController = new AbortController(); const mockFetch = createMockFetch(changes); const stream = new CouchDBChangesStream("http://mock-couchdb", { feed: "continuous", fetch: mockFetch, abortController, }); const result: CouchDBChange[] = []; const iterator = stream[Symbol.asyncIterator](); // Read first change const next1 = await iterator.next(); expect(next1.done).toBe(false); result.push(next1.value); // Abort the signal abortController.abort(); // Attempt to read the next change const next2 = await iterator.next(); expect(next2.done).toBe(true); expect(result).toHaveLength(1); }); it("isolated test for filter parameter", async () => { const mockFetch = createMockFetch([]); const options = { filter: "", doc_ids: [], feed: "continuous", fetch: mockFetch, } as const; const stream = new CouchDBChangesStream( "http://mock-couchdb", options, ); const iterator = stream[Symbol.asyncIterator](); await iterator.next(); expect(mockFetch).toHaveBeenCalledTimes(1); const fetchCallArgs = mockFetch.mock.calls[0]; const fetchUrl = fetchCallArgs[0] as string; const url = new URL(fetchUrl); const params = url.searchParams; expect(params.has("filter")).toBe(false); }); it("correctly reflects passed parameters in the HTTP request", async () => { const mockFetch = createMockFetch([]); const options = { since: "now", filter: "test_filter", doc_ids: ["doc1", "doc2"], feed: "continuous", conflicts: true, descending: false, include_docs: true, limit: 5, fetch: mockFetch, } as const; const stream = new CouchDBChangesStream( "http://mock-couchdb", options, ); const iterator = stream[Symbol.asyncIterator](); await iterator.next(); expect(mockFetch).toHaveBeenCalledTimes(1); const fetchCallArgs = mockFetch.mock.calls[0]; const fetchUrl = fetchCallArgs[0] as string; const fetchOptions = fetchCallArgs[1] as RequestInit; if (!fetchOptions) { throw new Error("fetchOptions is undefined"); } const url = new URL(fetchUrl); const params = url.searchParams; expect(params.get("since")).toBe("now"); expect(params.get("filter")).toBe("test_filter"); expect(params.get("include_docs")).toBe("true"); expect(params.get("limit")).toBe("5"); expect(fetchOptions.method).toBe("POST"); expect(fetchOptions.body).toEqual( JSON.stringify({ doc_ids: ["doc1", "doc2"] }), ); }); it("correctly maps changes to the specified document type", async () => { const nestedChanges: CouchDBChange[] = [ { seq: 1, id: "nested_doc1", changes: [{ rev: "1-nested" }], doc: { _id: "nested_doc1", _rev: "1-nested", type: "nested", data: { nested: { field: "example", value: 42 } }, }, }, ]; const simpleChanges: CouchDBChange[] = [ { seq: 2, id: "simple_doc1", changes: [{ rev: "1-simple" }], doc: { _id: "simple_doc1", _rev: "1-simple", type: "simple", }, }, ]; const mockFetchNested = createMockFetch(nestedChanges); const mockFetchSimple = createMockFetch(simpleChanges); const nestedStream = new CouchDBChangesStream( "http://mock-couchdb", { include_docs: true, feed: "continuous", fetch: mockFetchNested }, ); const simpleStream = new CouchDBChangesStream( "http://mock-couchdb", { include_docs: true, feed: "continuous", fetch: mockFetchSimple }, ); const nestedResult: CouchDBChange[] = []; for await (const change of nestedStream) { nestedResult.push(change); } const simpleResult: CouchDBChange[] = []; for await (const change of simpleStream) { simpleResult.push(change); } // Validate nested document results expect(nestedResult).toHaveLength(nestedChanges.length); expect(nestedResult[0].doc).toEqual(nestedChanges[0].doc); // Validate simple document results expect(simpleResult).toHaveLength(simpleChanges.length); expect(simpleResult[0].doc).toEqual(simpleChanges[0].doc); }); it("excludes 'live' from URL parameters but affects behavior", async () => { const mockFetch = createMockFetch([]); const options: CouchDBChangesOptions = { since: "now", feed: "continuous", filter: "test_filter", live: true, // This is the key parameter we're testing include_docs: true, fetch: mockFetch, }; const stream = new CouchDBChangesStream( "http://mock-couchdb", options, ); const iterator = stream[Symbol.asyncIterator](); await iterator.next(); expect(mockFetch).toHaveBeenCalledTimes(1); const fetchCallArgs = mockFetch.mock.calls[0]; const fetchUrl = fetchCallArgs[0] as string; const fetchOptions = fetchCallArgs[1] as RequestInit; if (!fetchOptions) { throw new Error("fetchOptions is undefined"); } const url = new URL(fetchUrl); const params = url.searchParams; // Ensure 'live' does not appear as a query parameter expect(params.get("live")).toBeNull(); // Check that other parameters are still included expect(params.get("since")).toBe("now"); expect(params.get("filter")).toBe("test_filter"); expect(params.get("include_docs")).toBe("true"); // Validate the fetch method and body expect(fetchOptions.method).toBe("GET"); expect(fetchOptions.body).toBeUndefined(); // Further behavior checks can be added for live mode if necessary }); it("adds Basic Auth header when credentials are included in URL", async () => { const mockFetch = jest.fn( async ( input: RequestInfo | URL, init?: RequestInit, ): Promise => { return new Response(JSON.stringify({ results: [], last_seq: "0" })); }, ) as jest.MockedFunction; const options = { since: "now", include_docs: true, fetch: mockFetch, }; const stream = new CouchDBChangesStream( "https://admin:password@mock-couchdb", options, ); const iterator = stream[Symbol.asyncIterator](); await iterator.next(); expect(mockFetch).toHaveBeenCalledTimes(1); const fetchCallArgs = mockFetch.mock.calls[0]; const fetchOptions = fetchCallArgs[1] as RequestInit; expect(fetchOptions?.headers).toBeDefined(); expect( "Authorization" in fetchOptions?.headers! && fetchOptions?.headers!["Authorization"], ).toBe("Basic " + btoa("admin:password")); }); it("processes all changes and stops in 'normal' mode", async () => { // Mock fetch to return a finite response const changesResponse = { results: [ { seq: 1, id: "doc1", changes: [{ rev: "1-abc" }], deleted: false }, { seq: 2, id: "doc2", changes: [{ rev: "1-def" }], deleted: false }, ], last_seq: "2", }; const mockFetch = jest.fn( async ( input: RequestInfo | URL, init?: RequestInit, ): Promise => { return new Response(JSON.stringify(changesResponse), { status: 200, headers: { "Content-Type": "application/json" }, }); }, ); const stream = new CouchDBChangesStream("http://mock-couchdb", { feed: "normal", include_docs: true, fetch: mockFetch, }); const result: CouchDBChange[] = []; for await (const change of stream) { result.push(change); } // Check results expect(result).toHaveLength(2); expect(result[0].seq).toBe(1); expect(result[1].seq).toBe(2); // Check fetch call expect(mockFetch).toHaveBeenCalledTimes(1); const [url] = mockFetch.mock.calls[0]; expect(url).toContain("_changes"); }); it("processes changes in 'longpoll' mode and continues waiting for updates", async () => { // Mock fetch to return different responses on each call const firstResponse = { results: [ { seq: 1, id: "doc1", changes: [{ rev: "1-abc" }], deleted: false }, ], last_seq: "1", }; const secondResponse = { results: [ { seq: 2, id: "doc2", changes: [{ rev: "1-def" }], deleted: false }, ], last_seq: "2", }; const mockFetch = jest .fn() .mockImplementationOnce(async () => { return new Response(JSON.stringify(firstResponse), { status: 200, headers: { "Content-Type": "application/json" }, }); }) .mockImplementationOnce(async () => { return new Response(JSON.stringify(secondResponse), { status: 200, headers: { "Content-Type": "application/json" }, }); }) as jest.MockedFunction; const stream = new CouchDBChangesStream("http://mock-couchdb", { feed: "longpoll", include_docs: true, live: true, fetch: mockFetch, }); const result: CouchDBChange[] = []; const iterator = stream[Symbol.asyncIterator](); // Read first set of changes result.push((await iterator.next()).value); // Read second set of changes result.push((await iterator.next()).value); // Stop the stream stream.stop(); // Check results expect(result).toHaveLength(2); expect(result[0].seq).toBe(1); expect(result[1].seq).toBe(2); // Check fetch calls expect(mockFetch).toHaveBeenCalledTimes(2); }); });