From bddf7bcb70741e0472324ddd3c2c69c0ad16f5ad Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Mon, 5 Oct 2026 03:15:06 -0500 Subject: [PATCH] test: prune redundant unit tests per testing-trophy audit Remove or merge unit cases that duplicate higher-tier coverage or only echo implementation, and drop the unused bucketTimestamps export. Cases with unique assertions (parsers, fingerprinting, CSV injection, queue/retry/concurrency) are kept. --- sdks/go/logwell/config_test.go | 19 ---- sdks/python/tests/unit/test_config.py | 17 +-- sdks/python/tests/unit/test_queue.py | 89 +++------------ sdks/python/tests/unit/test_transport.py | 19 +--- sdks/typescript/tests/fixtures/configs.ts | 6 - .../integration/client.integration.test.ts | 2 +- .../typescript/tests/unit/client.unit.test.ts | 80 ------------- .../typescript/tests/unit/config.unit.test.ts | 105 +++++------------- .../typescript/tests/unit/errors.unit.test.ts | 85 +------------- sdks/typescript/tests/unit/queue.unit.test.ts | 81 ++------------ .../tests/unit/source-location.unit.test.ts | 20 ++-- .../tests/unit/transport.unit.test.ts | 51 ++++----- src/hooks.server.unit.test.ts | 22 ++-- src/lib/server/config/env.unit.test.ts | 20 ++-- .../server/config/performance.unit.test.ts | 18 --- src/lib/server/config/retention.unit.test.ts | 64 ----------- src/lib/server/utils/api-key.unit.test.ts | 15 +-- .../server/utils/csv-serializer.unit.test.ts | 27 ++--- src/lib/server/utils/cursor.unit.test.ts | 1 - src/lib/server/utils/otlp.unit.test.ts | 63 +++-------- src/lib/server/utils/rate-limit.unit.test.ts | 57 ++++------ src/lib/server/utils/search.unit.test.ts | 11 +- .../server/utils/simple-ingest.unit.test.ts | 6 +- src/lib/shared/schemas/log-level.unit.test.ts | 6 - src/lib/shared/schemas/project.unit.test.ts | 57 +++------- src/lib/utils/format.unit.test.ts | 21 ---- src/lib/utils/keyboard.unit.test.ts | 60 +++------- src/lib/utils/log-sort.unit.test.ts | 5 +- src/lib/utils/time-range.unit.test.ts | 8 +- src/lib/utils/timeseries.ts | 16 --- src/lib/utils/timeseries.unit.test.ts | 80 +------------ src/lib/utils/toast.unit.test.ts | 9 +- tests/README.md | 26 ++--- 33 files changed, 217 insertions(+), 949 deletions(-) delete mode 100644 src/lib/server/config/retention.unit.test.ts diff --git a/sdks/go/logwell/config_test.go b/sdks/go/logwell/config_test.go index c0c73207..158b3b51 100644 --- a/sdks/go/logwell/config_test.go +++ b/sdks/go/logwell/config_test.go @@ -1,24 +1,5 @@ package logwell -import ( - "context" - "testing" -) - func validAPIKey() string { return "lw_" + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" // 32 chars after lw_ } - -func validEndpoint() string { - return "http://localhost:3000" -} - -// Config bounds are pinned by the TS reference SDK; one smoke case proves the -// Go mirror accepts a valid config (incl. its 100ms flush floor parity). -func TestConfigAcceptsValidConfig(t *testing.T) { - cfg, err := New(validEndpoint(), validAPIKey()) - if err != nil { - t.Fatalf("New() error = %v", err) - } - defer cfg.Shutdown(context.Background()) -} diff --git a/sdks/python/tests/unit/test_config.py b/sdks/python/tests/unit/test_config.py index 68ae4974..9ffb9be3 100644 --- a/sdks/python/tests/unit/test_config.py +++ b/sdks/python/tests/unit/test_config.py @@ -15,19 +15,12 @@ class TestFlushIntervalFloorGap: """Config bounds are pinned by the TS reference SDK. The one Python delta: TS/Go enforce a 100ms flush floor, Python only rejects <= 0.""" - def test_flush_interval_zero_rejected(self, valid_config: LogwellConfig) -> None: - config = dict(valid_config) - config["flush_interval"] = 0.0 - - with pytest.raises(LogwellError) as exc_info: - validate_config(config) # type: ignore[arg-type] - - assert exc_info.value.code == LogwellErrorCode.INVALID_CONFIG - assert "flush_interval" in exc_info.value.message - - def test_flush_interval_negative_rejected(self, valid_config: LogwellConfig) -> None: + @pytest.mark.parametrize("flush_interval", [0.0, -1.0], ids=["zero", "negative"]) + def test_flush_interval_zero_or_negative_rejected( + self, valid_config: LogwellConfig, flush_interval: float + ) -> None: config = dict(valid_config) - config["flush_interval"] = -1.0 + config["flush_interval"] = flush_interval with pytest.raises(LogwellError) as exc_info: validate_config(config) # type: ignore[arg-type] diff --git a/sdks/python/tests/unit/test_queue.py b/sdks/python/tests/unit/test_queue.py index 9362e8dd..8efaee16 100644 --- a/sdks/python/tests/unit/test_queue.py +++ b/sdks/python/tests/unit/test_queue.py @@ -38,19 +38,6 @@ async def mock_send(batch: list[LogEntry]) -> IngestResponse: class TestQueueOverflow: - def test_overflow_drops_oldest_entry(self) -> None: - send_batch, _ = make_send_batch_mock() - config = QueueConfig(batch_size=100, max_queue_size=3) - queue = BatchQueue(send_batch, config) - - queue.add(make_log_entry("one")) - queue.add(make_log_entry("two")) - queue.add(make_log_entry("three")) - assert queue.size == 3 - - queue.add(make_log_entry("four")) - assert queue.size == 3 - @pytest.mark.asyncio async def test_overflow_preserves_newest_entries(self) -> None: send_batch, captured = make_send_batch_mock() @@ -62,6 +49,7 @@ async def test_overflow_preserves_newest_entries(self) -> None: queue.add(make_log_entry("three")) queue.add(make_log_entry("four")) # Drops "one" queue.add(make_log_entry("five")) # Drops "two" + assert queue.size == 3 # Cap holds, oldest evicted await queue.flush() @@ -87,7 +75,10 @@ def test_overflow_calls_on_error(self) -> None: class TestBatchQueueShutdown: @pytest.mark.asyncio - async def test_shutdown_awaits_in_flight_flush(self) -> None: + @pytest.mark.parametrize( + "triggered_by_add", [False, True], ids=["explicit-flush", "add-triggered-flush"] + ) + async def test_shutdown_awaits_in_flight_flush(self, triggered_by_add: bool) -> None: flush_started = threading.Event() flush_continue = threading.Event() captured: list[list[LogEntry]] = [] @@ -99,11 +90,12 @@ async def slow_send(batch: list[LogEntry]) -> IngestResponse: await asyncio.sleep(0.01) return {"accepted": len(batch)} - queue = BatchQueue(MagicMock(side_effect=slow_send), QueueConfig(batch_size=100)) + config = QueueConfig(batch_size=1 if triggered_by_add else 100) + queue = BatchQueue(MagicMock(side_effect=slow_send), config) queue.add(make_log_entry("one")) - queue.add(make_log_entry("two")) - - flush_task = asyncio.create_task(queue.flush()) + if not triggered_by_add: + queue.add(make_log_entry("two")) + flush_task = asyncio.create_task(queue.flush()) while not flush_started.is_set(): await asyncio.sleep(0.01) @@ -114,37 +106,11 @@ async def slow_send(batch: list[LogEntry]) -> IngestResponse: flush_continue.set() await shutdown_task - await flush_task - - assert [e["message"] for batch in captured for e in batch] == ["one", "two"] - assert queue._queue_loop is None - - @pytest.mark.asyncio - async def test_shutdown_awaits_triggered_flush(self) -> None: - flush_started = threading.Event() - flush_continue = threading.Event() - captured: list[list[LogEntry]] = [] - - async def slow_send(batch: list[LogEntry]) -> IngestResponse: - captured.append(batch) - flush_started.set() - while not flush_continue.is_set(): - await asyncio.sleep(0.01) - return {"accepted": len(batch)} - - queue = BatchQueue(MagicMock(side_effect=slow_send), QueueConfig(batch_size=1)) - queue.add(make_log_entry("one")) - while not flush_started.is_set(): - await asyncio.sleep(0.01) - - shutdown_task = asyncio.create_task(queue.shutdown()) - await asyncio.sleep(0.05) - assert not shutdown_task.done() - - flush_continue.set() - await shutdown_task + if not triggered_by_add: + await flush_task - assert [e["message"] for batch in captured for e in batch] == ["one"] + expected = ["one"] if triggered_by_add else ["one", "two"] + assert [e["message"] for batch in captured for e in batch] == expected assert queue._queue_loop is None @@ -244,33 +210,6 @@ async def periodic_flush() -> None: total_captured = sum(len(batch) for batch in captured) assert total_captured == num_adds - def test_size_is_thread_safe(self) -> None: - send_batch, _ = make_send_batch_mock() - config = QueueConfig(batch_size=10000) - queue = BatchQueue(send_batch, config) - - num_adds = 1000 - sizes: list[int] = [] - - def add_entries() -> None: - for _ in range(num_adds): - queue.add(make_log_entry()) - - def read_size() -> None: - for _ in range(num_adds): - sizes.append(queue.size) - - t1 = threading.Thread(target=add_entries) - t2 = threading.Thread(target=read_size) - - t1.start() - t2.start() - t1.join() - t2.join() - - assert queue.size == num_adds - assert all(0 <= s <= num_adds for s in sizes) - class TestBatchQueueEdgeCases: @pytest.mark.asyncio diff --git a/sdks/python/tests/unit/test_transport.py b/sdks/python/tests/unit/test_transport.py index 7a498169..5f887f03 100644 --- a/sdks/python/tests/unit/test_transport.py +++ b/sdks/python/tests/unit/test_transport.py @@ -38,22 +38,6 @@ def _make_429_response(retry_after: str) -> httpx.Response: class TestRetryAfterCap: - @pytest.mark.asyncio - async def test_retry_after_capped_at_backoff_ceiling(self) -> None: - transport = _make_transport(max_retries=1) - transport._client.post = AsyncMock( # type: ignore[attr-defined] - return_value=_make_429_response("3600") - ) - - with ( - patch("asyncio.sleep", new=AsyncMock()) as mock_sleep, - pytest.raises(LogwellError) as exc_info, - ): - await transport.send([{"level": "info", "message": "hello"}]) - - assert exc_info.value.code == LogwellErrorCode.RATE_LIMITED - mock_sleep.assert_awaited_once_with(0.1) - @pytest.mark.asyncio async def test_retry_after_below_backoff_is_honored(self) -> None: transport = _make_transport(max_retries=1) @@ -79,10 +63,11 @@ async def test_retry_after_cap_scales_with_attempt(self) -> None: with ( patch("asyncio.sleep", new=AsyncMock()) as mock_sleep, - pytest.raises(LogwellError), + pytest.raises(LogwellError) as exc_info, ): await transport.send([{"level": "info", "message": "hello"}]) + assert exc_info.value.code == LogwellErrorCode.RATE_LIMITED assert mock_sleep.await_args_list == [call(0.1), call(0.2)] @pytest.mark.asyncio diff --git a/sdks/typescript/tests/fixtures/configs.ts b/sdks/typescript/tests/fixtures/configs.ts index 5607fed3..d4eef1d4 100644 --- a/sdks/typescript/tests/fixtures/configs.ts +++ b/sdks/typescript/tests/fixtures/configs.ts @@ -18,12 +18,6 @@ export const validConfigs = { onFlush: () => {}, } satisfies LogwellConfig, - withService: { - apiKey: "lw_aBcDeFgHiJkLmNoPqRsTuVwXyZ123456", - endpoint: "https://test.logwell.io", - service: "my-app", - } satisfies LogwellConfig, - withSourceLocation: { apiKey: "lw_aBcDeFgHiJkLmNoPqRsTuVwXyZ123456", endpoint: "https://test.logwell.io", diff --git a/sdks/typescript/tests/integration/client.integration.test.ts b/sdks/typescript/tests/integration/client.integration.test.ts index ee9b22e0..ff64baa1 100644 --- a/sdks/typescript/tests/integration/client.integration.test.ts +++ b/sdks/typescript/tests/integration/client.integration.test.ts @@ -277,7 +277,7 @@ describe("Logwell Client", () => { expect(client.queueSize).toBe(2); - await client.flush(); + await client.shutdown(); expect(capturedLogs).toHaveLength(2); }); diff --git a/sdks/typescript/tests/unit/client.unit.test.ts b/sdks/typescript/tests/unit/client.unit.test.ts index 17be6b3e..118609b5 100644 --- a/sdks/typescript/tests/unit/client.unit.test.ts +++ b/sdks/typescript/tests/unit/client.unit.test.ts @@ -19,75 +19,7 @@ describe("Logwell Client - Source Location", () => { vi.useRealTimers(); }); - describe("source location disabled (default)", () => { - it("does not include sourceFile when captureSourceLocation is false", () => { - const client = new Logwell(defaultConfig); - const queueAddSpy = vi.spyOn(client["queue"], "add"); - - client.info("Test message"); - - expect(queueAddSpy).toHaveBeenCalledTimes(1); - const entry = queueAddSpy.mock.calls[0][0] as LogEntry; - expect(entry.sourceFile).toBeUndefined(); - expect(entry.lineNumber).toBeUndefined(); - }); - - it("does not include sourceFile by default", () => { - const client = new Logwell(validConfigs.minimal); - const queueAddSpy = vi.spyOn(client["queue"], "add"); - - client.info("Test message"); - - const entry = queueAddSpy.mock.calls[0][0] as LogEntry; - expect(entry.sourceFile).toBeUndefined(); - expect(entry.lineNumber).toBeUndefined(); - }); - }); - describe("source location enabled", () => { - it("includes sourceFile when captureSourceLocation is true", () => { - const client = new Logwell({ - ...defaultConfig, - captureSourceLocation: true, - }); - const queueAddSpy = vi.spyOn(client["queue"], "add"); - - client.info("Test message"); - - const entry = queueAddSpy.mock.calls[0][0] as LogEntry; - expect(entry.sourceFile).toBeDefined(); - expect(entry.sourceFile).toContain("client.unit.test.ts"); - }); - - it("includes lineNumber when captureSourceLocation is true", () => { - const client = new Logwell({ - ...defaultConfig, - captureSourceLocation: true, - }); - const queueAddSpy = vi.spyOn(client["queue"], "add"); - - client.info("Test message"); - - const entry = queueAddSpy.mock.calls[0][0] as LogEntry; - expect(entry.lineNumber).toBeDefined(); - expect(typeof entry.lineNumber).toBe("number"); - expect(entry.lineNumber).toBeGreaterThan(0); - }); - - it("captures correct location for info()", () => { - const client = new Logwell({ - ...defaultConfig, - captureSourceLocation: true, - }); - const queueAddSpy = vi.spyOn(client["queue"], "add"); - - client.info("Test message"); // This line's number should be captured - - const entry = queueAddSpy.mock.calls[0][0] as LogEntry; - expect(entry.sourceFile).not.toContain("client.ts"); - expect(entry.sourceFile).toContain("client.unit.test.ts"); - }); - it("captures correct location for log()", () => { const client = new Logwell({ ...defaultConfig, @@ -170,18 +102,6 @@ describe("Logwell Client - Source Location", () => { expect(client.queueSize).toBe(1); }); - it("parent shutdown still flushes the shared queue", async () => { - const client = new Logwell(defaultConfig); - const child = client.child({}); - const shutdownSpy = vi.spyOn(client["queue"], "shutdown").mockResolvedValue(null); - - child.info("From child"); - const result = await client.shutdown(); - - expect(shutdownSpy).toHaveBeenCalledTimes(1); - expect(result).toBeNull(); - }); - it("child without metadata does not create an empty metadata object", async () => { const client = new Logwell(defaultConfig); const child = client.child({}); diff --git a/sdks/typescript/tests/unit/config.unit.test.ts b/sdks/typescript/tests/unit/config.unit.test.ts index 24b0a86f..d5950479 100644 --- a/sdks/typescript/tests/unit/config.unit.test.ts +++ b/sdks/typescript/tests/unit/config.unit.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; import { API_KEY_REGEX, DEFAULT_CONFIG, @@ -9,11 +9,6 @@ import { LogwellError } from "../../src/errors"; import { invalidConfigs, validConfigs } from "../fixtures/configs"; describe("API_KEY_REGEX", () => { - it("matches valid API key format", () => { - const validKey = "lw_aBcDeFgHiJkLmNoPqRsTuVwXyZ123456"; - expect(API_KEY_REGEX.test(validKey)).toBe(true); - }); - it("rejects keys without lw_ prefix", () => { const invalidKey = "xx_aBcDeFgHiJkLmNoPqRsTuVwXyZ123456"; expect(API_KEY_REGEX.test(invalidKey)).toBe(false); @@ -43,34 +38,21 @@ describe("validateApiKeyFormat", () => { expect(validateApiKeyFormat("")).toBe(false); }); - it("returns false for invalid format", () => { - expect(validateApiKeyFormat("invalid")).toBe(false); - }); - - it("returns false for wrong prefix", () => { - expect(validateApiKeyFormat("xx_aBcDeFgHiJkLmNoPqRsTuVwXyZ123456")).toBe(false); - }); - - it("returns false for short key", () => { - expect(validateApiKeyFormat("lw_short")).toBe(false); - }); + it.each(["invalid", "xx_aBcDeFgHiJkLmNoPqRsTuVwXyZ123456", "lw_short"])( + "returns false for %s", + (key) => { + expect(validateApiKeyFormat(key)).toBe(false); + }, + ); }); describe("DEFAULT_CONFIG", () => { - it("has correct default batchSize", () => { + it("pins documented default values", () => { expect(DEFAULT_CONFIG.batchSize).toBe(50); - }); - - it("has correct default flushInterval", () => { expect(DEFAULT_CONFIG.flushInterval).toBe(5000); - }); - - it("has correct default maxQueueSize", () => { expect(DEFAULT_CONFIG.maxQueueSize).toBe(1000); - }); - - it("has correct default maxRetries", () => { expect(DEFAULT_CONFIG.maxRetries).toBe(3); + expect(DEFAULT_CONFIG.timeout).toBe(30000); }); }); @@ -95,18 +77,13 @@ describe("validateConfig", () => { it("preserves provided optional values", () => { const result = validateConfig(validConfigs.full); + expect(result.service).toBe("test-service"); expect(result.batchSize).toBe(25); expect(result.flushInterval).toBe(3000); expect(result.maxQueueSize).toBe(500); expect(result.maxRetries).toBe(5); }); - it("preserves service name", () => { - const result = validateConfig(validConfigs.withService); - - expect(result.service).toBe("my-app"); - }); - it("preserves callback functions", () => { const onError = vi.fn(); const onFlush = vi.fn(); @@ -124,9 +101,11 @@ describe("validateConfig", () => { }); describe("invalid configurations", () => { - it("throws LogwellError for missing apiKey", () => { + it("throws LogwellError for missing or empty apiKey", () => { expect(() => validateConfig(invalidConfigs.missingApiKey)).toThrow(LogwellError); expect(() => validateConfig(invalidConfigs.missingApiKey)).toThrow("apiKey is required"); + expect(() => validateConfig(invalidConfigs.emptyApiKey)).toThrow(LogwellError); + expect(() => validateConfig(invalidConfigs.emptyApiKey)).toThrow("apiKey is required"); }); it("throws LogwellError for missing endpoint", () => { @@ -134,23 +113,17 @@ describe("validateConfig", () => { expect(() => validateConfig(invalidConfigs.missingEndpoint)).toThrow("endpoint is required"); }); - it("throws LogwellError for empty apiKey", () => { - expect(() => validateConfig(invalidConfigs.emptyApiKey)).toThrow(LogwellError); - }); - - it("throws LogwellError for invalid apiKey format", () => { - expect(() => validateConfig(invalidConfigs.invalidApiKeyFormat)).toThrow(LogwellError); - expect(() => validateConfig(invalidConfigs.invalidApiKeyFormat)).toThrow( - "Invalid API key format", - ); - }); - - it("throws LogwellError for apiKey with wrong prefix", () => { - expect(() => validateConfig(invalidConfigs.apiKeyWrongPrefix)).toThrow(LogwellError); - }); + it("throws LogwellError for invalid API key format", () => { + const configs = [ + invalidConfigs.invalidApiKeyFormat, + invalidConfigs.apiKeyWrongPrefix, + invalidConfigs.apiKeyTooShort, + ]; - it("throws LogwellError for apiKey too short", () => { - expect(() => validateConfig(invalidConfigs.apiKeyTooShort)).toThrow(LogwellError); + for (const config of configs) { + expect(() => validateConfig(config)).toThrow(LogwellError); + expect(() => validateConfig(config)).toThrow("Invalid API key format"); + } }); it("throws LogwellError for invalid endpoint URL", () => { @@ -158,15 +131,11 @@ describe("validateConfig", () => { expect(() => validateConfig(invalidConfigs.invalidEndpoint)).toThrow("Invalid endpoint URL"); }); - it("throws LogwellError for negative batchSize", () => { - expect(() => validateConfig(invalidConfigs.negativeBatchSize)).toThrow(LogwellError); - expect(() => validateConfig(invalidConfigs.negativeBatchSize)).toThrow( - "batchSize must be positive", - ); - }); - - it("throws LogwellError for zero batchSize", () => { - expect(() => validateConfig(invalidConfigs.zeroBatchSize)).toThrow(LogwellError); + it("throws LogwellError for non-positive batchSize", () => { + for (const config of [invalidConfigs.negativeBatchSize, invalidConfigs.zeroBatchSize]) { + expect(() => validateConfig(config)).toThrow(LogwellError); + expect(() => validateConfig(config)).toThrow("batchSize must be positive"); + } }); it("throws LogwellError for negative flushInterval", () => { @@ -178,21 +147,13 @@ describe("validateConfig", () => { }); describe("error details", () => { - it("throws with INVALID_CONFIG error code", () => { + it("throws INVALID_CONFIG error that is not retryable", () => { try { validateConfig(invalidConfigs.missingApiKey); expect.fail("Should have thrown"); } catch (error) { expect(error).toBeInstanceOf(LogwellError); expect((error as LogwellError).code).toBe("INVALID_CONFIG"); - } - }); - - it("throws non-retryable error", () => { - try { - validateConfig(invalidConfigs.missingApiKey); - expect.fail("Should have thrown"); - } catch (error) { expect((error as LogwellError).retryable).toBe(false); } }); @@ -211,13 +172,5 @@ describe("validateConfig", () => { }); expect(result.captureSourceLocation).toBe(true); }); - - it("preserves captureSourceLocation when set to false explicitly", () => { - const result = validateConfig({ - ...validConfigs.minimal, - captureSourceLocation: false, - }); - expect(result.captureSourceLocation).toBe(false); - }); }); }); diff --git a/sdks/typescript/tests/unit/errors.unit.test.ts b/sdks/typescript/tests/unit/errors.unit.test.ts index 97e9593c..c5173036 100644 --- a/sdks/typescript/tests/unit/errors.unit.test.ts +++ b/sdks/typescript/tests/unit/errors.unit.test.ts @@ -1,25 +1,15 @@ import { describe, expect, it } from "vite-plus/test"; -import { LogwellError, type LogwellErrorCode } from "../../src/errors"; +import { LogwellError } from "../../src/errors"; describe("LogwellError", () => { describe("constructor", () => { - it("creates error with message and code", () => { - const error = new LogwellError("Test error", "NETWORK_ERROR"); - - expect(error.message).toBe("Test error"); - expect(error.code).toBe("NETWORK_ERROR"); - expect(error.name).toBe("LogwellError"); - }); - - it("creates error with statusCode", () => { - const error = new LogwellError("Unauthorized", "UNAUTHORIZED", 401); - - expect(error.statusCode).toBe(401); - }); - - it("creates error with retryable flag", () => { + it("creates error with message, code, statusCode, and retryable", () => { const error = new LogwellError("Server error", "SERVER_ERROR", 500, true); + expect(error.message).toBe("Server error"); + expect(error.code).toBe("SERVER_ERROR"); + expect(error.name).toBe("LogwellError"); + expect(error.statusCode).toBe(500); expect(error.retryable).toBe(true); }); @@ -36,51 +26,6 @@ describe("LogwellError", () => { }); }); - describe("inheritance", () => { - it("is an instance of Error", () => { - const error = new LogwellError("Test", "NETWORK_ERROR"); - - expect(error).toBeInstanceOf(Error); - }); - - it("is an instance of LogwellError", () => { - const error = new LogwellError("Test", "NETWORK_ERROR"); - - expect(error).toBeInstanceOf(LogwellError); - }); - - it("has correct stack trace", () => { - const error = new LogwellError("Test", "NETWORK_ERROR"); - - expect(error.stack).toBeDefined(); - expect(error.stack).toContain("LogwellError"); - }); - }); - - describe("error codes", () => { - const testCases: Array<[LogwellErrorCode, string, number | undefined, boolean]> = [ - ["NETWORK_ERROR", "Network failed", undefined, true], - ["UNAUTHORIZED", "Invalid API key", 401, false], - ["VALIDATION_ERROR", "Invalid format", 400, false], - ["RATE_LIMITED", "Too many requests", 429, true], - ["SERVER_ERROR", "Internal error", 500, true], - ["QUEUE_OVERFLOW", "Queue full", undefined, false], - ["INVALID_CONFIG", "Bad config", undefined, false], - ]; - - it.each(testCases)( - "handles %s error code correctly", - (code, message, statusCode, retryable) => { - const error = new LogwellError(message, code, statusCode, retryable); - - expect(error.code).toBe(code); - expect(error.message).toBe(message); - expect(error.statusCode).toBe(statusCode); - expect(error.retryable).toBe(retryable); - }, - ); - }); - describe("serialization", () => { it("can be converted to JSON", () => { const error = new LogwellError("Test error", "SERVER_ERROR", 500, true); @@ -91,23 +36,5 @@ describe("LogwellError", () => { expect(parsed.statusCode).toBe(500); expect(parsed.retryable).toBe(true); }); - - it("has correct toString representation", () => { - const error = new LogwellError("Something went wrong", "NETWORK_ERROR"); - const str = error.toString(); - - expect(str).toContain("LogwellError"); - expect(str).toContain("Something went wrong"); - }); - }); - - describe("cause support", () => { - it("supports cause option for error chaining", () => { - const cause = new Error("Original error"); - const error = new LogwellError("Wrapped error", "NETWORK_ERROR", undefined, true); - Object.defineProperty(error, "cause", { value: cause }); - - expect(error.cause).toBe(cause); - }); }); }); diff --git a/sdks/typescript/tests/unit/queue.unit.test.ts b/sdks/typescript/tests/unit/queue.unit.test.ts index 76f6d9a7..1834ecb3 100644 --- a/sdks/typescript/tests/unit/queue.unit.test.ts +++ b/sdks/typescript/tests/unit/queue.unit.test.ts @@ -22,32 +22,18 @@ describe("BatchQueue", () => { vi.useRealTimers(); }); - describe("constructor", () => { - it("creates queue with config", () => { + describe("add", () => { + it("tracks queue size as entries are added", () => { const queue = new BatchQueue(mockSendBatch, defaultConfig); expect(queue).toBeInstanceOf(BatchQueue); expect(queue.size).toBe(0); - }); - }); - - describe("add", () => { - it("adds entry to queue", () => { - const queue = new BatchQueue(mockSendBatch, defaultConfig); - const log = createLogFixture(); - - queue.add(log); + queue.add(createLogFixture()); expect(queue.size).toBe(1); - }); - - it("increments size for each added entry", () => { - const queue = new BatchQueue(mockSendBatch, defaultConfig); queue.add(createLogFixture()); queue.add(createLogFixture()); - queue.add(createLogFixture()); - expect(queue.size).toBe(3); }); @@ -88,14 +74,6 @@ describe("BatchQueue", () => { expect(mockSendBatch).toHaveBeenCalledWith([log]); }); - it("does not flush if queue is empty", async () => { - new BatchQueue(mockSendBatch, defaultConfig); - - await vi.advanceTimersByTimeAsync(1000); - - expect(mockSendBatch).not.toHaveBeenCalled(); - }); - it("resets timer after manual flush", async () => { const queue = new BatchQueue(mockSendBatch, defaultConfig); @@ -122,31 +100,22 @@ describe("BatchQueue", () => { }); describe("flush", () => { - it("sends all queued logs", async () => { - const queue = new BatchQueue(mockSendBatch, defaultConfig); - const logs = createLogBatch(3); - - for (const log of logs) { - queue.add(log); - } - - await queue.flush(); - - expect(mockSendBatch).toHaveBeenCalledWith(logs); - expect(queue.size).toBe(0); - }); - - it("returns response from sendBatch", async () => { + it("sends all queued logs in order, returns the response, and clears the queue", async () => { mockSendBatch = vi.fn().mockResolvedValue({ accepted: 3 }); const queue = new BatchQueue(mockSendBatch, defaultConfig); + const log1 = createLogFixture({ message: "first" }); + const log2 = createLogFixture({ message: "second" }); + const log3 = createLogFixture({ message: "third" }); - queue.add(createLogFixture()); - queue.add(createLogFixture()); - queue.add(createLogFixture()); + queue.add(log1); + queue.add(log2); + queue.add(log3); const response = await queue.flush(); + expect(mockSendBatch).toHaveBeenCalledWith([log1, log2, log3]); expect(response).toEqual({ accepted: 3 }); + expect(queue.size).toBe(0); }); it("returns null if queue is empty", async () => { @@ -157,32 +126,6 @@ describe("BatchQueue", () => { expect(response).toBeNull(); expect(mockSendBatch).not.toHaveBeenCalled(); }); - - it("clears queue after successful flush", async () => { - const queue = new BatchQueue(mockSendBatch, defaultConfig); - - queue.add(createLogFixture()); - queue.add(createLogFixture()); - - await queue.flush(); - - expect(queue.size).toBe(0); - }); - - it("preserves log order", async () => { - const queue = new BatchQueue(mockSendBatch, defaultConfig); - const log1 = createLogFixture({ message: "first" }); - const log2 = createLogFixture({ message: "second" }); - const log3 = createLogFixture({ message: "third" }); - - queue.add(log1); - queue.add(log2); - queue.add(log3); - - await queue.flush(); - - expect(mockSendBatch).toHaveBeenCalledWith([log1, log2, log3]); - }); }); describe("queue overflow", () => { diff --git a/sdks/typescript/tests/unit/source-location.unit.test.ts b/sdks/typescript/tests/unit/source-location.unit.test.ts index fed77dcb..23ed352c 100644 --- a/sdks/typescript/tests/unit/source-location.unit.test.ts +++ b/sdks/typescript/tests/unit/source-location.unit.test.ts @@ -40,12 +40,17 @@ describe("parseStackFrame", () => { }); it("parses stack frame with constructor (new)", () => { - const frame = " at new Foo (/Users/dev/app/src/foo.ts:10:5)"; - const result = parseStackFrame(frame); - expect(result).toEqual({ + const fooFrame = " at new Foo (/Users/dev/app/src/foo.ts:10:5)"; + const classFrame = " at new MyClass (/Users/dev/app/src/my-class.ts:15:3)"; + + expect(parseStackFrame(fooFrame)).toEqual({ sourceFile: "/Users/dev/app/src/foo.ts", lineNumber: 10, }); + expect(parseStackFrame(classFrame)).toEqual({ + sourceFile: "/Users/dev/app/src/my-class.ts", + lineNumber: 15, + }); }); it("parses stack frame with aliased method [as alias]", () => { @@ -56,15 +61,6 @@ describe("parseStackFrame", () => { lineNumber: 50, }); }); - - it("parses stack frame with new and class name", () => { - const frame = " at new MyClass (/Users/dev/app/src/my-class.ts:15:3)"; - const result = parseStackFrame(frame); - expect(result).toEqual({ - sourceFile: "/Users/dev/app/src/my-class.ts", - lineNumber: 15, - }); - }); }); describe("SpiderMonkey/JSC format (Firefox/Safari)", () => { diff --git a/sdks/typescript/tests/unit/transport.unit.test.ts b/sdks/typescript/tests/unit/transport.unit.test.ts index c9065220..3c6d2f87 100644 --- a/sdks/typescript/tests/unit/transport.unit.test.ts +++ b/sdks/typescript/tests/unit/transport.unit.test.ts @@ -85,38 +85,25 @@ describe("HttpTransport - 4xx mapping", () => { vi.restoreAllMocks(); }); - it("maps 403 to non-retryable VALIDATION_ERROR without retrying", async () => { - const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( - new Response(JSON.stringify({ error: "forbidden", message: "Forbidden" }), { - status: 403, - headers: { "Content-Type": "application/json" }, - }), - ); - const transport = new HttpTransport({ endpoint, apiKey, maxRetries: 2 }); - - await expect(transport.send([createLogFixture()])).rejects.toMatchObject({ - code: "VALIDATION_ERROR", - statusCode: 403, - retryable: false, - }); - expect(fetchSpy).toHaveBeenCalledTimes(1); - }); - - it("maps 422 to non-retryable VALIDATION_ERROR", async () => { - vi.spyOn(globalThis, "fetch").mockResolvedValue( - new Response(JSON.stringify({ error: "unprocessable", message: "Unprocessable" }), { - status: 422, - headers: { "Content-Type": "application/json" }, - }), - ); - const transport = new HttpTransport({ endpoint, apiKey, maxRetries: 2 }); - - await expect(transport.send([createLogFixture()])).rejects.toMatchObject({ - code: "VALIDATION_ERROR", - statusCode: 422, - retryable: false, - }); - }); + it.each([403, 422] as const)( + "maps %i to non-retryable VALIDATION_ERROR without retrying", + async (status) => { + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ error: "rejected", message: "Rejected" }), { + status, + headers: { "Content-Type": "application/json" }, + }), + ); + const transport = new HttpTransport({ endpoint, apiKey, maxRetries: 2 }); + + await expect(transport.send([createLogFixture()])).rejects.toMatchObject({ + code: "VALIDATION_ERROR", + statusCode: status, + retryable: false, + }); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }, + ); it("still maps 429 to retryable RATE_LIMITED", async () => { const fetchSpy = vi diff --git a/src/hooks.server.unit.test.ts b/src/hooks.server.unit.test.ts index bf937e13..f9e95428 100644 --- a/src/hooks.server.unit.test.ts +++ b/src/hooks.server.unit.test.ts @@ -154,25 +154,21 @@ describe("hooks.server handle", () => { }); describe("CSRF enforcement on /api/auth/*", () => { - it("rejects a cross-origin non-GET request with csrf_error", async () => { - const event = createEvent(`${ORIGIN}/api/auth/sign-out`, { - method: "POST", - headers: { Origin: "https://evil.example" }, - }); + // Both rows drive the real handle() path; the exhaustive header matrix lives in + // tests/integration/utils/csrf.integration.test.ts. + const cases: [string, RequestInit][] = [ + ["cross-origin Origin", { method: "POST", headers: { Origin: "https://evil.example" } }], + ["neither Origin nor Referer", { method: "POST" }], + ]; - const response = await handle({ event, resolve }); + it.each(cases)("rejects a non-GET request with csrf_error (%s)", async (_scenario, init) => { + const event = createEvent(`${ORIGIN}/api/auth/sign-out`, init); - expect(response.status).toBe(403); - expect((await response.json()).error).toBe("csrf_error"); - expect(mocks.authHandler).not.toHaveBeenCalled(); - }); - - it("rejects a non-GET request carrying neither Origin nor Referer", async () => { - const event = createEvent(`${ORIGIN}/api/auth/sign-out`, { method: "POST" }); const response = await handle({ event, resolve }); expect(response.status).toBe(403); expect((await response.json()).error).toBe("csrf_error"); + expect(mocks.authHandler).not.toHaveBeenCalled(); }); it("delegates same-origin auth routes to the better-auth handler", async () => { diff --git a/src/lib/server/config/env.unit.test.ts b/src/lib/server/config/env.unit.test.ts index 3a27e212..9becbde5 100644 --- a/src/lib/server/config/env.unit.test.ts +++ b/src/lib/server/config/env.unit.test.ts @@ -67,28 +67,24 @@ describe("Environment Configuration", () => { expect(env[key as keyof typeof env]).toBe(value); }); - it.each([["ADMIN_PASSWORD"], ["ORIGIN"]])("returns undefined for unset %s", async (key) => { - const { env } = await loadEnv({ [key]: undefined }); - // SAFETY: ADMIN_PASSWORD and ORIGIN are actual exports of config/env, so keyof covers - // both keys and unsetting them leaves env[key] undefined as asserted. - expect(env[key as keyof typeof env]).toBeUndefined(); + it("returns undefined for unset optional variables", async () => { + const { env } = await loadEnv({ ADMIN_PASSWORD: undefined, ORIGIN: undefined }); + expect(env.ADMIN_PASSWORD).toBeUndefined(); + expect(env.ORIGIN).toBeUndefined(); }); it("defaults NODE_ENV to production when unset", async () => { - const { env } = await loadEnv({ NODE_ENV: undefined }); + const { env, isProduction, isDevelopment } = await loadEnv({ NODE_ENV: undefined }); expect(env.NODE_ENV).toBe("production"); + expect(isProduction()).toBe(true); + expect(isDevelopment()).toBe(false); }); it.each([ ["production", true, false], ["development", false, true], - [undefined, true, false], ])("NODE_ENV=%s → isProduction=%s isDevelopment=%s", async (nodeEnv, prod, dev) => { - // SAFETY: the it.each table above only supplies a string or undefined for NODE_ENV — - // the booleans occupy the isProduction/isDevelopment slots. - const { isProduction, isDevelopment } = await loadEnv({ - NODE_ENV: nodeEnv as string | undefined, - }); + const { isProduction, isDevelopment } = await loadEnv({ NODE_ENV: nodeEnv }); expect(isProduction()).toBe(prod); expect(isDevelopment()).toBe(dev); diff --git a/src/lib/server/config/performance.unit.test.ts b/src/lib/server/config/performance.unit.test.ts index f50855e5..57545a0a 100644 --- a/src/lib/server/config/performance.unit.test.ts +++ b/src/lib/server/config/performance.unit.test.ts @@ -139,11 +139,6 @@ describe("Performance Configuration", () => { expect(LOG_STREAM_CONFIG.DEFAULT_MAX_LOGS).toBe(expected); }, ); - - it("exports MAX_LOGS_UPPER_LIMIT with value of 10000", async () => { - const { LOG_STREAM_CONFIG } = await import("./performance"); - expect(LOG_STREAM_CONFIG.MAX_LOGS_UPPER_LIMIT).toBe(10000); - }); }); describe("Retention Configuration", () => { @@ -192,19 +187,6 @@ describe("Performance Configuration", () => { ); }); - describe("API Rate Limiting Configuration", () => { - it.each([ - ["BATCH_INSERT_LIMIT", 100], - ["DEFAULT_PAGE_SIZE", 100], - ["MAX_PAGE_SIZE", 500], - ])("exports %s with value %d", async (key, expected) => { - const { API_CONFIG } = await import("./performance"); - // SAFETY: BATCH_INSERT_LIMIT, DEFAULT_PAGE_SIZE and MAX_PAGE_SIZE are real API_CONFIG - // members; any other key would read undefined and fail the toBe below. - expect(API_CONFIG[key as keyof typeof API_CONFIG]).toBe(expected); - }); - }); - describe("Incident Configuration", () => { // SAFETY: each row is [INCIDENT_AUTO_RESOLVE_MINUTES or unset, expected value, label] — // the literals below only take those three shapes. diff --git a/src/lib/server/config/retention.unit.test.ts b/src/lib/server/config/retention.unit.test.ts deleted file mode 100644 index 6daecb3b..00000000 --- a/src/lib/server/config/retention.unit.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; - -describe("Retention Configuration", () => { - const originalEnv = { ...process.env }; - - beforeEach(() => { - vi.resetModules(); - }); - - afterEach(() => { - process.env = originalEnv; - vi.resetModules(); - }); - - async function loadRetention(overrides: Record) { - vi.resetModules(); - - for (const [key, value] of Object.entries(overrides)) { - if (value === undefined) delete process.env[key]; - else process.env[key] = value; - } - - return import("./performance"); - } - - // SAFETY: each row is [LOG_RETENTION_DAYS or unset, expected value, label] — the - // literals below only take those three shapes. - it.each([ - [undefined, 30, "default 30"], - ["0", 0, "0 disables"], - ["-10", 30, "negative falls back to the default, never to 0 = never delete"], - ["5000", 3650, "above max clamps to 3650"], - ["90", 90, "in range"], - ["invalid", 30, "non-numeric ignored"], - ] as [string | undefined, number, string][])( - "LOG_RETENTION_DAYS=%s → %s (%s)", - async (value, expected) => { - const { RETENTION_CONFIG } = await loadRetention({ - LOG_RETENTION_DAYS: value, - }); - - expect(RETENTION_CONFIG.LOG_RETENTION_DAYS).toBe(expected); - }, - ); - - // SAFETY: each row is [LOG_CLEANUP_INTERVAL_MS or unset, expected value, label] — the - // literals below only take those three shapes. - it.each([ - [undefined, 3600000, "default 1 hour"], - ["30000", 60000, "below min clamps to 1 minute"], - ["100000000", 86400000, "above max clamps to 24 hours"], - ["1800000", 1800000, "in range"], - ["invalid", 3600000, "non-numeric ignored"], - ] as [string | undefined, number, string][])( - "LOG_CLEANUP_INTERVAL_MS=%s → %s (%s)", - async (value, expected) => { - const { RETENTION_CONFIG } = await loadRetention({ - LOG_CLEANUP_INTERVAL_MS: value, - }); - - expect(RETENTION_CONFIG.LOG_CLEANUP_INTERVAL_MS).toBe(expected); - }, - ); -}); diff --git a/src/lib/server/utils/api-key.unit.test.ts b/src/lib/server/utils/api-key.unit.test.ts index 077028d1..454e3b23 100644 --- a/src/lib/server/utils/api-key.unit.test.ts +++ b/src/lib/server/utils/api-key.unit.test.ts @@ -96,17 +96,6 @@ describe("API key cache invalidation races", () => { await expect(afterRotation).rejects.toBeInstanceOf(ApiKeyError); }); - it("caches successful lookups so a second request skips the database", async () => { - let reads = 0; - - const db = stubDb(async () => { - reads++; - - return [{ id: "project-2" }]; - }); - - await expect(validateApiKey(request(key), db)).resolves.toBe("project-2"); - await expect(validateApiKey(request(key), db)).resolves.toBe("project-2"); - expect(reads).toBe(1); - }); + // Plain cache-hit behavior (a second lookup skips the database) is asserted with a + // stronger DB-read spy in tests/integration/utils/api-key.integration.test.ts. }); diff --git a/src/lib/server/utils/csv-serializer.unit.test.ts b/src/lib/server/utils/csv-serializer.unit.test.ts index c9e09e1c..a136b1a7 100644 --- a/src/lib/server/utils/csv-serializer.unit.test.ts +++ b/src/lib/server/utils/csv-serializer.unit.test.ts @@ -2,7 +2,9 @@ import { describe, expect, test } from "vite-plus/test"; import { escapeCSVField } from "./csv-serializer"; describe("escapeCSVField", () => { - const escaping: Array<[string | number | null | undefined, string, string]> = [ + // Last column labels the input class. The `formula` rows double as the OWASP CSV + // formula-injection guard: a leading = + - @ must get a ' prefix. + const cases: Array<[string | number | null | undefined, string, string]> = [ [null, "", "null"], [undefined, "", "undefined"], ["", "", "empty string"], @@ -11,27 +13,18 @@ describe("escapeCSVField", () => { ["simple text", "simple text", "plain text"], ["42", "42", "numeric string"], ["test-value", "test-value", "dash"], - ["normal text", "normal text", "safe value"], ["hello, world", '"hello, world"', "comma"], ['say "hello"', '"say ""hello"""', "quotes doubled"], ["line1\nline2", '"line1\nline2"', "newline"], - ['error: "value", unexpected', '"error: ""value"", unexpected"', "comma+quotes"], + ["=cmd|/C calc", "'=cmd|/C calc", "formula ="], + ["+cmd|/C calc", "'+cmd|/C calc", "formula +"], + ["-cmd|/C calc", "'-cmd|/C calc", "formula -"], + ["@SUM(A1:A10)", "'@SUM(A1:A10)", "formula @"], + ["=formula, with comma", '"\'=formula, with comma"', "formula & comma"], + ['+formula "with" quotes', '"\'+formula ""with"" quotes"', "formula & quotes"], ]; - test.each(escaping)("escapeCSVField(%s) returns %s (%s)", (input, expected) => { - expect(escapeCSVField(input)).toBe(expected); - }); - - // OWASP CSV formula-injection guard: leading = + - @ get a ' prefix - test.each([ - ["=cmd|/C calc", "'=cmd|/C calc"], - ["+cmd|/C calc", "'+cmd|/C calc"], - ["-cmd|/C calc", "'-cmd|/C calc"], - ["@SUM(A1:A10)", "'@SUM(A1:A10)"], - ["+1234567890", "'+1234567890"], - ["=formula, with comma", '"\'=formula, with comma"'], - ['+formula "with" quotes', '"\'+formula ""with"" quotes"'], - ])("prefixes formula %s as %s", (input, expected) => { + test.each(cases)("escapeCSVField(%s) returns %s (%s)", (input, expected) => { expect(escapeCSVField(input)).toBe(expected); }); }); diff --git a/src/lib/server/utils/cursor.unit.test.ts b/src/lib/server/utils/cursor.unit.test.ts index d4f01938..afaed8f5 100644 --- a/src/lib/server/utils/cursor.unit.test.ts +++ b/src/lib/server/utils/cursor.unit.test.ts @@ -14,7 +14,6 @@ describe("cursor utilities", () => { it.each([ ["log_123"], ["log_with_underscores_123"], - ["log_456"], ["log-with-dashes-789"], ["Log_MixedCase_123"], ])("roundtrips id %s", (id) => { diff --git a/src/lib/server/utils/otlp.unit.test.ts b/src/lib/server/utils/otlp.unit.test.ts index 6a3cc321..6a58a0b9 100644 --- a/src/lib/server/utils/otlp.unit.test.ts +++ b/src/lib/server/utils/otlp.unit.test.ts @@ -182,19 +182,7 @@ describe("parseUint64String", () => { expect(parseUint64String(input)).toBe(expected); }); - it.each([ - ["-1"], - ["-1000000"], - [" -42 "], - [-1], - [-1000000], - [1.5], - [-1.5], - ["abc"], - ["1.5"], - [""], - [" "], - ])("rejects %s", (input) => { + it.each([["-1"], [-1], [1.5], [-1.5], ["abc"], ["1.5"], [""]])("rejects %s", (input) => { expect(parseUint64String(input)).toBeNull(); }); }); @@ -311,17 +299,15 @@ describe("zero timeUnixNano handling", () => { }); describe("normalizeOtlpLogsRequest edge cases", () => { - it("rejects negative timeUnixNano and falls back to current timestamp", () => { - const payload = { + it("rejects negative timeUnixNano and observedTimeUnixNano and falls back to current timestamp", () => { + const payload: JsonValue = { resourceLogs: [ { scopeLogs: [ { logRecords: [ - { - timeUnixNano: "-1000000", - body: { stringValue: "test message" }, - }, + { timeUnixNano: "-1000000", body: { stringValue: "test message" } }, + { observedTimeUnixNano: "-1000000", body: { stringValue: "test message" } }, ], }, ], @@ -330,37 +316,18 @@ describe("normalizeOtlpLogsRequest edge cases", () => { }; const { records } = normalizeOtlpLogsRequest(payload); - expect(records).toHaveLength(1); - expect(records[0]!.timeUnixNano).toBeNull(); - const now = new Date(); - expect(records[0]!.timestamp.getTime()).toBeGreaterThanOrEqual(now.getTime() - 5000); - expect(records[0]!.timestamp.getTime()).toBeLessThanOrEqual(now.getTime() + 5000); - }); - - it("rejects negative observedTimeUnixNano and falls back to current timestamp", () => { - const payload = { - resourceLogs: [ - { - scopeLogs: [ - { - logRecords: [ - { - observedTimeUnixNano: "-1000000", - body: { stringValue: "test message" }, - }, - ], - }, - ], - }, - ], - }; + expect(records).toHaveLength(2); - const { records } = normalizeOtlpLogsRequest(payload); - expect(records).toHaveLength(1); - expect(records[0]!.observedTimeUnixNano).toBeNull(); const now = new Date(); - expect(records[0]!.timestamp.getTime()).toBeGreaterThanOrEqual(now.getTime() - 5000); - expect(records[0]!.timestamp.getTime()).toBeLessThanOrEqual(now.getTime() + 5000); + + for (const record of records) { + // Each record feeds one negative candidate; both raw columns must read as unset... + expect(record.timeUnixNano).toBeNull(); + expect(record.observedTimeUnixNano).toBeNull(); + // ...and the derived timestamp must fall back to ~now, never a pre-1970 date. + expect(record.timestamp.getTime()).toBeGreaterThanOrEqual(now.getTime() - 5000); + expect(record.timestamp.getTime()).toBeLessThanOrEqual(now.getTime() + 5000); + } }); it("normalizes empty attributes to null", () => { diff --git a/src/lib/server/utils/rate-limit.unit.test.ts b/src/lib/server/utils/rate-limit.unit.test.ts index 83de573e..2f24228b 100644 --- a/src/lib/server/utils/rate-limit.unit.test.ts +++ b/src/lib/server/utils/rate-limit.unit.test.ts @@ -28,40 +28,31 @@ describe("rate-limit env parsing", () => { return import("./rate-limit"); } - // SAFETY: each row is [RATE_LIMIT_LOGIN_RPM or unset, expected limit, label] — the - // literals below only take those three shapes. + // SAFETY: each row is [RATE_LIMIT_LOGIN_RPM / RATE_LIMIT_INGEST_RPM, value or unset, expected + // limit, label] — the literals below only take those four shapes. Each variable keeps its own + // fallback rows so both exports' wiring is proven independently. it.each([ - [undefined, 10, "unset"], - ["", 10, "empty"], - ["30", 30, "in range"], - ["0", 10, "zero has no documented meaning, so it is invalid"], - ["-1", 10, "negative"], - ["0.5", 10, "fractional"], - ["abc", 10, "non-numeric"], - ["600rpm", 10, "numeric prefix with a unit"], - ["1e999", 10, "overflow"], - ] as [string | undefined, number, string][])( - "RATE_LIMIT_LOGIN_RPM=%s → %s (%s)", - async (value, expected) => { - const { LOGIN_RPM } = await loadLimit("RATE_LIMIT_LOGIN_RPM", value); - expect(LOGIN_RPM).toBe(expected); - }, - ); - - // SAFETY: each row is [RATE_LIMIT_INGEST_RPM or unset, expected limit, label] — the - // literals below only take those three shapes. - it.each([ - [undefined, 600, "unset"], - ["0", 600, "zero"], - ["-100", 600, "negative"], - ["0.9", 600, "fractional"], - ["600 rpm", 600, "numeric prefix with a unit"], - ["1200", 1200, "in range"], - ] as [string | undefined, number, string][])( - "RATE_LIMIT_INGEST_RPM=%s → %s (%s)", - async (value, expected) => { - const { INGEST_RPM } = await loadLimit("RATE_LIMIT_INGEST_RPM", value); - expect(INGEST_RPM).toBe(expected); + ["RATE_LIMIT_LOGIN_RPM", undefined, 10, "unset"], + ["RATE_LIMIT_LOGIN_RPM", "", 10, "empty"], + ["RATE_LIMIT_LOGIN_RPM", "30", 30, "in range"], + ["RATE_LIMIT_LOGIN_RPM", "0", 10, "zero has no documented meaning, so it is invalid"], + ["RATE_LIMIT_LOGIN_RPM", "-1", 10, "negative"], + ["RATE_LIMIT_LOGIN_RPM", "0.5", 10, "fractional"], + ["RATE_LIMIT_LOGIN_RPM", "abc", 10, "non-numeric"], + ["RATE_LIMIT_LOGIN_RPM", "600rpm", 10, "numeric prefix with a unit"], + ["RATE_LIMIT_LOGIN_RPM", "1e999", 10, "overflow"], + ["RATE_LIMIT_INGEST_RPM", undefined, 600, "unset"], + ["RATE_LIMIT_INGEST_RPM", "0", 600, "zero"], + ["RATE_LIMIT_INGEST_RPM", "-100", 600, "negative"], + ["RATE_LIMIT_INGEST_RPM", "0.9", 600, "fractional"], + ["RATE_LIMIT_INGEST_RPM", "600 rpm", 600, "numeric prefix with a unit"], + ["RATE_LIMIT_INGEST_RPM", "1200", 1200, "in range"], + ] as [string, string | undefined, number, string][])( + "%s=%s → %s (%s)", + async (key, value, expected) => { + const { LOGIN_RPM, INGEST_RPM } = await loadLimit(key, value); + + expect(key === "RATE_LIMIT_LOGIN_RPM" ? LOGIN_RPM : INGEST_RPM).toBe(expected); }, ); diff --git a/src/lib/server/utils/search.unit.test.ts b/src/lib/server/utils/search.unit.test.ts index 9bc1843b..cec855da 100644 --- a/src/lib/server/utils/search.unit.test.ts +++ b/src/lib/server/utils/search.unit.test.ts @@ -10,7 +10,6 @@ describe("buildSearchQuery", () => { ["", "", "empty string"], [" ", "", "whitespace only"], ["error & warning", "error & warning", "ampersand"], - ["error | warning", "error & warning", "pipe"], ["error! warning", "error & warning", "exclamation"], ["error (warning) info", "error & warning & info", "parens"], ["error:warning", "error & warning", "colon splits terms"], @@ -19,13 +18,11 @@ describe("buildSearchQuery", () => { ["error's warning", "error & s & warning", "single quote splits terms"], ['error "warning" info', "error & warning & info", "double quotes"], ["error!|&* (warning)", "error & warning", "combined specials"], + // A hyphenated word also indexes its parts, so the logs-query integration test for + // `search=user-service` passes even if hyphens were split; this row is the only guard + // that a hyphenated term reaches tsquery intact. ["error-500 database-connection", "error-500 & database-connection", "hyphens kept"], - ["user_id error_message", "user_id & error_message", "underscores kept"], - [ - "Database connection failed! (timeout: 30s)", - "Database & connection & failed & timeout & 30s", - "real-world mix", - ], + ["user_ID error_CODE", "user_ID & error_CODE", "underscores and case kept"], ])("buildSearchQuery(%s) returns %s (%s)", (input, expected) => { expect(buildSearchQuery(input)).toBe(expected); }); diff --git a/src/lib/server/utils/simple-ingest.unit.test.ts b/src/lib/server/utils/simple-ingest.unit.test.ts index 3187fbf6..333b828e 100644 --- a/src/lib/server/utils/simple-ingest.unit.test.ts +++ b/src/lib/server/utils/simple-ingest.unit.test.ts @@ -44,7 +44,6 @@ describe("parseSimpleIngestRequest", () => { // SAFETY: every row is a [payload, expected error, label] triple whose payload is a JSON value. it.each([ [{ message: "test" }, "missing required field 'level'", "missing level"], - [{ level: "invalid", message: "test" }, "invalid level 'invalid'", "invalid level"], [{ level: "info" }, "missing required field 'message'", "missing message"], [{ level: "info", message: 123 }, "message must be a string", "non-string message"], [{ level: "info", message: " " }, "message cannot be empty", "empty message"], @@ -159,10 +158,9 @@ describe("parseSimpleIngestRequest", () => { describe("metadata extraction", () => { // SAFETY: every row is a [metadata, column, expected, label] triple whose column is one of NormalizedSimpleLog's identity fields. + // The three OTLP keys (request.id / enduser.id / client.address) are asserted + // end-to-end in tests/integration/simple-ingest/logs.integration.test.ts. it.each([ - [{ "request.id": "req-123" }, "requestId", "req-123", "OTLP request key"], - [{ "enduser.id": "user-456" }, "userId", "user-456", "OTLP user key"], - [{ "client.address": "192.168.1.1" }, "ipAddress", "192.168.1.1", "OTLP ip key"], [{ request_id: "req-789" }, "requestId", "req-789", "fallback request key"], [{ user_id: "user-999" }, "userId", "user-999", "fallback user key"], [{ ip_address: "10.0.0.1" }, "ipAddress", "10.0.0.1", "fallback ip key"], diff --git a/src/lib/shared/schemas/log-level.unit.test.ts b/src/lib/shared/schemas/log-level.unit.test.ts index 10f4453f..d9299766 100644 --- a/src/lib/shared/schemas/log-level.unit.test.ts +++ b/src/lib/shared/schemas/log-level.unit.test.ts @@ -2,10 +2,6 @@ import { describe, expect, it } from "vite-plus/test"; import { LOG_LEVELS, logLevelSchema, parseLevelFilter } from "./log"; describe("logLevelSchema", () => { - it.each([...LOG_LEVELS])("accepts valid level %s", (level) => { - expect(logLevelSchema.safeParse(level).success).toBe(true); - }); - it("rejects invalid log level", () => { expect(logLevelSchema.safeParse("invalid").success).toBe(false); }); @@ -18,8 +14,6 @@ describe("parseLevelFilter", () => { it.each<[string, string[] | null, string]>([ ["critical,trace", null, "all invalid"], - ["error", ["error"], "single level"], - ["error,fatal", ["error", "fatal"], "comma-separated"], [" warn , info ", ["warn", "info"], "trims whitespace"], ["ERROR,WARN", ["error", "warn"], "lowercases"], ["error,critical,fatal", ["error", "fatal"], "drops invalid"], diff --git a/src/lib/shared/schemas/project.unit.test.ts b/src/lib/shared/schemas/project.unit.test.ts index b01dfcac..f35b568d 100644 --- a/src/lib/shared/schemas/project.unit.test.ts +++ b/src/lib/shared/schemas/project.unit.test.ts @@ -3,53 +3,22 @@ import { projectCreatePayloadSchema, projectUpdatePayloadSchema } from "./projec describe("projectCreatePayloadSchema", () => { it.each([ - ["my-project", true, "hyphens"], - ["my-awesome-project", true, "hyphens long"], - ["my_awesome_project", true, "underscores"], - ["a", true, "single char"], - ["a".repeat(50), true, "exactly 50 chars"], - ["project123", true, "alphanumeric"], - ["", false, "empty"], - ["a".repeat(51), false, "over 50 chars"], - ["my-project@123", false, "special chars"], - ["my project", false, "spaces"], - ])("name %s valid=%s (%s)", (name, valid) => { - expect(projectCreatePayloadSchema.safeParse({ name }).success).toBe(valid); + ["my-project", "hyphens"], + ["my-awesome-project", "hyphens long"], + ["my_awesome_project", "underscores"], + ["a", "single char"], + ["a".repeat(50), "exactly 50 chars"], + ["project123", "alphanumeric"], + ])("accepts name %s (%s)", (name) => { + expect(projectCreatePayloadSchema.safeParse({ name }).success).toBe(true); }); }); describe("projectUpdatePayloadSchema with retentionDays", () => { - it.each<[number | null, boolean, string]>([ - [null, true, "system default"], - [0, true, "never delete"], - [1, true, "min positive"], - [30, true, "typical"], - [3650, true, "max"], - [-1, false, "negative"], - [3.5, false, "non-integer"], - [3651, false, "over max"], - ])("retentionDays %s valid=%s (%s)", (retentionDays, valid) => { - expect(projectUpdatePayloadSchema.safeParse({ retentionDays }).success).toBe(valid); - }); - - it("allows omitting retentionDays (optional field)", () => { - const result = projectUpdatePayloadSchema.safeParse({ name: "updated-project" }); - expect(result.success).toBe(true); - - if (result.success) expect(result.data.retentionDays).toBeUndefined(); - }); - - it("allows both name and retentionDays together", () => { - const result = projectUpdatePayloadSchema.safeParse({ - name: "updated-project", - retentionDays: 30, - }); - - expect(result.success).toBe(true); - - if (result.success) { - expect(result.data.name).toBe("updated-project"); - expect(result.data.retentionDays).toBe(30); - } + it.each([ + [1, "min positive"], + [3650, "max"], + ])("accepts retentionDays %s (%s)", (retentionDays) => { + expect(projectUpdatePayloadSchema.safeParse({ retentionDays }).success).toBe(true); }); }); diff --git a/src/lib/utils/format.unit.test.ts b/src/lib/utils/format.unit.test.ts index 310535b8..6b9f3256 100644 --- a/src/lib/utils/format.unit.test.ts +++ b/src/lib/utils/format.unit.test.ts @@ -4,9 +4,7 @@ import type { TimeRange } from "./time-range"; describe("formatTimestamp", () => { it.each([ ["2024-01-15T14:30:45.123Z", "14:30:45.123", "afternoon time"], - ["2024-01-15T09:15:30.456Z", "09:15:30.456", "morning time"], ["2024-01-15T00:00:00.000Z", "00:00:00.000", "midnight"], - ["2024-01-15T12:00:00.000Z", "12:00:00.000", "noon"], ["2024-01-15T01:05:08.100Z", "01:05:08.100", "single-digit hours"], ["2024-01-15T14:05:45.123Z", "14:05:45.123", "single-digit minutes"], ["2024-01-15T14:30:05.123Z", "14:30:05.123", "single-digit seconds"], @@ -32,7 +30,6 @@ describe("formatRelativeTime", () => { [0, "just now", "current time"], [4 * 1000, "just now", "less than 5 seconds ago"], [5 * 1000, "5 seconds ago", "5 seconds ago"], - [30 * 1000, "30 seconds ago", "30 seconds ago"], [59 * 1000, "59 seconds ago", "59 seconds ago"], ])('formatRelativeTime(%i ms ago) returns "%s" (%s)', (offset, expected) => { const date = new Date(now.getTime() - offset); @@ -44,7 +41,6 @@ describe("formatRelativeTime", () => { it.each([ [60 * 1000, "1 minute ago", "60 seconds ago"], [2 * 60 * 1000, "2 minutes ago", "2 minutes ago"], - [15 * 60 * 1000, "15 minutes ago", "15 minutes ago"], [59 * 60 * 1000, "59 minutes ago", "59 minutes ago"], ])('formatRelativeTime(%i ms ago) returns "%s" (%s)', (offset, expected) => { const date = new Date(now.getTime() - offset); @@ -56,7 +52,6 @@ describe("formatRelativeTime", () => { it.each([ [60 * 60 * 1000, "1 hour ago", "60 minutes ago"], [2 * 60 * 60 * 1000, "2 hours ago", "2 hours ago"], - [12 * 60 * 60 * 1000, "12 hours ago", "12 hours ago"], [23 * 60 * 60 * 1000, "23 hours ago", "23 hours ago"], ])('formatRelativeTime(%i ms ago) returns "%s" (%s)', (offset, expected) => { const date = new Date(now.getTime() - offset); @@ -68,7 +63,6 @@ describe("formatRelativeTime", () => { it.each([ [24 * 60 * 60 * 1000, "1 day ago", "24 hours ago"], [2 * 24 * 60 * 60 * 1000, "2 days ago", "2 days ago"], - [7 * 24 * 60 * 60 * 1000, "7 days ago", "7 days ago"], [30 * 24 * 60 * 60 * 1000, "30 days ago", "30 days ago"], ])('formatRelativeTime(%i ms ago) returns "%s" (%s)', (offset, expected) => { const date = new Date(now.getTime() - offset); @@ -116,29 +110,14 @@ describe("getTimeRangeStart", () => { expect(result.getTime()).toBeGreaterThanOrEqual(before - 60 * 60 * 1000); expect(result.getTime()).toBeLessThanOrEqual(after - 60 * 60 * 1000); }); - - it("returns a Date object for all valid ranges", () => { - expect(getTimeRangeStart("15m", now)).toBeInstanceOf(Date); - expect(getTimeRangeStart("1h", now)).toBeInstanceOf(Date); - expect(getTimeRangeStart("24h", now)).toBeInstanceOf(Date); - expect(getTimeRangeStart("7d", now)).toBeInstanceOf(Date); - }); - - it("preserves millisecond precision", () => { - const result = getTimeRangeStart("1h", now); - expect(result.getMilliseconds()).toBe(now.getMilliseconds()); - }); }); describe("formatFullDate", () => { it.each([ ["2024-01-15T14:30:45.123Z", "2024-01-15 14:30:45.123 UTC", "afternoon time"], - ["2024-06-20T08:15:30.456Z", "2024-06-20 08:15:30.456 UTC", "morning time"], ["2024-01-01T00:00:00.000Z", "2024-01-01 00:00:00.000 UTC", "midnight"], ["2024-12-31T23:59:59.999Z", "2024-12-31 23:59:59.999 UTC", "end of year"], - ["2024-01-15T14:30:45.123Z", "2024-01-15 14:30:45.123 UTC", "single-digit month"], ["2024-01-05T14:30:45.123Z", "2024-01-05 14:30:45.123 UTC", "single-digit day"], - ["2024-11-20T14:30:45.123Z", "2024-11-20 14:30:45.123 UTC", "double-digit month"], ["2024-01-15T14:30:45.001Z", "2024-01-15 14:30:45.001 UTC", "single-digit milliseconds"], ["2024-01-15T14:30:45.010Z", "2024-01-15 14:30:45.010 UTC", "double-digit milliseconds"], ])("formatFullDate(%s) returns %s (%s)", (input, expected) => { diff --git a/src/lib/utils/keyboard.unit.test.ts b/src/lib/utils/keyboard.unit.test.ts index 7e7687e3..81e05fad 100644 --- a/src/lib/utils/keyboard.unit.test.ts +++ b/src/lib/utils/keyboard.unit.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vite-plus/test"; -import { FORM_ELEMENTS, SHORTCUTS, shouldBlockShortcut } from "./keyboard"; +import { shouldBlockShortcut } from "./keyboard"; function createMockKeyboardEvent(options: { targetTagName?: string | null; @@ -23,50 +23,18 @@ function createMockKeyboardEvent(options: { } describe("shouldBlockShortcut", () => { - it.each<[Parameters[0], string]>([ - [{ targetTagName: "INPUT" }, "form input"], - [{ targetTagName: "TEXTAREA" }, "form textarea"], - [{ targetTagName: "SELECT" }, "form select"], - [{ isComposing: true }, "IME composition"], - [{ ctrlKey: true }, "ctrl"], - [{ altKey: true }, "alt"], - [{ metaKey: true }, "meta"], - [{ ctrlKey: true, altKey: true }, "multiple modifiers"], - ])("blocks shortcut (%s)", (options) => { - expect(shouldBlockShortcut(createMockKeyboardEvent(options))).toBe(true); - }); - - it.each([["DIV"], ["TABLE"], ["BUTTON"], ["BODY"]])("allows shortcut for %s target", (tag) => { - expect(shouldBlockShortcut(createMockKeyboardEvent({ targetTagName: tag }))).toBe(false); - }); - - it("handles null target gracefully", () => { - expect(shouldBlockShortcut(createMockKeyboardEvent({ targetTagName: null }))).toBe(false); - }); -}); - -describe("FORM_ELEMENTS", () => { - it("contains exactly INPUT, TEXTAREA, and SELECT", () => { - expect(FORM_ELEMENTS).toEqual(["INPUT", "TEXTAREA", "SELECT"]); - }); -}); - -describe("SHORTCUTS", () => { - it("covers navigation, search, and other groups with required shape", () => { - const keys = SHORTCUTS.map((s) => s.key); - - for (const key of ["j", "k", "Enter", "/", "Esc", "l", "?"]) { - expect(keys).toContain(key); - } - - for (const shortcut of SHORTCUTS) { - expect(shortcut.key).toEqual(expect.any(String)); - expect(shortcut.description).toEqual(expect.any(String)); - expect(["navigation", "search", "other"]).toContain(shortcut.group); - } - - for (const group of ["navigation", "search", "other"]) { - expect(SHORTCUTS.some((s) => s.group === group)).toBe(true); - } + it.each<[Parameters[0], boolean, string]>([ + [{ targetTagName: "INPUT" }, true, "form input"], + [{ targetTagName: "TEXTAREA" }, true, "form textarea"], + [{ targetTagName: "SELECT" }, true, "form select"], + [{ isComposing: true }, true, "IME composition"], + [{ ctrlKey: true }, true, "ctrl modifier"], + [{ altKey: true }, true, "alt modifier"], + [{ metaKey: true }, true, "meta modifier"], + [{ targetTagName: "DIV" }, false, "plain element"], + [{ targetTagName: "BUTTON" }, false, "button element"], + [{ targetTagName: null }, false, "null target"], + ])("shouldBlockShortcut(%o) returns %s (%s)", (options, expected) => { + expect(shouldBlockShortcut(createMockKeyboardEvent(options))).toBe(expected); }); }); diff --git a/src/lib/utils/log-sort.unit.test.ts b/src/lib/utils/log-sort.unit.test.ts index 85bd9e16..f0188ad7 100644 --- a/src/lib/utils/log-sort.unit.test.ts +++ b/src/lib/utils/log-sort.unit.test.ts @@ -69,10 +69,9 @@ describe("sortLogs", () => { expect(sortLogs(sampleLogs, key, dir)).toBe(sampleLogs); }); + // Timestamp/level ascending and timestamp descending are covered end-to-end + // by log-table.component.test.ts; keep the remaining directions here. it.each<[SortField, Exclude, string[]]>([ - ["timestamp", "asc", ["log_1", "log_3", "log_2"]], - ["timestamp", "desc", ["log_2", "log_3", "log_1"]], - ["level", "asc", ["log_2", "log_3", "log_1"]], ["level", "desc", ["log_1", "log_3", "log_2"]], ["message", "asc", ["log_2", "log_1", "log_3"]], ["message", "desc", ["log_3", "log_1", "log_2"]], diff --git a/src/lib/utils/time-range.unit.test.ts b/src/lib/utils/time-range.unit.test.ts index b9e4cd66..2e91d53a 100644 --- a/src/lib/utils/time-range.unit.test.ts +++ b/src/lib/utils/time-range.unit.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vite-plus/test"; -import { TIME_RANGES, parseTimeRange } from "./time-range"; +import { parseTimeRange } from "./time-range"; describe("parseTimeRange", () => { it("returns null for null input", () => { @@ -16,10 +16,8 @@ describe("parseTimeRange", () => { expect(parseTimeRange("30m")).toBeNull(); }); - it.each(TIME_RANGES)("returns %s for valid range input %s", (range) => { - expect(parseTimeRange(range)).toBe(range); - }); - + // Successful parsing of every valid range is asserted end-to-end by the + // timeseries/stats integration tests and the picker component tests. it("is case-sensitive (uppercase variants are invalid)", () => { expect(parseTimeRange("15M")).toBeNull(); expect(parseTimeRange("1H")).toBeNull(); diff --git a/src/lib/utils/timeseries.ts b/src/lib/utils/timeseries.ts index 2e5ca302..52cf68dc 100644 --- a/src/lib/utils/timeseries.ts +++ b/src/lib/utils/timeseries.ts @@ -25,22 +25,6 @@ export function getTimeBucketConfig(range: TimeRange): TimeBucketConfig { } } -export function bucketTimestamps(timestamps: Date[], config: TimeBucketConfig, rangeStart: Date) { - const buckets: Record = {}; - const startMs = rangeStart.getTime(); - - for (const ts of timestamps) { - const offsetMs = ts.getTime() - startMs; - const bucketIndex = Math.floor(offsetMs / config.intervalMs); - - if (bucketIndex >= 0 && bucketIndex < config.expectedBuckets) { - buckets[bucketIndex] = (buckets[bucketIndex] || 0) + 1; - } - } - - return buckets; -} - export function fillMissingBuckets( bucketCounts: Record, config: TimeBucketConfig, diff --git a/src/lib/utils/timeseries.unit.test.ts b/src/lib/utils/timeseries.unit.test.ts index b2ce1532..f97df615 100644 --- a/src/lib/utils/timeseries.unit.test.ts +++ b/src/lib/utils/timeseries.unit.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; import type { TimeRange } from "./time-range"; -import { bucketTimestamps, fillMissingBuckets, getTimeBucketConfig } from "./timeseries"; +import { fillMissingBuckets, getTimeBucketConfig } from "./timeseries"; describe("getTimeBucketConfig", () => { it.each<[TimeRange, number, number]>([ @@ -15,62 +15,6 @@ describe("getTimeBucketConfig", () => { }); }); -describe("bucketTimestamps", () => { - it("groups timestamps into correct buckets", () => { - const rangeStart = new Date("2024-01-15T10:00:00.000Z"); - const config = { intervalMs: 60 * 60 * 1000, expectedBuckets: 24 }; - - const timestamps = [ - new Date("2024-01-15T10:15:00.000Z"), - new Date("2024-01-15T10:45:00.000Z"), - new Date("2024-01-15T11:30:00.000Z"), - ]; - - const buckets = bucketTimestamps(timestamps, config, rangeStart); - - expect(buckets[0]).toBe(2); - expect(buckets[1]).toBe(1); - }); - - it("handles timestamps exactly on bucket boundaries", () => { - const rangeStart = new Date("2024-01-15T10:00:00.000Z"); - const config = { intervalMs: 60 * 60 * 1000, expectedBuckets: 24 }; - - const timestamps = [new Date("2024-01-15T10:00:00.000Z"), new Date("2024-01-15T11:00:00.000Z")]; - - const buckets = bucketTimestamps(timestamps, config, rangeStart); - - expect(buckets[0]).toBe(1); - expect(buckets[1]).toBe(1); - }); - - it("returns empty object for empty input", () => { - const rangeStart = new Date("2024-01-15T10:00:00.000Z"); - const config = { intervalMs: 60 * 60 * 1000, expectedBuckets: 24 }; - - const buckets = bucketTimestamps([], config, rangeStart); - - expect(buckets).toEqual({}); - }); - - it("ignores timestamps outside the expected bucket range", () => { - const rangeStart = new Date("2024-01-15T10:00:00.000Z"); - const config = { intervalMs: 60 * 60 * 1000, expectedBuckets: 3 }; - - const timestamps = [ - new Date("2024-01-15T09:00:00.000Z"), - new Date("2024-01-15T10:30:00.000Z"), - new Date("2024-01-15T15:00:00.000Z"), - ]; - - const buckets = bucketTimestamps(timestamps, config, rangeStart); - - expect(buckets[0]).toBe(1); - expect(buckets[-1]).toBeUndefined(); - expect(buckets[5]).toBeUndefined(); - }); -}); - describe("fillMissingBuckets", () => { it("fills gaps between buckets with zero count", () => { const rangeStart = new Date("2024-01-15T10:00:00.000Z"); @@ -87,17 +31,6 @@ describe("fillMissingBuckets", () => { expect(result[2]!.count).toBe(3); }); - it("generates all buckets for completely empty input", () => { - const rangeStart = new Date("2024-01-15T10:00:00.000Z"); - const rangeEnd = new Date("2024-01-15T13:00:00.000Z"); - const config = { intervalMs: 60 * 60 * 1000, expectedBuckets: 3 }; - - const result = fillMissingBuckets({}, config, rangeStart, rangeEnd); - - expect(result).toHaveLength(3); - expect(result.every((b) => b.count === 0)).toBe(true); - }); - it("preserves existing bucket counts", () => { const rangeStart = new Date("2024-01-15T10:00:00.000Z"); const rangeEnd = new Date("2024-01-15T12:00:00.000Z"); @@ -110,15 +43,4 @@ describe("fillMissingBuckets", () => { expect(result[0]!.count).toBe(10); expect(result[1]!.count).toBe(20); }); - - it("returns buckets with valid ISO timestamps", () => { - const rangeStart = new Date("2024-01-15T10:00:00.000Z"); - const rangeEnd = new Date("2024-01-15T12:00:00.000Z"); - const config = { intervalMs: 60 * 60 * 1000, expectedBuckets: 2 }; - - const result = fillMissingBuckets({}, config, rangeStart, rangeEnd); - - expect(result[0]!.timestamp).toBe("2024-01-15T10:00:00.000Z"); - expect(result[1]!.timestamp).toBe("2024-01-15T11:00:00.000Z"); - }); }); diff --git a/src/lib/utils/toast.unit.test.ts b/src/lib/utils/toast.unit.test.ts index 086cfb29..6bb019e9 100644 --- a/src/lib/utils/toast.unit.test.ts +++ b/src/lib/utils/toast.unit.test.ts @@ -12,16 +12,9 @@ vi.mock("svelte-sonner", () => ({ }, })); -import { toastError, toastSuccess } from "./toast"; +import { toastError } from "./toast"; describe("Toast Utility", () => { - it("toastSuccess forwards message and options", () => { - toastSuccess("Operation completed"); - expect(sonner.toast.success).toHaveBeenCalledWith("Operation completed", undefined); - toastSuccess("Done", { duration: 3000 }); - expect(sonner.toast.success).toHaveBeenCalledWith("Done", { duration: 3000 }); - }); - it.each<[Parameters[0], string, string]>([ ["Something went wrong", "Something went wrong", "plain message"], [new Error("Database connection failed"), "Database connection failed", "Error object"], diff --git a/tests/README.md b/tests/README.md index 29aac516..1d79daa6 100644 --- a/tests/README.md +++ b/tests/README.md @@ -1,12 +1,12 @@ # Testing Infrastructure -This project follows the Testing Trophy methodology, prioritizing integration tests while maintaining comprehensive coverage across all test types. +This project follows the Testing Trophy methodology: confidence per test, weighted toward integration. Test tiers are selected by filename suffix, and each tier runs with its own command. ## Test Structure ### Unit Tests (`.unit.test.ts`) -Located alongside source files in `src/`. Tests pure functions and utilities in isolation. +Located alongside source files in `src/`. Cover the boundaries higher tiers cannot reach cheaply: parsers, validators, security-sensitive configuration, state machines. ```bash pnpm run test:unit @@ -36,13 +36,18 @@ Located in `tests/e2e/`. Full end-to-end tests using Playwright across multiple pnpm run test:e2e ``` +## What Belongs in Which Tier + +- **Integration carries the weight.** Orchestration — routes, DB, auth, CSRF, ingest pipeline, SSE streams — runs through the real handler against PGlite. +- **Unit tests hold the boundaries.** Parsers, validators, security-sensitive configuration, and state machines stay unit-tested where integration cannot exercise them cheaply. +- **No overlapping assertions.** If a higher tier already asserts a behavior, do not re-assert it below. A component test that mocks or re-implements a module does not count as coverage of that module. +- **Table tests keep their inputs.** Deduplicate rows freely, but preserve every distinct boundary, type, and error input. +- **No coverage quotas.** Coverage is a signal for finding untested behavior, never a target, and no tier carries a required case count. + ## Running Tests ```bash -# Run all tests (unit + integration) -pnpm run test - -# Run tests in watch mode +# Run all Vitest tiers (unit + component + integration) pnpm run test # Run specific test types @@ -96,14 +101,9 @@ describe("My Integration Test", () => { }); ``` -## Coverage Thresholds - -The project maintains the following coverage thresholds: +## Coverage -- Lines: 75% -- Functions: 75% -- Branches: 65% -- Statements: 75% +Coverage is collected with the v8 provider via `pnpm run test:coverage`. It is signal-only: `vitest.config.ts` defines no coverage thresholds and CI runs the report without a gate. Use it to find untested behavior, not as a target. ## Tech Stack