Initial commit

This commit is contained in:
Zhongwei Li
2025-11-29 18:29:30 +08:00
commit 40d73f6839
33 changed files with 8109 additions and 0 deletions

View File

@@ -0,0 +1,110 @@
# .github/workflows/test.yml
name: Tests
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
jobs:
test-typescript:
name: TypeScript Tests
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_DB: test_db
POSTGRES_USER: test_user
POSTGRES_PASSWORD: test_password
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
redis:
image: redis:7-alpine
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 6379:6379
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: "bun"
- name: Install Doppler CLI
uses: dopplerhq/cli-action@v3
- name: Install dependencies
run: bun install
- name: Run tests with coverage
env:
DOPPLER_TOKEN: ${{ secrets.DOPPLER_TOKEN_TEST }}
run: doppler run --config test -- bun run test:coverage
- name: Upload coverage
uses: codecov/codecov-action@v4
with:
files: ./coverage/coverage-final.json
test-python:
name: Python Tests
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_DB: test_db
POSTGRES_USER: test_user
POSTGRES_PASSWORD: test_password
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: "pip"
- name: Install Doppler CLI
uses: dopplerhq/cli-action@v3
- name: Install dependencies
run: |
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt -r requirements-dev.txt
- name: Run tests with coverage
env:
DOPPLER_TOKEN: ${{ secrets.DOPPLER_TOKEN_TEST }}
run: |
source .venv/bin/activate
doppler run --config test -- pytest --cov=app --cov-report=xml
- name: Upload coverage
uses: codecov/codecov-action@v4
with:
files: ./coverage.xml

View File

@@ -0,0 +1,102 @@
# tests/conftest.py
"""Shared test fixtures for all tests."""
import pytest
import os
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker
from httpx import AsyncClient
from uuid import uuid4
from app.main import app
from app.db.models import Base
# Doppler provides DATABASE_URL_TEST at runtime
DATABASE_URL_TEST = os.getenv(
"DATABASE_URL_TEST",
"postgresql+asyncpg://localhost/test_db"
)
@pytest.fixture(scope="session")
async def engine():
"""Create test database engine."""
engine = create_async_engine(DATABASE_URL_TEST, echo=False)
# Create all tables
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield engine
# Drop all tables
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await engine.dispose()
@pytest.fixture
async def session(engine):
"""Create test database session with automatic rollback."""
async_session = sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False
)
async with async_session() as session:
yield session
await session.rollback()
@pytest.fixture
async def client():
"""Create test HTTP client."""
async with AsyncClient(app=app, base_url="http://test") as client:
yield client
@pytest.fixture
def tenant_id():
"""Provide test tenant ID."""
return uuid4()
@pytest.fixture
async def test_user(session, tenant_id):
"""Create test user."""
from app.db.models.user import User
user = User(
tenant_id=tenant_id,
email_address="test@example.com",
name="Test User",
is_active=True,
)
session.add(user)
await session.commit()
await session.refresh(user)
return user
@pytest.fixture
async def authenticated_client(client, test_user, tenant_id):
"""Create authenticated HTTP client."""
# Login and get token
response = await client.post(
"/api/auth/login",
json={
"email_address": test_user.email_address,
"password": "testpassword",
},
)
assert response.status_code == 200
token = response.json()["access_token"]
# Add auth header to client
client.headers["Authorization"] = f"Bearer {token}"
client.headers["X-Tenant-ID"] = str(tenant_id)
return client

View File

@@ -0,0 +1,113 @@
# tests/integration/test_FEATURE_api.py
import pytest
from httpx import AsyncClient
from uuid import uuid4
@pytest.mark.integration
class TestYourAPI:
"""Integration tests for Your API endpoints."""
async def test_create_endpoint(self, client: AsyncClient, tenant_id):
"""Test POST /api/YOUR_RESOURCE creates resource."""
response = await client.post(
"/api/YOUR_RESOURCE",
json={
"name": "Test Resource",
"description": "Test description",
},
headers={"X-Tenant-ID": str(tenant_id)},
)
assert response.status_code == 201
data = response.json()
assert data["name"] == "Test Resource"
assert data["tenant_id"] == str(tenant_id)
async def test_get_endpoint(self, client: AsyncClient, tenant_id, test_resource):
"""Test GET /api/YOUR_RESOURCE/{id} retrieves resource."""
response = await client.get(
f"/api/YOUR_RESOURCE/{test_resource.id}",
headers={"X-Tenant-ID": str(tenant_id)},
)
assert response.status_code == 200
data = response.json()
assert data["id"] == str(test_resource.id)
assert data["name"] == test_resource.name
async def test_get_enforces_tenant_isolation(
self, client: AsyncClient, tenant_id, test_resource
):
"""Test GET enforces tenant isolation."""
# Should succeed with correct tenant
response = await client.get(
f"/api/YOUR_RESOURCE/{test_resource.id}",
headers={"X-Tenant-ID": str(tenant_id)},
)
assert response.status_code == 200
# Should fail with different tenant
different_tenant = str(uuid4())
response = await client.get(
f"/api/YOUR_RESOURCE/{test_resource.id}",
headers={"X-Tenant-ID": different_tenant},
)
assert response.status_code == 404
async def test_list_endpoint(self, client: AsyncClient, tenant_id):
"""Test GET /api/YOUR_RESOURCE lists resources."""
response = await client.get(
"/api/YOUR_RESOURCE",
headers={"X-Tenant-ID": str(tenant_id)},
)
assert response.status_code == 200
data = response.json()
assert isinstance(data, list)
async def test_update_endpoint(
self, client: AsyncClient, tenant_id, test_resource
):
"""Test PATCH /api/YOUR_RESOURCE/{id} updates resource."""
response = await client.patch(
f"/api/YOUR_RESOURCE/{test_resource.id}",
json={"name": "Updated Name"},
headers={"X-Tenant-ID": str(tenant_id)},
)
assert response.status_code == 200
data = response.json()
assert data["name"] == "Updated Name"
async def test_delete_endpoint(
self, client: AsyncClient, tenant_id, test_resource
):
"""Test DELETE /api/YOUR_RESOURCE/{id} deletes resource."""
response = await client.delete(
f"/api/YOUR_RESOURCE/{test_resource.id}",
headers={"X-Tenant-ID": str(tenant_id)},
)
assert response.status_code == 204
# Verify deletion
response = await client.get(
f"/api/YOUR_RESOURCE/{test_resource.id}",
headers={"X-Tenant-ID": str(tenant_id)},
)
assert response.status_code == 404
async def test_validation_errors(self, client: AsyncClient, tenant_id):
"""Test endpoint validates input correctly."""
response = await client.post(
"/api/YOUR_RESOURCE",
json={
"name": "", # Invalid: empty name
},
headers={"X-Tenant-ID": str(tenant_id)},
)
assert response.status_code == 422
data = response.json()
assert "detail" in data

View File

@@ -0,0 +1,119 @@
# tests/unit/repositories/test_FEATURE_repository.py
import pytest
from uuid import uuid4
from app.db.repositories.YOUR_repository import YourRepository
from app.db.models.YOUR_model import YourModel
@pytest.mark.unit
class TestYourRepository:
"""Unit tests for YourRepository."""
async def test_get_by_id_success(self, session, tenant_id):
"""Test retrieving entity by ID."""
repo = YourRepository(session)
# Create test entity
entity = YourModel(
tenant_id=tenant_id,
name="Test Entity",
)
session.add(entity)
await session.commit()
await session.refresh(entity)
# Retrieve entity
result = await repo.get_by_id(entity.id, tenant_id)
assert result is not None
assert result.id == entity.id
assert result.name == "Test Entity"
async def test_get_by_id_enforces_tenant_isolation(
self, session, tenant_id
):
"""Test that get_by_id enforces tenant isolation."""
repo = YourRepository(session)
# Create entity
entity = YourModel(tenant_id=tenant_id, name="Test")
session.add(entity)
await session.commit()
# Try to access with different tenant_id
different_tenant = uuid4()
result = await repo.get_by_id(entity.id, different_tenant)
assert result is None
async def test_list_with_pagination(self, session, tenant_id):
"""Test list with pagination."""
repo = YourRepository(session)
# Create multiple entities
entities = [
YourModel(tenant_id=tenant_id, name=f"Entity {i}")
for i in range(10)
]
session.add_all(entities)
await session.commit()
# Get first page
page1 = await repo.list(tenant_id, limit=5, offset=0)
assert len(page1) == 5
# Get second page
page2 = await repo.list(tenant_id, limit=5, offset=5)
assert len(page2) == 5
# Verify no overlap
page1_ids = {e.id for e in page1}
page2_ids = {e.id for e in page2}
assert page1_ids.isdisjoint(page2_ids)
async def test_create_success(self, session, tenant_id):
"""Test creating new entity."""
repo = YourRepository(session)
entity = await repo.create(
tenant_id=tenant_id,
name="New Entity",
)
assert entity.id is not None
assert entity.tenant_id == tenant_id
assert entity.name == "New Entity"
async def test_update_success(self, session, tenant_id):
"""Test updating existing entity."""
repo = YourRepository(session)
# Create entity
entity = YourModel(tenant_id=tenant_id, name="Original")
session.add(entity)
await session.commit()
# Update entity
updated = await repo.update(
entity.id,
tenant_id,
name="Updated",
)
assert updated.name == "Updated"
async def test_delete_success(self, session, tenant_id):
"""Test deleting entity."""
repo = YourRepository(session)
# Create entity
entity = YourModel(tenant_id=tenant_id, name="To Delete")
session.add(entity)
await session.commit()
# Delete entity
await repo.delete(entity.id, tenant_id)
# Verify deletion
result = await repo.get_by_id(entity.id, tenant_id)
assert result is None

View File

@@ -0,0 +1,54 @@
// tests/unit/lib/components/COMPONENT.test.tsx
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import YourComponent from "~/lib/components/YourComponent";
// Mock dependencies
vi.mock("~/lib/server/functions/YOUR_MODULE");
describe("YourComponent", () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
it("renders correctly with initial state", () => {
render(<YourComponent />, { wrapper });
expect(screen.getByText("Expected Text")).toBeInTheDocument();
});
it("handles user interaction", async () => {
render(<YourComponent />, { wrapper });
const button = screen.getByRole("button", { name: /click me/i });
fireEvent.click(button);
await waitFor(() => {
expect(screen.getByText("Updated Text")).toBeInTheDocument();
});
});
it("displays loading state", () => {
render(<YourComponent isLoading={true} />, { wrapper });
expect(screen.getByText(/loading/i)).toBeInTheDocument();
});
it("displays error state", async () => {
// Mock error
vi.mocked(someFunction).mockRejectedValue(new Error("Test error"));
render(<YourComponent />, { wrapper });
await waitFor(() => {
expect(screen.getByText(/error/i)).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,87 @@
// tests/integration/FEATURE-flow.test.ts
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { db } from "~/lib/server/db";
import { users } from "~/lib/server/db/schema";
import { eq } from "drizzle-orm";
describe("Feature Integration Tests", () => {
const testTenantId = "550e8400-e29b-41d4-a716-446655440000";
beforeEach(async () => {
// Setup test data
await db.delete(users).where(eq(users.tenant_id, testTenantId));
});
afterEach(async () => {
// Cleanup test data
await db.delete(users).where(eq(users.tenant_id, testTenantId));
});
it("completes full workflow successfully", async () => {
// 1. Create resource
const [created] = await db
.insert(users)
.values({
tenant_id: testTenantId,
email_address: "test@example.com",
name: "Test User",
})
.returning();
expect(created).toBeDefined();
expect(created.email_address).toBe("test@example.com");
// 2. Retrieve resource
const [retrieved] = await db
.select()
.from(users)
.where(eq(users.id, created.id))
.where(eq(users.tenant_id, testTenantId));
expect(retrieved).toBeDefined();
expect(retrieved.id).toBe(created.id);
// 3. Update resource
const [updated] = await db
.update(users)
.set({ name: "Updated Name" })
.where(eq(users.id, created.id))
.returning();
expect(updated.name).toBe("Updated Name");
// 4. Delete resource
await db.delete(users).where(eq(users.id, created.id));
// 5. Verify deletion
const [deleted] = await db
.select()
.from(users)
.where(eq(users.id, created.id));
expect(deleted).toBeUndefined();
});
it("enforces tenant isolation", async () => {
const differentTenantId = "00000000-0000-0000-0000-000000000000";
// Create user in tenant 1
const [user] = await db
.insert(users)
.values({
tenant_id: testTenantId,
email_address: "tenant1@example.com",
name: "Tenant 1 User",
})
.returning();
// Attempt to access with different tenant_id
const [result] = await db
.select()
.from(users)
.where(eq(users.id, user.id))
.where(eq(users.tenant_id, differentTenantId));
expect(result).toBeUndefined();
});
});

View File

@@ -0,0 +1,19 @@
// tests/unit/lib/utils/FEATURE.test.ts
import { describe, it, expect } from "vitest";
import { functionToTest } from "~/lib/utils/FEATURE";
describe("functionToTest", () => {
it("handles valid input correctly", () => {
const result = functionToTest("valid input");
expect(result).toBe("expected output");
});
it("handles edge cases", () => {
expect(functionToTest("")).toBe("");
expect(functionToTest(null)).toBeNull();
});
it("throws error for invalid input", () => {
expect(() => functionToTest("invalid")).toThrow("Error message");
});
});

View File

@@ -0,0 +1,49 @@
// vitest.config.ts
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import path from "path";
export default defineConfig({
plugins: [react()],
test: {
// Enable global test APIs
globals: true,
// Use jsdom for browser-like environment
environment: "jsdom",
// Run setup file before tests
setupFiles: ["./tests/setup.ts"],
// Coverage configuration
coverage: {
provider: "v8",
reporter: ["text", "json", "html"],
exclude: [
"node_modules/",
"tests/",
"**/*.config.ts",
"**/*.d.ts",
],
thresholds: {
lines: 80,
functions: 80,
branches: 80,
statements: 80,
},
},
// Environment variables
env: {
DATABASE_URL_ADMIN: process.env.DATABASE_URL_ADMIN || "postgresql://localhost/test",
REDIS_URL: process.env.REDIS_URL || "redis://localhost:6379",
},
},
// Path aliases
resolve: {
alias: {
"~": path.resolve(__dirname, "./src"),
},
},
});