Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
name: CI

on:
pull_request:
branches: [main]
push:
branches: [main]

permissions:
contents: read

concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
backend:
name: Backend Tests
runs-on: ubuntu-latest

defaults:
run:
working-directory: backend

steps:
- name: Check out repository
uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"

- name: Set up uv
uses: astral-sh/setup-uv@v5

- name: Install backend dependencies
run: uv sync --group dev

- name: Run backend tests
run: uv run pytest

frontend:
name: Frontend Tests
runs-on: ubuntu-latest

defaults:
run:
working-directory: frontend

steps:
- name: Check out repository
uses: actions/checkout@v4

- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
cache-dependency-path: frontend/package-lock.json

- name: Install frontend dependencies
run: npm install

- name: Run frontend tests
run: npm run test

- name: Run production build
run: npm run build
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,28 @@ The backend and frontend need to run at the same time.

---

## Running Tests

Backend:

```bash
cd backend
uv sync --group dev
uv run pytest
```

Frontend:

```bash
cd frontend
npm install
npm run test
```

GitHub Actions runs these checks automatically on pushes to `main` and on pull requests that target `main`.

---

## Why Developers Use This

- Discover high-signal posts faster
Expand Down
10 changes: 10 additions & 0 deletions backend/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,13 @@ dependencies = [
"requests>=2.34.2",
"uvicorn>=0.48.0",
]

[dependency-groups]
dev = [
"httpx>=0.28.1",
"pytest>=8.4.1",
]

[tool.pytest.ini_options]
pythonpath = ["."]
testpaths = ["tests"]
25 changes: 25 additions & 0 deletions backend/tests/test_analyzer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
from src.services.analyzer import PostAnalyzer


def test_normalize_tech_stack_deduplicates_and_cleans_tokens() -> None:
raw = [" FastAPI ", "Python", "python", " ", "- Docker "]

result = PostAnalyzer._normalize_tech_stack(raw)

assert result == "FastAPI, Python, Docker"


def test_normalize_tech_stack_returns_na_for_empty_values() -> None:
assert PostAnalyzer._normalize_tech_stack(None) == "N/A"
assert PostAnalyzer._normalize_tech_stack("null") == "N/A"


def test_extract_github_url_from_text_returns_first_repo_url() -> None:
text = (
"See https://example.com first and then "
"https://github.com/openai/openai-python/issues/1)."
)

result = PostAnalyzer._extract_github_url_from_text(text)

assert result == "https://github.com/openai/openai-python/issues/1"
124 changes: 124 additions & 0 deletions backend/tests/test_routes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
from collections.abc import Generator

import pytest
from fastapi.testclient import TestClient

from src.main import app
from src.api import routes


@pytest.fixture
def client() -> Generator[TestClient, None, None]:
with TestClient(app) as test_client:
yield test_client


def test_read_root_returns_health_message(client: TestClient) -> None:
response = client.get("/")

assert response.status_code == 200
assert response.json() == {
"message": "Dev.to Analyzer API is running. Go to /gradio for the UI."
}


def test_analyze_endpoint_returns_empty_list_when_scanner_finds_no_posts(
client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
class ScannerStub:
def __init__(self, tag: str) -> None:
self.tag = tag

def fetch_posts(self, pages: int, progress): # noqa: ANN001
assert self.tag == "python"
assert pages == 2
return []

class AnalyzerStub:
def analyze_batch(self, posts): # noqa: ANN001
raise AssertionError("Analyzer should not be called when there are no posts")

async def fake_sleep(_seconds: float) -> None:
return None

monkeypatch.setattr(routes, "DevToScanner", ScannerStub)
monkeypatch.setattr(routes, "PostAnalyzer", AnalyzerStub)
monkeypatch.setattr(routes.asyncio, "sleep", fake_sleep)

response = client.get("/api/analyze", params={"tag": "python", "pages": 2})

assert response.status_code == 200
assert response.json() == []


def test_analyze_endpoint_transforms_scanner_and_analyzer_output(
client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
raw_posts = [
{
"id": 101,
"title": "Async API Patterns",
"published_at": "2026-07-08T12:00:00Z",
"public_reactions_count": 12,
"comments_count": 3,
"url": "https://dev.to/example/async-api-patterns",
"user": {
"name": "Ada Lovelace",
"profile_image_90": "https://images.dev/avatar.png",
},
}
]

class ScannerStub:
def __init__(self, tag: str) -> None:
self.tag = tag

def fetch_posts(self, pages: int, progress): # noqa: ANN001
assert self.tag == "fastapi"
assert pages == 1
progress(1.0, desc="stub")
return [post.copy() for post in raw_posts]

def get_post_details(self, post_id: int) -> str:
assert post_id == 101
return "Body markdown with https://github.com/acme/project"

class AnalyzerStub:
def analyze_batch(self, posts): # noqa: ANN001
assert posts[0]["body_markdown"] == "Body markdown with https://github.com/acme/project"
return [
{
"id": 101,
"tech_stack": "FastAPI, Python",
"pain_point": "Blocking requests slowed the API.",
"solution": "Move IO to async endpoints.",
"github_url": "https://github.com/acme/project",
}
]

async def fake_sleep(_seconds: float) -> None:
return None

monkeypatch.setattr(routes, "DevToScanner", ScannerStub)
monkeypatch.setattr(routes, "PostAnalyzer", AnalyzerStub)
monkeypatch.setattr(routes.asyncio, "sleep", fake_sleep)

response = client.get("/api/analyze", params={"tag": "fastapi", "pages": 1})

assert response.status_code == 200
assert response.json() == [
{
"id": 101,
"author": "Ada Lovelace",
"avatar": "https://images.dev/avatar.png",
"date": "2026-07-08",
"topic": "Async API Patterns",
"tech_stack": "FastAPI, Python",
"problem": "Blocking requests slowed the API.",
"solution": "Move IO to async endpoints.",
"github_url": "https://github.com/acme/project",
"reacts": 12,
"comments": 3,
"url": "https://dev.to/example/async-api-patterns",
}
]
69 changes: 69 additions & 0 deletions frontend/app/page.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { render, screen, waitFor } from "@testing-library/react"
import userEvent from "@testing-library/user-event"

import DevToAnalyzer from "./page"


const apiResult = [
{
id: 1,
author: "Grace Hopper",
avatar: "https://images.dev/grace.png",
date: "2026-07-08",
topic: "Ship Better Python APIs",
tech_stack: "FastAPI, Python",
problem: "Slow sync handlers.",
solution: "Adopt async IO.",
github_url: "https://github.com/acme/api",
reacts: 14,
comments: 2,
url: "https://dev.to/example/post",
},
]

describe("DevToAnalyzer page", () => {
afterEach(() => {
vi.restoreAllMocks()
})

it("fetches analysis results with the selected filters", async () => {
const user = userEvent.setup()
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => apiResult,
})
vi.stubGlobal("fetch", fetchMock)

render(<DevToAnalyzer />)

await user.clear(screen.getByPlaceholderText("e.g. webdev, python, react"))
await user.type(screen.getByPlaceholderText("e.g. webdev, python, react"), "fastapi")
await user.clear(screen.getByRole("spinbutton"))
await user.type(screen.getByRole("spinbutton"), "4")
await user.click(screen.getByRole("button", { name: "Analyze Posts" }))

await waitFor(() =>
expect(fetchMock).toHaveBeenCalledWith(
"http://127.0.0.1:8000/api/analyze?tag=fastapi&pages=4"
)
)
expect((await screen.findAllByText("Grace Hopper")).length).toBeGreaterThan(0)
expect(screen.getByRole("link", { name: "Repo" })).toHaveAttribute(
"href",
"https://github.com/acme/api"
)
})

it("shows an error message when the API request fails", async () => {
const user = userEvent.setup()
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false }))

render(<DevToAnalyzer />)

await user.click(screen.getByRole("button", { name: "Analyze Posts" }))

expect(await screen.findByText("Failed to fetch data from API")).toBeInTheDocument()
expect(consoleErrorSpy).toHaveBeenCalled()
})
})
63 changes: 63 additions & 0 deletions frontend/components/analyzer/results-section.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { render, screen } from "@testing-library/react"
import userEvent from "@testing-library/user-event"

import { ResultsSection, type PostData } from "./results-section"


const data: PostData[] = [
{
id: 1,
author: "Grace Hopper",
avatar: "https://images.dev/grace.png",
date: "2026-07-08",
topic: "Ship Better Python APIs",
tech_stack: "FastAPI, Python",
problem: "Slow sync handlers.",
solution: "Adopt async IO.",
github_url: "https://github.com/acme/api",
reacts: 14,
comments: 2,
url: "https://dev.to/example/post",
},
]

describe("ResultsSection", () => {
afterEach(() => {
vi.clearAllMocks()
})

it("copies a readable text summary to the clipboard", async () => {
const user = userEvent.setup()
const writeTextMock = vi.mocked(navigator.clipboard.writeText)

render(<ResultsSection data={data} />)

await user.click(screen.getByRole("button", { name: /copy data/i }))

expect(writeTextMock).toHaveBeenCalledWith(
expect.stringContaining("Grace Hopper - Ship Better Python APIs")
)
expect(writeTextMock).toHaveBeenCalledWith(
expect.stringContaining("GitHub URL: https://github.com/acme/api")
)
})

it("exports the current results as csv", async () => {
const user = userEvent.setup()
const createObjectURLMock = vi.mocked(URL.createObjectURL)
const revokeObjectURLMock = vi.mocked(URL.revokeObjectURL)
const clickSpy = vi
.spyOn(HTMLAnchorElement.prototype, "click")
.mockImplementation(() => {})

render(<ResultsSection data={data} />)

await user.click(screen.getByRole("button", { name: /export csv/i }))

expect(createObjectURLMock).toHaveBeenCalledTimes(1)
const blob = createObjectURLMock.mock.calls[0][0] as Blob
await expect(blob.text()).resolves.toContain('"Grace Hopper"')
expect(clickSpy).toHaveBeenCalledTimes(1)
expect(revokeObjectURLMock).toHaveBeenCalledWith("blob:mock-url")
})
})
Loading
Loading