Create a plan for implementing projects and also create the backend API. (#19)
Some checks failed
Check / Backend (push) Successful in 6m55s
Check / Frontend (push) Successful in 2m2s
Docker Build / Build and Push Backend Image (push) Failing after 11m21s
Docker Build / Build and Push Frontend Image (push) Successful in 5m44s

Creates projects to organize tasks under. (Note the migration also contains the tables for creating folders for projects as well because adding foreign keys is a PITA in sqlite apparently).

Reviewed-on: #19
Co-authored-by: Drew Galbraith <drew@tiramisu.one>
Co-committed-by: Drew Galbraith <drew@tiramisu.one>
This commit is contained in:
Drew 2025-10-28 04:13:12 +00:00 committed by Drew
parent 69f4a6f1ca
commit 4552c347c6
17 changed files with 957 additions and 12 deletions

2
backend/.gitignore vendored
View file

@ -1,5 +1,7 @@
target
coverage
reports
*.db
*.db-shm

View file

@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE projects \n SET title=$1, color=$2, folder_id=$3, status=$4, sort_order=$5, updated_at=$6, completed_at=$7 \n WHERE id=$8",
"describe": {
"columns": [],
"parameters": {
"Right": 8
},
"nullable": []
},
"hash": "3fbe5a29facacd03360f017a3ab078c9ae067aafcaa2e7ca29d597c0a74ac48d"
}

View file

@ -0,0 +1,20 @@
{
"db_name": "SQLite",
"query": "SELECT MAX(sort_order) as max_order FROM projects;",
"describe": {
"columns": [
{
"name": "max_order",
"ordinal": 0,
"type_info": "Integer"
}
],
"parameters": {
"Right": 0
},
"nullable": [
true
]
},
"hash": "93c31908b7409b034b143447cd8a1635f191f0abd4d547835c0f05aa403f96e0"
}

View file

@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM projects WHERE id = $1",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "a5ba908419fb3e456bdd2daca41ba06cc3212ffffb8520fc7dbbcc8b60ada314"
}

2
backend/Cargo.lock generated
View file

@ -3500,9 +3500,11 @@ dependencies = [
"bitflags 2.9.3",
"bytes",
"http 1.3.1",
"http-body 1.0.1",
"pin-project-lite",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]

View file

@ -12,7 +12,7 @@ chrono = { version = "0.4.41", features = ["serde"] }
serde = "1.0.219"
sqlx = { version = "0.8.6", features = ["sqlite", "runtime-tokio", "uuid", "chrono", "migrate"] }
tokio = { version = "1.47.1", features = ["rt-multi-thread", "tracing"] }
tower-http = { version = "0.6.0", features = ["cors"] }
tower-http = { version = "0.6.0", features = ["cors", "trace"] }
tracing = "0.1.41"
tracing-subscriber = "0.3.19"
uuid = { version = "1.18.0", features = ["serde", "v4"] }

View file

@ -0,0 +1,17 @@
ALTER TABLE tasks RENAME TO tasks_old;
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
);
INSERT INTO tasks SELECT id, title, description, status, created_at, updated_at, completed_at FROM tasks_old;
DROP TABLE tasks_old;
DROP TABLE projects;
DROP TABLE folders;

View file

@ -0,0 +1,39 @@
CREATE TABLE folders (
id UUID PRIMARY KEY NOT NULL,
title VARCHAR NOT NULL,
sort_order INTEGER NOT NULL UNIQUE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE projects (
id UUID PRIMARY KEY NOT NULL,
title VARCHAR NOT NULL,
color VARCHAR(7) NOT NULL, -- hex code
folder_id UUID,
sort_order INTEGER NOT NULL UNIQUE,
status TEXT CHECK(status IN ('active', 'done', 'backlog')) DEFAULT 'active',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
completed_at TIMESTAMP,
FOREIGN KEY(folder_id) REFERENCES folders(id)
);
ALTER TABLE tasks RENAME TO tasks_old;
-- 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,
project_id UUID,
FOREIGN KEY(project_id) REFERENCES projects(id)
);
INSERT INTO tasks SELECT *, NULL as project_id FROM tasks_old;
DROP TABLE tasks_old;

View file

@ -32,6 +32,7 @@ async fn main() {
let app = Router::new()
.route("/health", get(health))
.nest("/api/tasks", services::create_task_router())
.nest("/api/projects", services::create_project_router())
.layer(cors)
.with_state(pool);

View file

@ -1,3 +1,5 @@
mod project;
mod task;
pub use project::*;
pub use task::*;

View file

@ -0,0 +1,122 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::{Pool, Row, Sqlite};
use uuid::Uuid;
use crate::services::AppError;
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, Copy, sqlx::Type)]
#[serde(rename_all = "snake_case")]
#[sqlx(rename_all = "lowercase")]
pub enum ProjectStatus {
Active,
Done,
Backlog,
}
#[derive(sqlx::FromRow, Serialize)]
pub struct ProjectModel {
pub id: Uuid,
pub title: String,
pub color: String,
pub folder_id: Option<Uuid>,
pub sort_order: i64,
pub status: ProjectStatus,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub completed_at: Option<DateTime<Utc>>,
}
impl ProjectModel {
async fn get_new_sort_order(pool: &Pool<Sqlite>) -> anyhow::Result<i64> {
let result = sqlx::query!("SELECT MAX(sort_order) as max_order FROM projects;")
.fetch_one(pool)
.await?;
match result.max_order {
Some(i) => Ok(i + 1000),
None => Ok(0),
}
}
pub async fn insert(
pool: &Pool<Sqlite>,
title: String,
color: String,
) -> anyhow::Result<ProjectModel, AppError> {
if title.is_empty() {
return Err(AppError::Unprocessable(
"Title must not be empty".to_string(),
));
}
if color.len() != 7 || color.chars().next() != Some('#') {
return Err(AppError::Unprocessable(
"Color is not a valid hex".to_string(),
));
}
let id = Uuid::new_v4();
let sort_order = ProjectModel::get_new_sort_order(pool).await?;
let result = sqlx::query_as(
"INSERT INTO projects(id, title, color, sort_order) VALUES($1, $2, $3, $4) RETURNING *",
)
.bind(id)
.bind(title)
.bind(color)
.bind(sort_order)
.fetch_one(pool)
.await?;
Ok(result)
}
pub async fn get_by_id(
pool: &Pool<Sqlite>,
id: Uuid,
) -> anyhow::Result<ProjectModel, AppError> {
sqlx::query_as("SELECT * FROM projects WHERE id = $1")
.bind(id)
.fetch_one(pool)
.await
.map_err(|e| match e {
sqlx::Error::RowNotFound => AppError::NotFound,
e => e.into(),
})
}
pub async fn list(pool: &Pool<Sqlite>) -> anyhow::Result<Vec<ProjectModel>> {
sqlx::query_as("SELECT * FROM projects;")
.fetch_all(pool)
.await
.map_err(|e| e.into())
}
pub async fn update(self, pool: &Pool<Sqlite>) -> anyhow::Result<ProjectModel, AppError> {
let now: DateTime<Utc> = Utc::now();
let _ = sqlx::query!(
"UPDATE projects
SET title=$1, color=$2, folder_id=$3, status=$4, sort_order=$5, updated_at=$6, completed_at=$7
WHERE id=$8",
self.title,
self.color,
self.folder_id,
self.status,
self.sort_order,
now,
self.completed_at,
self.id,
)
.execute(pool)
.await?;
ProjectModel::get_by_id(pool, self.id).await
}
pub async fn delete(pool: &Pool<Sqlite>, id: Uuid) -> anyhow::Result<()> {
sqlx::query!("DELETE FROM projects WHERE id = $1", id)
.execute(pool)
.await?;
Ok(())
}
}

View file

@ -1,3 +1,4 @@
mod projects;
mod tasks;
use axum::{
@ -26,11 +27,14 @@ pub struct ErrorJson {
impl IntoResponse for AppError {
fn into_response(self) -> axum::response::Response {
match self {
Self::InternalError(anyhow_error) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Something went wrong {}", anyhow_error),
)
.into_response(),
Self::InternalError(anyhow_error) => {
error!(err = ?anyhow_error, "Internal Server Error");
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Something went wrong {}", anyhow_error),
)
.into_response()
}
Self::JsonExtractError(rej) => (
StatusCode::UNPROCESSABLE_ENTITY,
Json(ErrorJson {
@ -85,5 +89,7 @@ impl From<sqlx::Error> for AppError {
}
}
pub use projects::*;
use serde::Serialize;
pub use tasks::*;
use tracing::error;

View file

@ -0,0 +1,106 @@
use axum::{
Json, Router,
extract::{Path, State},
http::StatusCode,
routing::{get, post},
};
use axum_extra::extract::WithRejection;
use serde::Deserialize;
use sqlx::{Pool, Sqlite};
use uuid::Uuid;
use crate::models::{ProjectModel, ProjectStatus};
use super::AppError;
pub fn create_project_router() -> Router<Pool<Sqlite>> {
Router::new()
.route("/", post(create_project).get(list_projects))
.route(
"/{project_id}",
get(get_project).put(update_project).delete(delete_project),
)
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CreateProjectRequest {
title: String,
color: String,
}
pub async fn create_project(
State(pool): State<Pool<Sqlite>>,
WithRejection(Json(input), _): WithRejection<Json<CreateProjectRequest>, AppError>,
) -> Result<(StatusCode, Json<ProjectModel>), AppError> {
Ok((
StatusCode::CREATED,
Json(ProjectModel::insert(&pool, input.title, input.color).await?),
))
}
pub async fn list_projects(
State(pool): State<Pool<Sqlite>>,
) -> Result<(StatusCode, Json<Vec<ProjectModel>>), AppError> {
Ok((StatusCode::OK, Json(ProjectModel::list(&pool).await?)))
}
pub async fn get_project(
State(pool): State<Pool<Sqlite>>,
WithRejection(Path(project_id), _): WithRejection<Path<Uuid>, AppError>,
) -> Result<(StatusCode, Json<ProjectModel>), AppError> {
Ok((
StatusCode::OK,
Json(ProjectModel::get_by_id(&pool, project_id).await?),
))
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub struct UpdateProjectRequest {
title: Option<String>,
color: Option<String>,
status: Option<ProjectStatus>,
}
pub async fn update_project(
State(pool): State<Pool<Sqlite>>,
WithRejection(Path(project_id), _): WithRejection<Path<Uuid>, AppError>,
WithRejection(Json(input), _): WithRejection<Json<UpdateProjectRequest>, AppError>,
) -> Result<(StatusCode, Json<ProjectModel>), AppError> {
let mut project = ProjectModel::get_by_id(&pool, project_id).await?;
if let Some(new_title) = input.title {
if new_title.len() == 0 {
return Err(AppError::Unprocessable(
"Title must not be empty".to_string(),
));
}
project.title = new_title;
}
if let Some(color) = input.color {
if color.len() != 7 || color.chars().next() != Some('#') {
return Err(AppError::Unprocessable(
"Color is not a valid hex".to_string(),
));
}
project.color = color;
}
if let Some(status) = input.status {
project.status = status;
}
Ok((StatusCode::OK, Json(project.update(&pool).await?)))
}
pub async fn delete_project(
State(pool): State<Pool<Sqlite>>,
WithRejection(Path(project_id), _): WithRejection<Path<Uuid>, AppError>,
) -> Result<StatusCode, AppError> {
ProjectModel::get_by_id(&pool, project_id).await?;
ProjectModel::delete(&pool, project_id).await?;
Ok(StatusCode::NO_CONTENT)
}

View file

@ -1,9 +1,15 @@
use std::path::Path;
use axum::{Router, routing::get};
use axum_test::{TestServer, TestServerConfig, Transport};
use hurl::report::html::{Testcase, write_report};
use hurl::runner::{self, RunnerOptions, Value, VariableSet};
use hurl::util::logger::LoggerOptionsBuilder;
use hurl_core::input::Input;
use tower_http::trace::TraceLayer;
async fn create_app() -> Router {
tracing_subscriber::fmt::try_init();
use backend::database::{DatabaseConfig, create_pool};
use backend::services;
@ -24,6 +30,8 @@ async fn create_app() -> Router {
Router::new()
.route("/health", get(health))
.nest("/api/tasks", services::create_task_router())
.nest("/api/projects", services::create_project_router())
.layer(TraceLayer::new_for_http())
.with_state(pool)
}
@ -49,11 +57,14 @@ async fn run_hurl_test(hurl_file_path: &str) {
let logger_opts = LoggerOptionsBuilder::new().build();
let result = runner::run(&content, None, &runner_opts, &variables, &logger_opts).unwrap();
assert!(
result.success,
"Hurl test failed for {}: {:?}",
hurl_file_path, result
);
let input = Input::new(hurl_file_path);
let test_case = Testcase::from(&result, &input);
test_case
.write_html(&content, &result.entries, Path::new("reports/store"), &[])
.expect("Failed to write html files");
write_report(Path::new("reports/"), &vec![test_case]).expect("Failed to write report");
assert!(result.success, "Hurl test failed for {}", hurl_file_path);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
@ -75,3 +86,8 @@ async fn test_update_tasks_api() {
async fn test_delete_tasks_api() {
run_hurl_test("./tests/api/delete_tasks.hurl").await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_projects_api() {
run_hurl_test("./tests/api/projects.hurl").await;
}

View file

@ -0,0 +1,387 @@
# Project API Tests
# Test: Create a new project with all fields (POST /api/projects)
POST {{host}}/api/projects
Content-Type: application/json
{
"title": "Test Project Alpha",
"color": "#1976d2"
}
HTTP 201
[Captures]
project_id: jsonpath "$.id"
[Asserts]
jsonpath "$.title" == "Test Project Alpha"
jsonpath "$.color" == "#1976d2"
jsonpath "$.status" == "active"
jsonpath "$.folder_id" == null
jsonpath "$.sort_order" == 0
jsonpath "$.id" exists
jsonpath "$.created_at" exists
jsonpath "$.updated_at" exists
# Test: Create another new project
POST {{host}}/api/projects
Content-Type: application/json
{
"title": "Test Project Beta",
"color": "#ffffff"
}
HTTP 201
[Captures]
project_id_beta: jsonpath "$.id"
[Asserts]
jsonpath "$.title" == "Test Project Beta"
jsonpath "$.color" == "#ffffff"
jsonpath "$.status" == "active"
jsonpath "$.folder_id" == null
jsonpath "$.sort_order" == 1000
jsonpath "$.id" exists
jsonpath "$.created_at" exists
jsonpath "$.updated_at" exists
POST {{host}}/api/projects
Content-Type: application/json
{
"title": "Missing Color"
}
HTTP 422
[Asserts]
jsonpath "$.error" exists
# Test: Create project with invalid data (missing title)
POST {{host}}/api/projects
Content-Type: application/json
{
"color": "#ffffff"
}
HTTP 422
[Asserts]
jsonpath "$.error" exists
# Test: Create project with invalid data (invalid color)
POST {{host}}/api/projects
Content-Type: application/json
{
"title": "Invalid Color",
"color": "#ffffffasdf"
}
HTTP 422
[Asserts]
jsonpath "$.error" exists
# Test: Get a specific project by ID (GET /api/projects/{id})
GET {{host}}/api/projects/{{project_id}}
HTTP 200
[Asserts]
jsonpath "$.id" == "{{project_id}}"
jsonpath "$.title" == "Test Project Alpha"
jsonpath "$.color" == "#1976d2"
jsonpath "$.status" == "active"
jsonpath "$.folder_id" == null
jsonpath "$.created_at" exists
jsonpath "$.updated_at" exists
# Test: Get a minimal project by ID
GET {{host}}/api/projects/{{project_id_beta}}
HTTP 200
[Asserts]
jsonpath "$.id" == "{{project_id_beta}}"
jsonpath "$.title" == "Test Project Beta"
jsonpath "$.color" == "#ffffff"
jsonpath "$.status" == "active"
# Test: Get non-existent project
GET {{host}}/api/projects/00000000-0000-0000-0000-000000000000
HTTP 404
[Asserts]
jsonpath "$.error" exists
# Test: Get project with invalid UUID format
GET {{host}}/api/projects/invalid-uuid
HTTP 400
[Asserts]
jsonpath "$.error" exists
# Test: List all projects (GET /api/projects)
GET {{host}}/api/projects
HTTP 200
[Captures]
initial_count: jsonpath "$" count
[Asserts]
jsonpath "$" isCollection
jsonpath "$[*].id" contains "{{project_id}}"
jsonpath "$[*].id" contains "{{project_id_beta}}"
jsonpath "$[*].title" contains "Test Project Alpha"
jsonpath "$[*].title" contains "Test Project Beta"
# Test: Verify all projects have required fields
GET {{host}}/api/projects
HTTP 200
[Asserts]
jsonpath "$[?(@.id=='{{project_id}}')].title" exists
jsonpath "$[?(@.id=='{{project_id}}')].status" exists
jsonpath "$[?(@.id=='{{project_id}}')].created_at" exists
jsonpath "$[?(@.id=='{{project_id}}')].updated_at" exists
jsonpath "$[?(@.id=='{{project_id}}')].sort_order" exists
# Test: Update project title only (PUT /api/projects/{id})
PUT {{host}}/api/projects/{{project_id}}
Content-Type: application/json
{
"title": "Updated Project Alpha"
}
HTTP 200
[Captures]
first_updated_at: jsonpath "$.updated_at"
[Asserts]
jsonpath "$.id" == "{{project_id}}"
jsonpath "$.title" == "Updated Project Alpha"
jsonpath "$.color" == "#1976d2"
jsonpath "$.status" == "active"
# Test: Update status to done
PUT {{host}}/api/projects/{{project_id}}
Content-Type: application/json
{
"status": "done"
}
HTTP 200
[Asserts]
jsonpath "$.id" == "{{project_id}}"
jsonpath "$.status" == "done"
# Test: Update status to backlog
PUT {{host}}/api/projects/{{project_id}}
Content-Type: application/json
{
"status": "backlog"
}
HTTP 200
[Asserts]
jsonpath "$.id" == "{{project_id}}"
jsonpath "$.status" == "backlog"
# Test: Update color
PUT {{host}}/api/projects/{{project_id}}
Content-Type: application/json
{
"color": "#388e3c"
}
HTTP 200
[Asserts]
jsonpath "$.id" == "{{project_id}}"
jsonpath "$.color" == "#388e3c"
# Test: Update multiple fields together
PUT {{host}}/api/projects/{{project_id}}
Content-Type: application/json
{
"title": "Completely Updated Project",
"color": "#d32f2f",
"status": "active"
}
HTTP 200
[Asserts]
jsonpath "$.id" == "{{project_id}}"
jsonpath "$.title" == "Completely Updated Project"
jsonpath "$.color" == "#d32f2f"
jsonpath "$.status" == "active"
# Test: Update non-existent project
PUT {{host}}/api/projects/00000000-0000-0000-0000-000000000000
Content-Type: application/json
{
"title": "This should fail"
}
HTTP 404
[Asserts]
jsonpath "$.error" exists
# Test: Update with invalid UUID format
PUT {{host}}/api/projects/invalid-uuid-format
Content-Type: application/json
{
"title": "This should also fail"
}
HTTP 400
[Asserts]
jsonpath "$.error" exists
# Test: Update with empty title
PUT {{host}}/api/projects/{{project_id_beta}}
Content-Type: application/json
{
"title": ""
}
HTTP 422
[Asserts]
jsonpath "$.error" exists
# Test: Update with invalid status
PUT {{host}}/api/projects/{{project_id_beta}}
Content-Type: application/json
{
"status": "invalid_status"
}
HTTP 422
[Asserts]
jsonpath "$.error" exists
# Test: Update with invalid color format
PUT {{host}}/api/projects/{{project_id_beta}}
Content-Type: application/json
{
"color": "invalid-color"
}
HTTP 422
[Asserts]
jsonpath "$.error" exists
# Setup: Create a project to delete
POST {{host}}/api/projects
Content-Type: application/json
{
"title": "Project to Delete",
"color": "#222222"
}
HTTP 201
[Captures]
delete_project_id: jsonpath "$.id"
# Test: Delete project successfully (DELETE /api/projects/{id})
DELETE {{host}}/api/projects/{{delete_project_id}}
HTTP 204
# Test: Verify project is deleted (should return 404)
GET {{host}}/api/projects/{{delete_project_id}}
HTTP 404
[Asserts]
jsonpath "$.error" exists
# Setup: Create another project for additional delete tests
POST {{host}}/api/projects
Content-Type: application/json
{
"title": "Another Project to Delete",
"color": "#333333"
}
HTTP 201
[Captures]
another_delete_project_id: jsonpath "$.id"
# Test: Verify project exists before deletion
GET {{host}}/api/projects/{{another_delete_project_id}}
HTTP 200
[Asserts]
jsonpath "$.title" == "Another Project to Delete"
# Test: Delete the project
DELETE {{host}}/api/projects/{{another_delete_project_id}}
HTTP 204
# Test: Confirm project no longer exists
GET {{host}}/api/projects/{{another_delete_project_id}}
HTTP 404
[Asserts]
jsonpath "$.error" exists
# Test: Delete non-existent project
DELETE {{host}}/api/projects/00000000-0000-0000-0000-000000000000
HTTP 404
[Asserts]
jsonpath "$.error" exists
# Test: Delete with invalid UUID format
DELETE {{host}}/api/projects/invalid-uuid-format
HTTP 400
[Asserts]
jsonpath "$.error" exists
# Test: Multiple deletions of same project (idempotency test)
POST {{host}}/api/projects
Content-Type: application/json
{
"title": "Project for Idempotency Test",
"color": "#234567"
}
HTTP 201
[Captures]
idempotent_project_id: jsonpath "$.id"
# First deletion should succeed
DELETE {{host}}/api/projects/{{idempotent_project_id}}
HTTP 204
# Second deletion should return 404 (project already gone)
DELETE {{host}}/api/projects/{{idempotent_project_id}}
HTTP 404
[Asserts]
jsonpath "$.error" exists
# Test: List shows remaining projects after deletions
GET {{host}}/api/projects
HTTP 200
[Asserts]
jsonpath "$[*].id" contains "{{project_id}}"
jsonpath "$[*].id" contains "{{project_id_beta}}"
jsonpath "$[*].id" not contains "{{delete_project_id}}"
jsonpath "$[*].id" not contains "{{another_delete_project_id}}"
jsonpath "$[*].id" not contains "{{idempotent_project_id}}"
# Cleanup: Delete remaining test projects
DELETE {{host}}/api/projects/{{project_id}}
HTTP 204
DELETE {{host}}/api/projects/{{project_id_beta}}
HTTP 204
# Test: Verify all test projects are cleaned up
GET {{host}}/api/projects
HTTP 200
[Asserts]
jsonpath "$[*].id" not contains "{{project_id}}"
jsonpath "$[*].id" not contains "{{project_id_beta}}"