5
.dockerignore
Normal file
5
.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
||||
.git
|
||||
target
|
||||
README.md
|
||||
Dockerfile
|
||||
docker-compose.yml
|
9
.env
Normal file
9
.env
Normal file
@@ -0,0 +1,9 @@
|
||||
INTRASYS_HOST=0.0.0.0
|
||||
INTRASYS_PORT=8080
|
||||
PGUSER=postgres
|
||||
PGHOST=127.0.0.1
|
||||
PGPASSWORD=password
|
||||
INTRASYS_LOG=trace
|
||||
|
||||
#Only use for tests
|
||||
DATABASE_URL=postgres://postgres:password@localhost:5432
|
26
.gitea/workflows/build.yaml
Normal file
26
.gitea/workflows/build.yaml
Normal file
@@ -0,0 +1,26 @@
|
||||
name: CI
|
||||
on:
|
||||
push:
|
||||
branches: [ master ]
|
||||
jobs:
|
||||
build:
|
||||
runs-on: woryzen
|
||||
steps:
|
||||
- name: Login to Gitea container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: gitea.woggioni.net
|
||||
username: woggioni
|
||||
password: ${{ secrets.PUBLISHER_TOKEN }}
|
||||
-
|
||||
name: Build rbcs Docker image
|
||||
uses: docker/build-push-action@v5.3.0
|
||||
with:
|
||||
platforms: |
|
||||
linux/amd64
|
||||
linux/arm64
|
||||
push: true
|
||||
pull: true
|
||||
tags: |
|
||||
gitea.woggioni.net/woggioni/intrasys:latest
|
||||
cache-from: type=registry,ref=gitea.woggioni.net/woggioni/intrasys:buildx
|
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/target
|
2612
Cargo.lock
generated
Normal file
2612
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
Cargo.toml
Normal file
23
Cargo.toml
Normal file
@@ -0,0 +1,23 @@
|
||||
[package]
|
||||
name = "intrasys"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
axum = { version = "0.8", features = ["json"] }
|
||||
tokio = { version = "1.46", features = ["full"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
sqlx = { version = "0.8", features = ["postgres", "runtime-tokio", "chrono", "bigdecimal", "derive"] }
|
||||
thiserror = "2.0"
|
||||
uuid = { version = "1.17", features = ["v4"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
dotenv = "0.15"
|
||||
bigdecimal = { version = "0.4", features = ["serde"] }
|
||||
|
||||
[lib]
|
||||
crate-type = ["bin"]
|
||||
|
||||
[dev-dependencies]
|
||||
axum-test = "17.3"
|
32
Dockerfile
Normal file
32
Dockerfile
Normal file
@@ -0,0 +1,32 @@
|
||||
FROM --platform=$BUILDPLATFORM alpine:latest AS base
|
||||
|
||||
ARG TARGETPLATFORM
|
||||
|
||||
FROM base AS builder-linux_amd64
|
||||
ENV RUST_TARGET="x86_64-unknown-linux-musl"
|
||||
ENV CC_x86_64-unknown-linux-musl=/opt/x-tools/x86_64-unknown-linux-musl/bin/x86_64-unknown-linux-musl-gcc
|
||||
ENV CXX_x86_64-unknown-linux-musl=/opt/x-tools/x86_64-unknown-linux-musl/bin/x86_64-unknown-linux-musl-g++
|
||||
|
||||
FROM base AS builder-linux_arm64
|
||||
ENV RUST_TARGET="aarch64-unknown-linux-musl"
|
||||
ENV CC_aarch64_unknown_linux_musl=/opt/x-tools/aarch64-unknown-linux-musl/bin/aarch64-unknown-linux-musl-gcc
|
||||
ENV CXX_aarch64_unknown_linux_musl=/opt/x-tools/aarch64-unknown-linux-musl/bin/aarch64-unknown-linux-musl-g++
|
||||
|
||||
FROM builder-${TARGETPLATFORM/\//_} AS build
|
||||
RUN --mount=type=cache,target=/var/cache/apk apk update
|
||||
RUN --mount=type=cache,target=/var/cache/apk apk add rustup binutils gcc musl-dev linux-headers
|
||||
RUN adduser -D luser
|
||||
USER luser
|
||||
WORKDIR /home/luser
|
||||
RUN rustup-init -y --profile minimal --target "$RUST_TARGET"
|
||||
ADD --chown=luser:users . .
|
||||
RUN source $HOME/.cargo/env && cargo build --release --target "$RUST_TARGET"
|
||||
RUN cp "target/${RUST_TARGET}/release/intrasys" "./intrasys.x"
|
||||
|
||||
|
||||
FROM scratch AS release
|
||||
COPY --from=build --chown=9999:9999 /home/luser/intrasys.x /usr/local/bin/intrasys
|
||||
USER 9999
|
||||
ENTRYPOINT ["/usr/local/bin/intrasys"]
|
||||
ENV INTRASYS_HOST="0.0.0.0"
|
||||
EXPOSE 8080/tcp
|
9
LICENSE
Normal file
9
LICENSE
Normal file
@@ -0,0 +1,9 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 woggioni
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
118
README.md
Normal file
118
README.md
Normal file
@@ -0,0 +1,118 @@
|
||||
# Intrasys - Internal Transfers System
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
Intrasys is an internal financial transfers application built with Rust and Axum, providing HTTP endpoints for account management and transaction processing with PostgreSQL as the backing database.
|
||||
|
||||
## Features
|
||||
|
||||
- Account creation with initial balance
|
||||
- Account balance queries
|
||||
- Secure transaction processing between accounts
|
||||
- Atomic transaction handling with database integrity
|
||||
- Precise decimal arithmetic for financial amounts
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Rust (latest stable version)
|
||||
- Docker (for database provisioning)
|
||||
- PostgreSQL client (optional)
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Using docker compose
|
||||
Just run
|
||||
|
||||
```bash
|
||||
docker compose up
|
||||
```
|
||||
|
||||
### Build locally
|
||||
|
||||
#### Ensure you have rust installed
|
||||
Follow the steps described [here](https://www.rust-lang.org/tools/install) if you don't
|
||||
|
||||
#### Provision PostgreSQL Database
|
||||
|
||||
The easiest way is to run a PostgreSQL container with Docker:
|
||||
|
||||
```bash
|
||||
docker run --name intrasys-pg -d -e POSTGRES_PASSWORD=password -p 127.0.0.1:5432:5432 postgres:alpine
|
||||
```
|
||||
|
||||
#### Run the Application
|
||||
|
||||
```bash
|
||||
cargo run
|
||||
```
|
||||
|
||||
The application will be available at `http://localhost:8080`
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Account Management
|
||||
|
||||
- **Create Account**
|
||||
`POST /accounts`
|
||||
```json
|
||||
{
|
||||
"account_id": 123,
|
||||
"initial_balance": "100.23344"
|
||||
}
|
||||
```
|
||||
|
||||
- **Get Account Balance**
|
||||
`GET /accounts/{account_id}`
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"account_id": 123,
|
||||
"balance": "100.23344"
|
||||
}
|
||||
```
|
||||
|
||||
### Transactions
|
||||
|
||||
- **Process Transaction**
|
||||
`POST /transactions`
|
||||
```json
|
||||
{
|
||||
"source_account_id": 123,
|
||||
"destination_account_id": 456,
|
||||
"amount": "50.12345"
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration
|
||||
The app can be configured using environmental variable that can also be provided through a `.dotenv` file.
|
||||
The following variables affect the application behavior:
|
||||
|
||||
- `INTRASYS_LOG` changes the logging level, can be set to one of `trace`, `debug`, `info`, `warn`, `error`
|
||||
- `INTRASYS_HOST` is the address the HTTP server will bind to
|
||||
- `INTRASYS_PORT` is the port the HTTP server will listen to
|
||||
- any of the environmental variables mentioned [here](https://docs.rs/sqlx/latest/sqlx/postgres/struct.PgConnectOptions.html#method.options) affects the postgres database connection
|
||||
|
||||
## Running tests
|
||||
|
||||
Make sure you have an available postgres database and edit `DATABASE_URL` variable in the `.env` file accordingly. If you've used the docker command mentioned in the *Quickstart*
|
||||
section you don't need to change anything.
|
||||
|
||||
Then run
|
||||
|
||||
```bash
|
||||
cargo test
|
||||
```
|
||||
|
||||
## Assumptions
|
||||
|
||||
- All accounts use the same currency
|
||||
- No authentication or authorization is implemented
|
||||
- Decimal precision of 2 decimal places is sufficient for financial amounts, maximum allowed monetary amount is (10^18 -0.01) and the minimum is (-10^18 + 0.01)
|
||||
- Account IDs are positive integers
|
||||
- Account creation must NOT be idempotent since it also sets the account balance (creating an account that already exists should fail)
|
||||
- Transfer money to an account that does not exist should not create a new account, but fail with an error (to prevent users from accidentally locking their money forever)
|
||||
|
40
docker-compose.yml
Normal file
40
docker-compose.yml
Normal file
@@ -0,0 +1,40 @@
|
||||
volumes:
|
||||
postgres-data:
|
||||
driver: local
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- POSTGRES_PASSWORD=password
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: 1.00
|
||||
memory: 256M
|
||||
healthcheck:
|
||||
test: ["CMD", "pg_isready", "-U", "postgres"]
|
||||
interval: 30s
|
||||
timeout: 60s
|
||||
retries: 5
|
||||
start_period: 5s
|
||||
intrasys:
|
||||
image: gitea.woggioni.net/woggioni/intrasys:latest
|
||||
environment:
|
||||
- PGHOST=postgres
|
||||
- PGUSER=postgres
|
||||
- PGPASSWORD=password
|
||||
- INTRASYS_LOG=trace
|
||||
ports:
|
||||
- "127.0.0.1:8080:8080"
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: 1.00
|
||||
memory: 32M
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
16
migrations/20250704000000_init.sql
Normal file
16
migrations/20250704000000_init.sql
Normal file
@@ -0,0 +1,16 @@
|
||||
-- Create accounts table
|
||||
CREATE TABLE accounts (
|
||||
account_id BIGINT PRIMARY KEY,
|
||||
balance DECIMAL(20, 2) NOT NULL
|
||||
);
|
||||
|
||||
-- Create transactions table for audit purposes
|
||||
CREATE TABLE transactions (
|
||||
transaction_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
source_account_id BIGINT NOT NULL,
|
||||
destination_account_id BIGINT NOT NULL,
|
||||
amount DECIMAL(20, 2) NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
FOREIGN KEY (source_account_id) REFERENCES accounts (account_id),
|
||||
FOREIGN KEY (destination_account_id) REFERENCES accounts (account_id)
|
||||
);
|
18
src/db.rs
Executable file
18
src/db.rs
Executable file
@@ -0,0 +1,18 @@
|
||||
use sqlx::PgPool;
|
||||
use sqlx::postgres::{PgConnectOptions, PgPoolOptions};
|
||||
use tracing::info;
|
||||
|
||||
pub(crate) type DbPool = PgPool;
|
||||
|
||||
pub(crate) async fn create_pool() -> Result<DbPool, sqlx::Error> {
|
||||
info!("Creating database connection pool...");
|
||||
PgPoolOptions::new()
|
||||
.max_connections(5)
|
||||
.connect_with(PgConnectOptions::new())
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn run_migrations(pool: &DbPool) -> Result<(), sqlx::migrate::MigrateError> {
|
||||
info!("Running database migrations...");
|
||||
sqlx::migrate!("./migrations").run(pool).await
|
||||
}
|
34
src/errors.rs
Normal file
34
src/errors.rs
Normal file
@@ -0,0 +1,34 @@
|
||||
use axum::{
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use sqlx::Error as SqlxError;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub(crate) enum AppError {
|
||||
#[error("Account not found: {0}")]
|
||||
AccountNotFound(i64),
|
||||
#[error("Account already exists")]
|
||||
AccountAlreadyExists,
|
||||
#[error("Insufficient funds")]
|
||||
InsufficientFunds,
|
||||
#[error("Database error: {0}")]
|
||||
DatabaseError(#[from] SqlxError),
|
||||
#[error("Invalid input: {0}")]
|
||||
InvalidInput(String),
|
||||
}
|
||||
|
||||
impl IntoResponse for AppError {
|
||||
fn into_response(self) -> Response {
|
||||
let status = match self {
|
||||
AppError::AccountNotFound(_) => StatusCode::NOT_FOUND,
|
||||
AppError::InsufficientFunds => StatusCode::BAD_REQUEST,
|
||||
AppError::AccountAlreadyExists => StatusCode::BAD_REQUEST,
|
||||
AppError::DatabaseError(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
AppError::InvalidInput(_) => StatusCode::BAD_REQUEST,
|
||||
};
|
||||
|
||||
(status, self.to_string()).into_response()
|
||||
}
|
||||
}
|
176
src/handlers.rs
Normal file
176
src/handlers.rs
Normal file
@@ -0,0 +1,176 @@
|
||||
use std::str::FromStr;
|
||||
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::{Path, State},
|
||||
routing::{get, post},
|
||||
};
|
||||
use bigdecimal::BigDecimal;
|
||||
use sqlx::types::BigDecimal as SqlxBigDecimal;
|
||||
use tracing::debug;
|
||||
|
||||
use crate::{
|
||||
db::DbPool,
|
||||
errors::AppError,
|
||||
models::{Account, CreateAccount, TransactionRequest},
|
||||
};
|
||||
|
||||
async fn create_account(
|
||||
State(pool): State<DbPool>,
|
||||
Json(payload): Json<CreateAccount>,
|
||||
) -> Result<(), AppError> {
|
||||
if payload.account_id < 1 {
|
||||
return Err(AppError::InvalidInput(String::from(
|
||||
"account_id is invalid",
|
||||
)));
|
||||
}
|
||||
|
||||
if payload.initial_balance < BigDecimal::from(0) {
|
||||
return Err(AppError::InvalidInput(String::from(
|
||||
"initial_balance is negative",
|
||||
)));
|
||||
}
|
||||
debug!("Creating account: {:?}", payload.account_id);
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO accounts (account_id, balance)
|
||||
VALUES ($1, $2)
|
||||
"#,
|
||||
)
|
||||
.bind(payload.account_id)
|
||||
.bind(payload.initial_balance)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.map_err(|err| match err {
|
||||
sqlx::Error::Database(_) => AppError::AccountAlreadyExists,
|
||||
_ => AppError::from(err),
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_account(
|
||||
State(pool): State<DbPool>,
|
||||
Path(account_id): Path<i64>,
|
||||
) -> Result<Json<Account>, AppError> {
|
||||
if account_id < 1 {
|
||||
return Err(AppError::InvalidInput(String::from(
|
||||
"account_id is invalid",
|
||||
)));
|
||||
}
|
||||
debug!("Fetching account: {}", account_id);
|
||||
|
||||
let account = sqlx::query_as::<_, Account>(
|
||||
r#"
|
||||
SELECT account_id, balance
|
||||
FROM accounts
|
||||
WHERE account_id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(account_id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|_| AppError::AccountNotFound(account_id))?;
|
||||
|
||||
Ok(Json(account))
|
||||
}
|
||||
|
||||
async fn process_transaction(
|
||||
State(pool): State<DbPool>,
|
||||
Json(payload): Json<TransactionRequest>,
|
||||
) -> Result<(), AppError> {
|
||||
if payload.source_account_id < 1 {
|
||||
return Err(AppError::InvalidInput(String::from(
|
||||
"source_account_id is invalid",
|
||||
)));
|
||||
}
|
||||
|
||||
if payload.destination_account_id < 1 {
|
||||
return Err(AppError::InvalidInput(String::from(
|
||||
"destination_account_id is invalid",
|
||||
)));
|
||||
}
|
||||
|
||||
if payload.amount < BigDecimal::from_str("0.01").unwrap() {
|
||||
return Err(AppError::InvalidInput(String::from(
|
||||
"Minimum amount is 0.01",
|
||||
)));
|
||||
}
|
||||
|
||||
let mut tx = pool.begin().await.map_err(AppError::from)?;
|
||||
|
||||
// Check source account balance
|
||||
let source_balance: SqlxBigDecimal = sqlx::query_scalar(
|
||||
r#"
|
||||
SELECT balance FROM accounts WHERE account_id = $1 FOR UPDATE
|
||||
"#,
|
||||
)
|
||||
.bind(payload.source_account_id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.map_err(|_| AppError::AccountNotFound(payload.source_account_id))?;
|
||||
|
||||
if source_balance < payload.amount.clone() {
|
||||
return Err(AppError::InsufficientFunds);
|
||||
}
|
||||
|
||||
// Update source account
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE accounts
|
||||
SET balance = balance - $1
|
||||
WHERE account_id = $2
|
||||
"#,
|
||||
)
|
||||
.bind(payload.amount.clone())
|
||||
.bind(payload.source_account_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
// Update destination account
|
||||
let update_result = sqlx::query(
|
||||
r#"
|
||||
UPDATE accounts
|
||||
SET balance = balance + $1
|
||||
WHERE account_id = $2
|
||||
"#,
|
||||
)
|
||||
.bind(payload.amount.clone())
|
||||
.bind(payload.destination_account_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
if update_result.rows_affected() == 0 {
|
||||
return Err(AppError::AccountNotFound(payload.destination_account_id));
|
||||
}
|
||||
|
||||
// Record transaction
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO transactions (source_account_id, destination_account_id, amount)
|
||||
VALUES ($1, $2, $3)
|
||||
"#,
|
||||
)
|
||||
.bind(payload.source_account_id)
|
||||
.bind(payload.destination_account_id)
|
||||
.bind(payload.amount.clone())
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
debug!(
|
||||
"Processing transaction from {} to {} for amount {}",
|
||||
payload.source_account_id, payload.destination_account_id, payload.amount
|
||||
);
|
||||
tx.commit().await.map_err(AppError::from)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn create_router(pool: DbPool) -> Router {
|
||||
Router::new()
|
||||
.route("/accounts", post(create_account))
|
||||
.route("/accounts/{account_id}", get(get_account))
|
||||
.route("/transactions", post(process_transaction))
|
||||
.with_state(pool)
|
||||
}
|
45
src/lib.rs
Normal file
45
src/lib.rs
Normal file
@@ -0,0 +1,45 @@
|
||||
use dotenv::dotenv;
|
||||
use std::env;
|
||||
use tracing::info;
|
||||
use tracing_subscriber::layer::SubscriberExt;
|
||||
|
||||
mod db;
|
||||
mod errors;
|
||||
mod handlers;
|
||||
mod models;
|
||||
|
||||
#[tokio::main(flavor = "current_thread")]
|
||||
async fn main() {
|
||||
//Parse .env file and add the environmental variables configured there
|
||||
dotenv().ok();
|
||||
|
||||
let subscriber = tracing_subscriber::registry()
|
||||
.with(tracing_subscriber::EnvFilter::new(
|
||||
std::env::var("INTRASYS_LOG").unwrap_or_else(|_| "info".into()),
|
||||
))
|
||||
.with(tracing_subscriber::fmt::layer());
|
||||
tracing::subscriber::set_global_default(subscriber)
|
||||
.expect("Failed to set the global tracing subscriber");
|
||||
|
||||
let pool = db::create_pool().await.expect("Failed to create pool");
|
||||
|
||||
// Run migrations
|
||||
db::run_migrations(&pool)
|
||||
.await
|
||||
.expect("Failed to run migrations");
|
||||
|
||||
// Build the application's routes
|
||||
let app = handlers::create_router(pool);
|
||||
|
||||
// Run the server
|
||||
let host = env::var("INTRASYS_HOST").unwrap_or(String::from("127.0.0.1"));
|
||||
let port = env::var("INTRASYS_PORT").unwrap_or(String::from("8080"));
|
||||
let listener = tokio::net::TcpListener::bind(format!("{host}:{port}"))
|
||||
.await
|
||||
.unwrap();
|
||||
info!("listening on {}", listener.local_addr().unwrap());
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
24
src/models.rs
Normal file
24
src/models.rs
Normal file
@@ -0,0 +1,24 @@
|
||||
use bigdecimal::BigDecimal;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::FromRow;
|
||||
|
||||
type Amount = BigDecimal;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, FromRow)]
|
||||
pub(crate) struct Account {
|
||||
pub account_id: i64,
|
||||
pub balance: Amount,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub(crate) struct CreateAccount {
|
||||
pub account_id: i64,
|
||||
pub initial_balance: Amount,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub(crate) struct TransactionRequest {
|
||||
pub source_account_id: i64,
|
||||
pub destination_account_id: i64,
|
||||
pub amount: Amount,
|
||||
}
|
149
src/tests.rs
Normal file
149
src/tests.rs
Normal file
@@ -0,0 +1,149 @@
|
||||
use std::str::FromStr;
|
||||
|
||||
use crate::handlers::create_router;
|
||||
use axum::http::StatusCode;
|
||||
use axum_test::TestServer;
|
||||
use bigdecimal::BigDecimal;
|
||||
use serde_json::{Value, json};
|
||||
use sqlx::{Pool, Postgres};
|
||||
|
||||
#[sqlx::test]
|
||||
async fn test_get_account(pool: Pool<Postgres>) {
|
||||
let app = create_router(pool.clone());
|
||||
let server = TestServer::new(app).unwrap();
|
||||
|
||||
let response = server.get("/accounts/3").await;
|
||||
|
||||
//Account does not exist yet
|
||||
assert_eq!(response.status_code(), StatusCode::NOT_FOUND);
|
||||
// Create test account
|
||||
sqlx::query("INSERT INTO accounts (account_id, balance) VALUES (3, 150.50)")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let response = server.get("/accounts/3").await;
|
||||
|
||||
assert_eq!(response.status_code(), StatusCode::OK);
|
||||
|
||||
let account: Value = response.json();
|
||||
assert_eq!(3, account["account_id"]);
|
||||
assert_eq!(
|
||||
BigDecimal::from_str("150.50"),
|
||||
BigDecimal::from_str(account["balance"].as_str().unwrap())
|
||||
);
|
||||
let response = server.get("/accounts/4").await;
|
||||
|
||||
//Account does not exist
|
||||
assert_eq!(response.status_code(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn test_create_account_success(pool: Pool<Postgres>) {
|
||||
let app = create_router(pool.clone());
|
||||
let server = TestServer::new(app).unwrap();
|
||||
|
||||
let response = server
|
||||
.post("/accounts")
|
||||
.json(&json!({
|
||||
"account_id": 1,
|
||||
"initial_balance": "100.00"
|
||||
}))
|
||||
.await;
|
||||
|
||||
assert_eq!(response.status_code(), StatusCode::OK);
|
||||
|
||||
// Verify account was created
|
||||
let account = sqlx::query_as::<_, (i64, BigDecimal)>(
|
||||
"SELECT account_id, balance FROM accounts WHERE account_id = 1",
|
||||
)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(account.0, 1);
|
||||
assert_eq!(account.1, BigDecimal::from(100));
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn test_process_transaction_success(pool: Pool<Postgres>) {
|
||||
let app = create_router(pool.clone());
|
||||
let server = TestServer::new(app).unwrap();
|
||||
|
||||
// Create test accounts
|
||||
sqlx::query("INSERT INTO accounts (account_id, balance) VALUES (4, 200.00), (5, 50.00)")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let response = server
|
||||
.post("/transactions")
|
||||
.json(&json!({
|
||||
"source_account_id": 4,
|
||||
"destination_account_id": 5,
|
||||
"amount": "75.25"
|
||||
}))
|
||||
.await;
|
||||
|
||||
assert_eq!(response.status_code(), StatusCode::OK);
|
||||
|
||||
// Verify balances were updated
|
||||
let source = sqlx::query_as::<_, (i64, BigDecimal)>(
|
||||
"SELECT account_id, balance FROM accounts WHERE account_id = 4",
|
||||
)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let destination = sqlx::query_as::<_, (i64, BigDecimal)>(
|
||||
"SELECT account_id, balance FROM accounts WHERE account_id = 5",
|
||||
)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(source.1, BigDecimal::from_str("124.75").unwrap());
|
||||
assert_eq!(destination.1, BigDecimal::from_str("125.25").unwrap());
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn test_process_transaction_insufficient_funds(pool: Pool<Postgres>) {
|
||||
let app = create_router(pool.clone());
|
||||
let server = TestServer::new(app).unwrap();
|
||||
|
||||
// Create test accounts
|
||||
sqlx::query("INSERT INTO accounts (account_id, balance) VALUES (6, 50.00), (7, 100.00)")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let response = server
|
||||
.post("/transactions")
|
||||
.json(&json!({
|
||||
"source_account_id": 6,
|
||||
"destination_account_id": 7,
|
||||
"amount": "75.00"
|
||||
}))
|
||||
.await;
|
||||
|
||||
assert_eq!(response.status_code(), StatusCode::BAD_REQUEST);
|
||||
assert_eq!(response.text(), "Insufficient funds");
|
||||
|
||||
// Verify balances were not changed
|
||||
let source = sqlx::query_as::<_, (i64, BigDecimal)>(
|
||||
"SELECT account_id, balance FROM accounts WHERE account_id = 6",
|
||||
)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let destination = sqlx::query_as::<_, (i64, BigDecimal)>(
|
||||
"SELECT account_id, balance FROM accounts WHERE account_id = 7",
|
||||
)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(source.1, BigDecimal::from_str("50.00").unwrap());
|
||||
assert_eq!(destination.1, BigDecimal::from_str("100.00").unwrap());
|
||||
}
|
Reference in New Issue
Block a user