1
pub mod user {
2

            
3
    use crate::error::ServerError;
4
    use crate::user::User;
5
    use finance::tag::Tag;
6
    use sqlx::types::Uuid;
7

            
8
    /// Which join table to scope `list_tag_names` / `list_tag_values_for` to.
9
    #[derive(Debug, Clone, Copy)]
10
    pub enum TagScope {
11
        Transaction,
12
        Account,
13
        Split,
14
    }
15

            
16
    impl User {
17
80
        pub async fn create_tag(
18
80
            &self,
19
80
            name: String,
20
80
            value: String,
21
80
            description: Option<String>,
22
80
        ) -> Result<Uuid, ServerError> {
23
41
            if name.trim().is_empty() || value.trim().is_empty() {
24
                return Err(ServerError::Creation);
25
41
            }
26
41
            let mut conn = self.get_connection().await.map_err(|err| {
27
3
                log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
28
3
                ServerError::DB(err)
29
3
            })?;
30

            
31
38
            Tag {
32
38
                id: Uuid::new_v4(),
33
38
                tag_name: name,
34
38
                tag_value: value,
35
38
                description,
36
38
            }
37
38
            .commit(&mut *conn)
38
38
            .await
39
38
            .map_err(|err| {
40
                log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
41
                ServerError::Finance(err)
42
            })
43
41
        }
44

            
45
16
        pub async fn list_tags(&self) -> Result<Vec<Tag>, ServerError> {
46
3
            let mut conn = self.get_connection().await?;
47

            
48
2
            let tags = sqlx::query_file_as!(Tag, "sql/select/tags/all.sql")
49
2
                .fetch_all(&mut *conn)
50
2
                .await
51
2
                .map_err(|err| {
52
                    log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
53
                    ServerError::DB(crate::db::DBError::Sqlx(err))
54
                })?;
55

            
56
2
            Ok(tags)
57
3
        }
58

            
59
9
        pub async fn get_tag(&self, id: Uuid) -> Result<Tag, ServerError> {
60
9
            let mut conn = self.get_connection().await?;
61

            
62
9
            let tag = sqlx::query_file_as!(Tag, "sql/select/tags/by_id.sql", &id)
63
9
                .fetch_one(&mut *conn)
64
9
                .await
65
9
                .map_err(|err| {
66
2
                    log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
67
2
                    ServerError::DB(crate::db::DBError::Sqlx(err))
68
2
                })?;
69

            
70
7
            Ok(tag)
71
9
        }
72

            
73
30
        pub async fn update_tag(
74
30
            &self,
75
30
            id: Uuid,
76
30
            name: String,
77
30
            value: String,
78
30
            description: Option<String>,
79
30
        ) -> Result<(), ServerError> {
80
4
            let mut conn = self.get_connection().await?;
81

            
82
2
            sqlx::query_file!(
83
                "sql/update/tags/update.sql",
84
                &id,
85
                &name,
86
                &value,
87
2
                description.as_deref()
88
            )
89
2
            .execute(&mut *conn)
90
2
            .await
91
2
            .map_err(|err| {
92
                log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
93
                ServerError::DB(crate::db::DBError::Sqlx(err))
94
            })?;
95

            
96
2
            Ok(())
97
4
        }
98

            
99
        pub async fn get_transaction_tags(&self, tx_id: Uuid) -> Result<Vec<Tag>, ServerError> {
100
            let mut conn = self.get_connection().await?;
101

            
102
            let tags = sqlx::query_file_as!(Tag, "sql/select/tags/by_transaction.sql", &tx_id)
103
                .fetch_all(&mut *conn)
104
                .await
105
                .map_err(|err| {
106
                    log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
107
                    ServerError::DB(crate::db::DBError::Sqlx(err))
108
                })?;
109

            
110
            Ok(tags)
111
        }
112

            
113
21
        pub async fn create_transaction_tag(
114
21
            &self,
115
21
            tx_id: Uuid,
116
21
            name: String,
117
21
            value: String,
118
21
            description: Option<String>,
119
21
        ) -> Result<Uuid, ServerError> {
120
8
            let tag_id = self.create_tag(name, value, description).await?;
121
7
            let mut conn = self.get_connection().await?;
122
7
            sqlx::query_file!(
123
                "sql/insert/transaction_tags/transaction_tag.sql",
124
                &tx_id,
125
                &tag_id
126
            )
127
7
            .execute(&mut *conn)
128
7
            .await
129
7
            .map_err(|err| {
130
                log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
131
                ServerError::DB(crate::db::DBError::Sqlx(err))
132
            })?;
133
7
            Ok(tag_id)
134
8
        }
135

            
136
        /// Cascade-delete a tag: detach from every join table, then drop the
137
        /// row from `tags`. Used by user-initiated tag deletion in the UI;
138
        /// see `detach_*_tag` + `cleanup_orphan_tag` for the
139
        /// "remove from one entity, drop only if no longer referenced"
140
        /// pattern.
141
16
        pub async fn delete_tag(&self, id: Uuid) -> Result<(), ServerError> {
142
            use sqlx::Connection;
143
3
            let mut conn = self.get_connection().await?;
144
2
            let mut tx = conn.begin().await.map_err(|err| {
145
                log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
146
                ServerError::DB(crate::db::DBError::Sqlx(err))
147
            })?;
148
20
            for sql in [
149
2
                "DELETE FROM transaction_tags WHERE tag_id = $1",
150
2
                "DELETE FROM split_tags WHERE tag_id = $1",
151
2
                "DELETE FROM account_tags WHERE tag_id = $1",
152
2
                "DELETE FROM budget_tags WHERE tag_id = $1",
153
2
                "DELETE FROM commodity_tags WHERE tag_id = $1",
154
2
                "DELETE FROM price_tags WHERE tag_id = $1",
155
2
                "DELETE FROM book_tags WHERE tag_id = $1",
156
2
                "DELETE FROM script_tags WHERE tag_id = $1",
157
2
                "DELETE FROM tag_tags WHERE tagged_tag_id = $1 OR tagging_tag_id = $1",
158
2
                "DELETE FROM tags WHERE id = $1",
159
2
            ] {
160
20
                sqlx::query(sql)
161
20
                    .bind(id)
162
20
                    .execute(&mut *tx)
163
20
                    .await
164
20
                    .map_err(|err| {
165
                        log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
166
                        ServerError::DB(crate::db::DBError::Sqlx(err))
167
                    })?;
168
            }
169
2
            tx.commit().await.map_err(|err| {
170
                log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
171
                ServerError::DB(crate::db::DBError::Sqlx(err))
172
            })
173
3
        }
174

            
175
        pub async fn detach_transaction_tag(
176
            &self,
177
            tx_id: Uuid,
178
            tag_id: Uuid,
179
        ) -> Result<(), ServerError> {
180
            let mut conn = self.get_connection().await?;
181
            sqlx::query_file!("sql/delete/transaction_tags/by_pair.sql", &tx_id, &tag_id)
182
                .execute(&mut *conn)
183
                .await
184
                .map_err(|err| {
185
                    log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
186
                    ServerError::DB(crate::db::DBError::Sqlx(err))
187
                })?;
188
            Ok(())
189
        }
190

            
191
        pub async fn detach_split_tag(
192
            &self,
193
            split_id: Uuid,
194
            tag_id: Uuid,
195
        ) -> Result<(), ServerError> {
196
            let mut conn = self.get_connection().await?;
197
            sqlx::query_file!("sql/delete/split_tags/by_pair.sql", &split_id, &tag_id)
198
                .execute(&mut *conn)
199
                .await
200
                .map_err(|err| {
201
                    log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
202
                    ServerError::DB(crate::db::DBError::Sqlx(err))
203
                })?;
204
            Ok(())
205
        }
206

            
207
        pub async fn detach_account_tag(
208
            &self,
209
            account_id: Uuid,
210
            tag_id: Uuid,
211
        ) -> Result<(), ServerError> {
212
            let mut conn = self.get_connection().await?;
213
            sqlx::query_file!("sql/delete/account_tags/by_pair.sql", &account_id, &tag_id)
214
                .execute(&mut *conn)
215
                .await
216
                .map_err(|err| {
217
                    log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
218
                    ServerError::DB(crate::db::DBError::Sqlx(err))
219
                })?;
220
            Ok(())
221
        }
222

            
223
        pub async fn detach_commodity_tag(
224
            &self,
225
            commodity_id: Uuid,
226
            tag_id: Uuid,
227
        ) -> Result<(), ServerError> {
228
            let mut conn = self.get_connection().await?;
229
            sqlx::query_file!(
230
                "sql/delete/commodity_tags/by_pair.sql",
231
                &commodity_id,
232
                &tag_id
233
            )
234
            .execute(&mut *conn)
235
            .await
236
            .map_err(|err| {
237
                log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
238
                ServerError::DB(crate::db::DBError::Sqlx(err))
239
            })?;
240
            Ok(())
241
        }
242

            
243
        /// If `tag_id` is no longer referenced by any join table, delete the
244
        /// canonical row. Idempotent.
245
        pub async fn cleanup_orphan_tag(&self, tag_id: Uuid) -> Result<(), ServerError> {
246
            let mut conn = self.get_connection().await?;
247
            let row = sqlx::query_file!("sql/check/tags/is_orphaned.sql", &tag_id)
248
                .fetch_one(&mut *conn)
249
                .await
250
                .map_err(|err| {
251
                    log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
252
                    ServerError::DB(crate::db::DBError::Sqlx(err))
253
                })?;
254
            if row.is_orphaned.unwrap_or(false) {
255
                sqlx::query_file!("sql/delete/tags/by_id.sql", &tag_id)
256
                    .execute(&mut *conn)
257
                    .await
258
                    .map_err(|err| {
259
                        log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
260
                        ServerError::DB(crate::db::DBError::Sqlx(err))
261
                    })?;
262
            }
263
            Ok(())
264
        }
265

            
266
6
        pub async fn list_tag_names(&self, scope: TagScope) -> Result<Vec<String>, ServerError> {
267
6
            let mut conn = self.get_connection().await?;
268
6
            let result = match scope {
269
2
                TagScope::Transaction => sqlx::query_file!("sql/select/tags/transaction/names.sql")
270
2
                    .fetch_all(&mut *conn)
271
2
                    .await
272
2
                    .map(|rows| rows.into_iter().map(|r| r.tag_name).collect::<Vec<_>>()),
273
2
                TagScope::Account => sqlx::query_file!("sql/select/tags/account/names.sql")
274
2
                    .fetch_all(&mut *conn)
275
2
                    .await
276
2
                    .map(|rows| rows.into_iter().map(|r| r.tag_name).collect::<Vec<_>>()),
277
2
                TagScope::Split => sqlx::query_file!("sql/select/tags/split/names.sql")
278
2
                    .fetch_all(&mut *conn)
279
2
                    .await
280
2
                    .map(|rows| rows.into_iter().map(|r| r.tag_name).collect::<Vec<_>>()),
281
            };
282
6
            result.map_err(|err| {
283
                log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
284
                ServerError::DB(crate::db::DBError::Sqlx(err))
285
            })
286
6
        }
287

            
288
11
        pub async fn list_tag_values_for(
289
11
            &self,
290
11
            scope: TagScope,
291
11
            tag_name: &str,
292
11
        ) -> Result<Vec<String>, ServerError> {
293
11
            let mut conn = self.get_connection().await?;
294
11
            let result = match scope {
295
                TagScope::Transaction => {
296
4
                    sqlx::query_file!("sql/select/tags/transaction/values_by_name.sql", tag_name)
297
4
                        .fetch_all(&mut *conn)
298
4
                        .await
299
4
                        .map(|rows| rows.into_iter().map(|r| r.tag_value).collect::<Vec<_>>())
300
                }
301
                TagScope::Account => {
302
3
                    sqlx::query_file!("sql/select/tags/account/values_by_name.sql", tag_name)
303
3
                        .fetch_all(&mut *conn)
304
3
                        .await
305
3
                        .map(|rows| rows.into_iter().map(|r| r.tag_value).collect::<Vec<_>>())
306
                }
307
                TagScope::Split => {
308
4
                    sqlx::query_file!("sql/select/tags/split/values_by_name.sql", tag_name)
309
4
                        .fetch_all(&mut *conn)
310
4
                        .await
311
4
                        .map(|rows| rows.into_iter().map(|r| r.tag_value).collect::<Vec<_>>())
312
                }
313
            };
314
11
            result.map_err(|err| {
315
                log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
316
                ServerError::DB(crate::db::DBError::Sqlx(err))
317
            })
318
11
        }
319

            
320
2
        pub async fn list_transaction_tag_names(&self) -> Result<Vec<String>, ServerError> {
321
2
            self.list_tag_names(TagScope::Transaction).await
322
2
        }
323

            
324
4
        pub async fn list_transaction_tag_values(
325
4
            &self,
326
4
            tag_name: &str,
327
4
        ) -> Result<Vec<String>, ServerError> {
328
4
            self.list_tag_values_for(TagScope::Transaction, tag_name)
329
4
                .await
330
4
        }
331

            
332
22
        pub async fn create_split_tag(
333
22
            &self,
334
22
            split_id: Uuid,
335
22
            name: String,
336
22
            value: String,
337
22
            description: Option<String>,
338
22
        ) -> Result<Uuid, ServerError> {
339
22
            let tag_id = self.create_tag(name, value, description).await?;
340
22
            let mut conn = self.get_connection().await?;
341
22
            sqlx::query_file!("sql/insert/split_tags/split_tag.sql", &split_id, &tag_id)
342
22
                .execute(&mut *conn)
343
22
                .await
344
22
                .map_err(|err| {
345
                    log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
346
                    ServerError::DB(crate::db::DBError::Sqlx(err))
347
                })?;
348
22
            Ok(tag_id)
349
22
        }
350

            
351
1
        pub async fn create_account_tag(
352
1
            &self,
353
1
            account_id: Uuid,
354
1
            name: String,
355
1
            value: String,
356
1
            description: Option<String>,
357
1
        ) -> Result<Uuid, ServerError> {
358
1
            let tag_id = self.create_tag(name, value, description).await?;
359
1
            let mut conn = self.get_connection().await?;
360
1
            sqlx::query_file!(
361
                "sql/insert/account_tags/account_tag.sql",
362
                &account_id,
363
                &tag_id
364
            )
365
1
            .execute(&mut *conn)
366
1
            .await
367
1
            .map_err(|err| {
368
                log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
369
                ServerError::DB(crate::db::DBError::Sqlx(err))
370
            })?;
371
1
            Ok(tag_id)
372
1
        }
373

            
374
2
        pub async fn list_account_tag_names(&self) -> Result<Vec<String>, ServerError> {
375
2
            self.list_tag_names(TagScope::Account).await
376
2
        }
377

            
378
3
        pub async fn list_account_tag_values(
379
3
            &self,
380
3
            tag_name: &str,
381
3
        ) -> Result<Vec<String>, ServerError> {
382
3
            self.list_tag_values_for(TagScope::Account, tag_name).await
383
3
        }
384

            
385
2
        pub async fn list_split_tag_names(&self) -> Result<Vec<String>, ServerError> {
386
2
            self.list_tag_names(TagScope::Split).await
387
2
        }
388

            
389
4
        pub async fn list_split_tag_values(
390
4
            &self,
391
4
            tag_name: &str,
392
4
        ) -> Result<Vec<String>, ServerError> {
393
4
            self.list_tag_values_for(TagScope::Split, tag_name).await
394
4
        }
395
    }
396

            
397
    #[cfg(test)]
398
    mod tag_tests {
399
        use super::*;
400
        use crate::db::DB_POOL;
401
        #[cfg(feature = "testlog")]
402
        use env_logger;
403
        use finance::{split::Split, transaction::Transaction};
404
        #[cfg(feature = "testlog")]
405
        use log;
406
        use sqlx::PgPool;
407
        use sqlx::types::chrono;
408

            
409
        use supp_macro::local_db_sqlx_test;
410
        use tokio::sync::OnceCell;
411

            
412
        /// Context for keeping environment intact
413
        static CONTEXT: OnceCell<()> = OnceCell::const_new();
414
        static USER: OnceCell<User> = OnceCell::const_new();
415

            
416
26
        async fn setup() {
417
26
            CONTEXT
418
26
                .get_or_init(|| async {
419
                    #[cfg(feature = "testlog")]
420
1
                    let _ = env_logger::builder()
421
1
                        .is_test(true)
422
1
                        .filter_level(log::LevelFilter::Trace)
423
1
                        .try_init();
424
2
                })
425
26
                .await;
426
26
            USER.get_or_init(|| async { User { id: Uuid::new_v4() } })
427
26
                .await;
428
26
        }
429

            
430
        #[local_db_sqlx_test]
431
        async fn test_tag_creation(pool: PgPool) -> Result<(), anyhow::Error> {
432
            let user = USER.get().unwrap();
433
            user.commit()
434
                .await
435
                .expect("Failed to commit user to database");
436

            
437
            let id = user
438
                .create_tag("testtag".to_string(), "testval".to_string(), None)
439
                .await?;
440

            
441
            let mut conn = user.get_connection().await?;
442
            let res = sqlx::query_file!("testdata/query_tag_by_id.sql", &id)
443
                .fetch_one(&mut *conn)
444
                .await?;
445

            
446
            assert_eq!(res.tag_name, "testtag".to_string());
447
            assert_eq!(res.tag_value, "testval".to_string());
448
            assert_eq!(res.description, None);
449
        }
450

            
451
        #[local_db_sqlx_test]
452
        async fn test_tag_creation_with_description(pool: PgPool) -> Result<(), anyhow::Error> {
453
            let user = USER.get().unwrap();
454
            user.commit()
455
                .await
456
                .expect("Failed to commit user to database");
457

            
458
            let id = user
459
                .create_tag(
460
                    "categorytag".to_string(),
461
                    "category1".to_string(),
462
                    Some("Test description".to_string()),
463
                )
464
                .await?;
465

            
466
            let tag = user.get_tag(id).await?;
467

            
468
            assert_eq!(tag.tag_name, "categorytag");
469
            assert_eq!(tag.tag_value, "category1");
470
            assert_eq!(tag.description, Some("Test description".to_string()));
471
        }
472

            
473
        #[local_db_sqlx_test]
474
        async fn test_list_tags(pool: PgPool) -> Result<(), anyhow::Error> {
475
            let user = USER.get().unwrap();
476
            user.commit()
477
                .await
478
                .expect("Failed to commit user to database");
479

            
480
            let id1 = user
481
                .create_tag("tag1".to_string(), "value1".to_string(), None)
482
                .await?;
483

            
484
            let id2 = user
485
                .create_tag(
486
                    "tag2".to_string(),
487
                    "value2".to_string(),
488
                    Some("desc".to_string()),
489
                )
490
                .await?;
491

            
492
            let tags = user.list_tags().await?;
493

            
494
            assert!(tags.len() >= 2);
495
1
            assert!(tags.iter().any(|t| t.id == id1));
496
2
            assert!(tags.iter().any(|t| t.id == id2));
497

            
498
1
            let tag1 = tags.iter().find(|t| t.id == id1).unwrap();
499
            assert_eq!(tag1.tag_name, "tag1");
500
            assert_eq!(tag1.tag_value, "value1");
501
            assert_eq!(tag1.description, None);
502

            
503
2
            let tag2 = tags.iter().find(|t| t.id == id2).unwrap();
504
            assert_eq!(tag2.tag_name, "tag2");
505
            assert_eq!(tag2.tag_value, "value2");
506
            assert_eq!(tag2.description, Some("desc".to_string()));
507
        }
508

            
509
        #[local_db_sqlx_test]
510
        async fn test_get_tag(pool: PgPool) -> Result<(), anyhow::Error> {
511
            let user = USER.get().unwrap();
512
            user.commit()
513
                .await
514
                .expect("Failed to commit user to database");
515

            
516
            let id = user
517
                .create_tag(
518
                    "gettag".to_string(),
519
                    "getvalue".to_string(),
520
                    Some("Get description".to_string()),
521
                )
522
                .await?;
523

            
524
            let tag = user.get_tag(id).await?;
525

            
526
            assert_eq!(tag.id, id);
527
            assert_eq!(tag.tag_name, "gettag");
528
            assert_eq!(tag.tag_value, "getvalue");
529
            assert_eq!(tag.description, Some("Get description".to_string()));
530
        }
531

            
532
        #[local_db_sqlx_test]
533
        async fn test_get_nonexistent_tag(pool: PgPool) -> Result<(), anyhow::Error> {
534
            let user = USER.get().unwrap();
535
            user.commit()
536
                .await
537
                .expect("Failed to commit user to database");
538

            
539
            let nonexistent_id = Uuid::new_v4();
540
            let result = user.get_tag(nonexistent_id).await;
541

            
542
            assert!(result.is_err());
543
        }
544

            
545
        #[local_db_sqlx_test]
546
        async fn test_update_tag(pool: PgPool) -> Result<(), anyhow::Error> {
547
            let user = USER.get().unwrap();
548
            user.commit()
549
                .await
550
                .expect("Failed to commit user to database");
551

            
552
            let id = user
553
                .create_tag("oldname".to_string(), "oldvalue".to_string(), None)
554
                .await?;
555

            
556
            user.update_tag(
557
                id,
558
                "newname".to_string(),
559
                "newvalue".to_string(),
560
                Some("Updated description".to_string()),
561
            )
562
            .await?;
563

            
564
            let tag = user.get_tag(id).await?;
565

            
566
            assert_eq!(tag.id, id);
567
            assert_eq!(tag.tag_name, "newname");
568
            assert_eq!(tag.tag_value, "newvalue");
569
            assert_eq!(tag.description, Some("Updated description".to_string()));
570
        }
571

            
572
        #[local_db_sqlx_test]
573
        async fn test_update_tag_remove_description(pool: PgPool) {
574
            let user = USER.get().unwrap();
575
            user.commit()
576
                .await
577
                .expect("Failed to commit user to database");
578

            
579
            let id = user
580
                .create_tag(
581
                    "tagname".to_string(),
582
                    "tagvalue".to_string(),
583
                    Some("Initial description".to_string()),
584
                )
585
                .await?;
586

            
587
            user.update_tag(id, "tagname".to_string(), "tagvalue".to_string(), None)
588
                .await?;
589

            
590
            let tag = user.get_tag(id).await?;
591

            
592
            assert_eq!(tag.description, None);
593
        }
594

            
595
        #[local_db_sqlx_test]
596
        async fn test_delete_tag(pool: PgPool) -> Result<(), anyhow::Error> {
597
            let user = USER.get().unwrap();
598
            user.commit()
599
                .await
600
                .expect("Failed to commit user to database");
601

            
602
            let id = user
603
                .create_tag("deletetag".to_string(), "deletevalue".to_string(), None)
604
                .await?;
605

            
606
            let tag = user.get_tag(id).await;
607
            assert!(tag.is_ok());
608

            
609
            user.delete_tag(id).await?;
610

            
611
            let result = user.get_tag(id).await;
612
            assert!(result.is_err());
613
        }
614

            
615
        #[local_db_sqlx_test]
616
        async fn test_delete_nonexistent_tag(pool: PgPool) -> Result<(), anyhow::Error> {
617
            let user = USER.get().unwrap();
618
            user.commit()
619
                .await
620
                .expect("Failed to commit user to database");
621

            
622
            let nonexistent_id = Uuid::new_v4();
623
            let result = user.delete_tag(nonexistent_id).await;
624

            
625
            assert!(result.is_ok());
626
        }
627

            
628
        #[local_db_sqlx_test]
629
        async fn test_list_tags_empty(pool: PgPool) -> Result<(), anyhow::Error> {
630
            let user = USER.get().unwrap();
631
            user.commit()
632
                .await
633
                .expect("Failed to commit user to database");
634

            
635
            let tags = user.list_tags().await?;
636

            
637
            assert!(
638
                tags.is_empty()
639
                    || tags.iter().all(|t| t.tag_name == "name"
640
                        || t.tag_name == "note"
641
                        || t.tag_name == "symbol")
642
            );
643
        }
644

            
645
        #[local_db_sqlx_test]
646
        async fn test_list_transaction_tag_names_empty(pool: PgPool) -> Result<(), anyhow::Error> {
647
            let user = USER.get().unwrap();
648
            user.commit()
649
                .await
650
                .expect("Failed to commit user to database");
651

            
652
            let names = user.list_transaction_tag_names().await?;
653

            
654
            assert!(names.is_empty());
655
        }
656

            
657
        #[local_db_sqlx_test]
658
        async fn test_list_transaction_tag_names_with_data(
659
            pool: PgPool,
660
        ) -> Result<(), anyhow::Error> {
661
            let user = USER.get().unwrap();
662
            user.commit()
663
                .await
664
                .expect("Failed to commit user to database");
665

            
666
            let commodity_id = user
667
                .create_commodity("USD".to_string(), "US Dollar".to_string())
668
                .await?
669
                .id;
670
            let acc1 = user.create_account("test_acc1", None).await?.id;
671
            let acc2 = user.create_account("test_acc2", None).await?.id;
672

            
673
            let tx = Transaction {
674
                id: sqlx::types::Uuid::new_v4(),
675
                post_date: chrono::Utc::now(),
676
                enter_date: chrono::Utc::now(),
677
            };
678

            
679
            let mut conn = user.get_connection().await?;
680
            let mut ticket = tx.enter(&mut *conn).await?;
681
            let split1 = Split {
682
                id: sqlx::types::Uuid::new_v4(),
683
                account_id: acc1,
684
                tx_id: tx.id,
685
                value_num: 100,
686
                value_denom: 1,
687
                commodity_id,
688
                reconcile_state: None,
689
                reconcile_date: None,
690
                lot_id: None,
691
            };
692
            let split2 = Split {
693
                id: sqlx::types::Uuid::new_v4(),
694
                account_id: acc2,
695
                tx_id: tx.id,
696
                value_num: -100,
697
                value_denom: 1,
698
                commodity_id,
699
                reconcile_state: None,
700
                reconcile_date: None,
701
                lot_id: None,
702
            };
703
            ticket.add_splits(&[&split1, &split2]).await?;
704
            ticket.commit().await?;
705

            
706
            user.create_transaction_tag(tx.id, "category".to_string(), "food".to_string(), None)
707
                .await?;
708
            user.create_transaction_tag(tx.id, "priority".to_string(), "high".to_string(), None)
709
                .await?;
710

            
711
            let names = user.list_transaction_tag_names().await?;
712

            
713
            assert_eq!(names.len(), 2);
714
            assert!(names.contains(&"category".to_string()));
715
            assert!(names.contains(&"priority".to_string()));
716
        }
717

            
718
        #[local_db_sqlx_test]
719
        async fn test_list_transaction_tag_values_empty(pool: PgPool) -> Result<(), anyhow::Error> {
720
            let user = USER.get().unwrap();
721
            user.commit()
722
                .await
723
                .expect("Failed to commit user to database");
724

            
725
            let values = user.list_transaction_tag_values("category").await?;
726

            
727
            assert!(values.is_empty());
728
        }
729

            
730
        #[local_db_sqlx_test]
731
        async fn test_list_transaction_tag_values_with_data(
732
            pool: PgPool,
733
        ) -> Result<(), anyhow::Error> {
734
            let user = USER.get().unwrap();
735
            user.commit()
736
                .await
737
                .expect("Failed to commit user to database");
738

            
739
            let commodity_id = user
740
                .create_commodity("USD".to_string(), "US Dollar".to_string())
741
                .await?
742
                .id;
743
            let acc1 = user.create_account("test_acc1", None).await?.id;
744
            let acc2 = user.create_account("test_acc2", None).await?.id;
745

            
746
            let tx1 = Transaction {
747
                id: sqlx::types::Uuid::new_v4(),
748
                post_date: chrono::Utc::now(),
749
                enter_date: chrono::Utc::now(),
750
            };
751
            let tx2 = Transaction {
752
                id: sqlx::types::Uuid::new_v4(),
753
                post_date: chrono::Utc::now(),
754
                enter_date: chrono::Utc::now(),
755
            };
756
            {
757
                let mut conn = user.get_connection().await?;
758
                let mut ticket1 = tx1.enter(&mut *conn).await?;
759
                let split1a = Split {
760
                    id: sqlx::types::Uuid::new_v4(),
761
                    account_id: acc1,
762
                    tx_id: tx1.id,
763
                    value_num: 100,
764
                    value_denom: 1,
765
                    commodity_id,
766
                    reconcile_state: None,
767
                    reconcile_date: None,
768
                    lot_id: None,
769
                };
770
                let split1b = Split {
771
                    id: sqlx::types::Uuid::new_v4(),
772
                    account_id: acc2,
773
                    tx_id: tx1.id,
774
                    value_num: -100,
775
                    value_denom: 1,
776
                    commodity_id,
777
                    reconcile_state: None,
778
                    reconcile_date: None,
779
                    lot_id: None,
780
                };
781
                ticket1.add_splits(&[&split1a, &split1b]).await?;
782
                ticket1.commit().await?;
783
            }
784
            {
785
                let mut conn = user.get_connection().await?;
786
                let mut ticket2 = tx2.enter(&mut *conn).await?;
787
                let split2a = Split {
788
                    id: sqlx::types::Uuid::new_v4(),
789
                    account_id: acc1,
790
                    tx_id: tx2.id,
791
                    value_num: 200,
792
                    value_denom: 1,
793
                    commodity_id,
794
                    reconcile_state: None,
795
                    reconcile_date: None,
796
                    lot_id: None,
797
                };
798
                let split2b = Split {
799
                    id: sqlx::types::Uuid::new_v4(),
800
                    account_id: acc2,
801
                    tx_id: tx2.id,
802
                    value_num: -200,
803
                    value_denom: 1,
804
                    commodity_id,
805
                    reconcile_state: None,
806
                    reconcile_date: None,
807
                    lot_id: None,
808
                };
809
                ticket2.add_splits(&[&split2a, &split2b]).await?;
810
                ticket2.commit().await?;
811
            }
812
            user.create_transaction_tag(tx1.id, "category".to_string(), "food".to_string(), None)
813
                .await?;
814
            user.create_transaction_tag(
815
                tx2.id,
816
                "category".to_string(),
817
                "transport".to_string(),
818
                None,
819
            )
820
            .await?;
821
            user.create_transaction_tag(tx1.id, "priority".to_string(), "high".to_string(), None)
822
                .await?;
823

            
824
            let category_values = user.list_transaction_tag_values("category").await?;
825

            
826
            assert_eq!(category_values.len(), 2);
827
            assert!(category_values.contains(&"food".to_string()));
828
            assert!(category_values.contains(&"transport".to_string()));
829

            
830
            let priority_values = user.list_transaction_tag_values("priority").await?;
831

            
832
            assert_eq!(priority_values.len(), 1);
833
            assert!(priority_values.contains(&"high".to_string()));
834
        }
835

            
836
        #[local_db_sqlx_test]
837
        async fn test_list_transaction_tag_values_nonexistent_name(
838
            pool: PgPool,
839
        ) -> Result<(), anyhow::Error> {
840
            let user = USER.get().unwrap();
841
            user.commit()
842
                .await
843
                .expect("Failed to commit user to database");
844

            
845
            let commodity_id = user
846
                .create_commodity("USD".to_string(), "US Dollar".to_string())
847
                .await?
848
                .id;
849
            let acc1 = user.create_account("test_acc1", None).await?.id;
850
            let acc2 = user.create_account("test_acc2", None).await?.id;
851

            
852
            let tx = Transaction {
853
                id: sqlx::types::Uuid::new_v4(),
854
                post_date: chrono::Utc::now(),
855
                enter_date: chrono::Utc::now(),
856
            };
857

            
858
            let mut conn = user.get_connection().await?;
859
            let mut ticket = tx.enter(&mut *conn).await?;
860
            let split1 = Split {
861
                id: sqlx::types::Uuid::new_v4(),
862
                account_id: acc1,
863
                tx_id: tx.id,
864
                value_num: 100,
865
                value_denom: 1,
866
                commodity_id,
867
                reconcile_state: None,
868
                reconcile_date: None,
869
                lot_id: None,
870
            };
871
            let split2 = Split {
872
                id: sqlx::types::Uuid::new_v4(),
873
                account_id: acc2,
874
                tx_id: tx.id,
875
                value_num: -100,
876
                value_denom: 1,
877
                commodity_id,
878
                reconcile_state: None,
879
                reconcile_date: None,
880
                lot_id: None,
881
            };
882
            ticket.add_splits(&[&split1, &split2]).await?;
883
            ticket.commit().await?;
884

            
885
            user.create_transaction_tag(tx.id, "category".to_string(), "food".to_string(), None)
886
                .await?;
887

            
888
            let values = user.list_transaction_tag_values("nonexistent").await?;
889

            
890
            assert!(values.is_empty());
891
        }
892

            
893
        #[local_db_sqlx_test]
894
        async fn test_create_split_tag(pool: PgPool) -> Result<(), anyhow::Error> {
895
            let user = USER.get().unwrap();
896
            user.commit()
897
                .await
898
                .expect("Failed to commit user to database");
899

            
900
            let commodity_id = user
901
                .create_commodity("USD".to_string(), "US Dollar".to_string())
902
                .await?
903
                .id;
904
            let acc1 = user.create_account("test_acc1", None).await?.id;
905
            let acc2 = user.create_account("test_acc2", None).await?.id;
906

            
907
            let tx = Transaction {
908
                id: sqlx::types::Uuid::new_v4(),
909
                post_date: chrono::Utc::now(),
910
                enter_date: chrono::Utc::now(),
911
            };
912

            
913
            let mut conn = user.get_connection().await?;
914
            let mut ticket = tx.enter(&mut *conn).await?;
915
            let split1 = Split {
916
                id: sqlx::types::Uuid::new_v4(),
917
                account_id: acc1,
918
                tx_id: tx.id,
919
                value_num: 100,
920
                value_denom: 1,
921
                commodity_id,
922
                reconcile_state: None,
923
                reconcile_date: None,
924
                lot_id: None,
925
            };
926
            let split2 = Split {
927
                id: sqlx::types::Uuid::new_v4(),
928
                account_id: acc2,
929
                tx_id: tx.id,
930
                value_num: -100,
931
                value_denom: 1,
932
                commodity_id,
933
                reconcile_state: None,
934
                reconcile_date: None,
935
                lot_id: None,
936
            };
937
            ticket.add_splits(&[&split1, &split2]).await?;
938
            ticket.commit().await?;
939

            
940
            let tag_id = user
941
                .create_split_tag(
942
                    split1.id,
943
                    "project".to_string(),
944
                    "nomisync".to_string(),
945
                    Some("Split tag for project tracking".to_string()),
946
                )
947
                .await?;
948

            
949
            let mut conn = user.get_connection().await?;
950
            let res = sqlx::query!(
951
                "SELECT tag_id FROM split_tags WHERE split_id = $1",
952
                &split1.id
953
            )
954
            .fetch_one(&mut *conn)
955
            .await?;
956

            
957
            assert_eq!(res.tag_id, tag_id);
958

            
959
            let tag = user.get_tag(tag_id).await?;
960
            assert_eq!(tag.tag_name, "project");
961
            assert_eq!(tag.tag_value, "nomisync");
962
            assert_eq!(
963
                tag.description,
964
                Some("Split tag for project tracking".to_string())
965
            );
966
        }
967

            
968
        #[local_db_sqlx_test]
969
        async fn test_list_split_tag_names_empty(pool: PgPool) -> Result<(), anyhow::Error> {
970
            let user = USER.get().unwrap();
971
            user.commit()
972
                .await
973
                .expect("Failed to commit user to database");
974

            
975
            let names = user.list_split_tag_names().await?;
976

            
977
            assert!(names.is_empty());
978
        }
979

            
980
        #[local_db_sqlx_test]
981
        async fn test_list_split_tag_names_with_data(pool: PgPool) -> Result<(), anyhow::Error> {
982
            let user = USER.get().unwrap();
983
            user.commit()
984
                .await
985
                .expect("Failed to commit user to database");
986

            
987
            let commodity_id = user
988
                .create_commodity("USD".to_string(), "US Dollar".to_string())
989
                .await?
990
                .id;
991
            let acc1 = user.create_account("test_acc1", None).await?.id;
992
            let acc2 = user.create_account("test_acc2", None).await?.id;
993

            
994
            let tx = Transaction {
995
                id: sqlx::types::Uuid::new_v4(),
996
                post_date: chrono::Utc::now(),
997
                enter_date: chrono::Utc::now(),
998
            };
999

            
            let mut conn = user.get_connection().await?;
            let mut ticket = tx.enter(&mut *conn).await?;
            let split1 = Split {
                id: sqlx::types::Uuid::new_v4(),
                account_id: acc1,
                tx_id: tx.id,
                value_num: 100,
                value_denom: 1,
                commodity_id,
                reconcile_state: None,
                reconcile_date: None,
                lot_id: None,
            };
            let split2 = Split {
                id: sqlx::types::Uuid::new_v4(),
                account_id: acc2,
                tx_id: tx.id,
                value_num: -100,
                value_denom: 1,
                commodity_id,
                reconcile_state: None,
                reconcile_date: None,
                lot_id: None,
            };
            ticket.add_splits(&[&split1, &split2]).await?;
            ticket.commit().await?;
            user.create_split_tag(
                split1.id,
                "project".to_string(),
                "nomisync".to_string(),
                None,
            )
            .await?;
            user.create_split_tag(
                split2.id,
                "department".to_string(),
                "engineering".to_string(),
                None,
            )
            .await?;
            let names = user.list_split_tag_names().await?;
            assert_eq!(names.len(), 2);
            assert!(names.contains(&"project".to_string()));
            assert!(names.contains(&"department".to_string()));
        }
        #[local_db_sqlx_test]
        async fn test_list_split_tag_values_empty(pool: PgPool) -> Result<(), anyhow::Error> {
            let user = USER.get().unwrap();
            user.commit()
                .await
                .expect("Failed to commit user to database");
            let values = user.list_split_tag_values("project").await?;
            assert!(values.is_empty());
        }
        #[local_db_sqlx_test]
        async fn test_list_split_tag_values_with_data(pool: PgPool) -> Result<(), anyhow::Error> {
            let user = USER.get().unwrap();
            user.commit()
                .await
                .expect("Failed to commit user to database");
            let commodity_id = user
                .create_commodity("USD".to_string(), "US Dollar".to_string())
                .await?
                .id;
            let acc1 = user.create_account("test_acc1", None).await?.id;
            let acc2 = user.create_account("test_acc2", None).await?.id;
            let tx1 = Transaction {
                id: sqlx::types::Uuid::new_v4(),
                post_date: chrono::Utc::now(),
                enter_date: chrono::Utc::now(),
            };
            let tx2 = Transaction {
                id: sqlx::types::Uuid::new_v4(),
                post_date: chrono::Utc::now(),
                enter_date: chrono::Utc::now(),
            };
            let split1_id = sqlx::types::Uuid::new_v4();
            let split2_id = sqlx::types::Uuid::new_v4();
            let split3_id = sqlx::types::Uuid::new_v4();
            let split4_id = sqlx::types::Uuid::new_v4();
            {
                let mut conn = user.get_connection().await?;
                let mut ticket1 = tx1.enter(&mut *conn).await?;
                let split1a = Split {
                    id: split1_id,
                    account_id: acc1,
                    tx_id: tx1.id,
                    value_num: 100,
                    value_denom: 1,
                    commodity_id,
                    reconcile_state: None,
                    reconcile_date: None,
                    lot_id: None,
                };
                let split1b = Split {
                    id: split2_id,
                    account_id: acc2,
                    tx_id: tx1.id,
                    value_num: -100,
                    value_denom: 1,
                    commodity_id,
                    reconcile_state: None,
                    reconcile_date: None,
                    lot_id: None,
                };
                ticket1.add_splits(&[&split1a, &split1b]).await?;
                ticket1.commit().await?;
            }
            {
                let mut conn = user.get_connection().await?;
                let mut ticket2 = tx2.enter(&mut *conn).await?;
                let split2a = Split {
                    id: split3_id,
                    account_id: acc1,
                    tx_id: tx2.id,
                    value_num: 200,
                    value_denom: 1,
                    commodity_id,
                    reconcile_state: None,
                    reconcile_date: None,
                    lot_id: None,
                };
                let split2b = Split {
                    id: split4_id,
                    account_id: acc2,
                    tx_id: tx2.id,
                    value_num: -200,
                    value_denom: 1,
                    commodity_id,
                    reconcile_state: None,
                    reconcile_date: None,
                    lot_id: None,
                };
                ticket2.add_splits(&[&split2a, &split2b]).await?;
                ticket2.commit().await?;
            }
            user.create_split_tag(
                split1_id,
                "project".to_string(),
                "nomisync".to_string(),
                None,
            )
            .await?;
            user.create_split_tag(
                split3_id,
                "project".to_string(),
                "website".to_string(),
                None,
            )
            .await?;
            user.create_split_tag(
                split2_id,
                "department".to_string(),
                "engineering".to_string(),
                None,
            )
            .await?;
            let project_values = user.list_split_tag_values("project").await?;
            assert_eq!(project_values.len(), 2);
            assert!(project_values.contains(&"nomisync".to_string()));
            assert!(project_values.contains(&"website".to_string()));
            let department_values = user.list_split_tag_values("department").await?;
            assert_eq!(department_values.len(), 1);
            assert!(department_values.contains(&"engineering".to_string()));
        }
        #[local_db_sqlx_test]
        async fn test_list_split_tag_values_nonexistent_name(
            pool: PgPool,
        ) -> Result<(), anyhow::Error> {
            let user = USER.get().unwrap();
            user.commit()
                .await
                .expect("Failed to commit user to database");
            let commodity_id = user
                .create_commodity("USD".to_string(), "US Dollar".to_string())
                .await?
                .id;
            let acc1 = user.create_account("test_acc1", None).await?.id;
            let acc2 = user.create_account("test_acc2", None).await?.id;
            let tx = Transaction {
                id: sqlx::types::Uuid::new_v4(),
                post_date: chrono::Utc::now(),
                enter_date: chrono::Utc::now(),
            };
            let mut conn = user.get_connection().await?;
            let mut ticket = tx.enter(&mut *conn).await?;
            let split1 = Split {
                id: sqlx::types::Uuid::new_v4(),
                account_id: acc1,
                tx_id: tx.id,
                value_num: 100,
                value_denom: 1,
                commodity_id,
                reconcile_state: None,
                reconcile_date: None,
                lot_id: None,
            };
            let split2 = Split {
                id: sqlx::types::Uuid::new_v4(),
                account_id: acc2,
                tx_id: tx.id,
                value_num: -100,
                value_denom: 1,
                commodity_id,
                reconcile_state: None,
                reconcile_date: None,
                lot_id: None,
            };
            ticket.add_splits(&[&split1, &split2]).await?;
            ticket.commit().await?;
            user.create_split_tag(
                split1.id,
                "project".to_string(),
                "nomisync".to_string(),
                None,
            )
            .await?;
            let values = user.list_split_tag_values("nonexistent").await?;
            assert!(values.is_empty());
        }
        #[local_db_sqlx_test]
        async fn test_create_account_tag(pool: PgPool) -> Result<(), anyhow::Error> {
            let user = USER.get().unwrap();
            user.commit()
                .await
                .expect("Failed to commit user to database");
            let acc = user.create_account("test_acc", None).await?;
            let tag_id = user
                .create_account_tag(
                    acc.id,
                    "category".to_string(),
                    "assets".to_string(),
                    Some("Account category".to_string()),
                )
                .await?;
            let mut conn = user.get_connection().await?;
            let res = sqlx::query!(
                "SELECT tag_id FROM account_tags WHERE account_id = $1 AND tag_id = $2",
                &acc.id,
                &tag_id
            )
            .fetch_one(&mut *conn)
            .await?;
            assert_eq!(res.tag_id, tag_id);
            let tag = user.get_tag(tag_id).await?;
            assert_eq!(tag.tag_name, "category");
            assert_eq!(tag.tag_value, "assets");
            assert_eq!(tag.description, Some("Account category".to_string()));
        }
        #[local_db_sqlx_test]
        async fn test_list_account_tag_names_empty(pool: PgPool) -> Result<(), anyhow::Error> {
            let user = USER.get().unwrap();
            user.commit()
                .await
                .expect("Failed to commit user to database");
            let names = user.list_account_tag_names().await?;
            assert!(names.is_empty());
        }
        #[local_db_sqlx_test]
        async fn test_list_account_tag_names_with_data(pool: PgPool) -> Result<(), anyhow::Error> {
            let user = USER.get().unwrap();
            user.commit()
                .await
                .expect("Failed to commit user to database");
            let acc1 = user.create_account("test_acc1", None).await?;
            let acc2 = user.create_account("test_acc2", None).await?;
            user.set_account_tag(
                &acc1,
                &Tag {
                    id: Uuid::new_v4(),
                    tag_name: "category".to_string(),
                    tag_value: "assets".to_string(),
                    description: None,
                },
            )
            .await?;
            user.set_account_tag(
                &acc2,
                &Tag {
                    id: Uuid::new_v4(),
                    tag_name: "priority".to_string(),
                    tag_value: "high".to_string(),
                    description: None,
                },
            )
            .await?;
            let names = user.list_account_tag_names().await?;
            assert!(names.contains(&"category".to_string()));
            assert!(names.contains(&"priority".to_string()));
        }
        #[local_db_sqlx_test]
        async fn test_list_account_tag_values_empty(pool: PgPool) -> Result<(), anyhow::Error> {
            let user = USER.get().unwrap();
            user.commit()
                .await
                .expect("Failed to commit user to database");
            let values = user.list_account_tag_values("category").await?;
            assert!(values.is_empty());
        }
        #[local_db_sqlx_test]
        async fn test_list_account_tag_values_with_data(pool: PgPool) -> Result<(), anyhow::Error> {
            let user = USER.get().unwrap();
            user.commit()
                .await
                .expect("Failed to commit user to database");
            let acc1 = user.create_account("test_acc1", None).await?;
            let acc2 = user.create_account("test_acc2", None).await?;
            user.set_account_tag(
                &acc1,
                &Tag {
                    id: Uuid::new_v4(),
                    tag_name: "category".to_string(),
                    tag_value: "assets".to_string(),
                    description: None,
                },
            )
            .await?;
            user.set_account_tag(
                &acc2,
                &Tag {
                    id: Uuid::new_v4(),
                    tag_name: "category".to_string(),
                    tag_value: "liabilities".to_string(),
                    description: None,
                },
            )
            .await?;
            let values = user.list_account_tag_values("category").await?;
            assert_eq!(values.len(), 2);
            assert!(values.contains(&"assets".to_string()));
            assert!(values.contains(&"liabilities".to_string()));
            let nonexistent = user.list_account_tag_values("nonexistent").await?;
            assert!(nonexistent.is_empty());
        }
    }
}