···6565 let mut insert = self.client.insert(&self.table_name)?;
66666767 for exercise in &workout.exercises {
6868- let exercise_nr = workout.exercises.iter().position(|x| x.id == exercise.id).unwrap() as u32;
6868+ let exercise_nr = workout
6969+ .exercises
7070+ .iter()
7171+ .position(|x| x.id == exercise.id)
7272+ .unwrap() as u32;
6973 for set in &exercise.sets {
7074 let start_dt = OffsetDateTime::parse(
7175 &workout.start_date.clone().unwrap_or_default(),
7276 &Rfc3339,
7377 )?;
74787575- let end_dt = OffsetDateTime::parse(
7676- &workout.end_date.clone().unwrap_or_default(),
7777- &Rfc3339,
7878- )?;
7979+ let end_dt =
8080+ OffsetDateTime::parse(&workout.end_date.clone().unwrap_or_default(), &Rfc3339)?;
79818082 let set_nr = exercise.sets.iter().position(|x| x.id == set.id).unwrap() as u32;
8183···109111 println!("Workout {} imported successfully", workout.id);
110112 Ok(())
111113 }
112112-}114114+}
+11-17
strong-api-fetch/src/main.rs
···21212222 // Load configuration from environment variables.
2323 let config = load_config()?;
2424- let url = Url::parse(&config.strong_backend)
2525- .expect("STRONG_BACKEND is not a valid URL");
2424+ let url = Url::parse(&config.strong_backend).expect("STRONG_BACKEND is not a valid URL");
26252726 // Initialize the API and ClickHouse saver.
2827 let mut strong_api = StrongApi::new(url);
2928 let clickhouse_saver = create_clickhouse_saver(&config);
30293130 // Log in to the API.
3232- strong_api.login(config.username.as_str(), config.password.as_str()).await?;
3131+ strong_api
3232+ .login(config.username.as_str(), config.password.as_str())
3333+ .await?;
33343435 // Get the measurements (either from file or API).
3536 let measurements_response = get_measurements_response(&mut strong_api).await?;
···7273/// Load configuration values from environment variables.
7374fn load_config() -> Result<Config, Box<dyn std::error::Error>> {
7475 Ok(Config {
7575- username: env::var("STRONG_USER")
7676- .expect("STRONG_USER must be set"),
7777- password: env::var("STRONG_PASS")
7878- .expect("STRONG_PASS must be set"),
7979- strong_backend: env::var("STRONG_BACKEND")
8080- .expect("STRONG_BACKEND must be set"),
8181- clickhouse_url: env::var("CLICKHOUSE_URL")
8282- .expect("CLICKHOUSE_URL must be set"),
8383- clickhouse_user: env::var("CLICKHOUSE_USER")
8484- .expect("CLICKHOUSE_USER must be set"),
8585- clickhouse_pass: env::var("CLICKHOUSE_PASS")
8686- .expect("CLICKHOUSE_PASS must be set"),
7676+ username: env::var("STRONG_USER").expect("STRONG_USER must be set"),
7777+ password: env::var("STRONG_PASS").expect("STRONG_PASS must be set"),
7878+ strong_backend: env::var("STRONG_BACKEND").expect("STRONG_BACKEND must be set"),
7979+ clickhouse_url: env::var("CLICKHOUSE_URL").expect("CLICKHOUSE_URL must be set"),
8080+ clickhouse_user: env::var("CLICKHOUSE_USER").expect("CLICKHOUSE_USER must be set"),
8181+ clickhouse_pass: env::var("CLICKHOUSE_PASS").expect("CLICKHOUSE_PASS must be set"),
8782 clickhouse_database: env::var("CLICKHOUSE_DATABASE")
8883 .expect("CLICKHOUSE_DATABASE must be set"),
8989- clickhouse_table: env::var("CLICKHOUSE_TABLE")
9090- .expect("CLICKHOUSE_TABLE must be set"),
8484+ clickhouse_table: env::var("CLICKHOUSE_TABLE").expect("CLICKHOUSE_TABLE must be set"),
9185 })
9286}
9387
+5-1
strong-api-lib/Cargo.toml
···11[package]
22name = "strong-api-lib"
33-version = "0.3.0"
33+version = "0.3.1"
44edition = "2024"
5566[dependencies]
77reqwest = { version = "0.12.12", features = ["json"] }
88serde_json = "1.0.139"
99serde = { version = "1.0.218", features = ["derive"] }
1010+1111+[dev-dependencies]
1212+wiremock = "0.6"
1313+tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
10141115[features]
1216full = []
···9090 username: &str,
9191 password: &str,
9292 ) -> Result<(), Box<dyn std::error::Error>> {
9393- let url = self.url.join("auth/login")?;
9393+ let url = self
9494+ .url
9595+ .join("auth/login")
9696+ .expect("joining auth/login has failed, check the base URL");
9497 let body = json!({
9598 "usernameOrEmail": username,
9699 "password": password
···103106 .json(&body)
104107 .send()
105108 .await?;
106106- let response_text = response.text().await?;
109109+ let response_text = response.text().await.expect("failed to read response body");
107110108111 let parsed: LoginResponse = serde_json::from_str(&response_text)?;
109112···116119117120 /// Refreshes the access token using tokens obtained during login.
118121 pub async fn refresh(&mut self) -> Result<(), Box<dyn std::error::Error>> {
119119- let url = self.url.join("auth/login/refresh")?;
122122+ let url = self
123123+ .url
124124+ .join("auth/login/refresh")
125125+ .expect("joining auth/login/refresh has failed, check the base URL");
120126 let body = json!({
121127 "accessToken": self.access_token,
122128 "refreshToken": self.refresh_token
···133139 .send()
134140 .await?;
135141136136- let response_text = response.text().await?;
142142+ let response_text = response.text().await.expect("failed to read response body");
137143 let parsed: LoginResponse = serde_json::from_str(&response_text)?;
138144139145 self.access_token = parsed.access_token;
···149155 access_token: String,
150156 refresh_token: String,
151157 ) -> Result<(), Box<dyn std::error::Error>> {
152152- let url = self.url.join("auth/login/refresh")?;
158158+ let url = self
159159+ .url
160160+ .join("auth/login/refresh")
161161+ .expect("joining auth/login/refresh has failed, check the base URL");
153162 let body = json!({
154163 "accessToken": access_token.clone(),
155164 "refreshToken": refresh_token,
···163172 .json(&body)
164173 .send()
165174 .await?;
166166- let response_text = response.text().await?;
175175+ let response_text = response.text().await.expect("failed to read response body");
167176 let parsed: LoginResponse = serde_json::from_str(&response_text)?;
168177169178 self.access_token = parsed.access_token;
···186195 .user_id
187196 .as_ref()
188197 .ok_or("Missing user id. Use `login` before calling `get_user`")?;
189189- let mut url = self.url.join(&format!("api/users/{user_id}"))?;
198198+ let mut url = self
199199+ .url
200200+ .join(&format!("api/users/{user_id}"))
201201+ .expect("joining api/users/{user_id} has failed, check the base URL");
190202191203 {
192204 // Use query_pairs_mut to build the query string.
···208220209221 // Capture the status before consuming the response.
210222 let status = response.status();
211211- let response_text = response.text().await?;
223223+ let response_text = response.text().await.expect("failed to read response body");
212224213225 if !status.is_success() {
214226 let api_error: ApiErrorResponse = serde_json::from_str(&response_text)?;
···227239 &self,
228240 page: i8,
229241 ) -> Result<MeasurementsResponse, Box<dyn std::error::Error>> {
230230- let mut url = self.url.join("api/measurements")?;
242242+ let mut url = self
243243+ .url
244244+ .join("api/measurements")
245245+ .expect("joining api/measurements has failed, check the base URL");
231246232247 {
233248 let mut query_pairs = url.query_pairs_mut();
234249 query_pairs.append_pair("page", &page.to_string());
235250 }
236251237237- let response = self.client.get(url).headers(Self::default_headers()).send().await?;
238238- let response_text = response.text().await?;
252252+ let response = self
253253+ .client
254254+ .get(url)
255255+ .headers(Self::default_headers())
256256+ .send()
257257+ .await?;
258258+ let response_text = response.text().await.expect("failed to read response body");
239259240260 let response: MeasurementsResponse = serde_json::from_str(&response_text)?;
241261···244264245265 pub async fn get_logs_raw(&self) -> Result<String, Box<dyn std::error::Error>> {
246266 let user_id = self.user_id.as_ref().ok_or("Missing user id")?;
247247- let url = self.url.join(&format!("api/logs/{user_id}"))?;
267267+ let url = self
268268+ .url
269269+ .join(&format!("api/logs/{user_id}"))
270270+ .expect("joining api/logs/{user_id} has failed, check the base URL");
248271 let response = self
249272 .client
250273 .get(url)
···252275 .headers(self.headers.clone())
253276 .send()
254277 .await?;
255255- let response_text = response.text().await?;
278278+ let response_text = response.text().await.expect("failed to read response body");
256279257280 Ok(response_text)
258281 }
+3
strong-api-lib/tests/auth_tests.rs
···11+// LoginResponse is a plain #[derive(Deserialize)] struct with no logic.
22+// Its deserialization is covered by test_login_sets_tokens in strong_api_tests.rs.
33+// This file is intentionally empty.
+359
strong-api-lib/tests/data_transformer_tests.rs
···11+use strong_api_lib::data_transformer::DataTransformer;
22+use strong_api_lib::models::measurement::MeasurementsResponse;
33+use strong_api_lib::models::workout::UserResponse;
44+55+fn load_fixture(name: &str) -> String {
66+ std::fs::read_to_string(format!(
77+ "{}/tests/fixtures/{}",
88+ env!("CARGO_MANIFEST_DIR"),
99+ name
1010+ ))
1111+ .unwrap_or_else(|_| panic!("fixture '{name}' not found"))
1212+}
1313+1414+fn measurements_from_fixture() -> MeasurementsResponse {
1515+ let json = load_fixture("measurements_response.json");
1616+ serde_json::from_str(&json).unwrap()
1717+}
1818+1919+fn logs_from_fixture() -> Option<Vec<strong_api_lib::models::workout::Log>> {
2020+ let json = load_fixture("user_response.json");
2121+ let user: UserResponse = serde_json::from_str(&json).unwrap();
2222+ user.embedded.log
2323+}
2424+2525+// ---------------------------------------------------------------------------
2626+// Basic transformation — no measurements loaded, names fall back to empty
2727+// ---------------------------------------------------------------------------
2828+2929+#[test]
3030+fn test_transform_without_measurements_produces_workouts() {
3131+ let transformer = DataTransformer::new();
3232+ let logs = logs_from_fixture();
3333+ let workouts = transformer.get_measurements_from_logs(&logs).unwrap();
3434+3535+ assert!(!workouts.is_empty(), "should produce at least one workout");
3636+ // Without a measurement lookup every exercise name is empty string
3737+ for exercise in &workouts[0].exercises {
3838+ assert_eq!(exercise.name, "");
3939+ }
4040+}
4141+4242+// ---------------------------------------------------------------------------
4343+// With measurements: names are resolved and exercises are non-empty
4444+// ---------------------------------------------------------------------------
4545+4646+#[test]
4747+fn test_transform_with_measurements_resolves_names() {
4848+ let transformer =
4949+ DataTransformer::new().with_measurements_response(measurements_from_fixture());
5050+ let logs = logs_from_fixture();
5151+ let workouts = transformer.get_measurements_from_logs(&logs).unwrap();
5252+5353+ let exercises = &workouts[0].exercises;
5454+ assert!(!exercises.is_empty(), "should have at least one exercise");
5555+5656+ // Every exercise whose measurement was found in the lookup has a non-empty name.
5757+ let named = exercises.iter().filter(|e| !e.name.is_empty()).count();
5858+ assert!(
5959+ named > 0,
6060+ "at least one exercise should have a resolved name"
6161+ );
6262+}
6363+6464+// ---------------------------------------------------------------------------
6565+// Exercises come only from groups that have at least one valid (non-rest) set
6666+// ---------------------------------------------------------------------------
6767+6868+#[test]
6969+fn test_only_groups_with_real_sets_become_exercises() {
7070+ let transformer =
7171+ DataTransformer::new().with_measurements_response(measurements_from_fixture());
7272+ let logs = logs_from_fixture();
7373+ let workouts = transformer.get_measurements_from_logs(&logs).unwrap();
7474+7575+ // Count CSGs in the raw fixture log
7676+ let json = load_fixture("user_response.json");
7777+ let user: UserResponse = serde_json::from_str(&json).unwrap();
7878+ let raw_csg_count = user.embedded.log.unwrap()[0].embedded.cell_set_group.len();
7979+8080+ // Transformed exercise count can be at most the number of raw CSGs
8181+ assert!(workouts[0].exercises.len() <= raw_csg_count);
8282+}
8383+8484+// ---------------------------------------------------------------------------
8585+// Sets: weight and reps fields are always present on every set
8686+// ---------------------------------------------------------------------------
8787+8888+#[test]
8989+fn test_sets_have_weight_and_reps() {
9090+ let transformer =
9191+ DataTransformer::new().with_measurements_response(measurements_from_fixture());
9292+ let logs = logs_from_fixture();
9393+ let workouts = transformer.get_measurements_from_logs(&logs).unwrap();
9494+9595+ let exercises = &workouts[0].exercises;
9696+ for exercise in exercises {
9797+ assert!(
9898+ !exercise.sets.is_empty(),
9999+ "exercise {} should have sets",
100100+ exercise.id
101101+ );
102102+ for set in &exercise.sets {
103103+ let _ = set.reps; // always present
104104+ let _ = set.weight; // optional (None for bodyweight)
105105+ }
106106+ }
107107+}
108108+109109+// ---------------------------------------------------------------------------
110110+// Workout metadata is preserved
111111+// ---------------------------------------------------------------------------
112112+113113+#[test]
114114+fn test_workout_metadata_is_present() {
115115+ let transformer = DataTransformer::new();
116116+ let logs = logs_from_fixture();
117117+ let workouts = transformer.get_measurements_from_logs(&logs).unwrap();
118118+119119+ let workout = &workouts[0];
120120+ assert!(!workout.id.is_empty());
121121+ assert!(workout.timezone.is_some(), "timezone should be present");
122122+ assert!(workout.start_date.is_some(), "start_date should be present");
123123+ assert!(workout.end_date.is_some(), "end_date should be present");
124124+}
125125+126126+// ---------------------------------------------------------------------------
127127+// Measurement ID extraction from link (replaces former inline unit tests)
128128+// Tested indirectly through the public API: if the ID is extracted correctly
129129+// the name lookup succeeds; if the link is absent the name is empty.
130130+// ---------------------------------------------------------------------------
131131+132132+fn make_log_with_measurement_link(
133133+ measurement_href: Option<&str>,
134134+) -> Vec<strong_api_lib::models::workout::Log> {
135135+ use serde_json::json;
136136+ use strong_api_lib::models::common::Link;
137137+ use strong_api_lib::models::workout::{
138138+ Cell, CellSet, CellSetGroup, CellSetGroupEmbedded, CellSetGroupLinks, Log, LogEmbedded,
139139+ };
140140+141141+ vec![Log {
142142+ id: "log-link-test".to_string(),
143143+ embedded: LogEmbedded {
144144+ cell_set_group: vec![CellSetGroup {
145145+ id: "csg-link-test".to_string(),
146146+ links: CellSetGroupLinks {
147147+ measurement: measurement_href.map(|href| Link {
148148+ href: href.to_string(),
149149+ }),
150150+ },
151151+ embedded: CellSetGroupEmbedded {},
152152+ cell_sets: vec![CellSet {
153153+ id: "cs-link-test".to_string(),
154154+ is_completed: Some(true),
155155+ cells: vec![
156156+ Cell {
157157+ id: "c1".to_string(),
158158+ cell_type: "BARBELL_WEIGHT".to_string(),
159159+ value: Some("80".to_string()),
160160+ },
161161+ Cell {
162162+ id: "c2".to_string(),
163163+ cell_type: "REPS".to_string(),
164164+ value: Some("5".to_string()),
165165+ },
166166+ ],
167167+ }],
168168+ }],
169169+ },
170170+ links: json!({}),
171171+ timezone_id: None,
172172+ created: "2024-01-01T00:00:00Z".to_string(),
173173+ last_changed: "2024-01-01T00:00:00Z".to_string(),
174174+ name: None,
175175+ access: "private".to_string(),
176176+ start_date: None,
177177+ end_date: None,
178178+ log_type: "WORKOUT".to_string(),
179179+ }]
180180+}
181181+182182+#[test]
183183+fn test_measurement_id_extracted_from_link_resolves_name() {
184184+ // Use a known measurement ID from the fixture
185185+ let measurements = measurements_from_fixture();
186186+ let known = &measurements.embedded.measurements[0];
187187+ let known_id = known.id.clone();
188188+ let expected_name = known.name.to_string();
189189+190190+ let href = format!("/api/users/00000000-0000-0000-0000-000000000001/measurements/{known_id}");
191191+ let logs = make_log_with_measurement_link(Some(&href));
192192+193193+ let transformer = DataTransformer::new().with_measurements_response(measurements);
194194+ let workouts = transformer.get_measurements_from_logs(&Some(logs)).unwrap();
195195+196196+ assert_eq!(workouts[0].exercises[0].name, expected_name);
197197+}
198198+199199+#[test]
200200+fn test_missing_measurement_link_gives_empty_name() {
201201+ let logs = make_log_with_measurement_link(None);
202202+203203+ let transformer =
204204+ DataTransformer::new().with_measurements_response(measurements_from_fixture());
205205+ let workouts = transformer.get_measurements_from_logs(&Some(logs)).unwrap();
206206+207207+ assert_eq!(workouts[0].exercises[0].name, "");
208208+}
209209+210210+// ---------------------------------------------------------------------------
211211+// Cell type coverage — NOTE filter, all weight variants, RPE, missing REPS
212212+// ---------------------------------------------------------------------------
213213+214214+fn make_log_with_cells(
215215+ cells: Vec<(String, Option<String>)>,
216216+) -> Vec<strong_api_lib::models::workout::Log> {
217217+ use serde_json::json;
218218+ use strong_api_lib::models::workout::{
219219+ Cell, CellSet, CellSetGroup, CellSetGroupEmbedded, CellSetGroupLinks, Log, LogEmbedded,
220220+ };
221221+222222+ let cells: Vec<Cell> = cells
223223+ .into_iter()
224224+ .enumerate()
225225+ .map(|(i, (cell_type, value))| Cell {
226226+ id: format!("c{i}"),
227227+ cell_type,
228228+ value,
229229+ })
230230+ .collect();
231231+232232+ vec![Log {
233233+ id: "log-cell-test".to_string(),
234234+ embedded: LogEmbedded {
235235+ cell_set_group: vec![CellSetGroup {
236236+ id: "csg-cell-test".to_string(),
237237+ links: CellSetGroupLinks { measurement: None },
238238+ embedded: CellSetGroupEmbedded {},
239239+ cell_sets: vec![CellSet {
240240+ id: "cs-cell-test".to_string(),
241241+ is_completed: Some(true),
242242+ cells,
243243+ }],
244244+ }],
245245+ },
246246+ links: json!({}),
247247+ timezone_id: None,
248248+ created: "2024-01-01T00:00:00Z".to_string(),
249249+ last_changed: "2024-01-01T00:00:00Z".to_string(),
250250+ name: None,
251251+ access: "private".to_string(),
252252+ start_date: None,
253253+ end_date: None,
254254+ log_type: "WORKOUT".to_string(),
255255+ }]
256256+}
257257+258258+#[test]
259259+fn test_note_cell_type_is_excluded() {
260260+ let logs = make_log_with_cells(vec![
261261+ ("NOTE".to_string(), Some("Good session".to_string())),
262262+ ("REPS".to_string(), Some("10".to_string())),
263263+ ]);
264264+ let transformer = DataTransformer::new();
265265+ let workouts = transformer.get_measurements_from_logs(&Some(logs)).unwrap();
266266+ assert!(
267267+ workouts[0].exercises.is_empty(),
268268+ "NOTE cell should cause the group to be filtered out"
269269+ );
270270+}
271271+272272+#[test]
273273+fn test_dumbbell_weight_cell_type() {
274274+ let logs = make_log_with_cells(vec![
275275+ ("DUMBBELL_WEIGHT".to_string(), Some("20".to_string())),
276276+ ("REPS".to_string(), Some("12".to_string())),
277277+ ]);
278278+ let transformer = DataTransformer::new();
279279+ let workouts = transformer.get_measurements_from_logs(&Some(logs)).unwrap();
280280+ assert_eq!(workouts[0].exercises[0].sets[0].weight, Some(20.0));
281281+}
282282+283283+#[test]
284284+fn test_other_weight_cell_type() {
285285+ let logs = make_log_with_cells(vec![
286286+ ("OTHER_WEIGHT".to_string(), Some("15.5".to_string())),
287287+ ("REPS".to_string(), Some("8".to_string())),
288288+ ]);
289289+ let transformer = DataTransformer::new();
290290+ let workouts = transformer.get_measurements_from_logs(&Some(logs)).unwrap();
291291+ assert_eq!(workouts[0].exercises[0].sets[0].weight, Some(15.5));
292292+}
293293+294294+#[test]
295295+fn test_weighted_bodyweight_cell_type() {
296296+ let logs = make_log_with_cells(vec![
297297+ ("WEIGHTED_BODYWEIGHT".to_string(), Some("10".to_string())),
298298+ ("REPS".to_string(), Some("15".to_string())),
299299+ ]);
300300+ let transformer = DataTransformer::new();
301301+ let workouts = transformer.get_measurements_from_logs(&Some(logs)).unwrap();
302302+ assert_eq!(workouts[0].exercises[0].sets[0].weight, Some(10.0));
303303+}
304304+305305+#[test]
306306+fn test_no_weight_cell_gives_none() {
307307+ // Only REPS, no weight cell at all
308308+ let logs = make_log_with_cells(vec![("REPS".to_string(), Some("10".to_string()))]);
309309+ let transformer = DataTransformer::new();
310310+ let workouts = transformer.get_measurements_from_logs(&Some(logs)).unwrap();
311311+ assert_eq!(workouts[0].exercises[0].sets[0].weight, None);
312312+}
313313+314314+#[test]
315315+fn test_rpe_cell_type() {
316316+ let logs = make_log_with_cells(vec![
317317+ ("BARBELL_WEIGHT".to_string(), Some("100".to_string())),
318318+ ("REPS".to_string(), Some("5".to_string())),
319319+ ("RPE".to_string(), Some("9".to_string())),
320320+ ]);
321321+ let transformer = DataTransformer::new();
322322+ let workouts = transformer.get_measurements_from_logs(&Some(logs)).unwrap();
323323+ assert_eq!(workouts[0].exercises[0].sets[0].rpe, Some(9.0));
324324+}
325325+326326+#[test]
327327+fn test_missing_reps_value_defaults_to_zero() {
328328+ let logs = make_log_with_cells(vec![
329329+ ("BARBELL_WEIGHT".to_string(), Some("60".to_string())),
330330+ ("REPS".to_string(), None), // value is None
331331+ ]);
332332+ let transformer = DataTransformer::new();
333333+ let workouts = transformer.get_measurements_from_logs(&Some(logs)).unwrap();
334334+ assert_eq!(workouts[0].exercises[0].sets[0].reps, 0);
335335+}
336336+337337+#[test]
338338+fn test_empty_logs_vec_returns_empty_workouts() {
339339+ let transformer = DataTransformer::new();
340340+ let workouts = transformer
341341+ .get_measurements_from_logs(&Some(vec![]))
342342+ .unwrap();
343343+ assert!(workouts.is_empty());
344344+}
345345+346346+#[test]
347347+fn test_logs_option_none_returns_empty_workouts() {
348348+ let transformer = DataTransformer::new();
349349+ let workouts = transformer.get_measurements_from_logs(&None).unwrap();
350350+ assert!(workouts.is_empty());
351351+}
352352+353353+#[test]
354354+fn test_data_transformer_default_equals_new() {
355355+ // Exercises the Default impl (derived via `impl Default for DataTransformer`)
356356+ let by_default: DataTransformer = Default::default();
357357+ let workouts = by_default.get_measurements_from_logs(&None).unwrap();
358358+ assert!(workouts.is_empty());
359359+}
···11+// UserResponse and related workout structs are plain #[derive(Deserialize)] types with no logic.
22+// Their deserialization is covered by data_transformer_tests.rs which parses the same fixture.
33+// This file is intentionally empty.
44+//