Skip to content
Open
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
45 changes: 39 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ concurrency:
cancel-in-progress: true

jobs:
backend:
name: Backend (Rust)
backend-fmt:
name: Backend Format
runs-on: ubuntu-latest
defaults:
run:
Expand All @@ -24,19 +24,52 @@ jobs:
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
components: rustfmt

- name: Format
run: cargo fmt --all --check

backend-clippy:
name: Backend Clippy
runs-on: ubuntu-latest
defaults:
run:
working-directory: backend
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
components: clippy

- name: Cache Cargo
uses: Swatinem/rust-cache@v2
with:
workspaces: backend

- name: Format
run: cargo fmt --all --check

- name: Clippy
run: cargo clippy --workspace --all-targets -- -D warnings

backend-test:
name: Backend Test
runs-on: ubuntu-latest
defaults:
run:
working-directory: backend
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Rust
uses: dtolnay/rust-toolchain@stable

- name: Cache Cargo
uses: Swatinem/rust-cache@v2
with:
workspaces: backend

- name: Test
run: cargo test --workspace --all-targets

Expand Down
71 changes: 71 additions & 0 deletions .github/workflows/docker.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
name: Docker Build

on:
pull_request:
push:
branches:
- main

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

jobs:
build-indexer:
name: Indexer Docker (linux/amd64)
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Build indexer image
uses: docker/build-push-action@v6
with:
context: backend
target: indexer
platforms: linux/amd64
cache-from: type=gha,scope=backend
cache-to: type=gha,scope=backend,mode=max
outputs: type=cacheonly

build-api:
name: API Docker (linux/amd64)
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Build api image
uses: docker/build-push-action@v6
with:
context: backend
target: api
platforms: linux/amd64
cache-from: type=gha,scope=backend
cache-to: type=gha,scope=backend,mode=max
outputs: type=cacheonly

build-frontend:
name: Frontend Docker (linux/amd64)
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Build frontend image
uses: docker/build-push-action@v6
with:
context: frontend
platforms: linux/amd64
cache-from: type=gha
cache-to: type=gha,mode=max
outputs: type=cacheonly
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ pub struct AppState {
- **Migrations**: use `run_migrations(&database_url)` (not `&pool`) to get a timeout-free connection
- **Frontend**: uses Bun (not npm/yarn). Lockfile is `bun.lock` (text, Bun ≥ 1.2). Build with `bunx vite build` (skips tsc type check).
- **Docker**: frontend image uses `nginxinc/nginx-unprivileged:alpine` (non-root, port 8080). API/indexer use `alpine` with `ca-certificates`.
- **Tests**: add unit tests for new logic in a `#[cfg(test)] mod tests` block in the same file. Run with `cargo test --workspace`.
- **Commits**: authored by the user only — no Claude co-author lines.

## Environment Variables
Expand Down
70 changes: 70 additions & 0 deletions backend/crates/atlas-common/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,73 @@ impl AtlasError {
}
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn not_found_returns_404() {
assert_eq!(AtlasError::NotFound("resource".into()).status_code(), 404);
}

#[test]
fn invalid_input_returns_400() {
assert_eq!(
AtlasError::InvalidInput("bad input".into()).status_code(),
400
);
}

#[test]
fn unauthorized_returns_401() {
assert_eq!(AtlasError::Unauthorized("no key".into()).status_code(), 401);
}

#[test]
fn internal_error_returns_500() {
assert_eq!(AtlasError::Internal("oops".into()).status_code(), 500);
}

#[test]
fn rpc_error_returns_502() {
assert_eq!(AtlasError::Rpc("timeout".into()).status_code(), 502);
}

#[test]
fn metadata_fetch_returns_502() {
assert_eq!(
AtlasError::MetadataFetch("ipfs down".into()).status_code(),
502
);
}

#[test]
fn config_error_returns_500() {
assert_eq!(AtlasError::Config("missing env".into()).status_code(), 500);
}

#[test]
fn verification_error_returns_400() {
assert_eq!(
AtlasError::Verification("bad source".into()).status_code(),
400
);
}

#[test]
fn bytecode_mismatch_returns_400() {
assert_eq!(
AtlasError::BytecodeMismatch("different".into()).status_code(),
400
);
}

#[test]
fn compilation_error_returns_422() {
assert_eq!(
AtlasError::Compilation("syntax error".into()).status_code(),
422
);
}
}
87 changes: 87 additions & 0 deletions backend/crates/atlas-common/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -401,3 +401,90 @@ impl<T> PaginatedResponse<T> {
}
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn limit_above_max_clamps_to_100() {
let p = Pagination {
page: 1,
limit: 150,
};
assert_eq!(p.limit(), 100);
}

#[test]
fn limit_at_max_is_unchanged() {
let p = Pagination {
page: 1,
limit: 100,
};
assert_eq!(p.limit(), 100);
}

#[test]
fn limit_below_max_is_unchanged() {
let p = Pagination { page: 1, limit: 20 };
assert_eq!(p.limit(), 20);
}

#[test]
fn limit_zero_is_unchanged() {
let p = Pagination { page: 1, limit: 0 };
assert_eq!(p.limit(), 0);
}

#[test]
fn limit_u32_max_clamps_to_100() {
let p = Pagination {
page: 1,
limit: u32::MAX,
};
assert_eq!(p.limit(), 100);
}

#[test]
fn offset_page_zero_saturates_to_zero() {
// page=0 → saturating_sub(1)=0 → offset = 0 * limit = 0
let p = Pagination { page: 0, limit: 20 };
assert_eq!(p.offset(), 0);
}

#[test]
fn offset_page_one_is_zero() {
let p = Pagination { page: 1, limit: 20 };
assert_eq!(p.offset(), 0);
}

#[test]
fn offset_page_two() {
let p = Pagination { page: 2, limit: 20 };
assert_eq!(p.offset(), 20);
}

#[test]
fn offset_page_three() {
let p = Pagination { page: 3, limit: 10 };
assert_eq!(p.offset(), 20);
}

#[test]
fn paginated_response_total_pages_rounds_up() {
let resp = PaginatedResponse::new(vec![1, 2, 3], 1, 10, 25);
assert_eq!(resp.total_pages, 3);
}

#[test]
fn paginated_response_exact_division() {
let resp = PaginatedResponse::<i32>::new(vec![], 1, 10, 20);
assert_eq!(resp.total_pages, 2);
}

#[test]
fn paginated_response_zero_total() {
let resp = PaginatedResponse::<i32>::new(vec![], 1, 10, 0);
assert_eq!(resp.total_pages, 0);
}
}
Loading