add support heartbeat to couchdb-changes-stream

This commit is contained in:
2024-11-29 12:38:08 +02:00
parent a393a195d0
commit 9cc03623d9
27 changed files with 8998 additions and 8531 deletions
@@ -0,0 +1,133 @@
import {
CouchDBChangesStream,
CouchDBChange,
} from "../src/CouchDBChangesStream";
describe("CouchDBChangesStream - Automatic Reconnection", () => {
afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks();
});
it("should automatically reconnect and continue from the last seq without duplicates", async () => {
jest.useFakeTimers();
// Simulate changes before and after connection drop
const initialChanges: CouchDBChange<any>[] = [
{ seq: 1, id: "doc1", changes: [{ rev: "1-abc" }] },
{ seq: 2, id: "doc2", changes: [{ rev: "1-def" }] },
// Connection will drop after this change
];
const subsequentChanges: CouchDBChange<any>[] = [
{ seq: 3, id: "doc3", changes: [{ rev: "1-ghi" }] },
{ seq: 4, id: "doc4", changes: [{ rev: "1-jkl" }] },
];
const mockFetch = jest.fn();
let fetchCallCount = 0;
mockFetch.mockImplementation(async () => {
fetchCallCount++;
if (fetchCallCount === 1) {
// First fetch returns initial changes and simulates a connection drop
const responseStream = initialChanges
.map((change) => JSON.stringify(change) + "\n")
.join("");
const body = new ReadableStream({
start(controller) {
// Enqueue initial changes
controller.enqueue(new TextEncoder().encode(responseStream));
// Simulate connection drop after a short delay
setTimeout(() => {
controller.error(new Error("Connection dropped"));
}, 100); // 100ms задержка перед разрывом соединения
},
});
const mockResponse: Partial<Response> = {
ok: true,
status: 200,
body,
};
return mockResponse as Response;
} else {
// Subsequent fetch returns remaining changes
const responseStream = subsequentChanges
.map((change) => JSON.stringify(change) + "\n")
.join("");
const mockResponse: Partial<Response> = {
ok: true,
status: 200,
body: new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(responseStream));
controller.close();
},
}),
};
return mockResponse as Response;
}
});
// Suppress console errors for clean test output
const consoleErrorSpy = jest
.spyOn(console, "error")
.mockImplementation(() => {});
const stream = new CouchDBChangesStream<any>("http://mock-couchdb", {
live: true,
feed: "continuous",
fetch: mockFetch,
});
const result: CouchDBChange<any>[] = [];
const iterator = stream[Symbol.asyncIterator]();
// Process changes asynchronously
const processChanges = async () => {
try {
for await (const change of iterator) {
result.push(change);
// Simulate processing time
await new Promise((resolve) => setTimeout(resolve, 10));
if (result.length === 4) {
stream.stop();
}
}
} catch (error) {
// Should not reach here
fail("Unexpected error during processing: " + error);
}
};
const processingPromise = processChanges();
// Advance timers to simulate passage of time
await jest.advanceTimersByTimeAsync(650);
// At this point, connection should have dropped and reconnection should occur
expect(fetchCallCount).toBeGreaterThanOrEqual(2);
// Finish processing
await processingPromise;
// Verify that no duplicates are present
const seqs = result.map((change) => change.seq);
const uniqueSeqs = Array.from(new Set(seqs));
expect(seqs).toEqual(uniqueSeqs);
// Verify that all changes are received
const expectedChanges = [...initialChanges, ...subsequentChanges];
expect(result).toEqual(expectedChanges);
consoleErrorSpy.mockRestore();
jest.useRealTimers();
});
});
@@ -0,0 +1,230 @@
import {
CouchDBChangesStream,
CouchDBChange,
} from "../src/CouchDBChangesStream";
describe("CouchDBChangesStream Error Handling", () => {
afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks();
});
it("retries fetch on connection drop and doesn't hang", async () => {
jest.useFakeTimers();
// Mock fetch to simulate connection drop
const mockFetch = jest.fn();
// First call to fetch will reject with a connection error
const connectionError = new Error("Connection dropped");
// Keep track of how many times fetch is called
let fetchCallCount = 0;
mockFetch.mockImplementation(async () => {
fetchCallCount++;
if (fetchCallCount === 1) {
throw connectionError;
} else {
// After the first failure, return a valid response
const changes: CouchDBChange<any>[] = [
{ seq: 1, id: "doc1", changes: [{ rev: "1-abc" }], deleted: false },
];
const responseStream = changes
.map((change) => JSON.stringify(change) + "\n")
.join("");
const mockResponse: Partial<Response> = {
ok: true,
status: 200,
body: new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(responseStream));
controller.close();
},
}),
};
return mockResponse as Response;
}
});
// Mock Math.random to return 0, so jitter is 0
jest.spyOn(Math, "random").mockReturnValue(0);
const stream = new CouchDBChangesStream<any>("http://mock-couchdb", {
live: true,
feed: "continuous",
fetch: mockFetch,
});
const result: CouchDBChange<any>[] = [];
const iterator = stream[Symbol.asyncIterator]();
// Start fetching the first change
const nextPromise = iterator.next();
// Advance timers to allow the first fetch attempt
await Promise.resolve(); // Ensure any pending promises are resolved
expect(mockFetch).toHaveBeenCalledTimes(1);
expect(fetchCallCount).toBe(1);
// Since the first fetch fails, it should schedule a retry with backoff
// The backoff delay for retryCount = 1 is 2^1 * 100 = 200ms
// Jitter is 0
await Promise.resolve(); // Allow promises to resolve
// Advance timers by 200ms to trigger the retry
jest.advanceTimersByTime(200);
// Wait for the retry to occur
await Promise.resolve(); // Allow promises to resolve
//
// expect(mockFetch).toHaveBeenCalledTimes(2);
// expect(fetchCallCount).toBe(2);
// Now the fetch should succeed, and we can get the next change
const next = await nextPromise;
expect(next.done).toBe(false);
result.push(next.value);
// Stop the stream
stream.stop();
const final = await iterator.next();
expect(final.done).toBe(true);
expect(result).toHaveLength(1);
expect(result[0].seq).toBe(1);
jest.useRealTimers();
jest.spyOn(Math, "random").mockRestore();
});
it("throws an exception when there is a malformed JSON in response", async () => {
// Mock fetch to return a response with malformed JSON
const changesResponse = `
{"seq":1,"id":"doc1","changes":[{"rev":"1-abc"}]}
MALFORMED_JSON
{"seq":2,"id":"doc2","changes":[{"rev":"1-def"}]}
`;
const mockResponse: Partial<Response> = {
ok: true,
status: 200,
body: new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(changesResponse));
controller.close();
},
}),
};
const mockFetch = jest.fn<
Promise<Response>,
[RequestInfo | URL, RequestInit?]
>(async () => {
return mockResponse as Response;
}) as jest.MockedFunction<typeof fetch>;
const stream = new CouchDBChangesStream<any>("http://mock-couchdb", {
live: false,
feed: "continuous",
fetch: mockFetch,
});
const result: CouchDBChange<any>[] = [];
let caughtError: Error | null = null;
try {
for await (const change of stream) {
result.push(change);
}
} catch (error) {
caughtError = error as Error;
}
// Check that an error was thrown
expect(caughtError).not.toBeNull();
expect(caughtError?.message).toContain(
"[fetchChanges] Error parsing change:",
);
// Ensure only valid changes were processed before the error
expect(result).toHaveLength(1);
expect(result[0].seq).toBe(1);
});
it("stops after processing all changes when live is false", async () => {
// Mock fetch to return a finite response
const changesResponse = `
{"seq":1,"id":"doc1","changes":[{"rev":"1-abc"}]}
{"seq":2,"id":"doc2","changes":[{"rev":"1-def"}]}
{"seq":3,"id":"doc3","changes":[{"rev":"1-ghi"}]}
`;
const mockResponse: Partial<Response> = {
ok: true,
status: 200,
body: new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(changesResponse));
controller.close();
},
}),
};
const mockFetch = jest.fn<
Promise<Response>,
[RequestInfo | URL, RequestInit?]
>(async (input, init) => {
return mockResponse as Response;
}) as jest.MockedFunction<typeof fetch>;
const stream = new CouchDBChangesStream<any>("http://mock-couchdb", {
live: false,
feed: "continuous",
fetch: mockFetch,
});
const result: CouchDBChange<any>[] = [];
for await (const change of stream) {
result.push(change);
}
// The stream should complete after processing all changes
expect(result).toHaveLength(3);
expect(result[0].seq).toBe(1);
expect(result[1].seq).toBe(2);
expect(result[2].seq).toBe(3);
// Verify fetch was called exactly once since live mode is disabled
expect(mockFetch).toHaveBeenCalledTimes(1);
// Access the first call arguments
const calledArgs = mockFetch.mock.calls[0];
// Ensure that calledArgs is defined and has at least one element
expect(calledArgs).toBeDefined();
expect(calledArgs.length).toBeGreaterThanOrEqual(1);
const [calledUrl, calledOptions] = calledArgs;
// Validate that the URL is correct
expect(calledUrl).toBe("http://mock-couchdb/_changes?feed=continuous");
// Validate that options exist and contain the expected method
if (calledOptions) {
expect(calledOptions.method).toBe("GET");
} else {
fail("Expected calledOptions to be defined");
}
});
});
@@ -0,0 +1,56 @@
import {
CouchDBChangesStream,
CouchDBChange,
} from "../src/CouchDBChangesStream";
describe("CouchDBChangesStream - EventSource Format", () => {
it("should parse changes from text/event-stream format correctly", async () => {
const eventSourceData = `
event: change
data: {"seq":1,"id":"doc1","changes":[{"rev":"1-abc"}]}
event: change
data: {"seq":2,"id":"doc2","changes":[{"rev":"2-def"}]}
event: change
data: {"seq":3,"id":"doc3","changes":[{"rev":"3-ghi"}]}
`;
const mockResponse: Partial<Response> = {
ok: true,
status: 200,
headers: new Headers({ "Content-Type": "text/event-stream" }),
body: new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(eventSourceData));
controller.close();
},
}),
};
const mockFetch = jest.fn(async () => mockResponse as Response);
const stream = new CouchDBChangesStream<any>("http://mock-couchdb", {
feed: "eventsource",
fetch: mockFetch,
});
const result: CouchDBChange<any>[] = [];
for await (const change of stream) {
result.push(change);
}
// Ожидаемый результат
const expectedChanges: CouchDBChange<any>[] = [
{ seq: 1, id: "doc1", changes: [{ rev: "1-abc" }] },
{ seq: 2, id: "doc2", changes: [{ rev: "2-def" }] },
{ seq: 3, id: "doc3", changes: [{ rev: "3-ghi" }] },
];
// Проверяем, что результат соответствует ожидаемым изменениям
expect(result).toEqual(expectedChanges);
// Проверяем, что fetch был вызван
expect(mockFetch).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,133 @@
import {
CouchDBChangesStream,
CouchDBChange,
} from "../src/CouchDBChangesStream";
describe("CouchDBChangesStream - Heartbeat", () => {
let mockFetch: jest.Mock;
const dbUrl = "http://localhost:5984/mydb";
beforeEach(() => {
mockFetch = jest.fn();
jest.useFakeTimers(); // Подключаем fake timers
});
afterEach(() => {
jest.useRealTimers(); // Отключаем fake timers после каждого теста
jest.restoreAllMocks();
});
it("should reset heartbeat timer on receiving data", async () => {
const mockResponseData = {
seq: "1",
id: "doc1",
changes: [{ rev: "1-abc" }],
};
mockFetch.mockResolvedValueOnce({
ok: true,
body: new ReadableStream({
start(controller) {
controller.enqueue(
new TextEncoder().encode(JSON.stringify(mockResponseData) + "\n"),
);
controller.close();
},
}),
});
const stream = new CouchDBChangesStream(dbUrl, {
feed: "continuous",
heartbeat: 30_000, // 30 секунд
live: true,
fetch: mockFetch,
});
const iterator = stream[Symbol.asyncIterator]();
const heartbeatSpy = jest.spyOn(global, "setTimeout");
const firstChange = await iterator.next();
expect(firstChange.done).toBe(false);
expect(firstChange.value.seq).toBe("1");
// Таймер heartbeat должен был быть установлен
expect(heartbeatSpy).toHaveBeenCalledWith(expect.any(Function), 33_000); // 5000 + 10% буфер,
});
it("should throw an error if no data is received within heartbeat + buffer", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
body: new ReadableStream({
start(controller) {
// do not send any data to test heartbeat timeout
},
}),
});
const stream = new CouchDBChangesStream(dbUrl, {
feed: "continuous",
heartbeat: 20_000,
fetch: mockFetch,
});
const iterator = stream[Symbol.asyncIterator]();
const fetchPromise = iterator.next();
// Прокручиваем время до истечения heartbeat таймера (20_000 + 10%)
jest.advanceTimersByTime(22_000);
await expect(fetchPromise).rejects.toThrow(
"Heartbeat timeout: no data received from server after 22000 ms",
);
});
it("should continue working if data is received before heartbeat timeout", async () => {
const mockResponseData = {
seq: "1",
id: "doc1",
changes: [{ rev: "1-abc" }],
};
mockFetch.mockResolvedValue({
ok: true,
body: new ReadableStream({
start(controller) {
// Отправляем данные перед истечением таймера
setTimeout(() => {
controller.enqueue(
new TextEncoder().encode(JSON.stringify(mockResponseData) + "\n"),
);
controller.close();
}, 2000); // Data arrives before heartbeat timeout (3000)
},
}),
});
const stream = new CouchDBChangesStream(dbUrl, {
feed: "continuous",
heartbeat: 3_000,
fetch: mockFetch,
live: true,
});
const iterator = stream[Symbol.asyncIterator]();
const nextPromise = iterator.next();
await Promise.resolve();
jest.advanceTimersByTime(2_999);
await Promise.resolve();
const firstChange = await nextPromise;
expect(firstChange.done).toBe(false);
expect(firstChange.value.seq).toBe("1");
// Let's make sure that a timeout error does not occur
await Promise.resolve();
jest.advanceTimersByTime(2_999);
await Promise.resolve();
const secondFetch = iterator.next();
await expect(secondFetch).resolves.not.toThrow();
});
});
@@ -0,0 +1,125 @@
import {
CouchDBChangesStream,
CouchDBChange,
} from "../src/CouchDBChangesStream";
describe("CouchDBChangesStream - Limit Parameter", () => {
it("should return only the specified number of changes when limit is set", async () => {
const changes: CouchDBChange<any>[] = Array.from(
{ length: 10 },
(_, i) => ({
seq: i + 1,
id: `doc${i + 1}`,
changes: [{ rev: `${i + 1}-abc` }],
deleted: false,
}),
);
const mockResponse: Partial<Response> = {
ok: true,
status: 200,
body: new ReadableStream({
start(controller) {
const changeStream = changes
.map((change) => JSON.stringify(change) + "\n")
.join("");
controller.enqueue(new TextEncoder().encode(changeStream));
controller.close();
},
}),
};
const mockFetch = jest.fn(async (url) => {
const urlObj = new URL(url);
const limitParam = urlObj.searchParams.get("limit");
const limit = limitParam ? parseInt(limitParam, 10) : changes.length;
const responseStream = changes
.slice(0, limit) // Применяем limit
.map((change) => JSON.stringify(change) + "\n")
.join("");
const mockResponse: Partial<Response> = {
ok: true,
status: 200,
body: new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(responseStream));
controller.close();
},
}),
};
return mockResponse as Response;
});
for (const limit of [1, 5, 10]) {
const stream = new CouchDBChangesStream<any>("http://mock-couchdb", {
limit,
feed: "continuous",
fetch: mockFetch,
});
const result: CouchDBChange<any>[] = [];
for await (const change of stream) {
result.push(change);
}
// Проверяем, что количество возвращенных изменений соответствует лимиту
expect(result).toHaveLength(limit);
// Проверяем, что полученные изменения соответствуют первым `limit` изменениям
expect(result.map((r) => r.seq)).toEqual(
changes.slice(0, limit).map((c) => c.seq),
);
}
expect(mockFetch).toHaveBeenCalledTimes(3); // Проверяем, что fetch вызывался для каждого limit
});
it("should return all changes if limit is not set", async () => {
const changes: CouchDBChange<any>[] = Array.from(
{ length: 10 },
(_, i) => ({
seq: i + 1,
id: `doc${i + 1}`,
changes: [{ rev: `${i + 1}-abc` }],
deleted: false,
}),
);
const mockResponse: Partial<Response> = {
ok: true,
status: 200,
body: new ReadableStream({
start(controller) {
const changeStream = changes
.map((change) => JSON.stringify(change) + "\n")
.join("");
controller.enqueue(new TextEncoder().encode(changeStream));
controller.close();
},
}),
};
const mockFetch = jest.fn(async () => mockResponse as Response);
const stream = new CouchDBChangesStream<any>("http://mock-couchdb", {
feed: "continuous",
fetch: mockFetch,
});
const result: CouchDBChange<any>[] = [];
for await (const change of stream) {
result.push(change);
}
// Проверяем, что возвращены все изменения
expect(result).toHaveLength(changes.length);
// Проверяем, что полученные изменения соответствуют всем изменениям
expect(result.map((r) => r.seq)).toEqual(changes.map((c) => c.seq));
expect(mockFetch).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,77 @@
import {
CouchDBChangesStream,
CouchDBChange,
} from "../src/CouchDBChangesStream";
if (typeof global.gc !== "function") {
console.warn(
"Skipping tests: Run Node.js with --expose-gc to enable garbage collection.",
);
test.skip("Garbage collection is not enabled.", () => {});
} else {
describe("CouchDBChangesStream Memory Leak Test", () => {
it("should not cause memory leaks after being stopped", async () => {
// Mock fetch response
const changes: CouchDBChange<any>[] = Array.from(
{ length: 1000 },
(_, i) => ({
seq: i + 1,
id: `doc${i + 1}`,
changes: [{ rev: `${i + 1}-abc` }],
deleted: false,
}),
);
const mockResponse: Partial<Response> = {
ok: true,
status: 200,
body: new ReadableStream({
start(controller) {
changes.forEach((change) => {
const changeString = JSON.stringify(change) + "\n";
controller.enqueue(new TextEncoder().encode(changeString));
});
controller.close();
},
}),
};
const mockFetch = jest.fn(async () => mockResponse as Response);
// Measure memory before creating the stream
if (global.gc) global.gc(); // Force garbage collection
const memoryBefore = process.memoryUsage().heapUsed;
// Create and consume the stream
const stream = new CouchDBChangesStream<any>("http://mock-couchdb", {
feed: "continuous",
fetch: mockFetch,
});
const result: CouchDBChange<any>[] = [];
for await (const change of stream) {
result.push(change);
if (result.length === changes.length) {
stream.stop();
}
}
// Stop the stream and allow garbage collection
stream.stop();
if (global.gc) global.gc();
// Measure memory after the stream is stopped
const memoryAfter = process.memoryUsage().heapUsed;
// Log memory usage for analysis
console.log(`Memory Before: ${memoryBefore}`);
console.log(`Memory After: ${memoryAfter}`);
console.log(`Memory Difference: ${memoryAfter - memoryBefore}`);
// Allow for slightly larger tolerances in memory usage differences
const memoryLeakThreshold = 1024 * 100; // 100 KB
expect(memoryAfter - memoryBefore).toBeLessThan(memoryLeakThreshold);
});
});
}
@@ -0,0 +1,168 @@
import {
CouchDBChangesStream,
CouchDBChange,
} from "../src/CouchDBChangesStream";
describe("CouchDBChangesStream Performance and Scalability", () => {
it("processes a large number of changes correctly and completely", async () => {
const changeCount = 1000;
const changes: CouchDBChange<any>[] = Array.from(
{ length: changeCount },
(_, i) => ({
seq: i + 1,
id: `doc${i + 1}`,
changes: [{ rev: `${i + 1}-abc` }],
deleted: false,
}),
);
const mockResponse: Partial<Response> = {
ok: true,
status: 200,
body: new ReadableStream({
start(controller) {
const changeStream = changes
.map((change) => JSON.stringify(change) + "\n")
.join("");
controller.enqueue(new TextEncoder().encode(changeStream));
controller.close();
},
}),
};
const mockFetch = jest.fn(async () => {
return mockResponse as Response;
});
const stream = new CouchDBChangesStream<any>("http://mock-couchdb", {
feed: "continuous",
fetch: mockFetch,
});
const result: CouchDBChange<any>[] = [];
for await (const change of stream) {
result.push(change);
if (result.length === changeCount) {
stream.stop();
}
}
expect(result).toHaveLength(changeCount);
expect(result.map((r) => r.seq)).toEqual(changes.map((c) => c.seq));
});
it("maintains stability with increasing data volume", async () => {
const largeVolumes = [100, 1000, 5000];
const performanceResults: Record<number, number> = {};
for (const volume of largeVolumes) {
const changes: CouchDBChange<any>[] = Array.from(
{ length: volume },
(_, i) => ({
seq: i + 1,
id: `doc${i + 1}`,
changes: [{ rev: `${i + 1}-abc` }],
deleted: false,
}),
);
const mockResponse: Partial<Response> = {
ok: true,
status: 200,
body: new ReadableStream({
start(controller) {
const changeStream = changes
.map((change) => JSON.stringify(change) + "\n")
.join("");
controller.enqueue(new TextEncoder().encode(changeStream));
controller.close();
},
}),
};
const mockFetch = jest.fn(async () => {
return mockResponse as Response;
});
const stream = new CouchDBChangesStream<any>("http://mock-couchdb", {
feed: "continuous",
fetch: mockFetch,
});
const result: CouchDBChange<any>[] = [];
const startTime = performance.now();
for await (const change of stream) {
result.push(change);
if (result.length === volume) {
stream.stop();
}
}
const endTime = performance.now();
const elapsedTime = endTime - startTime;
performanceResults[volume] = elapsedTime;
expect(result).toHaveLength(volume);
expect(result.map((r) => r.seq)).toEqual(changes.map((c) => c.seq));
}
console.log("Performance Results (ms):", performanceResults);
});
it("handles high throughput with multiple chunks of data", async () => {
const chunkSize = 500;
const chunkCount = 5;
const changes: CouchDBChange<any>[] = Array.from(
{ length: chunkSize * chunkCount },
(_, i) => ({
seq: i + 1,
id: `doc${i + 1}`,
changes: [{ rev: `${i + 1}-abc` }],
deleted: false,
}),
);
const mockResponse: Partial<Response> = {
ok: true,
status: 200,
body: new ReadableStream({
start(controller) {
changes.forEach((change, i) => {
const changeString = JSON.stringify(change) + "\n";
controller.enqueue(new TextEncoder().encode(changeString));
if ((i + 1) % chunkSize === 0) {
// Simulate chunked data
controller.enqueue(new TextEncoder().encode("\n"));
}
});
controller.close();
},
}),
};
const mockFetch = jest.fn(async () => {
return mockResponse as Response;
});
const stream = new CouchDBChangesStream<any>("http://mock-couchdb", {
feed: "continuous",
fetch: mockFetch,
});
const result: CouchDBChange<any>[] = [];
for await (const change of stream) {
result.push(change);
if (result.length === changes.length) {
stream.stop();
}
}
expect(result).toHaveLength(changes.length);
expect(result.map((r) => r.seq)).toEqual(changes.map((c) => c.seq));
});
});
@@ -0,0 +1,491 @@
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<T>(
changes: CouchDBChange<T>[],
): jest.Mock<Promise<Response>> {
return jest.fn(async () => {
const responseStream = changes
.map((change) => JSON.stringify(change) + "\n")
.join("");
const mockResponse: Partial<Response> = {
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<any>[] = [
{ 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<any>("http://mock-couchdb", {
feed: "continuous",
fetch: mockFetch,
});
const result: CouchDBChange<any>[] = [];
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<any>[] = [
{ 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<any>("http://mock-couchdb", {
feed: "continuous",
fetch: mockFetch,
});
const result: CouchDBChange<any>[] = [];
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<any>[] = [
{ 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<any>("http://mock-couchdb", {
feed: "continuous",
fetch: mockFetch,
});
const result: CouchDBChange<any>[] = [];
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<any>[] = [
{ 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<any>("http://mock-couchdb", {
feed: "continuous",
fetch: mockFetch,
abortController,
});
const result: CouchDBChange<any>[] = [];
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<any>(
"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<any>(
"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<NestedDocument>[] = [
{
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<SimpleDocument>[] = [
{
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<NestedDocument>(
"http://mock-couchdb",
{ include_docs: true, feed: "continuous", fetch: mockFetchNested },
);
const simpleStream = new CouchDBChangesStream<SimpleDocument>(
"http://mock-couchdb",
{ include_docs: true, feed: "continuous", fetch: mockFetchSimple },
);
const nestedResult: CouchDBChange<NestedDocument>[] = [];
for await (const change of nestedStream) {
nestedResult.push(change);
}
const simpleResult: CouchDBChange<SimpleDocument>[] = [];
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<any>(
"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<Response> => {
return new Response(JSON.stringify({ results: [], last_seq: "0" }));
},
) as jest.MockedFunction<typeof fetch>;
const options = {
since: "now",
include_docs: true,
fetch: mockFetch,
};
const stream = new CouchDBChangesStream<any>(
"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<Response> => {
return new Response(JSON.stringify(changesResponse), {
status: 200,
headers: { "Content-Type": "application/json" },
});
},
);
const stream = new CouchDBChangesStream<any>("http://mock-couchdb", {
feed: "normal",
include_docs: true,
fetch: mockFetch,
});
const result: CouchDBChange<any>[] = [];
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<typeof fetch>;
const stream = new CouchDBChangesStream<any>("http://mock-couchdb", {
feed: "longpoll",
include_docs: true,
live: true,
fetch: mockFetch,
});
const result: CouchDBChange<any>[] = [];
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);
});
});