Compare commits

...
Sign in to create a new pull request.

142 commits

Author SHA1 Message Date
GitHub Action
ef2640b5fc Update OpenAPI documentation 2025-07-05 22:20:01 +00:00
majora2007
76fd7ab4ce Bump versions by dotnet-bump-version. 2025-07-05 22:18:52 +00:00
Joe Milazzo
08c52b4281
No More Sort Prefixes (#3895) 2025-07-05 15:18:11 -07:00
GitHub Action
9eadf956fb Update OpenAPI documentation 2025-07-05 19:53:05 +00:00
Joe Milazzo
eab3d7a207
v0.8.7 - Comic Metadata Downloading, Reading Profiles, Browse by Genre and More (#3888) 2025-07-05 12:51:19 -07:00
Weblate (bot)
1389eb6320
[skip ci] Weblate Changes (#3893)
Co-authored-by: Dark77 <Dark77@pobox.sk>
Co-authored-by: Gregory.Open <gregory.open@proton.me>
Co-authored-by: Mateusz <mateuszvx8.96@gmail.com>
Co-authored-by: 안세훈 <on9686@gmail.com>
2025-07-05 12:30:06 -07:00
Weblate (bot)
8deb96cf48
[skip ci] Weblate Changes (#3885)
Co-authored-by: Gregory.Open <gregory.open@proton.me>
2025-07-02 15:38:26 -07:00
Weblate (bot)
ff17908400
[skip ci] Weblate Changes (#3883)
Co-authored-by: Duarte Silva <smallflake@protonmail.com>
Co-authored-by: Gregory.Open <gregory.open@proton.me>
Co-authored-by: Havokdan <havokdan@yahoo.com.br>
Co-authored-by: lin49931104 <a82122794@gmail.com>
2025-06-30 05:20:19 -07:00
Weblate (bot)
e5d949161e
[skip ci] Weblate Changes (#3877)
Co-authored-by: Adam Havránek <adamhavra@seznam.cz>
Co-authored-by: Duarte Silva <smallflake@protonmail.com>
Co-authored-by: Frozehunter <frozehunter@me.com>
Co-authored-by: Lyrq <lyrq.ku@gmail.com>
Co-authored-by: தமிழ்நேரம் <anishprabu.t@gmail.com>
2025-06-28 12:20:04 -07:00
GitHub Action
6d4e207b65 Update OpenAPI documentation 2025-06-28 16:46:53 +00:00
majora2007
3ac816eaf7 Bump versions by dotnet-bump-version. 2025-06-28 16:45:47 +00:00
Fesaa
d909e03baf
A few more bug fixes (#3876)
Co-authored-by: Joseph Milazzo <joseph.v.milazzo@gmail.com>
2025-06-28 09:45:02 -07:00
Weblate (bot)
4b9bbc5d78
[skip ci] Weblate Changes (#3855)
Co-authored-by: Aindriú Mac Giolla Eoin <aindriu80@gmail.com>
Co-authored-by: Ascyra <czebeczauer@gmail.com>
Co-authored-by: Dark77 <Dark77@pobox.sk>
Co-authored-by: Frozehunter <frozehunter@me.com>
Co-authored-by: Havokdan <havokdan@yahoo.com.br>
Co-authored-by: N'Num Yutthaphon Inchaiya <yutthaphon30667@gmail.com>
Co-authored-by: lin49931104 <a82122794@gmail.com>
Co-authored-by: 無情天 <kofzhanganguo@126.com>
Co-authored-by: 안세훈 <on9686@gmail.com>
2025-06-26 16:16:45 -07:00
GitHub Action
994e5d4d83 Update OpenAPI documentation 2025-06-25 15:06:19 +00:00
majora2007
9c485350a5 Bump versions by dotnet-bump-version. 2025-06-25 15:05:07 +00:00
Fesaa
6fa1cf994e
A bunch of bug fixes and some enhancements (#3871)
Co-authored-by: Joseph Milazzo <josephmajora@gmail.com>
2025-06-25 10:04:26 -05:00
GitHub Action
62231d3c4e Update OpenAPI documentation 2025-06-23 23:58:56 +00:00
majora2007
d536cc7f6a Bump versions by dotnet-bump-version. 2025-06-23 23:57:53 +00:00
Joe Milazzo
36aa5f5c85
Ability to turn off Metadata Parsing (#3872) 2025-06-23 16:57:14 -07:00
GitHub Action
fa8d778c8d Update OpenAPI documentation 2025-06-20 19:11:24 +00:00
majora2007
225572732f Bump versions by dotnet-bump-version. 2025-06-20 19:10:12 +00:00
Joe Milazzo
14a8f5c1e5
Scrobbling Stability (#3863)
Co-authored-by: Amelia <77553571+Fesaa@users.noreply.github.com>
2025-06-20 12:09:29 -07:00
GitHub Action
45e24aa311 Update OpenAPI documentation 2025-06-20 17:47:47 +00:00
majora2007
55f94602d4 Bump versions by dotnet-bump-version. 2025-06-20 17:46:42 +00:00
Tyler Kenney
3107ca73e4
Koreader Progress Sync (#3823)
Co-authored-by: Joseph Milazzo <josephmajora@gmail.com>
2025-06-20 10:45:56 -07:00
Weblate (bot)
b6d004614a
[skip ci] Weblate Changes (#3804)
Co-authored-by: Adam Havránek <adamhavra@seznam.cz>
Co-authored-by: Adam Kleizer <adamkleizer@gmail.com>
Co-authored-by: Aindriú Mac Giolla Eoin <aindriu80@gmail.com>
Co-authored-by: Bora Atıcı <boratici.acc@gmail.com>
Co-authored-by: DR <weblate-kavita.snowflake668@slmail.me>
Co-authored-by: Dark77 <Dark77@pobox.sk>
Co-authored-by: Frozehunter <frozehunter@me.com>
Co-authored-by: Havokdan <havokdan@yahoo.com.br>
Co-authored-by: Itsmechinmoy <itsmechinmoy@users.noreply.hosted.weblate.org>
Co-authored-by: Yoan Jacquemin <yoanjacquemin@gmail.com>
Co-authored-by: lin49931104 <a82122794@gmail.com>
Co-authored-by: peter cerny <posli.to.semka@gmail.com>
Co-authored-by: Михаил <mikhael.petrov@gmail.com>
Co-authored-by: 無情天 <kofzhanganguo@126.com>
2025-06-16 04:36:01 -07:00
GitHub Action
10280c5487 Update OpenAPI documentation 2025-06-14 17:15:51 +00:00
majora2007
59e461fc96 Bump versions by dotnet-bump-version. 2025-06-14 17:14:50 +00:00
Joe Milazzo
c52ed1f65d
Browse by Genre/Tag/Person with new metadata system for People (#3835)
Co-authored-by: Stepan Goremykin <s.goremykin@proton.me>
Co-authored-by: goremykin <goremukin@gmail.com>
Co-authored-by: Christopher <39032787+MrRobotjs@users.noreply.github.com>
Co-authored-by: Fesaa <77553571+Fesaa@users.noreply.github.com>
2025-06-14 10:14:04 -07:00
majora2007
00c4712fc3 Bump versions by dotnet-bump-version. 2025-06-09 15:55:59 +00:00
Fesaa
5e54306fd0
Fix Import section not showing up in settings (#3849) 2025-06-09 10:55:18 -05:00
GitHub Action
fc4ba4509f Update OpenAPI documentation 2025-06-08 14:18:34 +00:00
majora2007
3d68c30cac Bump versions by dotnet-bump-version. 2025-06-08 14:17:28 +00:00
Fesaa
1856b01a46
Reading Profiles (#3845)
Co-authored-by: Joseph Milazzo <joseph.v.milazzo@gmail.com>
2025-06-08 07:16:44 -07:00
GitHub Action
ea28d64302 Update OpenAPI documentation 2025-06-04 07:46:56 +00:00
majora2007
7d6c541be5 Bump versions by dotnet-bump-version. 2025-06-04 07:45:50 +00:00
Fesaa
193e9b1da9
A collection of bug fixes (#3820)
Co-authored-by: Joseph Milazzo <joseph.v.milazzo@gmail.com>
2025-06-04 00:45:10 -07:00
GitHub Action
6288d89651 Update OpenAPI documentation 2025-05-14 16:14:05 +00:00
majora2007
a751da4ca6 Bump versions by dotnet-bump-version. 2025-05-14 16:12:57 +00:00
Fesaa
4396a07e7b
Merge pull request #3807 from Kareadita/bugfix/chapter-cover
Small Kavita+ cover fix
2025-05-14 18:12:11 +02:00
Amelia
d9ac52aa0f
Fix first chapter in a series not having a cover when K+ replaces series cover 2025-05-14 16:43:52 +02:00
Weblate (bot)
8ed2fa3829
[skip ci] Weblate Changes (#3773)
Co-authored-by: Adam Havránek <adamhavra@seznam.cz>
Co-authored-by: Aindriú Mac Giolla Eoin <aindriu80@gmail.com>
Co-authored-by: Bora Atıcı <boratici.acc@gmail.com>
Co-authored-by: Havokdan <havokdan@yahoo.com.br>
Co-authored-by: Robin Stolpe <robinstolpe@gmail.com>
Co-authored-by: axeron2036 <admin@axeron2036.ru>
Co-authored-by: 無情天 <kofzhanganguo@126.com>
Co-authored-by: 안세훈 <on9686@gmail.com>
2025-05-11 06:36:35 -07:00
GitHub Action
66f78aa859 Update OpenAPI documentation 2025-05-10 21:59:07 +00:00
majora2007
f88720ccfd Bump versions by dotnet-bump-version. 2025-05-10 21:57:58 +00:00
Joe Milazzo
70f00895e8
Random Stuff (#3798) 2025-05-10 14:57:14 -07:00
GitHub Action
574cf4b78e Update OpenAPI documentation 2025-05-09 22:20:05 +00:00
majora2007
005c1bf60b Bump versions by dotnet-bump-version. 2025-05-09 22:18:55 +00:00
Fesaa
7ce36bfc44
People Aliases and Merging (#3795)
Co-authored-by: Joseph Milazzo <josephmajora@gmail.com>
2025-05-09 15:18:13 -07:00
GitHub Action
cd2a6af6f2 Update OpenAPI documentation 2025-05-04 14:16:39 +00:00
majora2007
7e4216d0bf Bump versions by dotnet-bump-version. 2025-05-04 14:15:27 +00:00
Joe Milazzo
5b8a643d82
Small UI changes (#3787) 2025-05-04 07:14:44 -07:00
GitHub Action
50a052e412 Update OpenAPI documentation 2025-05-03 19:48:43 +00:00
majora2007
b83df31701 Bump versions by dotnet-bump-version. 2025-05-03 19:47:26 +00:00
Joe Milazzo
bc41b0256e
Fixed Delete Series + Issue Covers from Kavita+ (#3784) 2025-05-03 12:46:40 -07:00
GitHub Action
3a0d33ca13 Update OpenAPI documentation 2025-04-29 16:55:20 +00:00
majora2007
5a540ba7ea Bump versions by dotnet-bump-version. 2025-04-29 16:54:07 +00:00
Fesaa
4f7625ea77
Chapter/Issue level Reviews and Ratings (#3778)
Co-authored-by: Joseph Milazzo <josephmajora@gmail.com>
2025-04-29 09:53:24 -07:00
GitHub Action
3b8997e46e Update OpenAPI documentation 2025-04-26 18:34:47 +00:00
majora2007
8a77ffb4f9 Bump versions by dotnet-bump-version. 2025-04-26 18:33:39 +00:00
Joe Milazzo
7b3198ed9c
Small set of Fixes (#3776) 2025-04-26 11:32:56 -07:00
Weblate (bot)
5c06e14a73
[skip ci] Weblate Changes (#3755)
Co-authored-by: Adam Havránek <adamhavra@seznam.cz>
Co-authored-by: Amelia <77553571+Fesaa@users.noreply.github.com>
Co-authored-by: Björn Bause-Engel <github@bjoernengel.de>
Co-authored-by: Havokdan <havokdan@yahoo.com.br>
Co-authored-by: Lyrq <lyrq.ku@gmail.com>
Co-authored-by: Ricky Tigg <ricky.tigg@gmail.com>
Co-authored-by: Robin Stolpe <robinstolpe@gmail.com>
Co-authored-by: Sándor Mátyás <sandormatyas17@gmail.com>
Co-authored-by: 無情天 <kofzhanganguo@126.com>
Co-authored-by: 안세훈 <on9686@gmail.com>
2025-04-25 07:32:03 -07:00
GitHub Action
0ea1116d1d Update OpenAPI documentation 2025-04-25 13:28:46 +00:00
majora2007
7ccf743f9f Bump versions by dotnet-bump-version. 2025-04-25 13:27:34 +00:00
Joe Milazzo
ed154e4768
Kavita+ Comic Metadata Matching (#3740) 2025-04-25 06:26:48 -07:00
majora2007
4521965315 Bump versions by dotnet-bump-version. 2025-04-24 00:09:24 +00:00
Joe Milazzo
9ee5821cb2
Smart Filter UX (#3768) 2025-04-23 17:08:42 -07:00
Joe Milazzo
06a6d9e03b
v0.8.6.2 - General Settings Hotfix (#3761) 2025-04-20 10:00:56 -07:00
majora2007
9bb741a81b Bump versions by dotnet-bump-version. 2025-04-20 16:47:15 +00:00
Joe Milazzo
14bf4400a9
v0.8.6.2 - General Settings Hotfix (#3756)
Co-authored-by: Amelia <77553571+Fesaa@users.noreply.github.com>
Co-authored-by: Weblate (bot) <hosted@weblate.org>
Co-authored-by: Lyrq <lyrq.ku@gmail.com>
Co-authored-by: Ricky Tigg <ricky.tigg@gmail.com>
Co-authored-by: 無情天 <kofzhanganguo@126.com>
2025-04-20 09:46:33 -07:00
GitHub Action
d3f8a503c2 Update OpenAPI documentation 2025-04-18 12:34:41 +00:00
Joe Milazzo
a1264cb5e8
v0.8.6.1 - A few small issues Hotfix (#3749) 2025-04-18 05:32:42 -07:00
GitHub Action
e76a7152db Update OpenAPI documentation 2025-04-18 11:50:32 +00:00
majora2007
925ae83baf Bump versions by dotnet-bump-version. 2025-04-18 11:49:19 +00:00
Joe Milazzo
ad152aa26a
v0.8.6.1 - A few small issues Hotfix (#3744)
Co-authored-by: Amelia <77553571+Fesaa@users.noreply.github.com>
Co-authored-by: Weblate (bot) <hosted@weblate.org>
Co-authored-by: Lyrq <lyrq.ku@gmail.com>
2025-04-18 04:48:37 -07:00
DieselTech
00b759e532
[skip ci] Update bug_report.yml
Updated release version
2025-04-16 08:21:29 -04:00
GitHub Action
5c5b0df814 Update OpenAPI documentation 2025-04-14 22:09:45 +00:00
Joe Milazzo
45122f7bd8
v0.8.6 - Spring Cleaning (#3735) 2025-04-14 15:07:56 -07:00
majora2007
85471b4de0 Bump versions by dotnet-bump-version. 2025-04-14 11:19:06 +00:00
Joe Milazzo
fa88177783
Last fix before release (#3733) 2025-04-14 04:18:20 -07:00
GitHub Action
96fe0b9b3a Update OpenAPI documentation 2025-04-13 14:21:34 +00:00
majora2007
ce16988490 Bump versions by dotnet-bump-version. 2025-04-13 14:20:24 +00:00
Joe Milazzo
33221fee2b
Fix Genre/Tag with spaces getting normalized (#3731) 2025-04-13 07:19:42 -07:00
Samuel Martins
55966f0b2a
[skip ci] Fix broken wiki link (#3730) 2025-04-13 05:32:10 -07:00
Weblate (bot)
45481cafee
[skip ci] Weblate Changes (#3725)
Co-authored-by: Duarte Silva <smallflake@protonmail.com>
Co-authored-by: Frozehunter <frozehunter@me.com>
Co-authored-by: Gregory.Open <gregory.open@proton.me>
Co-authored-by: Lyrq <lyrq.ku@gmail.com>
Co-authored-by: majora2007 <kavitareader@gmail.com>
2025-04-12 13:26:59 -07:00
GitHub Action
cc4de12df8 Update OpenAPI documentation 2025-04-11 15:09:18 +00:00
majora2007
4876f5ce7c Bump versions by dotnet-bump-version. 2025-04-11 15:08:16 +00:00
Joe Milazzo
4453482d93
Polish before Release 2 (#3723)
Co-authored-by: Amelia <77553571+Fesaa@users.noreply.github.com>
2025-04-11 08:07:17 -07:00
Weblate (bot)
67d7d8467e
[skip ci] Weblate Changes (#3715)
Co-authored-by: Adam Havránek <adamhavra@seznam.cz>
Co-authored-by: Gregory.Open <gregory.open@proton.me>
Co-authored-by: thehijacker <thehijacker@gmail.com>
2025-04-11 07:41:28 -07:00
GitHub Action
6b7462a790 Update OpenAPI documentation 2025-04-08 23:27:24 +00:00
majora2007
ccb682ccc7 Bump versions by dotnet-bump-version. 2025-04-08 23:26:18 +00:00
Joe Milazzo
c80d046fc7
Polish for Release (#3714) 2025-04-08 16:25:37 -07:00
Weblate (bot)
9d9938bce2
[skip ci] Weblate Changes (#3708)
Co-authored-by: Adam Havránek <adamhavra@seznam.cz>
Co-authored-by: Aindriú Mac Giolla Eoin <aindriu80@gmail.com>
Co-authored-by: Havokdan <havokdan@yahoo.com.br>
Co-authored-by: 無情天 <kofzhanganguo@126.com>
2025-04-08 16:11:12 -07:00
GitHub Action
66d764f026 Update OpenAPI documentation 2025-04-06 18:15:53 +00:00
majora2007
6396455dab Bump versions by dotnet-bump-version. 2025-04-06 18:14:51 +00:00
Joe Milazzo
93dc6534fc
Another round of bugfixes (#3707) 2025-04-06 11:14:04 -07:00
Weblate (bot)
cbb97208b8
[skip ci] Weblate Changes (#3688)
Co-authored-by: Adam Havránek <adamhavra@seznam.cz>
Co-authored-by: Aindriú Mac Giolla Eoin <aindriu80@gmail.com>
Co-authored-by: Havokdan <havokdan@yahoo.com.br>
Co-authored-by: 無情天 <kofzhanganguo@126.com>
2025-04-05 14:18:16 -07:00
GitHub Action
71a0a8756b Update OpenAPI documentation 2025-04-05 20:53:52 +00:00
majora2007
d8c4b95089 Bump versions by dotnet-bump-version. 2025-04-05 20:52:46 +00:00
Joe Milazzo
37734554ba
A boatload of Bugs (#3704)
Co-authored-by: Amelia <77553571+Fesaa@users.noreply.github.com>
2025-04-05 13:52:01 -07:00
Weblate (bot)
ea9b7ad0d1
[skip ci] Weblate Changes (#3670)
Co-authored-by: Adam Havránek <adamhavra@seznam.cz>
Co-authored-by: Aindriú Mac Giolla Eoin <aindriu80@gmail.com>
Co-authored-by: Dark77 <Dark77@pobox.sk>
Co-authored-by: Gino Cicatiello <ginocic@gmail.com>
Co-authored-by: Havokdan <havokdan@yahoo.com.br>
Co-authored-by: beckett <beckett.blakey@proton.me>
Co-authored-by: nazo6 <git@nazo6.dev>
Co-authored-by: 無情天 <kofzhanganguo@126.com>
2025-03-29 18:03:25 -07:00
GitHub Action
412cecaf3c Update OpenAPI documentation 2025-03-30 00:49:42 +00:00
majora2007
4abe2532bf Bump versions by dotnet-bump-version. 2025-03-30 00:48:35 +00:00
Joe Milazzo
dad212bfb9
Reading List Detail Overhaul + More Bugfixes and Polish (#3687)
Co-authored-by: Yongun Seong <yseong.p@gmail.com>
2025-03-29 17:47:53 -07:00
GitHub Action
b2ee651fb8 Update OpenAPI documentation 2025-03-28 17:45:27 +00:00
majora2007
283936e618 Bump versions by dotnet-bump-version. 2025-03-28 17:44:21 +00:00
Fesaa
5738014a88
PDF Parser Changes (#3681) 2025-03-28 10:43:35 -07:00
GitHub Action
da9f02f963 Update OpenAPI documentation 2025-03-25 21:45:46 +00:00
majora2007
7a1541d229 Bump versions by dotnet-bump-version. 2025-03-25 21:44:27 +00:00
Joe Milazzo
cc3ae7f472
Angular 19 + Even more bugfixes (#3675) 2025-03-25 14:43:41 -07:00
GitHub Action
535165c445 Update OpenAPI documentation 2025-03-23 22:08:10 +00:00
majora2007
9c12de42d5 Bump versions by dotnet-bump-version. 2025-03-23 22:07:05 +00:00
Joe Milazzo
de651215f5
A ton of random bugs and polish (#3668) 2025-03-23 15:06:20 -07:00
Weblate (bot)
b45d92ea5c
[skip ci] Weblate Changes (#3614)
Co-authored-by: Adam Havránek <adamhavra@seznam.cz>
Co-authored-by: Adolfo Jayme Barrientos <fitojb@ubuntu.com>
Co-authored-by: Aindriú Mac Giolla Eoin <aindriu80@gmail.com>
Co-authored-by: Frozehunter <frozehunter@me.com>
Co-authored-by: Gregory.Open <gregory.open@proton.me>
Co-authored-by: Havokdan <havokdan@yahoo.com.br>
Co-authored-by: Ivan Zver <chihpuh777@yahoo.com>
Co-authored-by: LeeWan1210 <dldhks456@live.com>
Co-authored-by: Mateusz <mateuszvx8.96@gmail.com>
Co-authored-by: Mehrshad <mehrshad2028@gmail.com>
Co-authored-by: Swedish Wiking <william@soderberg.tech>
Co-authored-by: mevingrivault <mevin.grivault@gmail.com>
Co-authored-by: 無情天 <kofzhanganguo@126.com>
2025-03-23 14:51:32 -07:00
GitHub Action
b24e18c99e Update OpenAPI documentation 2025-03-23 20:12:44 +00:00
majora2007
a17d39f25a Bump versions by dotnet-bump-version. 2025-03-23 20:11:31 +00:00
Fesaa
1ad8a360cb
Various bug fixes (#3667) 2025-03-23 13:10:50 -07:00
majora2007
a1d3aef39b Bump versions by dotnet-bump-version. 2025-03-23 13:39:00 +00:00
Fesaa
acaf308c2e
Ability to Search on Localized Name in Series Relationships (#3560) 2025-03-23 06:38:19 -07:00
GitHub Action
e1e00c8c47 Update OpenAPI documentation 2025-03-22 20:07:46 +00:00
majora2007
4a6ea0a27d Bump versions by dotnet-bump-version. 2025-03-22 20:06:29 +00:00
Joe Milazzo
43d0d1277f
Version Update Modal Rework + A few bugfixes (#3664) 2025-03-22 13:05:48 -07:00
majora2007
9fb3bdd548 Bump versions by dotnet-bump-version. 2025-03-22 19:52:30 +00:00
Fesaa
6bb03618fb
EPUB Reader Fixes (#3662) 2025-03-22 12:51:49 -07:00
GitHub Action
4c68d2db26 Update OpenAPI documentation 2025-03-22 19:41:01 +00:00
majora2007
6e72854d37 Bump versions by dotnet-bump-version. 2025-03-22 19:39:52 +00:00
Fesaa
a7e1386bad
A few reading list bug fixes (#3663) 2025-03-22 12:39:09 -07:00
majora2007
0785d4afab Bump versions by dotnet-bump-version. 2025-03-19 16:18:25 +00:00
Fesaa
912c8af450
More Localization Fixes (#3655) 2025-03-19 11:17:39 -05:00
majora2007
b5d201e69b Bump versions by dotnet-bump-version. 2025-03-19 12:08:55 +00:00
Joe Milazzo
0f72f63b35
Localization Issues (#3653)
Co-authored-by: Amelia <77553571+Fesaa@users.noreply.github.com>
2025-03-19 07:08:12 -05:00
Fesaa
98a2b9d3ed
[skip ci] Fix unit tests on macOS (And probably Unix) (#3647) 2025-03-19 06:13:05 -05:00
GitHub Action
e897fb9a12 Update OpenAPI documentation 2025-03-17 19:26:49 +00:00
Joe Milazzo
ba17bf483d
v0.8.5.11 - Hotfix 2 (#3648) 2025-03-17 12:24:53 -07:00
GitHub Action
df11f5bac2 Update OpenAPI documentation 2025-03-17 13:52:20 +00:00
majora2007
92afcf7a23 Bump versions by dotnet-bump-version. 2025-03-17 13:51:09 +00:00
Joe Milazzo
fb8bbcfa13
Revert Subfolder Optimization (#3646) 2025-03-17 06:50:24 -07:00
GitHub Action
e93cab240e Update OpenAPI documentation 2025-03-15 18:10:38 +00:00
majora2007
cb207557e6 Bump versions by dotnet-bump-version. 2025-03-15 18:09:36 +00:00
0xGingi
4cb16be6a5
Added a UTC timestamp for reading history events (#3640) 2025-03-15 11:08:51 -07:00
GitHub Action
75419fb62b Update OpenAPI documentation 2025-03-15 16:14:57 +00:00
majora2007
9b70da7926 Bump versions by dotnet-bump-version. 2025-03-15 16:13:44 +00:00
Joe Milazzo
f281a63934
Scrobbling Polish and Some Scanner fixes (#3638)
Co-authored-by: Fesaa <77553571+Fesaa@users.noreply.github.com>
2025-03-15 09:13:01 -07:00
1023 changed files with 82040 additions and 17784 deletions

View file

@ -1,6 +1,7 @@
# Editor configuration, see https://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
@ -22,3 +23,7 @@ indent_size = 2
[*.csproj]
indent_size = 2
[*.cs]
# Disable SonarLint warning S1075 (Don't use hardcoded url)
dotnet_diagnostic.S1075.severity = none

View file

@ -25,10 +25,10 @@ body:
- type: dropdown
id: version
attributes:
label: Kavita Version Number - If you don not see your version number listed, please update Kavita and see if your issue still persists.
label: Kavita Version Number - If you don't see your version number listed, please update Kavita and see if your issue still persists.
multiple: false
options:
- 0.8.5.3 - Stable
- 0.8.7 - Stable
- Nightly Testing Branch
validations:
required: true

View file

@ -13,7 +13,7 @@ name: "CodeQL"
on:
push:
branches: [ "develop", "main" ]
branches: [ "develop"]
pull_request:
# The branches below must be a subset of the branches above
branches: [ "develop" ]
@ -38,7 +38,7 @@ jobs:
strategy:
fail-fast: false
matrix:
language: [ 'csharp', 'javascript-typescript', 'python' ]
language: [ 'csharp', 'javascript-typescript' ]
# CodeQL supports [ 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift' ]
# Use only 'java-kotlin' to analyze code written in Java, Kotlin or both
# Use only 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both

View file

@ -10,8 +10,8 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="BenchmarkDotNet" Version="0.14.0" />
<PackageReference Include="BenchmarkDotNet.Annotations" Version="0.14.0" />
<PackageReference Include="BenchmarkDotNet" Version="0.15.1" />
<PackageReference Include="BenchmarkDotNet.Annotations" Version="0.15.1" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
</ItemGroup>
@ -26,5 +26,10 @@
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
</ItemGroup>
<ItemGroup>
<None Update="Data\AesopsFables.epub">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

Binary file not shown.

View file

@ -0,0 +1,41 @@
using API.Helpers.Builders;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Order;
using System;
using API.Entities.Enums;
namespace API.Benchmark
{
[StopOnFirstError]
[MemoryDiagnoser]
[RankColumn]
[Orderer(SummaryOrderPolicy.FastestToSlowest)]
[SimpleJob(launchCount: 1, warmupCount: 5, invocationCount: 20)]
public class KoreaderHashBenchmark
{
private const string sourceEpub = "./Data/AesopsFables.epub";
[Benchmark(Baseline = true)]
public void TestBuildManga_baseline()
{
var file = new MangaFileBuilder(sourceEpub, MangaFormat.Epub)
.Build();
if (file == null)
{
throw new Exception("Failed to build manga file");
}
}
[Benchmark]
public void TestBuildManga_withHash()
{
var file = new MangaFileBuilder(sourceEpub, MangaFormat.Epub)
.WithHash()
.Build();
if (file == null)
{
throw new Exception("Failed to build manga file");
}
}
}
}

View file

@ -6,13 +6,13 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.2" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.6" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="22.0.11" />
<PackageReference Include="TestableIO.System.IO.Abstractions.Wrappers" Version="22.0.11" />
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="22.0.14" />
<PackageReference Include="TestableIO.System.IO.Abstractions.Wrappers" Version="22.0.14" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.1">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
@ -28,6 +28,7 @@
<ItemGroup>
<Folder Include="Services\Test Data\ArchiveService\ComicInfos" />
<Folder Include="Services\Test Data\CoverDbService\" />
<Folder Include="Services\Test Data\ImageService\Covers\" />
</ItemGroup>
@ -35,4 +36,10 @@
<None Remove="Extensions\Test Data\modified on run.txt" />
</ItemGroup>
<ItemGroup>
<None Update="Data\AesopsFables.epub">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

View file

@ -1,7 +1,5 @@
using System;
using System.Collections.Generic;
using System.Data.Common;
using System.IO.Abstractions.TestingHelpers;
using System.Linq;
using System.Threading.Tasks;
using API.Data;
@ -12,7 +10,6 @@ using API.Helpers.Builders;
using API.Services;
using AutoMapper;
using Hangfire;
using Microsoft.AspNetCore.Identity;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
@ -21,23 +18,13 @@ using NSubstitute;
namespace API.Tests;
public abstract class AbstractDbTest : IDisposable
public abstract class AbstractDbTest : AbstractFsTest , IDisposable
{
protected readonly DbConnection _connection;
protected readonly DataContext _context;
protected readonly IUnitOfWork _unitOfWork;
protected readonly IMapper _mapper;
protected const string CacheDirectory = "C:/kavita/config/cache/";
protected const string CacheLongDirectory = "C:/kavita/config/cache-long/";
protected const string CoverImageDirectory = "C:/kavita/config/covers/";
protected const string BackupDirectory = "C:/kavita/config/backups/";
protected const string LogDirectory = "C:/kavita/config/logs/";
protected const string BookmarkDirectory = "C:/kavita/config/bookmarks/";
protected const string SiteThemeDirectory = "C:/kavita/config/themes/";
protected const string TempDirectory = "C:/kavita/config/temp/";
protected const string DataDirectory = "C:/data/";
protected readonly DataContext Context;
protected readonly IUnitOfWork UnitOfWork;
protected readonly IMapper Mapper;
private readonly DbConnection _connection;
private bool _disposed;
protected AbstractDbTest()
{
@ -48,17 +35,17 @@ public abstract class AbstractDbTest : IDisposable
_connection = RelationalOptionsExtension.Extract(contextOptions).Connection;
_context = new DataContext(contextOptions);
Context = new DataContext(contextOptions);
_context.Database.EnsureCreated(); // Ensure DB schema is created
Context.Database.EnsureCreated(); // Ensure DB schema is created
Task.Run(SeedDb).GetAwaiter().GetResult();
var config = new MapperConfiguration(cfg => cfg.AddProfile<AutoMapperProfiles>());
_mapper = config.CreateMapper();
Mapper = config.CreateMapper();
GlobalConfiguration.Configuration.UseInMemoryStorage();
_unitOfWork = new UnitOfWork(_context, _mapper, null);
UnitOfWork = new UnitOfWork(Context, Mapper, null);
}
private static DbConnection CreateInMemoryDatabase()
@ -73,34 +60,34 @@ public abstract class AbstractDbTest : IDisposable
{
try
{
await _context.Database.EnsureCreatedAsync();
await Context.Database.EnsureCreatedAsync();
var filesystem = CreateFileSystem();
await Seed.SeedSettings(_context, new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), filesystem));
await Seed.SeedSettings(Context, new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), filesystem));
var setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.CacheDirectory).SingleAsync();
var setting = await Context.ServerSetting.Where(s => s.Key == ServerSettingKey.CacheDirectory).SingleAsync();
setting.Value = CacheDirectory;
setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.BackupDirectory).SingleAsync();
setting = await Context.ServerSetting.Where(s => s.Key == ServerSettingKey.BackupDirectory).SingleAsync();
setting.Value = BackupDirectory;
setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.BookmarkDirectory).SingleAsync();
setting = await Context.ServerSetting.Where(s => s.Key == ServerSettingKey.BookmarkDirectory).SingleAsync();
setting.Value = BookmarkDirectory;
setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.TotalLogs).SingleAsync();
setting = await Context.ServerSetting.Where(s => s.Key == ServerSettingKey.TotalLogs).SingleAsync();
setting.Value = "10";
_context.ServerSetting.Update(setting);
Context.ServerSetting.Update(setting);
_context.Library.Add(new LibraryBuilder("Manga")
Context.Library.Add(new LibraryBuilder("Manga")
.WithAllowMetadataMatching(true)
.WithFolderPath(new FolderPathBuilder(DataDirectory).Build())
.Build());
await _context.SaveChangesAsync();
await Context.SaveChangesAsync();
await Seed.SeedMetadataSettings(_context);
await Seed.SeedMetadataSettings(Context);
return true;
}
@ -113,27 +100,37 @@ public abstract class AbstractDbTest : IDisposable
protected abstract Task ResetDb();
protected static MockFileSystem CreateFileSystem()
{
var fileSystem = new MockFileSystem();
fileSystem.Directory.SetCurrentDirectory("C:/kavita/");
fileSystem.AddDirectory("C:/kavita/config/");
fileSystem.AddDirectory(CacheDirectory);
fileSystem.AddDirectory(CacheLongDirectory);
fileSystem.AddDirectory(CoverImageDirectory);
fileSystem.AddDirectory(BackupDirectory);
fileSystem.AddDirectory(BookmarkDirectory);
fileSystem.AddDirectory(SiteThemeDirectory);
fileSystem.AddDirectory(LogDirectory);
fileSystem.AddDirectory(TempDirectory);
fileSystem.AddDirectory(DataDirectory);
return fileSystem;
}
public void Dispose()
{
_context.Dispose();
_connection.Dispose();
Dispose(true);
GC.SuppressFinalize(this);
}
protected virtual void Dispose(bool disposing)
{
if (_disposed) return;
if (disposing)
{
Context?.Dispose();
_connection?.Dispose();
}
_disposed = true;
}
/// <summary>
/// Add a role to an existing User. Commits.
/// </summary>
/// <param name="userId"></param>
/// <param name="roleName"></param>
protected async Task AddUserWithRole(int userId, string roleName)
{
var role = new AppRole { Id = userId, Name = roleName, NormalizedName = roleName.ToUpper() };
await Context.Roles.AddAsync(role);
await Context.UserRoles.AddAsync(new AppUserRole { UserId = userId, RoleId = userId });
await Context.SaveChangesAsync();
}
}

View file

@ -0,0 +1,44 @@
using System.IO;
using System.IO.Abstractions;
using System.IO.Abstractions.TestingHelpers;
using API.Services.Tasks.Scanner.Parser;
namespace API.Tests;
public abstract class AbstractFsTest
{
protected static readonly string Root = Parser.NormalizePath(Path.GetPathRoot(Directory.GetCurrentDirectory()));
protected static readonly string ConfigDirectory = Root + "kavita/config/";
protected static readonly string CacheDirectory = ConfigDirectory + "cache/";
protected static readonly string CacheLongDirectory = ConfigDirectory + "cache-long/";
protected static readonly string CoverImageDirectory = ConfigDirectory + "covers/";
protected static readonly string BackupDirectory = ConfigDirectory + "backups/";
protected static readonly string LogDirectory = ConfigDirectory + "logs/";
protected static readonly string BookmarkDirectory = ConfigDirectory + "bookmarks/";
protected static readonly string SiteThemeDirectory = ConfigDirectory + "themes/";
protected static readonly string TempDirectory = ConfigDirectory + "temp/";
protected static readonly string ThemesDirectory = ConfigDirectory + "theme";
protected static readonly string DataDirectory = Root + "data/";
protected static MockFileSystem CreateFileSystem()
{
var fileSystem = new MockFileSystem();
fileSystem.Directory.SetCurrentDirectory(Root + "kavita/");
fileSystem.AddDirectory(Root + "kavita/config/");
fileSystem.AddDirectory(CacheDirectory);
fileSystem.AddDirectory(CacheLongDirectory);
fileSystem.AddDirectory(CoverImageDirectory);
fileSystem.AddDirectory(BackupDirectory);
fileSystem.AddDirectory(BookmarkDirectory);
fileSystem.AddDirectory(SiteThemeDirectory);
fileSystem.AddDirectory(LogDirectory);
fileSystem.AddDirectory(TempDirectory);
fileSystem.AddDirectory(DataDirectory);
fileSystem.AddDirectory(ThemesDirectory);
return fileSystem;
}
}

View file

@ -1,5 +1,4 @@
using API.Helpers.Converters;
using Hangfire;
using Xunit;
namespace API.Tests.Converters;

Binary file not shown.

View file

@ -142,7 +142,7 @@ public class ChapterListExtensionsTests
CreateChapter("darker than black", "1", CreateFile("/manga/darker than black.cbz", MangaFormat.Archive), false),
};
Assert.Equal(chapterList.First(), chapterList.GetFirstChapterWithFiles());
Assert.Equal(chapterList[0], chapterList.GetFirstChapterWithFiles());
}
[Fact]
@ -150,13 +150,13 @@ public class ChapterListExtensionsTests
{
var chapterList = new List<Chapter>()
{
CreateChapter("darker than black", API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter, CreateFile("/manga/darker than black.cbz", MangaFormat.Archive), true),
CreateChapter("darker than black", Parser.DefaultChapter, CreateFile("/manga/darker than black.cbz", MangaFormat.Archive), true),
CreateChapter("darker than black", "1", CreateFile("/manga/darker than black.cbz", MangaFormat.Archive), false),
};
chapterList.First().Files = new List<MangaFile>();
chapterList[0].Files = new List<MangaFile>();
Assert.Equal(chapterList.Last(), chapterList.GetFirstChapterWithFiles());
Assert.Equal(chapterList[^1], chapterList.GetFirstChapterWithFiles());
}
@ -181,7 +181,7 @@ public class ChapterListExtensionsTests
CreateChapter("detective comics", API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter, CreateFile("/manga/detective comics #001.cbz", MangaFormat.Archive), true)
};
chapterList[0].ReleaseDate = new DateTime(10, 1, 1);
chapterList[0].ReleaseDate = new DateTime(10, 1, 1, 0, 0, 0, DateTimeKind.Utc);
chapterList[1].ReleaseDate = DateTime.MinValue;
Assert.Equal(0, chapterList.MinimumReleaseYear());
@ -196,8 +196,8 @@ public class ChapterListExtensionsTests
CreateChapter("detective comics", API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter, CreateFile("/manga/detective comics #001.cbz", MangaFormat.Archive), true)
};
chapterList[0].ReleaseDate = new DateTime(2002, 1, 1);
chapterList[1].ReleaseDate = new DateTime(2012, 2, 1);
chapterList[0].ReleaseDate = new DateTime(2002, 1, 1, 0, 0, 0, DateTimeKind.Utc);
chapterList[1].ReleaseDate = new DateTime(2012, 2, 1, 0, 0, 0, DateTimeKind.Utc);
Assert.Equal(2002, chapterList.MinimumReleaseYear());
}

View file

@ -0,0 +1,31 @@
using System;
using System.Collections.Generic;
using System.Linq;
using API.Entities.Enums;
using API.Extensions;
using Xunit;
namespace API.Tests.Extensions;
public class EncodeFormatExtensionsTests
{
[Fact]
public void GetExtension_ShouldReturnCorrectExtensionForAllValues()
{
// Arrange
var expectedExtensions = new Dictionary<EncodeFormat, string>
{
{ EncodeFormat.PNG, ".png" },
{ EncodeFormat.WEBP, ".webp" },
{ EncodeFormat.AVIF, ".avif" }
};
// Act & Assert
foreach (var format in Enum.GetValues(typeof(EncodeFormat)).Cast<EncodeFormat>())
{
var extension = format.GetExtension();
Assert.Equal(expectedExtensions[format], extension);
}
}
}

View file

@ -7,7 +7,6 @@ using API.Extensions;
using API.Helpers.Builders;
using API.Services;
using API.Services.Tasks.Scanner.Parser;
using API.Tests.Helpers;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Xunit;

View file

@ -1,11 +1,9 @@
using System.Collections.Generic;
using System.Linq;
using API.Data;
using API.Data.Misc;
using API.Entities;
using API.Entities.Enums;
using API.Entities.Metadata;
using API.Extensions;
using API.Entities.Person;
using API.Extensions.QueryExtensions;
using API.Helpers.Builders;
using Xunit;
@ -69,7 +67,7 @@ public class QueryableExtensionsTests
[Theory]
[InlineData(true, 2)]
[InlineData(false, 1)]
[InlineData(false, 2)]
public void RestrictAgainstAgeRestriction_Genre_ShouldRestrictEverythingAboveTeen(bool includeUnknowns, int expectedCount)
{
var items = new List<Genre>()
@ -96,7 +94,7 @@ public class QueryableExtensionsTests
[Theory]
[InlineData(true, 2)]
[InlineData(false, 1)]
[InlineData(false, 2)]
public void RestrictAgainstAgeRestriction_Tag_ShouldRestrictEverythingAboveTeen(bool includeUnknowns, int expectedCount)
{
var items = new List<Tag>()

View file

@ -24,9 +24,9 @@ public class SeriesFilterTests : AbstractDbTest
{
protected override async Task ResetDb()
{
_context.Series.RemoveRange(_context.Series);
_context.AppUser.RemoveRange(_context.AppUser);
await _context.SaveChangesAsync();
Context.Series.RemoveRange(Context.Series);
Context.AppUser.RemoveRange(Context.AppUser);
await Context.SaveChangesAsync();
}
#region HasProgress
@ -54,18 +54,18 @@ public class SeriesFilterTests : AbstractDbTest
.WithLibrary(library)
.Build();
_context.Users.Add(user);
_context.Library.Add(library);
await _context.SaveChangesAsync();
Context.Users.Add(user);
Context.Library.Add(library);
await Context.SaveChangesAsync();
// Create read progress on Partial and Full
var readerService = new ReaderService(_unitOfWork, Substitute.For<ILogger<ReaderService>>(),
var readerService = new ReaderService(UnitOfWork, Substitute.For<ILogger<ReaderService>>(),
Substitute.For<IEventHub>(), Substitute.For<IImageService>(),
Substitute.For<IDirectoryService>(), Substitute.For<IScrobblingService>());
// Select Partial and set pages read to 5 on first chapter
var partialSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(2);
var partialSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(2);
var partialChapter = partialSeries.Volumes.First().Chapters.First();
Assert.True(await readerService.SaveReadingProgress(new ProgressDto()
@ -78,7 +78,7 @@ public class SeriesFilterTests : AbstractDbTest
}, user.Id));
// Select Full and set pages read to 10 on first chapter
var fullSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(3);
var fullSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(3);
var fullChapter = fullSeries.Volumes.First().Chapters.First();
Assert.True(await readerService.SaveReadingProgress(new ProgressDto()
@ -98,7 +98,7 @@ public class SeriesFilterTests : AbstractDbTest
{
var user = await SetupHasProgress();
var queryResult = await _context.Series.HasReadingProgress(true, FilterComparison.LessThan, 50, user.Id)
var queryResult = await Context.Series.HasReadingProgress(true, FilterComparison.LessThan, 50, user.Id)
.ToListAsync();
Assert.Single(queryResult);
@ -111,7 +111,7 @@ public class SeriesFilterTests : AbstractDbTest
var user = await SetupHasProgress();
// Query series with progress <= 50%
var queryResult = await _context.Series.HasReadingProgress(true, FilterComparison.LessThanEqual, 50, user.Id)
var queryResult = await Context.Series.HasReadingProgress(true, FilterComparison.LessThanEqual, 50, user.Id)
.ToListAsync();
Assert.Equal(2, queryResult.Count);
@ -125,7 +125,7 @@ public class SeriesFilterTests : AbstractDbTest
var user = await SetupHasProgress();
// Query series with progress > 50%
var queryResult = await _context.Series.HasReadingProgress(true, FilterComparison.GreaterThan, 50, user.Id)
var queryResult = await Context.Series.HasReadingProgress(true, FilterComparison.GreaterThan, 50, user.Id)
.ToListAsync();
Assert.Single(queryResult);
@ -138,7 +138,7 @@ public class SeriesFilterTests : AbstractDbTest
var user = await SetupHasProgress();
// Query series with progress == 100%
var queryResult = await _context.Series.HasReadingProgress(true, FilterComparison.Equal, 100, user.Id)
var queryResult = await Context.Series.HasReadingProgress(true, FilterComparison.Equal, 100, user.Id)
.ToListAsync();
Assert.Single(queryResult);
@ -151,7 +151,7 @@ public class SeriesFilterTests : AbstractDbTest
var user = await SetupHasProgress();
// Query series with progress < 100%
var queryResult = await _context.Series.HasReadingProgress(true, FilterComparison.LessThan, 100, user.Id)
var queryResult = await Context.Series.HasReadingProgress(true, FilterComparison.LessThan, 100, user.Id)
.ToListAsync();
Assert.Equal(2, queryResult.Count);
@ -165,7 +165,7 @@ public class SeriesFilterTests : AbstractDbTest
var user = await SetupHasProgress();
// Query series with progress <= 100%
var queryResult = await _context.Series.HasReadingProgress(true, FilterComparison.LessThanEqual, 100, user.Id)
var queryResult = await Context.Series.HasReadingProgress(true, FilterComparison.LessThanEqual, 100, user.Id)
.ToListAsync();
Assert.Equal(3, queryResult.Count);
@ -188,16 +188,16 @@ public class SeriesFilterTests : AbstractDbTest
.WithLibrary(library)
.Build();
_context.Users.Add(user);
_context.Library.Add(library);
await _context.SaveChangesAsync();
Context.Users.Add(user);
Context.Library.Add(library);
await Context.SaveChangesAsync();
var readerService = new ReaderService(_unitOfWork, Substitute.For<ILogger<ReaderService>>(),
var readerService = new ReaderService(UnitOfWork, Substitute.For<ILogger<ReaderService>>(),
Substitute.For<IEventHub>(), Substitute.For<IImageService>(),
Substitute.For<IDirectoryService>(), Substitute.For<IScrobblingService>());
// Set progress to 99.99% (99/100 pages read)
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1);
var series = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1);
var chapter = series.Volumes.First().Chapters.First();
Assert.True(await readerService.SaveReadingProgress(new ProgressDto()
@ -210,7 +210,7 @@ public class SeriesFilterTests : AbstractDbTest
}, user.Id));
// Query series with progress < 100%
var queryResult = await _context.Series.HasReadingProgress(true, FilterComparison.LessThan, 100, user.Id)
var queryResult = await Context.Series.HasReadingProgress(true, FilterComparison.LessThan, 100, user.Id)
.ToListAsync();
Assert.Single(queryResult);
@ -246,9 +246,9 @@ public class SeriesFilterTests : AbstractDbTest
.WithLibrary(library)
.Build();
_context.Users.Add(user);
_context.Library.Add(library);
await _context.SaveChangesAsync();
Context.Users.Add(user);
Context.Library.Add(library);
await Context.SaveChangesAsync();
return user;
}
@ -258,7 +258,7 @@ public class SeriesFilterTests : AbstractDbTest
{
await SetupHasLanguage();
var foundSeries = await _context.Series.HasLanguage(true, FilterComparison.Equal, ["en"]).ToListAsync();
var foundSeries = await Context.Series.HasLanguage(true, FilterComparison.Equal, ["en"]).ToListAsync();
Assert.Single(foundSeries);
Assert.Equal("en", foundSeries[0].Metadata.Language);
}
@ -268,7 +268,7 @@ public class SeriesFilterTests : AbstractDbTest
{
await SetupHasLanguage();
var foundSeries = await _context.Series.HasLanguage(true, FilterComparison.NotEqual, ["en"]).ToListAsync();
var foundSeries = await Context.Series.HasLanguage(true, FilterComparison.NotEqual, ["en"]).ToListAsync();
Assert.Equal(2, foundSeries.Count);
Assert.DoesNotContain(foundSeries, s => s.Metadata.Language == "en");
}
@ -278,7 +278,7 @@ public class SeriesFilterTests : AbstractDbTest
{
await SetupHasLanguage();
var foundSeries = await _context.Series.HasLanguage(true, FilterComparison.Contains, ["en", "fr"]).ToListAsync();
var foundSeries = await Context.Series.HasLanguage(true, FilterComparison.Contains, ["en", "fr"]).ToListAsync();
Assert.Equal(2, foundSeries.Count);
Assert.Contains(foundSeries, s => s.Metadata.Language == "en");
Assert.Contains(foundSeries, s => s.Metadata.Language == "fr");
@ -289,7 +289,7 @@ public class SeriesFilterTests : AbstractDbTest
{
await SetupHasLanguage();
var foundSeries = await _context.Series.HasLanguage(true, FilterComparison.NotContains, ["en", "fr"]).ToListAsync();
var foundSeries = await Context.Series.HasLanguage(true, FilterComparison.NotContains, ["en", "fr"]).ToListAsync();
Assert.Single(foundSeries);
Assert.Equal("es", foundSeries[0].Metadata.Language);
}
@ -300,11 +300,11 @@ public class SeriesFilterTests : AbstractDbTest
await SetupHasLanguage();
// Since "MustContains" matches all the provided languages, no series should match in this case.
var foundSeries = await _context.Series.HasLanguage(true, FilterComparison.MustContains, ["en", "fr"]).ToListAsync();
var foundSeries = await Context.Series.HasLanguage(true, FilterComparison.MustContains, ["en", "fr"]).ToListAsync();
Assert.Empty(foundSeries);
// Single language should work.
foundSeries = await _context.Series.HasLanguage(true, FilterComparison.MustContains, ["en"]).ToListAsync();
foundSeries = await Context.Series.HasLanguage(true, FilterComparison.MustContains, ["en"]).ToListAsync();
Assert.Single(foundSeries);
Assert.Equal("en", foundSeries[0].Metadata.Language);
}
@ -314,7 +314,7 @@ public class SeriesFilterTests : AbstractDbTest
{
await SetupHasLanguage();
var foundSeries = await _context.Series.HasLanguage(true, FilterComparison.Matches, ["e"]).ToListAsync();
var foundSeries = await Context.Series.HasLanguage(true, FilterComparison.Matches, ["e"]).ToListAsync();
Assert.Equal(2, foundSeries.Count);
Assert.Contains("en", foundSeries.Select(s => s.Metadata.Language));
Assert.Contains("es", foundSeries.Select(s => s.Metadata.Language));
@ -325,7 +325,7 @@ public class SeriesFilterTests : AbstractDbTest
{
await SetupHasLanguage();
var foundSeries = await _context.Series.HasLanguage(false, FilterComparison.Equal, ["en"]).ToListAsync();
var foundSeries = await Context.Series.HasLanguage(false, FilterComparison.Equal, ["en"]).ToListAsync();
Assert.Equal(3, foundSeries.Count);
}
@ -334,7 +334,7 @@ public class SeriesFilterTests : AbstractDbTest
{
await SetupHasLanguage();
var foundSeries = await _context.Series.HasLanguage(true, FilterComparison.Equal, new List<string>()).ToListAsync();
var foundSeries = await Context.Series.HasLanguage(true, FilterComparison.Equal, new List<string>()).ToListAsync();
Assert.Equal(3, foundSeries.Count);
}
@ -345,7 +345,7 @@ public class SeriesFilterTests : AbstractDbTest
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(async () =>
{
await _context.Series.HasLanguage(true, FilterComparison.GreaterThan, ["en"]).ToListAsync();
await Context.Series.HasLanguage(true, FilterComparison.GreaterThan, ["en"]).ToListAsync();
});
}
@ -379,9 +379,9 @@ public class SeriesFilterTests : AbstractDbTest
.WithLibrary(library)
.Build();
_context.Users.Add(user);
_context.Library.Add(library);
await _context.SaveChangesAsync();
Context.Users.Add(user);
Context.Library.Add(library);
await Context.SaveChangesAsync();
return user;
}
@ -391,7 +391,7 @@ public class SeriesFilterTests : AbstractDbTest
{
await SetupHasAverageRating();
var series = await _context.Series.HasAverageRating(true, FilterComparison.Equal, 100).ToListAsync();
var series = await Context.Series.HasAverageRating(true, FilterComparison.Equal, 100).ToListAsync();
Assert.Single(series);
Assert.Equal("Full", series[0].Name);
}
@ -401,7 +401,7 @@ public class SeriesFilterTests : AbstractDbTest
{
await SetupHasAverageRating();
var series = await _context.Series.HasAverageRating(true, FilterComparison.GreaterThan, 50).ToListAsync();
var series = await Context.Series.HasAverageRating(true, FilterComparison.GreaterThan, 50).ToListAsync();
Assert.Single(series);
Assert.Equal("Full", series[0].Name);
}
@ -411,7 +411,7 @@ public class SeriesFilterTests : AbstractDbTest
{
await SetupHasAverageRating();
var series = await _context.Series.HasAverageRating(true, FilterComparison.GreaterThanEqual, 50).ToListAsync();
var series = await Context.Series.HasAverageRating(true, FilterComparison.GreaterThanEqual, 50).ToListAsync();
Assert.Equal(2, series.Count);
Assert.Contains(series, s => s.Name == "Partial");
Assert.Contains(series, s => s.Name == "Full");
@ -422,7 +422,7 @@ public class SeriesFilterTests : AbstractDbTest
{
await SetupHasAverageRating();
var series = await _context.Series.HasAverageRating(true, FilterComparison.LessThan, 50).ToListAsync();
var series = await Context.Series.HasAverageRating(true, FilterComparison.LessThan, 50).ToListAsync();
Assert.Single(series);
Assert.Equal("None", series[0].Name);
}
@ -432,7 +432,7 @@ public class SeriesFilterTests : AbstractDbTest
{
await SetupHasAverageRating();
var series = await _context.Series.HasAverageRating(true, FilterComparison.LessThanEqual, 50).ToListAsync();
var series = await Context.Series.HasAverageRating(true, FilterComparison.LessThanEqual, 50).ToListAsync();
Assert.Equal(2, series.Count);
Assert.Contains(series, s => s.Name == "None");
Assert.Contains(series, s => s.Name == "Partial");
@ -443,7 +443,7 @@ public class SeriesFilterTests : AbstractDbTest
{
await SetupHasAverageRating();
var series = await _context.Series.HasAverageRating(true, FilterComparison.NotEqual, 100).ToListAsync();
var series = await Context.Series.HasAverageRating(true, FilterComparison.NotEqual, 100).ToListAsync();
Assert.Equal(2, series.Count);
Assert.DoesNotContain(series, s => s.Name == "Full");
}
@ -453,7 +453,7 @@ public class SeriesFilterTests : AbstractDbTest
{
await SetupHasAverageRating();
var series = await _context.Series.HasAverageRating(false, FilterComparison.Equal, 100).ToListAsync();
var series = await Context.Series.HasAverageRating(false, FilterComparison.Equal, 100).ToListAsync();
Assert.Equal(3, series.Count);
}
@ -462,7 +462,7 @@ public class SeriesFilterTests : AbstractDbTest
{
await SetupHasAverageRating();
var series = await _context.Series.HasAverageRating(true, FilterComparison.Equal, -1).ToListAsync();
var series = await Context.Series.HasAverageRating(true, FilterComparison.Equal, -1).ToListAsync();
Assert.Single(series);
Assert.Equal("None", series[0].Name);
}
@ -474,7 +474,7 @@ public class SeriesFilterTests : AbstractDbTest
await Assert.ThrowsAsync<KavitaException>(async () =>
{
await _context.Series.HasAverageRating(true, FilterComparison.Contains, 50).ToListAsync();
await Context.Series.HasAverageRating(true, FilterComparison.Contains, 50).ToListAsync();
});
}
@ -485,7 +485,7 @@ public class SeriesFilterTests : AbstractDbTest
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(async () =>
{
await _context.Series.HasAverageRating(true, (FilterComparison)999, 50).ToListAsync();
await Context.Series.HasAverageRating(true, (FilterComparison)999, 50).ToListAsync();
});
}
@ -519,9 +519,9 @@ public class SeriesFilterTests : AbstractDbTest
.WithLibrary(library)
.Build();
_context.Users.Add(user);
_context.Library.Add(library);
await _context.SaveChangesAsync();
Context.Users.Add(user);
Context.Library.Add(library);
await Context.SaveChangesAsync();
return user;
}
@ -531,7 +531,7 @@ public class SeriesFilterTests : AbstractDbTest
{
await SetupHasPublicationStatus();
var foundSeries = await _context.Series.HasPublicationStatus(true, FilterComparison.Equal, new List<PublicationStatus> { PublicationStatus.Cancelled }).ToListAsync();
var foundSeries = await Context.Series.HasPublicationStatus(true, FilterComparison.Equal, new List<PublicationStatus> { PublicationStatus.Cancelled }).ToListAsync();
Assert.Single(foundSeries);
Assert.Equal("Cancelled", foundSeries[0].Name);
}
@ -541,7 +541,7 @@ public class SeriesFilterTests : AbstractDbTest
{
await SetupHasPublicationStatus();
var foundSeries = await _context.Series.HasPublicationStatus(true, FilterComparison.Contains, new List<PublicationStatus> { PublicationStatus.Cancelled, PublicationStatus.Completed }).ToListAsync();
var foundSeries = await Context.Series.HasPublicationStatus(true, FilterComparison.Contains, new List<PublicationStatus> { PublicationStatus.Cancelled, PublicationStatus.Completed }).ToListAsync();
Assert.Equal(2, foundSeries.Count);
Assert.Contains(foundSeries, s => s.Name == "Cancelled");
Assert.Contains(foundSeries, s => s.Name == "Completed");
@ -552,7 +552,7 @@ public class SeriesFilterTests : AbstractDbTest
{
await SetupHasPublicationStatus();
var foundSeries = await _context.Series.HasPublicationStatus(true, FilterComparison.NotContains, new List<PublicationStatus> { PublicationStatus.Cancelled }).ToListAsync();
var foundSeries = await Context.Series.HasPublicationStatus(true, FilterComparison.NotContains, new List<PublicationStatus> { PublicationStatus.Cancelled }).ToListAsync();
Assert.Equal(2, foundSeries.Count);
Assert.Contains(foundSeries, s => s.Name == "OnGoing");
Assert.Contains(foundSeries, s => s.Name == "Completed");
@ -563,7 +563,7 @@ public class SeriesFilterTests : AbstractDbTest
{
await SetupHasPublicationStatus();
var foundSeries = await _context.Series.HasPublicationStatus(true, FilterComparison.NotEqual, new List<PublicationStatus> { PublicationStatus.OnGoing }).ToListAsync();
var foundSeries = await Context.Series.HasPublicationStatus(true, FilterComparison.NotEqual, new List<PublicationStatus> { PublicationStatus.OnGoing }).ToListAsync();
Assert.Equal(2, foundSeries.Count);
Assert.Contains(foundSeries, s => s.Name == "Cancelled");
Assert.Contains(foundSeries, s => s.Name == "Completed");
@ -574,7 +574,7 @@ public class SeriesFilterTests : AbstractDbTest
{
await SetupHasPublicationStatus();
var foundSeries = await _context.Series.HasPublicationStatus(false, FilterComparison.Equal, new List<PublicationStatus> { PublicationStatus.Cancelled }).ToListAsync();
var foundSeries = await Context.Series.HasPublicationStatus(false, FilterComparison.Equal, new List<PublicationStatus> { PublicationStatus.Cancelled }).ToListAsync();
Assert.Equal(3, foundSeries.Count);
}
@ -583,7 +583,7 @@ public class SeriesFilterTests : AbstractDbTest
{
await SetupHasPublicationStatus();
var foundSeries = await _context.Series.HasPublicationStatus(true, FilterComparison.Equal, new List<PublicationStatus>()).ToListAsync();
var foundSeries = await Context.Series.HasPublicationStatus(true, FilterComparison.Equal, new List<PublicationStatus>()).ToListAsync();
Assert.Equal(3, foundSeries.Count);
}
@ -594,7 +594,7 @@ public class SeriesFilterTests : AbstractDbTest
await Assert.ThrowsAsync<KavitaException>(async () =>
{
await _context.Series.HasPublicationStatus(true, FilterComparison.BeginsWith, new List<PublicationStatus> { PublicationStatus.Cancelled }).ToListAsync();
await Context.Series.HasPublicationStatus(true, FilterComparison.BeginsWith, new List<PublicationStatus> { PublicationStatus.Cancelled }).ToListAsync();
});
}
@ -605,7 +605,7 @@ public class SeriesFilterTests : AbstractDbTest
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(async () =>
{
await _context.Series.HasPublicationStatus(true, (FilterComparison)999, new List<PublicationStatus> { PublicationStatus.Cancelled }).ToListAsync();
await Context.Series.HasPublicationStatus(true, (FilterComparison)999, new List<PublicationStatus> { PublicationStatus.Cancelled }).ToListAsync();
});
}
#endregion
@ -637,9 +637,9 @@ public class SeriesFilterTests : AbstractDbTest
.WithLibrary(library)
.Build();
_context.Users.Add(user);
_context.Library.Add(library);
await _context.SaveChangesAsync();
Context.Users.Add(user);
Context.Library.Add(library);
await Context.SaveChangesAsync();
return user;
}
@ -649,7 +649,7 @@ public class SeriesFilterTests : AbstractDbTest
{
await SetupHasAgeRating();
var foundSeries = await _context.Series.HasAgeRating(true, FilterComparison.Equal, [AgeRating.G]).ToListAsync();
var foundSeries = await Context.Series.HasAgeRating(true, FilterComparison.Equal, [AgeRating.G]).ToListAsync();
Assert.Single(foundSeries);
Assert.Equal("G", foundSeries[0].Name);
}
@ -659,7 +659,7 @@ public class SeriesFilterTests : AbstractDbTest
{
await SetupHasAgeRating();
var foundSeries = await _context.Series.HasAgeRating(true, FilterComparison.Contains, new List<AgeRating> { AgeRating.G, AgeRating.Mature }).ToListAsync();
var foundSeries = await Context.Series.HasAgeRating(true, FilterComparison.Contains, new List<AgeRating> { AgeRating.G, AgeRating.Mature }).ToListAsync();
Assert.Equal(2, foundSeries.Count);
Assert.Contains(foundSeries, s => s.Name == "G");
Assert.Contains(foundSeries, s => s.Name == "Mature");
@ -670,7 +670,7 @@ public class SeriesFilterTests : AbstractDbTest
{
await SetupHasAgeRating();
var foundSeries = await _context.Series.HasAgeRating(true, FilterComparison.NotContains, new List<AgeRating> { AgeRating.Unknown }).ToListAsync();
var foundSeries = await Context.Series.HasAgeRating(true, FilterComparison.NotContains, new List<AgeRating> { AgeRating.Unknown }).ToListAsync();
Assert.Equal(2, foundSeries.Count);
Assert.Contains(foundSeries, s => s.Name == "G");
Assert.Contains(foundSeries, s => s.Name == "Mature");
@ -681,7 +681,7 @@ public class SeriesFilterTests : AbstractDbTest
{
await SetupHasAgeRating();
var foundSeries = await _context.Series.HasAgeRating(true, FilterComparison.NotEqual, new List<AgeRating> { AgeRating.G }).ToListAsync();
var foundSeries = await Context.Series.HasAgeRating(true, FilterComparison.NotEqual, new List<AgeRating> { AgeRating.G }).ToListAsync();
Assert.Equal(2, foundSeries.Count);
Assert.Contains(foundSeries, s => s.Name == "Unknown");
Assert.Contains(foundSeries, s => s.Name == "Mature");
@ -692,7 +692,7 @@ public class SeriesFilterTests : AbstractDbTest
{
await SetupHasAgeRating();
var foundSeries = await _context.Series.HasAgeRating(true, FilterComparison.GreaterThan, new List<AgeRating> { AgeRating.Unknown }).ToListAsync();
var foundSeries = await Context.Series.HasAgeRating(true, FilterComparison.GreaterThan, new List<AgeRating> { AgeRating.Unknown }).ToListAsync();
Assert.Equal(2, foundSeries.Count);
Assert.Contains(foundSeries, s => s.Name == "G");
Assert.Contains(foundSeries, s => s.Name == "Mature");
@ -703,7 +703,7 @@ public class SeriesFilterTests : AbstractDbTest
{
await SetupHasAgeRating();
var foundSeries = await _context.Series.HasAgeRating(true, FilterComparison.GreaterThanEqual, new List<AgeRating> { AgeRating.G }).ToListAsync();
var foundSeries = await Context.Series.HasAgeRating(true, FilterComparison.GreaterThanEqual, new List<AgeRating> { AgeRating.G }).ToListAsync();
Assert.Equal(2, foundSeries.Count);
Assert.Contains(foundSeries, s => s.Name == "G");
Assert.Contains(foundSeries, s => s.Name == "Mature");
@ -714,7 +714,7 @@ public class SeriesFilterTests : AbstractDbTest
{
await SetupHasAgeRating();
var foundSeries = await _context.Series.HasAgeRating(true, FilterComparison.LessThan, new List<AgeRating> { AgeRating.Mature }).ToListAsync();
var foundSeries = await Context.Series.HasAgeRating(true, FilterComparison.LessThan, new List<AgeRating> { AgeRating.Mature }).ToListAsync();
Assert.Equal(2, foundSeries.Count);
Assert.Contains(foundSeries, s => s.Name == "Unknown");
Assert.Contains(foundSeries, s => s.Name == "G");
@ -725,7 +725,7 @@ public class SeriesFilterTests : AbstractDbTest
{
await SetupHasAgeRating();
var foundSeries = await _context.Series.HasAgeRating(true, FilterComparison.LessThanEqual, new List<AgeRating> { AgeRating.G }).ToListAsync();
var foundSeries = await Context.Series.HasAgeRating(true, FilterComparison.LessThanEqual, new List<AgeRating> { AgeRating.G }).ToListAsync();
Assert.Equal(2, foundSeries.Count);
Assert.Contains(foundSeries, s => s.Name == "Unknown");
Assert.Contains(foundSeries, s => s.Name == "G");
@ -736,7 +736,7 @@ public class SeriesFilterTests : AbstractDbTest
{
await SetupHasAgeRating();
var foundSeries = await _context.Series.HasAgeRating(false, FilterComparison.Equal, new List<AgeRating> { AgeRating.G }).ToListAsync();
var foundSeries = await Context.Series.HasAgeRating(false, FilterComparison.Equal, new List<AgeRating> { AgeRating.G }).ToListAsync();
Assert.Equal(3, foundSeries.Count);
}
@ -745,7 +745,7 @@ public class SeriesFilterTests : AbstractDbTest
{
await SetupHasAgeRating();
var foundSeries = await _context.Series.HasAgeRating(true, FilterComparison.Equal, new List<AgeRating>()).ToListAsync();
var foundSeries = await Context.Series.HasAgeRating(true, FilterComparison.Equal, new List<AgeRating>()).ToListAsync();
Assert.Equal(3, foundSeries.Count);
}
@ -756,7 +756,7 @@ public class SeriesFilterTests : AbstractDbTest
await Assert.ThrowsAsync<KavitaException>(async () =>
{
await _context.Series.HasAgeRating(true, FilterComparison.BeginsWith, new List<AgeRating> { AgeRating.G }).ToListAsync();
await Context.Series.HasAgeRating(true, FilterComparison.BeginsWith, new List<AgeRating> { AgeRating.G }).ToListAsync();
});
}
@ -767,7 +767,7 @@ public class SeriesFilterTests : AbstractDbTest
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(async () =>
{
await _context.Series.HasAgeRating(true, (FilterComparison)999, new List<AgeRating> { AgeRating.G }).ToListAsync();
await Context.Series.HasAgeRating(true, (FilterComparison)999, new List<AgeRating> { AgeRating.G }).ToListAsync();
});
}
@ -801,9 +801,9 @@ public class SeriesFilterTests : AbstractDbTest
.WithLibrary(library)
.Build();
_context.Users.Add(user);
_context.Library.Add(library);
await _context.SaveChangesAsync();
Context.Users.Add(user);
Context.Library.Add(library);
await Context.SaveChangesAsync();
return user;
}
@ -813,7 +813,7 @@ public class SeriesFilterTests : AbstractDbTest
{
await SetupHasReleaseYear();
var foundSeries = await _context.Series.HasReleaseYear(true, FilterComparison.Equal, 2020).ToListAsync();
var foundSeries = await Context.Series.HasReleaseYear(true, FilterComparison.Equal, 2020).ToListAsync();
Assert.Single(foundSeries);
Assert.Equal("2020", foundSeries[0].Name);
}
@ -823,7 +823,7 @@ public class SeriesFilterTests : AbstractDbTest
{
await SetupHasReleaseYear();
var foundSeries = await _context.Series.HasReleaseYear(true, FilterComparison.GreaterThan, 2000).ToListAsync();
var foundSeries = await Context.Series.HasReleaseYear(true, FilterComparison.GreaterThan, 2000).ToListAsync();
Assert.Equal(2, foundSeries.Count);
Assert.Contains(foundSeries, s => s.Name == "2020");
Assert.Contains(foundSeries, s => s.Name == "2025");
@ -834,7 +834,7 @@ public class SeriesFilterTests : AbstractDbTest
{
await SetupHasReleaseYear();
var foundSeries = await _context.Series.HasReleaseYear(true, FilterComparison.LessThan, 2025).ToListAsync();
var foundSeries = await Context.Series.HasReleaseYear(true, FilterComparison.LessThan, 2025).ToListAsync();
Assert.Equal(2, foundSeries.Count);
Assert.Contains(foundSeries, s => s.Name == "2000");
Assert.Contains(foundSeries, s => s.Name == "2020");
@ -845,7 +845,7 @@ public class SeriesFilterTests : AbstractDbTest
{
await SetupHasReleaseYear();
var foundSeries = await _context.Series.HasReleaseYear(true, FilterComparison.IsInLast, 5).ToListAsync();
var foundSeries = await Context.Series.HasReleaseYear(true, FilterComparison.IsInLast, 5).ToListAsync();
Assert.Equal(2, foundSeries.Count);
}
@ -854,7 +854,7 @@ public class SeriesFilterTests : AbstractDbTest
{
await SetupHasReleaseYear();
var foundSeries = await _context.Series.HasReleaseYear(true, FilterComparison.IsNotInLast, 5).ToListAsync();
var foundSeries = await Context.Series.HasReleaseYear(true, FilterComparison.IsNotInLast, 5).ToListAsync();
Assert.Single(foundSeries);
Assert.Contains(foundSeries, s => s.Name == "2000");
}
@ -864,7 +864,7 @@ public class SeriesFilterTests : AbstractDbTest
{
await SetupHasReleaseYear();
var foundSeries = await _context.Series.HasReleaseYear(false, FilterComparison.Equal, 2020).ToListAsync();
var foundSeries = await Context.Series.HasReleaseYear(false, FilterComparison.Equal, 2020).ToListAsync();
Assert.Equal(3, foundSeries.Count);
}
@ -873,7 +873,7 @@ public class SeriesFilterTests : AbstractDbTest
{
await SetupHasReleaseYear();
var foundSeries = await _context.Series.HasReleaseYear(true, FilterComparison.Equal, null).ToListAsync();
var foundSeries = await Context.Series.HasReleaseYear(true, FilterComparison.Equal, null).ToListAsync();
Assert.Equal(3, foundSeries.Count);
}
@ -889,10 +889,10 @@ public class SeriesFilterTests : AbstractDbTest
.Build())
.Build();
_context.Library.Add(library);
await _context.SaveChangesAsync();
Context.Library.Add(library);
await Context.SaveChangesAsync();
var foundSeries = await _context.Series.HasReleaseYear(true, FilterComparison.IsEmpty, 0).ToListAsync();
var foundSeries = await Context.Series.HasReleaseYear(true, FilterComparison.IsEmpty, 0).ToListAsync();
Assert.Single(foundSeries);
Assert.Equal("EmptyYear", foundSeries[0].Name);
}
@ -925,29 +925,26 @@ public class SeriesFilterTests : AbstractDbTest
.WithLibrary(library)
.Build();
_context.Users.Add(user);
_context.Library.Add(library);
await _context.SaveChangesAsync();
Context.Users.Add(user);
Context.Library.Add(library);
await Context.SaveChangesAsync();
var seriesService = new SeriesService(_unitOfWork, Substitute.For<IEventHub>(),
Substitute.For<ITaskScheduler>(), Substitute.For<ILogger<SeriesService>>(),
Substitute.For<IScrobblingService>(), Substitute.For<ILocalizationService>());
var ratingService = new RatingService(UnitOfWork, Substitute.For<IScrobblingService>(), Substitute.For<ILogger<RatingService>>());
// Select 0 Rating
var zeroRating = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(2);
var zeroRating = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(2);
Assert.NotNull(zeroRating);
Assert.True(await seriesService.UpdateRating(user, new UpdateSeriesRatingDto()
Assert.True(await ratingService.UpdateSeriesRating(user, new UpdateRatingDto()
{
SeriesId = zeroRating.Id,
UserRating = 0
}));
// Select 4.5 Rating
var partialRating = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(3);
var partialRating = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(3);
Assert.True(await seriesService.UpdateRating(user, new UpdateSeriesRatingDto()
Assert.True(await ratingService.UpdateSeriesRating(user, new UpdateRatingDto()
{
SeriesId = partialRating.Id,
UserRating = 4.5f
@ -961,7 +958,7 @@ public class SeriesFilterTests : AbstractDbTest
{
var user = await SetupHasRating();
var foundSeries = await _context.Series
var foundSeries = await Context.Series
.HasRating(true, FilterComparison.Equal, 4.5f, user.Id)
.ToListAsync();
@ -974,7 +971,7 @@ public class SeriesFilterTests : AbstractDbTest
{
var user = await SetupHasRating();
var foundSeries = await _context.Series
var foundSeries = await Context.Series
.HasRating(true, FilterComparison.GreaterThan, 0, user.Id)
.ToListAsync();
@ -987,7 +984,7 @@ public class SeriesFilterTests : AbstractDbTest
{
var user = await SetupHasRating();
var foundSeries = await _context.Series
var foundSeries = await Context.Series
.HasRating(true, FilterComparison.LessThan, 4.5f, user.Id)
.ToListAsync();
@ -1000,7 +997,7 @@ public class SeriesFilterTests : AbstractDbTest
{
var user = await SetupHasRating();
var foundSeries = await _context.Series
var foundSeries = await Context.Series
.HasRating(true, FilterComparison.IsEmpty, 0, user.Id)
.ToListAsync();
@ -1013,7 +1010,7 @@ public class SeriesFilterTests : AbstractDbTest
{
var user = await SetupHasRating();
var foundSeries = await _context.Series
var foundSeries = await Context.Series
.HasRating(true, FilterComparison.GreaterThanEqual, 4.5f, user.Id)
.ToListAsync();
@ -1026,7 +1023,7 @@ public class SeriesFilterTests : AbstractDbTest
{
var user = await SetupHasRating();
var foundSeries = await _context.Series
var foundSeries = await Context.Series
.HasRating(true, FilterComparison.LessThanEqual, 0, user.Id)
.ToListAsync();
@ -1104,9 +1101,9 @@ public class SeriesFilterTests : AbstractDbTest
.WithLibrary(library)
.Build();
_context.Users.Add(user);
_context.Library.Add(library);
await _context.SaveChangesAsync();
Context.Users.Add(user);
Context.Library.Add(library);
await Context.SaveChangesAsync();
return user;
}
@ -1116,7 +1113,7 @@ public class SeriesFilterTests : AbstractDbTest
{
await SetupHasName();
var foundSeries = await _context.Series
var foundSeries = await Context.Series
.HasName(true, FilterComparison.Equal, "My Dress-Up Darling")
.ToListAsync();
@ -1129,7 +1126,7 @@ public class SeriesFilterTests : AbstractDbTest
{
await SetupHasName();
var foundSeries = await _context.Series
var foundSeries = await Context.Series
.HasName(true, FilterComparison.Equal, "Ijiranaide, Nagatoro-san")
.ToListAsync();
@ -1142,7 +1139,7 @@ public class SeriesFilterTests : AbstractDbTest
{
await SetupHasName();
var foundSeries = await _context.Series
var foundSeries = await Context.Series
.HasName(true, FilterComparison.BeginsWith, "My Dress")
.ToListAsync();
@ -1155,7 +1152,7 @@ public class SeriesFilterTests : AbstractDbTest
{
await SetupHasName();
var foundSeries = await _context.Series
var foundSeries = await Context.Series
.HasName(true, FilterComparison.BeginsWith, "Sono Bisque")
.ToListAsync();
@ -1168,7 +1165,7 @@ public class SeriesFilterTests : AbstractDbTest
{
await SetupHasName();
var foundSeries = await _context.Series
var foundSeries = await Context.Series
.HasName(true, FilterComparison.EndsWith, "Nagatoro")
.ToListAsync();
@ -1181,7 +1178,7 @@ public class SeriesFilterTests : AbstractDbTest
{
await SetupHasName();
var foundSeries = await _context.Series
var foundSeries = await Context.Series
.HasName(true, FilterComparison.Matches, "Toy With Me")
.ToListAsync();
@ -1194,7 +1191,7 @@ public class SeriesFilterTests : AbstractDbTest
{
await SetupHasName();
var foundSeries = await _context.Series
var foundSeries = await Context.Series
.HasName(true, FilterComparison.NotEqual, "My Dress-Up Darling")
.ToListAsync();
@ -1238,9 +1235,9 @@ public class SeriesFilterTests : AbstractDbTest
.WithLibrary(library)
.Build();
_context.Users.Add(user);
_context.Library.Add(library);
await _context.SaveChangesAsync();
Context.Users.Add(user);
Context.Library.Add(library);
await Context.SaveChangesAsync();
return user;
}
@ -1250,7 +1247,7 @@ public class SeriesFilterTests : AbstractDbTest
{
await SetupHasSummary();
var foundSeries = await _context.Series
var foundSeries = await Context.Series
.HasSummary(true, FilterComparison.Equal, "I like hippos")
.ToListAsync();
@ -1263,7 +1260,7 @@ public class SeriesFilterTests : AbstractDbTest
{
await SetupHasSummary();
var foundSeries = await _context.Series
var foundSeries = await Context.Series
.HasSummary(true, FilterComparison.BeginsWith, "I like h")
.ToListAsync();
@ -1276,7 +1273,7 @@ public class SeriesFilterTests : AbstractDbTest
{
await SetupHasSummary();
var foundSeries = await _context.Series
var foundSeries = await Context.Series
.HasSummary(true, FilterComparison.EndsWith, "apples")
.ToListAsync();
@ -1289,7 +1286,7 @@ public class SeriesFilterTests : AbstractDbTest
{
await SetupHasSummary();
var foundSeries = await _context.Series
var foundSeries = await Context.Series
.HasSummary(true, FilterComparison.Matches, "like ducks")
.ToListAsync();
@ -1302,7 +1299,7 @@ public class SeriesFilterTests : AbstractDbTest
{
await SetupHasSummary();
var foundSeries = await _context.Series
var foundSeries = await Context.Series
.HasSummary(true, FilterComparison.NotEqual, "I like ducks")
.ToListAsync();
@ -1315,7 +1312,7 @@ public class SeriesFilterTests : AbstractDbTest
{
await SetupHasSummary();
var foundSeries = await _context.Series
var foundSeries = await Context.Series
.HasSummary(true, FilterComparison.IsEmpty, string.Empty)
.ToListAsync();

View file

@ -0,0 +1,81 @@
using System;
using API.Extensions;
using Xunit;
namespace API.Tests.Extensions;
public class VersionHelperTests
{
[Fact]
public void CompareWithoutRevision_ShouldReturnTrue_WhenMajorMinorBuildMatch()
{
// Arrange
var v1 = new Version(1, 2, 3, 4);
var v2 = new Version(1, 2, 3, 5);
// Act
var result = v1.CompareWithoutRevision(v2);
// Assert
Assert.True(result);
}
[Fact]
public void CompareWithoutRevision_ShouldHandleBuildlessVersions()
{
// Arrange
var v1 = new Version(1, 2);
var v2 = new Version(1, 2);
// Act
var result = v1.CompareWithoutRevision(v2);
// Assert
Assert.True(result);
}
[Theory]
[InlineData(1, 2, 3, 1, 2, 4)]
[InlineData(1, 2, 3, 1, 2, 0)]
public void CompareWithoutRevision_ShouldReturnFalse_WhenBuildDiffers(
int major1, int minor1, int build1,
int major2, int minor2, int build2)
{
var v1 = new Version(major1, minor1, build1);
var v2 = new Version(major2, minor2, build2);
var result = v1.CompareWithoutRevision(v2);
Assert.False(result);
}
[Theory]
[InlineData(1, 2, 3, 1, 3, 3)]
[InlineData(1, 2, 3, 1, 0, 3)]
public void CompareWithoutRevision_ShouldReturnFalse_WhenMinorDiffers(
int major1, int minor1, int build1,
int major2, int minor2, int build2)
{
var v1 = new Version(major1, minor1, build1);
var v2 = new Version(major2, minor2, build2);
var result = v1.CompareWithoutRevision(v2);
Assert.False(result);
}
[Theory]
[InlineData(1, 2, 3, 2, 2, 3)]
[InlineData(1, 2, 3, 0, 2, 3)]
public void CompareWithoutRevision_ShouldReturnFalse_WhenMajorDiffers(
int major1, int minor1, int build1,
int major2, int minor2, int build2)
{
var v1 = new Version(major1, minor1, build1);
var v2 = new Version(major2, minor2, build2);
var result = v1.CompareWithoutRevision(v2);
Assert.False(result);
}
}

View file

@ -3,7 +3,6 @@ using API.Entities;
using API.Entities.Enums;
using API.Extensions;
using API.Helpers.Builders;
using API.Tests.Helpers;
using Xunit;
namespace API.Tests.Extensions;

View file

@ -0,0 +1,178 @@
using API.Helpers;
using Xunit;
namespace API.Tests.Helpers;
public class BookSortTitlePrefixHelperTests
{
[Theory]
[InlineData("The Avengers", "Avengers")]
[InlineData("A Game of Thrones", "Game of Thrones")]
[InlineData("An American Tragedy", "American Tragedy")]
public void TestEnglishPrefixes(string inputString, string expected)
{
Assert.Equal(expected, BookSortTitlePrefixHelper.GetSortTitle(inputString));
}
[Theory]
[InlineData("El Quijote", "Quijote")]
[InlineData("La Casa de Papel", "Casa de Papel")]
[InlineData("Los Miserables", "Miserables")]
[InlineData("Las Vegas", "Vegas")]
[InlineData("Un Mundo Feliz", "Mundo Feliz")]
[InlineData("Una Historia", "Historia")]
public void TestSpanishPrefixes(string inputString, string expected)
{
Assert.Equal(expected, BookSortTitlePrefixHelper.GetSortTitle(inputString));
}
[Theory]
[InlineData("Le Petit Prince", "Petit Prince")]
[InlineData("La Belle et la Bête", "Belle et la Bête")]
[InlineData("Les Misérables", "Misérables")]
[InlineData("Un Amour de Swann", "Amour de Swann")]
[InlineData("Une Vie", "Vie")]
[InlineData("Des Souris et des Hommes", "Souris et des Hommes")]
public void TestFrenchPrefixes(string inputString, string expected)
{
Assert.Equal(expected, BookSortTitlePrefixHelper.GetSortTitle(inputString));
}
[Theory]
[InlineData("Der Herr der Ringe", "Herr der Ringe")]
[InlineData("Die Verwandlung", "Verwandlung")]
[InlineData("Das Kapital", "Kapital")]
[InlineData("Ein Sommernachtstraum", "Sommernachtstraum")]
[InlineData("Eine Geschichte", "Geschichte")]
public void TestGermanPrefixes(string inputString, string expected)
{
Assert.Equal(expected, BookSortTitlePrefixHelper.GetSortTitle(inputString));
}
[Theory]
[InlineData("Il Nome della Rosa", "Nome della Rosa")]
[InlineData("La Divina Commedia", "Divina Commedia")]
[InlineData("Lo Hobbit", "Hobbit")]
[InlineData("Gli Ultimi", "Ultimi")]
[InlineData("Le Città Invisibili", "Città Invisibili")]
[InlineData("Un Giorno", "Giorno")]
[InlineData("Una Notte", "Notte")]
public void TestItalianPrefixes(string inputString, string expected)
{
Assert.Equal(expected, BookSortTitlePrefixHelper.GetSortTitle(inputString));
}
[Theory]
[InlineData("O Alquimista", "Alquimista")]
[InlineData("A Moreninha", "Moreninha")]
[InlineData("Os Lusíadas", "Lusíadas")]
[InlineData("As Meninas", "Meninas")]
[InlineData("Um Defeito de Cor", "Defeito de Cor")]
[InlineData("Uma História", "História")]
public void TestPortuguesePrefixes(string inputString, string expected)
{
Assert.Equal(expected, BookSortTitlePrefixHelper.GetSortTitle(inputString));
}
[Theory]
[InlineData("", "")] // Empty string returns empty
[InlineData("Book", "Book")] // Single word, no change
[InlineData("Avengers", "Avengers")] // No prefix, no change
public void TestNoPrefixCases(string inputString, string expected)
{
Assert.Equal(expected, BookSortTitlePrefixHelper.GetSortTitle(inputString));
}
[Theory]
[InlineData("The", "The")] // Just a prefix word alone
[InlineData("A", "A")] // Just single letter prefix alone
[InlineData("Le", "Le")] // French prefix alone
public void TestPrefixWordAlone(string inputString, string expected)
{
Assert.Equal(expected, BookSortTitlePrefixHelper.GetSortTitle(inputString));
}
[Theory]
[InlineData("THE AVENGERS", "AVENGERS")] // All caps
[InlineData("the avengers", "avengers")] // All lowercase
[InlineData("The AVENGERS", "AVENGERS")] // Mixed case
[InlineData("tHe AvEnGeRs", "AvEnGeRs")] // Random case
public void TestCaseInsensitivity(string inputString, string expected)
{
Assert.Equal(expected, BookSortTitlePrefixHelper.GetSortTitle(inputString));
}
[Theory]
[InlineData("Then Came You", "Then Came You")] // "The" + "n" = not a prefix
[InlineData("And Then There Were None", "And Then There Were None")] // "An" + "d" = not a prefix
[InlineData("Elsewhere", "Elsewhere")] // "El" + "sewhere" = not a prefix (no space)
[InlineData("Lesson Plans", "Lesson Plans")] // "Les" + "son" = not a prefix (no space)
[InlineData("Theory of Everything", "Theory of Everything")] // "The" + "ory" = not a prefix
public void TestFalsePositivePrefixes(string inputString, string expected)
{
Assert.Equal(expected, BookSortTitlePrefixHelper.GetSortTitle(inputString));
}
[Theory]
[InlineData("The ", "The ")] // Prefix with only space after - returns original
[InlineData("La ", "La ")] // Same for other languages
[InlineData("El ", "El ")] // Same for Spanish
public void TestPrefixWithOnlySpaceAfter(string inputString, string expected)
{
Assert.Equal(expected, BookSortTitlePrefixHelper.GetSortTitle(inputString));
}
[Theory]
[InlineData("The Multiple Spaces", " Multiple Spaces")] // Doesn't trim extra spaces from remainder
[InlineData("Le Petit Prince", " Petit Prince")] // Leading space preserved in remainder
public void TestSpaceHandling(string inputString, string expected)
{
Assert.Equal(expected, BookSortTitlePrefixHelper.GetSortTitle(inputString));
}
[Theory]
[InlineData("The The Matrix", "The Matrix")] // Removes first "The", leaves second
[InlineData("A A Clockwork Orange", "A Clockwork Orange")] // Removes first "A", leaves second
[InlineData("El El Cid", "El Cid")] // Spanish version
public void TestRepeatedPrefixes(string inputString, string expected)
{
Assert.Equal(expected, BookSortTitlePrefixHelper.GetSortTitle(inputString));
}
[Theory]
[InlineData("L'Étranger", "L'Étranger")] // French contraction - no space, no change
[InlineData("D'Artagnan", "D'Artagnan")] // Contraction - no space, no change
[InlineData("The-Matrix", "The-Matrix")] // Hyphen instead of space - no change
[InlineData("The.Avengers", "The.Avengers")] // Period instead of space - no change
public void TestNonSpaceSeparators(string inputString, string expected)
{
Assert.Equal(expected, BookSortTitlePrefixHelper.GetSortTitle(inputString));
}
[Theory]
[InlineData("三国演义", "三国演义")] // Chinese - no processing due to CJK detection
[InlineData("한국어", "한국어")] // Korean - not in CJK range, would be processed normally
public void TestCjkLanguages(string inputString, string expected)
{
// NOTE: These don't do anything, I am waiting for user input on if these are needed
Assert.Equal(expected, BookSortTitlePrefixHelper.GetSortTitle(inputString));
}
[Theory]
[InlineData("नमस्ते दुनिया", "नमस्ते दुनिया")] // Hindi - not CJK, processed normally
[InlineData("مرحبا بالعالم", "مرحبا بالعالم")] // Arabic - not CJK, processed normally
[InlineData("שלום עולם", "שלום עולם")] // Hebrew - not CJK, processed normally
public void TestNonLatinNonCjkScripts(string inputString, string expected)
{
Assert.Equal(expected, BookSortTitlePrefixHelper.GetSortTitle(inputString));
}
[Theory]
[InlineData("в мире", "мире")] // Russian "в" (in) - should be removed
[InlineData("на столе", "столе")] // Russian "на" (on) - should be removed
[InlineData("с друзьями", "друзьями")] // Russian "с" (with) - should be removed
public void TestRussianPrefixes(string inputString, string expected)
{
Assert.Equal(expected, BookSortTitlePrefixHelper.GetSortTitle(inputString));
}
}

View file

@ -2,7 +2,6 @@
using System.Collections.Generic;
using System.IO;
using System.IO.Abstractions.TestingHelpers;
using API.Entities;
using API.Entities.Enums;
using API.Helpers;
using API.Helpers.Builders;
@ -11,9 +10,9 @@ using Xunit;
namespace API.Tests.Helpers;
public class CacheHelperTests
public class CacheHelperTests: AbstractFsTest
{
private const string TestCoverImageDirectory = @"c:\";
private static readonly string TestCoverImageDirectory = Root;
private const string TestCoverImageFile = "thumbnail.jpg";
private readonly string _testCoverPath = Path.Join(TestCoverImageDirectory, TestCoverImageFile);
private const string TestCoverArchive = @"file in folder.zip";
@ -37,24 +36,29 @@ public class CacheHelperTests
[Theory]
[InlineData("", false)]
[InlineData("C:/", false)]
[InlineData(null, false)]
public void CoverImageExists_DoesFileExist(string coverImage, bool exists)
{
Assert.Equal(exists, _cacheHelper.CoverImageExists(coverImage));
}
[Fact]
public void CoverImageExists_DoesFileExistRoot()
{
Assert.False(_cacheHelper.CoverImageExists(Root));
}
[Fact]
public void CoverImageExists_FileExists()
{
Assert.True(_cacheHelper.CoverImageExists(TestCoverArchive));
Assert.True(_cacheHelper.CoverImageExists(Path.Join(TestCoverImageDirectory, TestCoverArchive)));
}
[Fact]
public void ShouldUpdateCoverImage_OnFirstRun()
{
var file = new MangaFileBuilder(TestCoverArchive, MangaFormat.Archive)
var file = new MangaFileBuilder(Path.Join(TestCoverImageDirectory, TestCoverArchive), MangaFormat.Archive)
.WithLastModified(DateTime.Now)
.Build();
Assert.True(_cacheHelper.ShouldUpdateCoverImage(null, file, DateTime.Now.Subtract(TimeSpan.FromMinutes(1)),
@ -65,7 +69,7 @@ public class CacheHelperTests
public void ShouldUpdateCoverImage_ShouldNotUpdateOnSecondRunWithCoverImageSetNotLocked()
{
// Represents first run
var file = new MangaFileBuilder(TestCoverArchive, MangaFormat.Archive)
var file = new MangaFileBuilder(Path.Join(TestCoverImageDirectory, TestCoverArchive), MangaFormat.Archive)
.WithLastModified(DateTime.Now)
.Build();
Assert.False(_cacheHelper.ShouldUpdateCoverImage(_testCoverPath, file, DateTime.Now.Subtract(TimeSpan.FromMinutes(1)),
@ -76,7 +80,7 @@ public class CacheHelperTests
public void ShouldUpdateCoverImage_ShouldNotUpdateOnSecondRunWithCoverImageSetNotLocked_2()
{
// Represents first run
var file = new MangaFileBuilder(TestCoverArchive, MangaFormat.Archive)
var file = new MangaFileBuilder(Path.Join(TestCoverImageDirectory, TestCoverArchive), MangaFormat.Archive)
.WithLastModified(DateTime.Now)
.Build();
Assert.False(_cacheHelper.ShouldUpdateCoverImage(_testCoverPath, file, DateTime.Now,
@ -87,7 +91,7 @@ public class CacheHelperTests
public void ShouldUpdateCoverImage_ShouldNotUpdateOnSecondRunWithCoverImageSetLocked()
{
// Represents first run
var file = new MangaFileBuilder(TestCoverArchive, MangaFormat.Archive)
var file = new MangaFileBuilder(Path.Join(TestCoverImageDirectory, TestCoverArchive), MangaFormat.Archive)
.WithLastModified(DateTime.Now)
.Build();
Assert.False(_cacheHelper.ShouldUpdateCoverImage(_testCoverPath, file, DateTime.Now.Subtract(TimeSpan.FromMinutes(1)),
@ -98,7 +102,7 @@ public class CacheHelperTests
public void ShouldUpdateCoverImage_ShouldNotUpdateOnSecondRunWithCoverImageSetLocked_Modified()
{
// Represents first run
var file = new MangaFileBuilder(TestCoverArchive, MangaFormat.Archive)
var file = new MangaFileBuilder(Path.Join(TestCoverImageDirectory, TestCoverArchive), MangaFormat.Archive)
.WithLastModified(DateTime.Now)
.Build();
Assert.False(_cacheHelper.ShouldUpdateCoverImage(_testCoverPath, file, DateTime.Now.Subtract(TimeSpan.FromMinutes(1)),
@ -122,7 +126,7 @@ public class CacheHelperTests
var cacheHelper = new CacheHelper(fileService);
var created = DateTime.Now.Subtract(TimeSpan.FromHours(1));
var file = new MangaFileBuilder(TestCoverArchive, MangaFormat.Archive)
var file = new MangaFileBuilder(Path.Join(TestCoverImageDirectory, TestCoverArchive), MangaFormat.Archive)
.WithLastModified(DateTime.Now.Subtract(TimeSpan.FromMinutes(1)))
.Build();
@ -133,9 +137,10 @@ public class CacheHelperTests
[Fact]
public void HasFileNotChangedSinceCreationOrLastScan_NotChangedSinceCreated()
{
var now = DateTimeOffset.Now;
var filesystemFile = new MockFileData("")
{
LastWriteTime = DateTimeOffset.Now
LastWriteTime =now,
};
var fileSystem = new MockFileSystem(new Dictionary<string, MockFileData>
{
@ -147,12 +152,12 @@ public class CacheHelperTests
var cacheHelper = new CacheHelper(fileService);
var chapter = new ChapterBuilder("1")
.WithLastModified(filesystemFile.LastWriteTime.DateTime)
.WithCreated(filesystemFile.LastWriteTime.DateTime)
.WithLastModified(now.DateTime)
.WithCreated(now.DateTime)
.Build();
var file = new MangaFileBuilder(TestCoverArchive, MangaFormat.Archive)
.WithLastModified(filesystemFile.LastWriteTime.DateTime)
var file = new MangaFileBuilder(Path.Join(TestCoverImageDirectory, TestCoverArchive), MangaFormat.Archive)
.WithLastModified(now.DateTime)
.Build();
Assert.True(cacheHelper.IsFileUnmodifiedSinceCreationOrLastScan(chapter, false, file));
}
@ -160,9 +165,10 @@ public class CacheHelperTests
[Fact]
public void HasFileNotChangedSinceCreationOrLastScan_NotChangedSinceLastModified()
{
var now = DateTimeOffset.Now;
var filesystemFile = new MockFileData("")
{
LastWriteTime = DateTimeOffset.Now
LastWriteTime = now,
};
var fileSystem = new MockFileSystem(new Dictionary<string, MockFileData>
{
@ -174,12 +180,12 @@ public class CacheHelperTests
var cacheHelper = new CacheHelper(fileService);
var chapter = new ChapterBuilder("1")
.WithLastModified(filesystemFile.LastWriteTime.DateTime)
.WithCreated(filesystemFile.LastWriteTime.DateTime)
.WithLastModified(now.DateTime)
.WithCreated(now.DateTime)
.Build();
var file = new MangaFileBuilder(TestCoverArchive, MangaFormat.Archive)
.WithLastModified(filesystemFile.LastWriteTime.DateTime)
var file = new MangaFileBuilder(Path.Join(TestCoverImageDirectory, TestCoverArchive), MangaFormat.Archive)
.WithLastModified(now.DateTime)
.Build();
Assert.True(cacheHelper.IsFileUnmodifiedSinceCreationOrLastScan(chapter, false, file));
@ -188,9 +194,10 @@ public class CacheHelperTests
[Fact]
public void HasFileNotChangedSinceCreationOrLastScan_NotChangedSinceLastModified_ForceUpdate()
{
var now = DateTimeOffset.Now;
var filesystemFile = new MockFileData("")
{
LastWriteTime = DateTimeOffset.Now
LastWriteTime = now.DateTime,
};
var fileSystem = new MockFileSystem(new Dictionary<string, MockFileData>
{
@ -202,12 +209,12 @@ public class CacheHelperTests
var cacheHelper = new CacheHelper(fileService);
var chapter = new ChapterBuilder("1")
.WithLastModified(filesystemFile.LastWriteTime.DateTime)
.WithCreated(filesystemFile.LastWriteTime.DateTime)
.WithLastModified(now.DateTime)
.WithCreated(now.DateTime)
.Build();
var file = new MangaFileBuilder(TestCoverArchive, MangaFormat.Archive)
.WithLastModified(filesystemFile.LastWriteTime.DateTime)
var file = new MangaFileBuilder(Path.Join(TestCoverImageDirectory, TestCoverArchive), MangaFormat.Archive)
.WithLastModified(now.DateTime)
.Build();
Assert.False(cacheHelper.IsFileUnmodifiedSinceCreationOrLastScan(chapter, true, file));
}
@ -215,10 +222,11 @@ public class CacheHelperTests
[Fact]
public void IsFileUnmodifiedSinceCreationOrLastScan_ModifiedSinceLastScan()
{
var now = DateTimeOffset.Now;
var filesystemFile = new MockFileData("")
{
LastWriteTime = DateTimeOffset.Now,
CreationTime = DateTimeOffset.Now
LastWriteTime = now.DateTime,
CreationTime = now.DateTime
};
var fileSystem = new MockFileSystem(new Dictionary<string, MockFileData>
{
@ -234,8 +242,8 @@ public class CacheHelperTests
.WithCreated(DateTime.Now.Subtract(TimeSpan.FromMinutes(10)))
.Build();
var file = new MangaFileBuilder(TestCoverArchive, MangaFormat.Archive)
.WithLastModified(filesystemFile.LastWriteTime.DateTime)
var file = new MangaFileBuilder(Path.Join(TestCoverImageDirectory, TestCoverArchive), MangaFormat.Archive)
.WithLastModified(now.DateTime)
.Build();
Assert.False(cacheHelper.IsFileUnmodifiedSinceCreationOrLastScan(chapter, false, file));
}
@ -243,9 +251,10 @@ public class CacheHelperTests
[Fact]
public void HasFileNotChangedSinceCreationOrLastScan_ModifiedSinceLastScan_ButLastModifiedSame()
{
var now = DateTimeOffset.Now;
var filesystemFile = new MockFileData("")
{
LastWriteTime = DateTimeOffset.Now
LastWriteTime =now.DateTime
};
var fileSystem = new MockFileSystem(new Dictionary<string, MockFileData>
{
@ -262,7 +271,7 @@ public class CacheHelperTests
.Build();
var file = new MangaFileBuilder(Path.Join(TestCoverImageDirectory, TestCoverArchive), MangaFormat.Archive)
.WithLastModified(filesystemFile.LastWriteTime.DateTime)
.WithLastModified(now.DateTime)
.Build();
Assert.False(cacheHelper.IsFileUnmodifiedSinceCreationOrLastScan(chapter, false, file));

View file

@ -0,0 +1,60 @@
using API.DTOs.Koreader;
using API.DTOs.Progress;
using API.Helpers;
using System.Runtime.CompilerServices;
using Xunit;
namespace API.Tests.Helpers;
public class KoreaderHelperTests
{
[Theory]
[InlineData("/body/DocFragment[11]/body/div/a", 10, null)]
[InlineData("/body/DocFragment[1]/body/div/p[40]", 0, 40)]
[InlineData("/body/DocFragment[8]/body/div/p[28]/text().264", 7, 28)]
public void GetEpubPositionDto(string koreaderPosition, int page, int? pNumber)
{
var expected = EmptyProgressDto();
expected.BookScrollId = pNumber.HasValue ? $"//html[1]/BODY/APP-ROOT[1]/DIV[1]/DIV[1]/DIV[1]/APP-BOOK-READER[1]/DIV[1]/DIV[2]/DIV[1]/DIV[1]/DIV[1]/P[{pNumber}]" : null;
expected.PageNum = page;
var actual = EmptyProgressDto();
KoreaderHelper.UpdateProgressDto(actual, koreaderPosition);
Assert.Equal(expected.BookScrollId, actual.BookScrollId);
Assert.Equal(expected.PageNum, actual.PageNum);
}
[Theory]
[InlineData("//html[1]/BODY/APP-ROOT[1]/DIV[1]/DIV[1]/DIV[1]/APP-BOOK-READER[1]/DIV[1]/DIV[2]/DIV[1]/DIV[1]/DIV[1]/P[20]", 5, "/body/DocFragment[6]/body/div/p[20]")]
[InlineData(null, 10, "/body/DocFragment[11]/body/div/a")]
public void GetKoreaderPosition(string scrollId, int page, string koreaderPosition)
{
var given = EmptyProgressDto();
given.BookScrollId = scrollId;
given.PageNum = page;
Assert.Equal(koreaderPosition, KoreaderHelper.GetKoreaderPosition(given));
}
[Theory]
[InlineData("./Data/AesopsFables.epub", "8795ACA4BF264B57C1EEDF06A0CEE688")]
public void GetKoreaderHash(string filePath, string hash)
{
Assert.Equal(KoreaderHelper.HashContents(filePath), hash);
}
private ProgressDto EmptyProgressDto()
{
return new ProgressDto
{
ChapterId = 0,
PageNum = 0,
VolumeId = 0,
SeriesId = 0,
LibraryId = 0
};
}
}

View file

@ -1,4 +1,5 @@
using System.Collections.Generic;
using System;
using System.Collections.Generic;
using System.Linq;
using API.Entities;
using API.Helpers;
@ -49,17 +50,14 @@ public class OrderableHelperTests
[Fact]
public void ReorderItems_InvalidPosition_NoChange()
{
// Arrange
var items = new List<AppUserSideNavStream>
{
new AppUserSideNavStream { Id = 1, Order = 0, Name = "A" },
new AppUserSideNavStream { Id = 2, Order = 1, Name = "A" },
};
// Act
OrderableHelper.ReorderItems(items, 2, 3); // Position 3 is out of range
// Assert
Assert.Equal(1, items[0].Id); // Item 1 should remain at position 0
Assert.Equal(2, items[1].Id); // Item 2 should remain at position 1
}
@ -80,7 +78,6 @@ public class OrderableHelperTests
[Fact]
public void ReorderItems_DoubleMove()
{
// Arrange
var items = new List<AppUserSideNavStream>
{
new AppUserSideNavStream { Id = 1, Order = 0, Name = "0" },
@ -94,7 +91,6 @@ public class OrderableHelperTests
// Move 4 -> 1
OrderableHelper.ReorderItems(items, 5, 1);
// Assert
Assert.Equal(1, items[0].Id);
Assert.Equal(0, items[0].Order);
Assert.Equal(5, items[1].Id);
@ -109,4 +105,98 @@ public class OrderableHelperTests
Assert.Equal("034125", string.Join("", items.Select(s => s.Name)));
}
private static List<ReadingListItem> CreateTestReadingListItems(int count = 4)
{
var items = new List<ReadingListItem>();
for (var i = 0; i < count; i++)
{
items.Add(new ReadingListItem() { Id = i + 1, Order = count, ReadingListId = i + 1});
}
return items;
}
[Fact]
public void ReorderItems_MoveItemToBeginning_CorrectOrder()
{
var items = CreateTestReadingListItems();
OrderableHelper.ReorderItems(items, 3, 0);
Assert.Equal(3, items[0].Id);
Assert.Equal(1, items[1].Id);
Assert.Equal(2, items[2].Id);
Assert.Equal(4, items[3].Id);
for (var i = 0; i < items.Count; i++)
{
Assert.Equal(i, items[i].Order);
}
}
[Fact]
public void ReorderItems_MoveItemToEnd_CorrectOrder()
{
var items = CreateTestReadingListItems();
OrderableHelper.ReorderItems(items, 1, 3);
Assert.Equal(2, items[0].Id);
Assert.Equal(3, items[1].Id);
Assert.Equal(4, items[2].Id);
Assert.Equal(1, items[3].Id);
for (var i = 0; i < items.Count; i++)
{
Assert.Equal(i, items[i].Order);
}
}
[Fact]
public void ReorderItems_MoveItemToMiddle_CorrectOrder()
{
var items = CreateTestReadingListItems();
OrderableHelper.ReorderItems(items, 4, 2);
Assert.Equal(1, items[0].Id);
Assert.Equal(2, items[1].Id);
Assert.Equal(4, items[2].Id);
Assert.Equal(3, items[3].Id);
for (var i = 0; i < items.Count; i++)
{
Assert.Equal(i, items[i].Order);
}
}
[Fact]
public void ReorderItems_MoveItemToOutOfBoundsPosition_MovesToEnd()
{
var items = CreateTestReadingListItems();
OrderableHelper.ReorderItems(items, 2, 10);
Assert.Equal(1, items[0].Id);
Assert.Equal(3, items[1].Id);
Assert.Equal(4, items[2].Id);
Assert.Equal(2, items[3].Id);
for (var i = 0; i < items.Count; i++)
{
Assert.Equal(i, items[i].Order);
}
}
[Fact]
public void ReorderItems_NegativePosition_ThrowsArgumentException()
{
var items = CreateTestReadingListItems();
Assert.Throws<ArgumentException>(() =>
OrderableHelper.ReorderItems(items, 2, -1)
);
}
}

View file

@ -1,8 +1,5 @@
using System.Collections.Generic;
using API.Entities;
using API.Entities.Enums;
using API.Entities.Metadata;
using API.Extensions;
using API.Helpers;
using API.Helpers.Builders;
using API.Services.Tasks.Scanner;

View file

@ -1,14 +1,9 @@
using System;
using System.Collections.Generic;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using API.Data;
using API.DTOs;
using API.Entities;
using API.Entities.Enums;
using API.Helpers;
using API.Helpers.Builders;
using API.Services.Tasks.Scanner.Parser;
using Xunit;
namespace API.Tests.Helpers;
@ -17,127 +12,215 @@ public class PersonHelperTests : AbstractDbTest
{
protected override async Task ResetDb()
{
_context.Series.RemoveRange(_context.Series.ToList());
await _context.SaveChangesAsync();
Context.Series.RemoveRange(Context.Series.ToList());
Context.Person.RemoveRange(Context.Person.ToList());
Context.Library.RemoveRange(Context.Library.ToList());
Context.Series.RemoveRange(Context.Series.ToList());
await Context.SaveChangesAsync();
}
//
// // 1. Test adding new people and keeping existing ones
// [Fact]
// public async Task UpdateChapterPeopleAsync_AddNewPeople_ExistingPersonRetained()
// {
// var existingPerson = new PersonBuilder("Joe Shmo").Build();
// var chapter = new ChapterBuilder("1").Build();
//
// // Create an existing person and assign them to the series with a role
// var series = new SeriesBuilder("Test 1")
// .WithFormat(MangaFormat.Archive)
// .WithMetadata(new SeriesMetadataBuilder()
// .WithPerson(existingPerson, PersonRole.Editor)
// .Build())
// .WithVolume(new VolumeBuilder("1").WithChapter(chapter).Build())
// .Build();
//
// _unitOfWork.SeriesRepository.Add(series);
// await _unitOfWork.CommitAsync();
//
// // Call UpdateChapterPeopleAsync with one existing and one new person
// await PersonHelper.UpdateChapterPeopleAsync(chapter, new List<string> { "Joe Shmo", "New Person" }, PersonRole.Editor, _unitOfWork);
//
// // Assert existing person retained and new person added
// var people = await _unitOfWork.PersonRepository.GetAllPeople();
// Assert.Contains(people, p => p.Name == "Joe Shmo");
// Assert.Contains(people, p => p.Name == "New Person");
//
// var chapterPeople = chapter.People.Select(cp => cp.Person.Name).ToList();
// Assert.Contains("Joe Shmo", chapterPeople);
// Assert.Contains("New Person", chapterPeople);
// }
//
// // 2. Test removing a person no longer in the list
// [Fact]
// public async Task UpdateChapterPeopleAsync_RemovePeople()
// {
// var existingPerson1 = new PersonBuilder("Joe Shmo").Build();
// var existingPerson2 = new PersonBuilder("Jane Doe").Build();
// var chapter = new ChapterBuilder("1").Build();
//
// var series = new SeriesBuilder("Test 1")
// .WithVolume(new VolumeBuilder("1")
// .WithChapter(new ChapterBuilder("1")
// .WithPerson(existingPerson1, PersonRole.Editor)
// .WithPerson(existingPerson2, PersonRole.Editor)
// .Build())
// .Build())
// .Build();
//
// _unitOfWork.SeriesRepository.Add(series);
// await _unitOfWork.CommitAsync();
//
// // Call UpdateChapterPeopleAsync with only one person
// await PersonHelper.UpdateChapterPeopleAsync(chapter, new List<string> { "Joe Shmo" }, PersonRole.Editor, _unitOfWork);
//
// var people = await _unitOfWork.PersonRepository.GetAllPeople();
// Assert.DoesNotContain(people, p => p.Name == "Jane Doe");
//
// var chapterPeople = chapter.People.Select(cp => cp.Person.Name).ToList();
// Assert.Contains("Joe Shmo", chapterPeople);
// Assert.DoesNotContain("Jane Doe", chapterPeople);
// }
//
// // 3. Test no changes when the list of people is the same
// [Fact]
// public async Task UpdateChapterPeopleAsync_NoChanges()
// {
// var existingPerson = new PersonBuilder("Joe Shmo").Build();
// var chapter = new ChapterBuilder("1").Build();
//
// var series = new SeriesBuilder("Test 1")
// .WithVolume(new VolumeBuilder("1")
// .WithChapter(new ChapterBuilder("1")
// .WithPerson(existingPerson, PersonRole.Editor)
// .Build())
// .Build())
// .Build();
//
// _unitOfWork.SeriesRepository.Add(series);
// await _unitOfWork.CommitAsync();
//
// // Call UpdateChapterPeopleAsync with the same list
// await PersonHelper.UpdateChapterPeopleAsync(chapter, new List<string> { "Joe Shmo" }, PersonRole.Editor, _unitOfWork);
//
// var people = await _unitOfWork.PersonRepository.GetAllPeople();
// Assert.Contains(people, p => p.Name == "Joe Shmo");
//
// var chapterPeople = chapter.People.Select(cp => cp.Person.Name).ToList();
// Assert.Contains("Joe Shmo", chapterPeople);
// Assert.Single(chapter.People); // No duplicate entries
// }
//
// // 4. Test multiple roles for a person
// [Fact]
// public async Task UpdateChapterPeopleAsync_MultipleRoles()
// {
// var person = new PersonBuilder("Joe Shmo").Build();
// var chapter = new ChapterBuilder("1").Build();
//
// var series = new SeriesBuilder("Test 1")
// .WithVolume(new VolumeBuilder("1")
// .WithChapter(new ChapterBuilder("1")
// .WithPerson(person, PersonRole.Writer) // Assign person as Writer
// .Build())
// .Build())
// .Build();
//
// _unitOfWork.SeriesRepository.Add(series);
// await _unitOfWork.CommitAsync();
//
// // Add same person as Editor
// await PersonHelper.UpdateChapterPeopleAsync(chapter, new List<string> { "Joe Shmo" }, PersonRole.Editor, _unitOfWork);
//
// // Ensure that the same person is assigned with two roles
// var chapterPeople = chapter.People.Where(cp => cp.Person.Name == "Joe Shmo").ToList();
// Assert.Equal(2, chapterPeople.Count); // One for each role
// Assert.Contains(chapterPeople, cp => cp.Role == PersonRole.Writer);
// Assert.Contains(chapterPeople, cp => cp.Role == PersonRole.Editor);
// }
// 1. Test adding new people and keeping existing ones
[Fact]
public async Task UpdateChapterPeopleAsync_AddNewPeople_ExistingPersonRetained()
{
await ResetDb();
var library = new LibraryBuilder("My Library")
.Build();
UnitOfWork.LibraryRepository.Add(library);
await UnitOfWork.CommitAsync();
var existingPerson = new PersonBuilder("Joe Shmo").Build();
var chapter = new ChapterBuilder("1").Build();
// Create an existing person and assign them to the series with a role
var series = new SeriesBuilder("Test 1")
.WithLibraryId(library.Id)
.WithFormat(MangaFormat.Archive)
.WithMetadata(new SeriesMetadataBuilder()
.WithPerson(existingPerson, PersonRole.Editor)
.Build())
.WithVolume(new VolumeBuilder("1").WithChapter(chapter).Build())
.Build();
UnitOfWork.SeriesRepository.Add(series);
await UnitOfWork.CommitAsync();
// Call UpdateChapterPeopleAsync with one existing and one new person
await PersonHelper.UpdateChapterPeopleAsync(chapter, new List<string> { "Joe Shmo", "New Person" }, PersonRole.Editor, UnitOfWork);
// Assert existing person retained and new person added
var people = await UnitOfWork.PersonRepository.GetAllPeople();
Assert.Contains(people, p => p.Name == "Joe Shmo");
Assert.Contains(people, p => p.Name == "New Person");
var chapterPeople = chapter.People.Select(cp => cp.Person.Name).ToList();
Assert.Contains("Joe Shmo", chapterPeople);
Assert.Contains("New Person", chapterPeople);
}
// 2. Test removing a person no longer in the list
[Fact]
public async Task UpdateChapterPeopleAsync_RemovePeople()
{
await ResetDb();
var library = new LibraryBuilder("My Library")
.Build();
UnitOfWork.LibraryRepository.Add(library);
await UnitOfWork.CommitAsync();
var existingPerson1 = new PersonBuilder("Joe Shmo").Build();
var existingPerson2 = new PersonBuilder("Jane Doe").Build();
var chapter = new ChapterBuilder("1")
.WithPerson(existingPerson1, PersonRole.Editor)
.WithPerson(existingPerson2, PersonRole.Editor)
.Build();
var series = new SeriesBuilder("Test 1")
.WithLibraryId(library.Id)
.WithVolume(new VolumeBuilder("1")
.WithChapter(chapter)
.Build())
.Build();
UnitOfWork.SeriesRepository.Add(series);
await UnitOfWork.CommitAsync();
// Call UpdateChapterPeopleAsync with only one person
await PersonHelper.UpdateChapterPeopleAsync(chapter, new List<string> { "Joe Shmo" }, PersonRole.Editor, UnitOfWork);
// PersonHelper does not remove the Person from the global DbSet itself
await UnitOfWork.PersonRepository.RemoveAllPeopleNoLongerAssociated();
var people = await UnitOfWork.PersonRepository.GetAllPeople();
Assert.DoesNotContain(people, p => p.Name == "Jane Doe");
var chapterPeople = chapter.People.Select(cp => cp.Person.Name).ToList();
Assert.Contains("Joe Shmo", chapterPeople);
Assert.DoesNotContain("Jane Doe", chapterPeople);
}
// 3. Test no changes when the list of people is the same
[Fact]
public async Task UpdateChapterPeopleAsync_NoChanges()
{
await ResetDb();
var library = new LibraryBuilder("My Library")
.Build();
UnitOfWork.LibraryRepository.Add(library);
await UnitOfWork.CommitAsync();
var existingPerson = new PersonBuilder("Joe Shmo").Build();
var chapter = new ChapterBuilder("1").WithPerson(existingPerson, PersonRole.Editor).Build();
var series = new SeriesBuilder("Test 1")
.WithLibraryId(library.Id)
.WithVolume(new VolumeBuilder("1")
.WithChapter(chapter)
.Build())
.Build();
UnitOfWork.SeriesRepository.Add(series);
await UnitOfWork.CommitAsync();
// Call UpdateChapterPeopleAsync with the same list
await PersonHelper.UpdateChapterPeopleAsync(chapter, new List<string> { "Joe Shmo" }, PersonRole.Editor, UnitOfWork);
var people = await UnitOfWork.PersonRepository.GetAllPeople();
Assert.Contains(people, p => p.Name == "Joe Shmo");
var chapterPeople = chapter.People.Select(cp => cp.Person.Name).ToList();
Assert.Contains("Joe Shmo", chapterPeople);
Assert.Single(chapter.People); // No duplicate entries
}
// 4. Test multiple roles for a person
[Fact]
public async Task UpdateChapterPeopleAsync_MultipleRoles()
{
await ResetDb();
var library = new LibraryBuilder("My Library")
.Build();
UnitOfWork.LibraryRepository.Add(library);
await UnitOfWork.CommitAsync();
var person = new PersonBuilder("Joe Shmo").Build();
var chapter = new ChapterBuilder("1").WithPerson(person, PersonRole.Writer).Build();
var series = new SeriesBuilder("Test 1")
.WithLibraryId(library.Id)
.WithVolume(new VolumeBuilder("1")
.WithChapter(chapter)
.Build())
.Build();
UnitOfWork.SeriesRepository.Add(series);
await UnitOfWork.CommitAsync();
// Add same person as Editor
await PersonHelper.UpdateChapterPeopleAsync(chapter, new List<string> { "Joe Shmo" }, PersonRole.Editor, UnitOfWork);
// Ensure that the same person is assigned with two roles
var chapterPeople = chapter
.People
.Where(cp =>
cp.Person.Name == "Joe Shmo")
.ToList();
Assert.Equal(2, chapterPeople.Count); // One for each role
Assert.Contains(chapterPeople, cp => cp.Role == PersonRole.Writer);
Assert.Contains(chapterPeople, cp => cp.Role == PersonRole.Editor);
}
[Fact]
public async Task UpdateChapterPeopleAsync_MatchOnAlias_NoChanges()
{
await ResetDb();
var library = new LibraryBuilder("My Library")
.Build();
UnitOfWork.LibraryRepository.Add(library);
await UnitOfWork.CommitAsync();
var person = new PersonBuilder("Joe Doe")
.WithAlias("Jonny Doe")
.Build();
var chapter = new ChapterBuilder("1")
.WithPerson(person, PersonRole.Editor)
.Build();
var series = new SeriesBuilder("Test 1")
.WithLibraryId(library.Id)
.WithVolume(new VolumeBuilder("1")
.WithChapter(chapter)
.Build())
.Build();
UnitOfWork.SeriesRepository.Add(series);
await UnitOfWork.CommitAsync();
// Add on Name
await PersonHelper.UpdateChapterPeopleAsync(chapter, new List<string> { "Joe Doe" }, PersonRole.Editor, UnitOfWork);
await UnitOfWork.CommitAsync();
var allPeople = await UnitOfWork.PersonRepository.GetAllPeople();
Assert.Single(allPeople);
// Add on alias
await PersonHelper.UpdateChapterPeopleAsync(chapter, new List<string> { "Jonny Doe" }, PersonRole.Editor, UnitOfWork);
await UnitOfWork.CommitAsync();
allPeople = await UnitOfWork.PersonRepository.GetAllPeople();
Assert.Single(allPeople);
}
// TODO: Unit tests for series
}

View file

@ -0,0 +1,124 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
namespace API.Tests.Helpers;
public class RandfHelper
{
private static readonly Random Random = new ();
/// <summary>
/// Returns true if all simple fields are equal
/// </summary>
/// <param name="obj1"></param>
/// <param name="obj2"></param>
/// <param name="ignoreFields">fields to ignore, note that the names are very weird sometimes</param>
/// <returns></returns>
/// <exception cref="ArgumentNullException"></exception>
/// <exception cref="ArgumentException"></exception>
public static bool AreSimpleFieldsEqual(object obj1, object obj2, IList<string> ignoreFields)
{
if (obj1 == null || obj2 == null)
throw new ArgumentNullException("Neither object can be null.");
Type type1 = obj1.GetType();
Type type2 = obj2.GetType();
if (type1 != type2)
throw new ArgumentException("Objects must be of the same type.");
FieldInfo[] fields = type1.GetFields(BindingFlags.Public | BindingFlags.Instance | BindingFlags.NonPublic);
foreach (var field in fields)
{
if (field.IsInitOnly) continue;
if (ignoreFields.Contains(field.Name)) continue;
Type fieldType = field.FieldType;
if (IsRelevantType(fieldType))
{
object value1 = field.GetValue(obj1);
object value2 = field.GetValue(obj2);
if (!Equals(value1, value2))
{
throw new ArgumentException("Fields must be of the same type: " + field.Name + " was " + value1 + " and " + value2);
}
}
}
return true;
}
private static bool IsRelevantType(Type type)
{
return type.IsPrimitive
|| type == typeof(string)
|| type.IsEnum;
}
/// <summary>
/// Sets all simple fields of the given object to a random value
/// </summary>
/// <param name="obj"></param>
/// <remarks>Simple is, primitive, string, or enum</remarks>
/// <exception cref="ArgumentNullException"></exception>
public static void SetRandomValues(object obj)
{
if (obj == null) throw new ArgumentNullException(nameof(obj));
Type type = obj.GetType();
FieldInfo[] fields = type.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
foreach (var field in fields)
{
if (field.IsInitOnly) continue; // Skip readonly fields
object value = GenerateRandomValue(field.FieldType);
if (value != null)
{
field.SetValue(obj, value);
}
}
}
private static object GenerateRandomValue(Type type)
{
if (type == typeof(int))
return Random.Next();
if (type == typeof(float))
return (float)Random.NextDouble() * 100;
if (type == typeof(double))
return Random.NextDouble() * 100;
if (type == typeof(bool))
return Random.Next(2) == 1;
if (type == typeof(char))
return (char)Random.Next('A', 'Z' + 1);
if (type == typeof(byte))
return (byte)Random.Next(0, 256);
if (type == typeof(short))
return (short)Random.Next(short.MinValue, short.MaxValue);
if (type == typeof(long))
return (long)(Random.NextDouble() * long.MaxValue);
if (type == typeof(string))
return GenerateRandomString(10);
if (type.IsEnum)
{
var values = Enum.GetValues(type);
return values.GetValue(Random.Next(values.Length));
}
// Unsupported type
return null;
}
private static string GenerateRandomString(int length)
{
const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
return new string(Enumerable.Repeat(chars, length)
.Select(s => s[Random.Next(s.Length)]).ToArray());
}
}

View file

@ -1,4 +1,5 @@
using System;
using System.Threading.Tasks;
using API.Helpers;
using Xunit;
@ -33,7 +34,7 @@ public class RateLimiterTests
}
[Fact]
public void AcquireTokens_Refill()
public async Task AcquireTokens_Refill()
{
// Arrange
var limiter = new RateLimiter(2, TimeSpan.FromSeconds(1));
@ -43,14 +44,14 @@ public class RateLimiterTests
limiter.TryAcquire("test_key");
// Wait for refill
System.Threading.Thread.Sleep(1100);
await Task.Delay(1100);
// Assert
Assert.True(limiter.TryAcquire("test_key"));
}
[Fact]
public void AcquireTokens_Refill_WithOff()
public async Task AcquireTokens_Refill_WithOff()
{
// Arrange
var limiter = new RateLimiter(2, TimeSpan.FromSeconds(10), false);
@ -60,7 +61,7 @@ public class RateLimiterTests
limiter.TryAcquire("test_key");
// Wait for refill
System.Threading.Thread.Sleep(2100);
await Task.Delay(2100);
// Assert
Assert.False(limiter.TryAcquire("test_key"));

View file

@ -0,0 +1,258 @@
using API.Helpers;
using System.Collections.Generic;
using System.Linq;
using Xunit;
using API.DTOs.SeriesDetail;
namespace API.Tests.Helpers;
public class ReviewHelperTests
{
#region SelectSpectrumOfReviews Tests
[Fact]
public void SelectSpectrumOfReviews_WhenLessThan10Reviews_ReturnsAllReviews()
{
// Arrange
var reviews = CreateReviewList(8);
// Act
var result = ReviewHelper.SelectSpectrumOfReviews(reviews).ToList();
// Assert
Assert.Equal(8, result.Count);
Assert.Equal(reviews, result.OrderByDescending(r => r.Score));
}
[Fact]
public void SelectSpectrumOfReviews_WhenMoreThan10Reviews_Returns10Reviews()
{
// Arrange
var reviews = CreateReviewList(20);
// Act
var result = ReviewHelper.SelectSpectrumOfReviews(reviews).ToList();
// Assert
Assert.Equal(10, result.Count);
Assert.Equal(reviews[0], result.First());
Assert.Equal(reviews[19], result.Last());
}
[Fact]
public void SelectSpectrumOfReviews_WithExactly10Reviews_ReturnsAllReviews()
{
// Arrange
var reviews = CreateReviewList(10);
// Act
var result = ReviewHelper.SelectSpectrumOfReviews(reviews).ToList();
// Assert
Assert.Equal(10, result.Count);
}
[Fact]
public void SelectSpectrumOfReviews_WithLargeNumberOfReviews_ReturnsCorrectSpectrum()
{
// Arrange
var reviews = CreateReviewList(100);
// Act
var result = ReviewHelper.SelectSpectrumOfReviews(reviews).ToList();
// Assert
Assert.Equal(10, result.Count);
Assert.Contains(reviews[0], result);
Assert.Contains(reviews[1], result);
Assert.Contains(reviews[98], result);
Assert.Contains(reviews[99], result);
}
[Fact]
public void SelectSpectrumOfReviews_WithEmptyList_ReturnsEmptyList()
{
// Arrange
var reviews = new List<UserReviewDto>();
// Act
var result = ReviewHelper.SelectSpectrumOfReviews(reviews).ToList();
// Assert
Assert.Empty(result);
}
[Fact]
public void SelectSpectrumOfReviews_ResultsOrderedByScoreDescending()
{
// Arrange
var reviews = new List<UserReviewDto>
{
new UserReviewDto { Tagline = "1", Score = 3 },
new UserReviewDto { Tagline = "2", Score = 5 },
new UserReviewDto { Tagline = "3", Score = 1 },
new UserReviewDto { Tagline = "4", Score = 4 },
new UserReviewDto { Tagline = "5", Score = 2 }
};
// Act
var result = ReviewHelper.SelectSpectrumOfReviews(reviews).ToList();
// Assert
Assert.Equal(5, result.Count);
Assert.Equal(5, result[0].Score);
Assert.Equal(4, result[1].Score);
Assert.Equal(3, result[2].Score);
Assert.Equal(2, result[3].Score);
Assert.Equal(1, result[4].Score);
}
#endregion
#region GetCharacters Tests
[Fact]
public void GetCharacters_WithNullBody_ReturnsNull()
{
// Arrange
string body = null;
// Act
var result = ReviewHelper.GetCharacters(body);
// Assert
Assert.Null(result);
}
[Fact]
public void GetCharacters_WithEmptyBody_ReturnsEmptyString()
{
// Arrange
var body = string.Empty;
// Act
var result = ReviewHelper.GetCharacters(body);
// Assert
Assert.Equal(string.Empty, result);
}
[Fact]
public void GetCharacters_WithNoTextNodes_ReturnsEmptyString()
{
// Arrange
const string body = "<div></div>";
// Act
var result = ReviewHelper.GetCharacters(body);
// Assert
Assert.Equal(string.Empty, result);
}
[Fact]
public void GetCharacters_WithLessCharactersThanLimit_ReturnsFullText()
{
// Arrange
var body = "<p>This is a short review.</p>";
// Act
var result = ReviewHelper.GetCharacters(body);
// Assert
Assert.Equal("This is a short review.…", result);
}
[Fact]
public void GetCharacters_WithMoreCharactersThanLimit_TruncatesText()
{
// Arrange
var body = "<p>" + new string('a', 200) + "</p>";
// Act
var result = ReviewHelper.GetCharacters(body);
// Assert
Assert.Equal(new string('a', 175) + "…", result);
Assert.Equal(176, result.Length); // 175 characters + ellipsis
}
[Fact]
public void GetCharacters_IgnoresScriptTags()
{
// Arrange
const string body = "<p>Visible text</p><script>console.log('hidden');</script>";
// Act
var result = ReviewHelper.GetCharacters(body);
// Assert
Assert.Equal("Visible text…", result);
Assert.DoesNotContain("hidden", result);
}
[Fact]
public void GetCharacters_RemovesMarkdownSymbols()
{
// Arrange
const string body = "<p>This is **bold** and _italic_ text with [link](url).</p>";
// Act
var result = ReviewHelper.GetCharacters(body);
// Assert
Assert.Equal("This is bold and italic text with link.…", result);
}
[Fact]
public void GetCharacters_HandlesComplexMarkdownAndHtml()
{
// Arrange
const string body = """
<div>
<h1># Header</h1>
<p>This is ~~strikethrough~~ and __underlined__ text</p>
<p>~~~code block~~~</p>
<p>+++highlighted+++</p>
<p>img123(image.jpg)</p>
</div>
""";
// Act
var result = ReviewHelper.GetCharacters(body);
// Assert
Assert.DoesNotContain("~~", result);
Assert.DoesNotContain("__", result);
Assert.DoesNotContain("~~~", result);
Assert.DoesNotContain("+++", result);
Assert.DoesNotContain("img123(", result);
Assert.Contains("Header", result);
Assert.Contains("strikethrough", result);
Assert.Contains("underlined", result);
Assert.Contains("code block", result);
Assert.Contains("highlighted", result);
}
#endregion
#region Helper Methods
private static List<UserReviewDto> CreateReviewList(int count)
{
var reviews = new List<UserReviewDto>();
for (var i = 0; i < count; i++)
{
reviews.Add(new UserReviewDto
{
Tagline = $"{i + 1}",
Score = count - i // This makes them ordered by score descending initially
});
}
return reviews;
}
#endregion
}

View file

@ -26,6 +26,7 @@ using NSubstitute;
using Xunit.Abstractions;
namespace API.Tests.Helpers;
#nullable enable
public class ScannerHelper
{

View file

@ -1,6 +1,5 @@
using System.Collections.Generic;
using System.Linq;
using API.Data;
using API.Entities;
using API.Entities.Enums;
using API.Extensions;

View file

@ -1,5 +1,4 @@
using System;
using API.Helpers;
using API.Helpers;
using Xunit;
namespace API.Tests.Helpers;
@ -11,8 +10,37 @@ public class StringHelperTests
"<p>A Perfect Marriage Becomes a Perfect Affair!<br /> <br><br><br /> Every woman wishes for that happily ever after, but when time flies by and you've become a neglected housewife, what's a woman to do?</p>",
"<p>A Perfect Marriage Becomes a Perfect Affair!<br /> Every woman wishes for that happily ever after, but when time flies by and you've become a neglected housewife, what's a woman to do?</p>"
)]
public void Test(string input, string expected)
[InlineData(
"<p><a href=\"https://blog.goo.ne.jp/tamakiya_web\">Blog</a> | <a href=\"https://twitter.com/tamakinozomu\">Twitter</a> | <a href=\"https://www.pixiv.net/member.php?id=68961\">Pixiv</a> | <a href=\"https://pawoo.net/&amp;#64;tamakiya\">Pawoo</a></p>",
"<p><a href=\"https://blog.goo.ne.jp/tamakiya_web\">Blog</a> | <a href=\"https://twitter.com/tamakinozomu\">Twitter</a> | <a href=\"https://www.pixiv.net/member.php?id=68961\">Pixiv</a> | <a href=\"https://pawoo.net/&amp;#64;tamakiya\">Pawoo</a></p>"
)]
public void TestSquashBreaklines(string input, string expected)
{
Assert.Equal(expected, StringHelper.SquashBreaklines(input));
}
[Theory]
[InlineData(
"<p>A Perfect Marriage Becomes a Perfect Affair!<br /> (Source: Anime News Network)</p>",
"<p>A Perfect Marriage Becomes a Perfect Affair!<br /></p>"
)]
[InlineData(
"<p>A Perfect Marriage Becomes a Perfect Affair!<br /></p>(Source: Anime News Network)",
"<p>A Perfect Marriage Becomes a Perfect Affair!<br /></p>"
)]
public void TestRemoveSourceInDescription(string input, string expected)
{
Assert.Equal(expected, StringHelper.RemoveSourceInDescription(input));
}
[Theory]
[InlineData(
"""<a href=\"https://pawoo.net/&amp;#64;tamakiya\">Pawoo</a></p>""",
"""<a href=\"https://pawoo.net/@tamakiya\">Pawoo</a></p>"""
)]
public void TestCorrectUrls(string input, string expected)
{
Assert.Equal(expected, StringHelper.CorrectUrls(input));
}
}

View file

@ -1,4 +1,5 @@
using System.IO.Abstractions.TestingHelpers;
using System.IO;
using System.IO.Abstractions.TestingHelpers;
using API.Entities.Enums;
using API.Services;
using API.Services.Tasks.Scanner.Parser;
@ -8,59 +9,54 @@ using Xunit;
namespace API.Tests.Parsers;
public class BasicParserTests
public class BasicParserTests : AbstractFsTest
{
private readonly BasicParser _parser;
private readonly ILogger<DirectoryService> _dsLogger = Substitute.For<ILogger<DirectoryService>>();
private const string RootDirectory = "C:/Books/";
private readonly string _rootDirectory;
public BasicParserTests()
{
var fileSystem = new MockFileSystem();
fileSystem.AddDirectory("C:/Books/");
fileSystem.AddFile("C:/Books/Harry Potter/Harry Potter - Vol 1.epub", new MockFileData(""));
var fileSystem = CreateFileSystem();
_rootDirectory = Path.Join(DataDirectory, "Books/");
fileSystem.AddDirectory(_rootDirectory);
fileSystem.AddFile($"{_rootDirectory}Harry Potter/Harry Potter - Vol 1.epub", new MockFileData(""));
fileSystem.AddFile("C:/Books/Accel World/Accel World - Volume 1.cbz", new MockFileData(""));
fileSystem.AddFile("C:/Books/Accel World/Accel World - Volume 1 Chapter 2.cbz", new MockFileData(""));
fileSystem.AddFile("C:/Books/Accel World/Accel World - Chapter 3.cbz", new MockFileData(""));
fileSystem.AddFile("C:/Books/Accel World/Accel World Gaiden SP01.cbz", new MockFileData(""));
fileSystem.AddFile($"{_rootDirectory}Accel World/Accel World - Volume 1.cbz", new MockFileData(""));
fileSystem.AddFile($"{_rootDirectory}Accel World/Accel World - Volume 1 Chapter 2.cbz", new MockFileData(""));
fileSystem.AddFile($"{_rootDirectory}Accel World/Accel World - Chapter 3.cbz", new MockFileData(""));
fileSystem.AddFile("$\"{RootDirectory}Accel World/Accel World Gaiden SP01.cbz", new MockFileData(""));
fileSystem.AddFile("C:/Books/Accel World/cover.png", new MockFileData(""));
fileSystem.AddFile($"{_rootDirectory}Accel World/cover.png", new MockFileData(""));
fileSystem.AddFile("C:/Books/Batman/Batman #1.cbz", new MockFileData(""));
fileSystem.AddFile($"{_rootDirectory}Batman/Batman #1.cbz", new MockFileData(""));
var ds = new DirectoryService(_dsLogger, fileSystem);
_parser = new BasicParser(ds, new ImageParser(ds));
}
#region Parse_Books
#endregion
#region Parse_Manga
/// <summary>
/// Tests that when there is a loose leaf cover in the manga library, that it is ignored
/// Tests that when there is a loose-leaf cover in the manga library, that it is ignored
/// </summary>
[Fact]
public void Parse_MangaLibrary_JustCover_ShouldReturnNull()
{
var actual = _parser.Parse(@"C:/Books/Accel World/cover.png", "C:/Books/Accel World/",
RootDirectory, LibraryType.Manga, null);
var actual = _parser.Parse($"{_rootDirectory}Accel World/cover.png", $"{_rootDirectory}Accel World/",
_rootDirectory, LibraryType.Manga);
Assert.Null(actual);
}
/// <summary>
/// Tests that when there is a loose leaf cover in the manga library, that it is ignored
/// Tests that when there is a loose-leaf cover in the manga library, that it is ignored
/// </summary>
[Fact]
public void Parse_MangaLibrary_OtherImage_ShouldReturnNull()
{
var actual = _parser.Parse(@"C:/Books/Accel World/page 01.png", "C:/Books/Accel World/",
RootDirectory, LibraryType.Manga, null);
var actual = _parser.Parse($"{_rootDirectory}Accel World/page 01.png", $"{_rootDirectory}Accel World/",
_rootDirectory, LibraryType.Manga);
Assert.NotNull(actual);
}
@ -70,8 +66,8 @@ public class BasicParserTests
[Fact]
public void Parse_MangaLibrary_VolumeAndChapterInFilename()
{
var actual = _parser.Parse("C:/Books/Mujaki no Rakuen/Mujaki no Rakuen Vol12 ch76.cbz", "C:/Books/Mujaki no Rakuen/",
RootDirectory, LibraryType.Manga, null);
var actual = _parser.Parse($"{_rootDirectory}Mujaki no Rakuen/Mujaki no Rakuen Vol12 ch76.cbz", $"{_rootDirectory}Mujaki no Rakuen/",
_rootDirectory, LibraryType.Manga);
Assert.NotNull(actual);
Assert.Equal("Mujaki no Rakuen", actual.Series);
@ -86,9 +82,9 @@ public class BasicParserTests
[Fact]
public void Parse_MangaLibrary_JustVolumeInFilename()
{
var actual = _parser.Parse("C:/Books/Shimoneta to Iu Gainen ga Sonzai Shinai Taikutsu na Sekai Man-hen/Vol 1.cbz",
"C:/Books/Shimoneta to Iu Gainen ga Sonzai Shinai Taikutsu na Sekai Man-hen/",
RootDirectory, LibraryType.Manga, null);
var actual = _parser.Parse($"{_rootDirectory}Shimoneta to Iu Gainen ga Sonzai Shinai Taikutsu na Sekai Man-hen/Vol 1.cbz",
$"{_rootDirectory}Shimoneta to Iu Gainen ga Sonzai Shinai Taikutsu na Sekai Man-hen/",
_rootDirectory, LibraryType.Manga);
Assert.NotNull(actual);
Assert.Equal("Shimoneta to Iu Gainen ga Sonzai Shinai Taikutsu na Sekai Man-hen", actual.Series);
@ -103,9 +99,9 @@ public class BasicParserTests
[Fact]
public void Parse_MangaLibrary_JustChapterInFilename()
{
var actual = _parser.Parse("C:/Books/Beelzebub/Beelzebub_01_[Noodles].zip",
"C:/Books/Beelzebub/",
RootDirectory, LibraryType.Manga, null);
var actual = _parser.Parse($"{_rootDirectory}Beelzebub/Beelzebub_01_[Noodles].zip",
$"{_rootDirectory}Beelzebub/",
_rootDirectory, LibraryType.Manga);
Assert.NotNull(actual);
Assert.Equal("Beelzebub", actual.Series);
@ -120,9 +116,9 @@ public class BasicParserTests
[Fact]
public void Parse_MangaLibrary_SpecialMarkerInFilename()
{
var actual = _parser.Parse("C:/Books/Summer Time Rendering/Specials/Record 014 (between chapter 083 and ch084) SP11.cbr",
"C:/Books/Summer Time Rendering/",
RootDirectory, LibraryType.Manga, null);
var actual = _parser.Parse($"{_rootDirectory}Summer Time Rendering/Specials/Record 014 (between chapter 083 and ch084) SP11.cbr",
$"{_rootDirectory}Summer Time Rendering/",
_rootDirectory, LibraryType.Manga);
Assert.NotNull(actual);
Assert.Equal("Summer Time Rendering", actual.Series);
@ -133,36 +129,54 @@ public class BasicParserTests
/// <summary>
/// Tests that when the filename parses as a speical, it appropriately parses
/// Tests that when the filename parses as a special, it appropriately parses
/// </summary>
[Fact]
public void Parse_MangaLibrary_SpecialInFilename()
{
var actual = _parser.Parse("C:/Books/Summer Time Rendering/Volume SP01.cbr",
"C:/Books/Summer Time Rendering/",
RootDirectory, LibraryType.Manga, null);
var actual = _parser.Parse($"{_rootDirectory}Summer Time Rendering/Volume SP01.cbr",
$"{_rootDirectory}Summer Time Rendering/",
_rootDirectory, LibraryType.Manga);
Assert.NotNull(actual);
Assert.Equal("Summer Time Rendering", actual.Series);
Assert.Equal("Volume SP01", actual.Title);
Assert.Equal("Volume", actual.Title);
Assert.Equal(Parser.SpecialVolume, actual.Volumes);
Assert.Equal(Parser.DefaultChapter, actual.Chapters);
Assert.True(actual.IsSpecial);
}
/// <summary>
/// Tests that when the filename parses as a speical, it appropriately parses
/// Tests that when the filename parses as a special, it appropriately parses
/// </summary>
[Fact]
public void Parse_MangaLibrary_SpecialInFilename2()
{
var actual = _parser.Parse("M:/Kimi wa Midara na Boku no Joou/Specials/[Renzokusei] Special 1 SP02.zip",
"M:/Kimi wa Midara na Boku no Joou/",
RootDirectory, LibraryType.Manga, null);
_rootDirectory, LibraryType.Manga);
Assert.NotNull(actual);
Assert.Equal("Kimi wa Midara na Boku no Joou", actual.Series);
Assert.Equal("[Renzokusei] Special 1 SP02", actual.Title);
Assert.Equal("[Renzokusei] Special 1", actual.Title);
Assert.Equal(Parser.SpecialVolume, actual.Volumes);
Assert.Equal(Parser.DefaultChapter, actual.Chapters);
Assert.True(actual.IsSpecial);
}
/// <summary>
/// Tests that when the filename parses as a special, it appropriately parses
/// </summary>
[Fact]
public void Parse_MangaLibrary_SpecialInFilename_StrangeNaming()
{
var actual = _parser.Parse($"{_rootDirectory}My Dress-Up Darling/SP01 1. Special Name.cbz",
_rootDirectory,
_rootDirectory, LibraryType.Manga);
Assert.NotNull(actual);
Assert.Equal("My Dress-Up Darling", actual.Series);
Assert.Equal("1. Special Name", actual.Title);
Assert.Equal(Parser.SpecialVolume, actual.Volumes);
Assert.Equal(Parser.DefaultChapter, actual.Chapters);
Assert.True(actual.IsSpecial);
@ -174,9 +188,9 @@ public class BasicParserTests
[Fact]
public void Parse_MangaLibrary_EditionInFilename()
{
var actual = _parser.Parse("C:/Books/Air Gear/Air Gear Omnibus v01 (2016) (Digital) (Shadowcat-Empire).cbz",
"C:/Books/Air Gear/",
RootDirectory, LibraryType.Manga, null);
var actual = _parser.Parse($"{_rootDirectory}Air Gear/Air Gear Omnibus v01 (2016) (Digital) (Shadowcat-Empire).cbz",
$"{_rootDirectory}Air Gear/",
_rootDirectory, LibraryType.Manga);
Assert.NotNull(actual);
Assert.Equal("Air Gear", actual.Series);
@ -195,9 +209,9 @@ public class BasicParserTests
[Fact]
public void Parse_MangaBooks_JustVolumeInFilename()
{
var actual = _parser.Parse("C:/Books/Epubs/Harrison, Kim - The Good, The Bad, and the Undead - Hollows Vol 2.5.epub",
"C:/Books/Epubs/",
RootDirectory, LibraryType.Manga, null);
var actual = _parser.Parse($"{_rootDirectory}Epubs/Harrison, Kim - The Good, The Bad, and the Undead - Hollows Vol 2.5.epub",
$"{_rootDirectory}Epubs/",
_rootDirectory, LibraryType.Manga);
Assert.NotNull(actual);
Assert.Equal("Harrison, Kim - The Good, The Bad, and the Undead - Hollows", actual.Series);

View file

@ -1,5 +1,4 @@
using System.IO.Abstractions.TestingHelpers;
using API.Data.Metadata;
using API.Entities.Enums;
using API.Services;
using API.Services.Tasks.Scanner.Parser;

View file

@ -36,7 +36,7 @@ public class ComicVineParserTests
public void Parse_SeriesWithComicInfo()
{
var actual = _parser.Parse("C:/Comics/Birds of Prey (2002)/Birds of Prey 001 (2002).cbz", "C:/Comics/Birds of Prey (2002)/",
RootDirectory, LibraryType.ComicVine, new ComicInfo()
RootDirectory, LibraryType.ComicVine, true, new ComicInfo()
{
Series = "Birds of Prey",
Volume = "2002"
@ -54,7 +54,7 @@ public class ComicVineParserTests
public void Parse_SeriesWithDirectoryNameAsSeriesYear()
{
var actual = _parser.Parse("C:/Comics/Birds of Prey (2002)/Birds of Prey 001 (2002).cbz", "C:/Comics/Birds of Prey (2002)/",
RootDirectory, LibraryType.ComicVine, null);
RootDirectory, LibraryType.ComicVine, true, null);
Assert.NotNull(actual);
Assert.Equal("Birds of Prey (2002)", actual.Series);
@ -69,7 +69,7 @@ public class ComicVineParserTests
public void Parse_SeriesWithADirectoryNameAsSeriesYear()
{
var actual = _parser.Parse("C:/Comics/DC Comics/Birds of Prey (1999)/Birds of Prey 001 (1999).cbz", "C:/Comics/DC Comics/",
RootDirectory, LibraryType.ComicVine, null);
RootDirectory, LibraryType.ComicVine, true, null);
Assert.NotNull(actual);
Assert.Equal("Birds of Prey (1999)", actual.Series);
@ -84,7 +84,7 @@ public class ComicVineParserTests
public void Parse_FallbackToDirectoryNameOnly()
{
var actual = _parser.Parse("C:/Comics/DC Comics/Blood Syndicate/Blood Syndicate 001 (1999).cbz", "C:/Comics/DC Comics/",
RootDirectory, LibraryType.ComicVine, null);
RootDirectory, LibraryType.ComicVine, true, null);
Assert.NotNull(actual);
Assert.Equal("Blood Syndicate", actual.Series);

View file

@ -33,7 +33,7 @@ public class DefaultParserTests
[InlineData("C:/", "C:/Something Random/Mujaki no Rakuen SP01.cbz", "Something Random")]
public void ParseFromFallbackFolders_FallbackShouldParseSeries(string rootDir, string inputPath, string expectedSeries)
{
var actual = _defaultParser.Parse(inputPath, rootDir, rootDir, LibraryType.Manga, null);
var actual = _defaultParser.Parse(inputPath, rootDir, rootDir, LibraryType.Manga, true, null);
if (actual == null)
{
Assert.NotNull(actual);
@ -74,7 +74,7 @@ public class DefaultParserTests
fs.AddFile(inputFile, new MockFileData(""));
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), fs);
var parser = new BasicParser(ds, new ImageParser(ds));
var actual = parser.Parse(inputFile, rootDirectory, rootDirectory, LibraryType.Manga, null);
var actual = parser.Parse(inputFile, rootDirectory, rootDirectory, LibraryType.Manga, true, null);
_defaultParser.ParseFromFallbackFolders(inputFile, rootDirectory, LibraryType.Manga, ref actual);
Assert.Equal(expectedParseInfo, actual.Series);
}
@ -90,7 +90,7 @@ public class DefaultParserTests
fs.AddFile(inputFile, new MockFileData(""));
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), fs);
var parser = new BasicParser(ds, new ImageParser(ds));
var actual = parser.Parse(inputFile, rootDirectory, rootDirectory, LibraryType.Manga, null);
var actual = parser.Parse(inputFile, rootDirectory, rootDirectory, LibraryType.Manga, true, null);
_defaultParser.ParseFromFallbackFolders(inputFile, rootDirectory, LibraryType.Manga, ref actual);
Assert.Equal(expectedParseInfo, actual.Series);
}
@ -251,7 +251,7 @@ public class DefaultParserTests
foreach (var file in expected.Keys)
{
var expectedInfo = expected[file];
var actual = _defaultParser.Parse(file, rootPath, rootPath, LibraryType.Manga, null);
var actual = _defaultParser.Parse(file, rootPath, rootPath, LibraryType.Manga, true, null);
if (expectedInfo == null)
{
Assert.Null(actual);
@ -289,7 +289,7 @@ public class DefaultParserTests
Chapters = "8", Filename = "13.jpg", Format = MangaFormat.Image,
FullFilePath = filepath, IsSpecial = false
};
var actual2 = _defaultParser.Parse(filepath, @"E:/Manga/Monster #8", "E:/Manga", LibraryType.Manga, null);
var actual2 = _defaultParser.Parse(filepath, @"E:/Manga/Monster #8", "E:/Manga", LibraryType.Manga, true, null);
Assert.NotNull(actual2);
_testOutputHelper.WriteLine($"Validating {filepath}");
Assert.Equal(expectedInfo2.Format, actual2.Format);
@ -315,7 +315,7 @@ public class DefaultParserTests
FullFilePath = filepath, IsSpecial = false
};
actual2 = _defaultParser.Parse(filepath, @"E:/Manga/Extra layer for no reason/", "E:/Manga",LibraryType.Manga, null);
actual2 = _defaultParser.Parse(filepath, @"E:/Manga/Extra layer for no reason/", "E:/Manga",LibraryType.Manga, true, null);
Assert.NotNull(actual2);
_testOutputHelper.WriteLine($"Validating {filepath}");
Assert.Equal(expectedInfo2.Format, actual2.Format);
@ -341,7 +341,7 @@ public class DefaultParserTests
FullFilePath = filepath, IsSpecial = false
};
actual2 = _defaultParser.Parse(filepath, @"E:/Manga/Extra layer for no reason/", "E:/Manga", LibraryType.Manga, null);
actual2 = _defaultParser.Parse(filepath, @"E:/Manga/Extra layer for no reason/", "E:/Manga", LibraryType.Manga, true, null);
Assert.NotNull(actual2);
_testOutputHelper.WriteLine($"Validating {filepath}");
Assert.Equal(expectedInfo2.Format, actual2.Format);
@ -383,7 +383,7 @@ public class DefaultParserTests
FullFilePath = filepath
};
var actual = parser.Parse(filepath, rootPath, rootPath, LibraryType.Manga, null);
var actual = parser.Parse(filepath, rootPath, rootPath, LibraryType.Manga, true, null);
Assert.NotNull(actual);
_testOutputHelper.WriteLine($"Validating {filepath}");
@ -412,7 +412,7 @@ public class DefaultParserTests
FullFilePath = filepath
};
actual = parser.Parse(filepath, rootPath, rootPath, LibraryType.Manga, null);
actual = parser.Parse(filepath, rootPath, rootPath, LibraryType.Manga, true, null);
Assert.NotNull(actual);
_testOutputHelper.WriteLine($"Validating {filepath}");
Assert.Equal(expected.Format, actual.Format);
@ -475,7 +475,7 @@ public class DefaultParserTests
foreach (var file in expected.Keys)
{
var expectedInfo = expected[file];
var actual = _defaultParser.Parse(file, rootPath, rootPath, LibraryType.Comic, null);
var actual = _defaultParser.Parse(file, rootPath, rootPath, LibraryType.Comic, true, null);
if (expectedInfo == null)
{
Assert.Null(actual);

View file

@ -34,7 +34,7 @@ public class ImageParserTests
public void Parse_SeriesWithDirectoryName()
{
var actual = _parser.Parse("C:/Comics/Birds of Prey/Chapter 01/01.jpg", "C:/Comics/Birds of Prey/",
RootDirectory, LibraryType.Image, null);
RootDirectory, LibraryType.Image, true, null);
Assert.NotNull(actual);
Assert.Equal("Birds of Prey", actual.Series);
@ -48,7 +48,7 @@ public class ImageParserTests
public void Parse_SeriesWithNoNestedChapter()
{
var actual = _parser.Parse("C:/Comics/Birds of Prey/Chapter 01 page 01.jpg", "C:/Comics/",
RootDirectory, LibraryType.Image, null);
RootDirectory, LibraryType.Image, true, null);
Assert.NotNull(actual);
Assert.Equal("Birds of Prey", actual.Series);
@ -62,7 +62,7 @@ public class ImageParserTests
public void Parse_SeriesWithLooseImages()
{
var actual = _parser.Parse("C:/Comics/Birds of Prey/page 01.jpg", "C:/Comics/",
RootDirectory, LibraryType.Image, null);
RootDirectory, LibraryType.Image, true, null);
Assert.NotNull(actual);
Assert.Equal("Birds of Prey", actual.Series);

View file

@ -35,7 +35,7 @@ public class PdfParserTests
{
var actual = _parser.Parse("C:/Books/A Dictionary of Japanese Food - Ingredients and Culture/A Dictionary of Japanese Food - Ingredients and Culture.pdf",
"C:/Books/A Dictionary of Japanese Food - Ingredients and Culture/",
RootDirectory, LibraryType.Book, null);
RootDirectory, LibraryType.Book, true, null);
Assert.NotNull(actual);
Assert.Equal("A Dictionary of Japanese Food - Ingredients and Culture", actual.Series);

View file

@ -34,7 +34,7 @@ public class ImageParsingTests
Chapters = "8", Filename = "13.jpg", Format = MangaFormat.Image,
FullFilePath = filepath, IsSpecial = false
};
var actual2 = _parser.Parse(filepath, @"E:\Manga\Monster #8", "E:/Manga", LibraryType.Image, null);
var actual2 = _parser.Parse(filepath, @"E:\Manga\Monster #8", "E:/Manga", LibraryType.Image, true, null);
Assert.NotNull(actual2);
_testOutputHelper.WriteLine($"Validating {filepath}");
Assert.Equal(expectedInfo2.Format, actual2.Format);
@ -60,7 +60,7 @@ public class ImageParsingTests
FullFilePath = filepath, IsSpecial = false
};
actual2 = _parser.Parse(filepath, @"E:\Manga\Extra layer for no reason\", "E:/Manga", LibraryType.Image, null);
actual2 = _parser.Parse(filepath, @"E:\Manga\Extra layer for no reason\", "E:/Manga", LibraryType.Image, true, null);
Assert.NotNull(actual2);
_testOutputHelper.WriteLine($"Validating {filepath}");
Assert.Equal(expectedInfo2.Format, actual2.Format);
@ -86,7 +86,7 @@ public class ImageParsingTests
FullFilePath = filepath, IsSpecial = false
};
actual2 = _parser.Parse(filepath, @"E:\Manga\Extra layer for no reason\", "E:/Manga", LibraryType.Image, null);
actual2 = _parser.Parse(filepath, @"E:\Manga\Extra layer for no reason\", "E:/Manga", LibraryType.Image, true, null);
Assert.NotNull(actual2);
_testOutputHelper.WriteLine($"Validating {filepath}");
Assert.Equal(expectedInfo2.Format, actual2.Format);

View file

@ -1,18 +1,10 @@
using API.Entities.Enums;
using Xunit;
using Xunit.Abstractions;
namespace API.Tests.Parsing;
public class MangaParsingTests
{
private readonly ITestOutputHelper _testOutputHelper;
public MangaParsingTests(ITestOutputHelper testOutputHelper)
{
_testOutputHelper = testOutputHelper;
}
[Theory]
[InlineData("Killing Bites Vol. 0001 Ch. 0001 - Galactica Scanlations (gb)", "1")]
[InlineData("My Girlfriend Is Shobitch v01 - ch. 09 - pg. 008.png", "1")]
@ -76,7 +68,6 @@ public class MangaParsingTests
[InlineData("Манга Тома 1-4", "1-4")]
[InlineData("Манга Том 1-4", "1-4")]
[InlineData("조선왕조실톡 106화", "106")]
[InlineData("죽음 13회", "13")]
[InlineData("동의보감 13장", "13")]
[InlineData("몰?루 아카이브 7.5권", "7.5")]
[InlineData("63권#200", "63")]
@ -84,6 +75,7 @@ public class MangaParsingTests
[InlineData("Accel World Chapter 001 Volume 002", "2")]
[InlineData("Accel World Volume 2", "2")]
[InlineData("Nagasarete Airantou - Vol. 30 Ch. 187.5 - Vol.31 Omake", "30")]
[InlineData("Zom 100 - Bucket List of the Dead v01", "1")]
public void ParseVolumeTest(string filename, string expected)
{
Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.ParseVolume(filename, LibraryType.Manga));
@ -212,6 +204,8 @@ public class MangaParsingTests
[InlineData("不安の種\uff0b - 01", "不安の種\uff0b")]
[InlineData("Giant Ojou-sama - Ch. 33.5 - Volume 04 Bonus Chapter", "Giant Ojou-sama")]
[InlineData("[218565]-(C92) [BRIO (Puyocha)] Mika-nee no Tanryoku Shidou - Mika s Guide to Self-Confidence (THE IDOLM@STE", "")]
[InlineData("Monster #8 Ch. 001", "Monster #8")]
[InlineData("Zom 100 - Bucket List of the Dead v01", "Zom 100 - Bucket List of the Dead")]
public void ParseSeriesTest(string filename, string expected)
{
Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.ParseSeries(filename, LibraryType.Manga));
@ -304,6 +298,7 @@ public class MangaParsingTests
[InlineData("เด็กคนนี้ขอลาออกจากการเป็นเจ้าของปราสาท เล่ม 1 ตอนที่ 3", "3")]
[InlineData("Max Level Returner ตอนที่ 5", "5")]
[InlineData("หนึ่งความคิด นิจนิรันดร์ บทที่ 112", "112")]
[InlineData("Monster #8 Ch. 001", "1")]
public void ParseChaptersTest(string filename, string expected)
{
Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.ParseChapter(filename, LibraryType.Manga));

View file

@ -11,14 +11,14 @@ public class ParserInfoTests
{
var p1 = new ParserInfo()
{
Chapters = API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter,
Chapters = Parser.DefaultChapter,
Edition = "",
Format = MangaFormat.Archive,
FullFilePath = "/manga/darker than black.cbz",
IsSpecial = false,
Series = "darker than black",
Title = "darker than black",
Volumes = API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume
Volumes = Parser.LooseLeafVolume
};
var p2 = new ParserInfo()
@ -30,7 +30,7 @@ public class ParserInfoTests
IsSpecial = false,
Series = "darker than black",
Title = "Darker Than Black",
Volumes = API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume
Volumes = Parser.LooseLeafVolume
};
var expected = new ParserInfo()
@ -42,7 +42,7 @@ public class ParserInfoTests
IsSpecial = false,
Series = "darker than black",
Title = "darker than black",
Volumes = API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume
Volumes = Parser.LooseLeafVolume
};
p1.Merge(p2);
@ -62,12 +62,12 @@ public class ParserInfoTests
IsSpecial = true,
Series = "darker than black",
Title = "darker than black",
Volumes = API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume
Volumes = Parser.LooseLeafVolume
};
var p2 = new ParserInfo()
{
Chapters = API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter,
Chapters = Parser.DefaultChapter,
Edition = "",
Format = MangaFormat.Archive,
FullFilePath = "/manga/darker than black.cbz",

View file

@ -1,6 +1,5 @@
using System.Globalization;
using System.Linq;
using System.Runtime.InteropServices;
using Xunit;
using static API.Services.Tasks.Scanner.Parser.Parser;
@ -11,9 +10,13 @@ public class ParsingTests
[Fact]
public void ShouldWork()
{
var s = 6.5f + "";
var s = 6.5f.ToString(CultureInfo.InvariantCulture);
var a = float.Parse(s, CultureInfo.InvariantCulture);
Assert.Equal(6.5f, a);
s = 6.5f + "";
a = float.Parse(s, CultureInfo.CurrentCulture);
Assert.Equal(6.5f, a);
}
// [Theory]
@ -40,6 +43,7 @@ public class ParsingTests
[InlineData("DEAD Tube Prologue", "DEAD Tube Prologue")]
[InlineData("DEAD Tube Prologue SP01", "DEAD Tube Prologue")]
[InlineData("DEAD_Tube_Prologue SP01", "DEAD Tube Prologue")]
[InlineData("SP01 1. DEAD Tube Prologue", "1. DEAD Tube Prologue")]
public void CleanSpecialTitleTest(string input, string expected)
{
Assert.Equal(expected, CleanSpecialTitle(input));
@ -247,6 +251,7 @@ public class ParsingTests
[InlineData("ch1/backcover.png", false)]
[InlineData("backcover.png", false)]
[InlineData("back_cover.png", false)]
[InlineData("LD Blacklands #1 35 (back cover).png", false)]
public void IsCoverImageTest(string inputPath, bool expected)
{
Assert.Equal(expected, IsCoverImage(inputPath));

View file

@ -15,7 +15,6 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Xunit;
namespace API.Tests.Repository;

View file

@ -0,0 +1,280 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using API.DTOs.Metadata.Browse;
using API.Entities;
using API.Entities.Enums;
using API.Entities.Metadata;
using API.Helpers;
using API.Helpers.Builders;
using Xunit;
namespace API.Tests.Repository;
public class GenreRepositoryTests : AbstractDbTest
{
private AppUser _fullAccess;
private AppUser _restrictedAccess;
private AppUser _restrictedAgeAccess;
protected override async Task ResetDb()
{
Context.Genre.RemoveRange(Context.Genre);
Context.Library.RemoveRange(Context.Library);
await Context.SaveChangesAsync();
}
private TestGenreSet CreateTestGenres()
{
return new TestGenreSet
{
SharedSeriesChaptersGenre = new GenreBuilder("Shared Series Chapter Genre").Build(),
SharedSeriesGenre = new GenreBuilder("Shared Series Genre").Build(),
SharedChaptersGenre = new GenreBuilder("Shared Chapters Genre").Build(),
Lib0SeriesChaptersGenre = new GenreBuilder("Lib0 Series Chapter Genre").Build(),
Lib0SeriesGenre = new GenreBuilder("Lib0 Series Genre").Build(),
Lib0ChaptersGenre = new GenreBuilder("Lib0 Chapters Genre").Build(),
Lib1SeriesChaptersGenre = new GenreBuilder("Lib1 Series Chapter Genre").Build(),
Lib1SeriesGenre = new GenreBuilder("Lib1 Series Genre").Build(),
Lib1ChaptersGenre = new GenreBuilder("Lib1 Chapters Genre").Build(),
Lib1ChapterAgeGenre = new GenreBuilder("Lib1 Chapter Age Genre").Build()
};
}
private async Task SeedDbWithGenres(TestGenreSet genres)
{
await CreateTestUsers();
await AddGenresToContext(genres);
await CreateLibrariesWithGenres(genres);
await AssignLibrariesToUsers();
}
private async Task CreateTestUsers()
{
_fullAccess = new AppUserBuilder("amelia", "amelia@example.com").Build();
_restrictedAccess = new AppUserBuilder("mila", "mila@example.com").Build();
_restrictedAgeAccess = new AppUserBuilder("eva", "eva@example.com").Build();
_restrictedAgeAccess.AgeRestriction = AgeRating.Teen;
_restrictedAgeAccess.AgeRestrictionIncludeUnknowns = true;
Context.Users.Add(_fullAccess);
Context.Users.Add(_restrictedAccess);
Context.Users.Add(_restrictedAgeAccess);
await Context.SaveChangesAsync();
}
private async Task AddGenresToContext(TestGenreSet genres)
{
var allGenres = genres.GetAllGenres();
Context.Genre.AddRange(allGenres);
await Context.SaveChangesAsync();
}
private async Task CreateLibrariesWithGenres(TestGenreSet genres)
{
var lib0 = new LibraryBuilder("lib0")
.WithSeries(new SeriesBuilder("lib0-s0")
.WithMetadata(new SeriesMetadataBuilder()
.WithGenres([genres.SharedSeriesChaptersGenre, genres.SharedSeriesGenre, genres.Lib0SeriesChaptersGenre, genres.Lib0SeriesGenre])
.Build())
.WithVolume(new VolumeBuilder("1")
.WithChapter(new ChapterBuilder("1")
.WithGenres([genres.SharedSeriesChaptersGenre, genres.SharedChaptersGenre, genres.Lib0SeriesChaptersGenre, genres.Lib0ChaptersGenre])
.Build())
.WithChapter(new ChapterBuilder("2")
.WithGenres([genres.SharedSeriesChaptersGenre, genres.SharedChaptersGenre, genres.Lib1SeriesChaptersGenre, genres.Lib1ChaptersGenre])
.Build())
.Build())
.Build())
.Build();
var lib1 = new LibraryBuilder("lib1")
.WithSeries(new SeriesBuilder("lib1-s0")
.WithMetadata(new SeriesMetadataBuilder()
.WithGenres([genres.SharedSeriesChaptersGenre, genres.SharedSeriesGenre, genres.Lib1SeriesChaptersGenre, genres.Lib1SeriesGenre])
.WithAgeRating(AgeRating.Mature17Plus)
.Build())
.WithVolume(new VolumeBuilder("1")
.WithChapter(new ChapterBuilder("1")
.WithGenres([genres.SharedSeriesChaptersGenre, genres.SharedChaptersGenre, genres.Lib1SeriesChaptersGenre, genres.Lib1ChaptersGenre])
.Build())
.WithChapter(new ChapterBuilder("2")
.WithGenres([genres.SharedSeriesChaptersGenre, genres.SharedChaptersGenre, genres.Lib1SeriesChaptersGenre, genres.Lib1ChaptersGenre, genres.Lib1ChapterAgeGenre])
.WithAgeRating(AgeRating.Mature17Plus)
.Build())
.Build())
.Build())
.WithSeries(new SeriesBuilder("lib1-s1")
.WithMetadata(new SeriesMetadataBuilder()
.WithGenres([genres.SharedSeriesChaptersGenre, genres.SharedSeriesGenre, genres.Lib1SeriesChaptersGenre, genres.Lib1SeriesGenre])
.Build())
.WithVolume(new VolumeBuilder("1")
.WithChapter(new ChapterBuilder("1")
.WithGenres([genres.SharedSeriesChaptersGenre, genres.SharedChaptersGenre, genres.Lib1SeriesChaptersGenre, genres.Lib1ChaptersGenre])
.Build())
.WithChapter(new ChapterBuilder("2")
.WithGenres([genres.SharedSeriesChaptersGenre, genres.SharedChaptersGenre, genres.Lib1SeriesChaptersGenre, genres.Lib1ChaptersGenre])
.Build())
.Build())
.Build())
.Build();
Context.Library.Add(lib0);
Context.Library.Add(lib1);
await Context.SaveChangesAsync();
}
private async Task AssignLibrariesToUsers()
{
var lib0 = Context.Library.First(l => l.Name == "lib0");
var lib1 = Context.Library.First(l => l.Name == "lib1");
_fullAccess.Libraries.Add(lib0);
_fullAccess.Libraries.Add(lib1);
_restrictedAccess.Libraries.Add(lib1);
_restrictedAgeAccess.Libraries.Add(lib1);
await Context.SaveChangesAsync();
}
private static Predicate<BrowseGenreDto> ContainsGenreCheck(Genre genre)
{
return g => g.Id == genre.Id;
}
private static void AssertGenrePresent(IEnumerable<BrowseGenreDto> genres, Genre expectedGenre)
{
Assert.Contains(genres, ContainsGenreCheck(expectedGenre));
}
private static void AssertGenreNotPresent(IEnumerable<BrowseGenreDto> genres, Genre expectedGenre)
{
Assert.DoesNotContain(genres, ContainsGenreCheck(expectedGenre));
}
private static BrowseGenreDto GetGenreDto(IEnumerable<BrowseGenreDto> genres, Genre genre)
{
return genres.First(dto => dto.Id == genre.Id);
}
[Fact]
public async Task GetBrowseableGenre_FullAccess_ReturnsAllGenresWithCorrectCounts()
{
// Arrange
await ResetDb();
var genres = CreateTestGenres();
await SeedDbWithGenres(genres);
// Act
var fullAccessGenres = await UnitOfWork.GenreRepository.GetBrowseableGenre(_fullAccess.Id, new UserParams());
// Assert
Assert.Equal(genres.GetAllGenres().Count, fullAccessGenres.TotalCount);
foreach (var genre in genres.GetAllGenres())
{
AssertGenrePresent(fullAccessGenres, genre);
}
// Verify counts - 1 lib0 series, 2 lib1 series = 3 total series
Assert.Equal(3, GetGenreDto(fullAccessGenres, genres.SharedSeriesChaptersGenre).SeriesCount);
Assert.Equal(6, GetGenreDto(fullAccessGenres, genres.SharedSeriesChaptersGenre).ChapterCount);
Assert.Equal(1, GetGenreDto(fullAccessGenres, genres.Lib0SeriesGenre).SeriesCount);
}
[Fact]
public async Task GetBrowseableGenre_RestrictedAccess_ReturnsOnlyAccessibleGenres()
{
// Arrange
await ResetDb();
var genres = CreateTestGenres();
await SeedDbWithGenres(genres);
// Act
var restrictedAccessGenres = await UnitOfWork.GenreRepository.GetBrowseableGenre(_restrictedAccess.Id, new UserParams());
// Assert - Should see: 3 shared + 4 library 1 specific = 7 genres
Assert.Equal(7, restrictedAccessGenres.TotalCount);
// Verify shared and Library 1 genres are present
AssertGenrePresent(restrictedAccessGenres, genres.SharedSeriesChaptersGenre);
AssertGenrePresent(restrictedAccessGenres, genres.SharedSeriesGenre);
AssertGenrePresent(restrictedAccessGenres, genres.SharedChaptersGenre);
AssertGenrePresent(restrictedAccessGenres, genres.Lib1SeriesChaptersGenre);
AssertGenrePresent(restrictedAccessGenres, genres.Lib1SeriesGenre);
AssertGenrePresent(restrictedAccessGenres, genres.Lib1ChaptersGenre);
AssertGenrePresent(restrictedAccessGenres, genres.Lib1ChapterAgeGenre);
// Verify Library 0 specific genres are not present
AssertGenreNotPresent(restrictedAccessGenres, genres.Lib0SeriesChaptersGenre);
AssertGenreNotPresent(restrictedAccessGenres, genres.Lib0SeriesGenre);
AssertGenreNotPresent(restrictedAccessGenres, genres.Lib0ChaptersGenre);
// Verify counts - 2 lib1 series
Assert.Equal(2, GetGenreDto(restrictedAccessGenres, genres.SharedSeriesChaptersGenre).SeriesCount);
Assert.Equal(4, GetGenreDto(restrictedAccessGenres, genres.SharedSeriesChaptersGenre).ChapterCount);
Assert.Equal(2, GetGenreDto(restrictedAccessGenres, genres.Lib1SeriesGenre).SeriesCount);
Assert.Equal(4, GetGenreDto(restrictedAccessGenres, genres.Lib1ChaptersGenre).ChapterCount);
Assert.Equal(1, GetGenreDto(restrictedAccessGenres, genres.Lib1ChapterAgeGenre).ChapterCount);
}
[Fact]
public async Task GetBrowseableGenre_RestrictedAgeAccess_FiltersAgeRestrictedContent()
{
// Arrange
await ResetDb();
var genres = CreateTestGenres();
await SeedDbWithGenres(genres);
// Act
var restrictedAgeAccessGenres = await UnitOfWork.GenreRepository.GetBrowseableGenre(_restrictedAgeAccess.Id, new UserParams());
// Assert - Should see: 3 shared + 3 lib1 specific = 6 genres (age-restricted genre filtered out)
Assert.Equal(6, restrictedAgeAccessGenres.TotalCount);
// Verify accessible genres are present
AssertGenrePresent(restrictedAgeAccessGenres, genres.SharedSeriesChaptersGenre);
AssertGenrePresent(restrictedAgeAccessGenres, genres.SharedSeriesGenre);
AssertGenrePresent(restrictedAgeAccessGenres, genres.SharedChaptersGenre);
AssertGenrePresent(restrictedAgeAccessGenres, genres.Lib1SeriesChaptersGenre);
AssertGenrePresent(restrictedAgeAccessGenres, genres.Lib1SeriesGenre);
AssertGenrePresent(restrictedAgeAccessGenres, genres.Lib1ChaptersGenre);
// Verify age-restricted genre is filtered out
AssertGenreNotPresent(restrictedAgeAccessGenres, genres.Lib1ChapterAgeGenre);
// Verify counts - 1 series lib1 (age-restricted series filtered out)
Assert.Equal(1, GetGenreDto(restrictedAgeAccessGenres, genres.SharedSeriesChaptersGenre).SeriesCount);
Assert.Equal(1, GetGenreDto(restrictedAgeAccessGenres, genres.Lib1SeriesGenre).SeriesCount);
// These values represent a bug - chapters are not properly filtered when their series is age-restricted
// Should be 2, but currently returns 3 due to the filtering issue
Assert.Equal(3, GetGenreDto(restrictedAgeAccessGenres, genres.SharedSeriesChaptersGenre).ChapterCount);
Assert.Equal(3, GetGenreDto(restrictedAgeAccessGenres, genres.Lib1ChaptersGenre).ChapterCount);
}
private class TestGenreSet
{
public Genre SharedSeriesChaptersGenre { get; set; }
public Genre SharedSeriesGenre { get; set; }
public Genre SharedChaptersGenre { get; set; }
public Genre Lib0SeriesChaptersGenre { get; set; }
public Genre Lib0SeriesGenre { get; set; }
public Genre Lib0ChaptersGenre { get; set; }
public Genre Lib1SeriesChaptersGenre { get; set; }
public Genre Lib1SeriesGenre { get; set; }
public Genre Lib1ChaptersGenre { get; set; }
public Genre Lib1ChapterAgeGenre { get; set; }
public List<Genre> GetAllGenres()
{
return
[
SharedSeriesChaptersGenre, SharedSeriesGenre, SharedChaptersGenre,
Lib0SeriesChaptersGenre, Lib0SeriesGenre, Lib0ChaptersGenre,
Lib1SeriesChaptersGenre, Lib1SeriesGenre, Lib1ChaptersGenre, Lib1ChapterAgeGenre
];
}
}
}

View file

@ -0,0 +1,342 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using API.DTOs.Metadata.Browse;
using API.DTOs.Metadata.Browse.Requests;
using API.Entities;
using API.Entities.Enums;
using API.Entities.Person;
using API.Helpers;
using API.Helpers.Builders;
using Xunit;
namespace API.Tests.Repository;
public class PersonRepositoryTests : AbstractDbTest
{
private AppUser _fullAccess;
private AppUser _restrictedAccess;
private AppUser _restrictedAgeAccess;
protected override async Task ResetDb()
{
Context.Person.RemoveRange(Context.Person.ToList());
Context.Library.RemoveRange(Context.Library.ToList());
Context.AppUser.RemoveRange(Context.AppUser.ToList());
await UnitOfWork.CommitAsync();
}
private async Task SeedDb()
{
_fullAccess = new AppUserBuilder("amelia", "amelia@example.com").Build();
_restrictedAccess = new AppUserBuilder("mila", "mila@example.com").Build();
_restrictedAgeAccess = new AppUserBuilder("eva", "eva@example.com").Build();
_restrictedAgeAccess.AgeRestriction = AgeRating.Teen;
_restrictedAgeAccess.AgeRestrictionIncludeUnknowns = true;
Context.AppUser.Add(_fullAccess);
Context.AppUser.Add(_restrictedAccess);
Context.AppUser.Add(_restrictedAgeAccess);
await Context.SaveChangesAsync();
var people = CreateTestPeople();
Context.Person.AddRange(people);
await Context.SaveChangesAsync();
var libraries = CreateTestLibraries(people);
Context.Library.AddRange(libraries);
await Context.SaveChangesAsync();
_fullAccess.Libraries.Add(libraries[0]); // lib0
_fullAccess.Libraries.Add(libraries[1]); // lib1
_restrictedAccess.Libraries.Add(libraries[1]); // lib1 only
_restrictedAgeAccess.Libraries.Add(libraries[1]); // lib1 only
await Context.SaveChangesAsync();
}
private static List<Person> CreateTestPeople()
{
return new List<Person>
{
new PersonBuilder("Shared Series Chapter Person").Build(),
new PersonBuilder("Shared Series Person").Build(),
new PersonBuilder("Shared Chapters Person").Build(),
new PersonBuilder("Lib0 Series Chapter Person").Build(),
new PersonBuilder("Lib0 Series Person").Build(),
new PersonBuilder("Lib0 Chapters Person").Build(),
new PersonBuilder("Lib1 Series Chapter Person").Build(),
new PersonBuilder("Lib1 Series Person").Build(),
new PersonBuilder("Lib1 Chapters Person").Build(),
new PersonBuilder("Lib1 Chapter Age Person").Build()
};
}
private static List<Library> CreateTestLibraries(List<Person> people)
{
var lib0 = new LibraryBuilder("lib0")
.WithSeries(new SeriesBuilder("lib0-s0")
.WithMetadata(new SeriesMetadataBuilder()
.WithPerson(GetPersonByName(people, "Shared Series Chapter Person"), PersonRole.Writer)
.WithPerson(GetPersonByName(people, "Shared Series Person"), PersonRole.Writer)
.WithPerson(GetPersonByName(people, "Lib0 Series Chapter Person"), PersonRole.Writer)
.WithPerson(GetPersonByName(people, "Lib0 Series Person"), PersonRole.Writer)
.Build())
.WithVolume(new VolumeBuilder("1")
.WithChapter(new ChapterBuilder("1")
.WithPerson(GetPersonByName(people, "Shared Series Chapter Person"), PersonRole.Colorist)
.WithPerson(GetPersonByName(people, "Shared Chapters Person"), PersonRole.Colorist)
.WithPerson(GetPersonByName(people, "Lib0 Series Chapter Person"), PersonRole.Colorist)
.WithPerson(GetPersonByName(people, "Lib0 Chapters Person"), PersonRole.Colorist)
.Build())
.WithChapter(new ChapterBuilder("2")
.WithPerson(GetPersonByName(people, "Shared Series Chapter Person"), PersonRole.Editor)
.WithPerson(GetPersonByName(people, "Shared Chapters Person"), PersonRole.Editor)
.WithPerson(GetPersonByName(people, "Lib0 Series Chapter Person"), PersonRole.Editor)
.WithPerson(GetPersonByName(people, "Lib0 Chapters Person"), PersonRole.Editor)
.Build())
.Build())
.Build())
.Build();
var lib1 = new LibraryBuilder("lib1")
.WithSeries(new SeriesBuilder("lib1-s0")
.WithMetadata(new SeriesMetadataBuilder()
.WithPerson(GetPersonByName(people, "Shared Series Chapter Person"), PersonRole.Letterer)
.WithPerson(GetPersonByName(people, "Shared Series Person"), PersonRole.Letterer)
.WithPerson(GetPersonByName(people, "Lib1 Series Chapter Person"), PersonRole.Letterer)
.WithPerson(GetPersonByName(people, "Lib1 Series Person"), PersonRole.Letterer)
.WithAgeRating(AgeRating.Mature17Plus)
.Build())
.WithVolume(new VolumeBuilder("1")
.WithChapter(new ChapterBuilder("1")
.WithPerson(GetPersonByName(people, "Shared Series Chapter Person"), PersonRole.Imprint)
.WithPerson(GetPersonByName(people, "Shared Chapters Person"), PersonRole.Imprint)
.WithPerson(GetPersonByName(people, "Lib1 Series Chapter Person"), PersonRole.Imprint)
.WithPerson(GetPersonByName(people, "Lib1 Chapters Person"), PersonRole.Imprint)
.Build())
.WithChapter(new ChapterBuilder("2")
.WithPerson(GetPersonByName(people, "Shared Series Chapter Person"), PersonRole.CoverArtist)
.WithPerson(GetPersonByName(people, "Shared Chapters Person"), PersonRole.CoverArtist)
.WithPerson(GetPersonByName(people, "Lib1 Series Chapter Person"), PersonRole.CoverArtist)
.WithPerson(GetPersonByName(people, "Lib1 Chapters Person"), PersonRole.CoverArtist)
.WithPerson(GetPersonByName(people, "Lib1 Chapter Age Person"), PersonRole.CoverArtist)
.WithAgeRating(AgeRating.Mature17Plus)
.Build())
.Build())
.Build())
.WithSeries(new SeriesBuilder("lib1-s1")
.WithMetadata(new SeriesMetadataBuilder()
.WithPerson(GetPersonByName(people, "Shared Series Chapter Person"), PersonRole.Inker)
.WithPerson(GetPersonByName(people, "Shared Series Person"), PersonRole.Inker)
.WithPerson(GetPersonByName(people, "Lib1 Series Chapter Person"), PersonRole.Inker)
.WithPerson(GetPersonByName(people, "Lib1 Series Person"), PersonRole.Inker)
.Build())
.WithVolume(new VolumeBuilder("1")
.WithChapter(new ChapterBuilder("1")
.WithPerson(GetPersonByName(people, "Shared Series Chapter Person"), PersonRole.Team)
.WithPerson(GetPersonByName(people, "Shared Chapters Person"), PersonRole.Team)
.WithPerson(GetPersonByName(people, "Lib1 Series Chapter Person"), PersonRole.Team)
.WithPerson(GetPersonByName(people, "Lib1 Chapters Person"), PersonRole.Team)
.Build())
.WithChapter(new ChapterBuilder("2")
.WithPerson(GetPersonByName(people, "Shared Series Chapter Person"), PersonRole.Translator)
.WithPerson(GetPersonByName(people, "Shared Chapters Person"), PersonRole.Translator)
.WithPerson(GetPersonByName(people, "Lib1 Series Chapter Person"), PersonRole.Translator)
.WithPerson(GetPersonByName(people, "Lib1 Chapters Person"), PersonRole.Translator)
.Build())
.Build())
.Build())
.Build();
return new List<Library> { lib0, lib1 };
}
private static Person GetPersonByName(List<Person> people, string name)
{
return people.First(p => p.Name == name);
}
private Person GetPersonByName(string name)
{
return Context.Person.First(p => p.Name == name);
}
private static Predicate<BrowsePersonDto> ContainsPersonCheck(Person person)
{
return p => p.Id == person.Id;
}
[Fact]
public async Task GetBrowsePersonDtos()
{
await ResetDb();
await SeedDb();
// Get people from database for assertions
var sharedSeriesChaptersPerson = GetPersonByName("Shared Series Chapter Person");
var lib0SeriesPerson = GetPersonByName("Lib0 Series Person");
var lib1SeriesPerson = GetPersonByName("Lib1 Series Person");
var lib1ChapterAgePerson = GetPersonByName("Lib1 Chapter Age Person");
var allPeople = Context.Person.ToList();
var fullAccessPeople =
await UnitOfWork.PersonRepository.GetBrowsePersonDtos(_fullAccess.Id, new BrowsePersonFilterDto(),
new UserParams());
Assert.Equal(allPeople.Count, fullAccessPeople.TotalCount);
foreach (var person in allPeople)
Assert.Contains(fullAccessPeople, ContainsPersonCheck(person));
// 1 series in lib0, 2 series in lib1
Assert.Equal(3, fullAccessPeople.First(dto => dto.Id == sharedSeriesChaptersPerson.Id).SeriesCount);
// 3 series with each 2 chapters
Assert.Equal(6, fullAccessPeople.First(dto => dto.Id == sharedSeriesChaptersPerson.Id).ChapterCount);
// 1 series in lib0
Assert.Equal(1, fullAccessPeople.First(dto => dto.Id == lib0SeriesPerson.Id).SeriesCount);
// 2 series in lib1
Assert.Equal(2, fullAccessPeople.First(dto => dto.Id == lib1SeriesPerson.Id).SeriesCount);
var restrictedAccessPeople =
await UnitOfWork.PersonRepository.GetBrowsePersonDtos(_restrictedAccess.Id, new BrowsePersonFilterDto(),
new UserParams());
Assert.Equal(7, restrictedAccessPeople.TotalCount);
Assert.Contains(restrictedAccessPeople, ContainsPersonCheck(GetPersonByName("Shared Series Chapter Person")));
Assert.Contains(restrictedAccessPeople, ContainsPersonCheck(GetPersonByName("Shared Series Person")));
Assert.Contains(restrictedAccessPeople, ContainsPersonCheck(GetPersonByName("Shared Chapters Person")));
Assert.Contains(restrictedAccessPeople, ContainsPersonCheck(GetPersonByName("Lib1 Series Chapter Person")));
Assert.Contains(restrictedAccessPeople, ContainsPersonCheck(GetPersonByName("Lib1 Series Person")));
Assert.Contains(restrictedAccessPeople, ContainsPersonCheck(GetPersonByName("Lib1 Chapters Person")));
Assert.Contains(restrictedAccessPeople, ContainsPersonCheck(GetPersonByName("Lib1 Chapter Age Person")));
// 2 series in lib1, no series in lib0
Assert.Equal(2, restrictedAccessPeople.First(dto => dto.Id == sharedSeriesChaptersPerson.Id).SeriesCount);
// 2 series with each 2 chapters
Assert.Equal(4, restrictedAccessPeople.First(dto => dto.Id == sharedSeriesChaptersPerson.Id).ChapterCount);
// 2 series in lib1
Assert.Equal(2, restrictedAccessPeople.First(dto => dto.Id == lib1SeriesPerson.Id).SeriesCount);
var restrictedAgeAccessPeople = await UnitOfWork.PersonRepository.GetBrowsePersonDtos(_restrictedAgeAccess.Id,
new BrowsePersonFilterDto(), new UserParams());
// Note: There is a potential bug here where a person in a different chapter of an age restricted series will show up
Assert.Equal(6, restrictedAgeAccessPeople.TotalCount);
// No access to the age restricted chapter
Assert.DoesNotContain(restrictedAgeAccessPeople, ContainsPersonCheck(lib1ChapterAgePerson));
}
[Fact]
public async Task GetRolesForPersonByName()
{
await ResetDb();
await SeedDb();
var sharedSeriesPerson = GetPersonByName("Shared Series Person");
var sharedChaptersPerson = GetPersonByName("Shared Chapters Person");
var lib1ChapterAgePerson = GetPersonByName("Lib1 Chapter Age Person");
var sharedSeriesRoles = await UnitOfWork.PersonRepository.GetRolesForPersonByName(sharedSeriesPerson.Id, _fullAccess.Id);
var chapterRoles = await UnitOfWork.PersonRepository.GetRolesForPersonByName(sharedChaptersPerson.Id, _fullAccess.Id);
var ageChapterRoles = await UnitOfWork.PersonRepository.GetRolesForPersonByName(lib1ChapterAgePerson.Id, _fullAccess.Id);
Assert.Equal(3, sharedSeriesRoles.Count());
Assert.Equal(6, chapterRoles.Count());
Assert.Single(ageChapterRoles);
var restrictedRoles = await UnitOfWork.PersonRepository.GetRolesForPersonByName(sharedSeriesPerson.Id, _restrictedAccess.Id);
var restrictedChapterRoles = await UnitOfWork.PersonRepository.GetRolesForPersonByName(sharedChaptersPerson.Id, _restrictedAccess.Id);
var restrictedAgePersonChapterRoles = await UnitOfWork.PersonRepository.GetRolesForPersonByName(lib1ChapterAgePerson.Id, _restrictedAccess.Id);
Assert.Equal(2, restrictedRoles.Count());
Assert.Equal(4, restrictedChapterRoles.Count());
Assert.Single(restrictedAgePersonChapterRoles);
var restrictedAgeRoles = await UnitOfWork.PersonRepository.GetRolesForPersonByName(sharedSeriesPerson.Id, _restrictedAgeAccess.Id);
var restrictedAgeChapterRoles = await UnitOfWork.PersonRepository.GetRolesForPersonByName(sharedChaptersPerson.Id, _restrictedAgeAccess.Id);
var restrictedAgeAgePersonChapterRoles = await UnitOfWork.PersonRepository.GetRolesForPersonByName(lib1ChapterAgePerson.Id, _restrictedAgeAccess.Id);
Assert.Single(restrictedAgeRoles);
Assert.Equal(2, restrictedAgeChapterRoles.Count());
// Note: There is a potential bug here where a person in a different chapter of an age restricted series will show up
Assert.Empty(restrictedAgeAgePersonChapterRoles);
}
[Fact]
public async Task GetPersonDtoByName()
{
await ResetDb();
await SeedDb();
var allPeople = Context.Person.ToList();
foreach (var person in allPeople)
{
Assert.NotNull(await UnitOfWork.PersonRepository.GetPersonDtoByName(person.Name, _fullAccess.Id));
}
Assert.Null(await UnitOfWork.PersonRepository.GetPersonDtoByName("Lib0 Chapters Person", _restrictedAccess.Id));
Assert.NotNull(await UnitOfWork.PersonRepository.GetPersonDtoByName("Shared Series Person", _restrictedAccess.Id));
Assert.NotNull(await UnitOfWork.PersonRepository.GetPersonDtoByName("Lib1 Series Person", _restrictedAccess.Id));
Assert.Null(await UnitOfWork.PersonRepository.GetPersonDtoByName("Lib0 Chapters Person", _restrictedAgeAccess.Id));
Assert.NotNull(await UnitOfWork.PersonRepository.GetPersonDtoByName("Lib1 Series Person", _restrictedAgeAccess.Id));
// Note: There is a potential bug here where a person in a different chapter of an age restricted series will show up
Assert.Null(await UnitOfWork.PersonRepository.GetPersonDtoByName("Lib1 Chapter Age Person", _restrictedAgeAccess.Id));
}
[Fact]
public async Task GetSeriesKnownFor()
{
await ResetDb();
await SeedDb();
var sharedSeriesPerson = GetPersonByName("Shared Series Person");
var lib1SeriesPerson = GetPersonByName("Lib1 Series Person");
var series = await UnitOfWork.PersonRepository.GetSeriesKnownFor(sharedSeriesPerson.Id, _fullAccess.Id);
Assert.Equal(3, series.Count());
series = await UnitOfWork.PersonRepository.GetSeriesKnownFor(sharedSeriesPerson.Id, _restrictedAccess.Id);
Assert.Equal(2, series.Count());
series = await UnitOfWork.PersonRepository.GetSeriesKnownFor(sharedSeriesPerson.Id, _restrictedAgeAccess.Id);
Assert.Single(series);
series = await UnitOfWork.PersonRepository.GetSeriesKnownFor(lib1SeriesPerson.Id, _restrictedAgeAccess.Id);
Assert.Single(series);
}
[Fact]
public async Task GetChaptersForPersonByRole()
{
await ResetDb();
await SeedDb();
var sharedChaptersPerson = GetPersonByName("Shared Chapters Person");
// Lib0
var chapters = await UnitOfWork.PersonRepository.GetChaptersForPersonByRole(sharedChaptersPerson.Id, _fullAccess.Id, PersonRole.Colorist);
var restrictedChapters = await UnitOfWork.PersonRepository.GetChaptersForPersonByRole(sharedChaptersPerson.Id, _restrictedAccess.Id, PersonRole.Colorist);
var restrictedAgeChapters = await UnitOfWork.PersonRepository.GetChaptersForPersonByRole(sharedChaptersPerson.Id, _restrictedAgeAccess.Id, PersonRole.Colorist);
Assert.Single(chapters);
Assert.Empty(restrictedChapters);
Assert.Empty(restrictedAgeChapters);
// Lib1 - age restricted series
chapters = await UnitOfWork.PersonRepository.GetChaptersForPersonByRole(sharedChaptersPerson.Id, _fullAccess.Id, PersonRole.Imprint);
restrictedChapters = await UnitOfWork.PersonRepository.GetChaptersForPersonByRole(sharedChaptersPerson.Id, _restrictedAccess.Id, PersonRole.Imprint);
restrictedAgeChapters = await UnitOfWork.PersonRepository.GetChaptersForPersonByRole(sharedChaptersPerson.Id, _restrictedAgeAccess.Id, PersonRole.Imprint);
Assert.Single(chapters);
Assert.Single(restrictedChapters);
Assert.Empty(restrictedAgeChapters);
// Lib1 - not age restricted series
chapters = await UnitOfWork.PersonRepository.GetChaptersForPersonByRole(sharedChaptersPerson.Id, _fullAccess.Id, PersonRole.Team);
restrictedChapters = await UnitOfWork.PersonRepository.GetChaptersForPersonByRole(sharedChaptersPerson.Id, _restrictedAccess.Id, PersonRole.Team);
restrictedAgeChapters = await UnitOfWork.PersonRepository.GetChaptersForPersonByRole(sharedChaptersPerson.Id, _restrictedAgeAccess.Id, PersonRole.Team);
Assert.Single(chapters);
Assert.Single(restrictedChapters);
Assert.Single(restrictedAgeChapters);
}
}

View file

@ -6,7 +6,6 @@ using System.Threading.Tasks;
using API.Data;
using API.Entities;
using API.Entities.Enums;
using API.Extensions;
using API.Helpers;
using API.Helpers.Builders;
using API.Services;

View file

@ -0,0 +1,278 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using API.DTOs.Metadata.Browse;
using API.Entities;
using API.Entities.Enums;
using API.Entities.Metadata;
using API.Helpers;
using API.Helpers.Builders;
using Xunit;
namespace API.Tests.Repository;
public class TagRepositoryTests : AbstractDbTest
{
private AppUser _fullAccess;
private AppUser _restrictedAccess;
private AppUser _restrictedAgeAccess;
protected override async Task ResetDb()
{
Context.Tag.RemoveRange(Context.Tag);
Context.Library.RemoveRange(Context.Library);
await Context.SaveChangesAsync();
}
private TestTagSet CreateTestTags()
{
return new TestTagSet
{
SharedSeriesChaptersTag = new TagBuilder("Shared Series Chapter Tag").Build(),
SharedSeriesTag = new TagBuilder("Shared Series Tag").Build(),
SharedChaptersTag = new TagBuilder("Shared Chapters Tag").Build(),
Lib0SeriesChaptersTag = new TagBuilder("Lib0 Series Chapter Tag").Build(),
Lib0SeriesTag = new TagBuilder("Lib0 Series Tag").Build(),
Lib0ChaptersTag = new TagBuilder("Lib0 Chapters Tag").Build(),
Lib1SeriesChaptersTag = new TagBuilder("Lib1 Series Chapter Tag").Build(),
Lib1SeriesTag = new TagBuilder("Lib1 Series Tag").Build(),
Lib1ChaptersTag = new TagBuilder("Lib1 Chapters Tag").Build(),
Lib1ChapterAgeTag = new TagBuilder("Lib1 Chapter Age Tag").Build()
};
}
private async Task SeedDbWithTags(TestTagSet tags)
{
await CreateTestUsers();
await AddTagsToContext(tags);
await CreateLibrariesWithTags(tags);
await AssignLibrariesToUsers();
}
private async Task CreateTestUsers()
{
_fullAccess = new AppUserBuilder("amelia", "amelia@example.com").Build();
_restrictedAccess = new AppUserBuilder("mila", "mila@example.com").Build();
_restrictedAgeAccess = new AppUserBuilder("eva", "eva@example.com").Build();
_restrictedAgeAccess.AgeRestriction = AgeRating.Teen;
_restrictedAgeAccess.AgeRestrictionIncludeUnknowns = true;
Context.Users.Add(_fullAccess);
Context.Users.Add(_restrictedAccess);
Context.Users.Add(_restrictedAgeAccess);
await Context.SaveChangesAsync();
}
private async Task AddTagsToContext(TestTagSet tags)
{
var allTags = tags.GetAllTags();
Context.Tag.AddRange(allTags);
await Context.SaveChangesAsync();
}
private async Task CreateLibrariesWithTags(TestTagSet tags)
{
var lib0 = new LibraryBuilder("lib0")
.WithSeries(new SeriesBuilder("lib0-s0")
.WithMetadata(new SeriesMetadata
{
Tags = [tags.SharedSeriesChaptersTag, tags.SharedSeriesTag, tags.Lib0SeriesChaptersTag, tags.Lib0SeriesTag]
})
.WithVolume(new VolumeBuilder("1")
.WithChapter(new ChapterBuilder("1")
.WithTags([tags.SharedSeriesChaptersTag, tags.SharedChaptersTag, tags.Lib0SeriesChaptersTag, tags.Lib0ChaptersTag])
.Build())
.WithChapter(new ChapterBuilder("2")
.WithTags([tags.SharedSeriesChaptersTag, tags.SharedChaptersTag, tags.Lib1SeriesChaptersTag, tags.Lib1ChaptersTag])
.Build())
.Build())
.Build())
.Build();
var lib1 = new LibraryBuilder("lib1")
.WithSeries(new SeriesBuilder("lib1-s0")
.WithMetadata(new SeriesMetadataBuilder()
.WithTags([tags.SharedSeriesChaptersTag, tags.SharedSeriesTag, tags.Lib1SeriesChaptersTag, tags.Lib1SeriesTag])
.WithAgeRating(AgeRating.Mature17Plus)
.Build())
.WithVolume(new VolumeBuilder("1")
.WithChapter(new ChapterBuilder("1")
.WithTags([tags.SharedSeriesChaptersTag, tags.SharedChaptersTag, tags.Lib1SeriesChaptersTag, tags.Lib1ChaptersTag])
.Build())
.WithChapter(new ChapterBuilder("2")
.WithTags([tags.SharedSeriesChaptersTag, tags.SharedChaptersTag, tags.Lib1SeriesChaptersTag, tags.Lib1ChaptersTag, tags.Lib1ChapterAgeTag])
.WithAgeRating(AgeRating.Mature17Plus)
.Build())
.Build())
.Build())
.WithSeries(new SeriesBuilder("lib1-s1")
.WithMetadata(new SeriesMetadataBuilder()
.WithTags([tags.SharedSeriesChaptersTag, tags.SharedSeriesTag, tags.Lib1SeriesChaptersTag, tags.Lib1SeriesTag])
.Build())
.WithVolume(new VolumeBuilder("1")
.WithChapter(new ChapterBuilder("1")
.WithTags([tags.SharedSeriesChaptersTag, tags.SharedChaptersTag, tags.Lib1SeriesChaptersTag, tags.Lib1ChaptersTag])
.Build())
.WithChapter(new ChapterBuilder("2")
.WithTags([tags.SharedSeriesChaptersTag, tags.SharedChaptersTag, tags.Lib1SeriesChaptersTag, tags.Lib1ChaptersTag])
.WithAgeRating(AgeRating.Mature17Plus)
.Build())
.Build())
.Build())
.Build();
Context.Library.Add(lib0);
Context.Library.Add(lib1);
await Context.SaveChangesAsync();
}
private async Task AssignLibrariesToUsers()
{
var lib0 = Context.Library.First(l => l.Name == "lib0");
var lib1 = Context.Library.First(l => l.Name == "lib1");
_fullAccess.Libraries.Add(lib0);
_fullAccess.Libraries.Add(lib1);
_restrictedAccess.Libraries.Add(lib1);
_restrictedAgeAccess.Libraries.Add(lib1);
await Context.SaveChangesAsync();
}
private static Predicate<BrowseTagDto> ContainsTagCheck(Tag tag)
{
return t => t.Id == tag.Id;
}
private static void AssertTagPresent(IEnumerable<BrowseTagDto> tags, Tag expectedTag)
{
Assert.Contains(tags, ContainsTagCheck(expectedTag));
}
private static void AssertTagNotPresent(IEnumerable<BrowseTagDto> tags, Tag expectedTag)
{
Assert.DoesNotContain(tags, ContainsTagCheck(expectedTag));
}
private static BrowseTagDto GetTagDto(IEnumerable<BrowseTagDto> tags, Tag tag)
{
return tags.First(dto => dto.Id == tag.Id);
}
[Fact]
public async Task GetBrowseableTag_FullAccess_ReturnsAllTagsWithCorrectCounts()
{
// Arrange
await ResetDb();
var tags = CreateTestTags();
await SeedDbWithTags(tags);
// Act
var fullAccessTags = await UnitOfWork.TagRepository.GetBrowseableTag(_fullAccess.Id, new UserParams());
// Assert
Assert.Equal(tags.GetAllTags().Count, fullAccessTags.TotalCount);
foreach (var tag in tags.GetAllTags())
{
AssertTagPresent(fullAccessTags, tag);
}
// Verify counts - 1 series lib0, 2 series lib1 = 3 total series
Assert.Equal(3, GetTagDto(fullAccessTags, tags.SharedSeriesChaptersTag).SeriesCount);
Assert.Equal(6, GetTagDto(fullAccessTags, tags.SharedSeriesChaptersTag).ChapterCount);
Assert.Equal(1, GetTagDto(fullAccessTags, tags.Lib0SeriesTag).SeriesCount);
}
[Fact]
public async Task GetBrowseableTag_RestrictedAccess_ReturnsOnlyAccessibleTags()
{
// Arrange
await ResetDb();
var tags = CreateTestTags();
await SeedDbWithTags(tags);
// Act
var restrictedAccessTags = await UnitOfWork.TagRepository.GetBrowseableTag(_restrictedAccess.Id, new UserParams());
// Assert - Should see: 3 shared + 4 library 1 specific = 7 tags
Assert.Equal(7, restrictedAccessTags.TotalCount);
// Verify shared and Library 1 tags are present
AssertTagPresent(restrictedAccessTags, tags.SharedSeriesChaptersTag);
AssertTagPresent(restrictedAccessTags, tags.SharedSeriesTag);
AssertTagPresent(restrictedAccessTags, tags.SharedChaptersTag);
AssertTagPresent(restrictedAccessTags, tags.Lib1SeriesChaptersTag);
AssertTagPresent(restrictedAccessTags, tags.Lib1SeriesTag);
AssertTagPresent(restrictedAccessTags, tags.Lib1ChaptersTag);
AssertTagPresent(restrictedAccessTags, tags.Lib1ChapterAgeTag);
// Verify Library 0 specific tags are not present
AssertTagNotPresent(restrictedAccessTags, tags.Lib0SeriesChaptersTag);
AssertTagNotPresent(restrictedAccessTags, tags.Lib0SeriesTag);
AssertTagNotPresent(restrictedAccessTags, tags.Lib0ChaptersTag);
// Verify counts - 2 series lib1
Assert.Equal(2, GetTagDto(restrictedAccessTags, tags.SharedSeriesChaptersTag).SeriesCount);
Assert.Equal(4, GetTagDto(restrictedAccessTags, tags.SharedSeriesChaptersTag).ChapterCount);
Assert.Equal(2, GetTagDto(restrictedAccessTags, tags.Lib1SeriesTag).SeriesCount);
Assert.Equal(4, GetTagDto(restrictedAccessTags, tags.Lib1ChaptersTag).ChapterCount);
}
[Fact]
public async Task GetBrowseableTag_RestrictedAgeAccess_FiltersAgeRestrictedContent()
{
// Arrange
await ResetDb();
var tags = CreateTestTags();
await SeedDbWithTags(tags);
// Act
var restrictedAgeAccessTags = await UnitOfWork.TagRepository.GetBrowseableTag(_restrictedAgeAccess.Id, new UserParams());
// Assert - Should see: 3 shared + 3 lib1 specific = 6 tags (age-restricted tag filtered out)
Assert.Equal(6, restrictedAgeAccessTags.TotalCount);
// Verify accessible tags are present
AssertTagPresent(restrictedAgeAccessTags, tags.SharedSeriesChaptersTag);
AssertTagPresent(restrictedAgeAccessTags, tags.SharedSeriesTag);
AssertTagPresent(restrictedAgeAccessTags, tags.SharedChaptersTag);
AssertTagPresent(restrictedAgeAccessTags, tags.Lib1SeriesChaptersTag);
AssertTagPresent(restrictedAgeAccessTags, tags.Lib1SeriesTag);
AssertTagPresent(restrictedAgeAccessTags, tags.Lib1ChaptersTag);
// Verify age-restricted tag is filtered out
AssertTagNotPresent(restrictedAgeAccessTags, tags.Lib1ChapterAgeTag);
// Verify counts - 1 series lib1 (age-restricted series filtered out)
Assert.Equal(1, GetTagDto(restrictedAgeAccessTags, tags.SharedSeriesChaptersTag).SeriesCount);
Assert.Equal(2, GetTagDto(restrictedAgeAccessTags, tags.SharedSeriesChaptersTag).ChapterCount);
Assert.Equal(1, GetTagDto(restrictedAgeAccessTags, tags.Lib1SeriesTag).SeriesCount);
Assert.Equal(2, GetTagDto(restrictedAgeAccessTags, tags.Lib1ChaptersTag).ChapterCount);
}
private class TestTagSet
{
public Tag SharedSeriesChaptersTag { get; set; }
public Tag SharedSeriesTag { get; set; }
public Tag SharedChaptersTag { get; set; }
public Tag Lib0SeriesChaptersTag { get; set; }
public Tag Lib0SeriesTag { get; set; }
public Tag Lib0ChaptersTag { get; set; }
public Tag Lib1SeriesChaptersTag { get; set; }
public Tag Lib1SeriesTag { get; set; }
public Tag Lib1ChaptersTag { get; set; }
public Tag Lib1ChapterAgeTag { get; set; }
public List<Tag> GetAllTags()
{
return
[
SharedSeriesChaptersTag, SharedSeriesTag, SharedChaptersTag,
Lib0SeriesChaptersTag, Lib0SeriesTag, Lib0ChaptersTag,
Lib1SeriesChaptersTag, Lib1SeriesTag, Lib1ChaptersTag, Lib1ChapterAgeTag
];
}
}
}

View file

@ -7,7 +7,6 @@ using System.Linq;
using API.Archive;
using API.Entities.Enums;
using API.Services;
using EasyCaching.Core;
using Microsoft.Extensions.Logging;
using NetVips;
using NSubstitute;

View file

@ -1,10 +1,8 @@
using System.Collections.Generic;
using System.Data.Common;
using System.Data.Common;
using System.IO.Abstractions.TestingHelpers;
using System.Linq;
using System.Threading.Tasks;
using API.Data;
using API.Entities;
using API.Entities.Enums;
using API.Helpers.Builders;
using API.Services;
@ -21,7 +19,7 @@ using Xunit;
namespace API.Tests.Services;
public class BackupServiceTests
public class BackupServiceTests: AbstractFsTest
{
private readonly ILogger<BackupService> _logger = Substitute.For<ILogger<BackupService>>();
private readonly IUnitOfWork _unitOfWork;
@ -31,13 +29,6 @@ public class BackupServiceTests
private readonly DbConnection _connection;
private readonly DataContext _context;
private const string CacheDirectory = "C:/kavita/config/cache/";
private const string CoverImageDirectory = "C:/kavita/config/covers/";
private const string BackupDirectory = "C:/kavita/config/backups/";
private const string LogDirectory = "C:/kavita/config/logs/";
private const string ConfigDirectory = "C:/kavita/config/";
private const string BookmarkDirectory = "C:/kavita/config/bookmarks";
private const string ThemesDirectory = "C:/kavita/config/theme";
public BackupServiceTests()
{
@ -82,7 +73,7 @@ public class BackupServiceTests
_context.ServerSetting.Update(setting);
_context.Library.Add(new LibraryBuilder("Manga")
.WithFolderPath(new FolderPathBuilder("C:/data/").Build())
.WithFolderPath(new FolderPathBuilder(Root + "data/").Build())
.Build());
return await _context.SaveChangesAsync() > 0;
}
@ -94,22 +85,6 @@ public class BackupServiceTests
await _context.SaveChangesAsync();
}
private static MockFileSystem CreateFileSystem()
{
var fileSystem = new MockFileSystem();
fileSystem.Directory.SetCurrentDirectory("C:/kavita/");
fileSystem.AddDirectory("C:/kavita/config/");
fileSystem.AddDirectory(CacheDirectory);
fileSystem.AddDirectory(CoverImageDirectory);
fileSystem.AddDirectory(BackupDirectory);
fileSystem.AddDirectory(LogDirectory);
fileSystem.AddDirectory(ThemesDirectory);
fileSystem.AddDirectory(BookmarkDirectory);
fileSystem.AddDirectory("C:/data/");
return fileSystem;
}
#endregion

View file

@ -1,7 +1,8 @@
using System.IO;
using System.IO.Abstractions;
using API.Entities.Enums;
using API.Services;
using EasyCaching.Core;
using API.Services.Tasks.Scanner.Parser;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Xunit;
@ -92,18 +93,17 @@ public class BookServiceTests
Assert.Equal("Georges Bizet \\(1838-1875\\)", comicInfo.Writer);
}
// TODO: Get the file from microtherion
// [Fact]
// public void ShouldUsePdfInfoDict()
// {
// var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ScannerService/Library/Books/PDFs");
// var document = Path.Join(testDirectory, "Rollo at Work SP01.pdf");
// var comicInfo = _bookService.GetComicInfo(document);
// Assert.NotNull(comicInfo);
// Assert.Equal("Rollo at Work", comicInfo.Title);
// Assert.Equal("Jacob Abbott", comicInfo.Writer);
// Assert.Equal(2008, comicInfo.Year);
// }
//[Fact]
public void ShouldUsePdfInfoDict()
{
var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ScannerService/Library/Books/PDFs");
var document = Path.Join(testDirectory, "Rollo at Work SP01.pdf");
var comicInfo = _bookService.GetComicInfo(document);
Assert.NotNull(comicInfo);
Assert.Equal("Rollo at Work", comicInfo.Title);
Assert.Equal("Jacob Abbott", comicInfo.Writer);
Assert.Equal(2008, comicInfo.Year);
}
[Fact]
public void ShouldHandleIndirectPdfObjects()
@ -124,4 +124,22 @@ public class BookServiceTests
var comicInfo = _bookService.GetComicInfo(document);
Assert.Null(comicInfo);
}
[Fact]
public void SeriesFallBackToMetadataTitle()
{
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), new FileSystem());
var pdfParser = new PdfParser(ds);
var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/BookService");
var filePath = Path.Join(testDirectory, "Bizet-Variations_Chromatiques_de_concert_Theme_A4.pdf");
var comicInfo = _bookService.GetComicInfo(filePath);
Assert.NotNull(comicInfo);
var parserInfo = pdfParser.Parse(filePath, testDirectory, ds.GetParentDirectoryName(testDirectory), LibraryType.Book, true, comicInfo);
Assert.NotNull(parserInfo);
Assert.Equal(parserInfo.Title, comicInfo.Title);
Assert.Equal(parserInfo.Series, comicInfo.Title);
}
}

View file

@ -9,12 +9,9 @@ using API.Data.Repositories;
using API.DTOs.Reader;
using API.Entities;
using API.Entities.Enums;
using API.Entities.Metadata;
using API.Extensions;
using API.Helpers;
using API.Helpers.Builders;
using API.Services;
using API.SignalR;
using AutoMapper;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
@ -25,17 +22,12 @@ using Xunit;
namespace API.Tests.Services;
public class BookmarkServiceTests
public class BookmarkServiceTests: AbstractFsTest
{
private readonly IUnitOfWork _unitOfWork;
private readonly DbConnection _connection;
private readonly DataContext _context;
private const string CacheDirectory = "C:/kavita/config/cache/";
private const string CoverImageDirectory = "C:/kavita/config/covers/";
private const string BackupDirectory = "C:/kavita/config/backups/";
private const string BookmarkDirectory = "C:/kavita/config/bookmarks/";
public BookmarkServiceTests()
{
@ -88,7 +80,7 @@ Substitute.For<IMediaConversionService>());
_context.ServerSetting.Update(setting);
_context.Library.Add(new LibraryBuilder("Manga")
.WithFolderPath(new FolderPathBuilder("C:/data/").Build())
.WithFolderPath(new FolderPathBuilder(Root + "data/").Build())
.Build());
return await _context.SaveChangesAsync() > 0;
}
@ -102,20 +94,6 @@ Substitute.For<IMediaConversionService>());
await _context.SaveChangesAsync();
}
private static MockFileSystem CreateFileSystem()
{
var fileSystem = new MockFileSystem();
fileSystem.Directory.SetCurrentDirectory("C:/kavita/");
fileSystem.AddDirectory("C:/kavita/config/");
fileSystem.AddDirectory(CacheDirectory);
fileSystem.AddDirectory(CoverImageDirectory);
fileSystem.AddDirectory(BackupDirectory);
fileSystem.AddDirectory(BookmarkDirectory);
fileSystem.AddDirectory("C:/data/");
return fileSystem;
}
#endregion
#region BookmarkPage

View file

@ -1,12 +1,10 @@
using System.Collections.Generic;
using System.Data.Common;
using System.Data.Common;
using System.IO;
using System.IO.Abstractions.TestingHelpers;
using System.Linq;
using System.Threading.Tasks;
using API.Data;
using API.Data.Metadata;
using API.Entities;
using API.Entities.Enums;
using API.Helpers.Builders;
using API.Services;
@ -52,17 +50,17 @@ internal class MockReadingItemServiceForCacheService : IReadingItemService
throw new System.NotImplementedException();
}
public ParserInfo Parse(string path, string rootPath, string libraryRoot, LibraryType type)
public ParserInfo Parse(string path, string rootPath, string libraryRoot, LibraryType type, bool enableMetadata = true)
{
throw new System.NotImplementedException();
}
public ParserInfo ParseFile(string path, string rootPath, string libraryRoot, LibraryType type)
public ParserInfo ParseFile(string path, string rootPath, string libraryRoot, LibraryType type, bool enableMetadata = true)
{
throw new System.NotImplementedException();
}
}
public class CacheServiceTests
public class CacheServiceTests: AbstractFsTest
{
private readonly ILogger<CacheService> _logger = Substitute.For<ILogger<CacheService>>();
private readonly IUnitOfWork _unitOfWork;
@ -71,11 +69,6 @@ public class CacheServiceTests
private readonly DbConnection _connection;
private readonly DataContext _context;
private const string CacheDirectory = "C:/kavita/config/cache/";
private const string CoverImageDirectory = "C:/kavita/config/covers/";
private const string BackupDirectory = "C:/kavita/config/backups/";
private const string DataDirectory = "C:/data/";
public CacheServiceTests()
{
var contextOptions = new DbContextOptionsBuilder()
@ -118,7 +111,7 @@ public class CacheServiceTests
_context.ServerSetting.Update(setting);
_context.Library.Add(new LibraryBuilder("Manga")
.WithFolderPath(new FolderPathBuilder("C:/data/").Build())
.WithFolderPath(new FolderPathBuilder(Root + "data/").Build())
.Build());
return await _context.SaveChangesAsync() > 0;
}
@ -130,19 +123,6 @@ public class CacheServiceTests
await _context.SaveChangesAsync();
}
private static MockFileSystem CreateFileSystem()
{
var fileSystem = new MockFileSystem();
fileSystem.Directory.SetCurrentDirectory("C:/kavita/");
fileSystem.AddDirectory("C:/kavita/config/");
fileSystem.AddDirectory(CacheDirectory);
fileSystem.AddDirectory(CoverImageDirectory);
fileSystem.AddDirectory(BackupDirectory);
fileSystem.AddDirectory(DataDirectory);
return fileSystem;
}
#endregion
#region Ensure
@ -263,7 +243,7 @@ public class CacheServiceTests
.WithFile(new MangaFileBuilder($"{DataDirectory}2.epub", MangaFormat.Epub).Build())
.Build();
cs.GetCachedFile(c);
Assert.Same($"{DataDirectory}1.epub", cs.GetCachedFile(c));
Assert.Equal($"{DataDirectory}1.epub", cs.GetCachedFile(c));
}
#endregion

View file

@ -1,16 +1,13 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Abstractions;
using System.IO.Abstractions.TestingHelpers;
using System.Linq;
using System.Threading.Tasks;
using API.Data;
using API.Data.Repositories;
using API.DTOs.Filtering;
using API.Entities;
using API.Entities.Enums;
using API.Entities.Metadata;
using API.Extensions;
using API.Helpers;
using API.Helpers.Builders;
@ -30,14 +27,13 @@ public class CleanupServiceTests : AbstractDbTest
private readonly IEventHub _messageHub = Substitute.For<IEventHub>();
private readonly IReaderService _readerService;
public CleanupServiceTests() : base()
{
_context.Library.Add(new LibraryBuilder("Manga")
.WithFolderPath(new FolderPathBuilder("C:/data/").Build())
Context.Library.Add(new LibraryBuilder("Manga")
.WithFolderPath(new FolderPathBuilder(Root + "data/").Build())
.Build());
_readerService = new ReaderService(_unitOfWork, Substitute.For<ILogger<ReaderService>>(), Substitute.For<IEventHub>(),
_readerService = new ReaderService(UnitOfWork, Substitute.For<ILogger<ReaderService>>(), Substitute.For<IEventHub>(),
Substitute.For<IImageService>(),
new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), new MockFileSystem()), Substitute.For<IScrobblingService>());
}
@ -47,11 +43,11 @@ public class CleanupServiceTests : AbstractDbTest
protected override async Task ResetDb()
{
_context.Series.RemoveRange(_context.Series.ToList());
_context.Users.RemoveRange(_context.Users.ToList());
_context.AppUserBookmark.RemoveRange(_context.AppUserBookmark.ToList());
Context.Series.RemoveRange(Context.Series.ToList());
Context.Users.RemoveRange(Context.Users.ToList());
Context.AppUserBookmark.RemoveRange(Context.AppUserBookmark.ToList());
await _context.SaveChangesAsync();
await Context.SaveChangesAsync();
}
#endregion
@ -72,18 +68,18 @@ public class CleanupServiceTests : AbstractDbTest
var s = new SeriesBuilder("Test 1").Build();
s.CoverImage = $"{ImageService.GetSeriesFormat(1)}.jpg";
s.LibraryId = 1;
_context.Series.Add(s);
Context.Series.Add(s);
s = new SeriesBuilder("Test 2").Build();
s.CoverImage = $"{ImageService.GetSeriesFormat(3)}.jpg";
s.LibraryId = 1;
_context.Series.Add(s);
Context.Series.Add(s);
s = new SeriesBuilder("Test 3").Build();
s.CoverImage = $"{ImageService.GetSeriesFormat(1000)}.jpg";
s.LibraryId = 1;
_context.Series.Add(s);
Context.Series.Add(s);
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), filesystem);
var cleanupService = new CleanupService(_logger, _unitOfWork, _messageHub,
var cleanupService = new CleanupService(_logger, UnitOfWork, _messageHub,
ds);
await cleanupService.DeleteSeriesCoverImages();
@ -106,16 +102,16 @@ public class CleanupServiceTests : AbstractDbTest
var s = new SeriesBuilder("Test 1").Build();
s.CoverImage = $"{ImageService.GetSeriesFormat(1)}.jpg";
s.LibraryId = 1;
_context.Series.Add(s);
Context.Series.Add(s);
s = new SeriesBuilder("Test 2").Build();
s.CoverImage = $"{ImageService.GetSeriesFormat(3)}.jpg";
s.LibraryId = 1;
_context.Series.Add(s);
Context.Series.Add(s);
await _context.SaveChangesAsync();
await Context.SaveChangesAsync();
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), filesystem);
var cleanupService = new CleanupService(_logger, _unitOfWork, _messageHub,
var cleanupService = new CleanupService(_logger, UnitOfWork, _messageHub,
ds);
await cleanupService.DeleteSeriesCoverImages();
@ -137,7 +133,7 @@ public class CleanupServiceTests : AbstractDbTest
await ResetDb();
// Add 2 series with cover images
_context.Series.Add(new SeriesBuilder("Test 1")
Context.Series.Add(new SeriesBuilder("Test 1")
.WithVolume(new VolumeBuilder("1")
.WithChapter(new ChapterBuilder(API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter).WithCoverImage("v01_c01.jpg").Build())
.WithCoverImage("v01_c01.jpg")
@ -146,7 +142,7 @@ public class CleanupServiceTests : AbstractDbTest
.WithLibraryId(1)
.Build());
_context.Series.Add(new SeriesBuilder("Test 2")
Context.Series.Add(new SeriesBuilder("Test 2")
.WithVolume(new VolumeBuilder("1")
.WithChapter(new ChapterBuilder(API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter).WithCoverImage("v01_c03.jpg").Build())
.WithCoverImage("v01_c03.jpg")
@ -156,9 +152,9 @@ public class CleanupServiceTests : AbstractDbTest
.Build());
await _context.SaveChangesAsync();
await Context.SaveChangesAsync();
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), filesystem);
var cleanupService = new CleanupService(_logger, _unitOfWork, _messageHub,
var cleanupService = new CleanupService(_logger, UnitOfWork, _messageHub,
ds);
await cleanupService.DeleteChapterCoverImages();
@ -227,7 +223,7 @@ public class CleanupServiceTests : AbstractDbTest
// Delete all Series to reset state
await ResetDb();
_context.Users.Add(new AppUser()
Context.Users.Add(new AppUser()
{
UserName = "Joe",
ReadingLists = new List<ReadingList>()
@ -243,9 +239,9 @@ public class CleanupServiceTests : AbstractDbTest
}
});
await _context.SaveChangesAsync();
await Context.SaveChangesAsync();
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), filesystem);
var cleanupService = new CleanupService(_logger, _unitOfWork, _messageHub,
var cleanupService = new CleanupService(_logger, UnitOfWork, _messageHub,
ds);
await cleanupService.DeleteReadingListCoverImages();
@ -264,7 +260,7 @@ public class CleanupServiceTests : AbstractDbTest
filesystem.AddFile($"{CacheDirectory}02.jpg", new MockFileData(""));
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), filesystem);
var cleanupService = new CleanupService(_logger, _unitOfWork, _messageHub,
var cleanupService = new CleanupService(_logger, UnitOfWork, _messageHub,
ds);
cleanupService.CleanupCacheAndTempDirectories();
Assert.Empty(ds.GetFiles(CacheDirectory, searchOption: SearchOption.AllDirectories));
@ -278,7 +274,7 @@ public class CleanupServiceTests : AbstractDbTest
filesystem.AddFile($"{CacheDirectory}subdir/02.jpg", new MockFileData(""));
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), filesystem);
var cleanupService = new CleanupService(_logger, _unitOfWork, _messageHub,
var cleanupService = new CleanupService(_logger, UnitOfWork, _messageHub,
ds);
cleanupService.CleanupCacheAndTempDirectories();
Assert.Empty(ds.GetFiles(CacheDirectory, searchOption: SearchOption.AllDirectories));
@ -301,7 +297,7 @@ public class CleanupServiceTests : AbstractDbTest
filesystem.AddFile($"{BackupDirectory}randomfile.zip", filesystemFile);
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), filesystem);
var cleanupService = new CleanupService(_logger, _unitOfWork, _messageHub,
var cleanupService = new CleanupService(_logger, UnitOfWork, _messageHub,
ds);
await cleanupService.CleanupBackups();
Assert.Single(ds.GetFiles(BackupDirectory, searchOption: SearchOption.AllDirectories));
@ -323,7 +319,7 @@ public class CleanupServiceTests : AbstractDbTest
});
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), filesystem);
var cleanupService = new CleanupService(_logger, _unitOfWork, _messageHub,
var cleanupService = new CleanupService(_logger, UnitOfWork, _messageHub,
ds);
await cleanupService.CleanupBackups();
Assert.True(filesystem.File.Exists($"{BackupDirectory}randomfile.zip"));
@ -347,7 +343,7 @@ public class CleanupServiceTests : AbstractDbTest
}
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), filesystem);
var cleanupService = new CleanupService(_logger, _unitOfWork, _messageHub,
var cleanupService = new CleanupService(_logger, UnitOfWork, _messageHub,
ds);
await cleanupService.CleanupLogs();
Assert.Single(ds.GetFiles(LogDirectory, searchOption: SearchOption.AllDirectories));
@ -376,7 +372,7 @@ public class CleanupServiceTests : AbstractDbTest
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), filesystem);
var cleanupService = new CleanupService(_logger, _unitOfWork, _messageHub,
var cleanupService = new CleanupService(_logger, UnitOfWork, _messageHub,
ds);
await cleanupService.CleanupLogs();
Assert.True(filesystem.File.Exists($"{LogDirectory}kavita20200911.log"));
@ -400,36 +396,36 @@ public class CleanupServiceTests : AbstractDbTest
.Build();
series.Library = new LibraryBuilder("Test LIb").Build();
_context.Series.Add(series);
Context.Series.Add(series);
_context.AppUser.Add(new AppUser()
Context.AppUser.Add(new AppUser()
{
UserName = "majora2007"
});
await _context.SaveChangesAsync();
await Context.SaveChangesAsync();
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Progress);
var user = await UnitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Progress);
await _readerService.MarkChaptersUntilAsRead(user, 1, 5);
await _context.SaveChangesAsync();
await Context.SaveChangesAsync();
// Validate correct chapters have read status
Assert.Equal(1, (await _unitOfWork.AppUserProgressRepository.GetUserProgressAsync(1, 1)).PagesRead);
Assert.Equal(1, (await UnitOfWork.AppUserProgressRepository.GetUserProgressAsync(1, 1)).PagesRead);
var cleanupService = new CleanupService(Substitute.For<ILogger<CleanupService>>(), _unitOfWork,
var cleanupService = new CleanupService(Substitute.For<ILogger<CleanupService>>(), UnitOfWork,
Substitute.For<IEventHub>(),
new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), new MockFileSystem()));
// Delete the Chapter
_context.Chapter.Remove(c);
await _unitOfWork.CommitAsync();
Assert.Empty(await _unitOfWork.AppUserProgressRepository.GetUserProgressForSeriesAsync(1, 1));
Context.Chapter.Remove(c);
await UnitOfWork.CommitAsync();
Assert.Empty(await UnitOfWork.AppUserProgressRepository.GetUserProgressForSeriesAsync(1, 1));
// NOTE: This may not be needed, the underlying DB structure seems fixed as of v0.7
await cleanupService.CleanupDbEntries();
Assert.Empty(await _unitOfWork.AppUserProgressRepository.GetUserProgressForSeriesAsync(1, 1));
Assert.Empty(await UnitOfWork.AppUserProgressRepository.GetUserProgressForSeriesAsync(1, 1));
}
[Fact]
@ -440,7 +436,7 @@ public class CleanupServiceTests : AbstractDbTest
.WithMetadata(new SeriesMetadataBuilder().Build())
.Build();
s.Library = new LibraryBuilder("Test LIb").Build();
_context.Series.Add(s);
Context.Series.Add(s);
var c = new AppUserCollection()
{
@ -450,24 +446,24 @@ public class CleanupServiceTests : AbstractDbTest
Items = new List<Series>() {s}
};
_context.AppUser.Add(new AppUser()
Context.AppUser.Add(new AppUser()
{
UserName = "majora2007",
Collections = new List<AppUserCollection>() {c}
});
await _context.SaveChangesAsync();
await Context.SaveChangesAsync();
var cleanupService = new CleanupService(Substitute.For<ILogger<CleanupService>>(), _unitOfWork,
var cleanupService = new CleanupService(Substitute.For<ILogger<CleanupService>>(), UnitOfWork,
Substitute.For<IEventHub>(),
new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), new MockFileSystem()));
// Delete the Chapter
_context.Series.Remove(s);
await _unitOfWork.CommitAsync();
Context.Series.Remove(s);
await UnitOfWork.CommitAsync();
await cleanupService.CleanupDbEntries();
Assert.Empty(await _unitOfWork.CollectionTagRepository.GetAllCollectionsAsync());
Assert.Empty(await UnitOfWork.CollectionTagRepository.GetAllCollectionsAsync());
}
#endregion
@ -484,15 +480,15 @@ public class CleanupServiceTests : AbstractDbTest
.Build();
s.Library = new LibraryBuilder("Test LIb").Build();
_context.Series.Add(s);
Context.Series.Add(s);
var user = new AppUser()
{
UserName = "CleanupWantToRead_ShouldRemoveFullyReadSeries",
};
_context.AppUser.Add(user);
Context.AppUser.Add(user);
await _unitOfWork.CommitAsync();
await UnitOfWork.CommitAsync();
// Add want to read
user.WantToRead = new List<AppUserWantToRead>()
@ -502,12 +498,12 @@ public class CleanupServiceTests : AbstractDbTest
SeriesId = s.Id
}
};
await _unitOfWork.CommitAsync();
await UnitOfWork.CommitAsync();
await _readerService.MarkSeriesAsRead(user, s.Id);
await _unitOfWork.CommitAsync();
await UnitOfWork.CommitAsync();
var cleanupService = new CleanupService(Substitute.For<ILogger<CleanupService>>(), _unitOfWork,
var cleanupService = new CleanupService(Substitute.For<ILogger<CleanupService>>(), UnitOfWork,
Substitute.For<IEventHub>(),
new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), new MockFileSystem()));
@ -515,7 +511,7 @@ public class CleanupServiceTests : AbstractDbTest
await cleanupService.CleanupWantToRead();
var wantToRead =
await _unitOfWork.SeriesRepository.GetWantToReadForUserAsync(user.Id, new UserParams(), new FilterDto());
await UnitOfWork.SeriesRepository.GetWantToReadForUserAsync(user.Id, new UserParams(), new FilterDto());
Assert.Equal(0, wantToRead.TotalCount);
}
@ -537,15 +533,15 @@ public class CleanupServiceTests : AbstractDbTest
.Build();
s.Library = new LibraryBuilder("Test Lib").Build();
_context.Series.Add(s);
Context.Series.Add(s);
var user = new AppUser()
{
UserName = "ConsolidateProgress_ShouldRemoveDuplicates",
};
_context.AppUser.Add(user);
Context.AppUser.Add(user);
await _unitOfWork.CommitAsync();
await UnitOfWork.CommitAsync();
// Add 2 progress events
user.Progresses ??= [];
@ -557,7 +553,7 @@ public class CleanupServiceTests : AbstractDbTest
LibraryId = s.LibraryId,
PagesRead = 1,
});
await _unitOfWork.CommitAsync();
await UnitOfWork.CommitAsync();
// Add a duplicate with higher page number
user.Progresses.Add(new AppUserProgress()
@ -568,18 +564,18 @@ public class CleanupServiceTests : AbstractDbTest
LibraryId = s.LibraryId,
PagesRead = 3,
});
await _unitOfWork.CommitAsync();
await UnitOfWork.CommitAsync();
Assert.Equal(2, (await _unitOfWork.AppUserProgressRepository.GetAllProgress()).Count());
Assert.Equal(2, (await UnitOfWork.AppUserProgressRepository.GetAllProgress()).Count());
var cleanupService = new CleanupService(Substitute.For<ILogger<CleanupService>>(), _unitOfWork,
var cleanupService = new CleanupService(Substitute.For<ILogger<CleanupService>>(), UnitOfWork,
Substitute.For<IEventHub>(),
new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), new MockFileSystem()));
await cleanupService.ConsolidateProgress();
var progress = await _unitOfWork.AppUserProgressRepository.GetAllProgress();
var progress = await UnitOfWork.AppUserProgressRepository.GetAllProgress();
Assert.Single(progress);
Assert.True(progress.First().PagesRead == 3);
@ -605,50 +601,50 @@ public class CleanupServiceTests : AbstractDbTest
{
new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume).WithChapter(c).Build()
};
_context.Series.Add(s);
Context.Series.Add(s);
var user = new AppUser()
{
UserName = "EnsureChapterProgressIsCapped",
Progresses = new List<AppUserProgress>()
};
_context.AppUser.Add(user);
Context.AppUser.Add(user);
await _unitOfWork.CommitAsync();
await UnitOfWork.CommitAsync();
await _readerService.MarkChaptersAsRead(user, s.Id, new List<Chapter>() {c});
await _unitOfWork.CommitAsync();
await UnitOfWork.CommitAsync();
var chapter = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(c.Id);
await _unitOfWork.ChapterRepository.AddChapterModifiers(user.Id, chapter);
var chapter = await UnitOfWork.ChapterRepository.GetChapterDtoAsync(c.Id);
await UnitOfWork.ChapterRepository.AddChapterModifiers(user.Id, chapter);
Assert.NotNull(chapter);
Assert.Equal(2, chapter.PagesRead);
// Update chapter to have 1 page
c.Pages = 1;
_unitOfWork.ChapterRepository.Update(c);
await _unitOfWork.CommitAsync();
UnitOfWork.ChapterRepository.Update(c);
await UnitOfWork.CommitAsync();
chapter = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(c.Id);
await _unitOfWork.ChapterRepository.AddChapterModifiers(user.Id, chapter);
chapter = await UnitOfWork.ChapterRepository.GetChapterDtoAsync(c.Id);
await UnitOfWork.ChapterRepository.AddChapterModifiers(user.Id, chapter);
Assert.NotNull(chapter);
Assert.Equal(2, chapter.PagesRead);
Assert.Equal(1, chapter.Pages);
var cleanupService = new CleanupService(Substitute.For<ILogger<CleanupService>>(), _unitOfWork,
var cleanupService = new CleanupService(Substitute.For<ILogger<CleanupService>>(), UnitOfWork,
Substitute.For<IEventHub>(),
new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), new MockFileSystem()));
await cleanupService.EnsureChapterProgressIsCapped();
chapter = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(c.Id);
await _unitOfWork.ChapterRepository.AddChapterModifiers(user.Id, chapter);
chapter = await UnitOfWork.ChapterRepository.GetChapterDtoAsync(c.Id);
await UnitOfWork.ChapterRepository.AddChapterModifiers(user.Id, chapter);
Assert.NotNull(chapter);
Assert.Equal(1, chapter.PagesRead);
_context.AppUser.Remove(user);
await _unitOfWork.CommitAsync();
Context.AppUser.Remove(user);
await UnitOfWork.CommitAsync();
}
#endregion

View file

@ -1,6 +1,8 @@
using System.Collections.Generic;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using API.Constants;
using API.Data;
using API.Data.Repositories;
using API.DTOs.Collection;
@ -10,6 +12,7 @@ using API.Helpers.Builders;
using API.Services;
using API.Services.Plus;
using API.SignalR;
using Kavita.Common;
using NSubstitute;
using Xunit;
@ -20,24 +23,24 @@ public class CollectionTagServiceTests : AbstractDbTest
private readonly ICollectionTagService _service;
public CollectionTagServiceTests()
{
_service = new CollectionTagService(_unitOfWork, Substitute.For<IEventHub>());
_service = new CollectionTagService(UnitOfWork, Substitute.For<IEventHub>());
}
protected override async Task ResetDb()
{
_context.AppUserCollection.RemoveRange(_context.AppUserCollection.ToList());
_context.Library.RemoveRange(_context.Library.ToList());
Context.AppUserCollection.RemoveRange(Context.AppUserCollection.ToList());
Context.Library.RemoveRange(Context.Library.ToList());
await _unitOfWork.CommitAsync();
await UnitOfWork.CommitAsync();
}
private async Task SeedSeries()
{
if (_context.AppUserCollection.Any()) return;
if (Context.AppUserCollection.Any()) return;
var s1 = new SeriesBuilder("Series 1").WithMetadata(new SeriesMetadataBuilder().WithAgeRating(AgeRating.Mature).Build()).Build();
var s2 = new SeriesBuilder("Series 2").WithMetadata(new SeriesMetadataBuilder().WithAgeRating(AgeRating.G).Build()).Build();
_context.Library.Add(new LibraryBuilder("Library 2", LibraryType.Manga)
Context.Library.Add(new LibraryBuilder("Library 2", LibraryType.Manga)
.WithSeries(s1)
.WithSeries(s2)
.Build());
@ -48,11 +51,69 @@ public class CollectionTagServiceTests : AbstractDbTest
new AppUserCollectionBuilder("Tag 1").WithItems(new []{s1}).Build(),
new AppUserCollectionBuilder("Tag 2").WithItems(new []{s1, s2}).WithIsPromoted(true).Build()
};
_unitOfWork.UserRepository.Add(user);
UnitOfWork.UserRepository.Add(user);
await _unitOfWork.CommitAsync();
await UnitOfWork.CommitAsync();
}
#region DeleteTag
[Fact]
public async Task DeleteTag_ShouldDeleteTag_WhenTagExists()
{
// Arrange
await SeedSeries();
var user = await UnitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections);
Assert.NotNull(user);
// Act
var result = await _service.DeleteTag(1, user);
// Assert
Assert.True(result);
var deletedTag = await UnitOfWork.CollectionTagRepository.GetCollectionAsync(1);
Assert.Null(deletedTag);
Assert.Single(user.Collections); // Only one collection should remain
}
[Fact]
public async Task DeleteTag_ShouldReturnTrue_WhenTagDoesNotExist()
{
// Arrange
await SeedSeries();
var user = await UnitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections);
Assert.NotNull(user);
// Act - Try to delete a non-existent tag
var result = await _service.DeleteTag(999, user);
// Assert
Assert.True(result); // Should return true because the tag is already "deleted"
Assert.Equal(2, user.Collections.Count); // Both collections should remain
}
[Fact]
public async Task DeleteTag_ShouldNotAffectOtherTags()
{
// Arrange
await SeedSeries();
var user = await UnitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections);
Assert.NotNull(user);
// Act
var result = await _service.DeleteTag(1, user);
// Assert
Assert.True(result);
var remainingTag = await UnitOfWork.CollectionTagRepository.GetCollectionAsync(2);
Assert.NotNull(remainingTag);
Assert.Equal("Tag 2", remainingTag.Title);
Assert.True(remainingTag.Promoted);
}
#endregion
#region UpdateTag
[Fact]
@ -60,12 +121,12 @@ public class CollectionTagServiceTests : AbstractDbTest
{
await SeedSeries();
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections);
var user = await UnitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections);
Assert.NotNull(user);
user.Collections.Add(new AppUserCollectionBuilder("UpdateTag_ShouldUpdateFields").WithIsPromoted(true).Build());
_unitOfWork.UserRepository.Update(user);
await _unitOfWork.CommitAsync();
UnitOfWork.UserRepository.Update(user);
await UnitOfWork.CommitAsync();
await _service.UpdateTag(new AppUserCollectionDto()
{
@ -76,7 +137,7 @@ public class CollectionTagServiceTests : AbstractDbTest
AgeRating = AgeRating.Unknown
}, 1);
var tag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(3);
var tag = await UnitOfWork.CollectionTagRepository.GetCollectionAsync(3);
Assert.NotNull(tag);
Assert.True(tag.Promoted);
Assert.False(string.IsNullOrEmpty(tag.Summary));
@ -90,12 +151,12 @@ public class CollectionTagServiceTests : AbstractDbTest
{
await SeedSeries();
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections);
var user = await UnitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections);
Assert.NotNull(user);
user.Collections.Add(new AppUserCollectionBuilder("UpdateTag_ShouldNotChangeTitle_WhenNotKavitaSource").WithSource(ScrobbleProvider.Mal).Build());
_unitOfWork.UserRepository.Update(user);
await _unitOfWork.CommitAsync();
UnitOfWork.UserRepository.Update(user);
await UnitOfWork.CommitAsync();
await _service.UpdateTag(new AppUserCollectionDto()
{
@ -106,11 +167,194 @@ public class CollectionTagServiceTests : AbstractDbTest
AgeRating = AgeRating.Unknown
}, 1);
var tag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(3);
var tag = await UnitOfWork.CollectionTagRepository.GetCollectionAsync(3);
Assert.NotNull(tag);
Assert.Equal("UpdateTag_ShouldNotChangeTitle_WhenNotKavitaSource", tag.Title);
Assert.False(string.IsNullOrEmpty(tag.Summary));
}
[Fact]
public async Task UpdateTag_ShouldThrowException_WhenTagDoesNotExist()
{
// Arrange
await SeedSeries();
// Act & Assert
var exception = await Assert.ThrowsAsync<KavitaException>(() => _service.UpdateTag(new AppUserCollectionDto()
{
Title = "Non-existent Tag",
Id = 999, // Non-existent ID
Promoted = false
}, 1));
Assert.Equal("collection-doesnt-exist", exception.Message);
}
[Fact]
public async Task UpdateTag_ShouldThrowException_WhenUserDoesNotOwnTag()
{
// Arrange
await SeedSeries();
// Create a second user
var user2 = new AppUserBuilder("user2", "user2", Seed.DefaultThemes.First()).Build();
UnitOfWork.UserRepository.Add(user2);
await UnitOfWork.CommitAsync();
// Act & Assert
var exception = await Assert.ThrowsAsync<KavitaException>(() => _service.UpdateTag(new AppUserCollectionDto()
{
Title = "Tag 1",
Id = 1, // This belongs to user1
Promoted = false
}, 2)); // User with ID 2
Assert.Equal("access-denied", exception.Message);
}
[Fact]
public async Task UpdateTag_ShouldThrowException_WhenTitleIsEmpty()
{
// Arrange
await SeedSeries();
// Act & Assert
var exception = await Assert.ThrowsAsync<KavitaException>(() => _service.UpdateTag(new AppUserCollectionDto()
{
Title = " ", // Empty after trimming
Id = 1,
Promoted = false
}, 1));
Assert.Equal("collection-tag-title-required", exception.Message);
}
[Fact]
public async Task UpdateTag_ShouldThrowException_WhenTitleAlreadyExists()
{
// Arrange
await SeedSeries();
// Act & Assert
var exception = await Assert.ThrowsAsync<KavitaException>(() => _service.UpdateTag(new AppUserCollectionDto()
{
Title = "Tag 2", // Already exists
Id = 1, // Trying to rename Tag 1 to Tag 2
Promoted = false
}, 1));
Assert.Equal("collection-tag-duplicate", exception.Message);
}
[Fact]
public async Task UpdateTag_ShouldUpdateCoverImageSettings()
{
// Arrange
await SeedSeries();
// Act
await _service.UpdateTag(new AppUserCollectionDto()
{
Title = "Tag 1",
Id = 1,
CoverImageLocked = true
}, 1);
// Assert
var tag = await UnitOfWork.CollectionTagRepository.GetCollectionAsync(1);
Assert.NotNull(tag);
Assert.True(tag.CoverImageLocked);
// Now test unlocking the cover image
await _service.UpdateTag(new AppUserCollectionDto()
{
Title = "Tag 1",
Id = 1,
CoverImageLocked = false
}, 1);
tag = await UnitOfWork.CollectionTagRepository.GetCollectionAsync(1);
Assert.NotNull(tag);
Assert.False(tag.CoverImageLocked);
Assert.Equal(string.Empty, tag.CoverImage);
}
[Fact]
public async Task UpdateTag_ShouldAllowPromoteForAdminRole()
{
// Arrange
await SeedSeries();
// Setup a user with admin role
var user = await UnitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections);
Assert.NotNull(user);
await AddUserWithRole(user.Id, PolicyConstants.AdminRole);
// Act - Try to promote a tag that wasn't previously promoted
await _service.UpdateTag(new AppUserCollectionDto()
{
Title = "Tag 1",
Id = 1,
Promoted = true
}, 1);
// Assert
var tag = await UnitOfWork.CollectionTagRepository.GetCollectionAsync(1);
Assert.NotNull(tag);
Assert.True(tag.Promoted);
}
[Fact]
public async Task UpdateTag_ShouldAllowPromoteForPromoteRole()
{
// Arrange
await SeedSeries();
// Setup a user with promote role
var user = await UnitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections);
Assert.NotNull(user);
// Mock to return promote role for the user
await AddUserWithRole(user.Id, PolicyConstants.PromoteRole);
// Act - Try to promote a tag that wasn't previously promoted
await _service.UpdateTag(new AppUserCollectionDto()
{
Title = "Tag 1",
Id = 1,
Promoted = true
}, 1);
// Assert
var tag = await UnitOfWork.CollectionTagRepository.GetCollectionAsync(1);
Assert.NotNull(tag);
Assert.True(tag.Promoted);
}
[Fact]
public async Task UpdateTag_ShouldNotChangePromotion_WhenUserHasNoPermission()
{
// Arrange
await SeedSeries();
// Setup a user with no special roles
var user = await UnitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections);
Assert.NotNull(user);
// Act - Try to promote a tag without proper role
await _service.UpdateTag(new AppUserCollectionDto()
{
Title = "Tag 1",
Id = 1,
Promoted = true
}, 1);
// Assert
var tag = await UnitOfWork.CollectionTagRepository.GetCollectionAsync(1);
Assert.NotNull(tag);
Assert.False(tag.Promoted); // Should remain unpromoted
}
#endregion
@ -121,17 +365,17 @@ public class CollectionTagServiceTests : AbstractDbTest
{
await SeedSeries();
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections);
var user = await UnitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections);
Assert.NotNull(user);
// Tag 2 has 2 series
var tag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(2);
var tag = await UnitOfWork.CollectionTagRepository.GetCollectionAsync(2);
Assert.NotNull(tag);
await _service.RemoveTagFromSeries(tag, new[] {1});
var userCollections = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections);
var userCollections = await UnitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections);
Assert.Equal(2, userCollections!.Collections.Count);
Assert.Equal(1, tag.Items.Count);
Assert.Single(tag.Items);
Assert.Equal(2, tag.Items.First().Id);
}
@ -143,11 +387,11 @@ public class CollectionTagServiceTests : AbstractDbTest
{
await SeedSeries();
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections);
var user = await UnitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections);
Assert.NotNull(user);
// Tag 2 has 2 series
var tag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(2);
var tag = await UnitOfWork.CollectionTagRepository.GetCollectionAsync(2);
Assert.NotNull(tag);
await _service.RemoveTagFromSeries(tag, new[] {1});
@ -163,18 +407,123 @@ public class CollectionTagServiceTests : AbstractDbTest
{
await SeedSeries();
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections);
var user = await UnitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections);
Assert.NotNull(user);
// Tag 1 has 1 series
var tag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(1);
var tag = await UnitOfWork.CollectionTagRepository.GetCollectionAsync(1);
Assert.NotNull(tag);
await _service.RemoveTagFromSeries(tag, new[] {1});
var tag2 = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(1);
var tag2 = await UnitOfWork.CollectionTagRepository.GetCollectionAsync(1);
Assert.Null(tag2);
}
[Fact]
public async Task RemoveTagFromSeries_ShouldReturnFalse_WhenTagIsNull()
{
// Act
var result = await _service.RemoveTagFromSeries(null, [1]);
// Assert
Assert.False(result);
}
[Fact]
public async Task RemoveTagFromSeries_ShouldHandleEmptySeriesIdsList()
{
// Arrange
await SeedSeries();
var tag = await UnitOfWork.CollectionTagRepository.GetCollectionAsync(1);
Assert.NotNull(tag);
var initialItemCount = tag.Items.Count;
// Act
var result = await _service.RemoveTagFromSeries(tag, Array.Empty<int>());
// Assert
Assert.True(result);
tag = await UnitOfWork.CollectionTagRepository.GetCollectionAsync(1);
Assert.NotNull(tag);
Assert.Equal(initialItemCount, tag.Items.Count); // No items should be removed
}
[Fact]
public async Task RemoveTagFromSeries_ShouldHandleNonExistentSeriesIds()
{
// Arrange
await SeedSeries();
var tag = await UnitOfWork.CollectionTagRepository.GetCollectionAsync(1);
Assert.NotNull(tag);
var initialItemCount = tag.Items.Count;
// Act - Try to remove a series that doesn't exist in the tag
var result = await _service.RemoveTagFromSeries(tag, [999]);
// Assert
Assert.True(result);
tag = await UnitOfWork.CollectionTagRepository.GetCollectionAsync(1);
Assert.NotNull(tag);
Assert.Equal(initialItemCount, tag.Items.Count); // No items should be removed
}
[Fact]
public async Task RemoveTagFromSeries_ShouldHandleNullItemsList()
{
// Arrange
await SeedSeries();
var tag = await UnitOfWork.CollectionTagRepository.GetCollectionAsync(1);
Assert.NotNull(tag);
// Force null items list
tag.Items = null;
UnitOfWork.CollectionTagRepository.Update(tag);
await UnitOfWork.CommitAsync();
// Act
var result = await _service.RemoveTagFromSeries(tag, [1]);
// Assert
Assert.True(result);
// The tag should not be removed since the items list was null, not empty
var tagAfter = await UnitOfWork.CollectionTagRepository.GetCollectionAsync(1);
Assert.Null(tagAfter);
}
[Fact]
public async Task RemoveTagFromSeries_ShouldUpdateAgeRating_WhenMultipleSeriesRemain()
{
// Arrange
await SeedSeries();
// Add a third series with a different age rating
var s3 = new SeriesBuilder("Series 3").WithMetadata(new SeriesMetadataBuilder().WithAgeRating(AgeRating.PG).Build()).Build();
Context.Library.First().Series.Add(s3);
await UnitOfWork.CommitAsync();
// Add series 3 to tag 2
var tag = await UnitOfWork.CollectionTagRepository.GetCollectionAsync(2);
Assert.NotNull(tag);
tag.Items.Add(s3);
UnitOfWork.CollectionTagRepository.Update(tag);
await UnitOfWork.CommitAsync();
// Act - Remove the series with Mature rating
await _service.RemoveTagFromSeries(tag, new[] {1});
// Assert
tag = await UnitOfWork.CollectionTagRepository.GetCollectionAsync(2);
Assert.NotNull(tag);
Assert.Equal(2, tag.Items.Count);
// The age rating should be updated to the highest remaining rating (PG)
Assert.Equal(AgeRating.PG, tag.AgeRating);
}
#endregion
}

View file

@ -0,0 +1,117 @@
using System.IO;
using System.IO.Abstractions;
using System.Reflection;
using System.Threading.Tasks;
using API.Constants;
using API.Entities.Enums;
using API.Extensions;
using API.Services;
using API.Services.Tasks.Metadata;
using API.SignalR;
using EasyCaching.Core;
using Kavita.Common;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Xunit;
namespace API.Tests.Services;
public class CoverDbServiceTests : AbstractDbTest
{
private readonly DirectoryService _directoryService;
private readonly IEasyCachingProviderFactory _cacheFactory = Substitute.For<IEasyCachingProviderFactory>();
private readonly ICoverDbService _coverDbService;
private static readonly string FaviconPath = Path.Join(Directory.GetCurrentDirectory(),
"../../../Services/Test Data/CoverDbService/Favicons");
/// <summary>
/// Path to download files temp to. Should be empty after each test.
/// </summary>
private static readonly string TempPath = Path.Join(Directory.GetCurrentDirectory(),
"../../../Services/Test Data/CoverDbService/Temp");
public CoverDbServiceTests()
{
_directoryService = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), CreateFileSystem());
var imageService = new ImageService(Substitute.For<ILogger<ImageService>>(), _directoryService);
_coverDbService = new CoverDbService(Substitute.For<ILogger<CoverDbService>>(), _directoryService, _cacheFactory,
Substitute.For<IHostEnvironment>(), imageService, UnitOfWork, Substitute.For<IEventHub>());
}
protected override Task ResetDb()
{
throw new System.NotImplementedException();
}
#region Download Favicon
/// <summary>
/// I cannot figure out how to test this code due to the reliance on the _directoryService.FaviconDirectory and not being
/// able to redirect it to the real filesystem.
/// </summary>
public async Task DownloadFaviconAsync_ShouldDownloadAndMatchExpectedFavicon()
{
// Arrange
var testUrl = "https://anilist.co/anime/6205/Kmpfer/";
var encodeFormat = EncodeFormat.WEBP;
var expectedFaviconPath = Path.Combine(FaviconPath, "anilist.co.webp");
// Ensure TempPath exists
_directoryService.ExistOrCreate(TempPath);
var baseUrl = "https://anilist.co";
// Ensure there is no cache result for this URL
var provider = Substitute.For<IEasyCachingProvider>();
provider.GetAsync<string>(baseUrl).Returns(new CacheValue<string>(null, false));
_cacheFactory.GetCachingProvider(EasyCacheProfiles.Favicon).Returns(provider);
// // Replace favicon directory with TempPath
// var directoryService = (DirectoryService)_directoryService;
// directoryService.FaviconDirectory = TempPath;
// Hack: Swap FaviconDirectory with TempPath for ability to download real files
typeof(DirectoryService)
.GetField("FaviconDirectory", BindingFlags.NonPublic | BindingFlags.Instance)
?.SetValue(_directoryService, TempPath);
// Act
var resultFilename = await _coverDbService.DownloadFaviconAsync(testUrl, encodeFormat);
var actualFaviconPath = Path.Combine(TempPath, resultFilename);
// Assert file exists
Assert.True(File.Exists(actualFaviconPath), "Downloaded favicon does not exist in temp path");
// Load and compare similarity
var similarity = expectedFaviconPath.CalculateSimilarity(actualFaviconPath); // Assuming you have this extension
Assert.True(similarity > 0.9f, $"Image similarity too low: {similarity}");
}
[Fact]
public async Task DownloadFaviconAsync_ShouldThrowKavitaException_WhenPreviouslyFailedUrlExistsInCache()
{
// Arrange
var testUrl = "https://example.com";
var encodeFormat = EncodeFormat.WEBP;
var provider = Substitute.For<IEasyCachingProvider>();
provider.GetAsync<string>(Arg.Any<string>())
.Returns(new CacheValue<string>(string.Empty, true)); // Simulate previous failure
_cacheFactory.GetCachingProvider(EasyCacheProfiles.Favicon).Returns(provider);
// Act & Assert
await Assert.ThrowsAsync<KavitaException>(() =>
_coverDbService.DownloadFaviconAsync(testUrl, encodeFormat));
}
#endregion
}

View file

@ -18,13 +18,13 @@ public class DeviceServiceDbTests : AbstractDbTest
public DeviceServiceDbTests() : base()
{
_deviceService = new DeviceService(_unitOfWork, _logger, Substitute.For<IEmailService>());
_deviceService = new DeviceService(UnitOfWork, _logger, Substitute.For<IEmailService>());
}
protected override async Task ResetDb()
{
_context.Users.RemoveRange(_context.Users.ToList());
await _unitOfWork.CommitAsync();
Context.Users.RemoveRange(Context.Users.ToList());
await UnitOfWork.CommitAsync();
}
@ -39,8 +39,8 @@ public class DeviceServiceDbTests : AbstractDbTest
Devices = new List<Device>()
};
_context.Users.Add(user);
await _unitOfWork.CommitAsync();
Context.Users.Add(user);
await UnitOfWork.CommitAsync();
var device = await _deviceService.Create(new CreateDeviceDto()
{
@ -62,8 +62,8 @@ public class DeviceServiceDbTests : AbstractDbTest
Devices = new List<Device>()
};
_context.Users.Add(user);
await _unitOfWork.CommitAsync();
Context.Users.Add(user);
await UnitOfWork.CommitAsync();
var device = await _deviceService.Create(new CreateDeviceDto()
{

View file

@ -1,8 +1,10 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.IO.Abstractions.TestingHelpers;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading.Tasks;
using API.Services;
@ -10,12 +12,19 @@ using Kavita.Common.Helpers;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Xunit;
using Xunit.Abstractions;
namespace API.Tests.Services;
public class DirectoryServiceTests
public class DirectoryServiceTests: AbstractFsTest
{
private readonly ILogger<DirectoryService> _logger = Substitute.For<ILogger<DirectoryService>>();
private readonly ITestOutputHelper _testOutputHelper;
public DirectoryServiceTests(ITestOutputHelper testOutputHelper)
{
_testOutputHelper = testOutputHelper;
}
#region TraverseTreeParallelForEach
@ -373,9 +382,16 @@ public class DirectoryServiceTests
#endregion
#region IsDriveMounted
// The root directory (/) is always mounted on non windows
[Fact]
public void IsDriveMounted_DriveIsNotMounted()
{
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
_testOutputHelper.WriteLine("Skipping test on non Windows platform");
return;
}
const string testDirectory = "c:/manga/";
var fileSystem = new MockFileSystem();
fileSystem.AddFile($"{testDirectory}data-0.txt", new MockFileData("abc"));
@ -387,6 +403,12 @@ public class DirectoryServiceTests
[Fact]
public void IsDriveMounted_DriveIsMounted()
{
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
_testOutputHelper.WriteLine("Skipping test on non Windows platform");
return;
}
const string testDirectory = "c:/manga/";
var fileSystem = new MockFileSystem();
fileSystem.AddFile($"{testDirectory}data-0.txt", new MockFileData("abc"));
@ -900,12 +922,14 @@ public class DirectoryServiceTests
#region GetHumanReadableBytes
[Theory]
[InlineData(1200, "1.17 KB")]
[InlineData(1, "1 B")]
[InlineData(10000000, "9.54 MB")]
[InlineData(10000000000, "9.31 GB")]
public void GetHumanReadableBytesTest(long bytes, string expected)
[InlineData(1200, 1.17, " KB")]
[InlineData(1, 1, " B")]
[InlineData(10000000, 9.54, " MB")]
[InlineData(10000000000, 9.31, " GB")]
public void GetHumanReadableBytesTest(long bytes, float number, string suffix)
{
// GetHumanReadableBytes is user facing, should be in CultureInfo.CurrentCulture
var expected = number.ToString(CultureInfo.CurrentCulture) + suffix;
Assert.Equal(expected, DirectoryService.GetHumanReadableBytes(bytes));
}
#endregion
@ -1041,11 +1065,14 @@ public class DirectoryServiceTests
#region GetParentDirectory
[Theory]
[InlineData(@"C:/file.txt", "C:/")]
[InlineData(@"C:/folder/file.txt", "C:/folder")]
[InlineData(@"C:/folder/subfolder/file.txt", "C:/folder/subfolder")]
[InlineData(@"file.txt", "")]
[InlineData(@"folder/file.txt", "folder")]
[InlineData(@"folder/subfolder/file.txt", "folder/subfolder")]
public void GetParentDirectoryName_ShouldFindParentOfFiles(string path, string expected)
{
path = Root + path;
expected = Root + expected;
var fileSystem = new MockFileSystem(new Dictionary<string, MockFileData>
{
{ path, new MockFileData(string.Empty)}
@ -1055,11 +1082,14 @@ public class DirectoryServiceTests
Assert.Equal(expected, ds.GetParentDirectoryName(path));
}
[Theory]
[InlineData(@"C:/folder", "C:/")]
[InlineData(@"C:/folder/subfolder", "C:/folder")]
[InlineData(@"C:/folder/subfolder/another", "C:/folder/subfolder")]
[InlineData(@"folder", "")]
[InlineData(@"folder/subfolder", "folder")]
[InlineData(@"folder/subfolder/another", "folder/subfolder")]
public void GetParentDirectoryName_ShouldFindParentOfDirectories(string path, string expected)
{
path = Root + path;
expected = Root + expected;
var fileSystem = new MockFileSystem();
fileSystem.AddDirectory(path);

File diff suppressed because it is too large Load diff

View file

@ -1,14 +1,9 @@
using System.Drawing;
using System.IO;
using System.IO.Abstractions;
using System.IO;
using System.Linq;
using System.Text;
using API.Entities.Enums;
using API.Services;
using EasyCaching.Core;
using Microsoft.Extensions.Logging;
using NetVips;
using NSubstitute;
using Xunit;
using Image = NetVips.Image;
@ -28,6 +23,7 @@ public class ImageServiceTests
public void GenerateBaseline()
{
GenerateFiles(BaselinePattern);
Assert.True(true);
}
/// <summary>
@ -38,6 +34,7 @@ public class ImageServiceTests
{
GenerateFiles(OutputPattern);
GenerateHtmlFile();
Assert.True(true);
}
private void GenerateFiles(string outputExtension)
@ -159,15 +156,15 @@ public class ImageServiceTests
// Step 4: Generate HTML file
GenerateHtmlFileForColorScape();
Assert.True(true);
}
private static void GenerateColorImage(string hexColor, string outputPath)
{
var color = ImageService.HexToRgb(hexColor);
using var colorImage = Image.Black(200, 100);
using var output = colorImage + new[] { color.R / 255.0, color.G / 255.0, color.B / 255.0 };
output.WriteToFile(outputPath);
var (r, g, b) = ImageService.HexToRgb(hexColor);
using var blackImage = Image.Black(200, 100);
using var colorImage = blackImage.NewFromImage(r, g, b);
colorImage.WriteToFile(outputPath);
}
private void GenerateHtmlFileForColorScape()

View file

@ -1,29 +1,19 @@
using System;
using System.Collections.Generic;
using System.Data.Common;
using System.IO;
using System.IO.Abstractions;
using System.IO.Abstractions.TestingHelpers;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using API.Data;
using API.Data.Metadata;
using API.Data.Repositories;
using API.Entities;
using API.Entities.Enums;
using API.Extensions;
using API.Helpers.Builders;
using API.Services;
using API.Services.Tasks.Scanner;
using API.Services.Tasks.Scanner.Parser;
using API.SignalR;
using API.Tests.Helpers;
using AutoMapper;
using Hangfire;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Xunit;
@ -68,35 +58,35 @@ public class MockReadingItemService : IReadingItemService
throw new NotImplementedException();
}
public ParserInfo Parse(string path, string rootPath, string libraryRoot, LibraryType type)
public ParserInfo Parse(string path, string rootPath, string libraryRoot, LibraryType type, bool enableMetadata)
{
if (_comicVineParser.IsApplicable(path, type))
{
return _comicVineParser.Parse(path, rootPath, libraryRoot, type, GetComicInfo(path));
return _comicVineParser.Parse(path, rootPath, libraryRoot, type, enableMetadata, GetComicInfo(path));
}
if (_imageParser.IsApplicable(path, type))
{
return _imageParser.Parse(path, rootPath, libraryRoot, type, GetComicInfo(path));
return _imageParser.Parse(path, rootPath, libraryRoot, type, enableMetadata, GetComicInfo(path));
}
if (_bookParser.IsApplicable(path, type))
{
return _bookParser.Parse(path, rootPath, libraryRoot, type, GetComicInfo(path));
return _bookParser.Parse(path, rootPath, libraryRoot, type, enableMetadata, GetComicInfo(path));
}
if (_pdfParser.IsApplicable(path, type))
{
return _pdfParser.Parse(path, rootPath, libraryRoot, type, GetComicInfo(path));
return _pdfParser.Parse(path, rootPath, libraryRoot, type, enableMetadata, GetComicInfo(path));
}
if (_basicParser.IsApplicable(path, type))
{
return _basicParser.Parse(path, rootPath, libraryRoot, type, GetComicInfo(path));
return _basicParser.Parse(path, rootPath, libraryRoot, type, enableMetadata, GetComicInfo(path));
}
return null;
}
public ParserInfo ParseFile(string path, string rootPath, string libraryRoot, LibraryType type)
public ParserInfo ParseFile(string path, string rootPath, string libraryRoot, LibraryType type, bool enableMetadata)
{
return Parse(path, rootPath, libraryRoot, type);
return Parse(path, rootPath, libraryRoot, type, enableMetadata);
}
}
@ -109,14 +99,14 @@ public class ParseScannedFilesTests : AbstractDbTest
{
// Since ProcessFile relies on _readingItemService, we can implement our own versions of _readingItemService so we have control over how the calls work
GlobalConfiguration.Configuration.UseInMemoryStorage();
_scannerHelper = new ScannerHelper(_unitOfWork, testOutputHelper);
_scannerHelper = new ScannerHelper(UnitOfWork, testOutputHelper);
}
protected override async Task ResetDb()
{
_context.Series.RemoveRange(_context.Series.ToList());
Context.Series.RemoveRange(Context.Series.ToList());
await _context.SaveChangesAsync();
await Context.SaveChangesAsync();
}
#region MergeName
@ -204,11 +194,11 @@ public class ParseScannedFilesTests : AbstractDbTest
public async Task ScanLibrariesForSeries_ShouldFindFiles()
{
var fileSystem = new MockFileSystem();
fileSystem.AddDirectory("C:/Data/");
fileSystem.AddFile("C:/Data/Accel World v1.cbz", new MockFileData(string.Empty));
fileSystem.AddFile("C:/Data/Accel World v2.cbz", new MockFileData(string.Empty));
fileSystem.AddFile("C:/Data/Accel World v2.pdf", new MockFileData(string.Empty));
fileSystem.AddFile("C:/Data/Nothing.pdf", new MockFileData(string.Empty));
fileSystem.AddDirectory(Root + "Data/");
fileSystem.AddFile(Root + "Data/Accel World v1.cbz", new MockFileData(string.Empty));
fileSystem.AddFile(Root + "Data/Accel World v2.cbz", new MockFileData(string.Empty));
fileSystem.AddFile(Root + "Data/Accel World v2.pdf", new MockFileData(string.Empty));
fileSystem.AddFile(Root + "Data/Nothing.pdf", new MockFileData(string.Empty));
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), fileSystem);
var psf = new ParseScannedFiles(Substitute.For<ILogger<ParseScannedFiles>>(), ds,
@ -216,13 +206,13 @@ public class ParseScannedFilesTests : AbstractDbTest
var library =
await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(1,
await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(1,
LibraryIncludes.Folders | LibraryIncludes.FileTypes);
Assert.NotNull(library);
library.Type = LibraryType.Manga;
var parsedSeries = await psf.ScanLibrariesForSeries(library, new List<string>() {"C:/Data/"}, false,
await _unitOfWork.SeriesRepository.GetFolderPathMap(1));
var parsedSeries = await psf.ScanLibrariesForSeries(library, new List<string>() {Root + "Data/"}, false,
await UnitOfWork.SeriesRepository.GetFolderPathMap(1));
// Assert.Equal(3, parsedSeries.Values.Count);
@ -261,9 +251,9 @@ public class ParseScannedFilesTests : AbstractDbTest
new MockReadingItemService(ds, Substitute.For<IBookService>()), Substitute.For<IEventHub>());
var directoriesSeen = new HashSet<string>();
var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(1,
var library = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(1,
LibraryIncludes.Folders | LibraryIncludes.FileTypes);
var scanResults = await psf.ScanFiles("C:/Data/", true, await _unitOfWork.SeriesRepository.GetFolderPathMap(1), library);
var scanResults = await psf.ScanFiles("C:/Data/", true, await UnitOfWork.SeriesRepository.GetFolderPathMap(1), library);
foreach (var scanResult in scanResults)
{
directoriesSeen.Add(scanResult.Folder);
@ -280,13 +270,13 @@ public class ParseScannedFilesTests : AbstractDbTest
var psf = new ParseScannedFiles(Substitute.For<ILogger<ParseScannedFiles>>(), ds,
new MockReadingItemService(ds, Substitute.For<IBookService>()), Substitute.For<IEventHub>());
var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(1,
var library = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(1,
LibraryIncludes.Folders | LibraryIncludes.FileTypes);
Assert.NotNull(library);
var directoriesSeen = new HashSet<string>();
var scanResults = await psf.ScanFiles("C:/Data/", false,
await _unitOfWork.SeriesRepository.GetFolderPathMap(1), library);
await UnitOfWork.SeriesRepository.GetFolderPathMap(1), library);
foreach (var scanResult in scanResults)
{
@ -315,10 +305,10 @@ public class ParseScannedFilesTests : AbstractDbTest
var psf = new ParseScannedFiles(Substitute.For<ILogger<ParseScannedFiles>>(), ds,
new MockReadingItemService(ds, Substitute.For<IBookService>()), Substitute.For<IEventHub>());
var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(1,
var library = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(1,
LibraryIncludes.Folders | LibraryIncludes.FileTypes);
Assert.NotNull(library);
var scanResults = await psf.ScanFiles("C:/Data", true, await _unitOfWork.SeriesRepository.GetFolderPathMap(1), library);
var scanResults = await psf.ScanFiles("C:/Data", true, await UnitOfWork.SeriesRepository.GetFolderPathMap(1), library);
Assert.Equal(2, scanResults.Count);
}
@ -344,11 +334,11 @@ public class ParseScannedFilesTests : AbstractDbTest
var psf = new ParseScannedFiles(Substitute.For<ILogger<ParseScannedFiles>>(), ds,
new MockReadingItemService(ds, Substitute.For<IBookService>()), Substitute.For<IEventHub>());
var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(1,
var library = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(1,
LibraryIncludes.Folders | LibraryIncludes.FileTypes);
Assert.NotNull(library);
var scanResults = await psf.ScanFiles("C:/Data", false,
await _unitOfWork.SeriesRepository.GetFolderPathMap(1), library);
await UnitOfWork.SeriesRepository.GetFolderPathMap(1), library);
Assert.Single(scanResults);
}
@ -358,7 +348,8 @@ public class ParseScannedFilesTests : AbstractDbTest
#endregion
[Fact]
// TODO: Add back in (removed for Hotfix v0.8.5.x)
//[Fact]
public async Task HasSeriesFolderNotChangedSinceLastScan_AllSeriesFoldersHaveChanges()
{
const string testcase = "Subfolders always scanning all series changes - Manga.json";
@ -366,8 +357,8 @@ public class ParseScannedFilesTests : AbstractDbTest
var library = await _scannerHelper.GenerateScannerData(testcase, infos);
var testDirectoryPath = library.Folders.First().Path;
_unitOfWork.LibraryRepository.Update(library);
await _unitOfWork.CommitAsync();
UnitOfWork.LibraryRepository.Update(library);
await UnitOfWork.CommitAsync();
var fs = new FileSystem();
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), fs);
@ -377,7 +368,7 @@ public class ParseScannedFilesTests : AbstractDbTest
var scanner = _scannerHelper.CreateServices(ds, fs);
await scanner.ScanLibrary(library.Id);
var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
Assert.NotNull(postLib);
Assert.Equal(4, postLib.Series.Count);
@ -390,7 +381,7 @@ public class ParseScannedFilesTests : AbstractDbTest
var executionerAndHerWayOfLife = postLib.Series.First(x => x.Name == "The Executioner and Her Way of Life");
Assert.Equal(2, executionerAndHerWayOfLife.Volumes.Count);
Thread.Sleep(1100); // Ensure at least one second has passed since library scan
await Task.Delay(1100); // Ensure at least one second has passed since library scan
// Add a new chapter to a volume of the series, and scan. Validate that only, and all directories of this
// series are marked as HasChanged
@ -400,7 +391,7 @@ public class ParseScannedFilesTests : AbstractDbTest
Path.Join(executionerCopyDir, "The Executioner and Her Way of Life Vol. 1 Ch. 0002.cbz"));
// 4 series, of which 2 have volumes as directories
var folderMap = await _unitOfWork.SeriesRepository.GetFolderPathMap(postLib.Id);
var folderMap = await UnitOfWork.SeriesRepository.GetFolderPathMap(postLib.Id);
Assert.Equal(6, folderMap.Count);
var res = await psf.ScanFiles(testDirectoryPath, true, folderMap, postLib);
@ -418,8 +409,8 @@ public class ParseScannedFilesTests : AbstractDbTest
var library = await _scannerHelper.GenerateScannerData(testcase, infos);
var testDirectoryPath = library.Folders.First().Path;
_unitOfWork.LibraryRepository.Update(library);
await _unitOfWork.CommitAsync();
UnitOfWork.LibraryRepository.Update(library);
await UnitOfWork.CommitAsync();
var fs = new FileSystem();
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), fs);
@ -429,7 +420,7 @@ public class ParseScannedFilesTests : AbstractDbTest
var scanner = _scannerHelper.CreateServices(ds, fs);
await scanner.ScanLibrary(library.Id);
var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
Assert.NotNull(postLib);
Assert.Equal(4, postLib.Series.Count);
@ -439,7 +430,7 @@ public class ParseScannedFilesTests : AbstractDbTest
var frieren = postLib.Series.First(x => x.Name == "Frieren - Beyond Journey's End");
Assert.Equal(2, frieren.Volumes.Count);
Thread.Sleep(1100); // Ensure at least one second has passed since library scan
await Task.Delay(1100); // Ensure at least one second has passed since library scan
// Add a volume to a series, and scan. Ensure only this series is marked as HasChanged
var executionerCopyDir = Path.Join(Path.Join(testDirectoryPath, "YenPress"), "The Executioner and Her Way of Life");
@ -447,8 +438,129 @@ public class ParseScannedFilesTests : AbstractDbTest
Path.Join(executionerCopyDir, "The Executioner and Her Way of Life Vol. 2.cbz"));
var res = await psf.ScanFiles(testDirectoryPath, true,
await _unitOfWork.SeriesRepository.GetFolderPathMap(postLib.Id), postLib);
await UnitOfWork.SeriesRepository.GetFolderPathMap(postLib.Id), postLib);
var changes = res.Count(sc => sc.HasChanged);
Assert.Equal(1, changes);
}
// TODO: Add back in (removed for Hotfix v0.8.5.x)
//[Fact]
public async Task SubFoldersNoSubFolders_SkipAll()
{
const string testcase = "Subfolders and files at root - Manga.json";
var infos = new Dictionary<string, ComicInfo>();
var library = await _scannerHelper.GenerateScannerData(testcase, infos);
var testDirectoryPath = library.Folders.First().Path;
UnitOfWork.LibraryRepository.Update(library);
await UnitOfWork.CommitAsync();
var fs = new FileSystem();
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), fs);
var psf = new ParseScannedFiles(Substitute.For<ILogger<ParseScannedFiles>>(), ds,
new MockReadingItemService(ds, Substitute.For<IBookService>()), Substitute.For<IEventHub>());
var scanner = _scannerHelper.CreateServices(ds, fs);
await scanner.ScanLibrary(library.Id);
var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
Assert.NotNull(postLib);
Assert.Single(postLib.Series);
var spiceAndWolf = postLib.Series.First(x => x.Name == "Spice and Wolf");
Assert.Equal(3, spiceAndWolf.Volumes.Count);
Assert.Equal(4, spiceAndWolf.Volumes.Sum(v => v.Chapters.Count));
// Needs to be actual time as the write time is now, so if we set LastFolderChecked in the past
// it'll always a scan as it was changed since the last scan.
await Task.Delay(1100); // Ensure at least one second has passed since library scan
var res = await psf.ScanFiles(testDirectoryPath, true,
await UnitOfWork.SeriesRepository.GetFolderPathMap(postLib.Id), postLib);
Assert.DoesNotContain(res, sc => sc.HasChanged);
}
[Fact]
public async Task SubFoldersNoSubFolders_ScanAllAfterAddInRoot()
{
const string testcase = "Subfolders and files at root - Manga.json";
var infos = new Dictionary<string, ComicInfo>();
var library = await _scannerHelper.GenerateScannerData(testcase, infos);
var testDirectoryPath = library.Folders.First().Path;
UnitOfWork.LibraryRepository.Update(library);
await UnitOfWork.CommitAsync();
var fs = new FileSystem();
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), fs);
var psf = new ParseScannedFiles(Substitute.For<ILogger<ParseScannedFiles>>(), ds,
new MockReadingItemService(ds, Substitute.For<IBookService>()), Substitute.For<IEventHub>());
var scanner = _scannerHelper.CreateServices(ds, fs);
await scanner.ScanLibrary(library.Id);
var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
Assert.NotNull(postLib);
Assert.Single(postLib.Series);
var spiceAndWolf = postLib.Series.First(x => x.Name == "Spice and Wolf");
Assert.Equal(3, spiceAndWolf.Volumes.Count);
Assert.Equal(4, spiceAndWolf.Volumes.Sum(v => v.Chapters.Count));
spiceAndWolf.LastFolderScanned = DateTime.Now.Subtract(TimeSpan.FromMinutes(2));
Context.Series.Update(spiceAndWolf);
await Context.SaveChangesAsync();
// Add file at series root
var spiceAndWolfDir = Path.Join(testDirectoryPath, "Spice and Wolf");
File.Copy(Path.Join(spiceAndWolfDir, "Spice and Wolf Vol. 1.cbz"),
Path.Join(spiceAndWolfDir, "Spice and Wolf Vol. 4.cbz"));
var res = await psf.ScanFiles(testDirectoryPath, true,
await UnitOfWork.SeriesRepository.GetFolderPathMap(postLib.Id), postLib);
var changes = res.Count(sc => sc.HasChanged);
Assert.Equal(2, changes);
}
[Fact]
public async Task SubFoldersNoSubFolders_ScanAllAfterAddInSubFolder()
{
const string testcase = "Subfolders and files at root - Manga.json";
var infos = new Dictionary<string, ComicInfo>();
var library = await _scannerHelper.GenerateScannerData(testcase, infos);
var testDirectoryPath = library.Folders.First().Path;
UnitOfWork.LibraryRepository.Update(library);
await UnitOfWork.CommitAsync();
var fs = new FileSystem();
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), fs);
var psf = new ParseScannedFiles(Substitute.For<ILogger<ParseScannedFiles>>(), ds,
new MockReadingItemService(ds, Substitute.For<IBookService>()), Substitute.For<IEventHub>());
var scanner = _scannerHelper.CreateServices(ds, fs);
await scanner.ScanLibrary(library.Id);
var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
Assert.NotNull(postLib);
Assert.Single(postLib.Series);
var spiceAndWolf = postLib.Series.First(x => x.Name == "Spice and Wolf");
Assert.Equal(3, spiceAndWolf.Volumes.Count);
Assert.Equal(4, spiceAndWolf.Volumes.Sum(v => v.Chapters.Count));
spiceAndWolf.LastFolderScanned = DateTime.Now.Subtract(TimeSpan.FromMinutes(2));
Context.Series.Update(spiceAndWolf);
await Context.SaveChangesAsync();
// Add file in subfolder
var spiceAndWolfDir = Path.Join(Path.Join(testDirectoryPath, "Spice and Wolf"), "Spice and Wolf Vol. 3");
File.Copy(Path.Join(spiceAndWolfDir, "Spice and Wolf Vol. 3 Ch. 0011.cbz"),
Path.Join(spiceAndWolfDir, "Spice and Wolf Vol. 3 Ch. 0013.cbz"));
var res = await psf.ScanFiles(testDirectoryPath, true,
await UnitOfWork.SeriesRepository.GetFolderPathMap(postLib.Id), postLib);
var changes = res.Count(sc => sc.HasChanged);
Assert.Equal(2, changes);
}
}

View file

@ -0,0 +1,286 @@
using System.Linq;
using System.Threading.Tasks;
using API.Data.Repositories;
using API.Entities;
using API.Entities.Enums;
using API.Entities.Person;
using API.Extensions;
using API.Helpers.Builders;
using API.Services;
using Xunit;
namespace API.Tests.Services;
public class PersonServiceTests: AbstractDbTest
{
[Fact]
public async Task PersonMerge_KeepNonEmptyMetadata()
{
var ps = new PersonService(UnitOfWork);
var person1 = new Person
{
Name = "Casey Delores",
NormalizedName = "Casey Delores".ToNormalized(),
HardcoverId = "ANonEmptyId",
MalId = 12,
};
var person2 = new Person
{
Name= "Delores Casey",
NormalizedName = "Delores Casey".ToNormalized(),
Description = "Hi, I'm Delores Casey!",
Aliases = [new PersonAliasBuilder("Casey, Delores").Build()],
AniListId = 27,
};
UnitOfWork.PersonRepository.Attach(person1);
UnitOfWork.PersonRepository.Attach(person2);
await UnitOfWork.CommitAsync();
await ps.MergePeopleAsync(person2, person1);
var allPeople = await UnitOfWork.PersonRepository.GetAllPeople();
Assert.Single(allPeople);
var person = allPeople[0];
Assert.Equal("Casey Delores", person.Name);
Assert.NotEmpty(person.Description);
Assert.Equal(27, person.AniListId);
Assert.NotNull(person.HardcoverId);
Assert.NotEmpty(person.HardcoverId);
Assert.Contains(person.Aliases, pa => pa.Alias == "Delores Casey");
Assert.Contains(person.Aliases, pa => pa.Alias == "Casey, Delores");
}
[Fact]
public async Task PersonMerge_MergedPersonDestruction()
{
var ps = new PersonService(UnitOfWork);
var person1 = new Person
{
Name = "Casey Delores",
NormalizedName = "Casey Delores".ToNormalized(),
};
var person2 = new Person
{
Name = "Delores Casey",
NormalizedName = "Delores Casey".ToNormalized(),
};
UnitOfWork.PersonRepository.Attach(person1);
UnitOfWork.PersonRepository.Attach(person2);
await UnitOfWork.CommitAsync();
await ps.MergePeopleAsync(person2, person1);
var allPeople = await UnitOfWork.PersonRepository.GetAllPeople();
Assert.Single(allPeople);
}
[Fact]
public async Task PersonMerge_RetentionChapters()
{
var ps = new PersonService(UnitOfWork);
var library = new LibraryBuilder("My Library").Build();
UnitOfWork.LibraryRepository.Add(library);
await UnitOfWork.CommitAsync();
var user = new AppUserBuilder("Amelia", "amelia@localhost")
.WithLibrary(library).Build();
UnitOfWork.UserRepository.Add(user);
var person = new PersonBuilder("Jillian Cowan").Build();
var person2 = new PersonBuilder("Cowan Jillian").Build();
var chapter = new ChapterBuilder("1")
.WithPerson(person, PersonRole.Editor)
.Build();
var chapter2 = new ChapterBuilder("2")
.WithPerson(person2, PersonRole.Editor)
.Build();
var series = new SeriesBuilder("Test 1")
.WithLibraryId(library.Id)
.WithVolume(new VolumeBuilder("1")
.WithChapter(chapter)
.Build())
.Build();
var series2 = new SeriesBuilder("Test 2")
.WithLibraryId(library.Id)
.WithVolume(new VolumeBuilder("2")
.WithChapter(chapter2)
.Build())
.Build();
UnitOfWork.SeriesRepository.Add(series);
UnitOfWork.SeriesRepository.Add(series2);
await UnitOfWork.CommitAsync();
await ps.MergePeopleAsync(person2, person);
var allPeople = await UnitOfWork.PersonRepository.GetAllPeople();
Assert.Single(allPeople);
var mergedPerson = allPeople[0];
Assert.Equal("Jillian Cowan", mergedPerson.Name);
var chapters = await UnitOfWork.PersonRepository.GetChaptersForPersonByRole(1, 1, PersonRole.Editor);
Assert.Equal(2, chapters.Count());
chapter = await UnitOfWork.ChapterRepository.GetChapterAsync(1, ChapterIncludes.People);
Assert.NotNull(chapter);
Assert.Single(chapter.People);
chapter2 = await UnitOfWork.ChapterRepository.GetChapterAsync(2, ChapterIncludes.People);
Assert.NotNull(chapter2);
Assert.Single(chapter2.People);
Assert.Equal(chapter.People.First().PersonId, chapter2.People.First().PersonId);
}
[Fact]
public async Task PersonMerge_NoDuplicateChaptersOrSeries()
{
await ResetDb();
var ps = new PersonService(UnitOfWork);
var library = new LibraryBuilder("My Library").Build();
UnitOfWork.LibraryRepository.Add(library);
await UnitOfWork.CommitAsync();
var user = new AppUserBuilder("Amelia", "amelia@localhost")
.WithLibrary(library).Build();
UnitOfWork.UserRepository.Add(user);
var person = new PersonBuilder("Jillian Cowan").Build();
var person2 = new PersonBuilder("Cowan Jillian").Build();
var chapter = new ChapterBuilder("1")
.WithPerson(person, PersonRole.Editor)
.WithPerson(person2, PersonRole.Colorist)
.Build();
var chapter2 = new ChapterBuilder("2")
.WithPerson(person2, PersonRole.Editor)
.WithPerson(person, PersonRole.Editor)
.Build();
var series = new SeriesBuilder("Test 1")
.WithLibraryId(library.Id)
.WithVolume(new VolumeBuilder("1")
.WithChapter(chapter)
.Build())
.WithMetadata(new SeriesMetadataBuilder()
.WithPerson(person, PersonRole.Editor)
.WithPerson(person2, PersonRole.Editor)
.Build())
.Build();
var series2 = new SeriesBuilder("Test 2")
.WithLibraryId(library.Id)
.WithVolume(new VolumeBuilder("2")
.WithChapter(chapter2)
.Build())
.WithMetadata(new SeriesMetadataBuilder()
.WithPerson(person, PersonRole.Editor)
.WithPerson(person2, PersonRole.Colorist)
.Build())
.Build();
UnitOfWork.SeriesRepository.Add(series);
UnitOfWork.SeriesRepository.Add(series2);
await UnitOfWork.CommitAsync();
await ps.MergePeopleAsync(person2, person);
var allPeople = await UnitOfWork.PersonRepository.GetAllPeople();
Assert.Single(allPeople);
var mergedPerson = await UnitOfWork.PersonRepository.GetPersonById(person.Id, PersonIncludes.All);
Assert.NotNull(mergedPerson);
Assert.Equal(3, mergedPerson.ChapterPeople.Count);
Assert.Equal(3, mergedPerson.SeriesMetadataPeople.Count);
chapter = await UnitOfWork.ChapterRepository.GetChapterAsync(chapter.Id, ChapterIncludes.People);
Assert.NotNull(chapter);
Assert.Equal(2, chapter.People.Count);
Assert.Single(chapter.People.Select(p => p.Person.Id).Distinct());
Assert.Contains(chapter.People, p => p.Role == PersonRole.Editor);
Assert.Contains(chapter.People, p => p.Role == PersonRole.Colorist);
chapter2 = await UnitOfWork.ChapterRepository.GetChapterAsync(chapter2.Id, ChapterIncludes.People);
Assert.NotNull(chapter2);
Assert.Single(chapter2.People);
Assert.Contains(chapter2.People, p => p.Role == PersonRole.Editor);
Assert.DoesNotContain(chapter2.People, p => p.Role == PersonRole.Colorist);
series = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(series.Id, SeriesIncludes.Metadata);
Assert.NotNull(series);
Assert.Single(series.Metadata.People);
Assert.Contains(series.Metadata.People, p => p.Role == PersonRole.Editor);
Assert.DoesNotContain(series.Metadata.People, p => p.Role == PersonRole.Colorist);
series2 = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(series2.Id, SeriesIncludes.Metadata);
Assert.NotNull(series2);
Assert.Equal(2, series2.Metadata.People.Count);
Assert.Contains(series2.Metadata.People, p => p.Role == PersonRole.Editor);
Assert.Contains(series2.Metadata.People, p => p.Role == PersonRole.Colorist);
}
[Fact]
public async Task PersonAddAlias_NoOverlap()
{
await ResetDb();
UnitOfWork.PersonRepository.Attach(new PersonBuilder("Jillian Cowan").Build());
UnitOfWork.PersonRepository.Attach(new PersonBuilder("Jilly Cowan").WithAlias("Jolly Cowan").Build());
await UnitOfWork.CommitAsync();
var ps = new PersonService(UnitOfWork);
var person1 = await UnitOfWork.PersonRepository.GetPersonByNameOrAliasAsync("Jillian Cowan");
var person2 = await UnitOfWork.PersonRepository.GetPersonByNameOrAliasAsync("Jilly Cowan");
Assert.NotNull(person1);
Assert.NotNull(person2);
// Overlap on Name
var success = await ps.UpdatePersonAliasesAsync(person1, ["Jilly Cowan"]);
Assert.False(success);
// Overlap on alias
success = await ps.UpdatePersonAliasesAsync(person1, ["Jolly Cowan"]);
Assert.False(success);
// No overlap
success = await ps.UpdatePersonAliasesAsync(person2, ["Jilly Joy Cowan"]);
Assert.True(success);
// Some overlap
success = await ps.UpdatePersonAliasesAsync(person1, ["Jolly Cowan", "Jilly Joy Cowan"]);
Assert.False(success);
// Some overlap
success = await ps.UpdatePersonAliasesAsync(person1, ["Jolly Cowan", "Jilly Joy Cowan"]);
Assert.False(success);
Assert.Single(person2.Aliases);
}
protected override async Task ResetDb()
{
Context.Person.RemoveRange(Context.Person.ToList());
await Context.SaveChangesAsync();
}
}

View file

@ -1,19 +1,4 @@
using System.IO;
using API.Data;
using API.Data.Metadata;
using API.Entities;
using API.Entities.Enums;
using API.Helpers;
using API.Helpers.Builders;
using API.Services;
using API.Services.Tasks.Metadata;
using API.Services.Tasks.Scanner;
using API.SignalR;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Xunit;
namespace API.Tests.Services;
namespace API.Tests.Services;
public class ProcessSeriesTests
{

View file

@ -0,0 +1,189 @@
using System.Linq;
using System.Threading.Tasks;
using API.Data.Repositories;
using API.DTOs;
using API.Entities.Enums;
using API.Helpers.Builders;
using API.Services;
using API.Services.Plus;
using Hangfire;
using Hangfire.InMemory;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Xunit;
namespace API.Tests.Services;
public class RatingServiceTests: AbstractDbTest
{
private readonly RatingService _ratingService;
public RatingServiceTests()
{
_ratingService = new RatingService(UnitOfWork, Substitute.For<IScrobblingService>(), Substitute.For<ILogger<RatingService>>());
}
[Fact]
public async Task UpdateRating_ShouldSetRating()
{
await ResetDb();
Context.Library.Add(new LibraryBuilder("Test LIb")
.WithAppUser(new AppUserBuilder("majora2007", string.Empty).Build())
.WithSeries(new SeriesBuilder("Test")
.WithVolume(new VolumeBuilder("1")
.WithChapter(new ChapterBuilder("1").WithPages(1).Build())
.Build())
.Build())
.Build());
await Context.SaveChangesAsync();
var user = await UnitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Ratings);
JobStorage.Current = new InMemoryStorage();
var result = await _ratingService.UpdateSeriesRating(user, new UpdateRatingDto
{
SeriesId = 1,
UserRating = 3,
});
Assert.True(result);
var ratings = (await UnitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Ratings))!
.Ratings;
Assert.NotEmpty(ratings);
Assert.Equal(3, ratings.First().Rating);
}
[Fact]
public async Task UpdateRating_ShouldUpdateExistingRating()
{
await ResetDb();
Context.Library.Add(new LibraryBuilder("Test LIb")
.WithAppUser(new AppUserBuilder("majora2007", string.Empty).Build())
.WithSeries(new SeriesBuilder("Test")
.WithVolume(new VolumeBuilder("1")
.WithChapter(new ChapterBuilder("1").WithPages(1).Build())
.Build())
.Build())
.Build());
await Context.SaveChangesAsync();
var user = await UnitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Ratings);
var result = await _ratingService.UpdateSeriesRating(user, new UpdateRatingDto
{
SeriesId = 1,
UserRating = 3,
});
Assert.True(result);
JobStorage.Current = new InMemoryStorage();
var ratings = (await UnitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Ratings))
.Ratings;
Assert.NotEmpty(ratings);
Assert.Equal(3, ratings.First().Rating);
// Update the DB again
var result2 = await _ratingService.UpdateSeriesRating(user, new UpdateRatingDto
{
SeriesId = 1,
UserRating = 5,
});
Assert.True(result2);
var ratings2 = (await UnitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Ratings))
.Ratings;
Assert.NotEmpty(ratings2);
Assert.True(ratings2.Count == 1);
Assert.Equal(5, ratings2.First().Rating);
}
[Fact]
public async Task UpdateRating_ShouldClampRatingAt5()
{
await ResetDb();
Context.Library.Add(new LibraryBuilder("Test LIb")
.WithAppUser(new AppUserBuilder("majora2007", string.Empty).Build())
.WithSeries(new SeriesBuilder("Test")
.WithVolume(new VolumeBuilder("1")
.WithChapter(new ChapterBuilder("1").WithPages(1).Build())
.Build())
.Build())
.Build());
await Context.SaveChangesAsync();
var user = await UnitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Ratings);
var result = await _ratingService.UpdateSeriesRating(user, new UpdateRatingDto
{
SeriesId = 1,
UserRating = 10,
});
Assert.True(result);
JobStorage.Current = new InMemoryStorage();
var ratings = (await UnitOfWork.UserRepository.GetUserByUsernameAsync("majora2007",
AppUserIncludes.Ratings)!)
.Ratings;
Assert.NotEmpty(ratings);
Assert.Equal(5, ratings.First().Rating);
}
[Fact]
public async Task UpdateRating_ShouldReturnFalseWhenSeriesDoesntExist()
{
await ResetDb();
Context.Library.Add(new LibraryBuilder("Test LIb", LibraryType.Book)
.WithAppUser(new AppUserBuilder("majora2007", string.Empty).Build())
.WithSeries(new SeriesBuilder("Test")
.WithVolume(new VolumeBuilder("1")
.WithChapter(new ChapterBuilder("1").WithPages(1).Build())
.Build())
.Build())
.Build());
await Context.SaveChangesAsync();
var user = await UnitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Ratings);
var result = await _ratingService.UpdateSeriesRating(user, new UpdateRatingDto
{
SeriesId = 2,
UserRating = 5,
});
Assert.False(result);
var ratings = user.Ratings;
Assert.Empty(ratings);
}
protected override async Task ResetDb()
{
Context.Series.RemoveRange(Context.Series.ToList());
Context.AppUserRating.RemoveRange(Context.AppUserRating.ToList());
Context.Genre.RemoveRange(Context.Genre.ToList());
Context.CollectionTag.RemoveRange(Context.CollectionTag.ToList());
Context.Person.RemoveRange(Context.Person.ToList());
Context.Library.RemoveRange(Context.Library.ToList());
await Context.SaveChangesAsync();
}
}

File diff suppressed because it is too large Load diff

View file

@ -11,15 +11,11 @@ using API.DTOs.ReadingLists;
using API.DTOs.ReadingLists.CBL;
using API.Entities;
using API.Entities.Enums;
using API.Entities.Metadata;
using API.Extensions;
using API.Helpers;
using API.Helpers.Builders;
using API.Services;
using API.Services.Plus;
using API.Services.Tasks;
using API.SignalR;
using API.Tests.Helpers;
using AutoMapper;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
@ -583,6 +579,93 @@ public class ReadingListServiceTests
Assert.Equal(AgeRating.G, readingList.AgeRating);
}
[Fact]
public async Task UpdateReadingListAgeRatingForSeries()
{
await ResetDb();
var spiceAndWolf = new SeriesBuilder("Spice and Wolf")
.WithMetadata(new SeriesMetadataBuilder().Build())
.WithVolumes([
new VolumeBuilder("1")
.WithChapters([
new ChapterBuilder("1").Build(),
new ChapterBuilder("2").Build(),
]).Build()
]).Build();
spiceAndWolf.Metadata.AgeRating = AgeRating.Everyone;
var othersidePicnic = new SeriesBuilder("Otherside Picnic ")
.WithMetadata(new SeriesMetadataBuilder().Build())
.WithVolumes([
new VolumeBuilder("1")
.WithChapters([
new ChapterBuilder("1").Build(),
new ChapterBuilder("2").Build(),
]).Build()
]).Build();
othersidePicnic.Metadata.AgeRating = AgeRating.Everyone;
_context.AppUser.Add(new AppUser()
{
UserName = "Amelia",
ReadingLists = new List<ReadingList>(),
Libraries = new List<Library>
{
new LibraryBuilder("Test Library", LibraryType.LightNovel)
.WithSeries(spiceAndWolf)
.WithSeries(othersidePicnic)
.Build(),
},
});
await _context.SaveChangesAsync();
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("Amelia", AppUserIncludes.ReadingLists);
Assert.NotNull(user);
var myTestReadingList = new ReadingListBuilder("MyReadingList").Build();
var mySecondTestReadingList = new ReadingListBuilder("MySecondReadingList").Build();
var myThirdTestReadingList = new ReadingListBuilder("MyThirdReadingList").Build();
user.ReadingLists = new List<ReadingList>()
{
myTestReadingList,
mySecondTestReadingList,
myThirdTestReadingList,
};
await _readingListService.AddChaptersToReadingList(spiceAndWolf.Id, new List<int> {1, 2}, myTestReadingList);
await _readingListService.AddChaptersToReadingList(othersidePicnic.Id, new List<int> {3, 4}, myTestReadingList);
await _readingListService.AddChaptersToReadingList(spiceAndWolf.Id, new List<int> {1, 2}, myThirdTestReadingList);
await _readingListService.AddChaptersToReadingList(othersidePicnic.Id, new List<int> {3, 4}, mySecondTestReadingList);
_unitOfWork.UserRepository.Update(user);
await _unitOfWork.CommitAsync();
await _readingListService.CalculateReadingListAgeRating(myTestReadingList);
await _readingListService.CalculateReadingListAgeRating(mySecondTestReadingList);
Assert.Equal(AgeRating.Everyone, myTestReadingList.AgeRating);
Assert.Equal(AgeRating.Everyone, mySecondTestReadingList.AgeRating);
Assert.Equal(AgeRating.Everyone, myThirdTestReadingList.AgeRating);
await _readingListService.UpdateReadingListAgeRatingForSeries(othersidePicnic.Id, AgeRating.Mature);
await _unitOfWork.CommitAsync();
// Reading lists containing Otherside Picnic are updated
myTestReadingList = await _unitOfWork.ReadingListRepository.GetReadingListByIdAsync(1);
Assert.NotNull(myTestReadingList);
Assert.Equal(AgeRating.Mature, myTestReadingList.AgeRating);
mySecondTestReadingList = await _unitOfWork.ReadingListRepository.GetReadingListByIdAsync(2);
Assert.NotNull(mySecondTestReadingList);
Assert.Equal(AgeRating.Mature, mySecondTestReadingList.AgeRating);
// Unrelated reading list is not updated
myThirdTestReadingList = await _unitOfWork.ReadingListRepository.GetReadingListByIdAsync(3);
Assert.NotNull(myThirdTestReadingList);
Assert.Equal(AgeRating.Everyone, myThirdTestReadingList.AgeRating);
}
#endregion
#region CalculateStartAndEndDates

View file

@ -0,0 +1,561 @@
using System.Linq;
using System.Threading.Tasks;
using API.Data.Repositories;
using API.DTOs;
using API.Entities;
using API.Entities.Enums;
using API.Helpers.Builders;
using API.Services;
using API.Tests.Helpers;
using Kavita.Common;
using Microsoft.EntityFrameworkCore;
using NSubstitute;
using Xunit;
namespace API.Tests.Services;
public class ReadingProfileServiceTest: AbstractDbTest
{
/// <summary>
/// Does not add a default reading profile
/// </summary>
/// <returns></returns>
public async Task<(ReadingProfileService, AppUser, Library, Series)> Setup()
{
var user = new AppUserBuilder("amelia", "amelia@localhost").Build();
Context.AppUser.Add(user);
await UnitOfWork.CommitAsync();
var series = new SeriesBuilder("Spice and Wolf").Build();
var library = new LibraryBuilder("Manga")
.WithSeries(series)
.Build();
user.Libraries.Add(library);
await UnitOfWork.CommitAsync();
var rps = new ReadingProfileService(UnitOfWork, Substitute.For<ILocalizationService>(), Mapper);
user = await UnitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.UserPreferences);
return (rps, user, library, series);
}
[Fact]
public async Task ImplicitProfileFirst()
{
await ResetDb();
var (rps, user, library, series) = await Setup();
var profile = new AppUserReadingProfileBuilder(user.Id)
.WithKind(ReadingProfileKind.Implicit)
.WithSeries(series)
.WithName("Implicit Profile")
.Build();
var profile2 = new AppUserReadingProfileBuilder(user.Id)
.WithSeries(series)
.WithName("Non-implicit Profile")
.Build();
user.ReadingProfiles.Add(profile);
user.ReadingProfiles.Add(profile2);
await UnitOfWork.CommitAsync();
var seriesProfile = await rps.GetReadingProfileDtoForSeries(user.Id, series.Id);
Assert.NotNull(seriesProfile);
Assert.Equal("Implicit Profile", seriesProfile.Name);
// Find parent
seriesProfile = await rps.GetReadingProfileDtoForSeries(user.Id, series.Id, true);
Assert.NotNull(seriesProfile);
Assert.Equal("Non-implicit Profile", seriesProfile.Name);
}
[Fact]
public async Task CantDeleteDefaultReadingProfile()
{
await ResetDb();
var (rps, user, _, _) = await Setup();
var profile = new AppUserReadingProfileBuilder(user.Id)
.WithKind(ReadingProfileKind.Default)
.Build();
Context.AppUserReadingProfiles.Add(profile);
await UnitOfWork.CommitAsync();
await Assert.ThrowsAsync<KavitaException>(async () =>
{
await rps.DeleteReadingProfile(user.Id, profile.Id);
});
var profile2 = new AppUserReadingProfileBuilder(user.Id).Build();
Context.AppUserReadingProfiles.Add(profile2);
await UnitOfWork.CommitAsync();
await rps.DeleteReadingProfile(user.Id, profile2.Id);
await UnitOfWork.CommitAsync();
var allProfiles = await Context.AppUserReadingProfiles.ToListAsync();
Assert.Single(allProfiles);
}
[Fact]
public async Task CreateImplicitSeriesReadingProfile()
{
await ResetDb();
var (rps, user, _, series) = await Setup();
var dto = new UserReadingProfileDto
{
ReaderMode = ReaderMode.Webtoon,
ScalingOption = ScalingOption.FitToHeight,
WidthOverride = 53,
};
await rps.UpdateImplicitReadingProfile(user.Id, series.Id, dto);
var profile = await rps.GetReadingProfileForSeries(user.Id, series.Id);
Assert.NotNull(profile);
Assert.Contains(profile.SeriesIds, s => s == series.Id);
Assert.Equal(ReadingProfileKind.Implicit, profile.Kind);
}
[Fact]
public async Task UpdateImplicitReadingProfile_DoesNotCreateNew()
{
await ResetDb();
var (rps, user, _, series) = await Setup();
var dto = new UserReadingProfileDto
{
ReaderMode = ReaderMode.Webtoon,
ScalingOption = ScalingOption.FitToHeight,
WidthOverride = 53,
};
await rps.UpdateImplicitReadingProfile(user.Id, series.Id, dto);
var profile = await rps.GetReadingProfileForSeries(user.Id, series.Id);
Assert.NotNull(profile);
Assert.Contains(profile.SeriesIds, s => s == series.Id);
Assert.Equal(ReadingProfileKind.Implicit, profile.Kind);
dto = new UserReadingProfileDto
{
ReaderMode = ReaderMode.LeftRight,
};
await rps.UpdateImplicitReadingProfile(user.Id, series.Id, dto);
profile = await rps.GetReadingProfileForSeries(user.Id, series.Id);
Assert.NotNull(profile);
Assert.Contains(profile.SeriesIds, s => s == series.Id);
Assert.Equal(ReadingProfileKind.Implicit, profile.Kind);
Assert.Equal(ReaderMode.LeftRight, profile.ReaderMode);
var implicitCount = await Context.AppUserReadingProfiles
.Where(p => p.Kind == ReadingProfileKind.Implicit)
.CountAsync();
Assert.Equal(1, implicitCount);
}
[Fact]
public async Task GetCorrectProfile()
{
await ResetDb();
var (rps, user, lib, series) = await Setup();
var profile = new AppUserReadingProfileBuilder(user.Id)
.WithSeries(series)
.WithName("Series Specific")
.Build();
var profile2 = new AppUserReadingProfileBuilder(user.Id)
.WithLibrary(lib)
.WithName("Library Specific")
.Build();
var profile3 = new AppUserReadingProfileBuilder(user.Id)
.WithKind(ReadingProfileKind.Default)
.WithName("Global")
.Build();
Context.AppUserReadingProfiles.Add(profile);
Context.AppUserReadingProfiles.Add(profile2);
Context.AppUserReadingProfiles.Add(profile3);
var series2 = new SeriesBuilder("Rainbows After Storms").Build();
lib.Series.Add(series2);
var lib2 = new LibraryBuilder("Manga2").Build();
var series3 = new SeriesBuilder("A Tropical Fish Yearns for Snow").Build();
lib2.Series.Add(series3);
user.Libraries.Add(lib2);
await UnitOfWork.CommitAsync();
var p = await rps.GetReadingProfileDtoForSeries(user.Id, series.Id);
Assert.NotNull(p);
Assert.Equal("Series Specific", p.Name);
p = await rps.GetReadingProfileDtoForSeries(user.Id, series2.Id);
Assert.NotNull(p);
Assert.Equal("Library Specific", p.Name);
p = await rps.GetReadingProfileDtoForSeries(user.Id, series3.Id);
Assert.NotNull(p);
Assert.Equal("Global", p.Name);
}
[Fact]
public async Task ReplaceReadingProfile()
{
await ResetDb();
var (rps, user, lib, series) = await Setup();
var profile1 = new AppUserReadingProfileBuilder(user.Id)
.WithSeries(series)
.WithName("Profile 1")
.Build();
var profile2 = new AppUserReadingProfileBuilder(user.Id)
.WithName("Profile 2")
.Build();
Context.AppUserReadingProfiles.Add(profile1);
Context.AppUserReadingProfiles.Add(profile2);
await UnitOfWork.CommitAsync();
var profile = await rps.GetReadingProfileDtoForSeries(user.Id, series.Id);
Assert.NotNull(profile);
Assert.Equal("Profile 1", profile.Name);
await rps.AddProfileToSeries(user.Id, profile2.Id, series.Id);
profile = await rps.GetReadingProfileDtoForSeries(user.Id, series.Id);
Assert.NotNull(profile);
Assert.Equal("Profile 2", profile.Name);
}
[Fact]
public async Task DeleteReadingProfile()
{
await ResetDb();
var (rps, user, lib, series) = await Setup();
var profile1 = new AppUserReadingProfileBuilder(user.Id)
.WithSeries(series)
.WithName("Profile 1")
.Build();
Context.AppUserReadingProfiles.Add(profile1);
await UnitOfWork.CommitAsync();
await rps.ClearSeriesProfile(user.Id, series.Id);
var profiles = await UnitOfWork.AppUserReadingProfileRepository.GetProfilesForUser(user.Id);
Assert.DoesNotContain(profiles, rp => rp.SeriesIds.Contains(series.Id));
}
[Fact]
public async Task BulkAddReadingProfiles()
{
await ResetDb();
var (rps, user, lib, series) = await Setup();
for (var i = 0; i < 10; i++)
{
var generatedSeries = new SeriesBuilder($"Generated Series #{i}").Build();
lib.Series.Add(generatedSeries);
}
var profile = new AppUserReadingProfileBuilder(user.Id)
.WithSeries(series)
.WithName("Profile")
.Build();
Context.AppUserReadingProfiles.Add(profile);
var profile2 = new AppUserReadingProfileBuilder(user.Id)
.WithSeries(series)
.WithName("Profile2")
.Build();
Context.AppUserReadingProfiles.Add(profile2);
await UnitOfWork.CommitAsync();
var someSeriesIds = lib.Series.Take(lib.Series.Count / 2).Select(s => s.Id).ToList();
await rps.BulkAddProfileToSeries(user.Id, profile.Id, someSeriesIds);
foreach (var id in someSeriesIds)
{
var foundProfile = await rps.GetReadingProfileDtoForSeries(user.Id, id);
Assert.NotNull(foundProfile);
Assert.Equal(profile.Id, foundProfile.Id);
}
var allIds = lib.Series.Select(s => s.Id).ToList();
await rps.BulkAddProfileToSeries(user.Id, profile2.Id, allIds);
foreach (var id in allIds)
{
var foundProfile = await rps.GetReadingProfileDtoForSeries(user.Id, id);
Assert.NotNull(foundProfile);
Assert.Equal(profile2.Id, foundProfile.Id);
}
}
[Fact]
public async Task BulkAssignDeletesImplicit()
{
await ResetDb();
var (rps, user, lib, series) = await Setup();
var implicitProfile = Mapper.Map<UserReadingProfileDto>(new AppUserReadingProfileBuilder(user.Id)
.Build());
var profile = new AppUserReadingProfileBuilder(user.Id)
.WithName("Profile 1")
.Build();
Context.AppUserReadingProfiles.Add(profile);
for (var i = 0; i < 10; i++)
{
var generatedSeries = new SeriesBuilder($"Generated Series #{i}").Build();
lib.Series.Add(generatedSeries);
}
await UnitOfWork.CommitAsync();
var ids = lib.Series.Select(s => s.Id).ToList();
foreach (var id in ids)
{
await rps.UpdateImplicitReadingProfile(user.Id, id, implicitProfile);
var seriesProfile = await rps.GetReadingProfileDtoForSeries(user.Id, id);
Assert.NotNull(seriesProfile);
Assert.Equal(ReadingProfileKind.Implicit, seriesProfile.Kind);
}
await rps.BulkAddProfileToSeries(user.Id, profile.Id, ids);
foreach (var id in ids)
{
var seriesProfile = await rps.GetReadingProfileDtoForSeries(user.Id, id);
Assert.NotNull(seriesProfile);
Assert.Equal(ReadingProfileKind.User, seriesProfile.Kind);
}
var implicitCount = await Context.AppUserReadingProfiles
.Where(p => p.Kind == ReadingProfileKind.Implicit)
.CountAsync();
Assert.Equal(0, implicitCount);
}
[Fact]
public async Task AddDeletesImplicit()
{
await ResetDb();
var (rps, user, lib, series) = await Setup();
var implicitProfile = Mapper.Map<UserReadingProfileDto>(new AppUserReadingProfileBuilder(user.Id)
.WithKind(ReadingProfileKind.Implicit)
.Build());
var profile = new AppUserReadingProfileBuilder(user.Id)
.WithName("Profile 1")
.Build();
Context.AppUserReadingProfiles.Add(profile);
await UnitOfWork.CommitAsync();
await rps.UpdateImplicitReadingProfile(user.Id, series.Id, implicitProfile);
var seriesProfile = await rps.GetReadingProfileDtoForSeries(user.Id, series.Id);
Assert.NotNull(seriesProfile);
Assert.Equal(ReadingProfileKind.Implicit, seriesProfile.Kind);
await rps.AddProfileToSeries(user.Id, profile.Id, series.Id);
seriesProfile = await rps.GetReadingProfileDtoForSeries(user.Id, series.Id);
Assert.NotNull(seriesProfile);
Assert.Equal(ReadingProfileKind.User, seriesProfile.Kind);
var implicitCount = await Context.AppUserReadingProfiles
.Where(p => p.Kind == ReadingProfileKind.Implicit)
.CountAsync();
Assert.Equal(0, implicitCount);
}
[Fact]
public async Task CreateReadingProfile()
{
await ResetDb();
var (rps, user, lib, series) = await Setup();
var dto = new UserReadingProfileDto
{
Name = "Profile 1",
ReaderMode = ReaderMode.LeftRight,
EmulateBook = false,
};
await rps.CreateReadingProfile(user.Id, dto);
var dto2 = new UserReadingProfileDto
{
Name = "Profile 2",
ReaderMode = ReaderMode.LeftRight,
EmulateBook = false,
};
await rps.CreateReadingProfile(user.Id, dto2);
var dto3 = new UserReadingProfileDto
{
Name = "Profile 1", // Not unique name
ReaderMode = ReaderMode.LeftRight,
EmulateBook = false,
};
await Assert.ThrowsAsync<KavitaException>(async () =>
{
await rps.CreateReadingProfile(user.Id, dto3);
});
var allProfiles = Context.AppUserReadingProfiles.ToList();
Assert.Equal(2, allProfiles.Count);
}
[Fact]
public async Task ClearSeriesProfile_RemovesImplicitAndUnlinksExplicit()
{
await ResetDb();
var (rps, user, _, series) = await Setup();
var implicitProfile = new AppUserReadingProfileBuilder(user.Id)
.WithSeries(series)
.WithKind(ReadingProfileKind.Implicit)
.WithName("Implicit Profile")
.Build();
var explicitProfile = new AppUserReadingProfileBuilder(user.Id)
.WithSeries(series)
.WithName("Explicit Profile")
.Build();
Context.AppUserReadingProfiles.Add(implicitProfile);
Context.AppUserReadingProfiles.Add(explicitProfile);
await UnitOfWork.CommitAsync();
var allBefore = await UnitOfWork.AppUserReadingProfileRepository.GetProfilesForUser(user.Id);
Assert.Equal(2, allBefore.Count(rp => rp.SeriesIds.Contains(series.Id)));
await rps.ClearSeriesProfile(user.Id, series.Id);
var remainingProfiles = await Context.AppUserReadingProfiles.ToListAsync();
Assert.Single(remainingProfiles);
Assert.Equal("Explicit Profile", remainingProfiles[0].Name);
Assert.Empty(remainingProfiles[0].SeriesIds);
var profilesForSeries = await UnitOfWork.AppUserReadingProfileRepository.GetProfilesForUser(user.Id);
Assert.DoesNotContain(profilesForSeries, rp => rp.SeriesIds.Contains(series.Id));
}
[Fact]
public async Task AddProfileToLibrary_AddsAndOverridesExisting()
{
await ResetDb();
var (rps, user, lib, _) = await Setup();
var profile = new AppUserReadingProfileBuilder(user.Id)
.WithName("Library Profile")
.Build();
Context.AppUserReadingProfiles.Add(profile);
await UnitOfWork.CommitAsync();
await rps.AddProfileToLibrary(user.Id, profile.Id, lib.Id);
await UnitOfWork.CommitAsync();
var linkedProfile = (await UnitOfWork.AppUserReadingProfileRepository.GetProfilesForUser(user.Id))
.FirstOrDefault(rp => rp.LibraryIds.Contains(lib.Id));
Assert.NotNull(linkedProfile);
Assert.Equal(profile.Id, linkedProfile.Id);
var newProfile = new AppUserReadingProfileBuilder(user.Id)
.WithName("New Profile")
.Build();
Context.AppUserReadingProfiles.Add(newProfile);
await UnitOfWork.CommitAsync();
await rps.AddProfileToLibrary(user.Id, newProfile.Id, lib.Id);
await UnitOfWork.CommitAsync();
linkedProfile = (await UnitOfWork.AppUserReadingProfileRepository.GetProfilesForUser(user.Id))
.FirstOrDefault(rp => rp.LibraryIds.Contains(lib.Id));
Assert.NotNull(linkedProfile);
Assert.Equal(newProfile.Id, linkedProfile.Id);
}
[Fact]
public async Task ClearLibraryProfile_RemovesImplicitOrUnlinksExplicit()
{
await ResetDb();
var (rps, user, lib, _) = await Setup();
var implicitProfile = new AppUserReadingProfileBuilder(user.Id)
.WithKind(ReadingProfileKind.Implicit)
.WithLibrary(lib)
.Build();
Context.AppUserReadingProfiles.Add(implicitProfile);
await UnitOfWork.CommitAsync();
await rps.ClearLibraryProfile(user.Id, lib.Id);
var profile = (await UnitOfWork.AppUserReadingProfileRepository.GetProfilesForUser(user.Id))
.FirstOrDefault(rp => rp.LibraryIds.Contains(lib.Id));
Assert.Null(profile);
var explicitProfile = new AppUserReadingProfileBuilder(user.Id)
.WithLibrary(lib)
.Build();
Context.AppUserReadingProfiles.Add(explicitProfile);
await UnitOfWork.CommitAsync();
await rps.ClearLibraryProfile(user.Id, lib.Id);
profile = (await UnitOfWork.AppUserReadingProfileRepository.GetProfilesForUser(user.Id))
.FirstOrDefault(rp => rp.LibraryIds.Contains(lib.Id));
Assert.Null(profile);
var stillExists = await Context.AppUserReadingProfiles.FindAsync(explicitProfile.Id);
Assert.NotNull(stillExists);
}
/// <summary>
/// As response to #3793, I'm not sure if we want to keep this. It's not the most nice. But I think the idea of this test
/// is worth having.
/// </summary>
[Fact]
public void UpdateFields_UpdatesAll()
{
// Repeat to ensure booleans are flipped and actually tested
for (int i = 0; i < 10; i++)
{
var profile = new AppUserReadingProfile();
var dto = new UserReadingProfileDto();
RandfHelper.SetRandomValues(profile);
RandfHelper.SetRandomValues(dto);
ReadingProfileService.UpdateReaderProfileFields(profile, dto);
var newDto = Mapper.Map<UserReadingProfileDto>(profile);
Assert.True(RandfHelper.AreSimpleFieldsEqual(dto, newDto,
["<Id>k__BackingField", "<UserId>k__BackingField"]));
}
}
protected override async Task ResetDb()
{
Context.AppUserReadingProfiles.RemoveRange(Context.AppUserReadingProfiles);
await UnitOfWork.CommitAsync();
}
}

View file

@ -1,34 +1,16 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Abstractions;
using System.IO.Compression;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using System.Xml;
using System.Xml.Serialization;
using API.Data;
using API.Data.Metadata;
using API.Data.Repositories;
using API.Entities;
using API.Entities.Enums;
using API.Extensions;
using API.Helpers;
using API.Helpers.Builders;
using API.Services;
using API.Services.Plus;
using API.Services.Tasks;
using API.Services.Tasks.Metadata;
using API.Services.Tasks.Scanner;
using API.Services.Tasks.Scanner.Parser;
using API.SignalR;
using API.Tests.Helpers;
using Hangfire;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Xunit;
using Xunit.Abstractions;
@ -46,13 +28,35 @@ public class ScannerServiceTests : AbstractDbTest
// Set up Hangfire to use in-memory storage for testing
GlobalConfiguration.Configuration.UseInMemoryStorage();
_scannerHelper = new ScannerHelper(_unitOfWork, testOutputHelper);
_scannerHelper = new ScannerHelper(UnitOfWork, testOutputHelper);
}
protected override async Task ResetDb()
{
_context.Library.RemoveRange(_context.Library);
await _context.SaveChangesAsync();
Context.Library.RemoveRange(Context.Library);
await Context.SaveChangesAsync();
}
protected async Task SetAllSeriesLastScannedInThePast(Library library, TimeSpan? duration = null)
{
foreach (var series in library.Series)
{
await SetLastScannedInThePast(series, duration, false);
}
await Context.SaveChangesAsync();
}
protected async Task SetLastScannedInThePast(Series series, TimeSpan? duration = null, bool save = true)
{
duration ??= TimeSpan.FromMinutes(2);
series.LastFolderScanned = DateTime.Now.Subtract(duration.Value);
Context.Series.Update(series);
if (save)
{
await Context.SaveChangesAsync();
}
}
[Fact]
@ -62,7 +66,7 @@ public class ScannerServiceTests : AbstractDbTest
var library = await _scannerHelper.GenerateScannerData(testcase);
var scanner = _scannerHelper.CreateServices();
await scanner.ScanLibrary(library.Id);
var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
Assert.NotNull(postLib);
Assert.Equal(4, postLib.Series.Count);
@ -75,7 +79,7 @@ public class ScannerServiceTests : AbstractDbTest
var library = await _scannerHelper.GenerateScannerData(testcase);
var scanner = _scannerHelper.CreateServices();
await scanner.ScanLibrary(library.Id);
var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
Assert.NotNull(postLib);
Assert.Single(postLib.Series);
@ -86,11 +90,11 @@ public class ScannerServiceTests : AbstractDbTest
[Fact]
public async Task ScanLibrary_FlatSeries()
{
var testcase = "Flat Series - Manga.json";
const string testcase = "Flat Series - Manga.json";
var library = await _scannerHelper.GenerateScannerData(testcase);
var scanner = _scannerHelper.CreateServices();
await scanner.ScanLibrary(library.Id);
var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
Assert.NotNull(postLib);
Assert.Single(postLib.Series);
@ -102,11 +106,11 @@ public class ScannerServiceTests : AbstractDbTest
[Fact]
public async Task ScanLibrary_FlatSeriesWithSpecialFolder()
{
var testcase = "Flat Series with Specials Folder Alt Naming - Manga.json";
const string testcase = "Flat Series with Specials Folder Alt Naming - Manga.json";
var library = await _scannerHelper.GenerateScannerData(testcase);
var scanner = _scannerHelper.CreateServices();
await scanner.ScanLibrary(library.Id);
var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
Assert.NotNull(postLib);
Assert.Single(postLib.Series);
@ -117,11 +121,11 @@ public class ScannerServiceTests : AbstractDbTest
[Fact]
public async Task ScanLibrary_FlatSeriesWithSpecialFolder_AlternativeNaming()
{
var testcase = "Flat Series with Specials Folder Alt Naming - Manga.json";
const string testcase = "Flat Series with Specials Folder Alt Naming - Manga.json";
var library = await _scannerHelper.GenerateScannerData(testcase);
var scanner = _scannerHelper.CreateServices();
await scanner.ScanLibrary(library.Id);
var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
Assert.NotNull(postLib);
Assert.Single(postLib.Series);
@ -137,7 +141,7 @@ public class ScannerServiceTests : AbstractDbTest
var library = await _scannerHelper.GenerateScannerData(testcase);
var scanner = _scannerHelper.CreateServices();
await scanner.ScanLibrary(library.Id);
var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
Assert.NotNull(postLib);
Assert.Single(postLib.Series);
@ -145,7 +149,6 @@ public class ScannerServiceTests : AbstractDbTest
Assert.NotNull(postLib.Series.First().Volumes.FirstOrDefault(v => v.Chapters.FirstOrDefault(c => c.IsSpecial) != null));
}
[Fact]
public async Task ScanLibrary_SeriesWithUnbalancedParenthesis()
{
@ -154,7 +157,7 @@ public class ScannerServiceTests : AbstractDbTest
var library = await _scannerHelper.GenerateScannerData(testcase);
var scanner = _scannerHelper.CreateServices();
await scanner.ScanLibrary(library.Id);
var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
Assert.NotNull(postLib);
Assert.Single(postLib.Series);
@ -185,7 +188,7 @@ public class ScannerServiceTests : AbstractDbTest
var scanner = _scannerHelper.CreateServices();
await scanner.ScanLibrary(library.Id);
var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
Assert.NotNull(postLib);
Assert.Single(postLib.Series);
@ -210,7 +213,7 @@ public class ScannerServiceTests : AbstractDbTest
var scanner = _scannerHelper.CreateServices();
await scanner.ScanLibrary(library.Id);
var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
Assert.NotNull(postLib);
Assert.Single(postLib.Series);
@ -241,7 +244,7 @@ public class ScannerServiceTests : AbstractDbTest
var scanner = _scannerHelper.CreateServices();
await scanner.ScanLibrary(library.Id);
var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
Assert.NotNull(postLib);
Assert.Single(postLib.Series);
@ -265,7 +268,7 @@ public class ScannerServiceTests : AbstractDbTest
var scanner = _scannerHelper.CreateServices();
await scanner.ScanLibrary(library.Id);
var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
Assert.NotNull(postLib);
Assert.Single(postLib.Series);
@ -285,7 +288,7 @@ public class ScannerServiceTests : AbstractDbTest
var scanner = _scannerHelper.CreateServices();
await scanner.ScanLibrary(library.Id);
var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
Assert.NotNull(postLib);
Assert.Single(postLib.Series);
@ -299,38 +302,38 @@ public class ScannerServiceTests : AbstractDbTest
}
[Fact]
public async Task ScanLibrary_PublishersInheritFromChapters()
[Fact]
public async Task ScanLibrary_PublishersInheritFromChapters()
{
const string testcase = "Flat Special - Manga.json";
var infos = new Dictionary<string, ComicInfo>();
infos.Add("Uzaki-chan Wants to Hang Out! v01 (2019) (Digital) (danke-Empire).cbz", new ComicInfo()
{
const string testcase = "Flat Special - Manga.json";
Publisher = "Correct Publisher"
});
infos.Add("Uzaki-chan Wants to Hang Out! - 2022 New Years Special SP01.cbz", new ComicInfo()
{
Publisher = "Special Publisher"
});
infos.Add("Uzaki-chan Wants to Hang Out! - Ch. 103 - Kouhai and Control.cbz", new ComicInfo()
{
Publisher = "Chapter Publisher"
});
var infos = new Dictionary<string, ComicInfo>();
infos.Add("Uzaki-chan Wants to Hang Out! v01 (2019) (Digital) (danke-Empire).cbz", new ComicInfo()
{
Publisher = "Correct Publisher"
});
infos.Add("Uzaki-chan Wants to Hang Out! - 2022 New Years Special SP01.cbz", new ComicInfo()
{
Publisher = "Special Publisher"
});
infos.Add("Uzaki-chan Wants to Hang Out! - Ch. 103 - Kouhai and Control.cbz", new ComicInfo()
{
Publisher = "Chapter Publisher"
});
var library = await _scannerHelper.GenerateScannerData(testcase, infos);
var library = await _scannerHelper.GenerateScannerData(testcase, infos);
var scanner = _scannerHelper.CreateServices();
await scanner.ScanLibrary(library.Id);
var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
var scanner = _scannerHelper.CreateServices();
await scanner.ScanLibrary(library.Id);
var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
Assert.NotNull(postLib);
Assert.Single(postLib.Series);
var publishers = postLib.Series.First().Metadata.People
.Where(p => p.Role == PersonRole.Publisher);
Assert.Equal(3, publishers.Count());
}
Assert.NotNull(postLib);
Assert.Single(postLib.Series);
var publishers = postLib.Series.First().Metadata.People
.Where(p => p.Role == PersonRole.Publisher);
Assert.Equal(3, publishers.Count());
}
/// <summary>
@ -347,7 +350,7 @@ public class ScannerServiceTests : AbstractDbTest
var scanner = _scannerHelper.CreateServices();
await scanner.ScanLibrary(library.Id);
var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
Assert.NotNull(postLib);
Assert.Single(postLib.Series);
@ -366,7 +369,7 @@ public class ScannerServiceTests : AbstractDbTest
var scanner = _scannerHelper.CreateServices();
await scanner.ScanLibrary(library.Id);
var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
Assert.NotNull(postLib);
Assert.Single(postLib.Series);
@ -396,7 +399,7 @@ public class ScannerServiceTests : AbstractDbTest
var scanner = _scannerHelper.CreateServices();
await scanner.ScanLibrary(library.Id);
var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
Assert.NotNull(postLib);
Assert.Single(postLib.Series);
@ -433,7 +436,7 @@ public class ScannerServiceTests : AbstractDbTest
var scanner = _scannerHelper.CreateServices();
await scanner.ScanLibrary(library.Id);
var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
Assert.NotNull(postLib);
Assert.Single(postLib.Series);
@ -461,7 +464,7 @@ public class ScannerServiceTests : AbstractDbTest
var scanner = _scannerHelper.CreateServices();
await scanner.ScanLibrary(library.Id);
var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
Assert.NotNull(postLib);
Assert.Single(postLib.Series);
@ -480,14 +483,14 @@ public class ScannerServiceTests : AbstractDbTest
var infos = new Dictionary<string, ComicInfo>();
var library = await _scannerHelper.GenerateScannerData(testcase, infos);
library.LibraryExcludePatterns = [new LibraryExcludePattern() {Pattern = "**/Extra/*"}];
_unitOfWork.LibraryRepository.Update(library);
await _unitOfWork.CommitAsync();
library.LibraryExcludePatterns = [new LibraryExcludePattern() { Pattern = "**/Extra/*" }];
UnitOfWork.LibraryRepository.Update(library);
await UnitOfWork.CommitAsync();
var scanner = _scannerHelper.CreateServices();
await scanner.ScanLibrary(library.Id);
var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
Assert.NotNull(postLib);
Assert.Single(postLib.Series);
@ -504,14 +507,14 @@ public class ScannerServiceTests : AbstractDbTest
var infos = new Dictionary<string, ComicInfo>();
var library = await _scannerHelper.GenerateScannerData(testcase, infos);
library.LibraryExcludePatterns = [new LibraryExcludePattern() {Pattern = "**\\Extra\\*"}];
_unitOfWork.LibraryRepository.Update(library);
await _unitOfWork.CommitAsync();
library.LibraryExcludePatterns = [new LibraryExcludePattern() { Pattern = "**\\Extra\\*" }];
UnitOfWork.LibraryRepository.Update(library);
await UnitOfWork.CommitAsync();
var scanner = _scannerHelper.CreateServices();
await scanner.ScanLibrary(library.Id);
var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
Assert.NotNull(postLib);
Assert.Single(postLib.Series);
@ -538,13 +541,13 @@ public class ScannerServiceTests : AbstractDbTest
new FolderPath() {Path = Path.Join(testDirectoryPath, "Root 2")}
];
_unitOfWork.LibraryRepository.Update(library);
await _unitOfWork.CommitAsync();
UnitOfWork.LibraryRepository.Update(library);
await UnitOfWork.CommitAsync();
var scanner = _scannerHelper.CreateServices();
await scanner.ScanLibrary(library.Id);
var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
Assert.NotNull(postLib);
Assert.Equal(2, postLib.Series.Count);
@ -560,7 +563,7 @@ public class ScannerServiceTests : AbstractDbTest
// Rescan to ensure nothing changes yet again
await scanner.ScanLibrary(library.Id, true);
postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
Assert.Equal(2, postLib.Series.Count);
s = postLib.Series.First(s => s.Name == "Plush");
Assert.Equal(3, s.Volumes.Count);
@ -591,13 +594,13 @@ public class ScannerServiceTests : AbstractDbTest
new FolderPath() {Path = Path.Join(testDirectoryPath, "Root 2")}
];
_unitOfWork.LibraryRepository.Update(library);
await _unitOfWork.CommitAsync();
UnitOfWork.LibraryRepository.Update(library);
await UnitOfWork.CommitAsync();
var scanner = _scannerHelper.CreateServices();
await scanner.ScanLibrary(library.Id);
var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
Assert.NotNull(postLib);
Assert.Equal(2, postLib.Series.Count);
@ -611,14 +614,12 @@ public class ScannerServiceTests : AbstractDbTest
File.Copy(Path.Join(root1PlushFolder, "Plush v02.cbz"), Path.Join(root1PlushFolder, "Plush v03.cbz"));
// Emulate time passage by updating lastFolderScan to be a min in the past
s.LastFolderScanned = DateTime.Now.Subtract(TimeSpan.FromMinutes(1));
_context.Series.Update(s);
await _context.SaveChangesAsync();
await SetLastScannedInThePast(s);
// Rescan to ensure nothing changes yet again
await scanner.ScanLibrary(library.Id, false);
postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
Assert.Equal(2, postLib.Series.Count);
s = postLib.Series.First(s => s.Name == "Plush");
Assert.Equal(3, s.Volumes.Count);
@ -646,14 +647,14 @@ public class ScannerServiceTests : AbstractDbTest
new FolderPath() { Path = Path.Combine(testDirectoryPath, "Root 2") }
];
_unitOfWork.LibraryRepository.Update(library);
await _unitOfWork.CommitAsync();
UnitOfWork.LibraryRepository.Update(library);
await UnitOfWork.CommitAsync();
var scanner = _scannerHelper.CreateServices();
// First Scan: Everything should be added
await scanner.ScanLibrary(library.Id);
var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
Assert.NotNull(postLib);
Assert.Contains(postLib.Series, s => s.Name == "Accel");
@ -661,19 +662,19 @@ public class ScannerServiceTests : AbstractDbTest
// Second Scan: Remove Root 2, expect Accel to be removed
library.Folders = [new FolderPath() { Path = Path.Combine(testDirectoryPath, "Root 1") }];
_unitOfWork.LibraryRepository.Update(library);
await _unitOfWork.CommitAsync();
UnitOfWork.LibraryRepository.Update(library);
await UnitOfWork.CommitAsync();
// Emulate time passage by updating lastFolderScan to be a min in the past
foreach (var s in postLib.Series)
{
s.LastFolderScanned = DateTime.Now.Subtract(TimeSpan.FromMinutes(1));
_context.Series.Update(s);
Context.Series.Update(s);
}
await _context.SaveChangesAsync();
await Context.SaveChangesAsync();
await scanner.ScanLibrary(library.Id);
postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
Assert.DoesNotContain(postLib.Series, s => s.Name == "Accel"); // Ensure Accel is gone
Assert.Contains(postLib.Series, s => s.Name == "Plush");
@ -684,34 +685,29 @@ public class ScannerServiceTests : AbstractDbTest
new FolderPath() { Path = Path.Combine(testDirectoryPath, "Root 1") },
new FolderPath() { Path = Path.Combine(testDirectoryPath, "Root 2") }
];
_unitOfWork.LibraryRepository.Update(library);
await _unitOfWork.CommitAsync();
UnitOfWork.LibraryRepository.Update(library);
await UnitOfWork.CommitAsync();
// Emulate time passage by updating lastFolderScan to be a min in the past
foreach (var s in postLib.Series)
{
s.LastFolderScanned = DateTime.Now.Subtract(TimeSpan.FromMinutes(1));
_context.Series.Update(s);
Context.Series.Update(s);
}
await _context.SaveChangesAsync();
await Context.SaveChangesAsync();
await scanner.ScanLibrary(library.Id);
postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
Assert.Contains(postLib.Series, s => s.Name == "Accel"); // Accel should be back
Assert.Contains(postLib.Series, s => s.Name == "Plush");
// Emulate time passage by updating lastFolderScan to be a min in the past
foreach (var s in postLib.Series)
{
s.LastFolderScanned = DateTime.Now.Subtract(TimeSpan.FromMinutes(1));
_context.Series.Update(s);
}
await _context.SaveChangesAsync();
await SetAllSeriesLastScannedInThePast(postLib);
// Fourth Scan: Run again to check stability (should not remove Accel)
await scanner.ScanLibrary(library.Id);
postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
Assert.Contains(postLib.Series, s => s.Name == "Accel");
Assert.Contains(postLib.Series, s => s.Name == "Plush");
@ -736,14 +732,14 @@ public class ScannerServiceTests : AbstractDbTest
new FolderPath() { Path = Path.Combine(testDirectoryPath, "Root 2") }
];
_unitOfWork.LibraryRepository.Update(library);
await _unitOfWork.CommitAsync();
UnitOfWork.LibraryRepository.Update(library);
await UnitOfWork.CommitAsync();
var scanner = _scannerHelper.CreateServices();
// First Scan: Everything should be added
await scanner.ScanLibrary(library.Id);
var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
Assert.NotNull(postLib);
Assert.Contains(postLib.Series, s => s.Name == "Accel");
@ -751,14 +747,14 @@ public class ScannerServiceTests : AbstractDbTest
// Second Scan: Delete the Series
library.Series = [];
await _unitOfWork.CommitAsync();
await UnitOfWork.CommitAsync();
postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
Assert.NotNull(postLib);
Assert.Empty(postLib.Series);
await scanner.ScanLibrary(library.Id);
postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
Assert.Contains(postLib.Series, s => s.Name == "Accel"); // Ensure Accel is gone
Assert.Contains(postLib.Series, s => s.Name == "Plush");
@ -772,13 +768,13 @@ public class ScannerServiceTests : AbstractDbTest
var library = await _scannerHelper.GenerateScannerData(testcase, infos);
var testDirectoryPath = library.Folders.First().Path;
_unitOfWork.LibraryRepository.Update(library);
await _unitOfWork.CommitAsync();
UnitOfWork.LibraryRepository.Update(library);
await UnitOfWork.CommitAsync();
var scanner = _scannerHelper.CreateServices();
await scanner.ScanLibrary(library.Id);
var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
Assert.NotNull(postLib);
Assert.Equal(4, postLib.Series.Count);
@ -794,7 +790,7 @@ public class ScannerServiceTests : AbstractDbTest
Assert.Equal(2, executionerAndHerWayOfLife.Volumes.Count);
Assert.Equal(2, executionerAndHerWayOfLife.Volumes.Sum(v => v.Chapters.Count));
Thread.Sleep(1100); // Ensure at least one second has passed since library scan
await SetAllSeriesLastScannedInThePast(postLib);
// Add a new chapter to a volume of the series, and scan. Validate that no chapters were lost, and the new
// chapter was added
@ -804,9 +800,9 @@ public class ScannerServiceTests : AbstractDbTest
Path.Join(executionerCopyDir, "The Executioner and Her Way of Life Vol. 1 Ch. 0002.cbz"));
await scanner.ScanLibrary(library.Id);
await _unitOfWork.CommitAsync();
await UnitOfWork.CommitAsync();
postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
Assert.NotNull(postLib);
Assert.Equal(4, postLib.Series.Count);
@ -822,4 +818,181 @@ public class ScannerServiceTests : AbstractDbTest
Assert.Equal(2, executionerAndHerWayOfLife.Volumes.Count);
Assert.Equal(3, executionerAndHerWayOfLife.Volumes.Sum(v => v.Chapters.Count)); // Incremented by 1
}
[Fact]
public async Task RemovalPickedUp_NoOtherChanges()
{
const string testcase = "Series removed when no other changes are made - Manga.json";
var infos = new Dictionary<string, ComicInfo>();
var library = await _scannerHelper.GenerateScannerData(testcase, infos);
var testDirectoryPath = library.Folders.First().Path;
UnitOfWork.LibraryRepository.Update(library);
await UnitOfWork.CommitAsync();
var scanner = _scannerHelper.CreateServices();
await scanner.ScanLibrary(library.Id);
var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
Assert.NotNull(postLib);
Assert.Equal(2, postLib.Series.Count);
var executionerCopyDir = Path.Join(testDirectoryPath, "The Executioner and Her Way of Life");
Directory.Delete(executionerCopyDir, true);
await scanner.ScanLibrary(library.Id);
await UnitOfWork.CommitAsync();
postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
Assert.NotNull(postLib);
Assert.Single(postLib.Series);
Assert.Single(postLib.Series, s => s.Name == "Spice and Wolf");
Assert.Equal(2, postLib.Series.First().Volumes.Count);
}
[Fact]
public async Task SubFoldersNoSubFolders_CorrectPickupAfterAdd()
{
// This test case is used in multiple tests and can result in conflict if not separated
const string testcase = "Subfolders and files at root (2) - Manga.json";
var infos = new Dictionary<string, ComicInfo>();
var library = await _scannerHelper.GenerateScannerData(testcase, infos);
var testDirectoryPath = library.Folders.First().Path;
UnitOfWork.LibraryRepository.Update(library);
await UnitOfWork.CommitAsync();
var scanner = _scannerHelper.CreateServices();
await scanner.ScanLibrary(library.Id);
var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
Assert.NotNull(postLib);
Assert.Single(postLib.Series);
var spiceAndWolf = postLib.Series.First(x => x.Name == "Spice and Wolf");
Assert.Equal(3, spiceAndWolf.Volumes.Count);
Assert.Equal(4, spiceAndWolf.Volumes.Sum(v => v.Chapters.Count));
await SetLastScannedInThePast(spiceAndWolf);
// Add volume to Spice and Wolf series directory
var spiceAndWolfDir = Path.Join(testDirectoryPath, "Spice and Wolf");
File.Copy(Path.Join(spiceAndWolfDir, "Spice and Wolf Vol. 1.cbz"),
Path.Join(spiceAndWolfDir, "Spice and Wolf Vol. 4.cbz"));
await scanner.ScanLibrary(library.Id);
postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
Assert.NotNull(postLib);
Assert.Single(postLib.Series);
spiceAndWolf = postLib.Series.First(x => x.Name == "Spice and Wolf");
Assert.Equal(4, spiceAndWolf.Volumes.Count);
Assert.Equal(5, spiceAndWolf.Volumes.Sum(v => v.Chapters.Count));
await SetLastScannedInThePast(spiceAndWolf);
// Add file in subfolder
spiceAndWolfDir = Path.Join(spiceAndWolfDir, "Spice and Wolf Vol. 3");
File.Copy(Path.Join(spiceAndWolfDir, "Spice and Wolf Vol. 3 Ch. 0012.cbz"),
Path.Join(spiceAndWolfDir, "Spice and Wolf Vol. 3 Ch. 0013.cbz"));
await scanner.ScanLibrary(library.Id);
postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
Assert.NotNull(postLib);
Assert.Single(postLib.Series);
spiceAndWolf = postLib.Series.First(x => x.Name == "Spice and Wolf");
Assert.Equal(4, spiceAndWolf.Volumes.Count);
Assert.Equal(6, spiceAndWolf.Volumes.Sum(v => v.Chapters.Count));
}
/// <summary>
/// Ensure when Kavita scans, the sort order of chapters is correct
/// </summary>
[Fact]
public async Task ScanLibrary_SortOrderWorks()
{
const string testcase = "Sort Order - Manga.json";
var library = await _scannerHelper.GenerateScannerData(testcase);
var scanner = _scannerHelper.CreateServices();
await scanner.ScanLibrary(library.Id);
var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
Assert.NotNull(postLib);
// Get the loose leaf volume and confirm each chapter aligns with expectation of Sort Order
var series = postLib.Series.First();
Assert.NotNull(series);
var volume = series.Volumes.FirstOrDefault();
Assert.NotNull(volume);
var sortedChapters = volume.Chapters.OrderBy(c => c.SortOrder).ToList();
Assert.True(sortedChapters[0].SortOrder.Is(1f));
Assert.True(sortedChapters[1].SortOrder.Is(4f));
Assert.True(sortedChapters[2].SortOrder.Is(5f));
}
[Fact]
public async Task ScanLibrary_MetadataDisabled_NoOverrides()
{
const string testcase = "Series with Localized No Metadata - Manga.json";
// Get the first file and generate a ComicInfo
var infos = new Dictionary<string, ComicInfo>();
infos.Add("Immoral Guild v01.cbz", new ComicInfo()
{
Series = "Immoral Guild",
LocalizedSeries = "Futoku no Guild" // Filename has a capital N and localizedSeries has lowercase
});
var library = await _scannerHelper.GenerateScannerData(testcase, infos);
// Disable metadata
library.EnableMetadata = false;
UnitOfWork.LibraryRepository.Update(library);
await UnitOfWork.CommitAsync();
var scanner = _scannerHelper.CreateServices();
await scanner.ScanLibrary(library.Id);
var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
// Validate that there are 2 series
Assert.NotNull(postLib);
Assert.Equal(2, postLib.Series.Count);
Assert.Contains(postLib.Series, x => x.Name == "Immoral Guild");
Assert.Contains(postLib.Series, x => x.Name == "Futoku No Guild");
}
[Fact]
public async Task ScanLibrary_SortName_NoPrefix()
{
const string testcase = "Series with Prefix - Book.json";
var library = await _scannerHelper.GenerateScannerData(testcase);
library.RemovePrefixForSortName = true;
UnitOfWork.LibraryRepository.Update(library);
await UnitOfWork.CommitAsync();
var scanner = _scannerHelper.CreateServices();
await scanner.ScanLibrary(library.Id);
var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
Assert.NotNull(postLib);
Assert.Equal(1, postLib.Series.Count);
Assert.Equal("The Avengers", postLib.Series.First().Name);
Assert.Equal("Avengers", postLib.Series.First().SortName);
}
}

View file

@ -1,11 +1,619 @@
using API.Services.Plus;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using API.Data.Repositories;
using API.DTOs.Scrobbling;
using API.Entities;
using API.Entities.Enums;
using API.Entities.Scrobble;
using API.Helpers.Builders;
using API.Services;
using API.Services.Plus;
using API.SignalR;
using Kavita.Common;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Xunit;
namespace API.Tests.Services;
#nullable enable
public class ScrobblingServiceTests
public class ScrobblingServiceTests : AbstractDbTest
{
private const int ChapterPages = 100;
/// <summary>
/// {
/// "Issuer": "Issuer",
/// "Issued At": "2025-06-15T21:01:57.615Z",
/// "Expiration": "2200-06-15T21:01:57.615Z"
/// }
/// </summary>
/// <remarks>Our UnitTests will fail in 2200 :(</remarks>
private const string ValidJwtToken =
"eyJhbGciOiJIUzI1NiJ9.eyJJc3N1ZXIiOiJJc3N1ZXIiLCJleHAiOjcyNzI0NTAxMTcsImlhdCI6MTc1MDAyMTMxN30.zADmcGq_BfxbcV8vy4xw5Cbzn4COkmVINxgqpuL17Ng";
private readonly ScrobblingService _service;
private readonly ILicenseService _licenseService;
private readonly ILocalizationService _localizationService;
private readonly ILogger<ScrobblingService> _logger;
private readonly IEmailService _emailService;
private readonly IKavitaPlusApiService _kavitaPlusApiService;
/// <summary>
/// IReaderService, without the ScrobblingService injected
/// </summary>
private readonly IReaderService _readerService;
/// <summary>
/// IReaderService, with the _service injected
/// </summary>
private readonly IReaderService _hookedUpReaderService;
public ScrobblingServiceTests()
{
_licenseService = Substitute.For<ILicenseService>();
_localizationService = Substitute.For<ILocalizationService>();
_logger = Substitute.For<ILogger<ScrobblingService>>();
_emailService = Substitute.For<IEmailService>();
_kavitaPlusApiService = Substitute.For<IKavitaPlusApiService>();
_service = new ScrobblingService(UnitOfWork, Substitute.For<IEventHub>(), _logger, _licenseService,
_localizationService, _emailService, _kavitaPlusApiService);
_readerService = new ReaderService(UnitOfWork,
Substitute.For<ILogger<ReaderService>>(),
Substitute.For<IEventHub>(),
Substitute.For<IImageService>(),
Substitute.For<IDirectoryService>(),
Substitute.For<IScrobblingService>()); // Do not use the actual one
_hookedUpReaderService = new ReaderService(UnitOfWork,
Substitute.For<ILogger<ReaderService>>(),
Substitute.For<IEventHub>(),
Substitute.For<IImageService>(),
Substitute.For<IDirectoryService>(),
_service);
}
protected override async Task ResetDb()
{
Context.ScrobbleEvent.RemoveRange(Context.ScrobbleEvent.ToList());
Context.Series.RemoveRange(Context.Series.ToList());
Context.Library.RemoveRange(Context.Library.ToList());
Context.AppUser.RemoveRange(Context.AppUser.ToList());
await UnitOfWork.CommitAsync();
}
private async Task SeedData()
{
var series = new SeriesBuilder("Test Series")
.WithFormat(MangaFormat.Archive)
.WithMetadata(new SeriesMetadataBuilder().Build())
.WithVolume(new VolumeBuilder("Volume 1")
.WithChapters([
new ChapterBuilder("1")
.WithPages(ChapterPages)
.Build(),
new ChapterBuilder("2")
.WithPages(ChapterPages)
.Build(),
new ChapterBuilder("3")
.WithPages(ChapterPages)
.Build()])
.Build())
.WithVolume(new VolumeBuilder("Volume 2")
.WithChapters([
new ChapterBuilder("4")
.WithPages(ChapterPages)
.Build(),
new ChapterBuilder("5")
.WithPages(ChapterPages)
.Build(),
new ChapterBuilder("6")
.WithPages(ChapterPages)
.Build()])
.Build())
.Build();
var library = new LibraryBuilder("Test Library", LibraryType.Manga)
.WithAllowScrobbling(true)
.WithSeries(series)
.Build();
Context.Library.Add(library);
var user = new AppUserBuilder("testuser", "testuser")
//.WithPreferences(new UserPreferencesBuilder().WithAniListScrobblingEnabled(true).Build())
.Build();
user.UserPreferences.AniListScrobblingEnabled = true;
UnitOfWork.UserRepository.Add(user);
await UnitOfWork.CommitAsync();
}
private async Task<ScrobbleEvent> CreateScrobbleEvent(int? seriesId = null)
{
var evt = new ScrobbleEvent
{
ScrobbleEventType = ScrobbleEventType.ChapterRead,
Format = PlusMediaFormat.Manga,
SeriesId = seriesId ?? 0,
LibraryId = 0,
AppUserId = 0,
};
if (seriesId != null)
{
var series = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId.Value);
if (series != null) evt.Series = series;
}
return evt;
}
#region K+ API Request Tests
[Fact]
public async Task PostScrobbleUpdate_AuthErrors()
{
_kavitaPlusApiService.PostScrobbleUpdate(null!, "")
.ReturnsForAnyArgs(new ScrobbleResponseDto()
{
ErrorMessage = "Unauthorized"
});
var evt = await CreateScrobbleEvent();
await Assert.ThrowsAsync<KavitaException>(async () =>
{
await _service.PostScrobbleUpdate(new ScrobbleDto(), "", evt);
});
Assert.True(evt.IsErrored);
Assert.Equal("Kavita+ subscription no longer active", evt.ErrorDetails);
}
[Fact]
public async Task PostScrobbleUpdate_UnknownSeriesLoggedAsError()
{
_kavitaPlusApiService.PostScrobbleUpdate(null!, "")
.ReturnsForAnyArgs(new ScrobbleResponseDto()
{
ErrorMessage = "Unknown Series"
});
await SeedData();
var evt = await CreateScrobbleEvent(1);
await _service.PostScrobbleUpdate(new ScrobbleDto(), "", evt);
await UnitOfWork.CommitAsync();
Assert.True(evt.IsErrored);
var series = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1);
Assert.NotNull(series);
Assert.True(series.IsBlacklisted);
var errors = await UnitOfWork.ScrobbleRepository.GetAllScrobbleErrorsForSeries(1);
Assert.Single(errors);
Assert.Equal("Series cannot be matched for Scrobbling", errors.First().Comment);
Assert.Equal(series.Id, errors.First().SeriesId);
}
[Fact]
public async Task PostScrobbleUpdate_InvalidAccessToken()
{
_kavitaPlusApiService.PostScrobbleUpdate(null!, "")
.ReturnsForAnyArgs(new ScrobbleResponseDto()
{
ErrorMessage = "Access token is invalid"
});
var evt = await CreateScrobbleEvent();
await Assert.ThrowsAsync<KavitaException>(async () =>
{
await _service.PostScrobbleUpdate(new ScrobbleDto(), "", evt);
});
Assert.True(evt.IsErrored);
Assert.Equal("Access Token needs to be rotated to continue scrobbling", evt.ErrorDetails);
}
#endregion
#region K+ API Request data tests
[Fact]
public async Task ProcessReadEvents_CreatesNoEventsWhenNoProgress()
{
await ResetDb();
await SeedData();
// Set Returns
_licenseService.HasActiveLicense().Returns(Task.FromResult(true));
_kavitaPlusApiService.GetRateLimit(Arg.Any<string>(), Arg.Any<string>())
.Returns(100);
var user = await UnitOfWork.UserRepository.GetUserByIdAsync(1);
Assert.NotNull(user);
// Ensure CanProcessScrobbleEvent returns true
user.AniListAccessToken = ValidJwtToken;
UnitOfWork.UserRepository.Update(user);
await UnitOfWork.CommitAsync();
var chapter = await UnitOfWork.ChapterRepository.GetChapterAsync(4);
Assert.NotNull(chapter);
var volume = await UnitOfWork.VolumeRepository.GetVolumeAsync(1, VolumeIncludes.Chapters);
Assert.NotNull(volume);
// Call Scrobble without having any progress
await _service.ScrobbleReadingUpdate(1, 1);
var events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(1);
Assert.Empty(events);
}
[Fact]
public async Task ProcessReadEvents_UpdateVolumeAndChapterData()
{
await ResetDb();
await SeedData();
// Set Returns
_licenseService.HasActiveLicense().Returns(Task.FromResult(true));
_kavitaPlusApiService.GetRateLimit(Arg.Any<string>(), Arg.Any<string>())
.Returns(100);
var user = await UnitOfWork.UserRepository.GetUserByIdAsync(1);
Assert.NotNull(user);
// Ensure CanProcessScrobbleEvent returns true
user.AniListAccessToken = ValidJwtToken;
UnitOfWork.UserRepository.Update(user);
await UnitOfWork.CommitAsync();
var chapter = await UnitOfWork.ChapterRepository.GetChapterAsync(4);
Assert.NotNull(chapter);
var volume = await UnitOfWork.VolumeRepository.GetVolumeAsync(1, VolumeIncludes.Chapters);
Assert.NotNull(volume);
// Mark something as read to trigger event creation
await _readerService.MarkChaptersAsRead(user, 1, new List<Chapter>() {volume.Chapters[0]});
await UnitOfWork.CommitAsync();
// Call Scrobble while having some progress
await _service.ScrobbleReadingUpdate(user.Id, 1);
var events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(1);
Assert.Single(events);
// Give it some (more) read progress
await _readerService.MarkChaptersAsRead(user, 1, volume.Chapters);
await _readerService.MarkChaptersAsRead(user, 1, [chapter]);
await UnitOfWork.CommitAsync();
await _service.ProcessUpdatesSinceLastSync();
await _kavitaPlusApiService.Received(1).PostScrobbleUpdate(
Arg.Is<ScrobbleDto>(data =>
data.ChapterNumber == (int)chapter.MaxNumber &&
data.VolumeNumber == (int)volume.MaxNumber
),
Arg.Any<string>());
}
#endregion
#region Scrobble Reading Update Tests
[Fact]
public async Task ScrobbleReadingUpdate_IgnoreNoLicense()
{
await ResetDb();
await SeedData();
_licenseService.HasActiveLicense().Returns(false);
await _service.ScrobbleReadingUpdate(1, 1);
var events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(1);
Assert.Empty(events);
}
[Fact]
public async Task ScrobbleReadingUpdate_RemoveWhenNoProgress()
{
await ResetDb();
await SeedData();
_licenseService.HasActiveLicense().Returns(true);
var user = await UnitOfWork.UserRepository.GetUserByIdAsync(1);
Assert.NotNull(user);
var volume = await UnitOfWork.VolumeRepository.GetVolumeAsync(1, VolumeIncludes.Chapters);
Assert.NotNull(volume);
await _readerService.MarkChaptersAsRead(user, 1, new List<Chapter>() {volume.Chapters[0]});
await UnitOfWork.CommitAsync();
await _service.ScrobbleReadingUpdate(1, 1);
var events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(1);
Assert.Single(events);
var readEvent = events.First();
Assert.False(readEvent.IsProcessed);
await _hookedUpReaderService.MarkSeriesAsUnread(user, 1);
await UnitOfWork.CommitAsync();
// Existing event is deleted
await _service.ScrobbleReadingUpdate(1, 1);
events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(1);
Assert.Empty(events);
await _hookedUpReaderService.MarkSeriesAsUnread(user, 1);
await UnitOfWork.CommitAsync();
// No new events are added
events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(1);
Assert.Empty(events);
}
[Fact]
public async Task ScrobbleReadingUpdate_UpdateExistingNotIsProcessed()
{
await ResetDb();
await SeedData();
var user = await UnitOfWork.UserRepository.GetUserByIdAsync(1);
Assert.NotNull(user);
var chapter1 = await UnitOfWork.ChapterRepository.GetChapterAsync(1);
var chapter2 = await UnitOfWork.ChapterRepository.GetChapterAsync(2);
var chapter3 = await UnitOfWork.ChapterRepository.GetChapterAsync(3);
Assert.NotNull(chapter1);
Assert.NotNull(chapter2);
Assert.NotNull(chapter3);
_licenseService.HasActiveLicense().Returns(true);
var events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(1);
Assert.Empty(events);
await _readerService.MarkChaptersAsRead(user, 1, [chapter1]);
await UnitOfWork.CommitAsync();
// Scrobble update
await _service.ScrobbleReadingUpdate(1, 1);
events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(1);
Assert.Single(events);
var readEvent = events[0];
Assert.False(readEvent.IsProcessed);
Assert.Equal(1, readEvent.ChapterNumber);
// Mark as processed
readEvent.IsProcessed = true;
await UnitOfWork.CommitAsync();
await _readerService.MarkChaptersAsRead(user, 1, [chapter2]);
await UnitOfWork.CommitAsync();
// Scrobble update
await _service.ScrobbleReadingUpdate(1, 1);
events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(1);
Assert.Equal(2, events.Count);
Assert.Single(events.Where(e => e.IsProcessed).ToList());
Assert.Single(events.Where(e => !e.IsProcessed).ToList());
// Should update the existing non processed event
await _readerService.MarkChaptersAsRead(user, 1, [chapter3]);
await UnitOfWork.CommitAsync();
// Scrobble update
await _service.ScrobbleReadingUpdate(1, 1);
events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(1);
Assert.Equal(2, events.Count);
Assert.Single(events.Where(e => e.IsProcessed).ToList());
Assert.Single(events.Where(e => !e.IsProcessed).ToList());
}
#endregion
#region ScrobbleWantToReadUpdate Tests
[Fact]
public async Task ScrobbleWantToReadUpdate_NoExistingEvents_WantToRead_ShouldCreateNewEvent()
{
// Arrange
await SeedData();
_licenseService.HasActiveLicense().Returns(Task.FromResult(true));
const int userId = 1;
const int seriesId = 1;
// Act
await _service.ScrobbleWantToReadUpdate(userId, seriesId, true);
// Assert
var events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(seriesId);
Assert.Single(events);
Assert.Equal(ScrobbleEventType.AddWantToRead, events[0].ScrobbleEventType);
Assert.Equal(userId, events[0].AppUserId);
}
[Fact]
public async Task ScrobbleWantToReadUpdate_NoExistingEvents_RemoveWantToRead_ShouldCreateNewEvent()
{
// Arrange
await SeedData();
_licenseService.HasActiveLicense().Returns(Task.FromResult(true));
const int userId = 1;
const int seriesId = 1;
// Act
await _service.ScrobbleWantToReadUpdate(userId, seriesId, false);
// Assert
var events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(seriesId);
Assert.Single(events);
Assert.Equal(ScrobbleEventType.RemoveWantToRead, events[0].ScrobbleEventType);
Assert.Equal(userId, events[0].AppUserId);
}
[Fact]
public async Task ScrobbleWantToReadUpdate_ExistingWantToReadEvent_WantToRead_ShouldNotCreateNewEvent()
{
// Arrange
await SeedData();
_licenseService.HasActiveLicense().Returns(Task.FromResult(true));
const int userId = 1;
const int seriesId = 1;
// First, let's create an event through the service
await _service.ScrobbleWantToReadUpdate(userId, seriesId, true);
// Act - Try to create the same event again
await _service.ScrobbleWantToReadUpdate(userId, seriesId, true);
// Assert
var events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(seriesId);
Assert.Single(events);
Assert.All(events, e => Assert.Equal(ScrobbleEventType.AddWantToRead, e.ScrobbleEventType));
}
[Fact]
public async Task ScrobbleWantToReadUpdate_ExistingWantToReadEvent_RemoveWantToRead_ShouldAddRemoveEvent()
{
// Arrange
await SeedData();
_licenseService.HasActiveLicense().Returns(Task.FromResult(true));
const int userId = 1;
const int seriesId = 1;
// First, let's create a want-to-read event through the service
await _service.ScrobbleWantToReadUpdate(userId, seriesId, true);
// Act - Now remove from want-to-read
await _service.ScrobbleWantToReadUpdate(userId, seriesId, false);
// Assert
var events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(seriesId);
Assert.Single(events);
Assert.Contains(events, e => e.ScrobbleEventType == ScrobbleEventType.RemoveWantToRead);
}
[Fact]
public async Task ScrobbleWantToReadUpdate_ExistingRemoveWantToReadEvent_RemoveWantToRead_ShouldNotCreateNewEvent()
{
// Arrange
await SeedData();
_licenseService.HasActiveLicense().Returns(Task.FromResult(true));
const int userId = 1;
const int seriesId = 1;
// First, let's create a remove-from-want-to-read event through the service
await _service.ScrobbleWantToReadUpdate(userId, seriesId, false);
// Act - Try to create the same event again
await _service.ScrobbleWantToReadUpdate(userId, seriesId, false);
// Assert
var events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(seriesId);
Assert.Single(events);
Assert.All(events, e => Assert.Equal(ScrobbleEventType.RemoveWantToRead, e.ScrobbleEventType));
}
[Fact]
public async Task ScrobbleWantToReadUpdate_ExistingRemoveWantToReadEvent_WantToRead_ShouldAddWantToReadEvent()
{
// Arrange
await SeedData();
_licenseService.HasActiveLicense().Returns(Task.FromResult(true));
const int userId = 1;
const int seriesId = 1;
// First, let's create a remove-from-want-to-read event through the service
await _service.ScrobbleWantToReadUpdate(userId, seriesId, false);
// Act - Now add to want-to-read
await _service.ScrobbleWantToReadUpdate(userId, seriesId, true);
// Assert
var events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(seriesId);
Assert.Single(events);
Assert.Contains(events, e => e.ScrobbleEventType == ScrobbleEventType.AddWantToRead);
}
#endregion
#region Scrobble Rating Update Test
[Fact]
public async Task ScrobbleRatingUpdate_IgnoreNoLicense()
{
await ResetDb();
await SeedData();
_licenseService.HasActiveLicense().Returns(false);
await _service.ScrobbleRatingUpdate(1, 1, 1);
var events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(1);
Assert.Empty(events);
}
[Fact]
public async Task ScrobbleRatingUpdate_UpdateExistingNotIsProcessed()
{
await ResetDb();
await SeedData();
_licenseService.HasActiveLicense().Returns(true);
var user = await UnitOfWork.UserRepository.GetUserByIdAsync(1);
Assert.NotNull(user);
var series = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1);
Assert.NotNull(series);
await _service.ScrobbleRatingUpdate(user.Id, series.Id, 1);
var events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(1);
Assert.Single(events);
Assert.Equal(1, events.First().Rating);
// Mark as processed
events.First().IsProcessed = true;
await UnitOfWork.CommitAsync();
await _service.ScrobbleRatingUpdate(user.Id, series.Id, 5);
events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(1);
Assert.Equal(2, events.Count);
Assert.Single(events, evt => evt.IsProcessed);
Assert.Single(events, evt => !evt.IsProcessed);
await _service.ScrobbleRatingUpdate(user.Id, series.Id, 5);
events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(1);
Assert.Single(events, evt => !evt.IsProcessed);
Assert.Equal(5, events.First(evt => !evt.IsProcessed).Rating);
}
#endregion
[Theory]
[InlineData("https://anilist.co/manga/35851/Byeontaega-Doeja/", 35851)]
[InlineData("https://anilist.co/manga/30105", 30105)]

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,292 @@
using System.Collections.Generic;
using System.IO.Abstractions;
using System.Threading.Tasks;
using API.Data;
using API.Data.Repositories;
using API.DTOs.KavitaPlus.Metadata;
using API.Entities;
using API.Entities.Enums;
using API.Entities.MetadataMatching;
using API.Services;
using API.Services.Tasks.Scanner;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Xunit;
namespace API.Tests.Services;
public class SettingsServiceTests
{
private readonly ISettingsService _settingsService;
private readonly IUnitOfWork _mockUnitOfWork;
public SettingsServiceTests()
{
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), new FileSystem());
_mockUnitOfWork = Substitute.For<IUnitOfWork>();
_settingsService = new SettingsService(_mockUnitOfWork, ds,
Substitute.For<ILibraryWatcher>(), Substitute.For<ITaskScheduler>(),
Substitute.For<ILogger<SettingsService>>());
}
#region UpdateMetadataSettings
[Fact]
public async Task UpdateMetadataSettings_ShouldUpdateExistingSettings()
{
// Arrange
var existingSettings = new MetadataSettings
{
Id = 1,
Enabled = false,
EnableSummary = false,
EnableLocalizedName = false,
EnablePublicationStatus = false,
EnableRelationships = false,
EnablePeople = false,
EnableStartDate = false,
EnableGenres = false,
EnableTags = false,
FirstLastPeopleNaming = false,
EnableCoverImage = false,
AgeRatingMappings = new Dictionary<string, AgeRating>(),
Blacklist = [],
Whitelist = [],
Overrides = [],
PersonRoles = [],
FieldMappings = []
};
var settingsRepo = Substitute.For<ISettingsRepository>();
settingsRepo.GetMetadataSettings().Returns(Task.FromResult(existingSettings));
settingsRepo.GetMetadataSettingDto().Returns(Task.FromResult(new MetadataSettingsDto()));
_mockUnitOfWork.SettingsRepository.Returns(settingsRepo);
var updateDto = new MetadataSettingsDto
{
Enabled = true,
EnableSummary = true,
EnableLocalizedName = true,
EnablePublicationStatus = true,
EnableRelationships = true,
EnablePeople = true,
EnableStartDate = true,
EnableGenres = true,
EnableTags = true,
FirstLastPeopleNaming = true,
EnableCoverImage = true,
AgeRatingMappings = new Dictionary<string, AgeRating> { { "Adult", AgeRating.R18Plus } },
Blacklist = ["blacklisted-tag"],
Whitelist = ["whitelisted-tag"],
Overrides = [MetadataSettingField.Summary],
PersonRoles = [PersonRole.Writer],
FieldMappings =
[
new MetadataFieldMappingDto
{
SourceType = MetadataFieldType.Genre,
DestinationType = MetadataFieldType.Tag,
SourceValue = "Action",
DestinationValue = "Fight",
ExcludeFromSource = true
}
]
};
// Act
await _settingsService.UpdateMetadataSettings(updateDto);
// Assert
await _mockUnitOfWork.Received(1).CommitAsync();
// Verify properties were updated
Assert.True(existingSettings.Enabled);
Assert.True(existingSettings.EnableSummary);
Assert.True(existingSettings.EnableLocalizedName);
Assert.True(existingSettings.EnablePublicationStatus);
Assert.True(existingSettings.EnableRelationships);
Assert.True(existingSettings.EnablePeople);
Assert.True(existingSettings.EnableStartDate);
Assert.True(existingSettings.EnableGenres);
Assert.True(existingSettings.EnableTags);
Assert.True(existingSettings.FirstLastPeopleNaming);
Assert.True(existingSettings.EnableCoverImage);
// Verify collections were updated
Assert.Single(existingSettings.AgeRatingMappings);
Assert.Equal(AgeRating.R18Plus, existingSettings.AgeRatingMappings["Adult"]);
Assert.Single(existingSettings.Blacklist);
Assert.Equal("blacklisted-tag", existingSettings.Blacklist[0]);
Assert.Single(existingSettings.Whitelist);
Assert.Equal("whitelisted-tag", existingSettings.Whitelist[0]);
Assert.Single(existingSettings.Overrides);
Assert.Equal(MetadataSettingField.Summary, existingSettings.Overrides[0]);
Assert.Single(existingSettings.PersonRoles);
Assert.Equal(PersonRole.Writer, existingSettings.PersonRoles[0]);
Assert.Single(existingSettings.FieldMappings);
Assert.Equal(MetadataFieldType.Genre, existingSettings.FieldMappings[0].SourceType);
Assert.Equal(MetadataFieldType.Tag, existingSettings.FieldMappings[0].DestinationType);
Assert.Equal("Action", existingSettings.FieldMappings[0].SourceValue);
Assert.Equal("Fight", existingSettings.FieldMappings[0].DestinationValue);
Assert.True(existingSettings.FieldMappings[0].ExcludeFromSource);
}
[Fact]
public async Task UpdateMetadataSettings_WithNullCollections_ShouldUseEmptyCollections()
{
// Arrange
var existingSettings = new MetadataSettings
{
Id = 1,
FieldMappings = [new MetadataFieldMapping {Id = 1, SourceValue = "OldValue"}]
};
var settingsRepo = Substitute.For<ISettingsRepository>();
settingsRepo.GetMetadataSettings().Returns(Task.FromResult(existingSettings));
settingsRepo.GetMetadataSettingDto().Returns(Task.FromResult(new MetadataSettingsDto()));
_mockUnitOfWork.SettingsRepository.Returns(settingsRepo);
var updateDto = new MetadataSettingsDto
{
AgeRatingMappings = null,
Blacklist = null,
Whitelist = null,
Overrides = null,
PersonRoles = null,
FieldMappings = null
};
// Act
await _settingsService.UpdateMetadataSettings(updateDto);
// Assert
await _mockUnitOfWork.Received(1).CommitAsync();
Assert.Empty(existingSettings.AgeRatingMappings);
Assert.Empty(existingSettings.Blacklist);
Assert.Empty(existingSettings.Whitelist);
Assert.Empty(existingSettings.Overrides);
Assert.Empty(existingSettings.PersonRoles);
// Verify existing field mappings were cleared
settingsRepo.Received(1).RemoveRange(Arg.Any<List<MetadataFieldMapping>>());
Assert.Empty(existingSettings.FieldMappings);
}
[Fact]
public async Task UpdateMetadataSettings_WithFieldMappings_ShouldReplaceExistingMappings()
{
// Arrange
var existingSettings = new MetadataSettings
{
Id = 1,
FieldMappings =
[
new MetadataFieldMapping
{
Id = 1,
SourceType = MetadataFieldType.Genre,
DestinationType = MetadataFieldType.Genre,
SourceValue = "OldValue",
DestinationValue = "OldDestination",
ExcludeFromSource = false
}
]
};
var settingsRepo = Substitute.For<ISettingsRepository>();
settingsRepo.GetMetadataSettings().Returns(Task.FromResult(existingSettings));
settingsRepo.GetMetadataSettingDto().Returns(Task.FromResult(new MetadataSettingsDto()));
_mockUnitOfWork.SettingsRepository.Returns(settingsRepo);
var updateDto = new MetadataSettingsDto
{
FieldMappings =
[
new MetadataFieldMappingDto
{
SourceType = MetadataFieldType.Tag,
DestinationType = MetadataFieldType.Genre,
SourceValue = "NewValue",
DestinationValue = "NewDestination",
ExcludeFromSource = true
},
new MetadataFieldMappingDto
{
SourceType = MetadataFieldType.Tag,
DestinationType = MetadataFieldType.Tag,
SourceValue = "AnotherValue",
DestinationValue = "AnotherDestination",
ExcludeFromSource = false
}
]
};
// Act
await _settingsService.UpdateMetadataSettings(updateDto);
// Assert
await _mockUnitOfWork.Received(1).CommitAsync();
// Verify existing field mappings were cleared and new ones added
settingsRepo.Received(1).RemoveRange(Arg.Any<List<MetadataFieldMapping>>());
Assert.Equal(2, existingSettings.FieldMappings.Count);
// Verify first mapping
Assert.Equal(MetadataFieldType.Tag, existingSettings.FieldMappings[0].SourceType);
Assert.Equal(MetadataFieldType.Genre, existingSettings.FieldMappings[0].DestinationType);
Assert.Equal("NewValue", existingSettings.FieldMappings[0].SourceValue);
Assert.Equal("NewDestination", existingSettings.FieldMappings[0].DestinationValue);
Assert.True(existingSettings.FieldMappings[0].ExcludeFromSource);
// Verify second mapping
Assert.Equal(MetadataFieldType.Tag, existingSettings.FieldMappings[1].SourceType);
Assert.Equal(MetadataFieldType.Tag, existingSettings.FieldMappings[1].DestinationType);
Assert.Equal("AnotherValue", existingSettings.FieldMappings[1].SourceValue);
Assert.Equal("AnotherDestination", existingSettings.FieldMappings[1].DestinationValue);
Assert.False(existingSettings.FieldMappings[1].ExcludeFromSource);
}
[Fact]
public async Task UpdateMetadataSettings_WithBlacklistWhitelist_ShouldNormalizeAndDeduplicateEntries()
{
// Arrange
var existingSettings = new MetadataSettings
{
Id = 1,
Blacklist = [],
Whitelist = []
};
// We need to mock the repository and provide a custom implementation for ToNormalized
var settingsRepo = Substitute.For<ISettingsRepository>();
settingsRepo.GetMetadataSettings().Returns(Task.FromResult(existingSettings));
settingsRepo.GetMetadataSettingDto().Returns(Task.FromResult(new MetadataSettingsDto()));
_mockUnitOfWork.SettingsRepository.Returns(settingsRepo);
var updateDto = new MetadataSettingsDto
{
// Include duplicates with different casing and whitespace
Blacklist = ["tag1", "Tag1", " tag2 ", "", " ", "tag3"],
Whitelist = ["allowed1", "Allowed1", " allowed2 ", "", "allowed3"]
};
// Act
await _settingsService.UpdateMetadataSettings(updateDto);
// Assert
await _mockUnitOfWork.Received(1).CommitAsync();
Assert.Equal(3, existingSettings.Blacklist.Count);
Assert.Equal(3, existingSettings.Whitelist.Count);
}
#endregion
}

View file

@ -31,24 +31,24 @@ public abstract class SiteThemeServiceTest : AbstractDbTest
protected override async Task ResetDb()
{
_context.SiteTheme.RemoveRange(_context.SiteTheme);
await _context.SaveChangesAsync();
Context.SiteTheme.RemoveRange(Context.SiteTheme);
await Context.SaveChangesAsync();
// Recreate defaults
await Seed.SeedThemes(_context);
await Seed.SeedThemes(Context);
}
[Fact]
public async Task UpdateDefault_ShouldThrowOnInvalidId()
{
await ResetDb();
_testOutputHelper.WriteLine($"[UpdateDefault_ShouldThrowOnInvalidId] All Themes: {(await _unitOfWork.SiteThemeRepository.GetThemes()).Count(t => t.IsDefault)}");
_testOutputHelper.WriteLine($"[UpdateDefault_ShouldThrowOnInvalidId] All Themes: {(await UnitOfWork.SiteThemeRepository.GetThemes()).Count(t => t.IsDefault)}");
var filesystem = CreateFileSystem();
filesystem.AddFile($"{SiteThemeDirectory}custom.css", new MockFileData("123"));
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), filesystem);
var siteThemeService = new ThemeService(ds, _unitOfWork, _messageHub, Substitute.For<IFileService>(),
var siteThemeService = new ThemeService(ds, UnitOfWork, _messageHub, Substitute.For<IFileService>(),
Substitute.For<ILogger<ThemeService>>(), Substitute.For<IMemoryCache>());
_context.SiteTheme.Add(new SiteTheme()
Context.SiteTheme.Add(new SiteTheme()
{
Name = "Custom",
NormalizedName = "Custom".ToNormalized(),
@ -56,7 +56,7 @@ public abstract class SiteThemeServiceTest : AbstractDbTest
FileName = "custom.css",
IsDefault = false
});
await _context.SaveChangesAsync();
await Context.SaveChangesAsync();
var ex = await Assert.ThrowsAsync<KavitaException>(() => siteThemeService.UpdateDefault(10));
Assert.Equal("Theme file missing or invalid", ex.Message);
@ -68,14 +68,14 @@ public abstract class SiteThemeServiceTest : AbstractDbTest
public async Task GetContent_ShouldReturnContent()
{
await ResetDb();
_testOutputHelper.WriteLine($"[GetContent_ShouldReturnContent] All Themes: {(await _unitOfWork.SiteThemeRepository.GetThemes()).Count(t => t.IsDefault)}");
_testOutputHelper.WriteLine($"[GetContent_ShouldReturnContent] All Themes: {(await UnitOfWork.SiteThemeRepository.GetThemes()).Count(t => t.IsDefault)}");
var filesystem = CreateFileSystem();
filesystem.AddFile($"{SiteThemeDirectory}custom.css", new MockFileData("123"));
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), filesystem);
var siteThemeService = new ThemeService(ds, _unitOfWork, _messageHub, Substitute.For<IFileService>(),
var siteThemeService = new ThemeService(ds, UnitOfWork, _messageHub, Substitute.For<IFileService>(),
Substitute.For<ILogger<ThemeService>>(), Substitute.For<IMemoryCache>());
_context.SiteTheme.Add(new SiteTheme()
Context.SiteTheme.Add(new SiteTheme()
{
Name = "Custom",
NormalizedName = "Custom".ToNormalized(),
@ -83,9 +83,9 @@ public abstract class SiteThemeServiceTest : AbstractDbTest
FileName = "custom.css",
IsDefault = false
});
await _context.SaveChangesAsync();
await Context.SaveChangesAsync();
var content = await siteThemeService.GetContent((await _unitOfWork.SiteThemeRepository.GetThemeDtoByName("Custom")).Id);
var content = await siteThemeService.GetContent((await UnitOfWork.SiteThemeRepository.GetThemeDtoByName("Custom")).Id);
Assert.NotNull(content);
Assert.NotEmpty(content);
Assert.Equal("123", content);
@ -95,14 +95,14 @@ public abstract class SiteThemeServiceTest : AbstractDbTest
public async Task UpdateDefault_ShouldHaveOneDefault()
{
await ResetDb();
_testOutputHelper.WriteLine($"[UpdateDefault_ShouldHaveOneDefault] All Themes: {(await _unitOfWork.SiteThemeRepository.GetThemes()).Count(t => t.IsDefault)}");
_testOutputHelper.WriteLine($"[UpdateDefault_ShouldHaveOneDefault] All Themes: {(await UnitOfWork.SiteThemeRepository.GetThemes()).Count(t => t.IsDefault)}");
var filesystem = CreateFileSystem();
filesystem.AddFile($"{SiteThemeDirectory}custom.css", new MockFileData("123"));
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), filesystem);
var siteThemeService = new ThemeService(ds, _unitOfWork, _messageHub, Substitute.For<IFileService>(),
var siteThemeService = new ThemeService(ds, UnitOfWork, _messageHub, Substitute.For<IFileService>(),
Substitute.For<ILogger<ThemeService>>(), Substitute.For<IMemoryCache>());
_context.SiteTheme.Add(new SiteTheme()
Context.SiteTheme.Add(new SiteTheme()
{
Name = "Custom",
NormalizedName = "Custom".ToNormalized(),
@ -110,16 +110,16 @@ public abstract class SiteThemeServiceTest : AbstractDbTest
FileName = "custom.css",
IsDefault = false
});
await _context.SaveChangesAsync();
await Context.SaveChangesAsync();
var customTheme = (await _unitOfWork.SiteThemeRepository.GetThemeDtoByName("Custom"));
var customTheme = (await UnitOfWork.SiteThemeRepository.GetThemeDtoByName("Custom"));
Assert.NotNull(customTheme);
await siteThemeService.UpdateDefault(customTheme.Id);
Assert.Equal(customTheme.Id, (await _unitOfWork.SiteThemeRepository.GetDefaultTheme()).Id);
Assert.Equal(customTheme.Id, (await UnitOfWork.SiteThemeRepository.GetDefaultTheme()).Id);
}
}

View file

@ -1,7 +1,5 @@
using API.Extensions;
using API.Helpers.Builders;
using API.Helpers.Builders;
using API.Services.Plus;
using API.Services.Tasks;
namespace API.Tests.Services;
using System.Collections.Generic;
@ -16,7 +14,6 @@ using API.Entities.Enums;
using API.Helpers;
using API.Services;
using SignalR;
using Helpers;
using AutoMapper;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 678 B

View file

@ -1,5 +1,5 @@
[
"Uzaki-chan Wants to Hang Out!\\Uzaki-chan Wants to Hang Out! - 2022 New Years Special SP01.cbz",
"Uzaki-chan Wants to Hang Out!\\Uzaki-chan Wants to Hang Out! - Ch. 103 - Kouhai and Control.cbz",
"Uzaki-chan Wants to Hang Out!\\Uzaki-chan Wants to Hang Out! v01 (2019) (Digital) (danke-Empire).cbz"
"Uzaki-chan Wants to Hang Out!/Uzaki-chan Wants to Hang Out! - 2022 New Years Special SP01.cbz",
"Uzaki-chan Wants to Hang Out!/Uzaki-chan Wants to Hang Out! - Ch. 103 - Kouhai and Control.cbz",
"Uzaki-chan Wants to Hang Out!/Uzaki-chan Wants to Hang Out! v01 (2019) (Digital) (danke-Empire).cbz"
]

View file

@ -0,0 +1,6 @@
[
"Spice and Wolf/Spice and Wolf Vol. 1.cbz",
"Spice and Wolf/Spice and Wolf Vol. 2.cbz",
"The Executioner and Her Way of Life/The Executioner and Her Way of Life Vol. 1.cbz",
"The Executioner and Her Way of Life/The Executioner and Her Way of Life Vol. 2.cbz"
]

View file

@ -0,0 +1,5 @@
[
"Immoral Guild/Immoral Guild v01.cbz",
"Immoral Guild/Immoral Guild v02.cbz",
"Immoral Guild/Futoku No Guild - Vol. 12 Ch. 67 - Take Responsibility.cbz"
]

View file

@ -0,0 +1,3 @@
[
"The Avengers/The Avengers vol 1.pdf"
]

View file

@ -0,0 +1,5 @@
[
"Uzaki-chan Wants to Hang Out!/Uzaki-chan Wants to Hang Out! Ch 1-3.cbz",
"Uzaki-chan Wants to Hang Out!/Uzaki-chan Wants to Hang Out! Ch 4.cbz",
"Uzaki-chan Wants to Hang Out!/Uzaki-chan Wants to Hang Out! Ch 5.cbz"
]

View file

@ -0,0 +1,6 @@
[
"Spice and Wolf/Spice and Wolf Vol. 1.cbz",
"Spice and Wolf/Spice and Wolf Vol. 2.cbz",
"Spice and Wolf/Spice and Wolf Vol. 3/Spice and Wolf Vol. 3 Ch. 0011.cbz",
"Spice and Wolf/Spice and Wolf Vol. 3/Spice and Wolf Vol. 3 Ch. 0012.cbz"
]

View file

@ -0,0 +1,6 @@
[
"Spice and Wolf/Spice and Wolf Vol. 1.cbz",
"Spice and Wolf/Spice and Wolf Vol. 2.cbz",
"Spice and Wolf/Spice and Wolf Vol. 3/Spice and Wolf Vol. 3 Ch. 0011.cbz",
"Spice and Wolf/Spice and Wolf Vol. 3/Spice and Wolf Vol. 3 Ch. 0012.cbz"
]

View file

@ -1,16 +1,11 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Reflection;
using System.Threading.Tasks;
using API.DTOs.Update;
using API.Extensions;
using API.Services;
using API.Services.Tasks;
using API.SignalR;
using Flurl.Http;
using Flurl.Http.Testing;
using Kavita.Common.EnvironmentInfo;
using Microsoft.Extensions.Logging;
@ -21,19 +16,15 @@ namespace API.Tests.Services;
public class VersionUpdaterServiceTests : IDisposable
{
private readonly ILogger<VersionUpdaterService> _logger;
private readonly IEventHub _eventHub;
private readonly IDirectoryService _directoryService;
private readonly ILogger<VersionUpdaterService> _logger = Substitute.For<ILogger<VersionUpdaterService>>();
private readonly IEventHub _eventHub = Substitute.For<IEventHub>();
private readonly IDirectoryService _directoryService = Substitute.For<IDirectoryService>();
private readonly VersionUpdaterService _service;
private readonly string _tempPath;
private readonly HttpTest _httpTest;
public VersionUpdaterServiceTests()
{
_logger = Substitute.For<ILogger<VersionUpdaterService>>();
_eventHub = Substitute.For<IEventHub>();
_directoryService = Substitute.For<IDirectoryService>();
// Create temp directory for cache
_tempPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
Directory.CreateDirectory(_tempPath);
@ -60,18 +51,19 @@ public class VersionUpdaterServiceTests : IDisposable
// Reset BuildInfo.Version
typeof(BuildInfo).GetProperty(nameof(BuildInfo.Version))?.SetValue(null, null);
GC.SuppressFinalize(this);
}
[Fact]
public async Task CheckForUpdate_ShouldReturnNull_WhenGithubApiReturnsNull()
{
// Arrange
_httpTest.RespondWith("null");
// Act
var result = await _service.CheckForUpdate();
// Assert
Assert.Null(result);
}
@ -79,7 +71,7 @@ public class VersionUpdaterServiceTests : IDisposable
//[Fact]
public async Task CheckForUpdate_ShouldReturnUpdateNotification_WhenNewVersionIsAvailable()
{
// Arrange
var githubResponse = new
{
tag_name = "v0.6.0",
@ -91,10 +83,10 @@ public class VersionUpdaterServiceTests : IDisposable
_httpTest.RespondWithJson(githubResponse);
// Act
var result = await _service.CheckForUpdate();
// Assert
Assert.NotNull(result);
Assert.Equal("0.6.0", result.UpdateVersion);
Assert.Equal("0.5.0.0", result.CurrentVersion);
@ -121,10 +113,10 @@ public class VersionUpdaterServiceTests : IDisposable
_httpTest.RespondWithJson(githubResponse);
// Act
var result = await _service.CheckForUpdate();
// Assert
Assert.NotNull(result);
Assert.True(result.IsReleaseEqual);
Assert.False(result.IsReleaseNewer);
@ -134,7 +126,7 @@ public class VersionUpdaterServiceTests : IDisposable
//[Fact]
public async Task PushUpdate_ShouldSendUpdateEvent_WhenNewerVersionAvailable()
{
// Arrange
var update = new UpdateNotificationDto
{
UpdateVersion = "0.6.0",
@ -145,10 +137,10 @@ public class VersionUpdaterServiceTests : IDisposable
PublishDate = null
};
// Act
await _service.PushUpdate(update);
// Assert
await _eventHub.Received(1).SendMessageAsync(
Arg.Is(MessageFactory.UpdateAvailable),
Arg.Any<SignalRMessage>(),
@ -159,7 +151,7 @@ public class VersionUpdaterServiceTests : IDisposable
[Fact]
public async Task PushUpdate_ShouldNotSendUpdateEvent_WhenVersionIsEqual()
{
// Arrange
var update = new UpdateNotificationDto
{
UpdateVersion = "0.5.0.0",
@ -170,10 +162,10 @@ public class VersionUpdaterServiceTests : IDisposable
PublishDate = null
};
// Act
await _service.PushUpdate(update);
// Assert
await _eventHub.DidNotReceive().SendMessageAsync(
Arg.Any<string>(),
Arg.Any<SignalRMessage>(),
@ -184,7 +176,7 @@ public class VersionUpdaterServiceTests : IDisposable
[Fact]
public async Task GetAllReleases_ShouldReturnReleases_LimitedByCount()
{
// Arrange
var releases = new List<object>
{
new
@ -215,10 +207,10 @@ public class VersionUpdaterServiceTests : IDisposable
_httpTest.RespondWithJson(releases);
// Act
var result = await _service.GetAllReleases(2);
// Assert
Assert.Equal(2, result.Count);
Assert.Equal("0.7.0.0", result[0].UpdateVersion);
Assert.Equal("0.6.0", result[1].UpdateVersion);
@ -227,7 +219,7 @@ public class VersionUpdaterServiceTests : IDisposable
[Fact]
public async Task GetAllReleases_ShouldUseCachedData_WhenCacheIsValid()
{
// Arrange
var releases = new List<UpdateNotificationDto>
{
new()
@ -257,10 +249,10 @@ public class VersionUpdaterServiceTests : IDisposable
await File.WriteAllTextAsync(cacheFilePath, System.Text.Json.JsonSerializer.Serialize(releases));
File.SetLastWriteTimeUtc(cacheFilePath, DateTime.UtcNow); // Ensure it's fresh
// Act
var result = await _service.GetAllReleases();
// Assert
Assert.Equal(2, result.Count);
Assert.Empty(_httpTest.CallLog); // No HTTP calls made
}
@ -268,7 +260,7 @@ public class VersionUpdaterServiceTests : IDisposable
[Fact]
public async Task GetAllReleases_ShouldFetchNewData_WhenCacheIsExpired()
{
// Arrange
var releases = new List<UpdateNotificationDto>
{
new()
@ -303,18 +295,18 @@ public class VersionUpdaterServiceTests : IDisposable
_httpTest.RespondWithJson(newReleases);
// Act
var result = await _service.GetAllReleases();
// Assert
Assert.Equal(1, result.Count);
Assert.Single(result);
Assert.Equal("0.7.0.0", result[0].UpdateVersion);
Assert.NotEmpty(_httpTest.CallLog); // HTTP call was made
}
public async Task GetNumberOfReleasesBehind_ShouldReturnCorrectCount()
{
// Arrange
var releases = new List<object>
{
new
@ -345,16 +337,16 @@ public class VersionUpdaterServiceTests : IDisposable
_httpTest.RespondWithJson(releases);
// Act
var result = await _service.GetNumberOfReleasesBehind();
// Assert
Assert.Equal(2 + 1, result); // Behind 0.7.0 and 0.6.0 - We have to add 1 because the current release is > 0.7.0
}
public async Task GetNumberOfReleasesBehind_ShouldReturnCorrectCount_WithNightlies()
{
// Arrange
var releases = new List<object>
{
new
@ -377,17 +369,17 @@ public class VersionUpdaterServiceTests : IDisposable
_httpTest.RespondWithJson(releases);
// Act
var result = await _service.GetNumberOfReleasesBehind();
// Assert
Assert.Equal(2, result); // We have to add 1 because the current release is > 0.7.0
}
[Fact]
public async Task ParseReleaseBody_ShouldExtractSections()
{
// Arrange
var githubResponse = new
{
tag_name = "v0.6.0",
@ -399,10 +391,10 @@ public class VersionUpdaterServiceTests : IDisposable
_httpTest.RespondWithJson(githubResponse);
// Act
var result = await _service.CheckForUpdate();
// Assert
Assert.NotNull(result);
Assert.Equal(2, result.Added.Count);
Assert.Equal(2, result.Fixed.Count);
@ -414,7 +406,7 @@ public class VersionUpdaterServiceTests : IDisposable
[Fact]
public async Task GetAllReleases_ShouldHandleNightlyBuilds()
{
// Arrange
// Set BuildInfo.Version to a nightly build version
typeof(BuildInfo).GetProperty(nameof(BuildInfo.Version))?.SetValue(null, new Version("0.7.1.0"));
@ -444,10 +436,10 @@ public class VersionUpdaterServiceTests : IDisposable
// Mock commit info for develop branch
_httpTest.RespondWithJson(new List<object>());
// Act
var result = await _service.GetAllReleases();
// Assert
Assert.NotNull(result);
Assert.True(result[0].IsOnNightlyInRelease);
}

View file

@ -1,6 +1,5 @@
using System.Collections.Generic;
using System.IO;
using System.IO.Abstractions;
using System.IO.Abstractions.TestingHelpers;
using System.Linq;
using System.Threading.Tasks;
@ -11,10 +10,8 @@ using API.Helpers;
using API.Helpers.Builders;
using API.Services;
using API.Services.Plus;
using API.Services.Tasks;
using API.Services.Tasks.Metadata;
using API.SignalR;
using API.Tests.Helpers;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Xunit;
@ -29,9 +26,10 @@ public class WordCountAnalysisTests : AbstractDbTest
private const long MinHoursToRead = 1;
private const float AvgHoursToRead = 1.66954792f;
private const long MaxHoursToRead = 3;
public WordCountAnalysisTests() : base()
public WordCountAnalysisTests()
{
_readerService = new ReaderService(_unitOfWork, Substitute.For<ILogger<ReaderService>>(),
_readerService = new ReaderService(UnitOfWork, Substitute.For<ILogger<ReaderService>>(),
Substitute.For<IEventHub>(), Substitute.For<IImageService>(),
new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), new MockFileSystem()),
Substitute.For<IScrobblingService>());
@ -39,9 +37,9 @@ public class WordCountAnalysisTests : AbstractDbTest
protected override async Task ResetDb()
{
_context.Series.RemoveRange(_context.Series.ToList());
Context.Series.RemoveRange(Context.Series.ToList());
await _context.SaveChangesAsync();
await Context.SaveChangesAsync();
}
[Fact]
@ -59,7 +57,7 @@ public class WordCountAnalysisTests : AbstractDbTest
MangaFormat.Epub).Build())
.Build();
_context.Library.Add(new LibraryBuilder("Test LIb", LibraryType.Book)
Context.Library.Add(new LibraryBuilder("Test LIb", LibraryType.Book)
.WithSeries(series)
.Build());
@ -70,11 +68,11 @@ public class WordCountAnalysisTests : AbstractDbTest
.Build(),
};
await _context.SaveChangesAsync();
await Context.SaveChangesAsync();
var cacheService = new CacheHelper(new FileService());
var service = new WordCountAnalyzerService(Substitute.For<ILogger<WordCountAnalyzerService>>(), _unitOfWork,
var service = new WordCountAnalyzerService(Substitute.For<ILogger<WordCountAnalyzerService>>(), UnitOfWork,
Substitute.For<IEventHub>(), cacheService, _readerService, Substitute.For<IMediaErrorService>());
@ -86,7 +84,7 @@ public class WordCountAnalysisTests : AbstractDbTest
Assert.Equal(MaxHoursToRead, series.MaxHoursToRead);
// Validate the Chapter gets updated correctly
var volume = series.Volumes.First();
var volume = series.Volumes[0];
Assert.Equal(WordCount, volume.WordCount);
Assert.Equal(MinHoursToRead, volume.MinHoursToRead);
Assert.Equal(AvgHoursToRead, volume.AvgHoursToRead);
@ -117,16 +115,16 @@ public class WordCountAnalysisTests : AbstractDbTest
.Build())
.Build();
_context.Library.Add(new LibraryBuilder("Test", LibraryType.Book)
Context.Library.Add(new LibraryBuilder("Test", LibraryType.Book)
.WithSeries(series)
.Build());
await _context.SaveChangesAsync();
await Context.SaveChangesAsync();
var cacheService = new CacheHelper(new FileService());
var service = new WordCountAnalyzerService(Substitute.For<ILogger<WordCountAnalyzerService>>(), _unitOfWork,
var service = new WordCountAnalyzerService(Substitute.For<ILogger<WordCountAnalyzerService>>(), UnitOfWork,
Substitute.For<IEventHub>(), cacheService, _readerService, Substitute.For<IMediaErrorService>());
await service.ScanSeries(1, 1);
@ -142,21 +140,21 @@ public class WordCountAnalysisTests : AbstractDbTest
.WithChapter(chapter2)
.Build());
series.Volumes.First().Chapters.Add(chapter2);
await _unitOfWork.CommitAsync();
series.Volumes[0].Chapters.Add(chapter2);
await UnitOfWork.CommitAsync();
await service.ScanSeries(1, 1);
Assert.Equal(WordCount * 2L, series.WordCount);
Assert.Equal(MinHoursToRead * 2, series.MinHoursToRead);
var firstVolume = series.Volumes.ElementAt(0);
var firstVolume = series.Volumes[0];
Assert.Equal(WordCount, firstVolume.WordCount);
Assert.Equal(MinHoursToRead, firstVolume.MinHoursToRead);
Assert.True(series.AvgHoursToRead.Is(AvgHoursToRead * 2));
Assert.Equal(MaxHoursToRead, firstVolume.MaxHoursToRead);
var secondVolume = series.Volumes.ElementAt(1);
var secondVolume = series.Volumes[1];
Assert.Equal(WordCount, secondVolume.WordCount);
Assert.Equal(MinHoursToRead, secondVolume.MinHoursToRead);
Assert.Equal(AvgHoursToRead, secondVolume.AvgHoursToRead);

View file

@ -50,9 +50,9 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CsvHelper" Version="33.0.1" />
<PackageReference Include="MailKit" Version="4.10.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.2">
<PackageReference Include="CsvHelper" Version="33.1.0" />
<PackageReference Include="MailKit" Version="4.12.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.6">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
@ -62,46 +62,45 @@
<PackageReference Include="ExCSS" Version="4.3.0" />
<PackageReference Include="Flurl" Version="4.0.0" />
<PackageReference Include="Flurl.Http" Version="4.0.2" />
<PackageReference Include="Hangfire" Version="1.8.18" />
<PackageReference Include="Hangfire" Version="1.8.20" />
<PackageReference Include="Hangfire.InMemory" Version="1.0.0" />
<PackageReference Include="Hangfire.MaximumConcurrentExecutions" Version="1.1.0" />
<PackageReference Include="Hangfire.Storage.SQLite" Version="0.4.2" />
<PackageReference Include="HtmlAgilityPack" Version="1.11.74" />
<PackageReference Include="HtmlAgilityPack" Version="1.12.1" />
<PackageReference Include="MarkdownDeep.NET.Core" Version="1.5.0.4" />
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.18" />
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.20" />
<PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.2.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.2" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="9.0.2" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="9.0.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.2" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.2" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.6" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="9.0.6" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="9.0.6" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.6" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.6" />
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="3.0.1" />
<PackageReference Include="MimeTypeMapOfficial" Version="1.0.17" />
<PackageReference Include="Nager.ArticleNumber" Version="1.0.7" />
<PackageReference Include="NetVips" Version="3.0.0" />
<PackageReference Include="NetVips.Native" Version="8.16.0" />
<PackageReference Include="NReco.Logging.File" Version="1.2.2" />
<PackageReference Include="Serilog" Version="4.2.0" />
<PackageReference Include="NetVips" Version="3.1.0" />
<PackageReference Include="NetVips.Native" Version="8.17.0.1" />
<PackageReference Include="Serilog" Version="4.3.0" />
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />
<PackageReference Include="Serilog.Enrichers.Thread" Version="4.0.0" />
<PackageReference Include="Serilog.Extensions.Hosting" Version="9.0.0" />
<PackageReference Include="Serilog.Settings.Configuration" Version="9.0.0" />
<PackageReference Include="Serilog.Sinks.AspNetCore.SignalR" Version="0.4.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
<PackageReference Include="Serilog.Sinks.SignalR.Core" Version="0.1.2" />
<PackageReference Include="SharpCompress" Version="0.39.0" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.7" />
<PackageReference Include="SonarAnalyzer.CSharp" Version="10.7.0.110445">
<PackageReference Include="SharpCompress" Version="0.40.0" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.10" />
<PackageReference Include="SonarAnalyzer.CSharp" Version="10.11.0.117924">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Swashbuckle.AspNetCore" Version="7.3.1" />
<PackageReference Include="Swashbuckle.AspNetCore.Filters" Version="8.0.2" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.6.0" />
<PackageReference Include="System.IO.Abstractions" Version="22.0.11" />
<PackageReference Include="System.Drawing.Common" Version="9.0.2" />
<PackageReference Include="VersOne.Epub" Version="3.3.2" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.1" />
<PackageReference Include="Swashbuckle.AspNetCore.Filters" Version="8.0.3" />
<PackageReference Include="System.Drawing.Common" Version="9.0.6" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.12.0" />
<PackageReference Include="System.IO.Abstractions" Version="22.0.14" />
<PackageReference Include="VersOne.Epub" Version="3.3.4" />
<PackageReference Include="YamlDotNet" Version="16.3.0" />
</ItemGroup>
@ -112,16 +111,16 @@
<ItemGroup>
<None Remove="Hangfire-log.db" />
<None Remove="obj\**" />
<None Remove="cache\**" />
<None Remove="backups\**" />
<None Remove="logs\**" />
<None Remove="temp\**" />
<None Remove="kavita.log" />
<None Remove="kavita.db" />
<None Remove="covers\**" />
<None Remove="config\kavita.log" />
<None Remove="config\kavita.db" />
<None Remove="config\covers\**" />
<None Remove="wwwroot\**" />
<None Remove="cache\cache-long\**" />
<None Remove="config\cache\**" />
<None Remove="config\logs\**" />
<None Remove="config\covers\**" />
@ -139,6 +138,7 @@
<Compile Remove="covers\**" />
<Compile Remove="wwwroot\**" />
<Compile Remove="config\cache\**" />
<Compile Remove="cache\cache-long\**" />
<Compile Remove="config\logs\**" />
<Compile Remove="config\covers\**" />
<Compile Remove="config\bookmarks\**" />
@ -188,7 +188,6 @@
</ItemGroup>
<ItemGroup>
<Folder Include="config\cache-long\" />
<Folder Include="config\themes" />
<Content Include="EmailTemplates\**">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>

View file

@ -8,6 +8,10 @@ public static class EasyCacheProfiles
public const string RevokedJwt = "revokedJWT";
public const string Favicon = "favicon";
/// <summary>
/// Images for Publishers
/// </summary>
public const string Publisher = "publisherImages";
/// <summary>
/// If a user's license is valid
/// </summary>
public const string License = "license";

View file

@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
@ -137,6 +138,12 @@ public class AccountController : BaseApiController
return BadRequest(usernameValidation);
}
// If Email is empty, default to the username
if (string.IsNullOrEmpty(registerDto.Email))
{
registerDto.Email = registerDto.Username;
}
var user = new AppUserBuilder(registerDto.Username, registerDto.Email,
await _unitOfWork.SiteThemeRepository.GetDefaultTheme()).Build();
@ -146,6 +153,9 @@ public class AccountController : BaseApiController
// Assign default streams
AddDefaultStreamsToUser(user);
// Assign default reading profile
await AddDefaultReadingProfileToUser(user);
var token = await _userManager.GenerateEmailConfirmationTokenAsync(user);
if (string.IsNullOrEmpty(token)) return BadRequest(await _localizationService.Get("en", "confirm-token-gen"));
if (!await ConfirmEmailToken(token, user)) return BadRequest(await _localizationService.Get("en", "validate-email", token));
@ -351,10 +361,11 @@ public class AccountController : BaseApiController
/// <param name="dto"></param>
/// <returns>Returns just if the email was sent or server isn't reachable</returns>
[HttpPost("update/email")]
public async Task<ActionResult> UpdateEmail(UpdateEmailDto? dto)
public async Task<ActionResult<InviteUserResponse>> UpdateEmail(UpdateEmailDto? dto)
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
if (user == null || User.IsInRole(PolicyConstants.ReadOnlyRole)) return Unauthorized(await _localizationService.Translate(User.GetUserId(), "permission-denied"));
if (user == null || User.IsInRole(PolicyConstants.ReadOnlyRole))
return Unauthorized(await _localizationService.Translate(User.GetUserId(), "permission-denied"));
if (dto == null || string.IsNullOrEmpty(dto.Email) || string.IsNullOrEmpty(dto.Password))
return BadRequest(await _localizationService.Translate(User.GetUserId(), "invalid-payload"));
@ -363,12 +374,13 @@ public class AccountController : BaseApiController
// Validate this user's password
if (! await _userManager.CheckPasswordAsync(user, dto.Password))
{
_logger.LogCritical("A user tried to change {UserName}'s email, but password didn't validate", user.UserName);
_logger.LogWarning("A user tried to change {UserName}'s email, but password didn't validate", user.UserName);
return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied"));
}
// Validate no other users exist with this email
if (user.Email!.Equals(dto.Email)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "nothing-to-do"));
if (user.Email!.Equals(dto.Email))
return BadRequest(await _localizationService.Translate(User.GetUserId(), "nothing-to-do"));
// Check if email is used by another user
var existingUserEmail = await _unitOfWork.UserRepository.GetUserByEmailAsync(dto.Email);
@ -385,8 +397,10 @@ public class AccountController : BaseApiController
return BadRequest(await _localizationService.Translate(User.GetUserId(), "generate-token"));
}
var isValidEmailAddress = _emailService.IsValidEmail(user.Email);
var serverSettings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync();
var shouldEmailUser = serverSettings.IsEmailSetup() || !_emailService.IsValidEmail(user.Email);
var shouldEmailUser = serverSettings.IsEmailSetup() || !isValidEmailAddress;
user.EmailConfirmed = !shouldEmailUser;
user.ConfirmationToken = token;
await _userManager.UpdateAsync(user);
@ -400,7 +414,8 @@ public class AccountController : BaseApiController
return Ok(new InviteUserResponse
{
EmailLink = string.Empty,
EmailSent = false
EmailSent = false,
InvalidEmail = !isValidEmailAddress
});
}
@ -408,7 +423,7 @@ public class AccountController : BaseApiController
// Send a confirmation email
try
{
if (!_emailService.IsValidEmail(user.Email))
if (!isValidEmailAddress)
{
_logger.LogCritical("[Update Email]: User is trying to update their email, but their existing email ({Email}) isn't valid. No email will be send", user.Email);
return Ok(new InviteUserResponse
@ -440,7 +455,8 @@ public class AccountController : BaseApiController
return Ok(new InviteUserResponse
{
EmailLink = string.Empty,
EmailSent = true
EmailSent = true,
InvalidEmail = !isValidEmailAddress
});
}
catch (Exception ex)
@ -596,7 +612,7 @@ public class AccountController : BaseApiController
}
/// <summary>
/// Requests the Invite Url for the UserId. Will return error if user is already validated.
/// Requests the Invite Url for the AppUserId. Will return error if user is already validated.
/// </summary>
/// <param name="userId"></param>
/// <param name="withBaseUrl">Include the "https://ip:port/" in the generated link</param>
@ -656,6 +672,9 @@ public class AccountController : BaseApiController
// Assign default streams
AddDefaultStreamsToUser(user);
// Assign default reading profile
await AddDefaultReadingProfileToUser(user);
// Assign Roles
var roles = dto.Roles;
var hasAdminRole = dto.Roles.Contains(PolicyConstants.AdminRole);
@ -766,6 +785,16 @@ public class AccountController : BaseApiController
}
}
private async Task AddDefaultReadingProfileToUser(AppUser user)
{
var profile = new AppUserReadingProfileBuilder(user.Id)
.WithName("Default Profile")
.WithKind(ReadingProfileKind.Default)
.Build();
_unitOfWork.AppUserReadingProfileRepository.Add(profile);
await _unitOfWork.CommitAsync();
}
/// <summary>
/// Last step in authentication flow, confirms the email token for email
/// </summary>

View file

@ -1,5 +1,6 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using API.Constants;
using API.Data;
using API.Data.ManualMigrations;
using API.DTOs;
@ -16,12 +17,10 @@ namespace API.Controllers;
public class AdminController : BaseApiController
{
private readonly UserManager<AppUser> _userManager;
private readonly IUnitOfWork _unitOfWork;
public AdminController(UserManager<AppUser> userManager, IUnitOfWork unitOfWork)
public AdminController(UserManager<AppUser> userManager)
{
_userManager = userManager;
_unitOfWork = unitOfWork;
}
/// <summary>
@ -32,18 +31,7 @@ public class AdminController : BaseApiController
[HttpGet("exists")]
public async Task<ActionResult<bool>> AdminExists()
{
var users = await _userManager.GetUsersInRoleAsync("Admin");
var users = await _userManager.GetUsersInRoleAsync(PolicyConstants.AdminRole);
return users.Count > 0;
}
/// <summary>
/// Set the progress information for a particular user
/// </summary>
/// <returns></returns>
[Authorize("RequireAdminRole")]
[HttpPost("update-chapter-progress")]
public async Task<ActionResult<bool>> UpdateChapterProgress(UpdateUserProgressDto dto)
{
return Ok(await Task.FromResult(false));
}
}

View file

@ -50,7 +50,7 @@ public class BookController : BaseApiController
case MangaFormat.Epub:
{
var mangaFile = (await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId))[0];
using var book = await EpubReader.OpenBookAsync(mangaFile.FilePath, BookService.BookReaderOptions);
using var book = await EpubReader.OpenBookAsync(mangaFile.FilePath, BookService.LenientBookReaderOptions);
bookTitle = book.Title;
break;
}
@ -102,7 +102,7 @@ public class BookController : BaseApiController
var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(chapterId);
if (chapter == null) return BadRequest(await _localizationService.Get("en", "chapter-doesnt-exist"));
using var book = await EpubReader.OpenBookAsync(chapter.Files.ElementAt(0).FilePath, BookService.BookReaderOptions);
using var book = await EpubReader.OpenBookAsync(chapter.Files.ElementAt(0).FilePath, BookService.LenientBookReaderOptions);
var key = BookService.CoalesceKeyForAnyFile(book, file);
if (!book.Content.AllFiles.ContainsLocalFileRefWithKey(key)) return BadRequest(await _localizationService.Get("en", "file-missing"));

View file

@ -6,15 +6,20 @@ using API.Constants;
using API.Data;
using API.Data.Repositories;
using API.DTOs;
using API.DTOs.SeriesDetail;
using API.Entities;
using API.Entities.Enums;
using API.Entities.MetadataMatching;
using API.Entities.Person;
using API.Extensions;
using API.Helpers;
using API.Services;
using API.Services.Tasks.Scanner.Parser;
using API.SignalR;
using AutoMapper;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Nager.ArticleNumber;
@ -26,13 +31,16 @@ public class ChapterController : BaseApiController
private readonly ILocalizationService _localizationService;
private readonly IEventHub _eventHub;
private readonly ILogger<ChapterController> _logger;
private readonly IMapper _mapper;
public ChapterController(IUnitOfWork unitOfWork, ILocalizationService localizationService, IEventHub eventHub, ILogger<ChapterController> logger)
public ChapterController(IUnitOfWork unitOfWork, ILocalizationService localizationService, IEventHub eventHub, ILogger<ChapterController> logger,
IMapper mapper)
{
_unitOfWork = unitOfWork;
_localizationService = localizationService;
_eventHub = eventHub;
_logger = logger;
_mapper = mapper;
}
/// <summary>
@ -61,7 +69,8 @@ public class ChapterController : BaseApiController
{
if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied"));
var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(chapterId);
var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(chapterId,
ChapterIncludes.Files | ChapterIncludes.ExternalReviews | ChapterIncludes.ExternalRatings);
if (chapter == null)
return BadRequest(_localizationService.Translate(User.GetUserId(), "chapter-doesnt-exist"));
@ -79,6 +88,15 @@ public class ChapterController : BaseApiController
_unitOfWork.ChapterRepository.Remove(chapter);
}
// If we removed the volume, do an additional check if we need to delete the actual series as well or not
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(vol.SeriesId, SeriesIncludes.ExternalData | SeriesIncludes.Volumes);
var needToRemoveSeries = needToRemoveVolume && series != null && series.Volumes.Count <= 1;
if (needToRemoveSeries)
{
_unitOfWork.SeriesRepository.Remove(series!);
}
if (!await _unitOfWork.CommitAsync()) return Ok(false);
@ -88,6 +106,12 @@ public class ChapterController : BaseApiController
await _eventHub.SendMessageAsync(MessageFactory.VolumeRemoved, MessageFactory.VolumeRemovedEvent(chapter.VolumeId, vol.SeriesId), false);
}
if (needToRemoveSeries)
{
await _eventHub.SendMessageAsync(MessageFactory.SeriesRemoved,
MessageFactory.SeriesRemovedEvent(series!.Id, series.Name, series.LibraryId), false);
}
return Ok(true);
}
@ -185,6 +209,7 @@ public class ChapterController : BaseApiController
if (chapter.AgeRating != dto.AgeRating)
{
chapter.AgeRating = dto.AgeRating;
chapter.KPlusOverrides.Remove(MetadataSettingField.AgeRating);
}
dto.Summary ??= string.Empty;
@ -192,6 +217,7 @@ public class ChapterController : BaseApiController
if (chapter.Summary != dto.Summary.Trim())
{
chapter.Summary = dto.Summary.Trim();
chapter.KPlusOverrides.Remove(MetadataSettingField.ChapterSummary);
}
if (chapter.Language != dto.Language)
@ -207,11 +233,13 @@ public class ChapterController : BaseApiController
if (chapter.TitleName != dto.TitleName)
{
chapter.TitleName = dto.TitleName;
chapter.KPlusOverrides.Remove(MetadataSettingField.ChapterTitle);
}
if (chapter.ReleaseDate != dto.ReleaseDate)
{
chapter.ReleaseDate = dto.ReleaseDate;
chapter.KPlusOverrides.Remove(MetadataSettingField.ChapterReleaseDate);
}
if (!string.IsNullOrEmpty(dto.ISBN) && ArticleNumberHelper.IsValidIsbn10(dto.ISBN) ||
@ -234,131 +262,123 @@ public class ChapterController : BaseApiController
#region Genres
if (dto.Genres is {Count: > 0})
{
chapter.Genres ??= new List<Genre>();
await GenreHelper.UpdateChapterGenres(chapter, dto.Genres.Select(t => t.Title), _unitOfWork);
}
chapter.Genres ??= [];
await GenreHelper.UpdateChapterGenres(chapter, dto.Genres.Select(t => t.Title), _unitOfWork);
#endregion
#region Tags
if (dto.Tags is {Count: > 0})
{
chapter.Tags ??= new List<Tag>();
await TagHelper.UpdateChapterTags(chapter, dto.Tags.Select(t => t.Title), _unitOfWork);
}
chapter.Tags ??= [];
await TagHelper.UpdateChapterTags(chapter, dto.Tags.Select(t => t.Title), _unitOfWork);
#endregion
#region People
if (PersonHelper.HasAnyPeople(dto))
{
chapter.People ??= new List<ChapterPeople>();
chapter.People ??= [];
// Update writers
await PersonHelper.UpdateChapterPeopleAsync(
chapter,
dto.Writers.Select(p => p.Name).ToList(),
PersonRole.Writer,
_unitOfWork
);
// Update writers
await PersonHelper.UpdateChapterPeopleAsync(
chapter,
dto.Writers.Select(p => p.Name).ToList(),
PersonRole.Writer,
_unitOfWork
);
// Update characters
await PersonHelper.UpdateChapterPeopleAsync(
chapter,
dto.Characters.Select(p => p.Name).ToList(),
PersonRole.Character,
_unitOfWork
);
// Update characters
await PersonHelper.UpdateChapterPeopleAsync(
chapter,
dto.Characters.Select(p => p.Name).ToList(),
PersonRole.Character,
_unitOfWork
);
// Update pencillers
await PersonHelper.UpdateChapterPeopleAsync(
chapter,
dto.Pencillers.Select(p => p.Name).ToList(),
PersonRole.Penciller,
_unitOfWork
);
// Update pencillers
await PersonHelper.UpdateChapterPeopleAsync(
chapter,
dto.Pencillers.Select(p => p.Name).ToList(),
PersonRole.Penciller,
_unitOfWork
);
// Update inkers
await PersonHelper.UpdateChapterPeopleAsync(
chapter,
dto.Inkers.Select(p => p.Name).ToList(),
PersonRole.Inker,
_unitOfWork
);
// Update inkers
await PersonHelper.UpdateChapterPeopleAsync(
chapter,
dto.Inkers.Select(p => p.Name).ToList(),
PersonRole.Inker,
_unitOfWork
);
// Update colorists
await PersonHelper.UpdateChapterPeopleAsync(
chapter,
dto.Colorists.Select(p => p.Name).ToList(),
PersonRole.Colorist,
_unitOfWork
);
// Update colorists
await PersonHelper.UpdateChapterPeopleAsync(
chapter,
dto.Colorists.Select(p => p.Name).ToList(),
PersonRole.Colorist,
_unitOfWork
);
// Update letterers
await PersonHelper.UpdateChapterPeopleAsync(
chapter,
dto.Letterers.Select(p => p.Name).ToList(),
PersonRole.Letterer,
_unitOfWork
);
// Update letterers
await PersonHelper.UpdateChapterPeopleAsync(
chapter,
dto.Letterers.Select(p => p.Name).ToList(),
PersonRole.Letterer,
_unitOfWork
);
// Update cover artists
await PersonHelper.UpdateChapterPeopleAsync(
chapter,
dto.CoverArtists.Select(p => p.Name).ToList(),
PersonRole.CoverArtist,
_unitOfWork
);
// Update cover artists
await PersonHelper.UpdateChapterPeopleAsync(
chapter,
dto.CoverArtists.Select(p => p.Name).ToList(),
PersonRole.CoverArtist,
_unitOfWork
);
// Update editors
await PersonHelper.UpdateChapterPeopleAsync(
chapter,
dto.Editors.Select(p => p.Name).ToList(),
PersonRole.Editor,
_unitOfWork
);
// Update editors
await PersonHelper.UpdateChapterPeopleAsync(
chapter,
dto.Editors.Select(p => p.Name).ToList(),
PersonRole.Editor,
_unitOfWork
);
// TODO: Only remove field if changes were made
chapter.KPlusOverrides.Remove(MetadataSettingField.ChapterPublisher);
// Update publishers
await PersonHelper.UpdateChapterPeopleAsync(
chapter,
dto.Publishers.Select(p => p.Name).ToList(),
PersonRole.Publisher,
_unitOfWork
);
// Update publishers
await PersonHelper.UpdateChapterPeopleAsync(
chapter,
dto.Publishers.Select(p => p.Name).ToList(),
PersonRole.Publisher,
_unitOfWork
);
// Update translators
await PersonHelper.UpdateChapterPeopleAsync(
chapter,
dto.Translators.Select(p => p.Name).ToList(),
PersonRole.Translator,
_unitOfWork
);
// Update translators
await PersonHelper.UpdateChapterPeopleAsync(
chapter,
dto.Translators.Select(p => p.Name).ToList(),
PersonRole.Translator,
_unitOfWork
);
// Update imprints
await PersonHelper.UpdateChapterPeopleAsync(
chapter,
dto.Imprints.Select(p => p.Name).ToList(),
PersonRole.Imprint,
_unitOfWork
);
// Update imprints
await PersonHelper.UpdateChapterPeopleAsync(
chapter,
dto.Imprints.Select(p => p.Name).ToList(),
PersonRole.Imprint,
_unitOfWork
);
// Update teams
await PersonHelper.UpdateChapterPeopleAsync(
chapter,
dto.Teams.Select(p => p.Name).ToList(),
PersonRole.Team,
_unitOfWork
);
// Update teams
await PersonHelper.UpdateChapterPeopleAsync(
chapter,
dto.Teams.Select(p => p.Name).ToList(),
PersonRole.Team,
_unitOfWork
);
// Update locations
await PersonHelper.UpdateChapterPeopleAsync(
chapter,
dto.Locations.Select(p => p.Name).ToList(),
PersonRole.Location,
_unitOfWork
);
}
// Update locations
await PersonHelper.UpdateChapterPeopleAsync(
chapter,
dto.Locations.Select(p => p.Name).ToList(),
PersonRole.Location,
_unitOfWork
);
#endregion
#region Locks
@ -400,6 +420,39 @@ public class ChapterController : BaseApiController
return Ok();
}
/// <summary>
/// Returns Ratings and Reviews for an individual Chapter
/// </summary>
/// <param name="chapterId"></param>
/// <returns></returns>
[HttpGet("chapter-detail-plus")]
public async Task<ActionResult<ChapterDetailPlusDto>> ChapterDetailPlus([FromQuery] int chapterId)
{
var ret = new ChapterDetailPlusDto();
var userReviews = (await _unitOfWork.UserRepository.GetUserRatingDtosForChapterAsync(chapterId, User.GetUserId()))
.Where(r => !string.IsNullOrEmpty(r.Body))
.OrderByDescending(review => review.Username.Equals(User.GetUsername()) ? 1 : 0)
.ToList();
var ownRating = await _unitOfWork.UserRepository.GetUserChapterRatingAsync(User.GetUserId(), chapterId);
if (ownRating != null)
{
ret.Rating = ownRating.Rating;
ret.HasBeenRated = ownRating.HasBeenRated;
}
var externalReviews = await _unitOfWork.ChapterRepository.GetExternalChapterReviewDtos(chapterId);
if (externalReviews.Count > 0)
{
userReviews.AddRange(ReviewHelper.SelectSpectrumOfReviews(externalReviews));
}
ret.Reviews = userReviews;
ret.Ratings = await _unitOfWork.ChapterRepository.GetExternalChapterRatingDtos(chapterId);
return Ok(ret);
}
}

View file

@ -158,6 +158,7 @@ public class DownloadController : BaseApiController
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.DownloadProgressEvent(username,
filename, $"Downloading {filename}", 0F, "started"));
if (files.Count == 1 && files.First().Format != MangaFormat.Image)
{
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
@ -167,15 +168,17 @@ public class DownloadController : BaseApiController
}
var filePath = _archiveService.CreateZipFromFoldersForDownload(files.Select(c => c.FilePath).ToList(), tempFolder, ProgressCallback);
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.DownloadProgressEvent(username,
filename, "Download Complete", 1F, "ended"));
return PhysicalFile(filePath, DefaultContentType, Uri.EscapeDataString(downloadName), true);
async Task ProgressCallback(Tuple<string, float> progressInfo)
{
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.DownloadProgressEvent(username, filename, $"Extracting {Path.GetFileNameWithoutExtension(progressInfo.Item1)}",
MessageFactory.DownloadProgressEvent(username, filename, $"Processing {Path.GetFileNameWithoutExtension(progressInfo.Item1)}",
Math.Clamp(progressInfo.Item2, 0F, 1F)));
}
}
@ -193,8 +196,10 @@ public class DownloadController : BaseApiController
public async Task<ActionResult> DownloadSeries(int seriesId)
{
if (!await HasDownloadPermission()) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied"));
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId);
if (series == null) return BadRequest("Invalid Series");
var files = await _unitOfWork.SeriesRepository.GetFilesForSeries(seriesId);
try
{

View file

@ -12,6 +12,7 @@ using API.Extensions;
using API.Helpers;
using API.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace API.Controllers;
@ -24,11 +25,16 @@ public class FilterController : BaseApiController
{
private readonly IUnitOfWork _unitOfWork;
private readonly ILocalizationService _localizationService;
private readonly IStreamService _streamService;
private readonly ILogger<FilterController> _logger;
public FilterController(IUnitOfWork unitOfWork, ILocalizationService localizationService)
public FilterController(IUnitOfWork unitOfWork, ILocalizationService localizationService, IStreamService streamService,
ILogger<FilterController> logger)
{
_unitOfWork = unitOfWork;
_localizationService = localizationService;
_streamService = streamService;
_logger = logger;
}
/// <summary>
@ -120,4 +126,57 @@ public class FilterController : BaseApiController
{
return Ok(SmartFilterHelper.Decode(dto.EncodedFilter));
}
/// <summary>
/// Rename a Smart Filter given the filterId and new name
/// </summary>
/// <param name="filterId"></param>
/// <param name="name"></param>
/// <returns></returns>
[HttpPost("rename")]
public async Task<ActionResult> RenameFilter([FromQuery] int filterId, [FromQuery] string name)
{
try
{
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(),
AppUserIncludes.SmartFilters);
if (user == null) return Unauthorized();
name = name.Trim();
if (User.IsInRole(PolicyConstants.ReadOnlyRole))
{
return BadRequest(await _localizationService.Translate(user.Id, "permission-denied"));
}
if (string.IsNullOrWhiteSpace(name))
{
return BadRequest(await _localizationService.Translate(user.Id, "smart-filter-name-required"));
}
if (Seed.DefaultStreams.Any(s => s.Name.Equals(name, StringComparison.InvariantCultureIgnoreCase)))
{
return BadRequest(await _localizationService.Translate(user.Id, "smart-filter-system-name"));
}
var filter = user.SmartFilters.FirstOrDefault(f => f.Id == filterId);
if (filter == null)
{
return BadRequest(await _localizationService.Translate(user.Id, "filter-not-found"));
}
filter.Name = name;
_unitOfWork.AppUserSmartFilterRepository.Update(filter);
await _unitOfWork.CommitAsync();
await _streamService.RenameSmartFilterStreams(filter);
return Ok();
}
catch (Exception ex)
{
_logger.LogError(ex, "There was an exception when renaming smart filter: {FilterId}", filterId);
return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-error"));
}
}
}

View file

@ -0,0 +1,119 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System;
using System.Threading.Tasks;
using API.Data;
using API.Data.Repositories;
using API.DTOs.Koreader;
using API.Entities;
using API.Extensions;
using API.Services;
using Kavita.Common;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Logging;
using static System.Net.WebRequestMethods;
namespace API.Controllers;
#nullable enable
/// <summary>
/// The endpoint to interface with Koreader's Progress Sync plugin.
/// </summary>
/// <remarks>
/// Koreader uses a different form of authentication. It stores the username and password in headers.
/// https://github.com/koreader/koreader/blob/master/plugins/kosync.koplugin/KOSyncClient.lua
/// </remarks>
[AllowAnonymous]
public class KoreaderController : BaseApiController
{
private readonly IUnitOfWork _unitOfWork;
private readonly ILocalizationService _localizationService;
private readonly IKoreaderService _koreaderService;
private readonly ILogger<KoreaderController> _logger;
public KoreaderController(IUnitOfWork unitOfWork, ILocalizationService localizationService,
IKoreaderService koreaderService, ILogger<KoreaderController> logger)
{
_unitOfWork = unitOfWork;
_localizationService = localizationService;
_koreaderService = koreaderService;
_logger = logger;
}
// We won't allow users to be created from Koreader. Rather, they
// must already have an account.
/*
[HttpPost("/users/create")]
public IActionResult CreateUser(CreateUserRequest request)
{
}
*/
[HttpGet("{apiKey}/users/auth")]
public async Task<IActionResult> Authenticate(string apiKey)
{
var userId = await GetUserId(apiKey);
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId);
if (user == null) return Unauthorized();
return Ok(new { username = user.UserName });
}
/// <summary>
/// Syncs book progress with Kavita. Will attempt to save the underlying reader position if possible.
/// </summary>
/// <param name="apiKey"></param>
/// <param name="request"></param>
/// <returns></returns>
[HttpPut("{apiKey}/syncs/progress")]
public async Task<ActionResult<KoreaderProgressUpdateDto>> UpdateProgress(string apiKey, KoreaderBookDto request)
{
try
{
var userId = await GetUserId(apiKey);
await _koreaderService.SaveProgress(request, userId);
return Ok(new KoreaderProgressUpdateDto{ Document = request.Document, Timestamp = DateTime.UtcNow });
}
catch (KavitaException ex)
{
return BadRequest(ex.Message);
}
}
/// <summary>
/// Gets book progress from Kavita, if not found will return a 400
/// </summary>
/// <param name="apiKey"></param>
/// <param name="ebookHash"></param>
/// <returns></returns>
[HttpGet("{apiKey}/syncs/progress/{ebookHash}")]
public async Task<ActionResult<KoreaderBookDto>> GetProgress(string apiKey, string ebookHash)
{
try
{
var userId = await GetUserId(apiKey);
var response = await _koreaderService.GetProgress(ebookHash, userId);
_logger.LogDebug("Koreader response progress for User ({UserId}): {Progress}", userId, response.Progress.Sanitize());
return Ok(response);
}
catch (KavitaException ex)
{
return BadRequest(ex.Message);
}
}
private async Task<int> GetUserId(string apiKey)
{
try
{
return await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey);
}
catch
{
throw new KavitaException(await _localizationService.Get("en", "user-doesnt-exist"));
}
}
}

View file

@ -213,7 +213,6 @@ public class LibraryController : BaseApiController
var ret = _unitOfWork.LibraryRepository.GetLibraryDtosForUsernameAsync(username);
await _libraryCacheProvider.SetAsync(CacheKey, ret, TimeSpan.FromHours(24));
_logger.LogDebug("Caching libraries for {Key}", cacheKey);
return Ok(ret);
}
@ -351,27 +350,6 @@ public class LibraryController : BaseApiController
return Ok();
}
[Authorize(Policy = "RequireAdminRole")]
[HttpPost("analyze")]
public ActionResult Analyze(int libraryId)
{
_taskScheduler.AnalyzeFilesForLibrary(libraryId, true);
return Ok();
}
[Authorize(Policy = "RequireAdminRole")]
[HttpPost("analyze-multiple")]
public ActionResult AnalyzeMultiple(BulkActionDto dto)
{
foreach (var libraryId in dto.Ids)
{
_taskScheduler.AnalyzeFilesForLibrary(libraryId, dto.Force ?? false);
}
return Ok();
}
/// <summary>
/// Copy the library settings (adv tab + optional type) to a set of other libraries.
/// </summary>
@ -440,8 +418,7 @@ public class LibraryController : BaseApiController
.Distinct()
.Select(Services.Tasks.Scanner.Parser.Parser.NormalizePath);
var seriesFolder = _directoryService.FindHighestDirectoriesFromFiles(libraryFolder,
new List<string>() {dto.FolderPath});
var seriesFolder = _directoryService.FindHighestDirectoriesFromFiles(libraryFolder, [dto.FolderPath]);
_taskScheduler.ScanFolder(seriesFolder.Keys.Count == 1 ? seriesFolder.Keys.First() : dto.FolderPath);
@ -646,6 +623,9 @@ public class LibraryController : BaseApiController
library.ManageReadingLists = dto.ManageReadingLists;
library.AllowScrobbling = dto.AllowScrobbling;
library.AllowMetadataMatching = dto.AllowMetadataMatching;
library.EnableMetadata = dto.EnableMetadata;
library.RemovePrefixForSortName = dto.RemovePrefixForSortName;
library.LibraryFileTypes = dto.FileGroupTypes
.Select(t => new LibraryFileTypeGroup() {FileTypeGroup = t, LibraryId = library.Id})
.Distinct()

View file

@ -12,6 +12,7 @@ using Hangfire;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using TaskScheduler = API.Services.TaskScheduler;
namespace API.Controllers;
@ -81,6 +82,10 @@ public class LicenseController(
}
}
/// <summary>
/// Remove the Kavita+ License on the Server
/// </summary>
/// <returns></returns>
[Authorize("RequireAdminRole")]
[HttpDelete]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.LicenseCache)]
@ -91,7 +96,9 @@ public class LicenseController(
setting.Value = null;
unitOfWork.SettingsRepository.Update(setting);
await unitOfWork.CommitAsync();
await taskScheduler.ScheduleKavitaPlusTasks();
TaskScheduler.RemoveKavitaPlusTasks();
return Ok();
}

View file

@ -4,6 +4,7 @@ using System.Globalization;
using System.Linq;
using System.Threading.Tasks;
using API.Constants;
using API.DTOs;
using API.DTOs.Filtering;
using API.Services;
using EasyCaching.Core;
@ -45,8 +46,8 @@ public class LocaleController : BaseApiController
}
var ret = _localizationService.GetLocales().Where(l => l.TranslationCompletion > 0f);
await _localeCacheProvider.SetAsync(CacheKey, ret, TimeSpan.FromDays(7));
await _localeCacheProvider.SetAsync(CacheKey, ret, TimeSpan.FromDays(1));
return Ok();
return Ok(ret);
}
}

View file

@ -9,10 +9,13 @@ using API.Data.Repositories;
using API.DTOs;
using API.DTOs.Filtering;
using API.DTOs.Metadata;
using API.DTOs.Metadata.Browse;
using API.DTOs.Person;
using API.DTOs.Recommendation;
using API.DTOs.SeriesDetail;
using API.Entities.Enums;
using API.Extensions;
using API.Helpers;
using API.Services;
using API.Services.Plus;
using Kavita.Common.Extensions;
@ -45,6 +48,22 @@ public class MetadataController(IUnitOfWork unitOfWork, ILocalizationService loc
return Ok(await unitOfWork.GenreRepository.GetAllGenreDtosForLibrariesAsync(User.GetUserId(), ids, context));
}
/// <summary>
/// Returns a list of Genres with counts for counts when Genre is on Series/Chapter
/// </summary>
/// <returns></returns>
[HttpPost("genres-with-counts")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.FiveMinute)]
public async Task<ActionResult<PagedList<BrowseGenreDto>>> GetBrowseGenres(UserParams? userParams = null)
{
userParams ??= UserParams.Default;
var list = await unitOfWork.GenreRepository.GetBrowseableGenre(User.GetUserId(), userParams);
Response.AddPaginationHeader(list.CurrentPage, list.PageSize, list.TotalCount, list.TotalPages);
return Ok(list);
}
/// <summary>
/// Fetches people from the instance by role
/// </summary>
@ -73,6 +92,7 @@ public class MetadataController(IUnitOfWork unitOfWork, ILocalizationService loc
{
return Ok(await unitOfWork.PersonRepository.GetAllPeopleDtosForLibrariesAsync(User.GetUserId(), ids));
}
return Ok(await unitOfWork.PersonRepository.GetAllPeopleDtosForLibrariesAsync(User.GetUserId()));
}
@ -93,6 +113,22 @@ public class MetadataController(IUnitOfWork unitOfWork, ILocalizationService loc
return Ok(await unitOfWork.TagRepository.GetAllTagDtosForLibrariesAsync(User.GetUserId()));
}
/// <summary>
/// Returns a list of Tags with counts for counts when Tag is on Series/Chapter
/// </summary>
/// <returns></returns>
[HttpPost("tags-with-counts")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.FiveMinute)]
public async Task<ActionResult<PagedList<BrowseTagDto>>> GetBrowseTags(UserParams? userParams = null)
{
userParams ??= UserParams.Default;
var list = await unitOfWork.TagRepository.GetBrowseableTag(User.GetUserId(), userParams);
Response.AddPaginationHeader(list.CurrentPage, list.PageSize, list.TotalCount, list.TotalPages);
return Ok(list);
}
/// <summary>
/// Fetches all age ratings from the instance
/// </summary>
@ -220,12 +256,12 @@ public class MetadataController(IUnitOfWork unitOfWork, ILocalizationService loc
return Ok(ret);
}
private async Task PrepareSeriesDetail(List<UserReviewDto> userReviews, SeriesDetailPlusDto ret)
private async Task PrepareSeriesDetail(List<UserReviewDto> userReviews, SeriesDetailPlusDto? ret)
{
var isAdmin = User.IsInRole(PolicyConstants.AdminRole);
var user = await unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId())!;
userReviews.AddRange(ReviewService.SelectSpectrumOfReviews(ret.Reviews.ToList()));
userReviews.AddRange(ReviewHelper.SelectSpectrumOfReviews(ret.Reviews.ToList()));
ret.Reviews = userReviews;
if (!isAdmin && ret.Recommendations != null && user != null)
@ -234,12 +270,12 @@ public class MetadataController(IUnitOfWork unitOfWork, ILocalizationService loc
ret.Recommendations.OwnedSeries =
await unitOfWork.SeriesRepository.GetSeriesDtoByIdsAsync(
ret.Recommendations.OwnedSeries.Select(s => s.Id), user);
ret.Recommendations.ExternalSeries = new List<ExternalSeriesDto>();
ret.Recommendations.ExternalSeries = [];
}
if (ret.Recommendations != null && user != null)
{
ret.Recommendations.OwnedSeries ??= new List<SeriesDto>();
ret.Recommendations.OwnedSeries ??= [];
await unitOfWork.SeriesRepository.AddSeriesModifiers(user.Id, ret.Recommendations.OwnedSeries);
}
}

View file

@ -15,6 +15,7 @@ using API.DTOs.CollectionTags;
using API.DTOs.Filtering;
using API.DTOs.Filtering.v2;
using API.DTOs.OPDS;
using API.DTOs.Person;
using API.DTOs.Progress;
using API.DTOs.Search;
using API.Entities;
@ -27,6 +28,7 @@ using AutoMapper;
using Kavita.Common;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using MimeTypes;
namespace API.Controllers;
@ -36,6 +38,7 @@ namespace API.Controllers;
[AllowAnonymous]
public class OpdsController : BaseApiController
{
private readonly ILogger<OpdsController> _logger;
private readonly IUnitOfWork _unitOfWork;
private readonly IDownloadService _downloadService;
private readonly IDirectoryService _directoryService;
@ -82,7 +85,7 @@ public class OpdsController : BaseApiController
IDirectoryService directoryService, ICacheService cacheService,
IReaderService readerService, ISeriesService seriesService,
IAccountService accountService, ILocalizationService localizationService,
IMapper mapper)
IMapper mapper, ILogger<OpdsController> logger)
{
_unitOfWork = unitOfWork;
_downloadService = downloadService;
@ -93,6 +96,7 @@ public class OpdsController : BaseApiController
_accountService = accountService;
_localizationService = localizationService;
_mapper = mapper;
_logger = logger;
_xmlSerializer = new XmlSerializer(typeof(Feed));
_xmlOpenSearchSerializer = new XmlSerializer(typeof(OpenSearchDescription));
@ -580,19 +584,25 @@ public class OpdsController : BaseApiController
public async Task<IActionResult> GetReadingListItems(int readingListId, string apiKey, [FromQuery] int pageNumber = 0)
{
var userId = await GetUser(apiKey);
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
return BadRequest(await _localizationService.Translate(userId, "opds-disabled"));
var (baseUrl, prefix) = await GetPrefix();
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId);
var userWithLists = await _unitOfWork.UserRepository.GetUserByUsernameAsync(user!.UserName!, AppUserIncludes.ReadingListsWithItems);
if (userWithLists == null) return Unauthorized();
var readingList = userWithLists.ReadingLists.SingleOrDefault(t => t.Id == readingListId);
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
{
return BadRequest(await _localizationService.Translate(userId, "opds-disabled"));
}
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId);
if (user == null)
{
return Unauthorized();
}
var readingList = await _unitOfWork.ReadingListRepository.GetReadingListDtoByIdAsync(readingListId, user.Id);
if (readingList == null)
{
return BadRequest(await _localizationService.Translate(userId, "reading-list-restricted"));
}
var (baseUrl, prefix) = await GetPrefix();
var feed = CreateFeed(readingList.Title + " " + await _localizationService.Translate(userId, "reading-list"), $"{apiKey}/reading-list/{readingListId}", apiKey, prefix);
SetFeedId(feed, $"reading-list-{readingListId}");

View file

@ -1,7 +1,13 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using API.Data;
using API.Data.Repositories;
using API.DTOs;
using API.DTOs.Filtering.v2;
using API.DTOs.Metadata.Browse;
using API.DTOs.Metadata.Browse.Requests;
using API.DTOs.Person;
using API.Entities.Enums;
using API.Extensions;
using API.Helpers;
@ -24,9 +30,10 @@ public class PersonController : BaseApiController
private readonly ICoverDbService _coverDbService;
private readonly IImageService _imageService;
private readonly IEventHub _eventHub;
private readonly IPersonService _personService;
public PersonController(IUnitOfWork unitOfWork, ILocalizationService localizationService, IMapper mapper,
ICoverDbService coverDbService, IImageService imageService, IEventHub eventHub)
ICoverDbService coverDbService, IImageService imageService, IEventHub eventHub, IPersonService personService)
{
_unitOfWork = unitOfWork;
_localizationService = localizationService;
@ -34,6 +41,7 @@ public class PersonController : BaseApiController
_coverDbService = coverDbService;
_imageService = imageService;
_eventHub = eventHub;
_personService = personService;
}
@ -43,6 +51,17 @@ public class PersonController : BaseApiController
return Ok(await _unitOfWork.PersonRepository.GetPersonDtoByName(name, User.GetUserId()));
}
/// <summary>
/// Find a person by name or alias against a query string
/// </summary>
/// <param name="queryString"></param>
/// <returns></returns>
[HttpGet("search")]
public async Task<ActionResult<List<PersonDto>>> SearchPeople([FromQuery] string queryString)
{
return Ok(await _unitOfWork.PersonRepository.SearchPeople(queryString));
}
/// <summary>
/// Returns all roles for a Person
/// </summary>
@ -54,17 +73,20 @@ public class PersonController : BaseApiController
return Ok(await _unitOfWork.PersonRepository.GetRolesForPersonByName(personId, User.GetUserId()));
}
/// <summary>
/// Returns a list of authors & artists for browsing
/// Returns a list of authors and artists for browsing
/// </summary>
/// <param name="userParams"></param>
/// <returns></returns>
[HttpPost("all")]
public async Task<ActionResult<PagedList<BrowsePersonDto>>> GetAuthorsForBrowse([FromQuery] UserParams? userParams)
public async Task<ActionResult<PagedList<BrowsePersonDto>>> GetPeopleForBrowse(BrowsePersonFilterDto filter, [FromQuery] UserParams? userParams)
{
userParams ??= UserParams.Default;
var list = await _unitOfWork.PersonRepository.GetAllWritersAndSeriesCount(User.GetUserId(), userParams);
var list = await _unitOfWork.PersonRepository.GetBrowsePersonDtos(User.GetUserId(), filter, userParams);
Response.AddPaginationHeader(list.CurrentPage, list.PageSize, list.TotalCount, list.TotalPages);
return Ok(list);
}
@ -78,7 +100,7 @@ public class PersonController : BaseApiController
public async Task<ActionResult<PersonDto>> UpdatePerson(UpdatePersonDto dto)
{
// This needs to get all people and update them equally
var person = await _unitOfWork.PersonRepository.GetPersonById(dto.Id);
var person = await _unitOfWork.PersonRepository.GetPersonById(dto.Id, PersonIncludes.Aliases);
if (person == null) return BadRequest(_localizationService.Translate(User.GetUserId(), "person-doesnt-exist"));
if (string.IsNullOrEmpty(dto.Name)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "person-name-required"));
@ -90,7 +112,12 @@ public class PersonController : BaseApiController
return BadRequest(await _localizationService.Translate(User.GetUserId(), "person-name-unique"));
}
var success = await _personService.UpdatePersonAliasesAsync(person, dto.Aliases);
if (!success) return BadRequest(await _localizationService.Translate(User.GetUserId(), "aliases-have-overlap"));
person.Name = dto.Name?.Trim();
person.NormalizedName = person.Name.ToNormalized();
person.Description = dto.Description ?? string.Empty;
person.CoverImageLocked = dto.CoverImageLocked;
@ -158,7 +185,7 @@ public class PersonController : BaseApiController
[HttpGet("series-known-for")]
public async Task<ActionResult<IEnumerable<SeriesDto>>> GetKnownSeries(int personId)
{
return Ok(await _unitOfWork.PersonRepository.GetSeriesKnownFor(personId));
return Ok(await _unitOfWork.PersonRepository.GetSeriesKnownFor(personId, User.GetUserId()));
}
/// <summary>
@ -173,5 +200,42 @@ public class PersonController : BaseApiController
return Ok(await _unitOfWork.PersonRepository.GetChaptersForPersonByRole(personId, User.GetUserId(), role));
}
/// <summary>
/// Merges Persons into one, this action is irreversible
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
[HttpPost("merge")]
[Authorize("RequireAdminRole")]
public async Task<ActionResult<PersonDto>> MergePeople(PersonMergeDto dto)
{
var dst = await _unitOfWork.PersonRepository.GetPersonById(dto.DestId, PersonIncludes.All);
if (dst == null) return BadRequest();
var src = await _unitOfWork.PersonRepository.GetPersonById(dto.SrcId, PersonIncludes.All);
if (src == null) return BadRequest();
await _personService.MergePeopleAsync(src, dst);
await _eventHub.SendMessageAsync(MessageFactory.PersonMerged, MessageFactory.PersonMergedMessage(dst, src));
return Ok(_mapper.Map<PersonDto>(dst));
}
/// <summary>
/// Ensure the alias is valid to be added. For example, the alias cannot be on another person or be the same as the current person name/alias.
/// </summary>
/// <param name="personId"></param>
/// <param name="alias"></param>
/// <returns></returns>
[HttpGet("valid-alias")]
public async Task<ActionResult<bool>> IsValidAlias(int personId, string alias)
{
var person = await _unitOfWork.PersonRepository.GetPersonById(personId, PersonIncludes.Aliases);
if (person == null) return NotFound();
var existingAlias = await _unitOfWork.PersonRepository.AnyAliasExist(alias);
return Ok(!existingAlias && person.NormalizedName != alias.ToNormalized());
}
}

View file

@ -30,7 +30,7 @@ public class PluginController(IUnitOfWork unitOfWork, ITokenService tokenService
public async Task<ActionResult<UserDto>> Authenticate([Required] string apiKey, [Required] string pluginName)
{
// NOTE: In order to log information about plugins, we need some Plugin Description information for each request
// Should log into access table so we can tell the user
// Should log into the access table so we can tell the user
var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString();
var userAgent = HttpContext.Request.Headers.UserAgent;
var userId = await unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey);
@ -45,7 +45,7 @@ public class PluginController(IUnitOfWork unitOfWork, ITokenService tokenService
throw new KavitaUnauthenticatedUserException();
}
var user = await unitOfWork.UserRepository.GetUserByIdAsync(userId);
logger.LogInformation("Plugin {PluginName} has authenticated with {UserName} ({UserId})'s API Key", pluginName.Replace(Environment.NewLine, string.Empty), user!.UserName, userId);
logger.LogInformation("Plugin {PluginName} has authenticated with {UserName} ({AppUserId})'s API Key", pluginName.Replace(Environment.NewLine, string.Empty), user!.UserName, userId);
return new UserDto
{

View file

@ -1,15 +1,12 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using API.Constants;
using API.Data;
using API.Data.Repositories;
using API.DTOs;
using API.Extensions;
using API.Services;
using API.Services.Plus;
using EasyCaching.Core;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace API.Controllers;
@ -21,21 +18,85 @@ namespace API.Controllers;
public class RatingController : BaseApiController
{
private readonly IUnitOfWork _unitOfWork;
private readonly IRatingService _ratingService;
private readonly ILocalizationService _localizationService;
public RatingController(IUnitOfWork unitOfWork)
public RatingController(IUnitOfWork unitOfWork, IRatingService ratingService, ILocalizationService localizationService)
{
_unitOfWork = unitOfWork;
_ratingService = ratingService;
_localizationService = localizationService;
}
[HttpGet("overall")]
public async Task<ActionResult<RatingDto>> GetOverallRating(int seriesId)
/// <summary>
/// Update the users' rating of the given series
/// </summary>
/// <param name="updateRating"></param>
/// <returns></returns>
/// <exception cref="UnauthorizedAccessException"></exception>
[HttpPost("series")]
public async Task<ActionResult> UpdateSeriesRating(UpdateRatingDto updateRating)
{
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.Ratings | AppUserIncludes.ChapterRatings);
if (user == null) throw new UnauthorizedAccessException();
if (await _ratingService.UpdateSeriesRating(user, updateRating))
{
return Ok();
}
return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-error"));
}
/// <summary>
/// Update the users' rating of the given chapter
/// </summary>
/// <param name="updateRating">chapterId must be set</param>
/// <returns></returns>
/// <exception cref="UnauthorizedAccessException"></exception>
[HttpPost("chapter")]
public async Task<ActionResult> UpdateChapterRating(UpdateRatingDto updateRating)
{
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.Ratings | AppUserIncludes.ChapterRatings);
if (user == null) throw new UnauthorizedAccessException();
if (await _ratingService.UpdateChapterRating(user, updateRating))
{
return Ok();
}
return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-error"));
}
/// <summary>
/// Overall rating from all Kavita users for a given Series
/// </summary>
/// <param name="seriesId"></param>
/// <returns></returns>
[HttpGet("overall-series")]
public async Task<ActionResult<RatingDto>> GetOverallSeriesRating(int seriesId)
{
return Ok(new RatingDto()
{
Provider = ScrobbleProvider.Kavita,
AverageScore = await _unitOfWork.SeriesRepository.GetAverageUserRating(seriesId, User.GetUserId()),
FavoriteCount = 0
FavoriteCount = 0,
});
}
/// <summary>
/// Overall rating from all Kavita users for a given Chapter
/// </summary>
/// <param name="chapterId"></param>
/// <returns></returns>
[HttpGet("overall-chapter")]
public async Task<ActionResult<RatingDto>> GetOverallChapterRating(int chapterId)
{
return Ok(new RatingDto()
{
Provider = ScrobbleProvider.Kavita,
AverageScore = await _unitOfWork.ChapterRepository.GetAverageUserRating(chapterId, User.GetUserId()),
FavoriteCount = 0,
});
}
}

Some files were not shown because too many files have changed in this diff Show more