Skip to content

Commit 059b69d

Browse files
committed
refactor: test structure and add integration tests
1 parent 562fe47 commit 059b69d

File tree

26 files changed

+2584
-756
lines changed

26 files changed

+2584
-756
lines changed

rustytime/Cargo.lock

Lines changed: 280 additions & 54 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

rustytime/Cargo.toml

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
11
[package]
22
name = "rustytime-server"
33
description = "🕒 blazingly fast time tracking for developers"
4-
version = "0.16.4"
4+
version = "0.17.0"
55
edition = "2024"
66
authors = ["ImShyMike"]
77
readme = "../README.md"
88
license = "AGPL-3.0-only"
99
homepage = "https://rustytime.shymike.dev"
1010
repository = "https://github.com/ImShyMike/rustytime"
1111

12+
[lib]
13+
name = "rustytime_server"
14+
path = "src/lib.rs"
15+
1216
[[bin]]
1317
name = "rustytime"
1418
path = "src/main.rs"
@@ -34,10 +38,11 @@ debug = false
3438
[features]
3539
default = []
3640
seed = ["rand"]
41+
integration = []
3742

3843
[dependencies]
3944
axum = { version = "0.8.8", features = ["json", "query", "http1", "http2", "tokio", "macros"], default-features = false }
40-
diesel = { version = "2.3.5", features = ["32-column-tables", "chrono", "postgres", "uuid", "r2d2", "network-address"], default-features = false }
45+
diesel = { version = "2.3.6", features = ["32-column-tables", "chrono", "postgres", "uuid", "r2d2", "network-address"], default-features = false }
4146
diesel_migrations = { version = "2.3.1", default-features = false }
4247
tokio = { version = "1.49.0", features = ["signal", "rt-multi-thread"], default-features = false }
4348
tower-http = { version = "0.6.7", features = ["limit", "timeout", "trace", "cors", "normalize-path", "compression-gzip", "decompression-gzip"], default-features = false }
@@ -56,7 +61,7 @@ reqwest = { version = "0.13.1", features = ["json", "cookies", "query", "rustls"
5661
url = { version = "2.5.8", default-features = false }
5762
tower = { version = "0.5.3", default-features = false }
5863
tower-cookies = { version = "0.11.0", features = ["axum-core"], default-features = false }
59-
time = { version = "0.3.45", default-features = false }
64+
time = { version = "0.3.46", default-features = false }
6065
urlencoding = { version = "2.1.3", default-features = false }
6166
regex = { version = "1.12.2", default-features = false }
6267
once_cell = { version = "1.21.3", default-features = false }
@@ -82,3 +87,9 @@ cron = { version = "0.15.0", default-features = false }
8287
futures = { version = "0.3.31", default-features = false }
8388
sqlx = { version = "0.8.6", default-features = false }
8489
moka = { version = "0.12.10", features = ["sync"], default-features = false }
90+
91+
[dev-dependencies]
92+
serde_urlencoded = { version = "0.7" }
93+
serde_qs = { version = "0.15.0" }
94+
axum-test = { version = "18.7.0" }
95+
tower = { version = "0.5", features = ["util"] }

rustytime/src/docs.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
use std::env;
2+
13
use aide::openapi::{
24
ApiKeyLocation, Components, License, OpenApi, ReferenceOr, SecurityScheme, Server, Tag,
35
};
@@ -20,7 +22,7 @@ pub fn get_openapi_docs() -> OpenApi {
2022

2123
openapi.servers = vec![
2224
Server {
23-
url: "https://api-rustytime.shymike.dev".into(),
25+
url: env::var("PUBLIC_BACKEND_API_URL").unwrap_or_else(|_| "https://api-rustytime.shymike.dev".into()),
2426
description: Some("Production deployment".into()),
2527
..Default::default()
2628
},

rustytime/src/handlers/data/import.rs

Lines changed: 7 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ use crate::jobs::import::enqueue_import;
1616
use crate::models::import_job::{ImportJob, ImportJobStatus};
1717
use crate::state::AppState;
1818
use crate::utils::extractors::AuthenticatedUser;
19+
use crate::utils::extractors::DbConnection;
1920
use crate::utils::session::SessionManager;
2021

2122
#[derive(Deserialize, JsonSchema)]
@@ -66,6 +67,7 @@ pub async fn import_heartbeats(
6667
Query(query): Query<ImportQuery>,
6768
cookies: NoApi<Cookies>,
6869
NoApi(AuthenticatedUser(current_user)): NoApi<AuthenticatedUser>,
70+
NoApi(DbConnection(mut conn)): NoApi<DbConnection>,
6971
) -> Result<Json<ImportStartResponse>, Response> {
7072
let Some(session_id) = SessionManager::get_session_from_cookies(&cookies) else {
7173
return Err((StatusCode::UNAUTHORIZED, "User session is invalid").into_response());
@@ -97,7 +99,7 @@ pub async fn import_heartbeats(
9799

98100
let user_id = current_user.id;
99101

100-
if let Ok(Some(active_job)) = ImportJob::get_active_for_user(&app_state.db_pool, user_id) {
102+
if let Ok(Some(active_job)) = ImportJob::get_active_for_user(&mut conn, user_id) {
101103
return Err((
102104
StatusCode::CONFLICT,
103105
format!(
@@ -109,7 +111,7 @@ pub async fn import_heartbeats(
109111
}
110112

111113
let import_job = db_query!(
112-
ImportJob::create(&app_state.db_pool, user_id),
114+
ImportJob::create(&mut conn, user_id),
113115
"Failed to create import job"
114116
);
115117

@@ -125,7 +127,7 @@ pub async fn import_heartbeats(
125127

126128
if let Err(e) = enqueue_import(store, user_id, api_key, import_job.id).await {
127129
error!(error = ?e, job_id = import_job.id, "Failed to enqueue import job");
128-
let _ = ImportJob::fail(&app_state.db_pool, import_job.id, "Failed to enqueue job");
130+
let _ = ImportJob::fail(&mut conn, import_job.id, "Failed to enqueue job");
129131
return Err((
130132
StatusCode::INTERNAL_SERVER_ERROR,
131133
"Failed to start import job",
@@ -147,13 +149,13 @@ pub async fn import_heartbeats(
147149
}
148150

149151
pub async fn import_status(
150-
State(app_state): State<AppState>,
151152
NoApi(AuthenticatedUser(current_user)): NoApi<AuthenticatedUser>,
153+
NoApi(DbConnection(mut conn)): NoApi<DbConnection>,
152154
) -> Result<Json<ImportStatusResponse>, Response> {
153155
let user_id = current_user.id;
154156

155157
let job = db_query!(
156-
ImportJob::get_latest_for_user(&app_state.db_pool, user_id),
158+
ImportJob::get_latest_for_user(&mut conn, user_id),
157159
"Failed to get import job"
158160
);
159161

@@ -162,32 +164,3 @@ pub async fn import_status(
162164
None => Err((StatusCode::NOT_FOUND, "No import jobs found").into_response()),
163165
}
164166
}
165-
166-
#[cfg(test)]
167-
mod tests {
168-
use super::*;
169-
170-
#[test]
171-
fn import_status_response_from_job() {
172-
use chrono::Utc;
173-
174-
let job = ImportJob {
175-
id: 1,
176-
user_id: 42,
177-
status: "completed".to_string(),
178-
imported_count: Some(100),
179-
processed_count: Some(150),
180-
request_count: Some(5),
181-
start_date: Some("2024-01-01T00:00:00Z".to_string()),
182-
time_taken: Some(12.5),
183-
error_message: None,
184-
created_at: Utc::now(),
185-
updated_at: Utc::now(),
186-
};
187-
188-
let response = ImportStatusResponse::from(job);
189-
assert_eq!(response.job_id, 1);
190-
assert_eq!(response.status, "completed");
191-
assert_eq!(response.imported_count, Some(100));
192-
}
193-
}

rustytime/src/handlers/page/imports.rs

Lines changed: 8 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,14 @@
11
use aide::NoApi;
22
use axum::Json;
33
use axum::extract::Query;
4-
use axum::{extract::State, http::StatusCode, response::IntoResponse, response::Response};
4+
use axum::{http::StatusCode, response::IntoResponse, response::Response};
55
use schemars::JsonSchema;
66
use serde::{Deserialize, Serialize};
77

88
use crate::db_query;
99
use crate::models::import_job::ImportJob;
10-
use crate::models::user::User;
11-
use crate::state::AppState;
12-
use crate::utils::extractors::AuthenticatedUser;
10+
use crate::models::import_job::ImportJobWithUser;
11+
use crate::utils::extractors::{AuthenticatedUser, DbConnection};
1312

1413
#[derive(Deserialize, JsonSchema)]
1514
pub struct ImportsQuery {
@@ -23,23 +22,6 @@ fn default_limit() -> i64 {
2322
50
2423
}
2524

26-
#[derive(Serialize, JsonSchema)]
27-
pub struct ImportJobWithUser {
28-
pub id: i64,
29-
pub user_id: i32,
30-
pub user_name: Option<String>,
31-
pub user_avatar_url: Option<String>,
32-
pub status: String,
33-
pub imported_count: Option<i64>,
34-
pub processed_count: Option<i64>,
35-
pub request_count: Option<i32>,
36-
pub start_date: Option<String>,
37-
pub time_taken: Option<f64>,
38-
pub error_message: Option<String>,
39-
pub created_at: String,
40-
pub updated_at: String,
41-
}
42-
4325
#[derive(Serialize, JsonSchema)]
4426
pub struct AdminImportsResponse {
4527
pub imports: Vec<ImportJobWithUser>,
@@ -49,55 +31,27 @@ pub struct AdminImportsResponse {
4931
}
5032

5133
pub async fn admin_imports(
52-
State(app_state): State<AppState>,
5334
Query(query): Query<ImportsQuery>,
5435
NoApi(AuthenticatedUser(current_user)): NoApi<AuthenticatedUser>,
36+
NoApi(DbConnection(mut conn)): NoApi<DbConnection>,
5537
) -> Result<Json<AdminImportsResponse>, Response> {
56-
if !current_user.is_admin() {
38+
if !current_user.is_owner() {
5739
return Err((StatusCode::FORBIDDEN, "No permission").into_response());
5840
}
5941

6042
let limit = query.limit.clamp(1, 100);
6143
let offset = query.offset.max(0);
6244

6345
let total = db_query!(
64-
ImportJob::count_all(&app_state.db_pool),
46+
ImportJob::count_all(&mut conn),
6547
"Failed to count import jobs"
6648
);
6749

68-
let jobs = db_query!(
69-
ImportJob::get_all(&app_state.db_pool, limit, offset),
50+
let imports = db_query!(
51+
ImportJob::get_all_with_users(&mut conn, limit, offset),
7052
"Failed to fetch import jobs"
7153
);
7254

73-
let user_ids: Vec<i32> = jobs.iter().map(|j| j.user_id).collect();
74-
let users = db_query!(
75-
User::get_by_ids(&app_state.db_pool, &user_ids),
76-
"Failed to fetch users"
77-
);
78-
79-
let imports: Vec<ImportJobWithUser> = jobs
80-
.into_iter()
81-
.map(|job| {
82-
let user = users.iter().find(|u| u.id == job.user_id);
83-
ImportJobWithUser {
84-
id: job.id,
85-
user_id: job.user_id,
86-
user_name: user.map(|u| u.name.clone()),
87-
user_avatar_url: user.map(|u| u.avatar_url.clone()),
88-
status: job.status,
89-
imported_count: job.imported_count,
90-
processed_count: job.processed_count,
91-
request_count: job.request_count,
92-
start_date: job.start_date,
93-
time_taken: job.time_taken,
94-
error_message: job.error_message,
95-
created_at: job.created_at.to_rfc3339(),
96-
updated_at: job.updated_at.to_rfc3339(),
97-
}
98-
})
99-
.collect();
100-
10155
Ok(Json(AdminImportsResponse {
10256
imports,
10357
total,

rustytime/src/jobs/import.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,14 +120,16 @@ async fn run_import(job: ImportJob, pool: Data<DbPool>) -> Result<String, BoxDyn
120120

121121
let elapsed = started.elapsed();
122122

123+
let conn = &mut *pool.get().expect("Failed to get DB connection from pool");
124+
123125
match result {
124126
Ok((imported, processed, requests, earliest_requested)) => {
125127
let start_date = earliest_requested
126128
.map(format_rfc3339)
127129
.unwrap_or_else(|| cutoff.to_rfc3339_opts(SecondsFormat::Millis, true));
128130

129131
if let Err(e) = ImportJobModel::complete(
130-
&pool,
132+
conn,
131133
job_id,
132134
imported as i64,
133135
processed as i64,
@@ -154,7 +156,7 @@ async fn run_import(job: ImportJob, pool: Data<DbPool>) -> Result<String, BoxDyn
154156
))
155157
}
156158
Err(error_message) => {
157-
if let Err(e) = ImportJobModel::fail(&pool, job_id, &error_message) {
159+
if let Err(e) = ImportJobModel::fail(conn, job_id, &error_message) {
158160
error!(error = ?e, "Failed to update import job as failed");
159161
}
160162

rustytime/src/lib.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
#![forbid(unsafe_code)]
2+
#![cfg(feature = "integration")]
3+
4+
pub mod db;
5+
pub mod docs;
6+
pub mod handlers;
7+
pub mod jobs;
8+
pub mod models;
9+
pub mod routes;
10+
pub mod schema;
11+
pub mod state;
12+
pub mod utils;

0 commit comments

Comments
 (0)