diff --git a/backend/tests/e2e/test_replay_analytics.py b/backend/tests/e2e/test_replay_analytics.py index 26d7724..82a6c9f 100644 --- a/backend/tests/e2e/test_replay_analytics.py +++ b/backend/tests/e2e/test_replay_analytics.py @@ -47,7 +47,13 @@ class ReplayPool(FakePool): if "COUNT" in query and "conversations" in query: return FakeCursor([(len(self._convos),)]) if "conversations" in query and "SELECT" in query: - return FakeCursor(self._convos) + # Respect LIMIT/OFFSET from params if provided + rows = self._convos + if params: + offset = params.get("offset", 0) + limit = params.get("limit", len(rows)) + rows = rows[offset : offset + limit] + return FakeCursor(rows) if "checkpoints" in query: return FakeCursor(self._checkpoints) # Analytics queries @@ -122,6 +128,11 @@ class TestFlow6ReplayConversation: assert resp.status_code == 200 body = resp.json() assert body["success"] is True + data = body["data"] + assert data["total"] == 5 + assert data["page"] == 1 + assert data["per_page"] == 2 + assert len(data["conversations"]) == 2 def test_replay_thread_not_found(self) -> None: pool = ReplayPool(checkpoints=[]) diff --git a/backend/tests/unit/analytics/test_queries.py b/backend/tests/unit/analytics/test_queries.py index 3bd9868..5a74272 100644 --- a/backend/tests/unit/analytics/test_queries.py +++ b/backend/tests/unit/analytics/test_queries.py @@ -158,6 +158,42 @@ class TestInterruptStatsQuery: assert result.expired == 0 +class TestTotalConversations: + @pytest.mark.asyncio + async def test_returns_count(self) -> None: + from app.analytics.queries import _total_conversations + + pool = _make_pool_with_fetchone({"total": 42}) + result = await _total_conversations(pool, range_days=7) + assert result == 42 + + @pytest.mark.asyncio + async def test_zero_state_returns_zero(self) -> None: + from app.analytics.queries import _total_conversations + + pool = _make_pool_with_fetchone(None) + result = await _total_conversations(pool, range_days=7) + assert result == 0 + + +class TestAvgTurns: + @pytest.mark.asyncio + async def test_returns_float(self) -> None: + from app.analytics.queries import _avg_turns + + pool = _make_pool_with_fetchone({"avg_turns": 3.5}) + result = await _avg_turns(pool, range_days=7) + assert result == 3.5 + + @pytest.mark.asyncio + async def test_zero_state_returns_zero(self) -> None: + from app.analytics.queries import _avg_turns + + pool = _make_pool_with_fetchone(None) + result = await _avg_turns(pool, range_days=7) + assert result == 0.0 + + class TestGetAnalytics: @pytest.mark.asyncio async def test_returns_analytics_result(self) -> None: diff --git a/backend/tests/unit/replay/test_api.py b/backend/tests/unit/replay/test_api.py index c2e179b..1acf55e 100644 --- a/backend/tests/unit/replay/test_api.py +++ b/backend/tests/unit/replay/test_api.py @@ -119,8 +119,8 @@ class TestListConversations: with TestClient(app) as client: resp = client.get("/api/conversations?per_page=200") - # FastAPI validation rejects values > 100 - assert resp.status_code in (200, 422) + # FastAPI Query(le=100) rejects values > 100 + assert resp.status_code == 422 class TestGetReplay: diff --git a/backend/tests/unit/replay/test_transformer.py b/backend/tests/unit/replay/test_transformer.py index 319c413..82af0b1 100644 --- a/backend/tests/unit/replay/test_transformer.py +++ b/backend/tests/unit/replay/test_transformer.py @@ -153,3 +153,105 @@ class TestTransformCheckpoints: rows = [_make_row([{"type": "human", "content": "Hi"}])] steps = transform_checkpoints(rows) assert isinstance(steps[0].timestamp, str) + + def test_list_content_joined_to_string(self) -> None: + from app.replay.transformer import transform_checkpoints + + rows = [ + _make_row( + [ + { + "type": "human", + "content": [ + {"text": "Hello"}, + {"text": " world"}, + ], + } + ] + ) + ] + steps = transform_checkpoints(rows) + assert len(steps) == 1 + assert steps[0].content == "Hello world" + + def test_checkpoint_as_string_skipped(self) -> None: + from app.replay.transformer import transform_checkpoints + + rows = [ + { + "thread_id": "t1", + "checkpoint_id": "cp1", + "checkpoint": "not-a-dict", + "metadata": {}, + } + ] + steps = transform_checkpoints(rows) + assert steps == [] + + def test_channel_values_not_dict_skipped(self) -> None: + from app.replay.transformer import transform_checkpoints + + rows = [ + { + "thread_id": "t1", + "checkpoint_id": "cp1", + "checkpoint": {"channel_values": "bad"}, + "metadata": {}, + } + ] + steps = transform_checkpoints(rows) + assert steps == [] + + def test_tool_result_valid_json_parsed(self) -> None: + from app.replay.transformer import transform_checkpoints + + rows = [ + _make_row( + [ + { + "type": "tool", + "content": '{"order_id": "123", "status": "shipped"}', + "name": "get_order_status", + } + ] + ) + ] + steps = transform_checkpoints(rows) + assert len(steps) == 1 + assert steps[0].result == {"order_id": "123", "status": "shipped"} + + def test_tool_result_invalid_json_wrapped(self) -> None: + from app.replay.transformer import transform_checkpoints + + rows = [ + _make_row( + [ + { + "type": "tool", + "content": "not valid json", + "name": "some_tool", + } + ] + ) + ] + steps = transform_checkpoints(rows) + assert len(steps) == 1 + assert steps[0].result == {"raw": "not valid json"} + + def test_malformed_message_skipped_gracefully(self) -> None: + from app.replay.transformer import transform_checkpoints + + rows = [ + _make_row( + [ + {"type": "human", "content": "Good message"}, + 42, # not a dict -- will raise in _step_from_message + {"type": "ai", "content": "Response", "tool_calls": []}, + ] + ) + ] + steps = transform_checkpoints(rows) + # The malformed message is skipped; the other two produce steps. + assert len(steps) == 2 + assert steps[0].step == 1 + assert steps[1].step == 2 diff --git a/backend/tests/unit/test_config.py b/backend/tests/unit/test_config.py index 85d6959..8dd19d8 100644 --- a/backend/tests/unit/test_config.py +++ b/backend/tests/unit/test_config.py @@ -89,3 +89,21 @@ class TestSettings: anthropic_api_key="key", openai_api_key="", ) + + def test_azure_openai_missing_endpoint_rejected(self) -> None: + with pytest.raises(ValueError, match="AZURE_OPENAI_ENDPOINT"): + _isolated_settings( + database_url="postgresql://x:x@localhost/db", + llm_provider="azure_openai", + azure_openai_api_key="key", + azure_openai_deployment="my-deploy", + ) + + def test_azure_openai_missing_deployment_rejected(self) -> None: + with pytest.raises(ValueError, match="AZURE_OPENAI_DEPLOYMENT"): + _isolated_settings( + database_url="postgresql://x:x@localhost/db", + llm_provider="azure_openai", + azure_openai_api_key="key", + azure_openai_endpoint="https://example.openai.azure.com", + ) diff --git a/backend/tests/unit/test_safety.py b/backend/tests/unit/test_safety.py index 861a616..6d101be 100644 --- a/backend/tests/unit/test_safety.py +++ b/backend/tests/unit/test_safety.py @@ -67,6 +67,17 @@ class TestClassifyMcpError: def test_unknown_message(self) -> None: assert classify_mcp_error(error_message="Something happened") == "unknown" + def test_status_code_takes_precedence_over_message(self) -> None: + # 429 is transient by code; message would classify as validation + assert classify_mcp_error(status_code=429, error_message="invalid param") == "transient" + + def test_non_classified_status_falls_through_to_message(self) -> None: + # 200 is not in any status set, so message classification takes over + assert classify_mcp_error(status_code=200, error_message="timed out") == "transient" + + def test_no_args_returns_unknown(self) -> None: + assert classify_mcp_error() == "unknown" + class TestRetryPolicy: def test_transient_is_retryable(self) -> None: diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 982d5f4..4be2440 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -14,13 +14,24 @@ "react-router-dom": "^7.13.2" }, "devDependencies": { + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^4.3.0", + "happy-dom": "^20.8.9", "typescript": "~5.7.0", - "vite": "^6.2.0" + "vite": "^6.2.0", + "vitest": "^4.1.2" } }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -255,6 +266,16 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", @@ -1152,6 +1173,97 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1197,6 +1309,17 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/debug": { "version": "4.1.13", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", @@ -1206,6 +1329,13 @@ "@types/ms": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1245,6 +1375,16 @@ "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", "license": "MIT" }, + "node_modules/@types/node": { + "version": "25.5.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.2.tgz", + "integrity": "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", @@ -1270,6 +1410,23 @@ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "license": "MIT" }, + "node_modules/@types/whatwg-mimetype": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", + "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@ungap/structured-clone": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", @@ -1297,6 +1454,164 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/expect": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.2.tgz", + "integrity": "sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.2", + "@vitest/utils": "4.1.2", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.2.tgz", + "integrity": "sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.2", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.2.tgz", + "integrity": "sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.2.tgz", + "integrity": "sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.2", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.2.tgz", + "integrity": "sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.2", + "@vitest/utils": "4.1.2", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.2.tgz", + "integrity": "sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.2.tgz", + "integrity": "sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.2", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/bail": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", @@ -1385,6 +1700,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/character-entities": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", @@ -1455,6 +1780,13 @@ "url": "https://opencollective.com/express" } }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1513,6 +1845,14 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/electron-to-chromium": { "version": "1.5.328", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.328.tgz", @@ -1520,6 +1860,26 @@ "dev": true, "license": "ISC" }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -1582,6 +1942,26 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -1631,6 +2011,24 @@ "node": ">=6.9.0" } }, + "node_modules/happy-dom": { + "version": "20.8.9", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.8.9.tgz", + "integrity": "sha512-Tz23LR9T9jOGVZm2x1EPdXqwA37G/owYMxRwU0E4miurAtFsPMQ1d2Jc2okUaSjZqAFz2oEn3FLXC5a0a+siyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": ">=20.0.0", + "@types/whatwg-mimetype": "^3.0.2", + "@types/ws": "^8.18.1", + "entities": "^7.0.1", + "whatwg-mimetype": "^3.0.0", + "ws": "^8.18.3" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/hast-util-to-jsx-runtime": { "version": "2.3.6", "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", @@ -1681,6 +2079,16 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/inline-style-parser": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", @@ -1796,6 +2204,27 @@ "yallist": "^3.0.2" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/mdast-util-from-markdown": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", @@ -2391,6 +2820,16 @@ ], "license": "MIT" }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -2423,6 +2862,17 @@ "dev": true, "license": "MIT" }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/parse-entities": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", @@ -2448,6 +2898,13 @@ "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", "license": "MIT" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2497,6 +2954,22 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, "node_modules/property-information": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", @@ -2528,6 +3001,14 @@ "react": "^19.2.4" } }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/react-markdown": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", @@ -2603,6 +3084,20 @@ "react-dom": ">=18" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/remark-parse": { "version": "11.0.0", "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", @@ -2703,6 +3198,13 @@ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", "license": "MIT" }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2723,6 +3225,20 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", + "dev": true, + "license": "MIT" + }, "node_modules/stringify-entities": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", @@ -2737,6 +3253,19 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/style-to-js": { "version": "1.1.21", "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", @@ -2755,6 +3284,23 @@ "inline-style-parser": "0.2.7" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", + "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -2772,6 +3318,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/trim-lines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", @@ -2806,6 +3362,13 @@ "node": ">=14.17" } }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, "node_modules/unified": { "version": "11.0.5", "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", @@ -3027,6 +3590,137 @@ } } }, + "node_modules/vitest": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.2.tgz", + "integrity": "sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.2", + "@vitest/mocker": "4.1.2", + "@vitest/pretty-format": "4.1.2", + "@vitest/runner": "4.1.2", + "@vitest/snapshot": "4.1.2", + "@vitest/spy": "4.1.2", + "@vitest/utils": "4.1.2", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.2", + "@vitest/browser-preview": "4.1.2", + "@vitest/browser-webdriverio": "4.1.2", + "@vitest/ui": "4.1.2", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 7b52305..9300d43 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,7 +6,9 @@ "scripts": { "dev": "vite", "build": "tsc -b && vite build", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "react": "^19.0.0", @@ -15,10 +17,14 @@ "react-router-dom": "^7.13.2" }, "devDependencies": { + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^4.3.0", + "happy-dom": "^20.8.9", "typescript": "~5.7.0", - "vite": "^6.2.0" + "vite": "^6.2.0", + "vitest": "^4.1.2" } } diff --git a/frontend/src/api.test.ts b/frontend/src/api.test.ts new file mode 100644 index 0000000..da6859f --- /dev/null +++ b/frontend/src/api.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { fetchConversations, fetchReplay, fetchAnalytics } from "./api"; + +// Mock global fetch +const mockFetch = vi.fn(); +vi.stubGlobal("fetch", mockFetch); + +beforeEach(() => { + mockFetch.mockReset(); +}); + +function jsonResponse(body: unknown, status = 200): Response { + return { + ok: status >= 200 && status < 300, + status, + statusText: status === 200 ? "OK" : "Error", + json: () => Promise.resolve(body), + } as Response; +} + +describe("fetchConversations", () => { + it("returns conversations page on success", async () => { + const data = { + conversations: [{ thread_id: "t1", created_at: "", last_activity: "", status: "active", total_tokens: 0, total_cost_usd: 0 }], + total: 1, + page: 1, + per_page: 20, + }; + mockFetch.mockResolvedValue(jsonResponse({ success: true, data, error: null })); + + const result = await fetchConversations(); + expect(result.conversations).toHaveLength(1); + expect(result.total).toBe(1); + expect(mockFetch).toHaveBeenCalledWith("/api/conversations?page=1&per_page=20"); + }); + + it("passes custom page and perPage", async () => { + mockFetch.mockResolvedValue(jsonResponse({ success: true, data: { conversations: [], total: 0, page: 2, per_page: 10 }, error: null })); + + await fetchConversations(2, 10); + expect(mockFetch).toHaveBeenCalledWith("/api/conversations?page=2&per_page=10"); + }); + + it("throws on HTTP error", async () => { + mockFetch.mockResolvedValue(jsonResponse({}, 500)); + + await expect(fetchConversations()).rejects.toThrow("API error 500"); + }); + + it("throws on success=false with error message", async () => { + mockFetch.mockResolvedValue(jsonResponse({ success: false, data: null, error: "Database unavailable" })); + + await expect(fetchConversations()).rejects.toThrow("Database unavailable"); + }); + + it("throws unknown error when success=false with no message", async () => { + mockFetch.mockResolvedValue(jsonResponse({ success: false, data: null, error: null })); + + await expect(fetchConversations()).rejects.toThrow("Unknown API error"); + }); +}); + +describe("fetchReplay", () => { + it("returns replay page on success", async () => { + const data = { + thread_id: "t1", + total_steps: 3, + page: 1, + per_page: 20, + steps: [{ step: 1, type: "message", content: "Hello", agent: null, tool: null, params: null, result: null, timestamp: "" }], + }; + mockFetch.mockResolvedValue(jsonResponse({ success: true, data, error: null })); + + const result = await fetchReplay("t1"); + expect(result.total_steps).toBe(3); + expect(result.steps).toHaveLength(1); + }); + + it("encodes thread_id in URL", async () => { + mockFetch.mockResolvedValue(jsonResponse({ success: true, data: { thread_id: "a/b", total_steps: 0, page: 1, per_page: 20, steps: [] }, error: null })); + + await fetchReplay("a/b"); + expect(mockFetch).toHaveBeenCalledWith("/api/replay/a%2Fb?page=1&per_page=20"); + }); + + it("throws on HTTP error", async () => { + mockFetch.mockResolvedValue(jsonResponse({}, 404)); + await expect(fetchReplay("missing")).rejects.toThrow("API error 404"); + }); +}); + +describe("fetchAnalytics", () => { + it("returns analytics data on success", async () => { + const data = { + range: "7d", + total_conversations: 100, + resolution_rate: 0.75, + escalation_rate: 0.25, + avg_turns_per_conversation: 3.5, + avg_cost_per_conversation_usd: 0.03, + agent_usage: [], + interrupt_stats: { total: 0, approved: 0, rejected: 0, expired: 0 }, + }; + mockFetch.mockResolvedValue(jsonResponse({ success: true, data, error: null })); + + const result = await fetchAnalytics("7d"); + expect(result.total_conversations).toBe(100); + expect(result.range).toBe("7d"); + }); + + it("uses default range", async () => { + mockFetch.mockResolvedValue(jsonResponse({ success: true, data: { range: "7d" }, error: null })); + + await fetchAnalytics(); + expect(mockFetch).toHaveBeenCalledWith("/api/analytics?range=7d"); + }); +}); diff --git a/frontend/src/pages/DashboardPage.test.tsx b/frontend/src/pages/DashboardPage.test.tsx new file mode 100644 index 0000000..141f7e3 --- /dev/null +++ b/frontend/src/pages/DashboardPage.test.tsx @@ -0,0 +1,70 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import { DashboardPage } from "./DashboardPage"; + +vi.mock("../api", () => ({ + fetchAnalytics: vi.fn(), +})); + +import { fetchAnalytics } from "../api"; +const mockFetchAnalytics = vi.mocked(fetchAnalytics); + +beforeEach(() => { + mockFetchAnalytics.mockReset(); +}); + +const MOCK_DATA = { + range: "30d", + total_conversations: 100, + resolution_rate: 0.75, + escalation_rate: 0.25, + avg_turns_per_conversation: 3.5, + avg_cost_per_conversation_usd: 0.03, + agent_usage: [{ agent: "order_agent", count: 50, percentage: 0.5 }], + interrupt_stats: { total: 10, approved: 8, rejected: 2, expired: 0 }, +}; + +describe("DashboardPage", () => { + it("renders loading state initially", () => { + mockFetchAnalytics.mockReturnValue(new Promise(() => {})); // never resolves + render(); + expect(document.querySelector(".skeleton-box")).toBeTruthy(); + }); + + it("renders data after successful fetch", async () => { + mockFetchAnalytics.mockResolvedValue(MOCK_DATA); + render(); + + await waitFor(() => { + expect(screen.getByText("100")).toBeInTheDocument(); + }); + expect(screen.getByText("75.0%")).toBeInTheDocument(); + expect(screen.getByText("$0.03")).toBeInTheDocument(); + }); + + it("renders error state on fetch failure", async () => { + mockFetchAnalytics.mockRejectedValue(new Error("Network error")); + render(); + + await waitFor(() => { + expect(screen.getByText("Failed to load analytics")).toBeInTheDocument(); + }); + expect(screen.getByText("Network error")).toBeInTheDocument(); + }); + + it("renders empty state when data has zero conversations", async () => { + mockFetchAnalytics.mockResolvedValue({ + ...MOCK_DATA, + total_conversations: 0, + agent_usage: [], + interrupt_stats: { total: 0, approved: 0, rejected: 0, expired: 0 }, + }); + render(); + + await waitFor(() => { + expect(screen.getByText("0")).toBeInTheDocument(); + }); + expect(screen.getByText("No agent activity recorded yet.")).toBeInTheDocument(); + expect(screen.getByText("No interrupt events recorded yet.")).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/pages/ReplayListPage.test.tsx b/frontend/src/pages/ReplayListPage.test.tsx new file mode 100644 index 0000000..f8ecb5b --- /dev/null +++ b/frontend/src/pages/ReplayListPage.test.tsx @@ -0,0 +1,106 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { ReplayListPage } from "./ReplayListPage"; + +vi.mock("../api", () => ({ + fetchConversations: vi.fn(), +})); + +import { fetchConversations } from "../api"; +const mockFetchConversations = vi.mocked(fetchConversations); + +beforeEach(() => { + mockFetchConversations.mockReset(); +}); + +function renderWithRouter() { + return render( + + + + ); +} + +describe("ReplayListPage", () => { + it("renders loading state initially", () => { + mockFetchConversations.mockReturnValue(new Promise(() => {})); + renderWithRouter(); + expect(document.querySelector(".skeleton-box")).toBeTruthy(); + }); + + it("renders empty state when no conversations", async () => { + mockFetchConversations.mockResolvedValue({ + conversations: [], + total: 0, + page: 1, + per_page: 20, + }); + renderWithRouter(); + + await waitFor(() => { + expect(screen.getByText("No conversations yet")).toBeInTheDocument(); + }); + }); + + it("renders conversation list on success", async () => { + mockFetchConversations.mockResolvedValue({ + conversations: [ + { + thread_id: "t1", + created_at: "2026-04-01T00:00:00Z", + last_activity: "2026-04-01T00:01:00Z", + status: "resolved", + total_tokens: 100, + total_cost_usd: 0.01, + }, + ], + total: 1, + page: 1, + per_page: 20, + }); + renderWithRouter(); + + await waitFor(() => { + expect(screen.getByText("t1")).toBeInTheDocument(); + }); + expect(screen.getByText("resolved")).toBeInTheDocument(); + }); + + it("renders error state on fetch failure", async () => { + mockFetchConversations.mockRejectedValue(new Error("Server down")); + renderWithRouter(); + + await waitFor(() => { + expect(screen.getByText("Failed to load conversations")).toBeInTheDocument(); + }); + expect(screen.getByText("Server down")).toBeInTheDocument(); + }); + + it("applies correct status badge classes", async () => { + mockFetchConversations.mockResolvedValue({ + conversations: [ + { thread_id: "t1", created_at: "", last_activity: "", status: "resolved", total_tokens: 0, total_cost_usd: 0 }, + { thread_id: "t2", created_at: "", last_activity: "", status: "escalated", total_tokens: 0, total_cost_usd: 0 }, + { thread_id: "t3", created_at: "", last_activity: "", status: null, total_tokens: 0, total_cost_usd: 0 }, + ], + total: 3, + page: 1, + per_page: 20, + }); + renderWithRouter(); + + await waitFor(() => { + expect(screen.getByText("resolved")).toBeInTheDocument(); + }); + + const resolvedBadge = screen.getByText("resolved"); + expect(resolvedBadge.className).toContain("status-badge--resolved"); + + const escalatedBadge = screen.getByText("escalated"); + expect(escalatedBadge.className).toContain("status-badge--escalated"); + + const activeBadge = screen.getByText("active"); + expect(activeBadge.className).toContain("status-badge--active"); + }); +}); diff --git a/frontend/src/pages/ReplayPage.test.tsx b/frontend/src/pages/ReplayPage.test.tsx new file mode 100644 index 0000000..5345daa --- /dev/null +++ b/frontend/src/pages/ReplayPage.test.tsx @@ -0,0 +1,85 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { ReplayPage } from "./ReplayPage"; + +vi.mock("../api", () => ({ + fetchReplay: vi.fn(), +})); + +vi.mock("../components/ReplayTimeline", () => ({ + ReplayTimeline: ({ steps }: { steps: unknown[] }) => ( +
{steps.length} steps
+ ), +})); + +import { fetchReplay } from "../api"; +const mockFetchReplay = vi.mocked(fetchReplay); + +beforeEach(() => { + mockFetchReplay.mockReset(); +}); + +function renderWithRoute(threadId: string) { + return render( + + + } /> + + + ); +} + +describe("ReplayPage", () => { + it("renders loading state initially", () => { + mockFetchReplay.mockReturnValue(new Promise(() => {})); + renderWithRoute("t1"); + expect(document.querySelector(".skeleton-box")).toBeTruthy(); + }); + + it("renders replay steps on success", async () => { + mockFetchReplay.mockResolvedValue({ + thread_id: "t1", + total_steps: 2, + page: 1, + per_page: 100, + steps: [ + { step: 1, type: "message", content: "Hello", agent: null, tool: null, params: null, result: null, timestamp: "2026-04-01T00:00:00Z" }, + { step: 2, type: "response", content: "Hi!", agent: "bot", tool: null, params: null, result: null, timestamp: "2026-04-01T00:00:01Z" }, + ], + }); + renderWithRoute("t1"); + + await waitFor(() => { + expect(screen.getByTestId("replay-timeline")).toBeInTheDocument(); + }); + expect(screen.getByText("2 steps")).toBeInTheDocument(); + // Thread ID appears in multiple places (header + sidebar) + expect(screen.getAllByText("t1").length).toBeGreaterThan(0); + }); + + it("renders empty state when no steps", async () => { + mockFetchReplay.mockResolvedValue({ + thread_id: "t1", + total_steps: 0, + page: 1, + per_page: 100, + steps: [], + }); + renderWithRoute("t1"); + + await waitFor(() => { + expect(screen.getByText("No replay steps found")).toBeInTheDocument(); + }); + }); + + it("renders error state on fetch failure", async () => { + mockFetchReplay.mockRejectedValue(new Error("Not found")); + renderWithRoute("t1"); + + await waitFor(() => { + expect(screen.getByText("Failed to load replay")).toBeInTheDocument(); + }); + expect(screen.getByText("Not found")).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/test-setup.ts b/frontend/src/test-setup.ts new file mode 100644 index 0000000..f149f27 --- /dev/null +++ b/frontend/src/test-setup.ts @@ -0,0 +1 @@ +import "@testing-library/jest-dom/vitest"; diff --git a/frontend/tsconfig.tsbuildinfo b/frontend/tsconfig.tsbuildinfo index b2985de..d0632af 100644 --- a/frontend/tsconfig.tsbuildinfo +++ b/frontend/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/app.tsx","./src/api.ts","./src/main.tsx","./src/types.ts","./src/vite-env.d.ts","./src/components/agentaction.tsx","./src/components/chatinput.tsx","./src/components/chatmessages.tsx","./src/components/errorbanner.tsx","./src/components/interruptprompt.tsx","./src/components/layout.tsx","./src/components/metriccard.tsx","./src/components/navbar.tsx","./src/components/replaytimeline.tsx","./src/hooks/usewebsocket.ts","./src/pages/chatpage.tsx","./src/pages/dashboardpage.tsx","./src/pages/replaylistpage.tsx","./src/pages/replaypage.tsx","./src/pages/reviewpage.tsx"],"version":"5.7.3"} \ No newline at end of file +{"root":["./src/app.tsx","./src/api.test.ts","./src/api.ts","./src/main.tsx","./src/test-setup.ts","./src/types.ts","./src/vite-env.d.ts","./src/components/agentaction.tsx","./src/components/chatinput.tsx","./src/components/chatmessages.tsx","./src/components/errorbanner.tsx","./src/components/interruptprompt.tsx","./src/components/layout.tsx","./src/components/metriccard.tsx","./src/components/navbar.tsx","./src/components/replaytimeline.tsx","./src/hooks/usewebsocket.ts","./src/pages/chatpage.tsx","./src/pages/dashboardpage.test.tsx","./src/pages/dashboardpage.tsx","./src/pages/replaylistpage.test.tsx","./src/pages/replaylistpage.tsx","./src/pages/replaypage.test.tsx","./src/pages/replaypage.tsx","./src/pages/reviewpage.tsx"],"version":"5.7.3"} \ No newline at end of file diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 7f1ee01..e94eefa 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,8 +1,14 @@ +/// import react from "@vitejs/plugin-react"; import { defineConfig } from "vite"; export default defineConfig({ plugins: [react()], + test: { + environment: "happy-dom", + globals: true, + setupFiles: ["./src/test-setup.ts"], + }, server: { port: 5173, proxy: {