Compare commits
No commits in common. "e188fec99cd56e9ba71248ebccf1865e20267a99" and "c2b7c12905faf0321fe2c801d13abc7a8f6ba7ff" have entirely different histories.
e188fec99c
...
c2b7c12905
13 changed files with 15 additions and 1923 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1 +0,0 @@
|
|||
.claude/settings.local.json
|
||||
2
backend/.gitignore
vendored
2
backend/.gitignore
vendored
|
|
@ -1,3 +1 @@
|
|||
/target
|
||||
|
||||
*.db
|
||||
|
|
|
|||
1577
backend/Cargo.lock
generated
1577
backend/Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -4,13 +4,7 @@ version = "0.1.0"
|
|||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0.99"
|
||||
axum = "0.8.4"
|
||||
chrono = "0.4.41"
|
||||
serde = "1.0.219"
|
||||
sqlx = { version = "0.8.6", features = ["sqlite", "runtime-tokio", "uuid", "chrono"] }
|
||||
tokio = { version = "1.47.1", features = ["rt-multi-thread", "tracing"] }
|
||||
tracing = "0.1.41"
|
||||
tracing-subscriber = "0.3.19"
|
||||
uuid = { version = "1.18.0", features = ["v4"] }
|
||||
|
||||
|
|
|
|||
|
|
@ -1,2 +0,0 @@
|
|||
-- Add down migration script here
|
||||
DROP TABLE tasks
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
-- Add up migration script here
|
||||
CREATE TABLE tasks (
|
||||
id UUID PRIMARY KEY NOT NULL,
|
||||
title VARCHAR NOT NULL,
|
||||
description TEXT,
|
||||
status TEXT CHECK(status IN ('todo', 'done', 'backlog')) DEFAULT 'todo',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
completed_at TIMESTAMP
|
||||
);
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
use anyhow::Result;
|
||||
use sqlx::{SqlitePool, sqlite::SqliteConnectOptions};
|
||||
use std::str::FromStr;
|
||||
|
||||
/// Database configuration
|
||||
pub struct DatabaseConfig {
|
||||
pub database_url: String,
|
||||
}
|
||||
|
||||
impl Default for DatabaseConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
database_url: "sqlite:local.db".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a SQLx connection pool
|
||||
pub async fn create_pool(config: &DatabaseConfig) -> Result<SqlitePool> {
|
||||
let options = SqliteConnectOptions::from_str(&config.database_url)?.create_if_missing(true);
|
||||
|
||||
let pool = SqlitePool::connect_with(options).await?;
|
||||
|
||||
sqlx::migrate!("./migrations").run(&pool).await?;
|
||||
|
||||
Ok(pool)
|
||||
}
|
||||
|
||||
/// Initialize database with connection pool
|
||||
pub async fn initialize_database() -> Result<SqlitePool> {
|
||||
let config = DatabaseConfig::default();
|
||||
create_pool(&config).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub async fn create_test_pool() -> Result<SqlitePool> {
|
||||
let options = SqliteConnectOptions::from_str("sqlite::memory:")?.create_if_missing(true);
|
||||
|
||||
let pool = SqlitePool::connect_with(options).await?;
|
||||
|
||||
sqlx::migrate!("./migrations").run(&pool).await?;
|
||||
|
||||
Ok(pool)
|
||||
}
|
||||
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
pub mod connection;
|
||||
|
||||
pub use connection::*;
|
||||
|
|
@ -1,8 +1,5 @@
|
|||
use axum::{Router, routing::get};
|
||||
|
||||
mod models;
|
||||
mod database;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
tracing_subscriber::fmt::init();
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
mod task;
|
||||
|
||||
pub use task::*;
|
||||
|
|
@ -1,269 +0,0 @@
|
|||
use anyhow::{Result, bail};
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::SqlitePool;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, Copy, sqlx::Type)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[sqlx(rename_all = "lowercase")]
|
||||
pub enum TaskStatus {
|
||||
Todo,
|
||||
Done,
|
||||
Backlog,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub struct TaskModel {
|
||||
pub id: Uuid,
|
||||
pub title: String,
|
||||
pub description: Option<String>,
|
||||
pub status: TaskStatus,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub completed_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
impl TaskModel {
|
||||
pub async fn insert(
|
||||
pool: &SqlitePool,
|
||||
title: &str,
|
||||
description: Option<&str>,
|
||||
) -> Result<TaskModel> {
|
||||
if title.len() == 0 {
|
||||
bail!("Title must not be empty");
|
||||
}
|
||||
|
||||
let id = Uuid::new_v4();
|
||||
let result = sqlx::query_as(
|
||||
"INSERT INTO tasks (id, title, description) VALUES ($1, $2, $3) RETURNING *",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(title)
|
||||
.bind(description)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub async fn get_by_id(pool: &SqlitePool, id: Uuid) -> Result<TaskModel> {
|
||||
let result = sqlx::query_as("SELECT * FROM tasks WHERE id = $1")
|
||||
.bind(id)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub async fn update(self, pool: &SqlitePool) -> Result<TaskModel> {
|
||||
let now: DateTime<Utc> = Utc::now();
|
||||
|
||||
let _ = sqlx::query(
|
||||
"UPDATE tasks
|
||||
SET title=$1, description=$2, status=$3, updated_at=$4, completed_at=$5
|
||||
WHERE id=$6",
|
||||
)
|
||||
.bind(&self.title)
|
||||
.bind(&self.description)
|
||||
.bind(&self.status)
|
||||
.bind(now)
|
||||
.bind(&self.completed_at)
|
||||
.bind(&self.id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
TaskModel::get_by_id(pool, self.id).await
|
||||
}
|
||||
|
||||
pub async fn delete(pool: &SqlitePool, id: Uuid) -> Result<()> {
|
||||
sqlx::query("DELETE FROM tasks WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn list_all(pool: &SqlitePool) -> Result<Vec<TaskModel>> {
|
||||
let tasks = sqlx::query_as("SELECT * FROM tasks ORDER BY created_at DESC")
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(tasks)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::database::create_test_pool;
|
||||
|
||||
async fn setup_test_db() -> SqlitePool {
|
||||
create_test_pool()
|
||||
.await
|
||||
.expect("Failed to create test pool")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_insert_task() {
|
||||
let pool = setup_test_db().await;
|
||||
|
||||
let title = "Test Task";
|
||||
let description = "Test Description";
|
||||
|
||||
let task = TaskModel::insert(&pool, title, Some(description))
|
||||
.await
|
||||
.expect("Failed to insert task");
|
||||
|
||||
assert_eq!(task.title, title);
|
||||
assert_eq!(task.description, Some(description.to_string()));
|
||||
assert_eq!(task.status, TaskStatus::Todo);
|
||||
assert!(task.completed_at.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_insert_task_with_null_description() {
|
||||
let pool = setup_test_db().await;
|
||||
|
||||
let title = "Test Task";
|
||||
let description = None;
|
||||
|
||||
let task = TaskModel::insert(&pool, title, description)
|
||||
.await
|
||||
.expect("Failed to insert task");
|
||||
|
||||
assert_eq!(task.title, title);
|
||||
assert_eq!(task.description, None);
|
||||
assert_eq!(task.status, TaskStatus::Todo);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_by_id_found() {
|
||||
let pool = setup_test_db().await;
|
||||
|
||||
let title = "Test Task";
|
||||
let description = Some("Test Description");
|
||||
|
||||
let inserted_task = TaskModel::insert(&pool, title, description)
|
||||
.await
|
||||
.expect("Failed to insert task");
|
||||
|
||||
let retrieved_task = TaskModel::get_by_id(&pool, inserted_task.id)
|
||||
.await
|
||||
.expect("Failed to get task by id");
|
||||
|
||||
assert_eq!(retrieved_task.id, inserted_task.id);
|
||||
assert_eq!(retrieved_task.title, inserted_task.title);
|
||||
assert_eq!(retrieved_task.description, inserted_task.description);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_by_id_not_found() {
|
||||
let pool = setup_test_db().await;
|
||||
|
||||
let non_existent_id = Uuid::new_v4();
|
||||
let result = TaskModel::get_by_id(&pool, non_existent_id).await;
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_update_task() {
|
||||
let pool = setup_test_db().await;
|
||||
|
||||
let title = "Original Task";
|
||||
let description = Some("Original Description");
|
||||
|
||||
let mut task = TaskModel::insert(&pool, title, description)
|
||||
.await
|
||||
.expect("Failed to insert task");
|
||||
|
||||
// Update the task
|
||||
task.title = "Updated Task".to_string();
|
||||
task.description = Some("Updated Description".to_string());
|
||||
task.status = TaskStatus::Done;
|
||||
task.completed_at = Some(Utc::now());
|
||||
|
||||
let updated_task = task.update(&pool).await.expect("Failed to update task");
|
||||
|
||||
assert_eq!(updated_task.title, "Updated Task");
|
||||
assert_eq!(
|
||||
updated_task.description,
|
||||
Some("Updated Description".to_string())
|
||||
);
|
||||
assert_eq!(updated_task.status, TaskStatus::Done);
|
||||
assert!(updated_task.completed_at.is_some());
|
||||
assert!(updated_task.updated_at > updated_task.created_at);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_delete_task() {
|
||||
let pool = setup_test_db().await;
|
||||
|
||||
let title = "Task to Delete";
|
||||
let description = Some("This will be deleted");
|
||||
|
||||
let task = TaskModel::insert(&pool, title, description)
|
||||
.await
|
||||
.expect("Failed to insert task");
|
||||
|
||||
// Delete the task
|
||||
TaskModel::delete(&pool, task.id)
|
||||
.await
|
||||
.expect("Failed to delete task");
|
||||
|
||||
// Verify it's gone
|
||||
let result = TaskModel::get_by_id(&pool, task.id).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_list_all_tasks() {
|
||||
let pool = setup_test_db().await;
|
||||
|
||||
// Insert multiple tasks
|
||||
let _task1 = TaskModel::insert(&pool, "Task 1", Some("Desc 1"))
|
||||
.await
|
||||
.expect("Failed to insert task 1");
|
||||
|
||||
let _task2 = TaskModel::insert(&pool, "Task 2", None)
|
||||
.await
|
||||
.expect("Failed to insert task 2");
|
||||
|
||||
let tasks = TaskModel::list_all(&pool)
|
||||
.await
|
||||
.expect("Failed to list tasks");
|
||||
|
||||
assert_eq!(tasks.len(), 2);
|
||||
|
||||
// Check that both tasks are present (order may vary due to timing)
|
||||
let titles: Vec<&String> = tasks.iter().map(|t| &t.title).collect();
|
||||
assert!(titles.contains(&&"Task 1".to_string()));
|
||||
assert!(titles.contains(&&"Task 2".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_task_status_serialization() {
|
||||
let pool = setup_test_db().await;
|
||||
|
||||
// Test each status
|
||||
let statuses = vec![TaskStatus::Todo, TaskStatus::Done, TaskStatus::Backlog];
|
||||
|
||||
for status in statuses {
|
||||
let mut task = TaskModel::insert(&pool, "Test Task", None)
|
||||
.await
|
||||
.expect("Failed to insert task");
|
||||
|
||||
task.status = status;
|
||||
task = task.update(&pool).await.expect("Failed to update task");
|
||||
|
||||
assert_eq!(task.status, status);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_empty_title_constraint() {
|
||||
let pool = setup_test_db().await;
|
||||
|
||||
let result = TaskModel::insert(&pool, "", None).await;
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
13
justfile
13
justfile
|
|
@ -1,8 +1,6 @@
|
|||
# Temporary until we have a frontend.
|
||||
set working-directory := 'backend'
|
||||
|
||||
export DATABASE_URL :="sqlite://local.db"
|
||||
|
||||
dev:
|
||||
cargo run
|
||||
|
||||
|
|
@ -22,14 +20,3 @@ clean:
|
|||
cargo clean
|
||||
|
||||
|
||||
reset-db:
|
||||
sqlx database drop
|
||||
sqlx database create
|
||||
sqlx migrate run
|
||||
|
||||
migrate:
|
||||
sqlx migrate run
|
||||
|
||||
migrate-revert:
|
||||
sqlx migrate revert
|
||||
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ captains-log/
|
|||
## Phase 2: Core Database Layer (Days 3-4)
|
||||
|
||||
### Task 2.0: Create Initial Database Migration
|
||||
- [x] **File**: `backend/migrations/001_create_tasks.sql`
|
||||
- [ ] **File**: `backend/migrations/001_create_tasks.sql`
|
||||
- Create tasks table with all required fields
|
||||
- Add proper indexes and constraints
|
||||
- Include created_at, updated_at triggers if needed
|
||||
|
|
@ -77,7 +77,7 @@ captains-log/
|
|||
- **Expected outcome**: `sqlx migrate run` creates table successfully
|
||||
|
||||
### Task 2.1: Define Task Model
|
||||
- [x] **File**: `backend/src/database/models.rs`
|
||||
- [ ] **File**: `backend/src/database/models.rs`
|
||||
- Create Task struct with SQLx derives
|
||||
- Add TaskStatus and Priority enums
|
||||
- Implement proper serialization/deserialization
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue