add support heartbeat to couchdb-changes-stream
This commit is contained in:
+133
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
+168
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user