Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4b3adcf87 | ||
|
|
075461391b | ||
|
|
2c1346f95f | ||
|
|
e20fa1339d | ||
|
|
4dc8b5e051 | ||
|
|
dcdc07610a | ||
|
|
6a6a7a79a7 | ||
|
|
282ec1d325 | ||
|
|
a28caf1bc9 | ||
|
|
1fbfcc0803 | ||
|
|
5c5a217430 | ||
|
|
ccdbb6294a | ||
|
|
107b0e7579 | ||
|
|
4884f1f297 | ||
|
|
74ec19ae48 | ||
|
|
6ae7592f6c | ||
|
|
3089b61a7a | ||
|
|
18da58c346 | ||
|
|
4d852911a5 | ||
|
|
71113d8838 | ||
|
|
c4cddf8897 | ||
|
|
d7bb3195de | ||
|
|
569cac9d61 | ||
|
|
96844ac89c | ||
|
|
9642ea263d | ||
|
|
d34ddb841d | ||
|
|
65afcaf3a4 | ||
|
|
d784091ee4 | ||
|
|
81cd7583cd | ||
|
|
3f6c3321e9 | ||
|
|
be3db1616e | ||
|
|
9fd99bd5f5 | ||
|
|
8f1ea44466 | ||
|
|
6ec19a92b2 | ||
|
|
fa1801875e | ||
|
|
c6ef6ad979 | ||
|
|
74f11f5beb | ||
|
|
c0950537ab | ||
|
|
7cc079c61b | ||
|
|
7aa3224f4d | ||
|
|
1cc068c2ab | ||
|
|
28f22f1085 | ||
|
|
084f83efca | ||
|
|
26b76908eb | ||
|
|
ffc8a0a39a | ||
|
|
8ccd93dc97 | ||
|
|
1ca935b62b | ||
|
|
90a4909e57 | ||
|
|
ef8fb2dd0b | ||
|
|
20ad056400 | ||
|
|
ab792f5fad | ||
|
|
48c36fab8f | ||
|
|
3553fce597 | ||
|
|
c1d2790c8d | ||
|
|
18a9c66ce7 | ||
|
|
7ec60c7ea0 | ||
|
|
a96bc36a1e | ||
|
|
b575644d5f | ||
|
|
13bc71c3cd | ||
|
|
c3f3ee880d | ||
|
|
8ab4a55595 | ||
|
|
85de80a463 | ||
|
|
9f48d3565a | ||
|
|
db2e3e71db | ||
|
|
5d400ad854 | ||
|
|
02a07d4fa3 | ||
|
|
41ca55d615 | ||
|
|
8770a8b18e | ||
|
|
24ecd88cd0 | ||
|
|
7ed0f307be | ||
|
|
5b500c533e | ||
|
|
b1ef63ef43 | ||
|
|
f14e345b52 | ||
|
|
89a0d1281f | ||
|
|
ee5e7c0819 | ||
|
|
d875d88191 | ||
|
|
447db6990a | ||
|
|
4e9d20db0a | ||
|
|
53fc5eff3b | ||
|
|
69fa09e23a | ||
|
|
3360b7a849 | ||
|
|
80b46df8e5 | ||
|
|
88aa71c343 | ||
|
|
7b80e038cc | ||
|
|
ef280ec398 | ||
|
|
81f6de03e7 | ||
|
|
17c3a6b02a | ||
|
|
4903b3137b | ||
|
|
868406c95c | ||
|
|
7122b16a7a | ||
|
|
b9fbac4ddc | ||
|
|
33e948d1e7 | ||
|
|
9f551f151e | ||
|
|
9447cb37c0 | ||
|
|
8183b324a0 | ||
|
|
a6a25de19c | ||
|
|
51a2a1b391 | ||
|
|
d7ab81ddcf | ||
|
|
8da07d4e93 | ||
|
|
20b93f6853 | ||
|
|
2ce204c1bc | ||
|
|
d653b7fa3f | ||
|
|
0dcb42efa2 | ||
|
|
820d254553 | ||
|
|
715ef5711b | ||
|
|
ea3244367e | ||
|
|
f26768bcf7 | ||
|
|
87bb92fac0 | ||
|
|
01dc90bfa7 | ||
|
|
c37e6384a5 | ||
|
|
70fc955d37 | ||
|
|
a44f226e8a | ||
|
|
edafde5810 | ||
|
|
54cd037cd2 | ||
|
|
2ebf67d44d | ||
|
|
d2d294b93c | ||
|
|
39eb91ac48 | ||
|
|
53376d94d9 | ||
|
|
f51973bb7b | ||
|
|
33d1756fdd | ||
|
|
63931763c4 | ||
|
|
bdb956ec84 | ||
|
|
41b48d1104 | ||
|
|
57fef26726 | ||
|
|
ea94dc4548 | ||
|
|
13a7232bc5 | ||
|
|
5fea8a0041 | ||
|
|
2333c86a08 | ||
|
|
cbfc1b2875 | ||
|
|
62e10afe7b | ||
|
|
d542c716f9 | ||
|
|
7eeaefaa65 | ||
|
|
bd55356881 | ||
|
|
df986a2e36 | ||
|
|
8a2aca79f7 | ||
|
|
e1cc0dba28 | ||
|
|
660dce0fa7 | ||
|
|
ee343ba469 | ||
|
|
d038738008 | ||
|
|
70f9995041 | ||
|
|
6a1aaec7c2 | ||
|
|
50e486bf5f | ||
|
|
bc15a9a934 | ||
|
|
573dbcf882 | ||
|
|
94acd9afa4 | ||
|
|
65d25a72a9 | ||
|
|
97eb2fce44 | ||
|
|
701829001c | ||
|
|
8aeeca111c | ||
|
|
c43936ce81 | ||
|
|
f35eb0c922 | ||
|
|
b2b0626b44 | ||
|
|
40f108d7ca | ||
|
|
6570f74b7e | ||
|
|
16f216cf84 | ||
|
|
69551edeff | ||
|
|
7192796e89 | ||
|
|
1d1ee7972f | ||
|
|
8bd6b86018 | ||
|
|
6abb542271 | ||
|
|
2aceae3078 | ||
|
|
65b200a68e | ||
|
|
de0c881944 | ||
|
|
d0ef01d79b | ||
|
|
9457236e99 | ||
|
|
d43b4fc1c4 | ||
|
|
e9750429eb | ||
|
|
b71b268b08 | ||
|
|
a708d22b27 | ||
|
|
a9a3b08ad6 | ||
|
|
1d1e8679e4 | ||
|
|
142d445ed0 | ||
|
|
375094862c | ||
|
|
58a72cef0f | ||
|
|
4ceff127a7 | ||
|
|
c07b811cf8 | ||
|
|
b16fa70774 | ||
|
|
b343165644 | ||
|
|
02dff0bb9b | ||
|
|
ac3be75082 | ||
|
|
a1663b865a | ||
|
|
c97a416d1e | ||
|
|
d28ab42303 | ||
|
|
fbb2bba3b6 | ||
|
|
08eda22587 | ||
|
|
a4045eebd3 | ||
|
|
a57cbccbb4 | ||
|
|
2221f118bb | ||
|
|
2cc3eb4ebb | ||
|
|
3a064a22bd | ||
|
|
ee764ff215 | ||
|
|
402677b69b | ||
|
|
97f63434fd | ||
|
|
07d0cf07e3 | ||
|
|
4be107439a | ||
|
|
89ef03a859 | ||
|
|
905384034d | ||
|
|
bf826da1ae | ||
|
|
6aac35181b | ||
|
|
efbf60dcdd | ||
|
|
ebb166a7b9 | ||
|
|
7aced28262 | ||
|
|
ae3e793498 | ||
|
|
4eb98b18a1 | ||
|
|
128e7e5f11 | ||
|
|
d224b2dea0 | ||
|
|
16e823b8d3 | ||
|
|
f2f11e3472 | ||
|
|
a3549c80a9 | ||
|
|
2b9c347ed6 | ||
|
|
98ccee866d | ||
|
|
911849c6dd | ||
|
|
cce3bb2c4a | ||
|
|
bcc117cd0d | ||
|
|
8e20a68ae2 | ||
|
|
736c146f25 | ||
|
|
6398ef1cc6 | ||
|
|
83e6a289be | ||
|
|
5662118b01 | ||
|
|
22dfc7b40d | ||
|
|
a51e387453 | ||
|
|
c7d2ec7311 | ||
|
|
bb9ac5b67b | ||
|
|
f93494adb2 | ||
|
|
7201520411 | ||
|
|
2a1e65e1af | ||
|
|
da318c3339 | ||
|
|
7149b6243f | ||
|
|
11f5a28c04 | ||
|
|
9cc36c7a50 | ||
|
|
861c135cc6 | ||
|
|
3b0275c411 | ||
|
|
cad1b51202 | ||
|
|
f50acd29f4 | ||
|
|
af11d595d8 | ||
|
|
44994d5b21 | ||
|
|
592fd2d846 | ||
|
|
e96be1fca2 | ||
|
|
ee44e2b5ac | ||
|
|
323bfc4d2e | ||
|
|
dca45585ca | ||
|
|
8b5918d221 | ||
|
|
9c227c1f59 | ||
|
|
2ad4499a6f | ||
|
|
33a5bf9ab3 | ||
|
|
de06d1c2d3 | ||
|
|
72855bc030 | ||
|
|
b185ea6899 | ||
|
|
1e0127e97e | ||
|
|
5bdbc98d68 | ||
|
|
e1aeb3da31 | ||
|
|
283b09e8f1 | ||
|
|
b03c96249b | ||
|
|
2971445090 | ||
|
|
55c23419cd | ||
|
|
c4b9d9503a | ||
|
|
823b73d9f0 | ||
|
|
31632d25a4 | ||
|
|
c59951a39c | ||
|
|
d9140d7b5b | ||
|
|
90865a73b5 | ||
|
|
cc45233223 | ||
|
|
5d12d601ae | ||
|
|
88f40438af | ||
|
|
0a9ec06841 | ||
|
|
a0ca6ec4b8 | ||
|
|
eb6cf96470 | ||
|
|
2ca0616771 | ||
|
|
bc85144e60 | ||
|
|
236e31c841 | ||
|
|
7a15139aa6 | ||
|
|
fb6ccfd011 | ||
|
|
ef85e2b690 | ||
|
|
bb734230aa | ||
|
|
aa31c31955 | ||
|
|
1a89822f36 | ||
|
|
fc9e0eca36 | ||
|
|
0010dcb1c6 | ||
|
|
0ab8611f29 | ||
|
|
9e02408a7e | ||
|
|
1bd0db05e6 | ||
|
|
fb438f2ca7 | ||
|
|
d4de7f2ec3 | ||
|
|
98ee1943f9 | ||
|
|
4a57c0fba3 | ||
|
|
db0698d515 | ||
|
|
712cc9ff1e | ||
|
|
501be0e4e7 | ||
|
|
19b7613eea | ||
|
|
3d9cd8f6a9 | ||
|
|
c8add22d3d | ||
|
|
69d8cc8fa0 | ||
|
|
8b8a4b3837 | ||
|
|
c45006f219 | ||
|
|
bc306a37c9 | ||
|
|
aab0487020 | ||
|
|
ca892ce188 | ||
|
|
cff5dc20e5 | ||
|
|
0050c45f09 | ||
|
|
2a8f4634e5 | ||
|
|
81da704e6f | ||
|
|
c82b5e11b1 | ||
|
|
c36d9eb9cf | ||
|
|
2f21603e8e | ||
|
|
586f9964b5 | ||
|
|
124670bb98 | ||
|
|
baf6a8c2f4 | ||
|
|
cd345afc54 | ||
|
|
246ec4d6eb | ||
|
|
569eeae181 | ||
|
|
5a0ef56074 | ||
|
|
09bd4321fb | ||
|
|
4939e37210 | ||
|
|
9463d7587f | ||
|
|
7d2bf41bec | ||
|
|
93bb8cc18d | ||
|
|
449d9e623f | ||
|
|
3a50d9be3c | ||
|
|
693f80fe6a | ||
|
|
8cfc73213a | ||
|
|
6fbae768a4 | ||
|
|
8e9d0127e0 | ||
|
|
b92d70769a | ||
|
|
75b001cf6a | ||
|
|
479ca7884e | ||
|
|
00d8910118 | ||
|
|
bd28c7ab05 | ||
|
|
720279df65 | ||
|
|
2d4ec648b8 | ||
|
|
704fdaca4a | ||
|
|
b134136e51 | ||
|
|
5ca717d7e0 | ||
|
|
7068ee5e5a | ||
|
|
9f770473e5 | ||
|
|
5fe0f5750a | ||
|
|
b8ce225ccc | ||
|
|
f21f7388b7 | ||
|
|
a1354f231a | ||
|
|
4bc1c33e81 | ||
|
|
32bcbab523 | ||
|
|
b94ae21e11 | ||
|
|
a92ebd75c2 | ||
|
|
e6d3929fc9 | ||
|
|
a68e13af35 | ||
|
|
324c3ace8f | ||
|
|
3a9d5d9085 | ||
|
|
89a6eaf0ce | ||
|
|
027c4a0f4d | ||
|
|
81990c6768 | ||
|
|
ba02aa0e49 | ||
|
|
5adbdbd920 | ||
|
|
b3b211d956 | ||
|
|
279bd6d82d | ||
|
|
5dced28228 | ||
|
|
51bdaf64e4 | ||
|
|
9c8e0ebedc | ||
|
|
e1bea8a8c8 | ||
|
|
a6d3820104 | ||
|
|
36c793a5fb | ||
|
|
aade8a91c3 | ||
|
|
3fe7c3de1a | ||
|
|
596a5aed8d | ||
|
|
19b3675701 | ||
|
|
1713d0fd1e | ||
|
|
3a95a302c0 | ||
|
|
e738ba2334 | ||
|
|
c813215f3e | ||
|
|
0f63a2d271 | ||
|
|
133c34de53 | ||
|
|
a3ca735b12 | ||
|
|
519ab6a0cd | ||
|
|
0c691a540a | ||
|
|
209f78717f | ||
|
|
a02be80ac1 | ||
|
|
8a8b906b6f | ||
|
|
b88ddde417 | ||
|
|
666c2656ec | ||
|
|
7786776ed8 | ||
|
|
2c60b38edf | ||
|
|
922f586706 | ||
|
|
2bc8e445ce | ||
|
|
058507ac39 | ||
|
|
f0dc51f10b | ||
|
|
c7ad1c5ee6 | ||
|
|
d7913ae2b8 | ||
|
|
21e59072d0 | ||
|
|
f905b17b3a | ||
|
|
8f024f8c7b | ||
|
|
79b438f7f7 | ||
|
|
4ed9269dc5 | ||
|
|
050779fc2c | ||
|
|
4644b4c62b | ||
|
|
d48c7d7479 | ||
|
|
dea5ed38c5 | ||
|
|
0ea89306f2 | ||
|
|
efccc28312 | ||
|
|
2502b123aa | ||
|
|
c56c9a8895 | ||
|
|
3f6a1b571a | ||
|
|
c61b66496a | ||
|
|
157f73cbe9 | ||
|
|
64bb9fc513 | ||
|
|
0a6ec21c95 | ||
|
|
74c49f041d | ||
|
|
f35abdefe5 | ||
|
|
43a11f0e4c | ||
|
|
a5a54e324d | ||
|
|
53adb6c1c1 | ||
|
|
a0c8ff72fb | ||
|
|
599242aa2a | ||
|
|
3e0913b437 | ||
|
|
54cabd98b4 | ||
|
|
cbc5c571b3 | ||
|
|
beea640d49 | ||
|
|
65b8a7f988 | ||
|
|
b86173d6c0 | ||
|
|
09c4b2a28e | ||
|
|
6e8545e4ca | ||
|
|
e0a6c7842b | ||
|
|
b323cb40ae | ||
|
|
95a35f9988 | ||
|
|
a7f3bad191 | ||
|
|
ed20b67b92 | ||
|
|
42a3f75d94 | ||
|
|
6de882d12e | ||
|
|
6587014e8d | ||
|
|
36a07b251a | ||
|
|
c48eed7f77 | ||
|
|
48f3c3b35b | ||
|
|
0d6f62dd70 | ||
|
|
77cc5c99ed | ||
|
|
513134fd65 | ||
|
|
906be45758 | ||
|
|
c4a15e77e4 | ||
|
|
baffdfdd5a | ||
|
|
827afb5a4d | ||
|
|
26939b2cd3 | ||
|
|
34d05c5416 | ||
|
|
b9376e02fa | ||
|
|
2d5c59ddba | ||
|
|
11864617de | ||
|
|
d5bff76a62 |
No files matched your search
@@ -0,0 +1 @@
|
||||
github: Flaminel
|
||||
@@ -0,0 +1,73 @@
|
||||
name: Bug report
|
||||
description: File a bug report if something is not working right.
|
||||
title: "[BUG] "
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to improve Cleanuparr!
|
||||
- type: checkboxes
|
||||
id: duplicate-check
|
||||
attributes:
|
||||
label: "Duplicate check"
|
||||
options:
|
||||
- label: I have searched for existing issues and confirmed this is not a duplicate.
|
||||
required: true
|
||||
- type: checkboxes
|
||||
id: init
|
||||
attributes:
|
||||
label: "Before submitting a bug report, I have:"
|
||||
options:
|
||||
- label: Reviewed the documentation.
|
||||
required: true
|
||||
- label: Ensured I am using ghcr.io/cleanuparr/cleanuparr docker repository.
|
||||
required: true
|
||||
- label: Ensured I am using the latest version.
|
||||
required: true
|
||||
- label: Enabled verbose logging.
|
||||
required: true
|
||||
- type: textarea
|
||||
id: what-happened
|
||||
attributes:
|
||||
label: What is the behavior?
|
||||
description: If applicable, mention what you expected to happen.
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: os
|
||||
attributes:
|
||||
label: Which operating system do you use?
|
||||
multiple: false
|
||||
options:
|
||||
- Windows
|
||||
- Linux
|
||||
- MacOS
|
||||
- Unraid
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: deployment
|
||||
attributes:
|
||||
label: What type of deployment do you use?
|
||||
multiple: false
|
||||
options:
|
||||
- Docker container
|
||||
- Binary (executable) file
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Relevant log output
|
||||
description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks.
|
||||
render: shell
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Anything else?
|
||||
description: |
|
||||
Links? References? Anything that will give us more context about the issue you are encountering!
|
||||
|
||||
Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in.
|
||||
validations:
|
||||
required: false
|
||||
@@ -0,0 +1,38 @@
|
||||
name: Feature request
|
||||
description: File a feature request.
|
||||
title: "[FEATURE] "
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to improve Cleanuparr!
|
||||
- type: checkboxes
|
||||
id: duplicate-check
|
||||
attributes:
|
||||
label: "Duplicate check"
|
||||
options:
|
||||
- label: I have searched for existing issues and confirmed this is not a duplicate.
|
||||
required: true
|
||||
- type: checkboxes
|
||||
id: init
|
||||
attributes:
|
||||
label: Implementation & testing support
|
||||
description: The requester should help answer questions, provide support for the implementation and test changes.
|
||||
options:
|
||||
- label: I understand I must be available to assist with implementation questions and to test the feature before being released.
|
||||
required: true
|
||||
- label: I understand that joining the Discord server may be necessary for better coordination and faster communication.
|
||||
required: true
|
||||
- label: I understand that failure to assist in the development process of my request will result in the request being closed.
|
||||
required: true
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: What would you like to see implemented next?
|
||||
description: |
|
||||
Links? References? Anything that will give us more context about your idea!
|
||||
|
||||
Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in.
|
||||
validations:
|
||||
required: true
|
||||
@@ -0,0 +1,39 @@
|
||||
name: Help request
|
||||
description: Ask a question to receive help.
|
||||
title: "[HELP] "
|
||||
labels: ["question"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
If you are experiencing unexpected behavior, please consider submitting a bug report instead.
|
||||
- type: checkboxes
|
||||
id: duplicate-check
|
||||
attributes:
|
||||
label: "Duplicate check"
|
||||
options:
|
||||
- label: I have searched for existing issues and confirmed this is not a duplicate.
|
||||
required: true
|
||||
- type: checkboxes
|
||||
id: init
|
||||
attributes:
|
||||
label: "Before submitting a help request, I have:"
|
||||
options:
|
||||
- label: Reviewed the documentation.
|
||||
required: true
|
||||
- label: Ensured I am using ghcr.io/cleanuparr/cleanuparr docker repository.
|
||||
required: true
|
||||
- label: Ensured I am using the latest version.
|
||||
required: true
|
||||
- label: Enabled verbose logging.
|
||||
required: true
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: How can we help?
|
||||
description: |
|
||||
Links? References? Anything that will give us more context about your question!
|
||||
|
||||
Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in.
|
||||
validations:
|
||||
required: true
|
||||
@@ -0,0 +1,8 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Discord Community
|
||||
url: https://discord.gg/SCtMCgtsc4
|
||||
about: Join our Discord for real-time help and discussions
|
||||
- name: Documentation
|
||||
url: https://cleanuparr.github.io/Cleanuparr/
|
||||
about: Check the documentation for configurations and usage guidelines
|
||||
@@ -0,0 +1,24 @@
|
||||
## Description
|
||||
<!-- Brief description of what this PR does -->
|
||||
|
||||
## Related Issue
|
||||
Closes #ISSUE_NUMBER
|
||||
|
||||
## Type of Change
|
||||
- [ ] Bug fix
|
||||
- [ ] New feature
|
||||
- [ ] Breaking change
|
||||
- [ ] Documentation update
|
||||
|
||||
## Testing
|
||||
<!-- Describe how you tested your changes -->
|
||||
|
||||
## Screenshots (if applicable)
|
||||
<!-- Add screenshots here -->
|
||||
|
||||
## Checklist
|
||||
- [ ] I have read the [Contributing Guide](../CONTRIBUTING.md)
|
||||
- [ ] I have announced my intent to work on this and received approval
|
||||
- [ ] My code follows the project's code standards
|
||||
- [ ] I have tested my changes thoroughly
|
||||
- [ ] I have updated relevant documentation
|
||||
@@ -0,0 +1,30 @@
|
||||
name: 'Get Vault Secrets'
|
||||
description: 'Retrieves secrets from HashiCorp Vault using AppRole authentication'
|
||||
inputs:
|
||||
vault_host:
|
||||
description: 'Vault server URL'
|
||||
required: true
|
||||
vault_role_id:
|
||||
description: 'Vault AppRole Role ID'
|
||||
required: true
|
||||
vault_secret_id:
|
||||
description: 'Vault AppRole Secret ID'
|
||||
required: true
|
||||
secrets:
|
||||
description: 'Secrets to retrieve (multiline string, one per line in format: path | output_name)'
|
||||
required: true
|
||||
default: |
|
||||
secrets/data/github repo_readonly_pat | REPO_READONLY_PAT
|
||||
secrets/data/github packages_pat | PACKAGES_PAT
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Get vault secrets
|
||||
uses: hashicorp/vault-action@v4
|
||||
with:
|
||||
url: ${{ inputs.vault_host }}
|
||||
method: approle
|
||||
roleId: ${{ inputs.vault_role_id }}
|
||||
secretId: ${{ inputs.vault_secret_id }}
|
||||
secrets: ${{ inputs.secrets }}
|
||||
@@ -0,0 +1,25 @@
|
||||
changelog:
|
||||
exclude:
|
||||
labels:
|
||||
- duplicate
|
||||
- invalid
|
||||
- wontfix
|
||||
categories:
|
||||
- title: Breaking Changes
|
||||
labels:
|
||||
- breaking
|
||||
- title: Features
|
||||
labels:
|
||||
- enhancement
|
||||
- title: Bug Fixes
|
||||
labels:
|
||||
- bug
|
||||
- title: Documentation
|
||||
labels:
|
||||
- documentation
|
||||
- title: Maintenance
|
||||
labels:
|
||||
- chore
|
||||
- title: Other Changes
|
||||
labels:
|
||||
- "*"
|
||||
@@ -0,0 +1,271 @@
|
||||
name: Build Docker Images
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'code/**'
|
||||
workflow_call:
|
||||
inputs:
|
||||
push_docker:
|
||||
description: 'Push Docker image to registry'
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
app_version:
|
||||
description: 'Application version'
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
# Cancel in-progress runs for the same PR
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
env:
|
||||
REGISTRY_IMAGE: ghcr.io/cleanuparr/cleanuparr
|
||||
|
||||
jobs:
|
||||
# Compute tags, version, and push decision for downstream jobs
|
||||
prepare:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
outputs:
|
||||
tags: ${{ steps.build-info.outputs.tags }}
|
||||
version: ${{ steps.build-info.outputs.version }}
|
||||
version_docker_tag: ${{ steps.build-info.outputs.version_docker_tag }}
|
||||
branch: ${{ steps.build-info.outputs.branch }}
|
||||
push: ${{ steps.build-info.outputs.push }}
|
||||
github_sha: ${{ github.sha }}
|
||||
steps:
|
||||
- name: Initialize build info
|
||||
id: build-info
|
||||
timeout-minutes: 1
|
||||
run: |
|
||||
githubHeadRef="${{ github.head_ref }}"
|
||||
githubRef="${{ github.ref }}"
|
||||
inputVersion="${{ inputs.app_version }}"
|
||||
latestDockerTag=""
|
||||
versionDockerTag=""
|
||||
majorVersionDockerTag=""
|
||||
minorVersionDockerTag=""
|
||||
version="0.0.1"
|
||||
|
||||
if [[ -n "$inputVersion" ]]; then
|
||||
# Version provided via input (manual release)
|
||||
branch="main"
|
||||
latestDockerTag="latest"
|
||||
versionDockerTag="$inputVersion"
|
||||
version="$inputVersion"
|
||||
|
||||
# Extract major and minor versions for additional tags
|
||||
if [[ "$versionDockerTag" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+) ]]; then
|
||||
majorVersionDockerTag="${BASH_REMATCH[1]}"
|
||||
minorVersionDockerTag="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}"
|
||||
fi
|
||||
elif [[ "$githubRef" =~ ^"refs/tags/" ]]; then
|
||||
# Tag push
|
||||
branch=${githubRef##*/}
|
||||
latestDockerTag="latest"
|
||||
versionDockerTag=${branch#v}
|
||||
version=${branch#v}
|
||||
|
||||
# Extract major and minor versions for additional tags
|
||||
if [[ "$versionDockerTag" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+) ]]; then
|
||||
majorVersionDockerTag="${BASH_REMATCH[1]}"
|
||||
minorVersionDockerTag="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}"
|
||||
fi
|
||||
else
|
||||
if [[ -z "$githubHeadRef" ]]; then
|
||||
# Main branch
|
||||
branch=${githubRef##*/}
|
||||
versionDockerTag="$branch"
|
||||
else
|
||||
# Pull request
|
||||
branch=$githubHeadRef
|
||||
versionDockerTag="$branch"
|
||||
fi
|
||||
fi
|
||||
|
||||
githubTags=""
|
||||
if [ -n "$latestDockerTag" ]; then
|
||||
githubTags="$githubTags,$REGISTRY_IMAGE:$latestDockerTag"
|
||||
fi
|
||||
if [ -n "$versionDockerTag" ]; then
|
||||
githubTags="$githubTags,$REGISTRY_IMAGE:$versionDockerTag"
|
||||
fi
|
||||
if [ -n "$minorVersionDockerTag" ]; then
|
||||
githubTags="$githubTags,$REGISTRY_IMAGE:$minorVersionDockerTag"
|
||||
fi
|
||||
if [ -n "$majorVersionDockerTag" ]; then
|
||||
githubTags="$githubTags,$REGISTRY_IMAGE:$majorVersionDockerTag"
|
||||
fi
|
||||
|
||||
githubTags="${githubTags#,}"
|
||||
|
||||
# Determine push decision
|
||||
push="${{ github.event_name == 'pull_request' || inputs.push_docker == true }}"
|
||||
|
||||
echo "tags=$githubTags" >> $GITHUB_OUTPUT
|
||||
echo "version=$version" >> $GITHUB_OUTPUT
|
||||
echo "version_docker_tag=$versionDockerTag" >> $GITHUB_OUTPUT
|
||||
echo "branch=$branch" >> $GITHUB_OUTPUT
|
||||
echo "push=$push" >> $GITHUB_OUTPUT
|
||||
|
||||
# Build each platform in parallel
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [prepare]
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
platform:
|
||||
- linux/amd64
|
||||
- linux/arm64
|
||||
steps:
|
||||
- name: Prepare platform pair
|
||||
run: |
|
||||
platform=${{ matrix.platform }}
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
||||
|
||||
- name: Get vault secrets
|
||||
uses: hashicorp/vault-action@v4
|
||||
with:
|
||||
url: ${{ secrets.VAULT_HOST }}
|
||||
method: approle
|
||||
roleId: ${{ secrets.VAULT_ROLE_ID }}
|
||||
secretId: ${{ secrets.VAULT_SECRET_ID }}
|
||||
secrets:
|
||||
secrets/data/github repo_readonly_pat | REPO_READONLY_PAT;
|
||||
secrets/data/github packages_pat | PACKAGES_PAT
|
||||
|
||||
- name: Checkout target repository
|
||||
uses: actions/checkout@v7
|
||||
timeout-minutes: 1
|
||||
with:
|
||||
repository: ${{ github.repository }}
|
||||
ref: ${{ needs.prepare.outputs.branch }}
|
||||
token: ${{ env.REPO_READONLY_PAT }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
timeout-minutes: 5
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: needs.prepare.outputs.push == 'true'
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push by digest
|
||||
if: needs.prepare.outputs.push == 'true'
|
||||
id: build-push
|
||||
timeout-minutes: 30
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: ${{ github.workspace }}/code
|
||||
file: ${{ github.workspace }}/code/Dockerfile
|
||||
provenance: false
|
||||
labels: |
|
||||
commit=sha-${{ needs.prepare.outputs.github_sha }}
|
||||
version=${{ needs.prepare.outputs.version_docker_tag }}
|
||||
build-args: |
|
||||
VERSION=${{ needs.prepare.outputs.version }}
|
||||
PACKAGES_USERNAME=${{ secrets.PACKAGES_USERNAME }}
|
||||
PACKAGES_PAT=${{ env.PACKAGES_PAT }}
|
||||
platforms: ${{ matrix.platform }}
|
||||
outputs: type=image,"name=${{ env.REGISTRY_IMAGE }}",push-by-digest=true,name-canonical=true,push=true
|
||||
cache-from: type=gha,scope=build-${{ env.PLATFORM_PAIR }}
|
||||
cache-to: type=gha,scope=build-${{ env.PLATFORM_PAIR }},mode=max
|
||||
|
||||
- name: Build (no push)
|
||||
if: needs.prepare.outputs.push != 'true'
|
||||
timeout-minutes: 30
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: ${{ github.workspace }}/code
|
||||
file: ${{ github.workspace }}/code/Dockerfile
|
||||
provenance: false
|
||||
labels: |
|
||||
commit=sha-${{ needs.prepare.outputs.github_sha }}
|
||||
version=${{ needs.prepare.outputs.version_docker_tag }}
|
||||
build-args: |
|
||||
VERSION=${{ needs.prepare.outputs.version }}
|
||||
PACKAGES_USERNAME=${{ secrets.PACKAGES_USERNAME }}
|
||||
PACKAGES_PAT=${{ env.PACKAGES_PAT }}
|
||||
platforms: ${{ matrix.platform }}
|
||||
push: false
|
||||
cache-from: type=gha,scope=build-${{ env.PLATFORM_PAIR }}
|
||||
cache-to: type=gha,scope=build-${{ env.PLATFORM_PAIR }},mode=max
|
||||
|
||||
- name: Export digest
|
||||
if: needs.prepare.outputs.push == 'true'
|
||||
run: |
|
||||
mkdir -p ${{ runner.temp }}/digests
|
||||
digest="${{ steps.build-push.outputs.digest }}"
|
||||
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
if: needs.prepare.outputs.push == 'true'
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: digests-${{ env.PLATFORM_PAIR }}
|
||||
path: ${{ runner.temp }}/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
# Create multi-platform manifest and push with final tags
|
||||
merge:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [prepare, build]
|
||||
if: needs.prepare.outputs.push == 'true'
|
||||
steps:
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: ${{ runner.temp }}/digests
|
||||
pattern: digests-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Create manifest list and push
|
||||
timeout-minutes: 5
|
||||
working-directory: ${{ runner.temp }}/digests
|
||||
run: |
|
||||
tags="${{ needs.prepare.outputs.tags }}"
|
||||
tag_args=""
|
||||
IFS=',' read -ra TAG_ARRAY <<< "$tags"
|
||||
for tag in "${TAG_ARRAY[@]}"; do
|
||||
tag=$(echo "$tag" | xargs)
|
||||
if [ -n "$tag" ]; then
|
||||
tag_args="$tag_args -t $tag"
|
||||
fi
|
||||
done
|
||||
|
||||
docker buildx imagetools create $tag_args \
|
||||
$(printf '${{ env.REGISTRY_IMAGE }}@sha256:%s ' *)
|
||||
|
||||
- name: Inspect image
|
||||
run: |
|
||||
tags="${{ needs.prepare.outputs.tags }}"
|
||||
first_tag=$(echo "$tags" | tr ',' '\n' | grep -v '^$' | head -1 | xargs)
|
||||
docker buildx imagetools inspect "$first_tag"
|
||||
@@ -0,0 +1,130 @@
|
||||
name: Build Executables
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
app_version:
|
||||
description: 'Application version'
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# Build for each platform in parallel using matrix strategy
|
||||
build-platform:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: true
|
||||
matrix:
|
||||
include:
|
||||
- runtime: win-x64
|
||||
platform: win-amd64
|
||||
- runtime: linux-x64
|
||||
platform: linux-amd64
|
||||
- runtime: linux-arm64
|
||||
platform: linux-arm64
|
||||
- runtime: osx-x64
|
||||
platform: osx-amd64
|
||||
- runtime: osx-arm64
|
||||
platform: osx-arm64
|
||||
|
||||
steps:
|
||||
- name: Set variables
|
||||
run: |
|
||||
ref=${{ github.ref }}
|
||||
|
||||
# Use input version if provided, otherwise determine from ref
|
||||
if [[ -n "${{ inputs.app_version }}" ]]; then
|
||||
appVersion="${{ inputs.app_version }}"
|
||||
releaseVersion="v$appVersion"
|
||||
elif [[ "$ref" =~ ^refs/tags/ ]]; then
|
||||
releaseVersion=${ref##refs/tags/}
|
||||
appVersion=${releaseVersion#v}
|
||||
else
|
||||
releaseVersion="dev-$(date +%Y%m%d-%H%M%S)"
|
||||
appVersion="0.0.1-dev"
|
||||
fi
|
||||
|
||||
repoFullName=${{ github.repository }}
|
||||
repositoryName=${repoFullName#*/}
|
||||
|
||||
echo "githubRepository=${{ github.repository }}" >> $GITHUB_ENV
|
||||
echo "githubRepositoryName=$repositoryName" >> $GITHUB_ENV
|
||||
echo "releaseVersion=$releaseVersion" >> $GITHUB_ENV
|
||||
echo "appVersion=$appVersion" >> $GITHUB_ENV
|
||||
echo "executableName=Cleanuparr.Api" >> $GITHUB_ENV
|
||||
|
||||
- name: Get vault secrets
|
||||
uses: hashicorp/vault-action@v4
|
||||
with:
|
||||
url: ${{ secrets.VAULT_HOST }}
|
||||
method: approle
|
||||
roleId: ${{ secrets.VAULT_ROLE_ID }}
|
||||
secretId: ${{ secrets.VAULT_SECRET_ID }}
|
||||
secrets:
|
||||
secrets/data/github repo_readonly_pat | REPO_READONLY_PAT;
|
||||
secrets/data/github packages_pat | PACKAGES_PAT
|
||||
|
||||
- name: Checkout target repository
|
||||
uses: actions/checkout@v7
|
||||
timeout-minutes: 1
|
||||
with:
|
||||
repository: ${{ env.githubRepository }}
|
||||
ref: ${{ github.ref_name }}
|
||||
token: ${{ env.REPO_READONLY_PAT }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup dotnet
|
||||
uses: actions/setup-dotnet@v6
|
||||
with:
|
||||
dotnet-version: 10.0.200
|
||||
|
||||
- name: Cache NuGet packages
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.nuget/packages
|
||||
key: ${{ runner.os }}-nuget-${{ hashFiles('**/packages.lock.json', '**/*.csproj') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-nuget-
|
||||
|
||||
- name: Download frontend artifact
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: frontend-dist
|
||||
path: code/frontend/dist/ui/browser
|
||||
|
||||
- name: Install dependencies and restore
|
||||
run: |
|
||||
dotnet nuget add source --username ${{ github.repository_owner }} --password ${{ env.PACKAGES_PAT }} --store-password-in-clear-text --name Cleanuparr https://nuget.pkg.github.com/Cleanuparr/index.json
|
||||
dotnet restore code/backend/${{ env.executableName }}/${{ env.executableName }}.csproj
|
||||
|
||||
- name: Copy frontend to backend wwwroot
|
||||
run: |
|
||||
mkdir -p code/backend/${{ env.executableName }}/wwwroot
|
||||
cp -r code/frontend/dist/ui/browser/* code/backend/${{ env.executableName }}/wwwroot/
|
||||
|
||||
- name: Build ${{ matrix.platform }}
|
||||
run: |
|
||||
dotnet publish code/backend/${{ env.executableName }}/${{ env.executableName }}.csproj \
|
||||
-c Release \
|
||||
--runtime ${{ matrix.runtime }} \
|
||||
--self-contained \
|
||||
-o artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-${{ matrix.platform }} \
|
||||
/p:PublishSingleFile=true \
|
||||
/p:Version=${{ env.appVersion }} \
|
||||
/p:DebugSymbols=false
|
||||
|
||||
- name: Zip artifact
|
||||
run: |
|
||||
cd ./artifacts
|
||||
zip -r ./${{ env.githubRepositoryName }}-${{ env.appVersion }}-${{ matrix.platform }}.zip ./${{ env.githubRepositoryName }}-${{ env.appVersion }}-${{ matrix.platform }}/
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: executable-${{ matrix.platform }}
|
||||
path: ./artifacts/*.zip
|
||||
retention-days: 30
|
||||
@@ -0,0 +1,56 @@
|
||||
name: Build Frontend
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
ref:
|
||||
description: 'Git ref to checkout (branch, tag, or SHA). Defaults to github.ref_name.'
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build-frontend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Get vault secrets
|
||||
uses: hashicorp/vault-action@v4
|
||||
with:
|
||||
url: ${{ secrets.VAULT_HOST }}
|
||||
method: approle
|
||||
roleId: ${{ secrets.VAULT_ROLE_ID }}
|
||||
secretId: ${{ secrets.VAULT_SECRET_ID }}
|
||||
secrets:
|
||||
secrets/data/github repo_readonly_pat | REPO_READONLY_PAT
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
timeout-minutes: 1
|
||||
with:
|
||||
repository: ${{ github.repository }}
|
||||
ref: ${{ inputs.ref || github.ref_name }}
|
||||
token: ${{ env.REPO_READONLY_PAT }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '26'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: code/frontend/package-lock.json
|
||||
|
||||
- name: Build frontend
|
||||
run: |
|
||||
cd code/frontend
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
- name: Upload frontend artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: frontend-dist
|
||||
path: code/frontend/dist/ui/browser
|
||||
retention-days: 1
|
||||
@@ -0,0 +1,377 @@
|
||||
name: Build macOS Installers
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
app_version:
|
||||
description: 'Application version'
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
jobs:
|
||||
build-macos-installer:
|
||||
name: Build macOS ${{ matrix.arch }} Installer
|
||||
runs-on: ${{ matrix.runner }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: Intel
|
||||
runner: macos-15-intel
|
||||
runtime: osx-x64
|
||||
min_os_version: "10.15"
|
||||
artifact_suffix: intel
|
||||
- arch: ARM
|
||||
runner: macos-15
|
||||
runtime: osx-arm64
|
||||
min_os_version: "11.0"
|
||||
artifact_suffix: arm64
|
||||
|
||||
steps:
|
||||
- name: Set variables
|
||||
run: |
|
||||
repoFullName=${{ github.repository }}
|
||||
ref=${{ github.ref }}
|
||||
|
||||
# Use input version if provided, otherwise determine from ref
|
||||
if [[ -n "${{ inputs.app_version }}" ]]; then
|
||||
appVersion="${{ inputs.app_version }}"
|
||||
releaseVersion="v$appVersion"
|
||||
elif [[ "$ref" =~ ^refs/tags/ ]]; then
|
||||
releaseVersion=${ref##refs/tags/}
|
||||
appVersion=${releaseVersion#v}
|
||||
else
|
||||
# For manual dispatch, use a default version
|
||||
releaseVersion="dev-$(date +%Y%m%d-%H%M%S)"
|
||||
appVersion="0.0.1-dev"
|
||||
fi
|
||||
|
||||
repositoryName=${repoFullName#*/}
|
||||
|
||||
echo "githubRepository=${{ github.repository }}" >> $GITHUB_ENV
|
||||
echo "githubRepositoryName=$repositoryName" >> $GITHUB_ENV
|
||||
echo "releaseVersion=$releaseVersion" >> $GITHUB_ENV
|
||||
echo "appVersion=$appVersion" >> $GITHUB_ENV
|
||||
echo "executableName=Cleanuparr.Api" >> $GITHUB_ENV
|
||||
|
||||
- name: Get vault secrets
|
||||
uses: hashicorp/vault-action@v4
|
||||
with:
|
||||
url: ${{ secrets.VAULT_HOST }}
|
||||
method: approle
|
||||
roleId: ${{ secrets.VAULT_ROLE_ID }}
|
||||
secretId: ${{ secrets.VAULT_SECRET_ID }}
|
||||
secrets:
|
||||
secrets/data/github repo_readonly_pat | REPO_READONLY_PAT;
|
||||
secrets/data/github packages_pat | PACKAGES_PAT
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
repository: ${{ env.githubRepository }}
|
||||
ref: ${{ github.ref_name }}
|
||||
token: ${{ env.REPO_READONLY_PAT }}
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Download frontend artifact
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: frontend-dist
|
||||
path: code/frontend/dist/ui/browser
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v6
|
||||
with:
|
||||
dotnet-version: 10.0.200
|
||||
|
||||
- name: Restore .NET dependencies
|
||||
run: |
|
||||
dotnet nuget add source --username ${{ github.repository_owner }} --password ${{ env.PACKAGES_PAT }} --store-password-in-clear-text --name Cleanuparr https://nuget.pkg.github.com/Cleanuparr/index.json
|
||||
dotnet restore code/backend/${{ env.executableName }}/${{ env.executableName }}.csproj
|
||||
|
||||
- name: Build macOS ${{ matrix.arch }} executable
|
||||
run: |
|
||||
# Clean any existing output directory
|
||||
rm -rf dist
|
||||
mkdir -p dist/temp
|
||||
|
||||
# Build to a temporary location
|
||||
dotnet publish code/backend/${{ env.executableName }}/${{ env.executableName }}.csproj \
|
||||
-c Release \
|
||||
--runtime ${{ matrix.runtime }} \
|
||||
--self-contained true \
|
||||
-o dist/temp \
|
||||
/p:PublishSingleFile=true \
|
||||
/p:Version=${{ env.appVersion }} \
|
||||
/p:DebugType=None \
|
||||
/p:DebugSymbols=false \
|
||||
/p:UseAppHost=true \
|
||||
/p:EnableMacOSCodeSign=false \
|
||||
/p:CodeSignOnCopy=false \
|
||||
/p:_CodeSignDuringBuild=false \
|
||||
/p:PublishTrimmed=false \
|
||||
/p:TrimMode=link
|
||||
|
||||
# Create proper app bundle structure
|
||||
mkdir -p dist/Cleanuparr.app/Contents/MacOS
|
||||
|
||||
# Copy the built executable (note: AssemblyName is "Cleanuparr" not "Cleanuparr.Api")
|
||||
cp dist/temp/Cleanuparr dist/Cleanuparr.app/Contents/MacOS/Cleanuparr
|
||||
|
||||
# Copy frontend directly to where it belongs in the app bundle
|
||||
mkdir -p dist/Cleanuparr.app/Contents/MacOS/wwwroot
|
||||
cp -r code/frontend/dist/ui/browser/* dist/Cleanuparr.app/Contents/MacOS/wwwroot/
|
||||
|
||||
# Copy any additional runtime files if they exist
|
||||
if [ -d "dist/temp" ]; then
|
||||
find dist/temp -name "*.dylib" -exec cp {} dist/Cleanuparr.app/Contents/MacOS/ \; 2>/dev/null || true
|
||||
find dist/temp -name "createdump" -exec cp {} dist/Cleanuparr.app/Contents/MacOS/ \; 2>/dev/null || true
|
||||
fi
|
||||
|
||||
- name: Post-build setup
|
||||
run: |
|
||||
# Make sure the executable is actually executable
|
||||
chmod +x dist/Cleanuparr.app/Contents/MacOS/Cleanuparr
|
||||
|
||||
# Remove any .pdb files that might have been created
|
||||
find dist/Cleanuparr.app/Contents/MacOS -name "*.pdb" -delete 2>/dev/null || true
|
||||
|
||||
echo "Checking architecture of built binary:"
|
||||
file dist/Cleanuparr.app/Contents/MacOS/Cleanuparr
|
||||
if command -v lipo >/dev/null 2>&1; then
|
||||
lipo -info dist/Cleanuparr.app/Contents/MacOS/Cleanuparr
|
||||
fi
|
||||
|
||||
echo "Files in MacOS directory:"
|
||||
ls -la dist/Cleanuparr.app/Contents/MacOS/
|
||||
|
||||
- name: Create macOS app bundle structure
|
||||
run: |
|
||||
# Create proper app bundle structure
|
||||
mkdir -p dist/Cleanuparr.app/Contents/{MacOS,Resources,Frameworks}
|
||||
|
||||
# Convert ICO to ICNS for macOS app bundle
|
||||
if command -v iconutil >/dev/null 2>&1; then
|
||||
# Create iconset directory structure
|
||||
mkdir -p Cleanuparr.iconset
|
||||
|
||||
# Use existing PNG files from Logo directory for different sizes
|
||||
cp Logo/16.png Cleanuparr.iconset/icon_16x16.png
|
||||
cp Logo/32.png Cleanuparr.iconset/icon_16x16@2x.png
|
||||
cp Logo/32.png Cleanuparr.iconset/icon_32x32.png
|
||||
cp Logo/64.png Cleanuparr.iconset/icon_32x32@2x.png
|
||||
cp Logo/128.png Cleanuparr.iconset/icon_128x128.png
|
||||
cp Logo/256.png Cleanuparr.iconset/icon_128x128@2x.png
|
||||
cp Logo/256.png Cleanuparr.iconset/icon_256x256.png
|
||||
cp Logo/512.png Cleanuparr.iconset/icon_256x256@2x.png
|
||||
cp Logo/512.png Cleanuparr.iconset/icon_512x512.png
|
||||
cp Logo/1024.png Cleanuparr.iconset/icon_512x512@2x.png
|
||||
|
||||
# Create ICNS file
|
||||
iconutil -c icns Cleanuparr.iconset -o dist/Cleanuparr.app/Contents/Resources/Cleanuparr.icns
|
||||
|
||||
# Clean up iconset directory
|
||||
rm -rf Cleanuparr.iconset
|
||||
fi
|
||||
|
||||
# Create Launch Daemon plist
|
||||
cat > dist/Cleanuparr.app/Contents/Resources/com.cleanuparr.daemon.plist << EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>com.cleanuparr.daemon</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/Applications/Cleanuparr.app/Contents/MacOS/Cleanuparr</string>
|
||||
</array>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
<key>StandardOutPath</key>
|
||||
<string>/var/log/cleanuparr.log</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>/var/log/cleanuparr.error.log</string>
|
||||
<key>WorkingDirectory</key>
|
||||
<string>/Applications/Cleanuparr.app/Contents/MacOS</string>
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>HTTP_PORTS</key>
|
||||
<string>11011</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
EOF
|
||||
|
||||
# Create Info.plist with proper configuration
|
||||
cat > dist/Cleanuparr.app/Contents/Info.plist << EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>Cleanuparr</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.Cleanuparr</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>Cleanuparr</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Cleanuparr</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>${{ env.appVersion }}</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>${{ env.appVersion }}</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>CLNR</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>Cleanuparr</string>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<true/>
|
||||
<key>NSRequiresAquaSystemAppearance</key>
|
||||
<false/>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>${{ matrix.min_os_version }}</string>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string>public.app-category.productivity</string>
|
||||
<key>NSSupportsAutomaticTermination</key>
|
||||
<false/>
|
||||
<key>NSSupportsSuddenTermination</key>
|
||||
<false/>
|
||||
<key>LSBackgroundOnly</key>
|
||||
<false/>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
EOF
|
||||
|
||||
# Clean up temp directory
|
||||
rm -rf dist/temp
|
||||
|
||||
- name: Create PKG installer
|
||||
run: |
|
||||
# Create preinstall script to handle existing installations
|
||||
mkdir -p scripts
|
||||
cat > scripts/preinstall << 'EOF'
|
||||
#!/bin/bash
|
||||
|
||||
# Stop and unload existing launch daemon if it exists
|
||||
if launchctl list | grep -q "com.cleanuparr.daemon"; then
|
||||
launchctl stop com.cleanuparr.daemon 2>/dev/null || true
|
||||
launchctl unload /Library/LaunchDaemons/com.cleanuparr.daemon.plist 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Stop any running instances of Cleanuparr
|
||||
pkill -f "Cleanuparr" || true
|
||||
sleep 2
|
||||
|
||||
# Remove old installation if it exists
|
||||
if [[ -d "/Applications/Cleanuparr.app" ]]; then
|
||||
rm -rf "/Applications/Cleanuparr.app"
|
||||
fi
|
||||
|
||||
# Remove old launch daemon plist if it exists
|
||||
if [[ -f "/Library/LaunchDaemons/com.cleanuparr.daemon.plist" ]]; then
|
||||
rm -f "/Library/LaunchDaemons/com.cleanuparr.daemon.plist"
|
||||
fi
|
||||
|
||||
exit 0
|
||||
EOF
|
||||
|
||||
chmod +x scripts/preinstall
|
||||
|
||||
# Create postinstall script
|
||||
cat > scripts/postinstall << 'EOF'
|
||||
#!/bin/bash
|
||||
|
||||
# Set proper permissions for the app bundle
|
||||
chmod -R 755 /Applications/Cleanuparr.app
|
||||
chmod +x /Applications/Cleanuparr.app/Contents/MacOS/Cleanuparr
|
||||
|
||||
# Install the launch daemon
|
||||
cp /Applications/Cleanuparr.app/Contents/Resources/com.cleanuparr.daemon.plist /Library/LaunchDaemons/
|
||||
chown root:wheel /Library/LaunchDaemons/com.cleanuparr.daemon.plist
|
||||
chmod 644 /Library/LaunchDaemons/com.cleanuparr.daemon.plist
|
||||
|
||||
# Load and start the service
|
||||
launchctl load /Library/LaunchDaemons/com.cleanuparr.daemon.plist
|
||||
launchctl start com.cleanuparr.daemon
|
||||
|
||||
# Wait a moment for service to start
|
||||
sleep 3
|
||||
|
||||
# Display as system notification
|
||||
osascript -e 'display notification "Cleanuparr service started! Visit http://localhost:11011 in your browser." with title "Installation Complete"' 2>/dev/null || true
|
||||
|
||||
exit 0
|
||||
EOF
|
||||
|
||||
chmod +x scripts/postinstall
|
||||
|
||||
# Create uninstall script (optional, for user reference)
|
||||
cat > scripts/uninstall_cleanuparr.sh << 'EOF'
|
||||
#!/bin/bash
|
||||
# Cleanuparr Uninstall Script
|
||||
# Run this script with sudo to completely remove Cleanuparr
|
||||
|
||||
echo "Stopping Cleanuparr service..."
|
||||
launchctl stop com.cleanuparr.daemon 2>/dev/null || true
|
||||
launchctl unload /Library/LaunchDaemons/com.cleanuparr.daemon.plist 2>/dev/null || true
|
||||
|
||||
echo "Removing service files..."
|
||||
rm -f /Library/LaunchDaemons/com.cleanuparr.daemon.plist
|
||||
|
||||
echo "Removing application..."
|
||||
rm -rf /Applications/Cleanuparr.app
|
||||
|
||||
echo "Removing logs..."
|
||||
rm -f /var/log/cleanuparr.log
|
||||
rm -f /var/log/cleanuparr.error.log
|
||||
|
||||
echo "Cleanuparr has been completely removed."
|
||||
echo "Note: Configuration files in /Applications/Cleanuparr.app/Contents/MacOS/config/ have been removed with the app."
|
||||
EOF
|
||||
|
||||
chmod +x scripts/uninstall_cleanuparr.sh
|
||||
|
||||
# Copy uninstall script to app bundle for user access
|
||||
cp scripts/uninstall_cleanuparr.sh dist/Cleanuparr.app/Contents/Resources/
|
||||
|
||||
# Determine package name - if app_version input was provided, it's a release build
|
||||
if [[ -n "${{ inputs.app_version }}" ]] || [[ "${{ github.ref }}" =~ ^refs/tags/ ]]; then
|
||||
pkg_name="Cleanuparr-${{ env.appVersion }}-macos-${{ matrix.artifact_suffix }}.pkg"
|
||||
else
|
||||
pkg_name="Cleanuparr-${{ env.appVersion }}-macos-${{ matrix.artifact_suffix }}-dev.pkg"
|
||||
fi
|
||||
|
||||
# Create PKG installer with better metadata
|
||||
pkgbuild --root dist/ \
|
||||
--scripts scripts/ \
|
||||
--identifier com.Cleanuparr \
|
||||
--version ${{ env.appVersion }} \
|
||||
--install-location /Applications \
|
||||
--ownership preserve \
|
||||
${pkg_name}
|
||||
|
||||
echo "pkgName=${pkg_name}" >> $GITHUB_ENV
|
||||
|
||||
- name: Upload installer as artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: Cleanuparr-macos-${{ matrix.artifact_suffix }}-installer
|
||||
path: '${{ env.pkgName }}'
|
||||
retention-days: 30
|
||||
@@ -0,0 +1,149 @@
|
||||
name: Build Windows Installer
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
app_version:
|
||||
description: 'Application version'
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
ref:
|
||||
description: 'Git ref to checkout (branch, tag, or SHA). Defaults to github.ref_name.'
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build-windows-installer:
|
||||
runs-on: windows-latest
|
||||
|
||||
steps:
|
||||
- name: Set variables
|
||||
shell: pwsh
|
||||
run: |
|
||||
$repoFullName = "${{ github.repository }}"
|
||||
$ref = "${{ github.ref }}"
|
||||
$inputVersion = "${{ inputs.app_version }}"
|
||||
|
||||
# Use input version if provided, otherwise determine from ref
|
||||
if ($inputVersion -ne "") {
|
||||
$appVersion = $inputVersion
|
||||
$releaseVersion = "v$appVersion"
|
||||
} elseif ($ref -match "^refs/tags/") {
|
||||
$releaseVersion = $ref -replace "refs/tags/", ""
|
||||
$appVersion = $releaseVersion -replace "^v", ""
|
||||
} else {
|
||||
# For manual dispatch, use a default version
|
||||
$releaseVersion = "dev-$(Get-Date -Format 'yyyyMMdd-HHmmss')"
|
||||
$appVersion = "0.0.1-dev"
|
||||
}
|
||||
|
||||
$repositoryName = $repoFullName.Split("/")[1]
|
||||
|
||||
echo "githubRepository=${{ github.repository }}" >> $env:GITHUB_ENV
|
||||
echo "githubRepositoryName=$repositoryName" >> $env:GITHUB_ENV
|
||||
echo "releaseVersion=$releaseVersion" >> $env:GITHUB_ENV
|
||||
echo "appVersion=$appVersion" >> $env:GITHUB_ENV
|
||||
echo "APP_VERSION=$appVersion" >> $env:GITHUB_ENV
|
||||
echo "executableName=Cleanuparr.Api" >> $env:GITHUB_ENV
|
||||
|
||||
- name: Get vault secrets
|
||||
uses: hashicorp/vault-action@v4
|
||||
with:
|
||||
url: ${{ secrets.VAULT_HOST }}
|
||||
method: approle
|
||||
roleId: ${{ secrets.VAULT_ROLE_ID }}
|
||||
secretId: ${{ secrets.VAULT_SECRET_ID }}
|
||||
secrets:
|
||||
secrets/data/github repo_readonly_pat | REPO_READONLY_PAT;
|
||||
secrets/data/github packages_pat | PACKAGES_PAT
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
repository: ${{ env.githubRepository }}
|
||||
ref: ${{ inputs.ref || github.ref_name }}
|
||||
token: ${{ env.REPO_READONLY_PAT }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Download frontend artifact
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: frontend-dist
|
||||
path: code/frontend/dist/ui/browser
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v6
|
||||
with:
|
||||
dotnet-version: 10.0.200
|
||||
|
||||
- name: Restore .NET dependencies
|
||||
run: |
|
||||
dotnet nuget add source --username ${{ github.repository_owner }} --password ${{ env.PACKAGES_PAT }} --store-password-in-clear-text --name Cleanuparr https://nuget.pkg.github.com/Cleanuparr/index.json
|
||||
dotnet restore code/backend/${{ env.executableName }}/${{ env.executableName }}.csproj
|
||||
|
||||
- name: Copy frontend to backend wwwroot
|
||||
shell: pwsh
|
||||
run: |
|
||||
New-Item -ItemType Directory -Force -Path "code/backend/${{ env.executableName }}/wwwroot"
|
||||
Copy-Item -Path "code/frontend/dist/ui/browser/*" -Destination "code/backend/${{ env.executableName }}/wwwroot/" -Recurse -Force
|
||||
|
||||
- name: Build Windows executable
|
||||
run: |
|
||||
dotnet publish code/backend/${{ env.executableName }}/${{ env.executableName }}.csproj -c Release --runtime win-x64 --self-contained -o dist /p:PublishSingleFile=true /p:Version=${{ env.appVersion }} /p:DebugType=None /p:DebugSymbols=false
|
||||
|
||||
- name: Setup Inno Setup
|
||||
shell: pwsh
|
||||
run: |
|
||||
choco install innosetup --no-progress -y
|
||||
$innoPath = "C:\Program Files (x86)\Inno Setup 6"
|
||||
echo "$innoPath" >> $env:GITHUB_PATH
|
||||
|
||||
- name: Verify LICENSE file exists
|
||||
shell: pwsh
|
||||
run: |
|
||||
if (-not (Test-Path "LICENSE")) {
|
||||
Write-Error "LICENSE file not found in repository root"
|
||||
exit 1
|
||||
}
|
||||
Write-Host "LICENSE file found successfully"
|
||||
|
||||
- name: Build Windows installer
|
||||
shell: pwsh
|
||||
run: |
|
||||
# Copy installer script to root
|
||||
Copy-Item "installers/windows/cleanuparr-installer.iss" -Destination "cleanuparr-installer.iss"
|
||||
|
||||
# The installer script has been pre-updated with proper icon and config paths
|
||||
# No dynamic modifications needed as the base script now includes correct references
|
||||
|
||||
# Run Inno Setup compiler
|
||||
& "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" "cleanuparr-installer.iss"
|
||||
|
||||
# Check if installer was created
|
||||
if (Test-Path "installer/Cleanuparr_Setup.exe") {
|
||||
Write-Host "Installer created successfully"
|
||||
} else {
|
||||
Write-Error "Installer creation failed"
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: Rename installer with version
|
||||
shell: pwsh
|
||||
run: |
|
||||
$installerName = "Cleanuparr-${{ env.appVersion }}-Setup.exe"
|
||||
Move-Item "installer/Cleanuparr_Setup.exe" "installer/$installerName"
|
||||
echo "installerName=$installerName" >> $env:GITHUB_ENV
|
||||
|
||||
- name: Upload installer artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: Cleanuparr-windows-installer
|
||||
path: installer/${{ env.installerName }}
|
||||
retention-days: 30
|
||||
|
||||
# Removed individual release step - handled by main release workflow
|
||||
@@ -1,11 +0,0 @@
|
||||
on:
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
uses: flmorg/universal-workflows/.github/workflows/dotnet.build.app.yml@main
|
||||
with:
|
||||
dockerRepository: flaminel/cleanuperr
|
||||
githubContext: ${{ toJSON(github) }}
|
||||
secrets: inherit
|
||||
@@ -0,0 +1,46 @@
|
||||
name: Deploy to Cloudflare Pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'Cloudflare/**'
|
||||
- 'blacklist'
|
||||
- 'blacklist_permissive'
|
||||
- 'whitelist'
|
||||
- 'whitelist_with_subtitles'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
name: Deploy to Cloudflare Pages
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Create directory for static files
|
||||
run: |
|
||||
mkdir -p Cloudflare/static
|
||||
|
||||
- name: Copy root static files to Cloudflare static directory
|
||||
run: |
|
||||
cp blacklist Cloudflare/static/
|
||||
cp blacklist_permissive Cloudflare/static/
|
||||
cp whitelist Cloudflare/static/
|
||||
cp whitelist_with_subtitles Cloudflare/static/
|
||||
|
||||
- name: Deploy to Cloudflare Pages
|
||||
uses: cloudflare/wrangler-action@v4
|
||||
with:
|
||||
apiToken: ${{ secrets.CLOUDFLARE_PAGES_TOKEN }}
|
||||
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
workingDirectory: "Cloudflare"
|
||||
command: pages deploy . --project-name=cleanuparr
|
||||
@@ -0,0 +1,43 @@
|
||||
name: Deploy Status Page to Cloudflare Pages
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Release version (e.g. v1.2.3)'
|
||||
type: string
|
||||
required: true
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Release version (e.g. v1.2.3)'
|
||||
type: string
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
name: Deploy Status Page to Cloudflare Pages
|
||||
|
||||
steps:
|
||||
- name: Create status files
|
||||
run: |
|
||||
mkdir -p status
|
||||
echo "{ \"version\": \"${{ inputs.version }}\" }" > status/status.json
|
||||
|
||||
# Cache static files for 10 minutes
|
||||
cat > status/_headers << 'EOF'
|
||||
/*
|
||||
Cache-Control: public, max-age=600, s-maxage=600
|
||||
EOF
|
||||
|
||||
- name: Deploy to Cloudflare Pages
|
||||
uses: cloudflare/wrangler-action@v4
|
||||
with:
|
||||
apiToken: ${{ secrets.CLOUDFLARE_PAGES_TOKEN }}
|
||||
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
workingDirectory: "status"
|
||||
command: pages deploy . --project-name=cleanuparr-status
|
||||
@@ -0,0 +1,47 @@
|
||||
name: Dependency Review
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
# Cancel in-progress runs for the same PR
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
dependency-review:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Dependency Review
|
||||
uses: actions/dependency-review-action@v5
|
||||
with:
|
||||
# Fail on critical and high severity vulnerabilities
|
||||
fail-on-severity: high
|
||||
# Warn on moderate vulnerabilities
|
||||
warn-on-severity: moderate
|
||||
# Allow licenses
|
||||
# allow-licenses: MIT, Apache-2.0, BSD-2-Clause, BSD-3-Clause, ISC, 0BSD
|
||||
# Comment summarizes the vulnerabilities found
|
||||
comment-summary-in-pr: on-failure
|
||||
# Show dependency changes in PR
|
||||
show-openssf-scorecard: true
|
||||
vulnerability-check: true
|
||||
|
||||
- name: Upload dependency review results
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: dependency-review-results
|
||||
path: dependency-review-*.json
|
||||
if-no-files-found: ignore
|
||||
retention-days: 30
|
||||
@@ -1,15 +0,0 @@
|
||||
on:
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
uses: flmorg/universal-workflows/.github/workflows/chart.install.yml@main
|
||||
with:
|
||||
githubContext: ${{ toJSON(github) }}
|
||||
chartRepo: oci://ghcr.io/flmorg
|
||||
chartName: universal-chart
|
||||
version: ^1.0.0
|
||||
valuesPath: chart/values.yaml
|
||||
releaseName: main
|
||||
secrets: inherit
|
||||
@@ -0,0 +1,63 @@
|
||||
name: Docs Preview Cleanup
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [closed]
|
||||
paths:
|
||||
- 'docs/**'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_number:
|
||||
description: 'PR number whose preview deployments to delete'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
cleanup:
|
||||
runs-on: ubuntu-latest
|
||||
# Skip fork PRs
|
||||
if: github.event_name == 'workflow_dispatch' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
env:
|
||||
CF_API_TOKEN: ${{ secrets.CLOUDFLARE_PAGES_TOKEN }}
|
||||
CF_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
PROJECT: cleanuparr-docs
|
||||
BRANCH: pull-${{ github.event.pull_request.number || inputs.pr_number }}
|
||||
steps:
|
||||
- name: Delete preview deployments for this PR
|
||||
run: |
|
||||
set -euo pipefail
|
||||
api="https://api.cloudflare.com/client/v4"
|
||||
auth="Authorization: Bearer $CF_API_TOKEN"
|
||||
|
||||
if [ -z "${CF_ACCOUNT_ID:-}" ]; then
|
||||
echo "CLOUDFLARE_ACCOUNT_ID repo variable is not set" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Account $CF_ACCOUNT_ID, tearing down branch $BRANCH"
|
||||
|
||||
ids=""
|
||||
page=1
|
||||
while :; do
|
||||
resp=$(curl -fsS -H "$auth" \
|
||||
"$api/accounts/$CF_ACCOUNT_ID/pages/projects/$PROJECT/deployments?page=$page&per_page=25")
|
||||
count=$(echo "$resp" | jq '.result | length')
|
||||
[ "$count" -eq 0 ] && break
|
||||
|
||||
page_ids=$(echo "$resp" | jq -r --arg b "$BRANCH" \
|
||||
'.result[] | select(.deployment_trigger.metadata.branch == $b) | .id')
|
||||
ids="$ids $page_ids"
|
||||
page=$((page + 1))
|
||||
done
|
||||
|
||||
deleted=0
|
||||
for id in $ids; do
|
||||
echo "Deleting deployment $id"
|
||||
curl -fsS -X DELETE -H "$auth" \
|
||||
"$api/accounts/$CF_ACCOUNT_ID/pages/projects/$PROJECT/deployments/$id?force=true" >/dev/null
|
||||
deleted=$((deleted + 1))
|
||||
done
|
||||
|
||||
echo "Deleted $deleted deployment(s) for $BRANCH"
|
||||
@@ -0,0 +1,85 @@
|
||||
name: Docs Preview
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'docs/**'
|
||||
|
||||
concurrency:
|
||||
group: docs-preview-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
preview:
|
||||
runs-on: ubuntu-latest
|
||||
# Skip fork PRs
|
||||
if: github.event.pull_request.head.repo.full_name == github.repository
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 26.x
|
||||
cache: yarn
|
||||
cache-dependency-path: docs/yarn.lock
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: docs
|
||||
run: yarn install --frozen-lockfile
|
||||
|
||||
- name: Build Docusaurus
|
||||
working-directory: docs
|
||||
run: yarn build
|
||||
|
||||
- name: Deploy preview to Cloudflare Pages
|
||||
uses: cloudflare/wrangler-action@v4
|
||||
with:
|
||||
apiToken: ${{ secrets.CLOUDFLARE_PAGES_TOKEN }}
|
||||
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
workingDirectory: docs
|
||||
command: pages deploy build --project-name=cleanuparr-docs --branch=pull-${{ github.event.pull_request.number }}
|
||||
|
||||
- name: Comment preview URL
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const url = `https://pull-${context.issue.number}.cleanuparr-docs.pages.dev`;
|
||||
const marker = '<!-- docs-preview -->';
|
||||
const body = [
|
||||
marker,
|
||||
`Docs preview for \`${context.payload.pull_request.head.ref}\` (\`${context.payload.pull_request.head.sha.substring(0, 7)}\`):`,
|
||||
``,
|
||||
`${url}/docs`
|
||||
].join('\n');
|
||||
|
||||
const comments = await github.paginate(github.rest.issues.listComments, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
per_page: 100
|
||||
});
|
||||
const existing = comments.find(c => c.body.includes(marker));
|
||||
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: existing.id,
|
||||
body
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
name: Deploy Docusaurus to GitHub Pages
|
||||
|
||||
on:
|
||||
workflow_call: {}
|
||||
workflow_dispatch: {}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: main
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 26.x
|
||||
cache: yarn
|
||||
cache-dependency-path: docs/yarn.lock
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: docs
|
||||
run: yarn install --frozen-lockfile
|
||||
|
||||
- name: Build Docusaurus
|
||||
working-directory: docs
|
||||
run: yarn build
|
||||
|
||||
- name: Publish to org site
|
||||
uses: peaceiris/actions-gh-pages@v4
|
||||
with:
|
||||
personal_token: ${{ secrets.DOCS_DEPLOY_TOKEN }}
|
||||
external_repository: Cleanuparr/Cleanuparr.github.io
|
||||
publish_branch: gh-pages
|
||||
publish_dir: docs/build
|
||||
|
||||
redirect:
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Build redirect stub
|
||||
run: |
|
||||
mkdir redirect
|
||||
cat > redirect/404.html <<'EOF'
|
||||
<!doctype html>
|
||||
<meta http-equiv="refresh" content="0;url=https://cleanuparr.github.io/docs">
|
||||
<script>
|
||||
var p = location.pathname.replace(/^\/Cleanuparr/, '');
|
||||
location.replace('https://cleanuparr.github.io' + p + location.search + location.hash);
|
||||
</script>
|
||||
EOF
|
||||
cp redirect/404.html redirect/index.html
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v5
|
||||
with:
|
||||
path: redirect
|
||||
retention-days: 1
|
||||
|
||||
- name: Deploy redirect to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v5
|
||||
@@ -0,0 +1,109 @@
|
||||
name: E2E Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'code/**'
|
||||
- 'e2e/**'
|
||||
- '.github/workflows/e2e.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'code/**'
|
||||
- 'e2e/**'
|
||||
- '.github/workflows/e2e.yml'
|
||||
workflow_call:
|
||||
|
||||
concurrency:
|
||||
group: E2E Tests-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
e2e:
|
||||
if: false
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
suite:
|
||||
- name: api
|
||||
make-target: up-api
|
||||
- name: download-clients
|
||||
make-target: up-dc
|
||||
|
||||
name: e2e (${{ matrix.suite.name }})
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
timeout-minutes: 1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Get vault secrets
|
||||
uses: hashicorp/vault-action@v4
|
||||
with:
|
||||
url: ${{ secrets.VAULT_HOST }}
|
||||
method: approle
|
||||
roleId: ${{ secrets.VAULT_ROLE_ID }}
|
||||
secretId: ${{ secrets.VAULT_SECRET_ID }}
|
||||
secrets:
|
||||
secrets/data/github packages_pat | PACKAGES_PAT
|
||||
|
||||
- name: Start services
|
||||
working-directory: e2e
|
||||
run: make ${{ matrix.suite.make-target }}
|
||||
env:
|
||||
PACKAGES_USERNAME: ${{ github.repository_owner }}
|
||||
PACKAGES_PAT: ${{ env.PACKAGES_PAT }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 26
|
||||
|
||||
- name: Install E2E dependencies
|
||||
working-directory: e2e
|
||||
run: npm ci
|
||||
|
||||
- name: Install Playwright browsers
|
||||
working-directory: e2e
|
||||
timeout-minutes: 5
|
||||
run: npx playwright install chromium
|
||||
|
||||
- name: Wait for Keycloak
|
||||
run: |
|
||||
echo "Waiting for Keycloak realm to be ready..."
|
||||
timeout 120 bash -c 'until curl -sf http://localhost:8080/realms/cleanuparr-test/.well-known/openid-configuration; do sleep 3; done'
|
||||
echo "Keycloak ready!"
|
||||
|
||||
- name: Wait for app
|
||||
run: |
|
||||
echo "Waiting for Cleanuparr to be ready..."
|
||||
timeout 120 bash -c 'until curl -sf http://localhost:5000/health; do sleep 3; done'
|
||||
echo "App ready!"
|
||||
|
||||
- name: Run E2E tests
|
||||
working-directory: e2e
|
||||
run: npx playwright test --project=${{ matrix.suite.name }}
|
||||
|
||||
- name: Upload test results
|
||||
uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
with:
|
||||
name: e2e-test-results-${{ matrix.suite.name }}
|
||||
path: |
|
||||
e2e/playwright-report/
|
||||
e2e/test-results/
|
||||
retention-days: 7
|
||||
|
||||
- name: Stop services
|
||||
if: always()
|
||||
working-directory: e2e
|
||||
run: docker compose -f docker-compose.e2e.yml down
|
||||
@@ -1,15 +0,0 @@
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- 'code/**'
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
uses: flmorg/cleanuperr/.github/workflows/build.yml@main
|
||||
secrets: inherit
|
||||
|
||||
# deploy:
|
||||
# needs: [ build ]
|
||||
# uses: flmorg/cleanuperr/.github/workflows/deploy.yml@main
|
||||
# secrets: inherit
|
||||
@@ -0,0 +1,184 @@
|
||||
name: PR Build (Comment Triggered)
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
concurrency:
|
||||
group: pr-build-${{ github.event.issue.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.issue.pull_request != null
|
||||
outputs:
|
||||
build_windows: ${{ steps.parse.outputs.build_windows }}
|
||||
pr_ref: ${{ steps.parse.outputs.pr_ref }}
|
||||
pr_sha: ${{ steps.parse.outputs.pr_sha }}
|
||||
pr_number: ${{ steps.parse.outputs.pr_number }}
|
||||
|
||||
steps:
|
||||
- name: Parse command and check permissions
|
||||
id: parse
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const comment = context.payload.comment.body.trim();
|
||||
|
||||
// Parse supported commands
|
||||
const commands = {
|
||||
'/build-windows': 'build_windows'
|
||||
};
|
||||
|
||||
const command = commands[comment];
|
||||
if (!command) {
|
||||
console.log(`Comment "${comment}" is not a recognized build command, skipping.`);
|
||||
core.setOutput('build_windows', 'false');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await github.rest.reactions.createForIssueComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: context.payload.comment.id,
|
||||
content: 'eyes'
|
||||
});
|
||||
} catch (e) {
|
||||
console.log(`Could not add reaction: ${e}`);
|
||||
}
|
||||
|
||||
// Fetch PR details
|
||||
const pr = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: context.issue.number
|
||||
});
|
||||
|
||||
// Verify PR is open
|
||||
if (pr.data.state !== 'open') {
|
||||
console.log('PR is not open, skipping.');
|
||||
core.setOutput('build_windows', 'false');
|
||||
return;
|
||||
}
|
||||
|
||||
// Block fork PRs — fork code should not run with access to secrets
|
||||
const isFork = pr.data.head.repo.full_name !== context.repo.owner + '/' + context.repo.repo;
|
||||
if (isFork) {
|
||||
console.log(`PR is from fork ${pr.data.head.repo.full_name}, blocking build.`);
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: 'On-demand builds are not available for PRs from forks.'
|
||||
});
|
||||
core.setOutput('build_windows', 'false');
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify commenter has write access
|
||||
let permission = 'none';
|
||||
try {
|
||||
const resp = await github.rest.repos.getCollaboratorPermissionLevel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
username: context.payload.comment.user.login
|
||||
});
|
||||
permission = resp.data.permission;
|
||||
} catch (_) {}
|
||||
|
||||
if (!['admin', 'write'].includes(permission)) {
|
||||
console.log(`User ${context.payload.comment.user.login} has '${permission}' permission — insufficient.`);
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: `@${context.payload.comment.user.login} Only collaborators with write access can trigger builds.`
|
||||
});
|
||||
core.setOutput('build_windows', 'false');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`User ${context.payload.comment.user.login} has '${permission}' permission — proceeding with ${command}.`);
|
||||
core.setOutput(command, 'true');
|
||||
|
||||
// Export PR details for downstream jobs
|
||||
core.setOutput('pr_ref', pr.data.head.ref);
|
||||
core.setOutput('pr_sha', pr.data.head.sha);
|
||||
core.setOutput('pr_number', String(pr.data.number));
|
||||
|
||||
build-frontend:
|
||||
needs: validate
|
||||
if: needs.validate.outputs.build_windows == 'true'
|
||||
uses: ./.github/workflows/build-frontend.yml
|
||||
with:
|
||||
ref: ${{ needs.validate.outputs.pr_ref }}
|
||||
secrets: inherit
|
||||
|
||||
build-windows:
|
||||
needs: [validate, build-frontend]
|
||||
if: needs.validate.outputs.build_windows == 'true'
|
||||
uses: ./.github/workflows/build-windows-installer.yml
|
||||
with:
|
||||
ref: ${{ needs.validate.outputs.pr_ref }}
|
||||
secrets: inherit
|
||||
|
||||
post-result:
|
||||
needs: [validate, build-windows]
|
||||
if: always() && needs.validate.outputs.build_windows == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Post result comment
|
||||
uses: actions/github-script@v9
|
||||
env:
|
||||
PR_REF: ${{ needs.validate.outputs.pr_ref }}
|
||||
with:
|
||||
script: |
|
||||
const buildResult = '${{ needs.build-windows.result }}';
|
||||
const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
|
||||
const prRef = process.env.PR_REF;
|
||||
const prSha = '${{ needs.validate.outputs.pr_sha }}';
|
||||
const shortSha = prSha.substring(0, 7);
|
||||
|
||||
// Skip comment for skipped builds
|
||||
if (buildResult === 'skipped') {
|
||||
console.log('Build was skipped, no comment needed.');
|
||||
return;
|
||||
}
|
||||
|
||||
let body;
|
||||
if (buildResult === 'success') {
|
||||
body = [
|
||||
`Windows installer build **succeeded** for \`${prRef}\` (\`${shortSha}\`).`,
|
||||
``,
|
||||
`**Download:** open the [workflow run](${runUrl}), scroll to the **Artifacts** section at the bottom.`,
|
||||
`The artifact \`Cleanuparr-windows-installer\` is retained for 30 days.`
|
||||
].join('\n');
|
||||
} else if (buildResult === 'cancelled') {
|
||||
body = [
|
||||
`Windows installer build was **cancelled** for \`${prRef}\` (\`${shortSha}\`).`,
|
||||
``,
|
||||
`See the [workflow run](${runUrl}) for details.`
|
||||
].join('\n');
|
||||
} else {
|
||||
body = [
|
||||
`Windows installer build **failed** for \`${prRef}\` (\`${shortSha}\`).`,
|
||||
``,
|
||||
`See the [workflow run](${runUrl}) for details.`
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: parseInt('${{ needs.validate.outputs.pr_number }}'),
|
||||
body
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
name: PR Label
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, reopened, synchronize, labeled, unlabeled]
|
||||
|
||||
concurrency:
|
||||
group: PR Label-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
category:
|
||||
name: Require category label
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Fail when no category label is present
|
||||
if: >-
|
||||
!contains(github.event.pull_request.labels.*.name, 'breaking') &&
|
||||
!contains(github.event.pull_request.labels.*.name, 'enhancement') &&
|
||||
!contains(github.event.pull_request.labels.*.name, 'bug') &&
|
||||
!contains(github.event.pull_request.labels.*.name, 'documentation') &&
|
||||
!contains(github.event.pull_request.labels.*.name, 'chore')
|
||||
run: |
|
||||
echo "::error::Add one of these labels so the release notes can be categorised: breaking, enhancement, bug, documentation, chore"
|
||||
exit 1
|
||||
@@ -1,78 +1,387 @@
|
||||
name: Release
|
||||
name: Release Build
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Version number'
|
||||
description: 'Version to release (e.g., 1.0.0)'
|
||||
required: true
|
||||
runTests:
|
||||
description: 'Run test suite'
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
buildDocker:
|
||||
description: 'Build Docker image'
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
pushDocker:
|
||||
description: 'Push Docker image to registry'
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
buildBinaries:
|
||||
description: 'Build executables and installers'
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
createRelease:
|
||||
description: 'Create GitHub release'
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
# Validate release
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
outputs:
|
||||
app_version: ${{ steps.version.outputs.app_version }}
|
||||
release_version: ${{ steps.version.outputs.release_version }}
|
||||
is_tag: ${{ steps.version.outputs.is_tag }}
|
||||
|
||||
steps:
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Install dependencies
|
||||
run: dotnet restore code/Executable/Executable.csproj
|
||||
|
||||
- name: Build win-x64
|
||||
run: dotnet publish code/Executable/Executable.csproj -c Release --runtime win-x64 --self-contained -o artifacts/cleanuperr-win /p:PublishSingleFile=true /p:AssemblyVersion=${{ github.event.inputs.version }}
|
||||
|
||||
- name: Build linux-x64
|
||||
run: dotnet publish code/Executable/Executable.csproj -c Release --runtime linux-x64 --self-contained -o artifacts/cleanuperr-linux /p:PublishSingleFile=true /p:AssemblyVersion=${{ github.event.inputs.version }}
|
||||
|
||||
- name: Build osx-x64
|
||||
run: dotnet publish code/Executable/Executable.csproj -c Release --runtime osx-x64 --self-contained -o artifacts/cleanuperr-osx /p:PublishSingleFile=true /p:AssemblyVersion=${{ github.event.inputs.version }}
|
||||
|
||||
- name: Zip the Win release
|
||||
run: zip ./artifacts/cleanuperr-win.zip ./artifacts/cleanuperr-win/cleanuperr.exe ./artifacts/cleanuperr-win/appsettings.json
|
||||
|
||||
- name: Zip the Linux release
|
||||
run: zip ./artifacts/cleanuperr-linux.zip ./artifacts/cleanuperr-linux/cleanuperr ./artifacts/cleanuperr-win/appsettings.json
|
||||
|
||||
- name: Zip the OSX release
|
||||
run: zip ./artifacts/cleanuperr-osx.zip ./artifacts/cleanuperr-osx/cleanuperr ./artifacts/cleanuperr-win/appsettings.json
|
||||
|
||||
- name: Release
|
||||
id: create_release
|
||||
uses: actions/create-release@v1.1.3
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
tag_name: ${{ github.event.inputs.version }}
|
||||
release_name: Release ${{ github.event.inputs.version }}
|
||||
draft: false
|
||||
|
||||
- name: Upload cleanuperr-win
|
||||
uses: actions/upload-release-asset@v1.0.2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
asset_path: ./artifacts/cleanuperr-win.zip
|
||||
asset_name: cleanuperr-win.zip
|
||||
asset_content_type: application/exe
|
||||
persist-credentials: false
|
||||
|
||||
- name: Upload cleanuperr-linux
|
||||
uses: actions/upload-release-asset@v1.0.2
|
||||
- name: Get version info
|
||||
id: version
|
||||
run: |
|
||||
if [[ "${{ github.ref }}" =~ ^refs/tags/ ]]; then
|
||||
# Tag event
|
||||
release_version=${GITHUB_REF##refs/tags/}
|
||||
app_version=${release_version#v}
|
||||
is_tag=true
|
||||
else
|
||||
# Manual workflow with version
|
||||
app_version="${{ github.event.inputs.version }}"
|
||||
|
||||
# Validate version format (x.x.x)
|
||||
if ! [[ "$app_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "Error: Version must be in format x.x.x (e.g., 1.0.0)"
|
||||
echo "Provided version: $app_version"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
release_version="v$app_version"
|
||||
is_tag=false
|
||||
fi
|
||||
|
||||
echo "app_version=$app_version" >> $GITHUB_OUTPUT
|
||||
echo "release_version=$release_version" >> $GITHUB_OUTPUT
|
||||
echo "is_tag=$is_tag" >> $GITHUB_OUTPUT
|
||||
|
||||
echo "Release Version: $release_version"
|
||||
echo "App Version: $app_version"
|
||||
echo "Is Tag: $is_tag"
|
||||
|
||||
- name: Check if release already exists
|
||||
run: |
|
||||
if gh release view "${{ steps.version.outputs.release_version }}" &>/dev/null; then
|
||||
echo "❌ Release ${{ steps.version.outputs.release_version }} already exists. Stopping workflow."
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Release ${{ steps.version.outputs.release_version }} does not exist. Proceeding."
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
asset_path: ./artifacts/cleanuperr-linux.zip
|
||||
asset_name: cleanuperr-linux.zip
|
||||
asset_content_type: application/exe
|
||||
|
||||
- name: Upload cleanuperr-osx
|
||||
uses: actions/upload-release-asset@v1.0.2
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Check the version against PR labels
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
APP_VERSION: ${{ steps.version.outputs.app_version }}
|
||||
run: |
|
||||
previous_tag=$(gh release view --json tagName --jq .tagName 2>/dev/null || echo "")
|
||||
|
||||
if [ -z "$previous_tag" ]; then
|
||||
echo "No previous release found. Skipping the label check."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
published=$(gh release view "$previous_tag" --json publishedAt --jq .publishedAt)
|
||||
|
||||
if ! breaking_list=$(gh api --paginate "repos/${{ github.repository }}/issues?labels=breaking&state=closed&since=$published&per_page=100" \
|
||||
--jq ".[] | select(.pull_request.merged_at > \"$published\") | \" #\(.number) \(.title)\""); then
|
||||
echo "::error::Failed to query pull request labels, cannot verify the version"
|
||||
printf '%s\n' "$breaking_list"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
breaking=$(printf '%s' "$breaking_list" | grep -c . || true)
|
||||
|
||||
previous_version=${previous_tag#v}
|
||||
|
||||
if [ "$previous_version" = "$APP_VERSION" ]; then
|
||||
echo "::error::$APP_VERSION is the same version as the previous release $previous_tag."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$(printf '%s\n%s\n' "$previous_version" "$APP_VERSION" | sort -V | tail -1)" != "$APP_VERSION" ]; then
|
||||
echo "::error::$APP_VERSION is older than the previous release $previous_tag. Releases must move forward."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
previous_minor=${previous_version%.*}
|
||||
new_minor=${APP_VERSION%.*}
|
||||
|
||||
echo "Previous release: $previous_tag (published $published)"
|
||||
echo "Breaking PRs merged since: $breaking"
|
||||
|
||||
if [ "$breaking" -gt 0 ] && [ "$previous_minor" = "$new_minor" ]; then
|
||||
echo "::error::$breaking PR(s) labelled 'breaking' were merged since $previous_tag, so $APP_VERSION cannot be a patch release. Bump the minor, or remove the label if it was applied by mistake. See RELEASING.md."
|
||||
printf '%s\n' "$breaking_list"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$breaking" -eq 0 ] && [ "$previous_minor" != "$new_minor" ]; then
|
||||
echo "::warning::$APP_VERSION bumps the minor but no PR merged since $previous_tag carries the 'breaking' label. Intentional for a policy or milestone release, otherwise check the labels."
|
||||
fi
|
||||
|
||||
echo "✅ Version $APP_VERSION is consistent with the PR labels."
|
||||
|
||||
# Run tests
|
||||
test:
|
||||
needs: validate
|
||||
if: ${{ needs.validate.outputs.is_tag == 'true' || github.event.inputs.runTests == 'true' }}
|
||||
uses: ./.github/workflows/test.yml
|
||||
secrets: inherit
|
||||
|
||||
# Run E2E tests
|
||||
e2e:
|
||||
needs: validate
|
||||
# if: ${{ needs.validate.outputs.is_tag == 'true' || github.event.inputs.runTests == 'true' }}
|
||||
if: false
|
||||
uses: ./.github/workflows/e2e.yml
|
||||
secrets: inherit
|
||||
|
||||
# Build frontend once for all build jobs and cache it
|
||||
build-frontend:
|
||||
needs: [validate, test, e2e]
|
||||
if: |
|
||||
always() &&
|
||||
needs.validate.result == 'success' &&
|
||||
(needs.test.result == 'success' || needs.test.result == 'skipped') &&
|
||||
(needs.e2e.result == 'success' || needs.e2e.result == 'skipped') &&
|
||||
(needs.validate.outputs.is_tag == 'true' || github.event.inputs.buildBinaries == 'true')
|
||||
uses: ./.github/workflows/build-frontend.yml
|
||||
secrets: inherit
|
||||
|
||||
# Build portable executables
|
||||
build-executables:
|
||||
needs: [validate, test, e2e, build-frontend]
|
||||
if: |
|
||||
always() &&
|
||||
needs.validate.result == 'success' &&
|
||||
(needs.test.result == 'success' || needs.test.result == 'skipped') &&
|
||||
(needs.e2e.result == 'success' || needs.e2e.result == 'skipped') &&
|
||||
needs.build-frontend.result == 'success' &&
|
||||
(needs.validate.outputs.is_tag == 'true' || github.event.inputs.buildBinaries == 'true')
|
||||
uses: ./.github/workflows/build-executable.yml
|
||||
with:
|
||||
app_version: ${{ needs.validate.outputs.app_version }}
|
||||
secrets: inherit
|
||||
|
||||
# Build Windows installer
|
||||
build-windows-installer:
|
||||
needs: [validate, test, e2e, build-frontend]
|
||||
if: |
|
||||
always() &&
|
||||
needs.validate.result == 'success' &&
|
||||
(needs.test.result == 'success' || needs.test.result == 'skipped') &&
|
||||
(needs.e2e.result == 'success' || needs.e2e.result == 'skipped') &&
|
||||
needs.build-frontend.result == 'success' &&
|
||||
(needs.validate.outputs.is_tag == 'true' || github.event.inputs.buildBinaries == 'true')
|
||||
uses: ./.github/workflows/build-windows-installer.yml
|
||||
with:
|
||||
app_version: ${{ needs.validate.outputs.app_version }}
|
||||
secrets: inherit
|
||||
|
||||
# Build macOS installers (Intel and ARM)
|
||||
build-macos:
|
||||
needs: [validate, test, e2e, build-frontend]
|
||||
if: |
|
||||
always() &&
|
||||
needs.validate.result == 'success' &&
|
||||
(needs.test.result == 'success' || needs.test.result == 'skipped') &&
|
||||
(needs.e2e.result == 'success' || needs.e2e.result == 'skipped') &&
|
||||
needs.build-frontend.result == 'success' &&
|
||||
(needs.validate.outputs.is_tag == 'true' || github.event.inputs.buildBinaries == 'true')
|
||||
uses: ./.github/workflows/build-macos-installer.yml
|
||||
with:
|
||||
app_version: ${{ needs.validate.outputs.app_version }}
|
||||
secrets: inherit
|
||||
|
||||
# Build and push Docker image(s)
|
||||
build-docker:
|
||||
needs: [validate, test, e2e]
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
if: |
|
||||
always() &&
|
||||
needs.validate.result == 'success' &&
|
||||
(needs.test.result == 'success' || needs.test.result == 'skipped') &&
|
||||
(needs.e2e.result == 'success' || needs.e2e.result == 'skipped') &&
|
||||
(needs.validate.outputs.is_tag == 'true' || github.event.inputs.buildDocker == 'true')
|
||||
uses: ./.github/workflows/build-docker.yml
|
||||
with:
|
||||
push_docker: ${{ needs.validate.outputs.is_tag == 'true' || github.event.inputs.pushDocker == 'true' }}
|
||||
app_version: ${{ needs.validate.outputs.app_version }}
|
||||
secrets: inherit
|
||||
|
||||
# Create GitHub release
|
||||
create-release:
|
||||
needs: [validate, build-executables, build-windows-installer, build-macos]
|
||||
runs-on: ubuntu-latest
|
||||
if: |
|
||||
always() &&
|
||||
needs.validate.result == 'success' &&
|
||||
needs.build-executables.result == 'success' &&
|
||||
needs.build-windows-installer.result == 'success' &&
|
||||
needs.build-macos.result == 'success' &&
|
||||
(
|
||||
needs.validate.outputs.is_tag == 'true' ||
|
||||
(github.event.inputs.createRelease == 'true' && github.event.inputs.buildBinaries == 'true')
|
||||
)
|
||||
|
||||
steps:
|
||||
- name: Get vault secrets
|
||||
uses: hashicorp/vault-action@v4
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
asset_path: ./artifacts/cleanuperr-osx.zip
|
||||
asset_name: cleanuperr-osx.zip
|
||||
asset_content_type: application/exe
|
||||
url: ${{ secrets.VAULT_HOST }}
|
||||
method: approle
|
||||
roleId: ${{ secrets.VAULT_ROLE_ID }}
|
||||
secretId: ${{ secrets.VAULT_SECRET_ID }}
|
||||
secrets:
|
||||
secrets/data/github repo_readonly_pat | REPO_READONLY_PAT
|
||||
|
||||
- name: Download executable artifacts
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
pattern: executable-*
|
||||
path: ./artifacts
|
||||
merge-multiple: true
|
||||
|
||||
- name: Download Windows installer
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: Cleanuparr-windows-installer
|
||||
path: ./artifacts
|
||||
|
||||
- name: Download macOS installers
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
pattern: Cleanuparr-macos-*-installer
|
||||
path: ./artifacts
|
||||
merge-multiple: true
|
||||
|
||||
- name: List downloaded artifacts
|
||||
run: |
|
||||
echo "Downloaded artifacts:"
|
||||
find ./artifacts -type f \( -name "*.zip" -o -name "*.pkg" -o -name "*.exe" \) | sort
|
||||
echo ""
|
||||
echo "Total files: $(find ./artifacts -type f \( -name "*.zip" -o -name "*.pkg" -o -name "*.exe" \) | wc -l)"
|
||||
|
||||
- name: Create release
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
name: ${{ needs.validate.outputs.release_version }}
|
||||
tag_name: ${{ needs.validate.outputs.release_version }}
|
||||
token: ${{ env.REPO_READONLY_PAT }}
|
||||
make_latest: true
|
||||
target_commitish: main
|
||||
generate_release_notes: true
|
||||
files: |
|
||||
./artifacts/*.zip
|
||||
./artifacts/*.pkg
|
||||
./artifacts/*.exe
|
||||
|
||||
# Deploy docs after a successful release
|
||||
deploy-docs:
|
||||
needs: [create-release]
|
||||
if: always() && needs.create-release.result == 'success'
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
uses: ./.github/workflows/docs.yml
|
||||
secrets: inherit
|
||||
|
||||
# Deploy Cloudflare status page after a successful release
|
||||
deploy-status:
|
||||
needs: [validate, create-release]
|
||||
if: always() && needs.create-release.result == 'success'
|
||||
uses: ./.github/workflows/cloudflare-pages-status.yml
|
||||
with:
|
||||
version: ${{ needs.validate.outputs.release_version }}
|
||||
secrets: inherit
|
||||
|
||||
# Summary job
|
||||
summary:
|
||||
needs: [validate, test, e2e, build-frontend, build-executables, build-windows-installer, build-macos, build-docker]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
if: always()
|
||||
|
||||
steps:
|
||||
- name: Record workflow start time
|
||||
id: workflow-start
|
||||
run: |
|
||||
# Get workflow start time from GitHub API
|
||||
workflow_start=$(gh api repos/${{ github.repository }}/actions/runs/${{ github.run_id }} --jq '.run_started_at')
|
||||
start_epoch=$(date -d "$workflow_start" +%s 2>/dev/null || date -j -f "%Y-%m-%dT%H:%M:%SZ" "$workflow_start" +%s)
|
||||
echo "start=$start_epoch" >> $GITHUB_OUTPUT
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build Summary
|
||||
run: |
|
||||
# Calculate total workflow duration
|
||||
start_time=${{ steps.workflow-start.outputs.start }}
|
||||
end_time=$(date +%s)
|
||||
duration=$((end_time - start_time))
|
||||
minutes=$((duration / 60))
|
||||
seconds=$((duration % 60))
|
||||
echo "## 🏗️ Cleanuparr Build Summary" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Version**: ${{ needs.validate.outputs.release_version }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**App Version**: ${{ needs.validate.outputs.app_version }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Is Tag**: ${{ needs.validate.outputs.is_tag }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Total Duration**: ${minutes}m ${seconds}s" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### Build Results" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# Helper function to print job result
|
||||
print_result() {
|
||||
local name="$1"
|
||||
local result="$2"
|
||||
case "$result" in
|
||||
success) echo "✅ **$name**: Success" >> $GITHUB_STEP_SUMMARY ;;
|
||||
skipped) echo "⏭️ **$name**: Skipped" >> $GITHUB_STEP_SUMMARY ;;
|
||||
*) echo "❌ **$name**: $result" >> $GITHUB_STEP_SUMMARY ;;
|
||||
esac
|
||||
}
|
||||
|
||||
print_result "Tests" "${{ needs.test.result }}"
|
||||
print_result "E2E Tests" "${{ needs.e2e.result }}"
|
||||
print_result "Frontend Build" "${{ needs.build-frontend.result }}"
|
||||
print_result "Portable Executables" "${{ needs.build-executables.result }}"
|
||||
print_result "Windows Installer" "${{ needs.build-windows-installer.result }}"
|
||||
print_result "macOS Installers (Intel & ARM)" "${{ needs.build-macos.result }}"
|
||||
print_result "Docker Image Build" "${{ needs.build-docker.result }}"
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "🎉 **Build completed!**" >> $GITHUB_STEP_SUMMARY
|
||||
@@ -0,0 +1,105 @@
|
||||
name: Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'code/backend/**'
|
||||
- '.github/workflows/test.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'code/backend/**'
|
||||
- '.github/workflows/test.yml'
|
||||
workflow_call:
|
||||
|
||||
# Cancel in-progress runs for the same PR
|
||||
concurrency:
|
||||
group: Tests-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
timeout-minutes: 1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v6
|
||||
with:
|
||||
dotnet-version: 10.0.200
|
||||
|
||||
- name: Cache NuGet packages
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.nuget/packages
|
||||
key: ${{ runner.os }}-nuget-${{ hashFiles('**/packages.lock.json', '**/*.csproj') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-nuget-
|
||||
|
||||
- name: Get vault secrets
|
||||
uses: hashicorp/vault-action@v4
|
||||
with:
|
||||
url: ${{ secrets.VAULT_HOST }}
|
||||
method: approle
|
||||
roleId: ${{ secrets.VAULT_ROLE_ID }}
|
||||
secretId: ${{ secrets.VAULT_SECRET_ID }}
|
||||
secrets:
|
||||
secrets/data/github packages_pat | PACKAGES_PAT
|
||||
|
||||
- name: Restore dependencies
|
||||
run: |
|
||||
dotnet nuget add source --username ${{ github.repository_owner }} --password ${{ env.PACKAGES_PAT }} --store-password-in-clear-text --name Cleanuparr https://nuget.pkg.github.com/Cleanuparr/index.json
|
||||
dotnet restore code/backend/cleanuparr.sln
|
||||
|
||||
- name: Build solution
|
||||
run: dotnet build code/backend/cleanuparr.sln --configuration Release --no-restore
|
||||
|
||||
- name: Run tests
|
||||
id: run-tests
|
||||
run: dotnet test code/backend/cleanuparr.sln --configuration Release --no-build --verbosity normal --logger trx --collect:"XPlat Code Coverage" --settings code/backend/coverage.runsettings --results-directory ./coverage
|
||||
|
||||
- name: Upload test results
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: test-results
|
||||
path: ./coverage/*.trx
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload coverage reports
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: coverage-report
|
||||
path: ./coverage/**/coverage.cobertura.xml
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v7
|
||||
with:
|
||||
files: ./coverage/**/coverage.cobertura.xml
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
fail_ci_if_error: false
|
||||
flags: backend
|
||||
name: backend-coverage
|
||||
|
||||
- name: Test Summary
|
||||
run: |
|
||||
echo "## Test Results" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
if [ "${{ steps.run-tests.outcome }}" == "success" ]; then
|
||||
echo "✅ All tests passed!" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "❌ Tests failed or were cancelled. Status: ${{ steps.run-tests.outcome }}" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Test artifacts have been uploaded for detailed analysis." >> $GITHUB_STEP_SUMMARY
|
||||
@@ -0,0 +1,69 @@
|
||||
name: Get Version Info
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
manual_version:
|
||||
description: 'Manual version override (e.g., 1.0.0)'
|
||||
required: false
|
||||
type: string
|
||||
default: ''
|
||||
outputs:
|
||||
app_version:
|
||||
description: 'Application version (without v prefix)'
|
||||
value: ${{ jobs.version.outputs.app_version }}
|
||||
release_version:
|
||||
description: 'Release version (with v prefix)'
|
||||
value: ${{ jobs.version.outputs.release_version }}
|
||||
is_tag:
|
||||
description: 'Whether this is a tag event'
|
||||
value: ${{ jobs.version.outputs.is_tag }}
|
||||
repository_name:
|
||||
description: 'Repository name without owner'
|
||||
value: ${{ jobs.version.outputs.repository_name }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
version:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
app_version: ${{ steps.version.outputs.app_version }}
|
||||
release_version: ${{ steps.version.outputs.release_version }}
|
||||
is_tag: ${{ steps.version.outputs.is_tag }}
|
||||
repository_name: ${{ steps.version.outputs.repository_name }}
|
||||
|
||||
steps:
|
||||
- name: Calculate version info
|
||||
id: version
|
||||
run: |
|
||||
repoFullName="${{ github.repository }}"
|
||||
repositoryName="${repoFullName#*/}"
|
||||
|
||||
if [[ "${{ github.ref }}" =~ ^refs/tags/ ]]; then
|
||||
# Tag event
|
||||
release_version="${GITHUB_REF##refs/tags/}"
|
||||
app_version="${release_version#v}"
|
||||
is_tag="true"
|
||||
elif [[ -n "${{ inputs.manual_version }}" ]]; then
|
||||
# Manual workflow with version
|
||||
app_version="${{ inputs.manual_version }}"
|
||||
release_version="v${app_version}"
|
||||
is_tag="false"
|
||||
else
|
||||
# Development build
|
||||
app_version="0.0.1-dev-$(date +%Y%m%d-%H%M%S)"
|
||||
release_version="v${app_version}"
|
||||
is_tag="false"
|
||||
fi
|
||||
|
||||
echo "app_version=${app_version}" >> $GITHUB_OUTPUT
|
||||
echo "release_version=${release_version}" >> $GITHUB_OUTPUT
|
||||
echo "is_tag=${is_tag}" >> $GITHUB_OUTPUT
|
||||
echo "repository_name=${repositoryName}" >> $GITHUB_OUTPUT
|
||||
|
||||
echo "📦 Repository: ${repositoryName}"
|
||||
echo "🏷️ Release Version: ${release_version}"
|
||||
echo "📱 App Version: ${app_version}"
|
||||
echo "🔖 Is Tag: ${is_tag}"
|
||||
@@ -105,7 +105,6 @@ _NCrunch_*
|
||||
_TeamCity*
|
||||
|
||||
# Sonarr
|
||||
config.xml
|
||||
nzbdrone.log*txt
|
||||
UpdateLogs/
|
||||
*workspace.xml
|
||||
@@ -165,3 +164,10 @@ src/.idea/
|
||||
|
||||
# Ignore Jetbrains IntelliJ Workspace Directories
|
||||
.idea/
|
||||
|
||||
**/logs/
|
||||
**/MediaCover/
|
||||
**/archive/
|
||||
**/Backups/
|
||||
*.fastresume
|
||||
*.bak
|
||||
@@ -0,0 +1,195 @@
|
||||
# Cleanuparr - Claude AI Rules
|
||||
|
||||
## Rules
|
||||
|
||||
1. **DO NOT break existing functionality** - All features are critical and must continue to work
|
||||
2. **When in doubt, ASK** - Don't assume, clarify with the maintainer first
|
||||
3. **Always read existing code before making changes** - Understand the current architecture and patterns
|
||||
4. **Follow existing patterns** - Study the codebase style and match it exactly
|
||||
5. **Ask before introducing new patterns** - Use current coding standards or get approval first
|
||||
6. **Prefer editing existing files over creating new ones** - Build on existing work
|
||||
7. **Flag potential gotchas or issues immediately** - Document and report anything unexpected
|
||||
8. **If unsure about an approach, ask before implementing**
|
||||
|
||||
## Project Overview
|
||||
|
||||
Cleanuparr is a tool for automating the cleanup of unwanted or blocked files in Sonarr, Radarr, Lidarr, Readarr, Whisparr and supported download clients (qBittorrent, Transmission, Deluge, uTorrent, rTorrent). It provides malware protection, automated cleanup, and queue management for *arr applications.
|
||||
|
||||
**Key Features:**
|
||||
- Strike system for bad downloads
|
||||
- Malware detection and blocking
|
||||
- Automatic search triggering after removal (Seeker)
|
||||
- Missing and upgrade search
|
||||
- Orphaned download cleanup with cross-seed support
|
||||
- Authentication (OIDC, 2FA)
|
||||
- Notification providers (Apprise, Discord, Gotify, Notifiarr, Ntfy, Pushover, Telegram)
|
||||
|
||||
## Architecture & Tech Stack
|
||||
|
||||
### Backend
|
||||
- **.NET 10.0** (C#) with ASP.NET Core
|
||||
- **Architecture**: Clean Architecture with `Features/` subdirectory pattern
|
||||
- `Cleanuparr.Api` - REST API and web host (`Features/` for endpoint groups)
|
||||
- `Cleanuparr.Application` - Application services and use cases
|
||||
- `Cleanuparr.Domain` - Domain models (Entities, Enums, Exceptions)
|
||||
- `Cleanuparr.Infrastructure` - External integrations (`Features/` for Arr, DownloadClient, Notifications, etc.)
|
||||
- `Cleanuparr.Persistence` - Data access with EF Core (SQLite)
|
||||
- `Cleanuparr.Shared` - Shared utilities
|
||||
- **Database**: SQLite with Entity Framework Core
|
||||
- Three separate contexts: `DataContext`, `EventsContext`, `UsersContext`
|
||||
- **Key Libraries**:
|
||||
- MassTransit (messaging)
|
||||
- Quartz.NET (scheduling)
|
||||
- Serilog (logging)
|
||||
- SignalR (real-time communication)
|
||||
- **Testing**: xUnit + NSubstitute + Shouldly
|
||||
- Always use **NSubstitute** for mocking in new tests (Moq is being phased out)
|
||||
|
||||
### Frontend
|
||||
- **Angular 22** with TypeScript 6.0, Node 26 (standalone components, zoneless, OnPush)
|
||||
- **UI**: Custom glassmorphism design system with 33 custom components — no external UI frameworks
|
||||
- **Icons**: @ng-icons/core + @ng-icons/tabler-icons
|
||||
- **Design System**: 3-layer SCSS (`_variables` -> `_tokens` -> `_themes`), dark/light themes
|
||||
- **State Management**: Angular signals (`signal`/`computed`/`effect`) — `@ngrx/signals` was removed (it was unused)
|
||||
- **Data fetching**: Angular 22 Resource API — `rxResource` from `@angular/core/rxjs-interop` (not manual `HttpClient.subscribe()`)
|
||||
- **Forms**: Angular 22 Signal Forms — `form()` + `[formField]` from `@angular/forms/signals` (settings forms; a few not-yet-migrated forms still use per-field signals)
|
||||
- **Real-time Updates**: @microsoft/signalr 10.0.0
|
||||
- **PWA**: Service Worker support enabled
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
Cleanuparr/
|
||||
├── code/
|
||||
│ ├── backend/
|
||||
│ │ ├── Cleanuparr.Api/ # REST API (Features/ for endpoint groups)
|
||||
│ │ ├── Cleanuparr.Api.Tests/ # API layer tests
|
||||
│ │ ├── Cleanuparr.Application/ # Business logic layer
|
||||
│ │ ├── Cleanuparr.Domain/ # Domain models
|
||||
│ │ ├── Cleanuparr.Infrastructure/ # External integrations (Features/ subdirs)
|
||||
│ │ ├── Cleanuparr.Infrastructure.Tests/
|
||||
│ │ ├── Cleanuparr.Persistence/ # SQLite data access
|
||||
│ │ ├── Cleanuparr.Persistence.Tests/
|
||||
│ │ └── Cleanuparr.Shared/ # Shared utilities
|
||||
│ ├── frontend/ # Angular 22 application
|
||||
│ ├── e2e/ # Playwright E2E tests
|
||||
│ ├── Dockerfile # Multi-stage Docker build
|
||||
│ ├── entrypoint.sh # Docker entrypoint
|
||||
│ └── Makefile # Build & migration helpers
|
||||
├── docs/ # Docusaurus documentation
|
||||
├── .github/workflows/ # CI/CD pipelines
|
||||
├── blacklist # Default malware patterns (strict)
|
||||
├── blacklist_permissive # Less strict malware patterns
|
||||
├── whitelist # Safe file extensions
|
||||
└── whitelist_with_subtitles # Includes subtitle formats
|
||||
```
|
||||
|
||||
## Code Standards & Conventions
|
||||
|
||||
**IMPORTANT:** Always study existing code in the relevant area before making changes. Match the existing style exactly.
|
||||
|
||||
### Backend (C#)
|
||||
- Follow Microsoft C# Coding Conventions
|
||||
- Use nullable reference types (`<Nullable>enable</Nullable>`)
|
||||
- Add XML documentation comments for public APIs
|
||||
- Use meaningful names - avoid abbreviations unless widely understood
|
||||
- Keep services focused - single responsibility principle
|
||||
- New integrations go under `Features/` subdirectories (e.g., `Infrastructure/Features/Arr/`)
|
||||
|
||||
### Frontend (TypeScript/Angular)
|
||||
- All components must be **standalone** with **ChangeDetectionStrategy.OnPush**
|
||||
- Use `input()` / `output()` function APIs (not `@Input()` / `@Output()` decorators)
|
||||
- Use Angular **signals** for reactive state (`signal()`, `computed()`, `effect()`)
|
||||
- **Data fetching**: use the **Resource API** (`rxResource`) with a reactive `params` + `stream`, not manual `HttpClient.subscribe()`; drive spinners/errors off `isLoading()`/`error()`
|
||||
- **Forms**: use **Signal Forms** (`form()` + `[formField]`) with a single model signal + schema validators; keep the JSON-snapshot dirty tracking (`buildSnapshot()`/`hasPendingChanges()`), do NOT use Signal Forms `dirty()` for the unsaved-changes guard
|
||||
- Follow the 3-layer SCSS design system (`_variables` -> `_tokens` -> `_themes`)
|
||||
- **Do not introduce external UI frameworks** (no PrimeNG, Material, Tailwind, etc.)
|
||||
- Component naming: `{feature}.component.ts`
|
||||
- Service naming: `{feature}.service.ts`
|
||||
- **Look at similar existing components before creating new ones**
|
||||
|
||||
### Testing
|
||||
- **Backend**: xUnit + NSubstitute + Shouldly
|
||||
- Always use **NSubstitute** for mocking (Moq is being phased out)
|
||||
- Write unit tests for new features and bug fixes
|
||||
- Use descriptive test names that explain what is being tested
|
||||
- No frontend unit tests currently
|
||||
|
||||
### Git Commit Messages
|
||||
- Use clear, descriptive messages in imperative mood
|
||||
- Examples: "Add Discord notification support", "Fix memory leak in download client polling"
|
||||
- Reference issue numbers when applicable: "Fix #123: Handle null response from Radarr API"
|
||||
|
||||
## Development Setup
|
||||
|
||||
### Running the Backend
|
||||
```bash
|
||||
cd code/backend
|
||||
dotnet build Cleanuparr.Api/Cleanuparr.Api.csproj
|
||||
dotnet run --project Cleanuparr.Api/Cleanuparr.Api.csproj
|
||||
```
|
||||
API runs at http://localhost:5000
|
||||
|
||||
### Running the Frontend
|
||||
```bash
|
||||
cd code/frontend
|
||||
npm install
|
||||
npm start
|
||||
```
|
||||
UI runs at http://localhost:4200
|
||||
|
||||
### Running Tests
|
||||
```bash
|
||||
cd code/backend
|
||||
dotnet test
|
||||
```
|
||||
|
||||
## Database Migrations
|
||||
|
||||
Three separate database contexts, all commands run from the `code` directory:
|
||||
|
||||
```bash
|
||||
# Data migrations (DataContext)
|
||||
make migrate-data name=YourMigrationName
|
||||
|
||||
# Events migrations (EventsContext)
|
||||
make migrate-events name=YourMigrationName
|
||||
|
||||
# Users migrations (UsersContext)
|
||||
make migrate-users name=YourMigrationName
|
||||
```
|
||||
|
||||
## Common Development Workflows
|
||||
|
||||
### Adding a New *arr Application Integration
|
||||
1. Add integration in `Cleanuparr.Infrastructure/Features/Arr/`
|
||||
2. Update domain models in `Cleanuparr.Domain/`
|
||||
3. Create/update services in `Cleanuparr.Application/`
|
||||
4. Add API endpoints in `Cleanuparr.Api/Features/Arr/`
|
||||
5. Update frontend in `code/frontend/src/app/`
|
||||
|
||||
### Adding a New Download Client
|
||||
1. Add client implementation in `Cleanuparr.Infrastructure/Features/DownloadClient/`
|
||||
2. Follow existing patterns (qBittorrent, Transmission, etc.)
|
||||
3. Add configuration models to `Cleanuparr.Domain/`
|
||||
4. Update API and frontend as above
|
||||
|
||||
### Adding a New Notification Provider
|
||||
1. Add provider in `Cleanuparr.Infrastructure/Features/Notifications/`
|
||||
2. Update configuration models
|
||||
3. Add UI configuration in frontend
|
||||
|
||||
## Key Gotchas
|
||||
|
||||
- **Custom glassmorphism design system** - Do not introduce external UI frameworks (no PrimeNG, Material, Tailwind)
|
||||
- **All frontend components** must be standalone with OnPush change detection
|
||||
- **Database migrations** require awareness of all three contexts (Data, Events, Users)
|
||||
- **Malware blocker** is a critical security feature - changes require careful testing
|
||||
- **Cross-seed integration** allows keeping torrents that are actively seeding
|
||||
- **Real-time updates** use SignalR - maintain websocket patterns when adding features
|
||||
- Use `@ng-icons/core` + `@ng-icons/tabler-icons` for icons (NOT `angular-tabler-icons` which doesn't support Angular 22)
|
||||
- **Sidebar** stays dark purple in both themes - uses sidebar-specific CSS variables
|
||||
- The project uses **Clean Architecture** - respect layer boundaries
|
||||
- **Settings dirty tracking** uses JSON snapshot comparison (`buildSnapshot()` + `hasPendingChanges()`) — keep this even with Signal Forms; Signal Forms `dirty()` means "touched", not "differs from saved"
|
||||
- **Resource API** (`rxResource`): `value()` throws in the error state — always set a `defaultValue` (lists) or guard with `hasValue()` before reading
|
||||
- **Signal Forms** (`[formField]`) owns `min`/`max`/`disabled`/`required` — set these via schema validators, not template bindings. Custom controls satisfy the contract via `model()` signals (`chip-input` exposes a `value` model; `size-input`'s numeric-min input is named `minValue` to avoid clashing with the field min)
|
||||
@@ -0,0 +1,332 @@
|
||||
# Contributing to Cleanuparr
|
||||
|
||||
Thanks for your interest in contributing to Cleanuparr! This guide will help you get started with development.
|
||||
|
||||
## Before You Start
|
||||
|
||||
### AI usage
|
||||
|
||||
In this ever-evolving field of work, AI is now the shiny new tool to help programmers work faster and there's nothing wrong with that.
|
||||
But it is **very** wrong to rely solely on AI tools to write, review and test your code.
|
||||
|
||||
**If you do not have a background in programming and you do not intend to test your code properly, please do not submit AI-generated code.** If you still want to help in other ways such as testing features, that would also help a lot!
|
||||
|
||||
### Announce Your Intent
|
||||
|
||||
Before starting any work, please let us know what you want to contribute:
|
||||
|
||||
- For existing issues: Comment on the issue stating you'd like to work on it
|
||||
- For new features/changes: Create a new issue first and mention that you want to work on it
|
||||
|
||||
This helps us avoid redundant work, git conflicts, and contributions that may not align with the project's direction.
|
||||
|
||||
**Wait for approval from the maintainers before proceeding with your contribution.**
|
||||
|
||||
## Development Setup
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [.NET 10.0 SDK](https://dotnet.microsoft.com/download/dotnet/10.0)
|
||||
- [Node.js 26+](https://nodejs.org/)
|
||||
- [Git](https://git-scm.com/)
|
||||
- (Optional) [Make](https://www.gnu.org/software/make/) for database migrations
|
||||
- (Optional) IDE: [JetBrains Rider](https://www.jetbrains.com/rider/) or [Visual Studio](https://visualstudio.microsoft.com/)
|
||||
|
||||
### Repository Setup
|
||||
|
||||
1. Fork the repository on GitHub
|
||||
2. Clone your fork locally:
|
||||
```bash
|
||||
git clone https://github.com/YOUR_USERNAME/Cleanuparr.git
|
||||
cd Cleanuparr
|
||||
```
|
||||
3. Add the upstream repository:
|
||||
```bash
|
||||
git remote add upstream https://github.com/Cleanuparr/Cleanuparr.git
|
||||
```
|
||||
|
||||
## Backend Development
|
||||
|
||||
### Initial Setup
|
||||
|
||||
#### 1. Create a GitHub Personal Access Token (PAT)
|
||||
|
||||
Cleanuparr uses GitHub Packages for NuGet dependencies. You'll need a PAT with `read:packages` permission:
|
||||
|
||||
1. Go to [GitHub Settings > Developer Settings > Personal Access Tokens > Tokens (classic)](https://github.com/settings/tokens)
|
||||
2. Click "Generate new token" → "Generate new token (classic)"
|
||||
3. Give it a descriptive name (e.g., "Cleanuparr NuGet Access")
|
||||
4. Set an expiration (recommend 90 days or longer for development)
|
||||
5. Select only the `read:packages` scope
|
||||
6. Click "Generate token" and copy it
|
||||
|
||||
#### 2. Configure NuGet Source
|
||||
|
||||
Add the Cleanuparr NuGet repository:
|
||||
|
||||
```bash
|
||||
dotnet nuget add source \
|
||||
--username YOUR_GITHUB_USERNAME \
|
||||
--password YOUR_GITHUB_PAT \
|
||||
--store-password-in-clear-text \
|
||||
--name Cleanuparr \
|
||||
https://nuget.pkg.github.com/Cleanuparr/index.json
|
||||
```
|
||||
|
||||
Replace `YOUR_GITHUB_USERNAME` and `YOUR_GITHUB_PAT` with your GitHub username and the PAT you created.
|
||||
|
||||
### Running the Backend
|
||||
|
||||
#### Option 1: Using .NET CLI
|
||||
|
||||
Navigate to the backend directory:
|
||||
```bash
|
||||
cd code/backend
|
||||
```
|
||||
|
||||
Build the application:
|
||||
```bash
|
||||
dotnet build Cleanuparr.Api/Cleanuparr.Api.csproj
|
||||
```
|
||||
|
||||
Run the application:
|
||||
```bash
|
||||
dotnet run --project Cleanuparr.Api/Cleanuparr.Api.csproj
|
||||
```
|
||||
|
||||
Run tests:
|
||||
```bash
|
||||
dotnet test
|
||||
```
|
||||
|
||||
The API will be available at http://localhost:5000
|
||||
|
||||
#### Option 2: Using an IDE
|
||||
|
||||
For JetBrains Rider or Visual Studio:
|
||||
1. Open the solution file: `code/backend/cleanuparr.sln`
|
||||
2. Set `Cleanuparr.Api` as the startup project
|
||||
3. Press `F5` to start the application
|
||||
|
||||
### Database Migrations
|
||||
|
||||
Cleanuparr uses two separate database contexts: `DataContext` and `EventsContext`.
|
||||
|
||||
#### Prerequisites
|
||||
|
||||
Install Make if not already installed:
|
||||
- Windows: Install via [Chocolatey](https://chocolatey.org/) (`choco install make`) or use [WSL](https://docs.microsoft.com/windows/wsl/)
|
||||
- macOS: Install via Homebrew (`brew install make`)
|
||||
- Linux: Usually pre-installed, or install via package manager (`apt install make`, `yum install make`, etc.)
|
||||
|
||||
#### Creating Migrations
|
||||
|
||||
From the `code` directory:
|
||||
|
||||
For data migrations (DataContext):
|
||||
```bash
|
||||
make migrate-data name=YourMigrationName
|
||||
```
|
||||
|
||||
For events migrations (EventsContext):
|
||||
```bash
|
||||
make migrate-events name=YourMigrationName
|
||||
```
|
||||
|
||||
Example:
|
||||
```bash
|
||||
make migrate-data name=AddUserPreferences
|
||||
make migrate-events name=AddAuditLogEvents
|
||||
```
|
||||
|
||||
## Frontend Development
|
||||
|
||||
### Setup
|
||||
|
||||
1. Navigate to the frontend directory:
|
||||
```bash
|
||||
cd code/frontend
|
||||
```
|
||||
|
||||
2. Install dependencies:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. Start the development server:
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
The UI will be available at http://localhost:4200
|
||||
|
||||
## Documentation Development
|
||||
|
||||
### Setup
|
||||
|
||||
1. Navigate to the docs directory:
|
||||
```bash
|
||||
cd docs
|
||||
```
|
||||
|
||||
2. Install dependencies:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. Start the development server:
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
The documentation site will be available at http://localhost:3000
|
||||
|
||||
## Building with Docker
|
||||
|
||||
### Building a Local Docker Image
|
||||
|
||||
To build the Docker image locally for testing:
|
||||
|
||||
1. Navigate to the `code` directory:
|
||||
```bash
|
||||
cd code
|
||||
```
|
||||
|
||||
2. Build the image:
|
||||
```bash
|
||||
docker build \
|
||||
--build-arg PACKAGES_USERNAME=YOUR_GITHUB_USERNAME \
|
||||
--build-arg PACKAGES_PAT=YOUR_GITHUB_PAT \
|
||||
-t cleanuparr:local \
|
||||
-f Dockerfile .
|
||||
```
|
||||
|
||||
Replace `YOUR_GITHUB_USERNAME` and `YOUR_GITHUB_PAT` with your credentials.
|
||||
|
||||
3. Run the container:
|
||||
```bash
|
||||
docker run -d \
|
||||
--name cleanuparr-dev \
|
||||
-p 11011:11011 \
|
||||
-v /path/to/config:/config \
|
||||
-e PUID=1000 \
|
||||
-e PGID=1000 \
|
||||
-e TZ=Etc/UTC \
|
||||
cleanuparr:local
|
||||
```
|
||||
|
||||
4. Access the application at http://localhost:11011
|
||||
|
||||
### Building for Multiple Architectures
|
||||
|
||||
Use Docker Buildx for multi-platform builds:
|
||||
|
||||
```bash
|
||||
docker buildx build \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--build-arg PACKAGES_USERNAME=YOUR_GITHUB_USERNAME \
|
||||
--build-arg PACKAGES_PAT=YOUR_GITHUB_PAT \
|
||||
-t cleanuparr:local \
|
||||
-f Dockerfile .
|
||||
```
|
||||
|
||||
## Code Standards
|
||||
|
||||
### Backend (.NET/C#)
|
||||
- Follow existing conventions and [Microsoft C# Coding Conventions](https://docs.microsoft.com/dotnet/csharp/fundamentals/coding-style/coding-conventions)
|
||||
- Use meaningful variable and method names
|
||||
- Add XML documentation comments for public APIs
|
||||
- Write unit tests whenever possible
|
||||
|
||||
### Frontend (Angular/TypeScript)
|
||||
- Follow existing conventions and the [Angular Style Guide](https://angular.io/guide/styleguide)
|
||||
- Use TypeScript strict mode
|
||||
- Write unit tests whenever possible
|
||||
|
||||
### Documentation
|
||||
- Use clear, concise language
|
||||
- Include code examples where appropriate
|
||||
- Update relevant documentation when adding/changing features
|
||||
- Check for spelling and grammar
|
||||
|
||||
## Submitting Your Contribution
|
||||
|
||||
### 1. Create a Feature Branch
|
||||
|
||||
```bash
|
||||
git checkout -b feature/your-feature-name
|
||||
# or
|
||||
git checkout -b fix/your-bug-fix-name
|
||||
```
|
||||
|
||||
### 2. Make Your Changes
|
||||
|
||||
- Write clean, well-documented code
|
||||
- Follow the code standards outlined above
|
||||
- **Test your changes thoroughly!**
|
||||
|
||||
### 3. Commit Your Changes
|
||||
|
||||
Write clear, descriptive commit messages:
|
||||
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "Add feature: brief description of your changes"
|
||||
```
|
||||
|
||||
### 4. Keep Your Branch Updated
|
||||
|
||||
```bash
|
||||
git fetch upstream
|
||||
git rebase upstream/main
|
||||
```
|
||||
|
||||
### 5. Push to Your Fork
|
||||
|
||||
```bash
|
||||
git push origin feature/your-feature-name
|
||||
```
|
||||
|
||||
### 6. Create a Pull Request
|
||||
|
||||
1. Go to the [Cleanuparr repository](https://github.com/Cleanuparr/Cleanuparr)
|
||||
2. Click "New Pull Request"
|
||||
3. Select your fork and branch
|
||||
4. Fill out the PR template with:
|
||||
- A descriptive title (e.g., "Add support for Prowlarr integration" or "Fix memory leak in download client polling")
|
||||
- Description of changes
|
||||
- Related issue number
|
||||
- Testing performed
|
||||
- Screenshots (if applicable)
|
||||
|
||||
### 7. Code Review Process
|
||||
|
||||
- Maintainers will review your PR
|
||||
- Address any feedback or requested changes
|
||||
- Once approved, your PR will be merged
|
||||
|
||||
## Other Ways to Contribute
|
||||
|
||||
### Help Test New Features
|
||||
|
||||
We're always looking for testers to help validate new features before they are released. If you'd like to help test upcoming changes:
|
||||
|
||||
1. Join our [Discord community](https://discord.gg/SCtMCgtsc4)
|
||||
2. Let us know you're interested in testing
|
||||
3. We'll provide you with pre-release builds and testing instructions
|
||||
|
||||
Your feedback helps us catch issues early and deliver better releases.
|
||||
|
||||
## Getting Help
|
||||
|
||||
- Discord: Join our [Discord community](https://discord.gg/SCtMCgtsc4) for real-time help
|
||||
- Issues: Check existing [GitHub issues](https://github.com/Cleanuparr/Cleanuparr/issues) or create a new one
|
||||
- Documentation: Review the [complete documentation](https://cleanuparr.github.io/Cleanuparr/)
|
||||
|
||||
## License
|
||||
|
||||
By contributing to Cleanuparr, you agree that your contributions will be licensed under the same license as the project.
|
||||
|
||||
---
|
||||
|
||||
Thanks for contributing to Cleanuparr!
|
||||
@@ -0,0 +1,3 @@
|
||||
# Cache static files for 5 minutes
|
||||
/static/*
|
||||
Cache-Control: public, max-age=300, s-maxage=300
|
||||
@@ -0,0 +1,674 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 638 B |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 69 KiB |
@@ -1,143 +1,128 @@
|
||||
# cleanuperr
|
||||
<div align="center">
|
||||
|
||||
## Usage
|
||||
_Love this project? Give it a ⭐️ and let others know!_
|
||||
|
||||
```
|
||||
docker run \
|
||||
-e QuartzConfig__BlockedTorrentTrigger="0 0/10 * * * ?" \
|
||||
-e QBitConfig__Url="http://localhost:8080" \
|
||||
-e QBitConfig__Username="user" \
|
||||
-e QBitConfig__Password="pass" \
|
||||
-e SonarrConfig__Instances__0__Url="http://localhost:8989" \
|
||||
-e SonarrConfig__Instances__0__ApiKey="secret1" \
|
||||
-e SonarrConfig__Instances__1__Url="http://localhost:8990" \
|
||||
-e SonarrConfig__Instances__1__ApiKey="secret2" \
|
||||
...
|
||||
flaminel/cleanuperr:latest
|
||||
# <img width="24px" src="./Logo/256.png" alt="Cleanuparr"></img> Cleanuparr
|
||||
|
||||
_/kliː.nʌp.ər/ — like "cleanuper", someone who does the cleanup. Not "CleanupArr" or "CleanUpArr"._
|
||||
|
||||

|
||||

|
||||
[](https://github.com/Cleanuparr/Cleanuparr/actions/workflows/test.yml)
|
||||
|
||||
|
||||
[](https://discord.gg/SCtMCgtsc4)
|
||||
|
||||
</div>
|
||||
|
||||
Cleanuparr is an advanced download manager for the Servarr ecosystem. It works with Sonarr, Radarr, Lidarr, Readarr, and Whisparr alongside download clients like qBittorrent, Transmission, and Deluge. Beyond cleaning up stalled, blocked, and malicious downloads, it searches for missing content and quality upgrades, manages seeding, and removes orphaned files.
|
||||
|
||||
Cleanuparr was created primarily to address malicious files, such as `*.lnk` or `*.zipx`, that were getting stuck in Sonarr/Radarr and required manual intervention. Some of the reddit posts that made Cleanuparr come to life can be found [here](https://www.reddit.com/r/sonarr/comments/1gqnx16/psa_sonarr_downloaded_a_virus/), [here](https://www.reddit.com/r/sonarr/comments/1gqwklr/sonar_downloaded_a_mkv_file_which_looked_like_a/), [here](https://www.reddit.com/r/sonarr/comments/1gpw2wa/downloaded_waiting_to_import/) and [here](https://www.reddit.com/r/sonarr/comments/1gpi344/downloads_not_importing_no_files_found/).
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Features:**
|
||||
> - Strike system to mark bad downloads.
|
||||
> - Remove and block downloads that reached a maximum number of strikes.
|
||||
> - Remove and block downloads that are **failing to be imported** by the arrs.
|
||||
> - Remove and block downloads that are **stalled** or in **metadata downloading** state.
|
||||
> - Remove and block downloads that have a **low download speed** or **high estimated completion time**.
|
||||
> - Remove and block downloads blocked by qBittorrent or by Cleanuparr's **Malware Blocker**.
|
||||
> - Remove and block known malware based on patterns found by the community.
|
||||
> - Automatically trigger a search for downloads removed from the arrs.
|
||||
> - Proactively search for **missing** items across your Radarr and Sonarr libraries.
|
||||
> - Search for **quality upgrades** for items that haven't met their quality profile's cutoff (a.k.a. **Cutoff Unmet**).
|
||||
> - Search for **custom format score upgrades** with automatic score tracking.
|
||||
> - Clean up downloads that have been **seeding** for a certain amount of time.
|
||||
> - Remove downloads that are **orphaned**/have no **hardlinks**/are not referenced by the arrs anymore (with [cross-seed](https://www.cross-seed.org/) support).
|
||||
> - Scan configured directories for **files not claimed by any active torrent**, move them to a dedicated orphaned directory, and optionally auto-purge.
|
||||
> - Notify on strike or download removal.
|
||||
> - Ignore certain torrent hashes, categories, tags or trackers from being processed by Cleanuparr.
|
||||
|
||||
## Sponsored by GitAds
|
||||
[](https://gitads.dev/v1/ad-track?source=cleanuparr/cleanuparr@github)
|
||||
|
||||
## Screenshots
|
||||
|
||||
https://cleanuparr.github.io/Cleanuparr/docs/screenshots
|
||||
|
||||
## 🎯 Supported Applications
|
||||
|
||||
### *Arr Applications (latest version)
|
||||
- **Sonarr**
|
||||
- **Radarr**
|
||||
- **Lidarr**
|
||||
- **Readarr**
|
||||
- **Whisparr v2**
|
||||
- **Whisparr v3**
|
||||
|
||||
### Download Clients (latest version)
|
||||
- **qBittorrent**
|
||||
- **Transmission**
|
||||
- **Deluge**
|
||||
- **µTorrent**
|
||||
- **rTorrent**
|
||||
|
||||
### Platforms
|
||||
- **Docker**
|
||||
- **Windows**
|
||||
- **macOS**
|
||||
- **Linux**
|
||||
- **Unraid**
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
```bash
|
||||
docker run -d --name cleanuparr \
|
||||
--restart unless-stopped \
|
||||
-p 11011:11011 \
|
||||
-v /path/to/config:/config \
|
||||
-e PORT=11011 \
|
||||
-e PUID=1000 \
|
||||
-e PGID=1000 \
|
||||
-e TZ=Etc/UTC \
|
||||
ghcr.io/cleanuparr/cleanuparr:latest
|
||||
```
|
||||
|
||||
## Environment variables
|
||||
For Docker Compose, health checks, and other installation methods, see the [Complete Installation Guide](https://cleanuparr.github.io/Cleanuparr/docs/installation/detailed), but not before reading the [Prerequisites](https://cleanuparr.github.io/Cleanuparr/docs/installation/).
|
||||
|
||||
| Variable | Required | Description | Default value |
|
||||
|---|---|---|---|
|
||||
| QuartzConfig__BlockedTorrentTrigger | No | Quartz cron trigger | 0 0/5 * * * ? |
|
||||
| QBitConfig__Url | Yes | qBittorrent instance url | http://localhost:8080 |
|
||||
| QBitConfig__Username | Yes | qBittorrent user | empty |
|
||||
| QBitConfig__Password | Yes | qBittorrent password | empty |
|
||||
| SonarrConfig__Instances__0__Url | Yes | First Sonarr instance url | http://localhost:8989 |
|
||||
| SonarrConfig__Instances__0__ApiKey | Yes | First Sonarr instance API key | empty |
|
||||
> Prefer not to self-host? A managed Cleanuparr instance is available via [ElfHosted](https://store.elfhosted.com/product-category/personal-stacks/?utm_source=github&utm_medium=readme&utm_campaign=cleanuparr-readme), bundled alongside Sonarr/Radarr to keep your queues tidy (7-day trial).
|
||||
|
||||
#
|
||||
|
||||
Multiple Sonarr instances can be specified using this format:
|
||||
### 🌐 Access the Web Interface
|
||||
|
||||
After installation, open your browser and navigate to:
|
||||
```
|
||||
SonarrConfig__Instances__<NUMBER>__Url
|
||||
SonarrConfig__Instances__<NUMBER>__ApiKey
|
||||
http://localhost:11011
|
||||
```
|
||||
|
||||
where `<NUMBER>` starts from 0.
|
||||
**Next Steps:** Check out the [📖 Complete Documentation](https://cleanuparr.github.io/Cleanuparr/) for detailed configuration guides and setup instructions.
|
||||
|
||||
## How it works
|
||||
## 📖 Documentation & Support
|
||||
|
||||
1. Add excluded file names to prevent malicious files from being downloaded by qBittorrent.
|
||||
2. cleanuperr goes through all items in Sonarr's queue every at every 5th minute.
|
||||
3. For each queue item, a call is made to qBittorrent to get the stats of the torrent.
|
||||
4. If a torrent is found to be marked as completed, but with 0 downloaded bytes, cleanuperr calls Sonarr to add that torrent to the blocklist.
|
||||
5. If any malicious torrents have been found, cleanuperr calls Sonarr to automatically search again.
|
||||
- **📚 [Complete Documentation](https://cleanuparr.github.io/Cleanuparr/)** - Installation guides, configuration, and troubleshooting
|
||||
- **⚙️ [Configuration Guide](https://cleanuparr.github.io/Cleanuparr/docs/category/configuration)** - Set up download clients, *arr apps, and features
|
||||
- **🔧 [Setup Scenarios](https://cleanuparr.github.io/Cleanuparr/docs/category/setup-scenarios)** - Common use cases and examples
|
||||
- **💬 [Discord Community](https://discord.gg/SCtMCgtsc4)** - Get help and discuss with other users
|
||||
- **🔗 [GitHub Releases](https://github.com/Cleanuparr/Cleanuparr/releases)** - Download binaries and view changelog
|
||||
|
||||
<details>
|
||||
<summary>Extensions to block</summary>
|
||||
<pre><code>*.apk
|
||||
*.bat
|
||||
*.bin
|
||||
*.bmp
|
||||
*.cmd
|
||||
*.com
|
||||
*.db
|
||||
*.diz
|
||||
*.dll
|
||||
*.dmg
|
||||
*.etc
|
||||
*.exe
|
||||
*.gif
|
||||
*.htm
|
||||
*.html
|
||||
*.ico
|
||||
*.ini
|
||||
*.iso
|
||||
*.jar
|
||||
*.jpg
|
||||
*.js
|
||||
*.link
|
||||
*.lnk
|
||||
*.msi
|
||||
*.nfo
|
||||
*.perl
|
||||
*.php
|
||||
*.pl
|
||||
*.png
|
||||
*.ps1
|
||||
*.psc1
|
||||
*.psd1
|
||||
*.psm1
|
||||
*.py
|
||||
*.pyd
|
||||
*.rb
|
||||
*.readme
|
||||
*.reg
|
||||
*.run
|
||||
*.scr
|
||||
*.sh
|
||||
*.sql
|
||||
*.text
|
||||
*.thumb
|
||||
*.torrent
|
||||
*.txt
|
||||
*.url
|
||||
*.vbs
|
||||
*.wsf
|
||||
*.xml
|
||||
*.zipx
|
||||
*.7z
|
||||
*.bdjo
|
||||
*.bdmv
|
||||
*.bin
|
||||
*.bmp
|
||||
*.cci
|
||||
*.clpi
|
||||
*.crt
|
||||
*.dll
|
||||
*.exe
|
||||
*.html
|
||||
*.idx
|
||||
*.inf
|
||||
*.jar
|
||||
*.jpeg
|
||||
*.jpg
|
||||
*.lnk
|
||||
*.m4a
|
||||
*.mpls
|
||||
*.msi
|
||||
*.nfo
|
||||
*.pdf
|
||||
*.png
|
||||
*.rar
|
||||
*(sample).*
|
||||
*sample.mkv
|
||||
*sample.mp4
|
||||
*.sfv
|
||||
*.srt
|
||||
*.sub
|
||||
*.tbl
|
||||
Trailer.*
|
||||
*.txt
|
||||
*.url
|
||||
*.xig
|
||||
*.xml
|
||||
*.xrt
|
||||
*.zip
|
||||
*.zipx
|
||||
*.Lnk
|
||||
</code></pre>
|
||||
</details>
|
||||
## 🤝 Contributing
|
||||
|
||||
We welcome contributions from the community! Whether it's bug fixes, new features, documentation improvements, or testing, your help is appreciated.
|
||||
|
||||
**Before contributing:** Please read our [Contributing Guide](CONTRIBUTING.md) and announce your intent to work on an issue before starting.
|
||||
|
||||
- **[Contributing Guide](CONTRIBUTING.md)** - Learn how to set up your development environment and submit contributions
|
||||
- **[Report Issues](https://github.com/Cleanuparr/Cleanuparr/issues/new/choose)** - Found a bug? Let us know!
|
||||
- **[Feature Requests](https://github.com/Cleanuparr/Cleanuparr/issues/new/choose)** - Share your ideas for new features
|
||||
- **[Help Test Features](https://discord.gg/SCtMCgtsc4)** - Join Discord to test pre-release features and provide feedback
|
||||
|
||||
# Credits
|
||||
Special thanks for inspiration go to:
|
||||
- [ThijmenGThN/swaparr](https://github.com/ThijmenGThN/swaparr)
|
||||
- [ManiMatter/decluttarr](https://github.com/ManiMatter/decluttarr)
|
||||
- [PaeyMoopy/sonarr-radarr-queue-cleaner](https://github.com/PaeyMoopy/sonarr-radarr-queue-cleaner)
|
||||
- [Sonarr](https://github.com/Sonarr/Sonarr) & [Radarr](https://github.com/Radarr/Radarr)
|
||||
|
||||
# Buy me a coffee
|
||||
If I made your life just a tiny bit easier, consider buying me a coffee!
|
||||
|
||||
<a href="https://buymeacoffee.com/flaminel" target="_blank"><img src="https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png" alt="Buy Me A Coffee" style="height: 41px !important;width: 174px !important;box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;-webkit-box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;" ></a>
|
||||
@@ -0,0 +1,87 @@
|
||||
# Releasing Cleanuparr
|
||||
|
||||
Versioning rules and the release process are detailed in the user-facing version of this policy at [docs/docs/installation/versioning.mdx](docs/docs/installation/versioning.mdx) and published under [Versioning](https://cleanuparr.github.io/docs/installation/versioning).
|
||||
|
||||
## Versioning
|
||||
|
||||
This policy takes effect starting with **v2.10.0**. Previous releases did not follow this approach, and their version numbers have no compatibility meaning.
|
||||
|
||||
| Part | Bump when |
|
||||
| --- | --- |
|
||||
| Major | Probably never, unless something significant changes. |
|
||||
| Minor | The release contains a breaking change. |
|
||||
| Patch | Anything else: bug fixes and new features. |
|
||||
|
||||
## Deciding the number
|
||||
|
||||
Every pull request includes a category label, which is enforced by [pr-label.yml](.github/workflows/pr-label.yml). The `breaking` label determines the version number.
|
||||
|
||||
From the `code` directory, use this command to list every breaking pull request that was merged since the last release and indicate which number to bump:
|
||||
|
||||
```bash
|
||||
make release-check
|
||||
```
|
||||
|
||||
To view the full release notes for a version before tagging it, without creating anything:
|
||||
|
||||
```bash
|
||||
make release-notes version=2.11.0
|
||||
```
|
||||
|
||||
A `## Breaking Changes` section means the release is a minor version. If this section is absent, the release is a patch.
|
||||
|
||||
[release.yml](.github/workflows/release.yml) performs the same check and stops the release process before anything is built if breaking pull requests were merged but the minor version did not increment. It issues a warning but continues if the minor version moves without a breaking pull request leading it.
|
||||
|
||||
Examples of breaking changes:
|
||||
|
||||
- A migration that drops or rewrites data, preventing rollback to the previous version
|
||||
- Renaming or removing an environment variable
|
||||
- Dropping a platform, architecture, installer, or database provider
|
||||
- A default value that alters how an existing configuration interacts with user data
|
||||
- A setting whose unit or interpretation changes and cannot be altered by a migration
|
||||
- Dropping a download client or *arr application, or increasing the minimum supported version
|
||||
- A change to the public REST API (e.g., the `stats` endpoint)
|
||||
|
||||
Examples of non-breaking changes:
|
||||
|
||||
- A migration that simply adds columns or tables
|
||||
- Renaming any value stored in the database and configured through the UI, accompanied by a migration
|
||||
- Any change to the private REST API, which is intended only for the UI
|
||||
- Modifications to the Web UI layout, internal structure, or logging
|
||||
|
||||
## Auditing migrations before a release
|
||||
|
||||
The easiest breaking change to overlook is a migration that prevents rollback. To see the migrations added since the previous tag:
|
||||
|
||||
```bash
|
||||
git diff --name-only <last-tag>..main -- '*/Migrations/**' \
|
||||
| grep -v -e Designer -e Snapshot \
|
||||
| xargs grep -l 'DropColumn\|DropTable\|AlterColumn\|RenameColumn\|DeleteData\|migrationBuilder\.Sql'
|
||||
```
|
||||
|
||||
Only `Up()` matters, since every migration drops things in `Down()` by definition, leading to false positives in that grep.
|
||||
|
||||
Additive changes are safe. `AddColumn` and `CreateTable` allow previous versions to start. However, a conversion or rewrite does not.
|
||||
|
||||
## Release process
|
||||
|
||||
Tag pushes that match `v*.*.*` trigger [release.yml](.github/workflows/release.yml).
|
||||
|
||||
```bash
|
||||
git tag v2.10.0
|
||||
git push origin v2.10.0
|
||||
```
|
||||
|
||||
### Release notes
|
||||
|
||||
GitHub generates notes from the pull requests merged since the last tag, grouped into sections by label according to [release.yml](.github/release.yml):
|
||||
|
||||
| Label | Section |
|
||||
| --- | --- |
|
||||
| `breaking` | Breaking Changes |
|
||||
| `enhancement` | Features |
|
||||
| `bug` | Bug Fixes |
|
||||
| `documentation` | Documentation |
|
||||
| `chore` | Maintenance |
|
||||
|
||||
The status check for `Require category label` is mandatory on `main`, so a missing label will be caught before merging. Labels added after merging still apply because notes are generated at release time.
|
||||
@@ -0,0 +1,850 @@
|
||||
*(sample).*
|
||||
*sample.avchd
|
||||
*sample.avi
|
||||
*sample.mkv
|
||||
*sample.mov
|
||||
*sample.mp4
|
||||
*sample.webm
|
||||
*sample.wmv
|
||||
*.000
|
||||
*.001
|
||||
*.002
|
||||
*.004
|
||||
*.0xe
|
||||
*.73k
|
||||
*.73p
|
||||
*.7z
|
||||
*.7z.001
|
||||
*.7z.002
|
||||
*.89k
|
||||
*.89z
|
||||
*.8ck
|
||||
*.a00
|
||||
*.a01
|
||||
*.a02
|
||||
*.a7r
|
||||
*.ac
|
||||
*.acc
|
||||
*.ace
|
||||
*.acr
|
||||
*.actc
|
||||
*.action
|
||||
*.actm
|
||||
*.ade
|
||||
*.adp
|
||||
*.afmacro
|
||||
*.afmacros
|
||||
*.ahk
|
||||
*.ai
|
||||
*.aif
|
||||
*.ain
|
||||
*.air
|
||||
*.alz
|
||||
*.ana
|
||||
*.apex
|
||||
*.api
|
||||
*.apk
|
||||
*.app
|
||||
*.appimage
|
||||
*.applescript
|
||||
*.application
|
||||
*.appx
|
||||
*.apz
|
||||
*.ar
|
||||
*.arc
|
||||
*.archiver
|
||||
*.arduboy
|
||||
*.arh
|
||||
*.ari
|
||||
*.arj
|
||||
*.ark
|
||||
*.arscript
|
||||
*.asb
|
||||
*.asice
|
||||
*.asp
|
||||
*.aspx
|
||||
*.aspx-exe
|
||||
*.atmx
|
||||
*.ayt
|
||||
*.azw2
|
||||
*.b1
|
||||
*.b6z
|
||||
*.b64
|
||||
*.ba
|
||||
*.ba_
|
||||
*.bak
|
||||
*.bas
|
||||
*.bash
|
||||
*.bat
|
||||
*.bdjo
|
||||
*.bdmv
|
||||
*.bdoc
|
||||
*.beam
|
||||
*.bh
|
||||
*.bin
|
||||
*.bmp
|
||||
*.bms
|
||||
*.bndl
|
||||
*.bns
|
||||
*.boo
|
||||
*.bsa
|
||||
*.btm
|
||||
*.bundle
|
||||
*.bz
|
||||
*.bz2
|
||||
*.bza
|
||||
*.bzabw
|
||||
*.bzip
|
||||
*.bzip2
|
||||
*.c
|
||||
*.c00
|
||||
*.c01
|
||||
*.c02
|
||||
*.c10
|
||||
*.cab
|
||||
*.caction
|
||||
*.car
|
||||
*.cb7
|
||||
*.cba
|
||||
*.cbr
|
||||
*.cbt
|
||||
*.cbz
|
||||
*.cci
|
||||
*.cda
|
||||
*.cdb
|
||||
*.cdz
|
||||
*.cel
|
||||
*.celx
|
||||
*.cfs
|
||||
*.cgi
|
||||
*.cheat
|
||||
*.chm
|
||||
*.cit
|
||||
*.ckpt
|
||||
*.cla
|
||||
*.class
|
||||
*.clpi
|
||||
*.cmd
|
||||
*.cof
|
||||
*.coffee
|
||||
*.com
|
||||
*.command
|
||||
*.comppkg.hauptwerk.rar
|
||||
*.comppkg_hauptwerk_rar
|
||||
*.conda
|
||||
*.conf
|
||||
*.config
|
||||
*.cp9
|
||||
*.cpgz
|
||||
*.cpl
|
||||
*.cpt
|
||||
*.crt
|
||||
*.cs
|
||||
*.csh
|
||||
*.csharp
|
||||
*.csproj
|
||||
*.css
|
||||
*.csv
|
||||
*.ctx
|
||||
*.ctz
|
||||
*.cue
|
||||
*.cur
|
||||
*.cxarchive
|
||||
*.cyw
|
||||
*.czip
|
||||
*.daemon
|
||||
*.daf
|
||||
*.dar
|
||||
*.dat
|
||||
*.data-00000-of-00001
|
||||
*.db
|
||||
*.dd
|
||||
*.deamon
|
||||
*.deb
|
||||
*.dek
|
||||
*.dgc
|
||||
*.dist
|
||||
*.diz
|
||||
*.dl_
|
||||
*.dld
|
||||
*.dll
|
||||
*.dmc
|
||||
*.dmg
|
||||
*.doc
|
||||
*.docb
|
||||
*.docm
|
||||
*.docx
|
||||
*.dot
|
||||
*.dotb
|
||||
*.dotm
|
||||
*.drv
|
||||
*.ds
|
||||
*.dw
|
||||
*.dword
|
||||
*.dxl
|
||||
*.dz
|
||||
*.e_e
|
||||
*.ear
|
||||
*.ebacmd
|
||||
*.ebm
|
||||
*.ebs
|
||||
*.ebs2
|
||||
*.ecar
|
||||
*.ecf
|
||||
*.ecs
|
||||
*.ecsbx
|
||||
*.edz
|
||||
*.efw
|
||||
*.egg
|
||||
*.eham
|
||||
*.elf
|
||||
*.elf-so
|
||||
*.email
|
||||
*.emu
|
||||
*.epk
|
||||
*.epi
|
||||
*.es
|
||||
*.esh
|
||||
*.etc
|
||||
*.ex4
|
||||
*.ex5
|
||||
*.ex_
|
||||
*.exe
|
||||
*.exe-only
|
||||
*.exe-service
|
||||
*.exe-small
|
||||
*.exe1
|
||||
*.exopc
|
||||
*.exz
|
||||
*.ezs
|
||||
*.ezt
|
||||
*.f
|
||||
*.f3z
|
||||
*.fas
|
||||
*.fba
|
||||
*.fcx
|
||||
*.fky
|
||||
*.flac
|
||||
*.flatpak
|
||||
*.flv
|
||||
*.fp8
|
||||
*.fpi
|
||||
*.frs
|
||||
*.fxp
|
||||
*.fzpz
|
||||
*.gadget
|
||||
*.gar
|
||||
*.gat
|
||||
*.gca
|
||||
*.gif
|
||||
*.gifv
|
||||
*.gm9
|
||||
*.gmz
|
||||
*.gpe
|
||||
*.gpu
|
||||
*.gs
|
||||
*.gz
|
||||
*.gz2
|
||||
*.gza
|
||||
*.gzi
|
||||
*.gzip
|
||||
*.h5
|
||||
*.ha
|
||||
*.ham
|
||||
*.hbc
|
||||
*.hbc2
|
||||
*.hbe
|
||||
*.hex
|
||||
*.hki
|
||||
*.hki1
|
||||
*.hki2
|
||||
*.hki3
|
||||
*.hlp
|
||||
*.hms
|
||||
*.hpf
|
||||
*.hpk
|
||||
*.hpkg
|
||||
*.hta
|
||||
*.hta-psh
|
||||
*.htaccess
|
||||
*.htm
|
||||
*.html
|
||||
*.htmi
|
||||
*.hyp
|
||||
*.iadproj
|
||||
*.icd
|
||||
*.ice
|
||||
*.icns
|
||||
*.ico
|
||||
*.idx
|
||||
*.iim
|
||||
*.img
|
||||
*.index
|
||||
*.inf
|
||||
*.ini
|
||||
*.ink
|
||||
*.ins
|
||||
*.ipa
|
||||
*.ipf
|
||||
*.ipg
|
||||
*.ipk
|
||||
*.ipsw
|
||||
*.iqylink
|
||||
*.ish
|
||||
*.iso
|
||||
*.isp
|
||||
*.isu
|
||||
*.isx
|
||||
*.ita
|
||||
*.ize
|
||||
*.izh
|
||||
*.izma ace
|
||||
*.j
|
||||
*.jar
|
||||
*.jar.pack
|
||||
*.java
|
||||
*.jex
|
||||
*.jgz
|
||||
*.jhh
|
||||
*.jic
|
||||
*.jpeg
|
||||
*.jpg
|
||||
*.js
|
||||
*.js_be
|
||||
*.js_le
|
||||
*.jse
|
||||
*.jsf
|
||||
*.json
|
||||
*.jsonlz4
|
||||
*.jsp
|
||||
*.jsx
|
||||
*.kextraction
|
||||
*.kgb
|
||||
*.kix
|
||||
*.ksh
|
||||
*.ksp
|
||||
*.kwgt
|
||||
*.kx
|
||||
*.kz
|
||||
*.layout
|
||||
*.lbr
|
||||
*.lck
|
||||
*.ldb
|
||||
*.lemon
|
||||
*.lha
|
||||
*.lhzd
|
||||
*.lib
|
||||
*.libzip
|
||||
*.link
|
||||
*.lnk
|
||||
*.lo
|
||||
*.lock
|
||||
*.log
|
||||
*.loop-vbs
|
||||
*.lpkg
|
||||
*.lqr
|
||||
*.ls
|
||||
*.lz
|
||||
*.lz4
|
||||
*.lzh
|
||||
*.lzm
|
||||
*.lzma
|
||||
*.lzo
|
||||
*.lzr
|
||||
*.lzx
|
||||
*.m3u
|
||||
*.m4a
|
||||
*.mac
|
||||
*.macho
|
||||
*.mamc
|
||||
*.manifest
|
||||
*.mar
|
||||
*.mbz
|
||||
*.mcr
|
||||
*.md
|
||||
*.mda
|
||||
*.mdb
|
||||
*.mde
|
||||
*.mdf
|
||||
*.mdn
|
||||
*.mdt
|
||||
*.mel
|
||||
*.mem
|
||||
*.memo
|
||||
*.meta
|
||||
*.mgm
|
||||
*.mhm
|
||||
*.mht
|
||||
*.mhtml
|
||||
*.mid
|
||||
*.mint
|
||||
*.mio
|
||||
*.mlappinstall
|
||||
*.mlproj
|
||||
*.mlx
|
||||
*.mm
|
||||
*.mobileconfig
|
||||
*.model
|
||||
*.moo
|
||||
*.mou
|
||||
*.movpkg
|
||||
*.mozlz4
|
||||
*.mp3
|
||||
*.mpa
|
||||
*.mpk
|
||||
*.mpkg
|
||||
*.mpls
|
||||
*.mrc
|
||||
*.mrp
|
||||
*.ms
|
||||
*.msc
|
||||
*.msh
|
||||
*.msh1
|
||||
*.msh1xml
|
||||
*.msh2
|
||||
*.msh2xml
|
||||
*.mshxml
|
||||
*.msi
|
||||
*.msi-nouac
|
||||
*.msix
|
||||
*.msl
|
||||
*.msp
|
||||
*.mst
|
||||
*.msu
|
||||
*.mxc
|
||||
*.mxe
|
||||
*.mzp
|
||||
*.n
|
||||
*.nar
|
||||
*.ncl
|
||||
*.net
|
||||
*.nex
|
||||
*.nexe
|
||||
*.nfo
|
||||
*.npk
|
||||
*.nrg
|
||||
*.num
|
||||
*.nz
|
||||
*.nzb.bz2
|
||||
*.nzb.gz
|
||||
*.nzbs
|
||||
*.oar
|
||||
*.ocx
|
||||
*.odlgz
|
||||
*.odt
|
||||
*.opk
|
||||
*.ore
|
||||
*.osf
|
||||
*.ost
|
||||
*.osx
|
||||
*.osx-app
|
||||
*.otm
|
||||
*.out
|
||||
*.ova
|
||||
*.oz
|
||||
*.p
|
||||
*.p01
|
||||
*.p19
|
||||
*.p7z
|
||||
*.pa
|
||||
*.pack.gz
|
||||
*.package
|
||||
*.pae
|
||||
*.paf
|
||||
*.pak
|
||||
*.paq6
|
||||
*.paq7
|
||||
*.paq8
|
||||
*.paq8f
|
||||
*.paq8l
|
||||
*.paq8p
|
||||
*.par
|
||||
*.par2
|
||||
*.pax
|
||||
*.pb
|
||||
*.pbi
|
||||
*.pcd
|
||||
*.pcv
|
||||
*.pdb
|
||||
*.pdf
|
||||
*.pea
|
||||
*.perl
|
||||
*.pet
|
||||
*.pex
|
||||
*.pf
|
||||
*.phar
|
||||
*.php
|
||||
*.php5
|
||||
*.pif
|
||||
*.pim
|
||||
*.pima
|
||||
*.pit
|
||||
*.piz
|
||||
*.pkg
|
||||
*.pkg.tar.xz
|
||||
*.pkg.tar.zst
|
||||
*.pkz
|
||||
*.pl
|
||||
*.plsc
|
||||
*.plx
|
||||
*.png
|
||||
*.pol
|
||||
*.pot
|
||||
*.potm
|
||||
*.powershell
|
||||
*.ppam
|
||||
*.ppkg
|
||||
*.pps
|
||||
*.ppsm
|
||||
*.ppt
|
||||
*.pptm
|
||||
*.pptx
|
||||
*.prc
|
||||
*.prg
|
||||
*.prs
|
||||
*.ps
|
||||
*.ps1
|
||||
*.ps1xml
|
||||
*.ps2
|
||||
*.ps2xml
|
||||
*.psc1
|
||||
*.psc2
|
||||
*.psd
|
||||
*.psd1
|
||||
*.psh
|
||||
*.psh-cmd
|
||||
*.psh-net
|
||||
*.psh-reflection
|
||||
*.psm1
|
||||
*.pst
|
||||
*.psz
|
||||
*.pt
|
||||
*.pup
|
||||
*.puz
|
||||
*.pvd
|
||||
*.pvmp
|
||||
*.pvmz
|
||||
*.pwa
|
||||
*.pwc
|
||||
*.pxl
|
||||
*.pxo
|
||||
*.py
|
||||
*.pyc
|
||||
*.pyd
|
||||
*.pyo
|
||||
*.python
|
||||
*.pyz
|
||||
*.q
|
||||
*.qda
|
||||
*.qit
|
||||
*.qpx
|
||||
*.r0
|
||||
*.r00
|
||||
*.r01
|
||||
*.r02
|
||||
*.r03
|
||||
*.r04
|
||||
*.r1
|
||||
*.r2
|
||||
*.r21
|
||||
*.r30
|
||||
*.ram
|
||||
*.rar
|
||||
*.raw
|
||||
*.rb
|
||||
*.rbf
|
||||
*.rbx
|
||||
*.readme
|
||||
*.reg
|
||||
*.resources
|
||||
*.resx
|
||||
*.rev
|
||||
*.rfs
|
||||
*.rfu
|
||||
*.rgs
|
||||
*.rk
|
||||
*.rm
|
||||
*.rnc
|
||||
*.rox
|
||||
*.rp9
|
||||
*.rpg
|
||||
*.rpj
|
||||
*.rpm
|
||||
*.rss
|
||||
*.ruby
|
||||
*.run
|
||||
*.rxe
|
||||
*.rz
|
||||
*.s00
|
||||
*.s01
|
||||
*.s02
|
||||
*.s09
|
||||
*.s2a
|
||||
*.s7z
|
||||
*.sample
|
||||
*.sapk
|
||||
*.sar
|
||||
*.savedmodel
|
||||
*.sbs
|
||||
*.sbx
|
||||
*.sca
|
||||
*.scar
|
||||
*.scb
|
||||
*.scf
|
||||
*.scpt
|
||||
*.scptd
|
||||
*.scr
|
||||
*.script
|
||||
*.sct
|
||||
*.sdc
|
||||
*.sdn
|
||||
*.sdoc
|
||||
*.sdocx
|
||||
*.sea
|
||||
*.seed
|
||||
*.sen
|
||||
*.server
|
||||
*.service
|
||||
*.sfg
|
||||
*.sfm
|
||||
*.sfs
|
||||
*.sfv
|
||||
*.sfx
|
||||
*.sh
|
||||
*.shar
|
||||
*.shb
|
||||
*.shell
|
||||
*.shk
|
||||
*.shortcut
|
||||
*.shr
|
||||
*.shs
|
||||
*.shtml
|
||||
*.sifz
|
||||
*.sipa
|
||||
*.sit
|
||||
*.sitx
|
||||
*.sk
|
||||
*.sldm
|
||||
*.sln
|
||||
*.smm
|
||||
*.smpf
|
||||
*.snap
|
||||
*.snagitstamps
|
||||
*.snappy
|
||||
*.snb
|
||||
*.snd
|
||||
*.snz
|
||||
*.spa
|
||||
*.spd
|
||||
*.spl
|
||||
*.spm
|
||||
*.spr
|
||||
*.spt
|
||||
*.sql
|
||||
*.sqf
|
||||
*.sqx
|
||||
*.sqz
|
||||
*.srec
|
||||
*.srep
|
||||
*.srt
|
||||
*.ssm
|
||||
*.stg
|
||||
*.stkdoodlz
|
||||
*.stproj
|
||||
*.sts
|
||||
*.sub
|
||||
*.svg
|
||||
*.swf
|
||||
*.sy_
|
||||
*.sys
|
||||
*.tar
|
||||
*.tar.bz2
|
||||
*.tar.gz
|
||||
*.tar.gz2
|
||||
*.tar.lz
|
||||
*.tar.lzma
|
||||
*.tar.xz
|
||||
*.tar.z
|
||||
*.tar.zip
|
||||
*.taz
|
||||
*.tbl
|
||||
*.tbz
|
||||
*.tbz2
|
||||
*.tcp
|
||||
*.tcx
|
||||
*.text
|
||||
*.tf
|
||||
*.tg
|
||||
*.tgs
|
||||
*.tgz
|
||||
*.thm
|
||||
*.thmx
|
||||
*.thumb
|
||||
*.tiapp
|
||||
*.tif
|
||||
*.tiff
|
||||
*.tipa
|
||||
*.tlz
|
||||
*.tlzma
|
||||
*.tmp
|
||||
*.tms
|
||||
*.toast
|
||||
*.torrent
|
||||
*.tpk
|
||||
*.tpsr
|
||||
*.trs
|
||||
*.txt
|
||||
*.tx_
|
||||
*.txz
|
||||
*.tz
|
||||
*.tzst
|
||||
*.u3p
|
||||
*.ubz
|
||||
*.uc2
|
||||
*.udf
|
||||
*.ufdr
|
||||
*.ufs.uzip
|
||||
*.uha
|
||||
*.upk
|
||||
*.upx
|
||||
*.url
|
||||
*.uue
|
||||
*.uvm
|
||||
*.uw8
|
||||
*.uzed
|
||||
*.uzip
|
||||
*.vb
|
||||
*.vba
|
||||
*.vba-exe
|
||||
*.vba-psh
|
||||
*.vbapplication
|
||||
*.vbe
|
||||
*.vbs
|
||||
*.vbscript
|
||||
*.vbscript
|
||||
*.vcd
|
||||
*.vdo
|
||||
*.vem
|
||||
*.vexe
|
||||
*.vfs
|
||||
*.vhd
|
||||
*.vhdx
|
||||
*.vib
|
||||
*.vip
|
||||
*.vlx
|
||||
*.vm
|
||||
*.vmcz
|
||||
*.vmdk
|
||||
*.vms
|
||||
*.vob
|
||||
*.vocab
|
||||
*.voca
|
||||
*.vpk
|
||||
*.vpm
|
||||
*.vrpackage
|
||||
*.vsi
|
||||
*.vwi
|
||||
*.vxp
|
||||
*.wa
|
||||
*.wacz
|
||||
*.waff
|
||||
*.war
|
||||
*.wastickers
|
||||
*.wav
|
||||
*.wbk
|
||||
*.wcm
|
||||
*.wdz
|
||||
*.webm
|
||||
*.whl
|
||||
*.wick
|
||||
*.widget
|
||||
*.wim
|
||||
*.wiz
|
||||
*.wlb
|
||||
*.wma
|
||||
*.workflow
|
||||
*.wot
|
||||
*.wpk
|
||||
*.wpl
|
||||
*.wpm
|
||||
*.wps
|
||||
*.ws
|
||||
*.wsc
|
||||
*.wsf
|
||||
*.wsh
|
||||
*.wux
|
||||
*.x86
|
||||
*.x86_64
|
||||
*.xaml
|
||||
*.xap
|
||||
*.xapk
|
||||
*.xar
|
||||
*.xbap
|
||||
*.xbe
|
||||
*.xcf.bz2
|
||||
*.xcf.gz
|
||||
*.xcf.xz
|
||||
*.xcfbz2
|
||||
*.xcfgz
|
||||
*.xcfxz
|
||||
*.xex
|
||||
*.xez
|
||||
*.xfp
|
||||
*.xig
|
||||
*.xip
|
||||
*.xla
|
||||
*.xlam
|
||||
*.xll
|
||||
*.xlm
|
||||
*.xls
|
||||
*.xlsb
|
||||
*.xlsm
|
||||
*.xlsx
|
||||
*.xlt
|
||||
*.xltb
|
||||
*.xltm
|
||||
*.xlw
|
||||
*.xmcdz
|
||||
*.xml
|
||||
*.xoj
|
||||
*.xopp
|
||||
*.xqt
|
||||
*.xrt
|
||||
*.xx
|
||||
*.xys
|
||||
*.xz
|
||||
*.xzm
|
||||
*.y
|
||||
*.yc
|
||||
*.ygh
|
||||
*.yz1
|
||||
*.z
|
||||
*.z00
|
||||
*.z01
|
||||
*.z02
|
||||
*.z03
|
||||
*.z04
|
||||
*.zabw
|
||||
*.zap
|
||||
*.zed
|
||||
*.zfsendtotarget
|
||||
*.zhelp
|
||||
*.zi
|
||||
*.zi_
|
||||
*.zim
|
||||
*.zip
|
||||
*.zipx
|
||||
*.zix
|
||||
*.zl
|
||||
*.zl9
|
||||
*.zoo
|
||||
*.zpaq
|
||||
*.zpi
|
||||
*.zsplit
|
||||
*.zst
|
||||
*.zw
|
||||
*.zwi
|
||||
*.zz
|
||||
Trailer.*
|
||||
VOSTFR
|
||||
api
|
||||
@@ -0,0 +1,410 @@
|
||||
*.000
|
||||
*.001
|
||||
*.002
|
||||
*.004
|
||||
*.7z
|
||||
*.7z.001
|
||||
*.7z.002
|
||||
*.a00
|
||||
*.a01
|
||||
*.a02
|
||||
*.ace
|
||||
*.ain
|
||||
*.alz
|
||||
*.ana
|
||||
*.apex
|
||||
*.apk
|
||||
*.apz
|
||||
*.ar
|
||||
*.arc
|
||||
*.archiver
|
||||
*.arduboy
|
||||
*.arh
|
||||
*.ari
|
||||
*.arj
|
||||
*.ark
|
||||
*.asice
|
||||
*.ayt
|
||||
*.b1
|
||||
*.b6z
|
||||
*.b64
|
||||
*.ba
|
||||
*.bat
|
||||
*.bdoc
|
||||
*.bh
|
||||
*.bin
|
||||
*.bmp
|
||||
*.bndl
|
||||
*.boo
|
||||
*.bundle
|
||||
*.bz
|
||||
*.bz2
|
||||
*.bza
|
||||
*.bzabw
|
||||
*.bzip
|
||||
*.bzip2
|
||||
*.c00
|
||||
*.c01
|
||||
*.c02
|
||||
*.c10
|
||||
*.car
|
||||
*.cb7
|
||||
*.cba
|
||||
*.cbr
|
||||
*.cbt
|
||||
*.cbz
|
||||
*.cdz
|
||||
*.cit
|
||||
*.cmd
|
||||
*.com
|
||||
*.comppkg.hauptwerk.rar
|
||||
*.comppkg_hauptwerk_rar
|
||||
*.conda
|
||||
*.cp9
|
||||
*.cpgz
|
||||
*.cpt
|
||||
*.ctx
|
||||
*.ctz
|
||||
*.cxarchive
|
||||
*.czip
|
||||
*.daf
|
||||
*.dar
|
||||
*.db
|
||||
*.dd
|
||||
*.deb
|
||||
*.dgc
|
||||
*.dist
|
||||
*.diz
|
||||
*.dl_
|
||||
*.dll
|
||||
*.dmg
|
||||
*.dz
|
||||
*.ecar
|
||||
*.ecs
|
||||
*.ecsbx
|
||||
*.edz
|
||||
*.efw
|
||||
*.egg
|
||||
*.epi
|
||||
*.etc
|
||||
*.exe
|
||||
*.f
|
||||
*.f3z
|
||||
*.fcx
|
||||
*.fp8
|
||||
*.fzpz
|
||||
*.gar
|
||||
*.gca
|
||||
*.gif
|
||||
*.gmz
|
||||
*.gz
|
||||
*.gz2
|
||||
*.gza
|
||||
*.gzi
|
||||
*.gzip
|
||||
*.ha
|
||||
*.hbc
|
||||
*.hbc2
|
||||
*.hbe
|
||||
*.hki
|
||||
*.hki1
|
||||
*.hki2
|
||||
*.hki3
|
||||
*.hpk
|
||||
*.hpkg
|
||||
*.htm
|
||||
*.html
|
||||
*.htmi
|
||||
*.hyp
|
||||
*.iadproj
|
||||
*.ice
|
||||
*.ico
|
||||
*.ini
|
||||
*.ipg
|
||||
*.ipk
|
||||
*.ish
|
||||
*.iso
|
||||
*.isx
|
||||
*.ita
|
||||
*.ize
|
||||
*.j
|
||||
*.jar
|
||||
*.jar.pack
|
||||
*.jex
|
||||
*.jgz
|
||||
*.jhh
|
||||
*.jic
|
||||
*.jpg
|
||||
*.js
|
||||
*.jsonlz4
|
||||
*.kextraction
|
||||
*.kgb
|
||||
*.ksp
|
||||
*.kwgt
|
||||
*.kz
|
||||
*.layout
|
||||
*.lbr
|
||||
*.lemon
|
||||
*.lha
|
||||
*.lhzd
|
||||
*.libzip
|
||||
*.link
|
||||
*.lnk
|
||||
*.lpkg
|
||||
*.lqr
|
||||
*.lz
|
||||
*.lz4
|
||||
*.lzh
|
||||
*.lzm
|
||||
*.lzma
|
||||
*.lzo
|
||||
*.lzr
|
||||
*.lzx
|
||||
*.mar
|
||||
*.mbz
|
||||
*.md
|
||||
*.memo
|
||||
*.mint
|
||||
*.mlproj
|
||||
*.mou
|
||||
*.movpkg
|
||||
*.mozlz4
|
||||
*.mpkg
|
||||
*.msi
|
||||
*.mxc
|
||||
*.mzp
|
||||
*.nar
|
||||
*.nex
|
||||
*.nfo
|
||||
*.npk
|
||||
*.nz
|
||||
*.oar
|
||||
*.odlgz
|
||||
*.opk
|
||||
*.osf
|
||||
*.oz
|
||||
*.p01
|
||||
*.p19
|
||||
*.p7z
|
||||
*.pa
|
||||
*.pack.gz
|
||||
*.package
|
||||
*.pae
|
||||
*.pak
|
||||
*.paq6
|
||||
*.paq7
|
||||
*.paq8
|
||||
*.paq8f
|
||||
*.paq8l
|
||||
*.paq8p
|
||||
*.par
|
||||
*.par2
|
||||
*.pax
|
||||
*.pbi
|
||||
*.pcv
|
||||
*.pea
|
||||
*.perl
|
||||
*.pet
|
||||
*.pf
|
||||
*.php
|
||||
*.pim
|
||||
*.pima
|
||||
*.pit
|
||||
*.piz
|
||||
*.pkg
|
||||
*.pkg.tar.xz
|
||||
*.pkg.tar.zst
|
||||
*.pkz
|
||||
*.pl
|
||||
*.png
|
||||
*.prs
|
||||
*.ps1
|
||||
*.psc1
|
||||
*.psd1
|
||||
*.psm1
|
||||
*.psz
|
||||
*.pup
|
||||
*.puz
|
||||
*.pvmp
|
||||
*.pvmz
|
||||
*.pwa
|
||||
*.pxl
|
||||
*.py
|
||||
*.pyd
|
||||
*.q
|
||||
*.qda
|
||||
*.r0
|
||||
*.r00
|
||||
*.r01
|
||||
*.r02
|
||||
*.r03
|
||||
*.r04
|
||||
*.r1
|
||||
*.r2
|
||||
*.r21
|
||||
*.r30
|
||||
*.rar
|
||||
*.rb
|
||||
*.readme
|
||||
*.reg
|
||||
*.rev
|
||||
*.rk
|
||||
*.rnc
|
||||
*.rp9
|
||||
*.rpm
|
||||
*.rss
|
||||
*.run
|
||||
*.rz
|
||||
*.s00
|
||||
*.s01
|
||||
*.s02
|
||||
*.s09
|
||||
*.s7z
|
||||
*.sar
|
||||
*.sbx
|
||||
*.scr
|
||||
*.sdc
|
||||
*.sdn
|
||||
*.sdoc
|
||||
*.sdocx
|
||||
*.sea
|
||||
*.sen
|
||||
*.sfg
|
||||
*.sfm
|
||||
*.sfs
|
||||
*.sfx
|
||||
*.sh
|
||||
*.shar
|
||||
*.shk
|
||||
*.shr
|
||||
*.sifz
|
||||
*.sipa
|
||||
*.sit
|
||||
*.sitx
|
||||
*.smpf
|
||||
*.snagitstamps
|
||||
*.snappy
|
||||
*.snb
|
||||
*.snz
|
||||
*.spa
|
||||
*.spd
|
||||
*.spl
|
||||
*.spm
|
||||
*.spt
|
||||
*.sql
|
||||
*.sqf
|
||||
*.sqx
|
||||
*.sqz
|
||||
*.srep
|
||||
*.stg
|
||||
*.stkdoodlz
|
||||
*.stproj
|
||||
*.sy_
|
||||
*.tar.bz2
|
||||
*.tar.gz
|
||||
*.tar.gz2
|
||||
*.tar.lz
|
||||
*.tar.lzma
|
||||
*.tar.xz
|
||||
*.tar.z
|
||||
*.tar.zip
|
||||
*.taz
|
||||
*.tbz
|
||||
*.tbz2
|
||||
*.tcx
|
||||
*.text
|
||||
*.tg
|
||||
*.tgs
|
||||
*.tgz
|
||||
*.thumb
|
||||
*.tlz
|
||||
*.tlzma
|
||||
*.torrent
|
||||
*.tpsr
|
||||
*.trs
|
||||
*.txt
|
||||
*.tx_
|
||||
*.txz
|
||||
*.tz
|
||||
*.tzst
|
||||
*.ubz
|
||||
*.uc2
|
||||
*.ufdr
|
||||
*.ufs.uzip
|
||||
*.uha
|
||||
*.url
|
||||
*.uue
|
||||
*.uvm
|
||||
*.uzed
|
||||
*.uzip
|
||||
*.vbs
|
||||
*.vem
|
||||
*.vfs
|
||||
*.vib
|
||||
*.vip
|
||||
*.vmcz
|
||||
*.vms
|
||||
*.voca
|
||||
*.vpk
|
||||
*.vrpackage
|
||||
*.vsi
|
||||
*.vwi
|
||||
*.wa
|
||||
*.wacz
|
||||
*.waff
|
||||
*.war
|
||||
*.wastickers
|
||||
*.wdz
|
||||
*.whl
|
||||
*.wick
|
||||
*.wlb
|
||||
*.wot
|
||||
*.wsf
|
||||
*.wux
|
||||
*.xapk
|
||||
*.xar
|
||||
*.xcf.bz2
|
||||
*.xcf.gz
|
||||
*.xcf.xz
|
||||
*.xcfbz2
|
||||
*.xcfgz
|
||||
*.xcfxz
|
||||
*.xez
|
||||
*.xfp
|
||||
*.xip
|
||||
*.xml
|
||||
*.xmcdz
|
||||
*.xoj
|
||||
*.xopp
|
||||
*.xx
|
||||
*.xz
|
||||
*.xzm
|
||||
*.y
|
||||
*.yc
|
||||
*.yz1
|
||||
*.z
|
||||
*.z00
|
||||
*.z01
|
||||
*.z02
|
||||
*.z03
|
||||
*.z04
|
||||
*.zabw
|
||||
*.zap
|
||||
*.zed
|
||||
*.zfsendtotarget
|
||||
*.zhelp
|
||||
*.zi
|
||||
*.zi_
|
||||
*.zim
|
||||
*.zip
|
||||
*.zipx
|
||||
*.zix
|
||||
*.zl
|
||||
*.zoo
|
||||
*.zpaq
|
||||
*.zpi
|
||||
*.zsplit
|
||||
*.zst
|
||||
*.zw
|
||||
*.zwi
|
||||
*.zz
|
||||
@@ -0,0 +1,87 @@
|
||||
# Documentation
|
||||
*.md
|
||||
docs/
|
||||
|
||||
# Version control
|
||||
.git/
|
||||
.gitignore
|
||||
|
||||
# IDE files
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Node.js
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
frontend/.angular/
|
||||
|
||||
# .NET
|
||||
backend/bin/
|
||||
backend/obj/
|
||||
backend/*/bin/
|
||||
backend/*/obj/
|
||||
backend/.vs/
|
||||
|
||||
# Build artifacts
|
||||
artifacts/
|
||||
dist/
|
||||
|
||||
# Test files
|
||||
backend/**/*Tests/
|
||||
backend/**/Tests/
|
||||
|
||||
# Development files
|
||||
docker-compose*.yml
|
||||
test/
|
||||
|
||||
# ================================
|
||||
# Node and build output
|
||||
# ================================
|
||||
node_modules
|
||||
dist
|
||||
out-tsc
|
||||
.angular
|
||||
.cache
|
||||
.tmp
|
||||
|
||||
# ================================
|
||||
# Testing & Coverage
|
||||
# ================================
|
||||
coverage
|
||||
jest
|
||||
cypress
|
||||
cypress/screenshots
|
||||
cypress/videos
|
||||
reports
|
||||
playwright-report
|
||||
.vite
|
||||
.vitepress
|
||||
|
||||
# ================================
|
||||
# Environment & log files
|
||||
# ================================
|
||||
*.env*
|
||||
!*.env.production
|
||||
*.log
|
||||
*.tsbuildinfo
|
||||
|
||||
# ================================
|
||||
# Docker & local orchestration
|
||||
# ================================
|
||||
Dockerfile
|
||||
Dockerfile.*
|
||||
.dockerignore
|
||||
docker-compose.yml
|
||||
docker-compose*.yml
|
||||
|
||||
# ================================
|
||||
# Miscellaneous
|
||||
# ================================
|
||||
*.bak
|
||||
*.old
|
||||
@@ -1,9 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,10 +0,0 @@
|
||||
namespace Common.Configuration;
|
||||
|
||||
public sealed class QBitConfig
|
||||
{
|
||||
public required Uri Url { get; set; }
|
||||
|
||||
public required string Username { get; set; }
|
||||
|
||||
public required string Password { get; set; }
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
namespace Common.Configuration;
|
||||
|
||||
public sealed class QuartzConfig
|
||||
{
|
||||
public required string BlockedTorrentTrigger { get; init; }
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
namespace Common.Configuration;
|
||||
|
||||
public sealed class SonarrConfig
|
||||
{
|
||||
public required List<SonarrInstance> Instances { get; set; }
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
namespace Common.Configuration;
|
||||
|
||||
public sealed class SonarrInstance
|
||||
{
|
||||
public required Uri Url { get; set; }
|
||||
|
||||
public required string ApiKey { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
# Build Angular frontend
|
||||
FROM --platform=$BUILDPLATFORM node:26-alpine AS frontend-build
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files first for better layer caching
|
||||
COPY frontend/package*.json ./
|
||||
# Use cache mount for npm to speed up builds
|
||||
RUN --mount=type=cache,target=/root/.npm \
|
||||
npm ci && npm install -g @angular/cli
|
||||
|
||||
# Copy source code
|
||||
COPY frontend/ .
|
||||
|
||||
# Build with appropriate base-href and deploy-url
|
||||
RUN npm run build
|
||||
|
||||
# Build .NET backend
|
||||
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
ARG TARGETARCH
|
||||
ARG VERSION=0.0.1
|
||||
ARG PACKAGES_USERNAME
|
||||
ARG PACKAGES_PAT
|
||||
WORKDIR /app
|
||||
EXPOSE 11011
|
||||
|
||||
# Copy source code
|
||||
COPY backend/ ./backend/
|
||||
|
||||
# Add NuGet source
|
||||
RUN dotnet nuget add source --username ${PACKAGES_USERNAME} --password ${PACKAGES_PAT} --store-password-in-clear-text --name Cleanuparr https://nuget.pkg.github.com/Cleanuparr/index.json
|
||||
|
||||
# Restore and publish with cache mount
|
||||
RUN --mount=type=cache,target=/root/.nuget/packages,sharing=locked \
|
||||
dotnet restore ./backend/Cleanuparr.Api/Cleanuparr.Api.csproj -a $TARGETARCH && \
|
||||
dotnet publish ./backend/Cleanuparr.Api/Cleanuparr.Api.csproj \
|
||||
-a $TARGETARCH \
|
||||
-c Release \
|
||||
-o /app/publish \
|
||||
--no-restore \
|
||||
/p:Version=${VERSION} \
|
||||
/p:PublishSingleFile=true \
|
||||
/p:DebugSymbols=false
|
||||
|
||||
# Runtime stage
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0
|
||||
|
||||
# Install required packages for user management, timezone support, and Python for Apprise CLI
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl \
|
||||
tzdata \
|
||||
gosu \
|
||||
python3 \
|
||||
python3-venv \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Create virtual environment and install Apprise CLI
|
||||
ENV VIRTUAL_ENV=/opt/apprise-venv
|
||||
RUN python3 -m venv $VIRTUAL_ENV
|
||||
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
|
||||
RUN pip install --no-cache-dir apprise==1.9.6
|
||||
|
||||
ENV PUID=1000 \
|
||||
PGID=1000 \
|
||||
UMASK=022 \
|
||||
TZ=Etc/UTC \
|
||||
HTTP_PORTS=11011
|
||||
|
||||
# Fix FileSystemWatcher in Docker: https://github.com/dotnet/dotnet-docker/issues/3546
|
||||
ENV DOTNET_USE_POLLING_FILE_WATCHER=true
|
||||
|
||||
# GC tuning: prefer lower memory footprint over throughput
|
||||
ENV DOTNET_GCDynamicAdaptationMode=1 \
|
||||
DOTNET_GCConserveMemory=5 \
|
||||
DOTNET_GCRetainVM=0
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy backend
|
||||
COPY --from=build /app/publish .
|
||||
# Copy frontend to wwwroot
|
||||
COPY --from=frontend-build /app/dist/ui/browser ./wwwroot
|
||||
# Copy entrypoint script
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
CMD ["./Cleanuparr"]
|
||||
@@ -1,6 +0,0 @@
|
||||
namespace Domain.Sonarr.Queue;
|
||||
|
||||
public record CustomFormat(
|
||||
int Id,
|
||||
string Name
|
||||
);
|
||||
@@ -1,6 +0,0 @@
|
||||
namespace Domain.Sonarr.Queue;
|
||||
|
||||
public record Language(
|
||||
int Id,
|
||||
string Name
|
||||
);
|
||||
@@ -1,10 +0,0 @@
|
||||
namespace Domain.Sonarr.Queue;
|
||||
|
||||
public record QueueListResponse(
|
||||
int Page,
|
||||
int PageSize,
|
||||
string SortKey,
|
||||
string SortDirection,
|
||||
int TotalRecords,
|
||||
IReadOnlyList<Record> Records
|
||||
);
|
||||
@@ -1,28 +0,0 @@
|
||||
namespace Domain.Sonarr.Queue;
|
||||
|
||||
public record Record(
|
||||
int SeriesId,
|
||||
int EpisodeId,
|
||||
int SeasonNumber,
|
||||
IReadOnlyList<Language> Languages,
|
||||
IReadOnlyList<CustomFormat> CustomFormats,
|
||||
int CustomFormatScore,
|
||||
int Size,
|
||||
string Title,
|
||||
int Sizeleft,
|
||||
string Timeleft,
|
||||
DateTime EstimatedCompletionTime,
|
||||
DateTime Added,
|
||||
string Status,
|
||||
string TrackedDownloadStatus,
|
||||
string TrackedDownloadState,
|
||||
IReadOnlyList<StatusMessage> StatusMessages,
|
||||
string DownloadId,
|
||||
string Protocol,
|
||||
string DownloadClient,
|
||||
bool DownloadClientHasPostImportCategory,
|
||||
string Indexer,
|
||||
string OutputPath,
|
||||
bool EpisodeHasFile,
|
||||
int Id
|
||||
);
|
||||
@@ -1,7 +0,0 @@
|
||||
namespace Domain.Sonarr.Queue;
|
||||
|
||||
public record Revision(
|
||||
int Version,
|
||||
int Real,
|
||||
bool IsRepack
|
||||
);
|
||||
@@ -1,6 +0,0 @@
|
||||
namespace Domain.Sonarr.Queue;
|
||||
|
||||
public record StatusMessage(
|
||||
string Title,
|
||||
IReadOnlyList<string> Messages
|
||||
);
|
||||
@@ -1,61 +0,0 @@
|
||||
using Common.Configuration;
|
||||
using Executable.Jobs;
|
||||
using Infrastructure.Verticals.BlockedTorrent;
|
||||
|
||||
namespace Executable;
|
||||
using Quartz;
|
||||
|
||||
public static class DependencyInjection
|
||||
{
|
||||
public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration) =>
|
||||
services
|
||||
.AddLogging(builder => builder.ClearProviders().AddConsole())
|
||||
.AddHttpClient()
|
||||
.AddConfiguration(configuration)
|
||||
.AddServices()
|
||||
.AddQuartzServices(configuration);
|
||||
|
||||
private static IServiceCollection AddConfiguration(this IServiceCollection services, IConfiguration configuration) =>
|
||||
services
|
||||
.Configure<QuartzConfig>(configuration.GetSection(nameof(QuartzConfig)))
|
||||
.Configure<QBitConfig>(configuration.GetSection(nameof(QBitConfig)))
|
||||
.Configure<SonarrConfig>(configuration.GetSection(nameof(SonarrConfig)));
|
||||
|
||||
private static IServiceCollection AddServices(this IServiceCollection services) =>
|
||||
services
|
||||
.AddTransient<BlockedTorrentJob>()
|
||||
.AddTransient<BlockedTorrentHandler>();
|
||||
|
||||
private static IServiceCollection AddQuartzServices(this IServiceCollection services, IConfiguration configuration) =>
|
||||
services
|
||||
.AddQuartz(q =>
|
||||
{
|
||||
QuartzConfig? config = configuration.GetRequiredSection(nameof(QuartzConfig)).Get<QuartzConfig>();
|
||||
|
||||
if (config is null)
|
||||
{
|
||||
throw new NullReferenceException("Quartz configuration is null");
|
||||
}
|
||||
|
||||
q.AddBlockedTorrentJob(config.BlockedTorrentTrigger);
|
||||
})
|
||||
.AddQuartzHostedService(opt =>
|
||||
{
|
||||
opt.WaitForJobsToComplete = true;
|
||||
});
|
||||
|
||||
private static void AddBlockedTorrentJob(this IServiceCollectionQuartzConfigurator q, string trigger)
|
||||
{
|
||||
q.AddJob<BlockedTorrentJob>(opts =>
|
||||
{
|
||||
opts.WithIdentity(nameof(BlockedTorrentJob));
|
||||
});
|
||||
|
||||
q.AddTrigger(opts =>
|
||||
{
|
||||
opts.ForJob(nameof(BlockedTorrentJob))
|
||||
.WithIdentity($"{nameof(BlockedTorrentJob)}-trigger")
|
||||
.WithCronSchedule(trigger);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Worker">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UserSecretsId>dotnet-Executable-6108b2ba-f035-47bc-addf-aaf5e20da4b8</UserSecretsId>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.1"/>
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.1" />
|
||||
<PackageReference Include="Quartz" Version="3.13.1" />
|
||||
<PackageReference Include="Quartz.Extensions.DependencyInjection" Version="3.13.1" />
|
||||
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.13.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Common\Common.csproj" />
|
||||
<ProjectReference Include="..\Infrastructure\Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -1,29 +0,0 @@
|
||||
using Infrastructure.Verticals.BlockedTorrent;
|
||||
using Quartz;
|
||||
|
||||
namespace Executable.Jobs;
|
||||
|
||||
[DisallowConcurrentExecution]
|
||||
public sealed class BlockedTorrentJob : IJob
|
||||
{
|
||||
private ILogger<BlockedTorrentJob> _logger;
|
||||
private BlockedTorrentHandler _handler;
|
||||
|
||||
public BlockedTorrentJob(ILogger<BlockedTorrentJob> logger, BlockedTorrentHandler handler)
|
||||
{
|
||||
_logger = logger;
|
||||
_handler = handler;
|
||||
}
|
||||
|
||||
public async Task Execute(IJobExecutionContext context)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _handler.HandleAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, $"{nameof(BlockedTorrentJob)} failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
using Executable;
|
||||
|
||||
var builder = Host.CreateApplicationBuilder(args);
|
||||
|
||||
builder.Services.AddInfrastructure(builder.Configuration);
|
||||
|
||||
var host = builder.Build();
|
||||
|
||||
host.Run();
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.Hosting.Lifetime": "Information"
|
||||
}
|
||||
},
|
||||
"QuartzConfig": {
|
||||
"BlockedTorrentTrigger": "0 0/1 * * * ?"
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.Hosting.Lifetime": "Information",
|
||||
"Quartz": "Warning",
|
||||
"System.Net.Http.HttpClient": "Error"
|
||||
}
|
||||
},
|
||||
"QuartzConfig": {
|
||||
"BlockedTorrentTrigger": "0 0/5 * * * ?"
|
||||
},
|
||||
"QBitConfig": {
|
||||
"Url": "http://localhost:8080",
|
||||
"Username": "",
|
||||
"Password": ""
|
||||
},
|
||||
"SonarrConfig": {
|
||||
"Instances": [
|
||||
{
|
||||
"Url": "http://localhost:8989",
|
||||
"ApiKey": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Common\Common.csproj" />
|
||||
<ProjectReference Include="..\Domain\Domain.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="8.0.1" />
|
||||
<PackageReference Include="QBittorrent.Client" Version="1.9.24285.1" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,175 +0,0 @@
|
||||
using System.Text;
|
||||
using Common.Configuration;
|
||||
using Domain.Sonarr.Queue;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Newtonsoft.Json;
|
||||
using QBittorrent.Client;
|
||||
|
||||
namespace Infrastructure.Verticals.BlockedTorrent;
|
||||
|
||||
public sealed class BlockedTorrentHandler
|
||||
{
|
||||
private readonly ILogger<BlockedTorrentHandler> _logger;
|
||||
private readonly QBitConfig _qBitConfig;
|
||||
private readonly SonarrConfig _sonarrConfig;
|
||||
private readonly HttpClient _httpClient;
|
||||
|
||||
private const string QueueListPathTemplate = "/api/v3/queue?page={0}&pageSize=200&sortKey=timeleft";
|
||||
private const string QueueDeletePathTemplate = "/api/v3/queue/{0}?removeFromClient=true&blocklist=true&skipRedownload=true&changeCategory=false";
|
||||
private const string SonarrCommandUriPath = "/api/v3/command";
|
||||
private const string SearchCommandPayloadTemplate = "{\"name\":\"SeriesSearch\",\"seriesId\":{0}}";
|
||||
|
||||
public BlockedTorrentHandler(
|
||||
ILogger<BlockedTorrentHandler> logger,
|
||||
IOptions<QBitConfig> qBitConfig,
|
||||
IOptions<SonarrConfig> sonarrConfig,
|
||||
IHttpClientFactory httpClientFactory)
|
||||
{
|
||||
_logger = logger;
|
||||
_qBitConfig = qBitConfig.Value;
|
||||
_sonarrConfig = sonarrConfig.Value;
|
||||
_httpClient = httpClientFactory.CreateClient();
|
||||
}
|
||||
|
||||
public async Task HandleAsync()
|
||||
{
|
||||
QBittorrentClient qBitClient = new(_qBitConfig.Url);
|
||||
|
||||
await qBitClient.LoginAsync(_qBitConfig.Username, _qBitConfig.Password);
|
||||
|
||||
foreach (SonarrInstance sonarrInstance in _sonarrConfig.Instances)
|
||||
{
|
||||
ushort page = 1;
|
||||
int totalRecords = 0;
|
||||
int processedRecords = 0;
|
||||
HashSet<int> seriesToBeRefreshed = [];
|
||||
|
||||
do
|
||||
{
|
||||
QueueListResponse queueResponse = await ListQueuedTorrentsAsync(sonarrInstance, page);
|
||||
|
||||
if (totalRecords is 0)
|
||||
{
|
||||
totalRecords = queueResponse.TotalRecords;
|
||||
|
||||
_logger.LogInformation(
|
||||
"{items} items found in queue | {url}",
|
||||
queueResponse.TotalRecords, sonarrInstance.Url);
|
||||
}
|
||||
|
||||
foreach (Record record in queueResponse.Records)
|
||||
{
|
||||
var torrent = (await qBitClient.GetTorrentListAsync(new TorrentListQuery { Hashes = [record.DownloadId] }))
|
||||
.FirstOrDefault();
|
||||
|
||||
if (torrent is not { CompletionOn: not null, Downloaded: null or 0 })
|
||||
{
|
||||
_logger.LogInformation("skip | {torrent}", record.Title);
|
||||
return;
|
||||
}
|
||||
|
||||
seriesToBeRefreshed.Add(record.SeriesId);
|
||||
|
||||
await DeleteTorrentFromQueueAsync(sonarrInstance, record);
|
||||
}
|
||||
|
||||
if (queueResponse.Records.Count is 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
processedRecords += queueResponse.Records.Count;
|
||||
|
||||
if (processedRecords >= totalRecords)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
page++;
|
||||
} while (processedRecords < totalRecords);
|
||||
|
||||
foreach (int id in seriesToBeRefreshed)
|
||||
{
|
||||
await RefreshSeriesAsync(sonarrInstance, id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<QueueListResponse> ListQueuedTorrentsAsync(SonarrInstance sonarrInstance, int page)
|
||||
{
|
||||
Uri sonarrUri = new(sonarrInstance.Url, string.Format(QueueListPathTemplate, page));
|
||||
|
||||
using HttpRequestMessage sonarrRequest = new(HttpMethod.Get, sonarrUri);
|
||||
sonarrRequest.Headers.Add("x-api-key", sonarrInstance.ApiKey);
|
||||
|
||||
using HttpResponseMessage response = await _httpClient.SendAsync(sonarrRequest);
|
||||
|
||||
try
|
||||
{
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
catch
|
||||
{
|
||||
_logger.LogError("queue list failed | {uri}", sonarrUri);
|
||||
throw;
|
||||
}
|
||||
|
||||
string responseBody = await response.Content.ReadAsStringAsync();
|
||||
QueueListResponse? queueResponse = JsonConvert.DeserializeObject<QueueListResponse>(responseBody);
|
||||
|
||||
if (queueResponse is null)
|
||||
{
|
||||
throw new Exception($"unrecognized response | {responseBody}");
|
||||
}
|
||||
|
||||
return queueResponse;
|
||||
}
|
||||
|
||||
private async Task DeleteTorrentFromQueueAsync(SonarrInstance sonarrInstance, Record record)
|
||||
{
|
||||
Uri sonarrUri = new(sonarrInstance.Url, string.Format(QueueDeletePathTemplate, record.Id));
|
||||
using HttpRequestMessage sonarrRequest = new(HttpMethod.Delete, sonarrUri);
|
||||
sonarrRequest.Headers.Add("x-api-key", sonarrInstance.ApiKey);
|
||||
|
||||
using HttpResponseMessage response = await _httpClient.SendAsync(sonarrRequest);
|
||||
|
||||
try
|
||||
{
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
_logger.LogInformation("queue item deleted | {record}", record.Title);
|
||||
}
|
||||
catch
|
||||
{
|
||||
_logger.LogError("queue delete failed | {uri}", sonarrUri);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RefreshSeriesAsync(SonarrInstance sonarrInstance, int seriesId)
|
||||
{
|
||||
Uri sonarrUri = new(sonarrInstance.Url, SonarrCommandUriPath);
|
||||
using HttpRequestMessage sonarrRequest = new(HttpMethod.Post, sonarrUri);
|
||||
sonarrRequest.Content = new StringContent(
|
||||
SearchCommandPayloadTemplate.Replace("{0}", seriesId.ToString()),
|
||||
Encoding.UTF8,
|
||||
"application/json"
|
||||
);
|
||||
sonarrRequest.Headers.Add("x-api-key", sonarrInstance.ApiKey);
|
||||
|
||||
using HttpResponseMessage response = await _httpClient.SendAsync(sonarrRequest);
|
||||
|
||||
try
|
||||
{
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
_logger.LogInformation("series search triggered | series id: {id}", seriesId);
|
||||
}
|
||||
catch
|
||||
{
|
||||
_logger.LogError("series search failed | series id: {id}", seriesId);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
.DEFAULT_GOAL := no-default
|
||||
|
||||
no-default:
|
||||
$(error You must specify a make target)
|
||||
|
||||
migrate-data:
|
||||
ifndef name
|
||||
$(error name is required. Usage: make migrate-data name=YourMigrationName)
|
||||
endif
|
||||
dotnet ef migrations add $(name) --context DataContext --project backend/Cleanuparr.Persistence.Sqlite/Cleanuparr.Persistence.Sqlite.csproj --startup-project backend/Cleanuparr.Api/Cleanuparr.Api.csproj --output-dir Migrations/Data
|
||||
|
||||
migrate-events:
|
||||
ifndef name
|
||||
$(error name is required. Usage: make migrate-events name=YourMigrationName)
|
||||
endif
|
||||
dotnet ef migrations add $(name) --context EventsContext --project backend/Cleanuparr.Persistence.Sqlite/Cleanuparr.Persistence.Sqlite.csproj --startup-project backend/Cleanuparr.Api/Cleanuparr.Api.csproj --output-dir Migrations/Events
|
||||
|
||||
migrate-users:
|
||||
ifndef name
|
||||
$(error name is required. Usage: make migrate-users name=YourMigrationName)
|
||||
endif
|
||||
dotnet ef migrations add $(name) --context UsersContext --project backend/Cleanuparr.Persistence.Sqlite/Cleanuparr.Persistence.Sqlite.csproj --startup-project backend/Cleanuparr.Api/Cleanuparr.Api.csproj --output-dir Migrations/Users
|
||||
|
||||
PG_DUMMY = DATABASE_PROVIDER=postgres POSTGRES_HOST=localhost POSTGRES_PORT=5432 POSTGRES_USER=postgres POSTGRES_PASS=postgres POSTGRES_DB=cleanuparr
|
||||
|
||||
migrate-postgres-data:
|
||||
ifndef name
|
||||
$(error name is required. Usage: make migrate-postgres-data name=YourMigrationName)
|
||||
endif
|
||||
$(PG_DUMMY) dotnet ef migrations add $(name) --context DataContext --project backend/Cleanuparr.Persistence.Postgres/Cleanuparr.Persistence.Postgres.csproj --startup-project backend/Cleanuparr.Api/Cleanuparr.Api.csproj --output-dir Migrations/Data
|
||||
|
||||
migrate-postgres-events:
|
||||
ifndef name
|
||||
$(error name is required. Usage: make migrate-postgres-events name=YourMigrationName)
|
||||
endif
|
||||
$(PG_DUMMY) dotnet ef migrations add $(name) --context EventsContext --project backend/Cleanuparr.Persistence.Postgres/Cleanuparr.Persistence.Postgres.csproj --startup-project backend/Cleanuparr.Api/Cleanuparr.Api.csproj --output-dir Migrations/Events
|
||||
|
||||
migrate-postgres-users:
|
||||
ifndef name
|
||||
$(error name is required. Usage: make migrate-postgres-users name=YourMigrationName)
|
||||
endif
|
||||
$(PG_DUMMY) dotnet ef migrations add $(name) --context UsersContext --project backend/Cleanuparr.Persistence.Postgres/Cleanuparr.Persistence.Postgres.csproj --startup-project backend/Cleanuparr.Api/Cleanuparr.Api.csproj --output-dir Migrations/Users
|
||||
|
||||
release-check:
|
||||
@previous=$$(gh release view --json tagName --jq .tagName) || exit 1; \
|
||||
published=$$(gh release view --json publishedAt --jq .publishedAt); \
|
||||
echo "Previous release: $$previous ($$published)"; \
|
||||
echo ""; \
|
||||
if ! breaking=$$(gh api --paginate "repos/{owner}/{repo}/issues?labels=breaking&state=closed&since=$$published&per_page=100" \
|
||||
--jq ".[] | select(.pull_request.merged_at > \"$$published\") | \" #\(.number) \(.title)\""); then \
|
||||
echo "Failed to query pull request labels:" >&2; \
|
||||
echo "$$breaking" >&2; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
if [ -n "$$breaking" ]; then \
|
||||
echo "Breaking changes merged since $$previous:"; \
|
||||
echo "$$breaking"; \
|
||||
echo ""; \
|
||||
echo "=> the next release must bump the MINOR"; \
|
||||
else \
|
||||
echo "No breaking changes merged since $$previous"; \
|
||||
echo ""; \
|
||||
echo "=> the next release can be a PATCH"; \
|
||||
fi
|
||||
|
||||
release-notes:
|
||||
ifndef version
|
||||
$(error version is required. Usage: make release-notes version=2.11.0)
|
||||
endif
|
||||
@previous=$$(gh release view --json tagName --jq .tagName); \
|
||||
gh api -X POST repos/{owner}/{repo}/releases/generate-notes \
|
||||
-f tag_name=v$(version) -f target_commitish=main -f previous_tag_name=$$previous --jq .body
|
||||
|
||||
docker-build:
|
||||
ifndef tag
|
||||
$(error tag is required. Usage: make docker-build tag=latest version=1.0.0 user=... pat=...)
|
||||
endif
|
||||
ifndef version
|
||||
$(error version is required. Usage: make docker-build tag=latest version=1.0.0 user=... pat=...)
|
||||
endif
|
||||
ifndef user
|
||||
$(error user is required. Usage: make docker-build tag=latest version=1.0.0 user=... pat=...)
|
||||
endif
|
||||
ifndef pat
|
||||
$(error pat is required. Usage: make docker-build tag=latest version=1.0.0 user=... pat=...)
|
||||
endif
|
||||
DOCKER_BUILDKIT=1 docker build \
|
||||
--build-arg VERSION=$(version) \
|
||||
--build-arg PACKAGES_USERNAME=$(user) \
|
||||
--build-arg PACKAGES_PAT=$(pat) \
|
||||
-t cleanuparr:$(tag) \
|
||||
.
|
||||
@@ -0,0 +1,32 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="NSubstitute" Version="5.3.0" />
|
||||
<PackageReference Include="Shouldly" Version="4.3.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Cleanuparr.Api\Cleanuparr.Api.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,102 @@
|
||||
using Cleanuparr.Api.Contracts.Responses;
|
||||
using Cleanuparr.Api.Controllers;
|
||||
using Cleanuparr.Api.Features.Events.Contracts.Responses;
|
||||
using Cleanuparr.Api.Tests.Features.Seeker.TestHelpers;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Events;
|
||||
using Cleanuparr.Persistence.Providers;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Shouldly;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the events list endpoint's ordering, filtering, search, and primitive-collection round-tripping.
|
||||
/// Runs against real SQLite so the projection is actually translated.
|
||||
/// </summary>
|
||||
public class EventsControllerMergeTests : IDisposable
|
||||
{
|
||||
private readonly EventsContext _context;
|
||||
private readonly EventsController _controller;
|
||||
|
||||
public EventsControllerMergeTests()
|
||||
{
|
||||
_context = SeekerTestDataFactory.CreateEventsContext();
|
||||
_controller = new EventsController(_context, new SqliteDatabaseProvider());
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_context.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
private async Task SeedAsync()
|
||||
{
|
||||
_context.Events.Add(new AppEvent
|
||||
{
|
||||
EventType = EventType.FailedImportStrike,
|
||||
Message = "active",
|
||||
Severity = EventSeverity.Important,
|
||||
Timestamp = DateTimeOffset.UtcNow.AddDays(-1),
|
||||
ItemTitle = "Active Item",
|
||||
FailedImportReasons = ["reason one", "reason two"],
|
||||
GrabbedItems = ["grab one"],
|
||||
});
|
||||
_context.Events.Add(new AppEvent
|
||||
{
|
||||
EventType = EventType.QueueItemDeleted,
|
||||
Message = "archived",
|
||||
Severity = EventSeverity.Important,
|
||||
Timestamp = DateTimeOffset.UtcNow.AddDays(-100),
|
||||
ItemTitle = "Archived Item",
|
||||
});
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static PaginatedResult<EventListItem> GetPage(ActionResult<PaginatedResult<EventListItem>> action)
|
||||
{
|
||||
OkObjectResult ok = action.Result.ShouldBeOfType<OkObjectResult>();
|
||||
return ok.Value.ShouldBeOfType<PaginatedResult<EventListItem>>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvents_OrdersNewestFirst_AndRoundTripsCollections()
|
||||
{
|
||||
await SeedAsync();
|
||||
|
||||
PaginatedResult<EventListItem> page = GetPage(await _controller.GetEvents());
|
||||
|
||||
page.TotalCount.ShouldBe(2);
|
||||
page.Items.Count.ShouldBe(2);
|
||||
page.Items[0].Message.ShouldBe("active"); // newer
|
||||
page.Items[1].Message.ShouldBe("archived");
|
||||
|
||||
// The primitive-collection columns must survive the Concat projection.
|
||||
page.Items[0].FailedImportReasons.ShouldBe(["reason one", "reason two"]);
|
||||
page.Items[0].GrabbedItems.ShouldBe(["grab one"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvents_EventTypeFilter_Applies()
|
||||
{
|
||||
await SeedAsync();
|
||||
|
||||
PaginatedResult<EventListItem> page = GetPage(await _controller.GetEvents(eventType: nameof(EventType.QueueItemDeleted)));
|
||||
|
||||
page.TotalCount.ShouldBe(1);
|
||||
page.Items[0].Message.ShouldBe("archived");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvents_SearchFilter_MatchesArchivedItemTitle()
|
||||
{
|
||||
await SeedAsync();
|
||||
|
||||
PaginatedResult<EventListItem> page = GetPage(await _controller.GetEvents(search: "Archived"));
|
||||
|
||||
page.TotalCount.ShouldBe(1);
|
||||
page.Items[0].Message.ShouldBe("archived");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
using Cleanuparr.Api.Controllers;
|
||||
using Cleanuparr.Api.Features.Events.Contracts.Responses;
|
||||
using Cleanuparr.Api.Tests.Features.Seeker.TestHelpers;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Events;
|
||||
using Cleanuparr.Persistence.Providers;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Shouldly;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Controllers;
|
||||
|
||||
public class EventsControllerTimelineTests : IDisposable
|
||||
{
|
||||
private readonly EventsContext _context;
|
||||
private readonly EventsController _controller;
|
||||
|
||||
public EventsControllerTimelineTests()
|
||||
{
|
||||
_context = SeekerTestDataFactory.CreateEventsContext();
|
||||
_controller = new EventsController(_context, new SqliteDatabaseProvider());
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_context.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
private static EventTypeTimelineResponse GetTimeline(ActionResult<EventTypeTimelineResponse> action)
|
||||
{
|
||||
OkObjectResult ok = action.Result.ShouldBeOfType<OkObjectResult>();
|
||||
return ok.Value.ShouldBeOfType<EventTypeTimelineResponse>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTimeline_BucketsEventsByTypeAndDay()
|
||||
{
|
||||
DateOnly today = DateOnly.FromDateTime(DateTimeOffset.UtcNow.UtcDateTime);
|
||||
DateTimeOffset sameDay = new(today.ToDateTime(new TimeOnly(12, 0)), TimeSpan.Zero);
|
||||
|
||||
_context.Events.Add(new AppEvent
|
||||
{
|
||||
EventType = EventType.FailedImportStrike,
|
||||
Message = "active a",
|
||||
Severity = EventSeverity.Important,
|
||||
Timestamp = sameDay,
|
||||
});
|
||||
_context.Events.Add(new AppEvent
|
||||
{
|
||||
EventType = EventType.FailedImportStrike,
|
||||
Message = "active b",
|
||||
Severity = EventSeverity.Important,
|
||||
Timestamp = sameDay.AddHours(-1),
|
||||
});
|
||||
_context.Events.Add(new AppEvent
|
||||
{
|
||||
EventType = EventType.StalledStrike,
|
||||
Message = "active c",
|
||||
Severity = EventSeverity.Important,
|
||||
Timestamp = sameDay.AddHours(-2),
|
||||
});
|
||||
_context.Events.Add(new AppEvent
|
||||
{
|
||||
EventType = EventType.QueueItemDeleted,
|
||||
Message = "older removal",
|
||||
Severity = EventSeverity.Important,
|
||||
Timestamp = DateTimeOffset.UtcNow.AddDays(-10),
|
||||
});
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
EventTypeTimelineResponse timeline = GetTimeline(await _controller.GetTimeline(hours: 24 * 30));
|
||||
|
||||
timeline.Types.ShouldBe(["FailedImportStrike", "StalledStrike", "QueueItemDeleted"]);
|
||||
|
||||
int failedImport = timeline.Buckets.Sum(b => b.Counts.GetValueOrDefault("FailedImportStrike"));
|
||||
int stalled = timeline.Buckets.Sum(b => b.Counts.GetValueOrDefault("StalledStrike"));
|
||||
int removed = timeline.Buckets.Sum(b => b.Counts.GetValueOrDefault("QueueItemDeleted"));
|
||||
|
||||
failedImport.ShouldBe(2);
|
||||
stalled.ShouldBe(1);
|
||||
removed.ShouldBe(1);
|
||||
|
||||
DateTimeOffset todayStart = new(today.ToDateTime(TimeOnly.MinValue), TimeSpan.Zero);
|
||||
EventTypeTimelineBucket todayBucket = timeline.Buckets.Single(b => b.Date == todayStart);
|
||||
todayBucket.Counts["FailedImportStrike"].ShouldBe(2);
|
||||
todayBucket.Counts["StalledStrike"].ShouldBe(1);
|
||||
todayBucket.Counts.ShouldNotContainKey("QueueItemDeleted");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTimeline_UsesHourlyBucketsForDayWindow()
|
||||
{
|
||||
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||
_context.Events.Add(new AppEvent
|
||||
{
|
||||
EventType = EventType.StalledStrike,
|
||||
Message = "recent",
|
||||
Severity = EventSeverity.Important,
|
||||
Timestamp = now.AddHours(-1),
|
||||
});
|
||||
_context.Events.Add(new AppEvent
|
||||
{
|
||||
EventType = EventType.StalledStrike,
|
||||
Message = "earlier",
|
||||
Severity = EventSeverity.Important,
|
||||
Timestamp = now.AddHours(-3),
|
||||
});
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
EventTypeTimelineResponse timeline = GetTimeline(await _controller.GetTimeline(hours: 24));
|
||||
|
||||
int nonEmpty = timeline.Buckets.Count(b => b.Counts.GetValueOrDefault("StalledStrike") > 0);
|
||||
nonEmpty.ShouldBe(2);
|
||||
timeline.Buckets.Count.ShouldBeGreaterThan(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTimeline_ExcludesEventsOutsideWindow()
|
||||
{
|
||||
_context.Events.Add(new AppEvent
|
||||
{
|
||||
EventType = EventType.QueueItemDeleted,
|
||||
Message = "too old",
|
||||
Severity = EventSeverity.Important,
|
||||
Timestamp = DateTimeOffset.UtcNow.AddDays(-40),
|
||||
});
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
EventTypeTimelineResponse timeline = GetTimeline(await _controller.GetTimeline(hours: 24 * 7));
|
||||
|
||||
timeline.Types.ShouldBeEmpty();
|
||||
timeline.Buckets.ShouldAllBe(b => b.Counts.Count == 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Xunit;
|
||||
|
||||
// Integration tests share file-system state (config-dir used by SetupGuardMiddleware),
|
||||
// so they must be run sequentially to avoid interference between factories.
|
||||
[assembly: CollectionBehavior(DisableTestParallelization = true)]
|
||||
|
||||
namespace Cleanuparr.Api.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Custom WebApplicationFactory that redirects all database contexts to an isolated temp directory
|
||||
/// </summary>
|
||||
public class CustomWebApplicationFactory : WebApplicationFactory<Program>
|
||||
{
|
||||
private readonly string _tempDir;
|
||||
|
||||
public CustomWebApplicationFactory()
|
||||
{
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), $"cleanuparr-test-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_tempDir);
|
||||
|
||||
ConfigurationPathProvider.SetConfigPath(_tempDir);
|
||||
}
|
||||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
builder.UseEnvironment("Testing");
|
||||
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
// Remove all hosted services (Quartz scheduler, BackgroundJobManager) to prevent
|
||||
// Quartz.Logging.LogProvider.ResolvedLogProvider (a cached Lazy<T>) from being accessed
|
||||
foreach (var hostedService in services.Where(d => d.ServiceType == typeof(IHostedService)).ToList())
|
||||
{
|
||||
services.Remove(hostedService);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
base.Dispose(disposing);
|
||||
|
||||
if (disposing && Directory.Exists(_tempDir))
|
||||
{
|
||||
try { Directory.Delete(_tempDir, true); } catch { /* best effort cleanup */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using Cleanuparr.Api.Tests.Features.Seeker.TestHelpers;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Events;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Shouldly;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Events;
|
||||
|
||||
/// <summary>
|
||||
/// Exercises the manual-event partial unique index against a real SQLite context configured with the
|
||||
/// production naming conventions. The EF Core InMemory provider ignores unique indexes, so the guarantee
|
||||
/// that <see cref="Cleanuparr.Infrastructure.Events.EventPublisher.PublishManualAsync"/> relies on to
|
||||
/// dedup racing publishers can only be verified here.
|
||||
/// </summary>
|
||||
public class ManualEventDedupTests : IDisposable
|
||||
{
|
||||
private readonly EventsContext _context;
|
||||
|
||||
public ManualEventDedupTests()
|
||||
{
|
||||
_context = SeekerTestDataFactory.CreateEventsContext();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_context.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
private static ManualEvent NewEvent(string hash, bool isResolved) => new()
|
||||
{
|
||||
Type = ManualEventType.RecurringDownload,
|
||||
Message = "m",
|
||||
Severity = EventSeverity.Warning,
|
||||
ItemHash = hash,
|
||||
IsResolved = isResolved,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task TwoUnresolvedSameTypeAndHash_ViolatesUniqueIndex_WithSqliteConstraintError()
|
||||
{
|
||||
_context.ManualEvents.Add(NewEvent("abc123", isResolved: false));
|
||||
_context.ManualEvents.Add(NewEvent("abc123", isResolved: false));
|
||||
|
||||
// The exception must surface as SQLITE_CONSTRAINT (19) — the exact code PublishManualAsync's
|
||||
// catch filters on to treat the loser of a race as deduped.
|
||||
DbUpdateException ex = await Should.ThrowAsync<DbUpdateException>(() => _context.SaveChangesAsync());
|
||||
SqliteException sqliteEx = ex.InnerException.ShouldBeOfType<SqliteException>();
|
||||
sqliteEx.SqliteErrorCode.ShouldBe(19);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolvedDuplicate_IsExemptFromUniqueIndex()
|
||||
{
|
||||
_context.ManualEvents.Add(NewEvent("abc123", isResolved: false));
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
// The index is filtered on "is_resolved = 0", so a resolved row with the same type/hash is allowed.
|
||||
_context.ManualEvents.Add(NewEvent("abc123", isResolved: true));
|
||||
await Should.NotThrowAsync(() => _context.SaveChangesAsync());
|
||||
|
||||
(await _context.ManualEvents.CountAsync()).ShouldBe(2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
using Cleanuparr.Api.Features.Arr.Contracts.Requests;
|
||||
using Cleanuparr.Api.Features.Arr.Controllers;
|
||||
using Cleanuparr.Api.Tests.TestHelpers;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Dtos;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.Arr;
|
||||
|
||||
public class ArrConfigControllerTests : IDisposable
|
||||
{
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly EventsContext _eventsContext;
|
||||
private readonly IArrClientFactory _arrClientFactory;
|
||||
private readonly IArrClient _arrClient;
|
||||
private readonly ArrConfigController _controller;
|
||||
|
||||
public ArrConfigControllerTests()
|
||||
{
|
||||
_dataContext = ConfigControllerTestDataFactory.CreateDataContext();
|
||||
_eventsContext = ConfigControllerTestDataFactory.CreateEventsContext();
|
||||
var logger = Substitute.For<ILogger<ArrConfigController>>();
|
||||
_arrClientFactory = Substitute.For<IArrClientFactory>();
|
||||
_arrClient = Substitute.For<IArrClient>();
|
||||
_arrClientFactory.GetClient(Arg.Any<InstanceType>(), Arg.Any<float>()).Returns(_arrClient);
|
||||
_controller = new ArrConfigController(logger, _dataContext, _eventsContext, _arrClientFactory);
|
||||
ConfigControllerTestDataFactory.ConfigureProblemDetails(_controller);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_dataContext.Dispose();
|
||||
_eventsContext.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
#region GET configs
|
||||
|
||||
[Theory]
|
||||
[InlineData(InstanceType.Sonarr)]
|
||||
[InlineData(InstanceType.Radarr)]
|
||||
[InlineData(InstanceType.Lidarr)]
|
||||
[InlineData(InstanceType.Readarr)]
|
||||
[InlineData(InstanceType.Whisparr)]
|
||||
public async Task GetArrConfig_AllTypes_ReturnOk(InstanceType type)
|
||||
{
|
||||
// Act
|
||||
var result = await DispatchGet(type);
|
||||
|
||||
// Assert
|
||||
var ok = result.ShouldBeOfType<OkObjectResult>();
|
||||
var dto = ok.Value.ShouldBeOfType<ArrConfigDto>();
|
||||
dto.Type.ShouldBe(type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSonarrConfig_OrdersInstancesByName()
|
||||
{
|
||||
// Arrange
|
||||
var config = await _dataContext.ArrConfigs.FirstAsync(c => c.Type == InstanceType.Sonarr);
|
||||
_dataContext.ArrInstances.AddRange(
|
||||
new ArrInstance { Name = "z", Url = new Uri("http://z"), ApiKey = "k", ArrConfigId = config.Id, Enabled = true },
|
||||
new ArrInstance { Name = "a", Url = new Uri("http://a"), ApiKey = "k", ArrConfigId = config.Id, Enabled = true });
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
// Act
|
||||
var result = await _controller.GetSonarrConfig();
|
||||
|
||||
// Assert
|
||||
var ok = result.ShouldBeOfType<OkObjectResult>();
|
||||
var dto = ok.Value.ShouldBeOfType<ArrConfigDto>();
|
||||
dto.Instances[0].Name.ShouldBe("a");
|
||||
dto.Instances[1].Name.ShouldBe("z");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region PUT configs
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSonarrConfig_PersistsFailedImportMaxStrikes()
|
||||
{
|
||||
// Arrange
|
||||
var request = new UpdateArrConfigRequest { FailedImportMaxStrikes = 7 };
|
||||
|
||||
// Act
|
||||
var result = await _controller.UpdateSonarrConfig(request);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeOfType<OkObjectResult>();
|
||||
var saved = await _dataContext.ArrConfigs.AsNoTracking().FirstAsync(c => c.Type == InstanceType.Sonarr);
|
||||
saved.FailedImportMaxStrikes.ShouldBe((short)7);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSonarrConfig_DefaultStrikes_PassesThrough()
|
||||
{
|
||||
// ArrConfig.Validate is currently a no-op; -1 (the default disabled value) is accepted
|
||||
var request = new UpdateArrConfigRequest { FailedImportMaxStrikes = -1 };
|
||||
|
||||
// Act
|
||||
var result = await _controller.UpdateSonarrConfig(request);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeOfType<OkObjectResult>();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Create instance
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSonarrInstance_PersistsInstanceUnderSonarrConfig()
|
||||
{
|
||||
// Arrange
|
||||
var request = new ArrInstanceRequest
|
||||
{
|
||||
Name = "test",
|
||||
Url = "http://sonarr.test:8989",
|
||||
ApiKey = "abc",
|
||||
Version = 4f,
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _controller.CreateSonarrInstance(request);
|
||||
|
||||
// Assert
|
||||
var created = result.ShouldBeOfType<CreatedAtActionResult>();
|
||||
var dto = created.Value.ShouldBeOfType<ArrInstanceDto>();
|
||||
dto.Name.ShouldBe("test");
|
||||
var sonarrConfig = await _dataContext.ArrConfigs
|
||||
.Include(c => c.Instances)
|
||||
.FirstAsync(c => c.Type == InstanceType.Sonarr);
|
||||
sonarrConfig.Instances.ShouldContain(i => i.Name == "test");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSonarrInstance_PlaceholderApiKey_ThrowsValidationException()
|
||||
{
|
||||
// Arrange — placeholder ApiKey is rejected by ArrInstanceRequest.ToEntity
|
||||
var request = new ArrInstanceRequest
|
||||
{
|
||||
Name = "test",
|
||||
Url = "http://sonarr.test:8989",
|
||||
ApiKey = "••••••••",
|
||||
Version = 4f,
|
||||
};
|
||||
|
||||
// Act / Assert
|
||||
await Should.ThrowAsync<Cleanuparr.Domain.Exceptions.ValidationException>(
|
||||
() => _controller.CreateSonarrInstance(request));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Update instance
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSonarrInstance_UnknownId_ReturnsNotFound()
|
||||
{
|
||||
// Arrange
|
||||
var request = new ArrInstanceRequest
|
||||
{
|
||||
Name = "x",
|
||||
Url = "http://x",
|
||||
ApiKey = "k",
|
||||
Version = 4f,
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _controller.UpdateSonarrInstance(Guid.NewGuid(), request);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(404);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSonarrInstance_Existing_PersistsChanges()
|
||||
{
|
||||
// Arrange
|
||||
var sonarr = await _dataContext.ArrConfigs.FirstAsync(c => c.Type == InstanceType.Sonarr);
|
||||
var instance = new ArrInstance
|
||||
{
|
||||
Name = "orig",
|
||||
Url = new Uri("http://orig:8989"),
|
||||
ApiKey = "k",
|
||||
ArrConfigId = sonarr.Id,
|
||||
Enabled = true,
|
||||
};
|
||||
_dataContext.ArrInstances.Add(instance);
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
var request = new ArrInstanceRequest
|
||||
{
|
||||
Name = "renamed",
|
||||
Url = "http://renamed:8989",
|
||||
ApiKey = "newkey",
|
||||
Version = 4f,
|
||||
Enabled = false,
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _controller.UpdateSonarrInstance(instance.Id, request);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeOfType<OkObjectResult>();
|
||||
var saved = await _dataContext.ArrInstances.AsNoTracking().FirstAsync(i => i.Id == instance.Id);
|
||||
saved.Name.ShouldBe("renamed");
|
||||
saved.Enabled.ShouldBeFalse();
|
||||
saved.ApiKey.ShouldBe("newkey");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Delete instance
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteSonarrInstance_UnknownId_ReturnsNotFound()
|
||||
{
|
||||
// Act
|
||||
var result = await _controller.DeleteSonarrInstance(Guid.NewGuid());
|
||||
|
||||
// Assert
|
||||
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(404);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteSonarrInstance_Existing_ReturnsNoContent()
|
||||
{
|
||||
// Arrange
|
||||
var sonarr = await _dataContext.ArrConfigs.FirstAsync(c => c.Type == InstanceType.Sonarr);
|
||||
var instance = new ArrInstance
|
||||
{
|
||||
Name = "doomed",
|
||||
Url = new Uri("http://doomed:8989"),
|
||||
ApiKey = "k",
|
||||
ArrConfigId = sonarr.Id,
|
||||
Enabled = true,
|
||||
};
|
||||
_dataContext.ArrInstances.Add(instance);
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
// Act
|
||||
var result = await _controller.DeleteSonarrInstance(instance.Id);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeOfType<NoContentResult>();
|
||||
(await _dataContext.ArrInstances.CountAsync(i => i.Id == instance.Id)).ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteSonarrInstance_WhenStateCleanupFails_KeepsInstance()
|
||||
{
|
||||
// Arrange
|
||||
var sonarr = await _dataContext.ArrConfigs.FirstAsync(c => c.Type == InstanceType.Sonarr);
|
||||
var instance = new ArrInstance
|
||||
{
|
||||
Name = "doomed",
|
||||
Url = new Uri("http://doomed:8989"),
|
||||
ApiKey = "k",
|
||||
ArrConfigId = sonarr.Id,
|
||||
Enabled = true,
|
||||
};
|
||||
_dataContext.ArrInstances.Add(instance);
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
// Force the events cleanup to fail so the instance delete must roll back
|
||||
await _eventsContext.DisposeAsync();
|
||||
|
||||
// Act
|
||||
await Should.ThrowAsync<Exception>(() => _controller.DeleteSonarrInstance(instance.Id));
|
||||
|
||||
// Assert — instance is preserved (never deleted without its events state being removed)
|
||||
(await _dataContext.ArrInstances.AsNoTracking().CountAsync(i => i.Id == instance.Id)).ShouldBe(1);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Test instance
|
||||
|
||||
[Fact]
|
||||
public async Task TestSonarrInstance_HealthCheckSucceeds_ReturnsOk()
|
||||
{
|
||||
// Arrange — IArrClient.HealthCheckAsync returns Task.CompletedTask by default
|
||||
var request = new TestArrInstanceRequest
|
||||
{
|
||||
Url = "http://sonarr.test:8989",
|
||||
ApiKey = "k",
|
||||
Version = 4f,
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _controller.TestSonarrInstance(request);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeOfType<OkObjectResult>();
|
||||
await _arrClient.Received(1).HealthCheckAsync(Arg.Any<ArrInstance>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TestSonarrInstance_HealthCheckThrows_ReturnsBadRequest()
|
||||
{
|
||||
// Arrange
|
||||
_arrClient.HealthCheckAsync(Arg.Any<ArrInstance>())
|
||||
.Returns(Task.FromException(new HttpRequestException("unreachable")));
|
||||
|
||||
var request = new TestArrInstanceRequest
|
||||
{
|
||||
Url = "http://sonarr.test:8989",
|
||||
ApiKey = "k",
|
||||
Version = 4f,
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _controller.TestSonarrInstance(request);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(400);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TestSonarrInstance_PlaceholderApiKeyNoInstanceId_ReturnsBadRequest()
|
||||
{
|
||||
// Arrange — placeholder API key with no InstanceId means we can't resolve it; ToTestInstance throws
|
||||
var request = new TestArrInstanceRequest
|
||||
{
|
||||
Url = "http://sonarr.test:8989",
|
||||
ApiKey = "••••••••",
|
||||
Version = 4f,
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _controller.TestSonarrInstance(request);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(400);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TestSonarrInstance_PlaceholderApiKeyResolvesFromInstanceId_RunsCheck()
|
||||
{
|
||||
// Arrange
|
||||
var sonarr = await _dataContext.ArrConfigs.FirstAsync(c => c.Type == InstanceType.Sonarr);
|
||||
var stored = new ArrInstance
|
||||
{
|
||||
Name = "stored",
|
||||
Url = new Uri("http://stored:8989"),
|
||||
ApiKey = "stored-key",
|
||||
ArrConfigId = sonarr.Id,
|
||||
Enabled = true,
|
||||
};
|
||||
_dataContext.ArrInstances.Add(stored);
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
var request = new TestArrInstanceRequest
|
||||
{
|
||||
Url = "http://sonarr.test:8989",
|
||||
ApiKey = "••••••••",
|
||||
Version = 4f,
|
||||
InstanceId = stored.Id,
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _controller.TestSonarrInstance(request);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeOfType<OkObjectResult>();
|
||||
await _arrClient.Received(1).HealthCheckAsync(Arg.Is<ArrInstance>(i => i.ApiKey == "stored-key"));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private Task<IActionResult> DispatchGet(InstanceType type) => type switch
|
||||
{
|
||||
InstanceType.Sonarr => _controller.GetSonarrConfig(),
|
||||
InstanceType.Radarr => _controller.GetRadarrConfig(),
|
||||
InstanceType.Lidarr => _controller.GetLidarrConfig(),
|
||||
InstanceType.Readarr => _controller.GetReadarrConfig(),
|
||||
InstanceType.Whisparr => _controller.GetWhisparrConfig(),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(type)),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Shouldly;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for POST /api/account/feature-views. Verifies that feature "first seen"
|
||||
/// timestamps are recorded per user, that recording is idempotent, and that the endpoint
|
||||
/// requires authentication.
|
||||
/// </summary>
|
||||
[Collection("Auth Integration Tests")]
|
||||
[TestCaseOrderer("Cleanuparr.Api.Tests.PriorityOrderer", "Cleanuparr.Api.Tests")]
|
||||
public class AccountControllerFeatureViewsTests : IClassFixture<CustomWebApplicationFactory>
|
||||
{
|
||||
private readonly CustomWebApplicationFactory _factory;
|
||||
private readonly HttpClient _client;
|
||||
|
||||
private static string? _accessToken;
|
||||
|
||||
public AccountControllerFeatureViewsTests(CustomWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
_client = factory.CreateClient();
|
||||
|
||||
if (_accessToken is not null)
|
||||
{
|
||||
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _accessToken);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact, TestPriority(0)]
|
||||
public async Task Setup_CreateAccountAndLogin()
|
||||
{
|
||||
var createResponse = await _client.PostAsJsonAsync("/api/auth/setup/account", new
|
||||
{
|
||||
username = "featureadmin",
|
||||
password = "FeaturePassword123!"
|
||||
});
|
||||
createResponse.StatusCode.ShouldBe(HttpStatusCode.Created);
|
||||
|
||||
var completeResponse = await _client.PostAsJsonAsync("/api/auth/setup/complete", new { });
|
||||
completeResponse.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var loginResponse = await _client.PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = "featureadmin",
|
||||
password = "FeaturePassword123!"
|
||||
});
|
||||
loginResponse.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await loginResponse.Content.ReadFromJsonAsync<JsonElement>();
|
||||
_accessToken = body.GetProperty("tokens").GetProperty("accessToken").GetString();
|
||||
_accessToken.ShouldNotBeNullOrEmpty();
|
||||
|
||||
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _accessToken);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(1)]
|
||||
public async Task RecordFeatureViews_NewIds_RecordsTimestampsAndReturnsMapWithAnchor()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/account/feature-views", new
|
||||
{
|
||||
featureIds = new[] { "feature-a", "feature-b" }
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<FeatureViewsResponseDto>();
|
||||
body.ShouldNotBeNull();
|
||||
body.CreatedAt.ShouldNotBe(default);
|
||||
body.Views.ShouldContainKey("feature-a");
|
||||
body.Views.ShouldContainKey("feature-b");
|
||||
body.Views["feature-a"].Offset.ShouldBe(TimeSpan.Zero);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(2)]
|
||||
public async Task RecordFeatureViews_DuplicateId_IsIdempotentAndKeepsOriginalTimestamp()
|
||||
{
|
||||
var firstResponse = await _client.PostAsJsonAsync("/api/account/feature-views", new
|
||||
{
|
||||
featureIds = new[] { "feature-a" }
|
||||
});
|
||||
firstResponse.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
var firstBody = await firstResponse.Content.ReadFromJsonAsync<FeatureViewsResponseDto>();
|
||||
var originalTimestamp = firstBody!.Views["feature-a"];
|
||||
|
||||
var secondResponse = await _client.PostAsJsonAsync("/api/account/feature-views", new
|
||||
{
|
||||
featureIds = new[] { "feature-a" }
|
||||
});
|
||||
secondResponse.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
var secondBody = await secondResponse.Content.ReadFromJsonAsync<FeatureViewsResponseDto>();
|
||||
|
||||
secondBody!.Views["feature-a"].ShouldBe(originalTimestamp);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(3)]
|
||||
public async Task RecordFeatureViews_WhenUnauthenticated_ReturnsUnauthorized()
|
||||
{
|
||||
var unauthClient = _factory.CreateClient();
|
||||
|
||||
var response = await unauthClient.PostAsJsonAsync("/api/account/feature-views", new
|
||||
{
|
||||
featureIds = new[] { "feature-a" }
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(4)]
|
||||
public async Task RecordFeatureViews_TooManyIds_ReturnsBadRequest()
|
||||
{
|
||||
var tooMany = Enumerable.Range(0, 101).Select(i => $"feature-{i}").ToArray();
|
||||
|
||||
var response = await _client.PostAsJsonAsync("/api/account/feature-views", new
|
||||
{
|
||||
featureIds = tooMany
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(5)]
|
||||
public async Task RecordFeatureViews_OverLengthId_IsSkipped()
|
||||
{
|
||||
var overLengthId = new string('x', 65);
|
||||
|
||||
var response = await _client.PostAsJsonAsync("/api/account/feature-views", new
|
||||
{
|
||||
featureIds = new[] { "feature-ok", overLengthId }
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<FeatureViewsResponseDto>();
|
||||
body.ShouldNotBeNull();
|
||||
body.Views.ShouldContainKey("feature-ok");
|
||||
body.Views.ShouldNotContainKey(overLengthId);
|
||||
}
|
||||
|
||||
private sealed record FeatureViewsResponseDto
|
||||
{
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public Dictionary<string, DateTimeOffset> Views { get; init; } = new();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Cleanuparr.Infrastructure.Features.Auth;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Auth;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Shouldly;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for the OIDC account linking flow (POST /api/account/oidc/link and
|
||||
/// GET /api/account/oidc/link/callback). Uses a mock IOidcAuthService that tracks the
|
||||
/// initiatorUserId passed from StartOidcLink so OidcLinkCallback can complete the flow.
|
||||
/// </summary>
|
||||
[Collection("Auth Integration Tests")]
|
||||
[TestCaseOrderer("Cleanuparr.Api.Tests.PriorityOrderer", "Cleanuparr.Api.Tests")]
|
||||
public class AccountControllerOidcTests : IClassFixture<AccountControllerOidcTests.OidcLinkWebApplicationFactory>
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
private readonly OidcLinkWebApplicationFactory _factory;
|
||||
|
||||
// Shared across ordered tests
|
||||
private static string? _accessToken;
|
||||
|
||||
public AccountControllerOidcTests(OidcLinkWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
_client = factory.CreateClient(new WebApplicationFactoryClientOptions
|
||||
{
|
||||
AllowAutoRedirect = false
|
||||
});
|
||||
|
||||
if (_accessToken is not null)
|
||||
{
|
||||
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _accessToken);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact, TestPriority(0)]
|
||||
public async Task Setup_CreateAccountAndComplete()
|
||||
{
|
||||
var createResponse = await _client.PostAsJsonAsync("/api/auth/setup/account", new
|
||||
{
|
||||
username = "linkadmin",
|
||||
password = "LinkPassword123!"
|
||||
});
|
||||
createResponse.StatusCode.ShouldBe(HttpStatusCode.Created);
|
||||
|
||||
var completeResponse = await _client.PostAsJsonAsync("/api/auth/setup/complete", new { });
|
||||
completeResponse.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(1)]
|
||||
public async Task Login_StoreAccessToken()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = "linkadmin",
|
||||
password = "LinkPassword123!"
|
||||
});
|
||||
|
||||
var bodyText = await response.Content.ReadAsStringAsync();
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK, $"Login failed. Body: {bodyText}");
|
||||
|
||||
var body = JsonSerializer.Deserialize<JsonElement>(bodyText);
|
||||
body.TryGetProperty("requiresTwoFactor", out var rtf)
|
||||
.ShouldBeTrue($"Missing 'requiresTwoFactor' in body: {bodyText}");
|
||||
rtf.GetBoolean().ShouldBeFalse();
|
||||
// Tokens are nested: { "requiresTwoFactor": false, "tokens": { "accessToken": "..." } }
|
||||
_accessToken = body.GetProperty("tokens").GetProperty("accessToken").GetString();
|
||||
_accessToken.ShouldNotBeNullOrEmpty();
|
||||
|
||||
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _accessToken);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(2)]
|
||||
public async Task OidcLink_WhenOidcDisabled_ReturnsBadRequest()
|
||||
{
|
||||
var response = await _client.PostAsync("/api/account/oidc/link", null);
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("detail").GetString().ShouldContain("OIDC is not enabled");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(3)]
|
||||
public async Task EnableOidcConfig_ViaDirectDbUpdate()
|
||||
{
|
||||
await _factory.EnableOidcAsync();
|
||||
|
||||
var statusResponse = await _client.GetAsync("/api/auth/status");
|
||||
statusResponse.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await statusResponse.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("oidcEnabled").GetBoolean().ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(4)]
|
||||
public async Task OidcLink_WhenAuthenticated_ReturnsAuthorizationUrl()
|
||||
{
|
||||
var response = await _client.PostAsync("/api/account/oidc/link", null);
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var authUrl = body.GetProperty("authorizationUrl").GetString();
|
||||
authUrl.ShouldNotBeNullOrEmpty();
|
||||
authUrl.ShouldContain("authorize");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(5)]
|
||||
public async Task OidcLinkCallback_WithErrorParam_RedirectsToSettingsWithError()
|
||||
{
|
||||
var response = await _client.GetAsync("/api/account/oidc/link/callback?error=access_denied");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Redirect);
|
||||
var location = response.Headers.Location?.ToString();
|
||||
location.ShouldNotBeNull();
|
||||
location.ShouldContain("/settings/account");
|
||||
location.ShouldContain("oidc_link_error=failed");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(6)]
|
||||
public async Task OidcLinkCallback_MissingCodeOrState_RedirectsWithError()
|
||||
{
|
||||
var noParams = await _client.GetAsync("/api/account/oidc/link/callback");
|
||||
noParams.StatusCode.ShouldBe(HttpStatusCode.Redirect);
|
||||
noParams.Headers.Location?.ToString().ShouldContain("oidc_link_error=failed");
|
||||
|
||||
var onlyCode = await _client.GetAsync("/api/account/oidc/link/callback?code=some-code");
|
||||
onlyCode.StatusCode.ShouldBe(HttpStatusCode.Redirect);
|
||||
onlyCode.Headers.Location?.ToString().ShouldContain("oidc_link_error=failed");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(7)]
|
||||
public async Task OidcLinkCallback_ValidFlow_SavesSubjectAndRedirectsToSuccess()
|
||||
{
|
||||
// First trigger StartOidcLink so the mock captures the initiatorUserId
|
||||
var linkResponse = await _client.PostAsync("/api/account/oidc/link", null);
|
||||
linkResponse.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
// Now simulate the IdP callback with the mock's success state
|
||||
var callbackResponse = await _client.GetAsync(
|
||||
$"/api/account/oidc/link/callback?code=valid-code&state={MockOidcAuthService.LinkSuccessState}");
|
||||
|
||||
callbackResponse.StatusCode.ShouldBe(HttpStatusCode.Redirect);
|
||||
var location = callbackResponse.Headers.Location?.ToString();
|
||||
location.ShouldNotBeNull();
|
||||
location.ShouldContain("/settings/account");
|
||||
location.ShouldContain("oidc_link=success");
|
||||
location.ShouldNotContain("oidc_link_error");
|
||||
|
||||
// Verify the subject was saved to config
|
||||
var savedSubject = await _factory.GetAuthorizedSubjectAsync();
|
||||
savedSubject.ShouldBe(MockOidcAuthService.LinkedSubject);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(8)]
|
||||
public async Task OidcLinkCallback_NoInitiatorUserId_RedirectsWithError()
|
||||
{
|
||||
var response = await _client.GetAsync(
|
||||
$"/api/account/oidc/link/callback?code=valid-code&state={MockOidcAuthService.NoInitiatorState}");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Redirect);
|
||||
var location = response.Headers.Location?.ToString();
|
||||
location.ShouldNotBeNull();
|
||||
location.ShouldContain("oidc_link_error=failed");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(9)]
|
||||
public async Task OidcLink_WhenUnauthenticated_ReturnsUnauthorized()
|
||||
{
|
||||
// Create a fresh unauthenticated client
|
||||
var unauthClient = _factory.CreateClient(new WebApplicationFactoryClientOptions
|
||||
{
|
||||
AllowAutoRedirect = false
|
||||
});
|
||||
|
||||
var response = await unauthClient.PostAsync("/api/account/oidc/link", null);
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
|
||||
}
|
||||
|
||||
#region Exclusive Mode
|
||||
|
||||
[Fact, TestPriority(10)]
|
||||
public async Task EnableExclusiveMode_ViaDirectDbUpdate()
|
||||
{
|
||||
await _factory.SetOidcExclusiveModeAsync(true);
|
||||
|
||||
var response = await _client.GetAsync("/api/auth/status");
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("oidcExclusiveMode").GetBoolean().ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(11)]
|
||||
public async Task ChangePassword_Blocked_WhenExclusiveModeActive()
|
||||
{
|
||||
var response = await _client.PutAsJsonAsync("/api/account/password", new
|
||||
{
|
||||
currentPassword = "LinkPassword123!",
|
||||
newPassword = "NewPassword456!"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(12)]
|
||||
public async Task PlexLink_Blocked_WhenExclusiveModeActive()
|
||||
{
|
||||
var response = await _client.PostAsync("/api/account/plex/link", null);
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(13)]
|
||||
public async Task PlexUnlink_Blocked_WhenExclusiveModeActive()
|
||||
{
|
||||
var response = await _client.DeleteAsync("/api/account/plex/link");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(14)]
|
||||
public async Task OidcConfigUpdate_StillWorks_WhenExclusiveModeActive()
|
||||
{
|
||||
var response = await _client.PutAsJsonAsync("/api/account/oidc", new
|
||||
{
|
||||
enabled = true,
|
||||
issuerUrl = "https://mock-oidc-provider.test",
|
||||
clientId = "test-client",
|
||||
clientSecret = "test-secret",
|
||||
scopes = "openid profile email",
|
||||
authorizedSubject = MockOidcAuthService.LinkedSubject,
|
||||
providerName = "TestProvider",
|
||||
redirectUrl = "",
|
||||
exclusiveMode = true
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(15)]
|
||||
public async Task OidcUnlink_ResetsExclusiveMode()
|
||||
{
|
||||
var response = await _client.DeleteAsync("/api/account/oidc/link");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
// Verify exclusive mode was reset
|
||||
var exclusiveMode = await _factory.GetExclusiveModeAsync();
|
||||
exclusiveMode.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(16)]
|
||||
public async Task DisableExclusiveMode_PasswordChangeWorks_Again()
|
||||
{
|
||||
// Re-enable OIDC with a linked subject but without exclusive mode
|
||||
await _factory.EnableOidcAsync();
|
||||
await _factory.SetOidcExclusiveModeAsync(false);
|
||||
|
||||
var response = await _client.PutAsJsonAsync("/api/account/password", new
|
||||
{
|
||||
currentPassword = "LinkPassword123!",
|
||||
newPassword = "NewPassword789!"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Test Infrastructure
|
||||
|
||||
public class OidcLinkWebApplicationFactory : CustomWebApplicationFactory
|
||||
{
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
base.ConfigureWebHost(builder);
|
||||
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
var oidcDescriptor = services.SingleOrDefault(d => d.ServiceType == typeof(IOidcAuthService));
|
||||
if (oidcDescriptor != null) services.Remove(oidcDescriptor);
|
||||
|
||||
services.AddSingleton<IOidcAuthService, MockOidcAuthService>();
|
||||
});
|
||||
}
|
||||
|
||||
public async Task EnableOidcAsync()
|
||||
{
|
||||
using var scope = Services.CreateScope();
|
||||
var usersContext = scope.ServiceProvider.GetRequiredService<UsersContext>();
|
||||
|
||||
var user = await usersContext.Users.FirstOrDefaultAsync();
|
||||
if (user is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
user.Oidc = new OidcConfig
|
||||
{
|
||||
Enabled = true,
|
||||
IssuerUrl = "https://mock-oidc-provider.test",
|
||||
ClientId = "test-client",
|
||||
ClientSecret = "test-secret",
|
||||
Scopes = "openid profile email",
|
||||
AuthorizedSubject = "initial-subject",
|
||||
ProviderName = "TestProvider"
|
||||
};
|
||||
|
||||
await usersContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task<string?> GetAuthorizedSubjectAsync()
|
||||
{
|
||||
using var scope = Services.CreateScope();
|
||||
var usersContext = scope.ServiceProvider.GetRequiredService<UsersContext>();
|
||||
|
||||
var user = await usersContext.Users.AsNoTracking().FirstOrDefaultAsync();
|
||||
return user?.Oidc.AuthorizedSubject;
|
||||
}
|
||||
|
||||
public async Task SetOidcExclusiveModeAsync(bool enabled)
|
||||
{
|
||||
using var scope = Services.CreateScope();
|
||||
var usersContext = scope.ServiceProvider.GetRequiredService<UsersContext>();
|
||||
|
||||
var user = await usersContext.Users.FirstOrDefaultAsync();
|
||||
if (user is not null)
|
||||
{
|
||||
user.Oidc.ExclusiveMode = enabled;
|
||||
await usersContext.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> GetExclusiveModeAsync()
|
||||
{
|
||||
using var scope = Services.CreateScope();
|
||||
var usersContext = scope.ServiceProvider.GetRequiredService<UsersContext>();
|
||||
|
||||
var user = await usersContext.Users.AsNoTracking().FirstOrDefaultAsync();
|
||||
return user?.Oidc.ExclusiveMode ?? false;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private sealed class MockOidcAuthService : IOidcAuthService
|
||||
{
|
||||
public const string LinkSuccessState = "mock-link-success-state";
|
||||
public const string NoInitiatorState = "mock-no-initiator-state";
|
||||
public const string LinkedSubject = "newly-linked-subject-123";
|
||||
|
||||
private string? _lastInitiatorUserId;
|
||||
private readonly ConcurrentDictionary<string, OidcTokenExchangeResult> _oneTimeCodes = new();
|
||||
|
||||
public Task<OidcAuthorizationResult> StartAuthorization(string redirectUri, string? initiatorUserId = null)
|
||||
{
|
||||
_lastInitiatorUserId = initiatorUserId;
|
||||
return Task.FromResult(new OidcAuthorizationResult
|
||||
{
|
||||
AuthorizationUrl = $"https://mock-oidc-provider.test/authorize?state={LinkSuccessState}",
|
||||
State = LinkSuccessState
|
||||
});
|
||||
}
|
||||
|
||||
public Task<OidcCallbackResult> HandleCallback(string code, string state, string redirectUri)
|
||||
{
|
||||
if (state == LinkSuccessState)
|
||||
{
|
||||
return Task.FromResult(new OidcCallbackResult
|
||||
{
|
||||
Success = true,
|
||||
Subject = LinkedSubject,
|
||||
PreferredUsername = "linkuser",
|
||||
Email = "link@example.com",
|
||||
InitiatorUserId = _lastInitiatorUserId
|
||||
});
|
||||
}
|
||||
|
||||
if (state == NoInitiatorState)
|
||||
{
|
||||
return Task.FromResult(new OidcCallbackResult
|
||||
{
|
||||
Success = true,
|
||||
Subject = LinkedSubject,
|
||||
InitiatorUserId = null // No initiator — controller should redirect with error
|
||||
});
|
||||
}
|
||||
|
||||
return Task.FromResult(new OidcCallbackResult
|
||||
{
|
||||
Success = false,
|
||||
Error = "Invalid or expired OIDC state"
|
||||
});
|
||||
}
|
||||
|
||||
public string StoreOneTimeCode(string accessToken, string refreshToken, int expiresIn)
|
||||
{
|
||||
var code = Guid.NewGuid().ToString("N");
|
||||
_oneTimeCodes.TryAdd(code, new OidcTokenExchangeResult
|
||||
{
|
||||
AccessToken = accessToken,
|
||||
RefreshToken = refreshToken,
|
||||
ExpiresIn = expiresIn
|
||||
});
|
||||
return code;
|
||||
}
|
||||
|
||||
public OidcTokenExchangeResult? ExchangeOneTimeCode(string code) =>
|
||||
_oneTimeCodes.TryRemove(code, out var result) ? result : null;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Shouldly;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for the authentication flow.
|
||||
/// Uses a single shared factory to avoid static state conflicts.
|
||||
/// Tests are ordered to build on each other: setup → login → protected endpoints.
|
||||
/// </summary>
|
||||
[Collection("Auth Integration Tests")]
|
||||
[TestCaseOrderer("Cleanuparr.Api.Tests.PriorityOrderer", "Cleanuparr.Api.Tests")]
|
||||
public class AuthControllerTests : IClassFixture<CustomWebApplicationFactory>
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
|
||||
public AuthControllerTests(CustomWebApplicationFactory factory)
|
||||
{
|
||||
_client = factory.CreateClient();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(0)]
|
||||
public async Task GetStatus_BeforeSetup_ReturnsNotCompleted()
|
||||
{
|
||||
var response = await _client.GetAsync("/api/auth/status");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("setupCompleted").GetBoolean().ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(0)]
|
||||
public async Task AuthEndpoints_AlwaysReturnNoCacheHeaders()
|
||||
{
|
||||
var response = await _client.GetAsync("/api/auth/status");
|
||||
|
||||
response.Headers.CacheControl.ShouldNotBeNull();
|
||||
response.Headers.CacheControl!.NoCache.ShouldBeTrue();
|
||||
response.Headers.CacheControl!.NoStore.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(1)]
|
||||
public async Task Setup_CreateAccount_ReturnsCreated()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/setup/account", new
|
||||
{
|
||||
username = "admin",
|
||||
password = "TestPassword123!"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Created);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("userId").GetString().ShouldNotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(2)]
|
||||
public async Task Setup_CreateDuplicateAccount_ReturnsConflict()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/setup/account", new
|
||||
{
|
||||
username = "another",
|
||||
password = "TestPassword123!"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Conflict);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(3)]
|
||||
public async Task Setup_Generate2FA_ReturnsSecretAndRecoveryCodes()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/setup/2fa/generate", new { });
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("secret").GetString().ShouldNotBeNullOrEmpty();
|
||||
body.GetProperty("qrCodeUri").GetString().ShouldNotBeNullOrEmpty();
|
||||
body.GetProperty("recoveryCodes").GetArrayLength().ShouldBeGreaterThan(0);
|
||||
|
||||
// Store the secret for the next test
|
||||
_totpSecret = body.GetProperty("secret").GetString()!;
|
||||
}
|
||||
|
||||
[Fact, TestPriority(4)]
|
||||
public async Task Setup_Verify2FA_WithValidCode_Succeeds()
|
||||
{
|
||||
// If we don't have the secret from the previous test, generate it again
|
||||
if (string.IsNullOrEmpty(_totpSecret))
|
||||
{
|
||||
var genResponse = await _client.PostAsJsonAsync("/api/auth/setup/2fa/generate", new { });
|
||||
var genBody = await genResponse.Content.ReadFromJsonAsync<JsonElement>();
|
||||
_totpSecret = genBody.GetProperty("secret").GetString()!;
|
||||
}
|
||||
|
||||
var code = GenerateTotpCode(_totpSecret);
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/setup/2fa/verify", new { code });
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(5)]
|
||||
public async Task Setup_Complete_Succeeds()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/setup/complete", new { });
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(6)]
|
||||
public async Task Login_ValidCredentials_RequiresTwoFactor()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = "admin",
|
||||
password = "TestPassword123!"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("requiresTwoFactor").GetBoolean().ShouldBeTrue();
|
||||
body.GetProperty("loginToken").GetString().ShouldNotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(7)]
|
||||
public async Task Login_InvalidCredentials_ReturnsUnauthorized()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = "admin",
|
||||
password = "WrongPassword!"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(8)]
|
||||
public async Task Login_BruteForce_ReturnsRetryAfter()
|
||||
{
|
||||
// Make multiple failed attempts
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
await _client.PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = "admin",
|
||||
password = "WrongPassword!"
|
||||
});
|
||||
}
|
||||
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = "admin",
|
||||
password = "WrongPassword!"
|
||||
});
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.TooManyRequests)
|
||||
{
|
||||
body.GetProperty("retryAfterSeconds").GetInt32().ShouldBeGreaterThan(0);
|
||||
}
|
||||
else
|
||||
{
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
|
||||
body.TryGetProperty("retryAfterSeconds", out var retry).ShouldBeTrue();
|
||||
retry.GetInt32().ShouldBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact, TestPriority(9)]
|
||||
public async Task ProtectedEndpoint_WithoutAuth_DeniesAccess()
|
||||
{
|
||||
var response = await _client.GetAsync("/api/account");
|
||||
|
||||
// 401 (FallbackPolicy) or 403 (SetupGuardMiddleware) - both deny unauthenticated access
|
||||
new[] { HttpStatusCode.Unauthorized, HttpStatusCode.Forbidden }
|
||||
.ShouldContain(response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(10)]
|
||||
public async Task HealthEndpoint_WithoutAuth_Returns200()
|
||||
{
|
||||
var response = await _client.GetAsync("/health");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(11)]
|
||||
public async Task Setup_2FAGenerate_AfterCompletion_IsBlocked()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/setup/2fa/generate", new { });
|
||||
|
||||
// Blocked by middleware (403) or controller defense-in-depth (409)
|
||||
new[] { HttpStatusCode.Forbidden, HttpStatusCode.Conflict }
|
||||
.ShouldContain(response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(12)]
|
||||
public async Task Setup_PlexPin_AfterCompletion_IsBlocked()
|
||||
{
|
||||
var response = await _client.PostAsync("/api/auth/setup/plex/pin", null);
|
||||
|
||||
// Blocked by middleware (403) or controller defense-in-depth (409)
|
||||
new[] { HttpStatusCode.Forbidden, HttpStatusCode.Conflict }
|
||||
.ShouldContain(response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(13)]
|
||||
public async Task Setup_Complete_AfterCompletion_IsBlocked()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/setup/complete", new { });
|
||||
|
||||
// Blocked by middleware (403) or controller defense-in-depth (409)
|
||||
new[] { HttpStatusCode.Forbidden, HttpStatusCode.Conflict }
|
||||
.ShouldContain(response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(14)]
|
||||
public async Task Login_NotBlockedByMiddleware_AfterSetupEndpointsBlocked()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = "admin",
|
||||
password = "TestPassword123!"
|
||||
});
|
||||
|
||||
// Login endpoint must NOT be blocked by the middleware (403).
|
||||
// It may return OK (200) or TooManyRequests (429) due to brute force lockout from earlier tests.
|
||||
response.StatusCode.ShouldNotBe(HttpStatusCode.Forbidden);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(15)]
|
||||
public async Task AuthStatus_StillWorks_AfterSetupEndpointsBlocked()
|
||||
{
|
||||
var response = await _client.GetAsync("/api/auth/status");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("setupCompleted").GetBoolean().ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(16)]
|
||||
public async Task OidcExchange_WithNonexistentCode_ReturnsNotFound()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/oidc/exchange", new
|
||||
{
|
||||
code = "nonexistent-one-time-code"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(17)]
|
||||
public async Task AuthStatus_IncludesOidcFields()
|
||||
{
|
||||
var response = await _client.GetAsync("/api/auth/status");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
// Verify OIDC fields exist in the response (values depend on shared static DB state)
|
||||
body.TryGetProperty("oidcEnabled", out _).ShouldBeTrue();
|
||||
body.TryGetProperty("oidcProviderName", out _).ShouldBeTrue();
|
||||
}
|
||||
|
||||
#region TOTP helpers
|
||||
|
||||
private static string _totpSecret = "";
|
||||
|
||||
private static string GenerateTotpCode(string base32Secret)
|
||||
{
|
||||
var key = Base32Decode(base32Secret);
|
||||
var timestep = (long)(DateTime.UtcNow - DateTime.UnixEpoch).TotalSeconds / 30;
|
||||
var timestepBytes = BitConverter.GetBytes(timestep);
|
||||
|
||||
if (BitConverter.IsLittleEndian)
|
||||
Array.Reverse(timestepBytes);
|
||||
|
||||
using var hmac = new System.Security.Cryptography.HMACSHA1(key);
|
||||
var hash = hmac.ComputeHash(timestepBytes);
|
||||
|
||||
var offset = hash[^1] & 0x0F;
|
||||
var binaryCode =
|
||||
((hash[offset] & 0x7F) << 24) |
|
||||
((hash[offset + 1] & 0xFF) << 16) |
|
||||
((hash[offset + 2] & 0xFF) << 8) |
|
||||
(hash[offset + 3] & 0xFF);
|
||||
|
||||
return (binaryCode % 1_000_000).ToString("D6");
|
||||
}
|
||||
|
||||
private static byte[] Base32Decode(string base32)
|
||||
{
|
||||
const string alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||
base32 = base32.ToUpperInvariant().TrimEnd('=');
|
||||
|
||||
var bits = new List<byte>();
|
||||
foreach (var c in base32)
|
||||
{
|
||||
var val = alphabet.IndexOf(c);
|
||||
if (val < 0) continue;
|
||||
for (var i = 4; i >= 0; i--)
|
||||
bits.Add((byte)((val >> i) & 1));
|
||||
}
|
||||
|
||||
var bytes = new byte[bits.Count / 8];
|
||||
for (var i = 0; i < bytes.Length; i++)
|
||||
{
|
||||
for (var j = 0; j < 8; j++)
|
||||
bytes[i] = (byte)((bytes[i] << 1) | bits[i * 8 + j]);
|
||||
}
|
||||
|
||||
return bytes;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using Cleanuparr.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Shouldly;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Tests that the login endpoint always runs BCrypt verification regardless of
|
||||
/// username validity, preventing timing-based username enumeration.
|
||||
/// </summary>
|
||||
[Collection("Login Timing Tests")]
|
||||
[TestCaseOrderer("Cleanuparr.Api.Tests.PriorityOrderer", "Cleanuparr.Api.Tests")]
|
||||
public class LoginTimingTests : IClassFixture<TimingTestWebApplicationFactory>
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
private readonly TimingTestWebApplicationFactory _factory;
|
||||
|
||||
public LoginTimingTests(TimingTestWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
_client = factory.CreateClient();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(0)]
|
||||
public async Task Login_NoUserExists_StillCallsPasswordVerification()
|
||||
{
|
||||
_factory.TrackingPasswordService.Reset();
|
||||
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = "nouser",
|
||||
password = "SomePassword123!"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
|
||||
_factory.TrackingPasswordService.VerifyPasswordCallCount.ShouldBeGreaterThanOrEqualTo(1);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(1)]
|
||||
public async Task Setup_CreateAccountAndComplete()
|
||||
{
|
||||
var createResponse = await _client.PostAsJsonAsync("/api/auth/setup/account", new
|
||||
{
|
||||
username = "timingtest",
|
||||
password = "TimingTestPassword123!"
|
||||
});
|
||||
createResponse.StatusCode.ShouldBe(HttpStatusCode.Created);
|
||||
|
||||
var completeResponse = await _client.PostAsJsonAsync("/api/auth/setup/complete", new { });
|
||||
completeResponse.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(2)]
|
||||
public async Task Login_ValidUsername_CallsPasswordVerification()
|
||||
{
|
||||
_factory.TrackingPasswordService.Reset();
|
||||
|
||||
await _client.PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = "timingtest",
|
||||
password = "TimingTestPassword123!"
|
||||
});
|
||||
|
||||
_factory.TrackingPasswordService.VerifyPasswordCallCount.ShouldBeGreaterThanOrEqualTo(1);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(3)]
|
||||
public async Task Login_NonexistentUsername_StillCallsPasswordVerification()
|
||||
{
|
||||
_factory.TrackingPasswordService.Reset();
|
||||
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = "doesnotexist",
|
||||
password = "SomePassword123!"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
|
||||
_factory.TrackingPasswordService.VerifyPasswordCallCount.ShouldBeGreaterThanOrEqualTo(1);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(4)]
|
||||
public async Task Login_LockedOutUser_StillCallsPasswordVerification()
|
||||
{
|
||||
// Set lockout state directly in the database to avoid timing sensitivity
|
||||
using (var scope = _factory.Services.CreateScope())
|
||||
{
|
||||
var context = scope.ServiceProvider.GetRequiredService<UsersContext>();
|
||||
var user = await context.Users.FirstAsync();
|
||||
user.FailedLoginAttempts = 5;
|
||||
user.LockoutEnd = DateTime.UtcNow.AddMinutes(5);
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
_factory.TrackingPasswordService.Reset();
|
||||
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = "timingtest",
|
||||
password = "WrongPassword!"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.TooManyRequests);
|
||||
_factory.TrackingPasswordService.VerifyPasswordCallCount.ShouldBeGreaterThanOrEqualTo(1);
|
||||
|
||||
// Reset lockout for subsequent tests
|
||||
using (var scope = _factory.Services.CreateScope())
|
||||
{
|
||||
var context = scope.ServiceProvider.GetRequiredService<UsersContext>();
|
||||
var user = await context.Users.FirstAsync();
|
||||
user.FailedLoginAttempts = 0;
|
||||
user.LockoutEnd = null;
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact, TestPriority(5)]
|
||||
public async Task Login_TimingConsistency_InvalidAndValidUsernamesTakeSimilarTime()
|
||||
{
|
||||
const int iterations = 10;
|
||||
|
||||
// Warm up the server and BCrypt static init
|
||||
await _client.PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = "warmup",
|
||||
password = "WarmupPassword123!"
|
||||
});
|
||||
|
||||
var invalidTimings = new List<long>(iterations);
|
||||
var validTimings = new List<long>(iterations);
|
||||
|
||||
for (var i = 0; i < iterations; i++)
|
||||
{
|
||||
// Alternate to avoid ordering bias
|
||||
var invalidSw = Stopwatch.StartNew();
|
||||
await _client.PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = $"nonexistent_{i}",
|
||||
password = "SomePassword123!"
|
||||
});
|
||||
invalidSw.Stop();
|
||||
invalidTimings.Add(invalidSw.ElapsedMilliseconds);
|
||||
|
||||
var validSw = Stopwatch.StartNew();
|
||||
await _client.PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = "timingtest",
|
||||
password = "WrongPasswordForTiming!"
|
||||
});
|
||||
validSw.Stop();
|
||||
validTimings.Add(validSw.ElapsedMilliseconds);
|
||||
}
|
||||
|
||||
var invalidMedian = Median(invalidTimings);
|
||||
var validMedian = Median(validTimings);
|
||||
|
||||
// The invalid-username path must not be suspiciously fast
|
||||
invalidMedian.ShouldBeGreaterThan(50,
|
||||
$"Non-existent username median too fast ({invalidMedian}ms) — BCrypt may have been skipped");
|
||||
|
||||
// Medians should be in the same ballpark
|
||||
var ratio = invalidMedian > validMedian
|
||||
? (double)invalidMedian / validMedian
|
||||
: (double)validMedian / invalidMedian;
|
||||
|
||||
ratio.ShouldBeLessThan(3.0,
|
||||
$"Timing difference too large: invalid median={invalidMedian}ms, valid median={validMedian}ms (ratio={ratio:F1}x)");
|
||||
}
|
||||
|
||||
private static long Median(List<long> values)
|
||||
{
|
||||
values.Sort();
|
||||
var mid = values.Count / 2;
|
||||
return values.Count % 2 == 0
|
||||
? (values[mid - 1] + values[mid]) / 2
|
||||
: values[mid];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,630 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Cleanuparr.Infrastructure.Features.Auth;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Auth;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Shouldly;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for the OIDC authentication flow.
|
||||
/// Uses a mock IOidcAuthService to simulate IdP behavior.
|
||||
/// Tests are ordered to build on each other: setup → enable OIDC → test flow.
|
||||
/// </summary>
|
||||
[Collection("Auth Integration Tests")]
|
||||
[TestCaseOrderer("Cleanuparr.Api.Tests.PriorityOrderer", "Cleanuparr.Api.Tests")]
|
||||
public class OidcAuthControllerTests : IClassFixture<OidcAuthControllerTests.OidcWebApplicationFactory>
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
private readonly OidcWebApplicationFactory _factory;
|
||||
|
||||
public OidcAuthControllerTests(OidcWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
_client = factory.CreateClient(new WebApplicationFactoryClientOptions
|
||||
{
|
||||
AllowAutoRedirect = false // We want to inspect redirects
|
||||
});
|
||||
}
|
||||
|
||||
[Fact, TestPriority(0)]
|
||||
public async Task OidcStart_BeforeSetup_ReturnsBadRequest()
|
||||
{
|
||||
var response = await _client.PostAsync("/api/auth/oidc/start", null);
|
||||
|
||||
// OIDC start is on /api/auth/ path (not blocked by SetupGuardMiddleware)
|
||||
// but the controller returns BadRequest because OIDC is not configured
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(1)]
|
||||
public async Task Setup_CreateAccountAndComplete()
|
||||
{
|
||||
// Create account
|
||||
var createResponse = await _client.PostAsJsonAsync("/api/auth/setup/account", new
|
||||
{
|
||||
username = "admin",
|
||||
password = "TestPassword123!"
|
||||
});
|
||||
createResponse.StatusCode.ShouldBe(HttpStatusCode.Created);
|
||||
|
||||
// Complete setup (skip 2FA for this test suite)
|
||||
var completeResponse = await _client.PostAsJsonAsync("/api/auth/setup/complete", new { });
|
||||
completeResponse.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(2)]
|
||||
public async Task OidcStart_WhenDisabled_ReturnsBadRequest()
|
||||
{
|
||||
var response = await _client.PostAsync("/api/auth/oidc/start", null);
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
|
||||
response.Content.Headers.ContentType!.MediaType.ShouldBe("application/problem+json");
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("detail").GetString()!.ShouldContain("OIDC is not enabled");
|
||||
body.GetProperty("traceId").GetString().ShouldNotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(3)]
|
||||
public async Task OidcExchange_WhenDisabled_ReturnsNotFound()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/oidc/exchange", new
|
||||
{
|
||||
code = "some-random-code"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(4)]
|
||||
public async Task OidcCallback_WithErrorParam_RedirectsToLoginWithError()
|
||||
{
|
||||
var response = await _client.GetAsync("/api/auth/oidc/callback?error=access_denied");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Redirect);
|
||||
var location = response.Headers.Location?.ToString();
|
||||
location.ShouldNotBeNull();
|
||||
location.ShouldContain("/auth/login");
|
||||
location.ShouldContain("oidc_error=provider_error");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(5)]
|
||||
public async Task OidcCallback_WithoutCodeOrState_RedirectsToLoginWithError()
|
||||
{
|
||||
var response = await _client.GetAsync("/api/auth/oidc/callback");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Redirect);
|
||||
var location = response.Headers.Location?.ToString();
|
||||
location.ShouldNotBeNull();
|
||||
location.ShouldContain("oidc_error=invalid_request");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(6)]
|
||||
public async Task OidcCallback_WithOnlyCode_RedirectsToLoginWithError()
|
||||
{
|
||||
var response = await _client.GetAsync("/api/auth/oidc/callback?code=some-code");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Redirect);
|
||||
var location = response.Headers.Location?.ToString();
|
||||
location.ShouldNotBeNull();
|
||||
location.ShouldContain("oidc_error=invalid_request");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(7)]
|
||||
public async Task OidcCallback_WithInvalidState_RedirectsToLoginWithError()
|
||||
{
|
||||
// Even with code and state, if the state is invalid the mock will return failure
|
||||
var response = await _client.GetAsync("/api/auth/oidc/callback?code=some-code&state=invalid-state");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Redirect);
|
||||
var location = response.Headers.Location?.ToString();
|
||||
location.ShouldNotBeNull();
|
||||
location.ShouldContain("oidc_error=authentication_failed");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(8)]
|
||||
public async Task EnableOidcConfig_ViaDirectDbUpdate()
|
||||
{
|
||||
// Simulate enabling OIDC via direct DB manipulation (since we'd normally do this through settings UI)
|
||||
await _factory.EnableOidcAsync();
|
||||
|
||||
// Verify auth status reflects OIDC enabled
|
||||
var response = await _client.GetAsync("/api/auth/status");
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("oidcEnabled").GetBoolean().ShouldBeTrue();
|
||||
body.GetProperty("oidcProviderName").GetString().ShouldBe("TestProvider");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(9)]
|
||||
public async Task OidcStart_WhenEnabled_ReturnsAuthorizationUrl()
|
||||
{
|
||||
var response = await _client.PostAsync("/api/auth/oidc/start", null);
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var authUrl = body.GetProperty("authorizationUrl").GetString();
|
||||
authUrl.ShouldNotBeNullOrEmpty();
|
||||
authUrl.ShouldContain("authorize");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(10)]
|
||||
public async Task OidcCallback_ValidFlow_RedirectsWithOneTimeCode()
|
||||
{
|
||||
// Use the mock's valid state to simulate a successful callback
|
||||
var response = await _client.GetAsync(
|
||||
$"/api/auth/oidc/callback?code=valid-auth-code&state={MockOidcAuthService.ValidState}");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Redirect);
|
||||
var location = response.Headers.Location?.ToString();
|
||||
location.ShouldNotBeNull();
|
||||
location.ShouldContain("/auth/oidc/callback");
|
||||
location.ShouldContain("code=");
|
||||
// Should NOT contain oidc_error
|
||||
location.ShouldNotContain("oidc_error");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(11)]
|
||||
public async Task OidcExchange_ValidOneTimeCode_ReturnsTokens()
|
||||
{
|
||||
// First, trigger a valid callback to get a one-time code
|
||||
var callbackResponse = await _client.GetAsync(
|
||||
$"/api/auth/oidc/callback?code=valid-auth-code&state={MockOidcAuthService.ValidState}");
|
||||
callbackResponse.StatusCode.ShouldBe(HttpStatusCode.Redirect);
|
||||
|
||||
var location = callbackResponse.Headers.Location?.ToString();
|
||||
location.ShouldNotBeNull();
|
||||
|
||||
// Extract the one-time code from the redirect URL
|
||||
var uri = new Uri("http://localhost" + location);
|
||||
var queryParams = System.Web.HttpUtility.ParseQueryString(uri.Query);
|
||||
var oneTimeCode = queryParams["code"];
|
||||
oneTimeCode.ShouldNotBeNullOrEmpty();
|
||||
|
||||
// Exchange the one-time code for tokens
|
||||
var exchangeResponse = await _client.PostAsJsonAsync("/api/auth/oidc/exchange", new
|
||||
{
|
||||
code = oneTimeCode
|
||||
});
|
||||
|
||||
exchangeResponse.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await exchangeResponse.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("accessToken").GetString().ShouldNotBeNullOrEmpty();
|
||||
body.GetProperty("refreshToken").GetString().ShouldNotBeNullOrEmpty();
|
||||
body.GetProperty("expiresIn").GetInt32().ShouldBeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(12)]
|
||||
public async Task OidcExchange_SameCodeTwice_SecondFails()
|
||||
{
|
||||
// First, trigger a valid callback
|
||||
var callbackResponse = await _client.GetAsync(
|
||||
$"/api/auth/oidc/callback?code=valid-auth-code&state={MockOidcAuthService.ValidState}");
|
||||
var location = callbackResponse.Headers.Location?.ToString()!;
|
||||
var uri = new Uri("http://localhost" + location);
|
||||
var queryParams = System.Web.HttpUtility.ParseQueryString(uri.Query);
|
||||
var oneTimeCode = queryParams["code"]!;
|
||||
|
||||
// First exchange succeeds
|
||||
var response1 = await _client.PostAsJsonAsync("/api/auth/oidc/exchange", new { code = oneTimeCode });
|
||||
response1.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
// Second exchange with same code fails
|
||||
var response2 = await _client.PostAsJsonAsync("/api/auth/oidc/exchange", new { code = oneTimeCode });
|
||||
response2.StatusCode.ShouldBe(HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(13)]
|
||||
public async Task OidcExchange_InvalidCode_ReturnsNotFound()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/oidc/exchange", new
|
||||
{
|
||||
code = "completely-invalid-code"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(14)]
|
||||
public async Task OidcCallback_UnauthorizedSubject_RedirectsWithError()
|
||||
{
|
||||
// Use the mock's state that returns a different subject
|
||||
var response = await _client.GetAsync(
|
||||
$"/api/auth/oidc/callback?code=valid-auth-code&state={MockOidcAuthService.WrongSubjectState}");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Redirect);
|
||||
var location = response.Headers.Location?.ToString();
|
||||
location.ShouldNotBeNull();
|
||||
location.ShouldContain("oidc_error=unauthorized");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(15)]
|
||||
public async Task AuthStatus_IncludesOidcFields()
|
||||
{
|
||||
var response = await _client.GetAsync("/api/auth/status");
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("setupCompleted").GetBoolean().ShouldBeTrue();
|
||||
body.GetProperty("oidcEnabled").GetBoolean().ShouldBeTrue();
|
||||
body.GetProperty("oidcProviderName").GetString().ShouldBe("TestProvider");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(16)]
|
||||
public async Task PasswordLogin_StillWorks_AfterOidcEnabled()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = "admin",
|
||||
password = "TestPassword123!"
|
||||
});
|
||||
|
||||
// Should succeed (no 2FA since we skipped it in setup)
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
// No 2FA, so should have tokens directly
|
||||
body.GetProperty("requiresTwoFactor").GetBoolean().ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(17)]
|
||||
public async Task OidcStatus_WhenSubjectCleared_StillEnabled()
|
||||
{
|
||||
// Clearing the authorized subject should NOT disable OIDC — it just means any user can log in
|
||||
await _factory.SetOidcAuthorizedSubjectAsync("");
|
||||
|
||||
var response = await _client.GetAsync("/api/auth/status");
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("oidcEnabled").GetBoolean().ShouldBeTrue();
|
||||
|
||||
// Restore for subsequent tests
|
||||
await _factory.SetOidcAuthorizedSubjectAsync(MockOidcAuthService.AuthorizedSubject);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(17)]
|
||||
public async Task OidcStatus_WhenMissingIssuerUrl_ReturnsFalse()
|
||||
{
|
||||
// OIDC should be disabled when essential config (IssuerUrl) is missing
|
||||
await _factory.SetOidcIssuerUrlAsync("");
|
||||
|
||||
var response = await _client.GetAsync("/api/auth/status");
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("oidcEnabled").GetBoolean().ShouldBeFalse();
|
||||
|
||||
// Restore for subsequent tests
|
||||
await _factory.SetOidcIssuerUrlAsync("https://mock-oidc-provider.test");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(17)]
|
||||
public async Task OidcCallback_WithoutLinkedSubject_AllowsAnyUser()
|
||||
{
|
||||
// Clear the authorized subject — any OIDC user should be allowed
|
||||
await _factory.SetOidcAuthorizedSubjectAsync("");
|
||||
|
||||
// Use the "wrong subject" state — this returns a different subject than the authorized one
|
||||
// With no linked subject, it should still succeed
|
||||
var response = await _client.GetAsync(
|
||||
$"/api/auth/oidc/callback?code=valid-auth-code&state={MockOidcAuthService.WrongSubjectState}");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Redirect);
|
||||
var location = response.Headers.Location?.ToString();
|
||||
location.ShouldNotBeNull();
|
||||
location.ShouldContain("code=");
|
||||
location.ShouldNotContain("oidc_error");
|
||||
|
||||
// Restore for subsequent tests
|
||||
await _factory.SetOidcAuthorizedSubjectAsync(MockOidcAuthService.AuthorizedSubject);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(18)]
|
||||
public async Task OidcExchange_RandomCode_ReturnsNotFound()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/oidc/exchange", new
|
||||
{
|
||||
code = "completely-random-nonexistent-code"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
#region Exclusive Mode
|
||||
|
||||
[Fact, TestPriority(19)]
|
||||
public async Task EnableExclusiveMode_AuthStatusReflectsIt()
|
||||
{
|
||||
await _factory.SetOidcExclusiveModeAsync(true);
|
||||
|
||||
var response = await _client.GetAsync("/api/auth/status");
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("oidcExclusiveMode").GetBoolean().ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(20)]
|
||||
public async Task PasswordLogin_Blocked_WhenExclusiveModeActive()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = "admin",
|
||||
password = "TestPassword123!"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(21)]
|
||||
public async Task TwoFactorLogin_Blocked_WhenExclusiveModeActive()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/login/2fa", new
|
||||
{
|
||||
loginToken = "some-token",
|
||||
code = "123456"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(22)]
|
||||
public async Task PlexLoginPin_Blocked_WhenExclusiveModeActive()
|
||||
{
|
||||
var response = await _client.PostAsync("/api/auth/login/plex/pin", null);
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(23)]
|
||||
public async Task PlexLoginVerify_Blocked_WhenExclusiveModeActive()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/login/plex/verify", new
|
||||
{
|
||||
pinId = 12345
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(24)]
|
||||
public async Task OidcStart_StillWorks_WhenExclusiveModeActive()
|
||||
{
|
||||
var response = await _client.PostAsync("/api/auth/oidc/start", null);
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("authorizationUrl").GetString().ShouldNotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(25)]
|
||||
public async Task OidcCallback_StillWorks_WhenExclusiveModeActive()
|
||||
{
|
||||
var response = await _client.GetAsync(
|
||||
$"/api/auth/oidc/callback?code=valid-auth-code&state={MockOidcAuthService.ValidState}");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Redirect);
|
||||
var location = response.Headers.Location?.ToString();
|
||||
location.ShouldNotBeNull();
|
||||
location.ShouldContain("code=");
|
||||
location.ShouldNotContain("oidc_error");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(26)]
|
||||
public async Task DisableExclusiveMode_PasswordLoginWorks_Again()
|
||||
{
|
||||
await _factory.SetOidcExclusiveModeAsync(false);
|
||||
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = "admin",
|
||||
password = "TestPassword123!"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Test Infrastructure
|
||||
|
||||
/// <summary>
|
||||
/// Custom factory that replaces IOidcAuthService with a mock for testing.
|
||||
/// </summary>
|
||||
public class OidcWebApplicationFactory : WebApplicationFactory<Program>
|
||||
{
|
||||
private readonly string _tempDir;
|
||||
|
||||
public OidcWebApplicationFactory()
|
||||
{
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), $"cleanuparr-oidc-test-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_tempDir);
|
||||
|
||||
// Redirect all database contexts to this factory's temp directory.
|
||||
ConfigurationPathProvider.SetConfigPath(_tempDir);
|
||||
}
|
||||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
builder.UseEnvironment("Testing");
|
||||
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
// Replace IOidcAuthService with mock
|
||||
var oidcDescriptor = services.SingleOrDefault(d => d.ServiceType == typeof(IOidcAuthService));
|
||||
if (oidcDescriptor != null) services.Remove(oidcDescriptor);
|
||||
|
||||
services.AddSingleton<IOidcAuthService, MockOidcAuthService>();
|
||||
|
||||
// Remove all hosted services (Quartz scheduler, BackgroundJobManager) to prevent
|
||||
// Quartz.Logging.LogProvider.ResolvedLogProvider (a cached Lazy<T>) from being accessed
|
||||
// with a disposed ILoggerFactory from the previous factory lifecycle.
|
||||
// Auth tests don't depend on background job scheduling, so this is safe.
|
||||
foreach (var hostedService in services.Where(d => d.ServiceType == typeof(IHostedService)).ToList())
|
||||
services.Remove(hostedService);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enables OIDC on the user in the UsersContext database.
|
||||
/// </summary>
|
||||
public async Task EnableOidcAsync()
|
||||
{
|
||||
using var scope = Services.CreateScope();
|
||||
var usersContext = scope.ServiceProvider.GetRequiredService<UsersContext>();
|
||||
|
||||
var user = await usersContext.Users.FirstOrDefaultAsync();
|
||||
if (user is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
user.Oidc = new OidcConfig
|
||||
{
|
||||
Enabled = true,
|
||||
IssuerUrl = "https://mock-oidc-provider.test",
|
||||
ClientId = "test-client",
|
||||
ClientSecret = "test-secret",
|
||||
Scopes = "openid profile email",
|
||||
AuthorizedSubject = MockOidcAuthService.AuthorizedSubject,
|
||||
ProviderName = "TestProvider"
|
||||
};
|
||||
|
||||
await usersContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task SetOidcIssuerUrlAsync(string issuerUrl)
|
||||
{
|
||||
using var scope = Services.CreateScope();
|
||||
var usersContext = scope.ServiceProvider.GetRequiredService<UsersContext>();
|
||||
|
||||
var user = await usersContext.Users.FirstOrDefaultAsync();
|
||||
if (user is not null)
|
||||
{
|
||||
user.Oidc.IssuerUrl = issuerUrl;
|
||||
await usersContext.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SetOidcAuthorizedSubjectAsync(string subject)
|
||||
{
|
||||
using var scope = Services.CreateScope();
|
||||
var usersContext = scope.ServiceProvider.GetRequiredService<UsersContext>();
|
||||
|
||||
var user = await usersContext.Users.FirstOrDefaultAsync();
|
||||
if (user is not null)
|
||||
{
|
||||
user.Oidc.AuthorizedSubject = subject;
|
||||
await usersContext.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SetOidcExclusiveModeAsync(bool enabled)
|
||||
{
|
||||
using var scope = Services.CreateScope();
|
||||
var usersContext = scope.ServiceProvider.GetRequiredService<UsersContext>();
|
||||
|
||||
var user = await usersContext.Users.FirstOrDefaultAsync();
|
||||
if (user is not null)
|
||||
{
|
||||
user.Oidc.ExclusiveMode = enabled;
|
||||
await usersContext.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
base.Dispose(disposing);
|
||||
|
||||
if (disposing && Directory.Exists(_tempDir))
|
||||
{
|
||||
try { Directory.Delete(_tempDir, true); } catch { /* best effort */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mock OIDC auth service that simulates IdP behavior without network calls.
|
||||
/// </summary>
|
||||
private sealed class MockOidcAuthService : IOidcAuthService
|
||||
{
|
||||
public const string ValidState = "mock-valid-state";
|
||||
public const string WrongSubjectState = "mock-wrong-subject-state";
|
||||
public const string AuthorizedSubject = "mock-authorized-subject-123";
|
||||
|
||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, OidcTokenExchangeResult> _oneTimeCodes = new();
|
||||
|
||||
public Task<OidcAuthorizationResult> StartAuthorization(string redirectUri, string? initiatorUserId = null)
|
||||
{
|
||||
return Task.FromResult(new OidcAuthorizationResult
|
||||
{
|
||||
AuthorizationUrl = $"https://mock-oidc-provider.test/authorize?redirect_uri={Uri.EscapeDataString(redirectUri)}&state={ValidState}",
|
||||
State = ValidState
|
||||
});
|
||||
}
|
||||
|
||||
public Task<OidcCallbackResult> HandleCallback(string code, string state, string redirectUri)
|
||||
{
|
||||
if (state == ValidState)
|
||||
{
|
||||
return Task.FromResult(new OidcCallbackResult
|
||||
{
|
||||
Success = true,
|
||||
Subject = AuthorizedSubject,
|
||||
PreferredUsername = "testuser",
|
||||
Email = "testuser@example.com"
|
||||
});
|
||||
}
|
||||
|
||||
if (state == WrongSubjectState)
|
||||
{
|
||||
return Task.FromResult(new OidcCallbackResult
|
||||
{
|
||||
Success = true,
|
||||
Subject = "wrong-subject-that-doesnt-match",
|
||||
PreferredUsername = "wronguser",
|
||||
Email = "wrong@example.com"
|
||||
});
|
||||
}
|
||||
|
||||
return Task.FromResult(new OidcCallbackResult
|
||||
{
|
||||
Success = false,
|
||||
Error = "Invalid or expired OIDC state"
|
||||
});
|
||||
}
|
||||
|
||||
public string StoreOneTimeCode(string accessToken, string refreshToken, int expiresIn)
|
||||
{
|
||||
var code = Guid.NewGuid().ToString("N");
|
||||
_oneTimeCodes.TryAdd(code, new OidcTokenExchangeResult
|
||||
{
|
||||
AccessToken = accessToken,
|
||||
RefreshToken = refreshToken,
|
||||
ExpiresIn = expiresIn
|
||||
});
|
||||
return code;
|
||||
}
|
||||
|
||||
public OidcTokenExchangeResult? ExchangeOneTimeCode(string code)
|
||||
{
|
||||
return _oneTimeCodes.TryRemove(code, out var result) ? result : null;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using Cleanuparr.Infrastructure.Features.Auth;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Factory variant that replaces <see cref="IPasswordService"/> with a
|
||||
/// <see cref="TrackingPasswordService"/> spy so tests can assert that
|
||||
/// password verification is always called regardless of username validity.
|
||||
/// </summary>
|
||||
public class TimingTestWebApplicationFactory : CustomWebApplicationFactory
|
||||
{
|
||||
public TrackingPasswordService TrackingPasswordService { get; } = new();
|
||||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
base.ConfigureWebHost(builder);
|
||||
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
// Replace IPasswordService with our tracking spy
|
||||
var descriptor = services.SingleOrDefault(d => d.ServiceType == typeof(IPasswordService));
|
||||
if (descriptor != null) services.Remove(descriptor);
|
||||
|
||||
services.AddSingleton<IPasswordService>(TrackingPasswordService);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using Cleanuparr.Infrastructure.Features.Auth;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Spy wrapper around <see cref="PasswordService"/> that tracks calls to
|
||||
/// <see cref="VerifyPassword"/> for behavioral assertions in timing tests.
|
||||
/// </summary>
|
||||
public sealed class TrackingPasswordService : IPasswordService
|
||||
{
|
||||
private readonly PasswordService _inner = new();
|
||||
private int _verifyPasswordCallCount;
|
||||
|
||||
public int VerifyPasswordCallCount => _verifyPasswordCallCount;
|
||||
|
||||
public string DummyHash => _inner.DummyHash;
|
||||
|
||||
public string HashPassword(string password)
|
||||
{
|
||||
return _inner.HashPassword(password);
|
||||
}
|
||||
|
||||
public bool VerifyPassword(string password, string hash)
|
||||
{
|
||||
Interlocked.Increment(ref _verifyPasswordCallCount);
|
||||
return _inner.VerifyPassword(password, hash);
|
||||
}
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
Interlocked.Exchange(ref _verifyPasswordCallCount, 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
using Cleanuparr.Api.Features.BlacklistSync.Contracts.Requests;
|
||||
using Cleanuparr.Api.Features.BlacklistSync.Controllers;
|
||||
using Cleanuparr.Api.Tests.TestHelpers;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration.BlacklistSync;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.BlacklistSync;
|
||||
|
||||
public class BlacklistSyncConfigControllerTests : IDisposable
|
||||
{
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly IJobManagementService _jobManagementService;
|
||||
private readonly BlacklistSyncConfigController _controller;
|
||||
|
||||
public BlacklistSyncConfigControllerTests()
|
||||
{
|
||||
_dataContext = ConfigControllerTestDataFactory.CreateDataContext();
|
||||
var logger = Substitute.For<ILogger<BlacklistSyncConfigController>>();
|
||||
_jobManagementService = Substitute.For<IJobManagementService>();
|
||||
_controller = new BlacklistSyncConfigController(logger, _dataContext, _jobManagementService);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_dataContext.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetBlacklistSyncConfig_ReturnsExistingConfig()
|
||||
{
|
||||
// Act
|
||||
var result = await _controller.GetBlacklistSyncConfig();
|
||||
|
||||
// Assert
|
||||
var ok = result.ShouldBeOfType<OkObjectResult>();
|
||||
ok.Value.ShouldBeOfType<BlacklistSyncConfig>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateBlacklistSyncConfig_BecameEnabled_StartsAndTriggersJob()
|
||||
{
|
||||
// Arrange — start disabled, enable with URL path
|
||||
var request = new UpdateBlacklistSyncConfigRequest
|
||||
{
|
||||
Enabled = true,
|
||||
BlacklistPath = "https://example.com/blacklist.txt",
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _controller.UpdateBlacklistSyncConfig(request);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeOfType<OkObjectResult>();
|
||||
await _jobManagementService.Received(1).StartJob(JobType.BlacklistSynchronizer, null, Arg.Any<string>());
|
||||
await _jobManagementService.Received(1).TriggerJobOnce(JobType.BlacklistSynchronizer);
|
||||
await _jobManagementService.DidNotReceive().StopJob(Arg.Any<JobType>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateBlacklistSyncConfig_BecameDisabled_StopsJob()
|
||||
{
|
||||
// Arrange — pre-enable so the toggle to disabled hits the stop path
|
||||
var existing = await _dataContext.BlacklistSyncConfigs.FirstAsync();
|
||||
existing.Enabled = true;
|
||||
existing.BlacklistPath = "https://example.com/blacklist.txt";
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
var request = new UpdateBlacklistSyncConfigRequest
|
||||
{
|
||||
Enabled = false,
|
||||
BlacklistPath = existing.BlacklistPath,
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _controller.UpdateBlacklistSyncConfig(request);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeOfType<OkObjectResult>();
|
||||
await _jobManagementService.Received(1).StopJob(JobType.BlacklistSynchronizer);
|
||||
await _jobManagementService.DidNotReceive().StartJob(Arg.Any<JobType>(), Arg.Any<Cleanuparr.Infrastructure.Models.JobSchedule?>(), Arg.Any<string?>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateBlacklistSyncConfig_StaysEnabledAndPathChanged_TriggersOnce()
|
||||
{
|
||||
// Arrange — pre-enable
|
||||
var existing = await _dataContext.BlacklistSyncConfigs.FirstAsync();
|
||||
existing.Enabled = true;
|
||||
existing.BlacklistPath = "https://example.com/old.txt";
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
var request = new UpdateBlacklistSyncConfigRequest
|
||||
{
|
||||
Enabled = true,
|
||||
BlacklistPath = "https://example.com/new.txt",
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _controller.UpdateBlacklistSyncConfig(request);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeOfType<OkObjectResult>();
|
||||
await _jobManagementService.Received(1).TriggerJobOnce(JobType.BlacklistSynchronizer);
|
||||
await _jobManagementService.DidNotReceive().StartJob(Arg.Any<JobType>(), Arg.Any<Cleanuparr.Infrastructure.Models.JobSchedule?>(), Arg.Any<string?>());
|
||||
await _jobManagementService.DidNotReceive().StopJob(Arg.Any<JobType>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateBlacklistSyncConfig_StaysEnabledNoPathChange_NoJobOps()
|
||||
{
|
||||
// Arrange — pre-enable, then resubmit identical values
|
||||
var existing = await _dataContext.BlacklistSyncConfigs.FirstAsync();
|
||||
existing.Enabled = true;
|
||||
existing.BlacklistPath = "https://example.com/list.txt";
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
var request = new UpdateBlacklistSyncConfigRequest
|
||||
{
|
||||
Enabled = true,
|
||||
BlacklistPath = existing.BlacklistPath,
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _controller.UpdateBlacklistSyncConfig(request);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeOfType<OkObjectResult>();
|
||||
await _jobManagementService.DidNotReceive().StartJob(Arg.Any<JobType>(), Arg.Any<Cleanuparr.Infrastructure.Models.JobSchedule?>(), Arg.Any<string?>());
|
||||
await _jobManagementService.DidNotReceive().StopJob(Arg.Any<JobType>());
|
||||
await _jobManagementService.DidNotReceive().TriggerJobOnce(Arg.Any<JobType>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateBlacklistSyncConfig_EnabledWithMissingPath_Throws()
|
||||
{
|
||||
// Arrange
|
||||
var request = new UpdateBlacklistSyncConfigRequest
|
||||
{
|
||||
Enabled = true,
|
||||
BlacklistPath = null,
|
||||
};
|
||||
|
||||
// Act / Assert — Validate throws and is rethrown
|
||||
await Should.ThrowAsync<Exception>(() => _controller.UpdateBlacklistSyncConfig(request));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateBlacklistSyncConfig_PersistsChangesToDatabase()
|
||||
{
|
||||
// Arrange
|
||||
var request = new UpdateBlacklistSyncConfigRequest
|
||||
{
|
||||
Enabled = true,
|
||||
BlacklistPath = "https://example.com/list.txt",
|
||||
};
|
||||
|
||||
// Act
|
||||
await _controller.UpdateBlacklistSyncConfig(request);
|
||||
|
||||
// Assert
|
||||
var saved = await _dataContext.BlacklistSyncConfigs.AsNoTracking().FirstAsync();
|
||||
saved.Enabled.ShouldBeTrue();
|
||||
saved.BlacklistPath.ShouldBe("https://example.com/list.txt");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests;
|
||||
using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Responses;
|
||||
using Cleanuparr.Api.Features.DownloadCleaner.Controllers;
|
||||
using Cleanuparr.Api.Tests.Features.DownloadCleaner.TestHelpers;
|
||||
using Cleanuparr.Api.Tests.TestHelpers;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using Shouldly;
|
||||
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.DownloadCleaner;
|
||||
|
||||
public class DeadTorrentConfigControllerTests : IDisposable
|
||||
{
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly DeadTorrentConfigController _controller;
|
||||
|
||||
public DeadTorrentConfigControllerTests()
|
||||
{
|
||||
_dataContext = SeedingRulesTestDataFactory.CreateDataContext();
|
||||
var logger = Substitute.For<ILogger<DeadTorrentConfigController>>();
|
||||
_controller = new DeadTorrentConfigController(logger, _dataContext);
|
||||
ControllerTestContext.Attach(_controller);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_dataContext.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
private static DeadTorrentConfigRequest ValidRequest(
|
||||
bool enabled = true,
|
||||
string targetCategory = "cleanuparr-dead",
|
||||
bool useTag = false,
|
||||
ushort maxStrikes = 3,
|
||||
List<string>? categories = null)
|
||||
=> new()
|
||||
{
|
||||
Enabled = enabled,
|
||||
TargetCategory = targetCategory,
|
||||
UseTag = useTag,
|
||||
MaxStrikes = maxStrikes,
|
||||
Categories = categories ?? ["movies"],
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task Update_ValidRequest_PersistsConfig()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
|
||||
var result = await _controller.UpdateDeadTorrentConfig(client.Id, ValidRequest(maxStrikes: 5, categories: ["movies", "tv"]));
|
||||
|
||||
result.ShouldBeOfType<OkObjectResult>();
|
||||
var saved = await _dataContext.DeadTorrentConfigs.AsNoTracking().SingleAsync(d => d.DownloadClientConfigId == client.Id);
|
||||
saved.Enabled.ShouldBeTrue();
|
||||
saved.MaxStrikes.ShouldBe((ushort)5);
|
||||
saved.Categories.ShouldBe(new List<string> { "movies", "tv" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_ThenGet_RoundTrips()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
await _controller.UpdateDeadTorrentConfig(client.Id, ValidRequest(useTag: true, maxStrikes: 4));
|
||||
|
||||
var result = await _controller.GetDeadTorrentConfig(client.Id);
|
||||
|
||||
var ok = result.ShouldBeOfType<OkObjectResult>();
|
||||
var config = ok.Value.ShouldBeOfType<DeadTorrentConfigResponse>();
|
||||
config.UseTag.ShouldBeTrue();
|
||||
config.MaxStrikes.ShouldBe((ushort)4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_StrikesBelowMinimum_ThrowsValidationException()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
|
||||
await Should.ThrowAsync<ValidationException>(() => _controller.UpdateDeadTorrentConfig(client.Id, ValidRequest(maxStrikes: 2)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_EnabledForRTorrent_ReturnsBadRequest()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext, DownloadClientTypeName.rTorrent, "Test rTorrent");
|
||||
|
||||
var result = await _controller.UpdateDeadTorrentConfig(client.Id, ValidRequest());
|
||||
|
||||
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(StatusCodes.Status400BadRequest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_NonExistentClient_ReturnsNotFound()
|
||||
{
|
||||
var result = await _controller.UpdateDeadTorrentConfig(Guid.NewGuid(), ValidRequest());
|
||||
|
||||
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(StatusCodes.Status404NotFound);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests;
|
||||
using Cleanuparr.Api.Features.DownloadCleaner.Controllers;
|
||||
using Cleanuparr.Api.Tests.TestHelpers;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Cleanuparr.Persistence;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.DownloadCleaner;
|
||||
|
||||
public class DownloadCleanerConfigControllerTests : IDisposable
|
||||
{
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly IJobManagementService _jobManagementService;
|
||||
private readonly DownloadCleanerConfigController _controller;
|
||||
|
||||
public DownloadCleanerConfigControllerTests()
|
||||
{
|
||||
_dataContext = ConfigControllerTestDataFactory.CreateDataContext();
|
||||
var logger = Substitute.For<ILogger<DownloadCleanerConfigController>>();
|
||||
_jobManagementService = Substitute.For<IJobManagementService>();
|
||||
_controller = new DownloadCleanerConfigController(logger, _dataContext, _jobManagementService);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_dataContext.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetDownloadCleanerConfig_NoClients_ReturnsConfigWithEmptyClientsList()
|
||||
{
|
||||
// Act
|
||||
var result = await _controller.GetDownloadCleanerConfig();
|
||||
|
||||
// Assert
|
||||
var ok = result.ShouldBeOfType<OkObjectResult>();
|
||||
ok.Value.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateDownloadCleanerConfig_Enabled_StartsJob()
|
||||
{
|
||||
// Arrange
|
||||
var request = new UpdateDownloadCleanerConfigRequest
|
||||
{
|
||||
Enabled = true,
|
||||
CronExpression = "0 0 * * * ?",
|
||||
IgnoredDownloads = new List<string>(),
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _controller.UpdateDownloadCleanerConfig(request);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeOfType<OkObjectResult>();
|
||||
await _jobManagementService.Received(1).StartJob(JobType.DownloadCleaner, null, "0 0 * * * ?");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateDownloadCleanerConfig_Disabled_StopsJob()
|
||||
{
|
||||
// Arrange — pre-enable
|
||||
var existing = await _dataContext.DownloadCleanerConfigs.FirstAsync();
|
||||
existing.Enabled = true;
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
var request = new UpdateDownloadCleanerConfigRequest
|
||||
{
|
||||
Enabled = false,
|
||||
CronExpression = "0 0 * * * ?",
|
||||
IgnoredDownloads = new List<string>(),
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _controller.UpdateDownloadCleanerConfig(request);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeOfType<OkObjectResult>();
|
||||
await _jobManagementService.Received(1).StopJob(JobType.DownloadCleaner);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateDownloadCleanerConfig_InvalidCron_PropagatesValidationException()
|
||||
{
|
||||
// Arrange — controller's catch only handles System.ComponentModel.DataAnnotations.ValidationException;
|
||||
// CronValidationHelper throws Cleanuparr.Domain.Exceptions.ValidationException which propagates.
|
||||
var request = new UpdateDownloadCleanerConfigRequest
|
||||
{
|
||||
Enabled = true,
|
||||
CronExpression = "not-a-cron",
|
||||
IgnoredDownloads = new List<string>(),
|
||||
};
|
||||
|
||||
// Act / Assert
|
||||
await Should.ThrowAsync<Cleanuparr.Domain.Exceptions.ValidationException>(
|
||||
() => _controller.UpdateDownloadCleanerConfig(request));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateDownloadCleanerConfig_PersistsChanges()
|
||||
{
|
||||
// Arrange
|
||||
var request = new UpdateDownloadCleanerConfigRequest
|
||||
{
|
||||
Enabled = true,
|
||||
CronExpression = "0 0/15 * * * ?",
|
||||
UseAdvancedScheduling = true,
|
||||
IgnoredDownloads = new List<string> { "skip-me" },
|
||||
};
|
||||
|
||||
// Act
|
||||
await _controller.UpdateDownloadCleanerConfig(request);
|
||||
|
||||
// Assert
|
||||
var saved = await _dataContext.DownloadCleanerConfigs.AsNoTracking().FirstAsync();
|
||||
saved.Enabled.ShouldBeTrue();
|
||||
saved.CronExpression.ShouldBe("0 0/15 * * * ?");
|
||||
saved.UseAdvancedScheduling.ShouldBeTrue();
|
||||
saved.IgnoredDownloads.ShouldContain("skip-me");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,501 @@
|
||||
using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests;
|
||||
using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Responses;
|
||||
using Cleanuparr.Api.Features.DownloadCleaner.Controllers;
|
||||
using Cleanuparr.Api.Tests.Features.DownloadCleaner.TestHelpers;
|
||||
using Cleanuparr.Api.Tests.TestHelpers;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Domain.Exceptions;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using Shouldly;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.DownloadCleaner;
|
||||
|
||||
public class SeedingRulesControllerTests : IDisposable
|
||||
{
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly SeedingRulesController _controller;
|
||||
|
||||
public SeedingRulesControllerTests()
|
||||
{
|
||||
_dataContext = SeedingRulesTestDataFactory.CreateDataContext();
|
||||
var logger = Substitute.For<ILogger<SeedingRulesController>>();
|
||||
_controller = new SeedingRulesController(logger, _dataContext);
|
||||
ControllerTestContext.Attach(_controller);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_dataContext.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
private static SeedingRuleRequest CreateValidRequest(
|
||||
string name = "Test Rule",
|
||||
List<string>? categories = null,
|
||||
List<string>? trackerPatterns = null,
|
||||
List<string>? tagsAny = null,
|
||||
List<string>? tagsAll = null,
|
||||
int? priority = null,
|
||||
double maxRatio = 2.0,
|
||||
double minSeedTime = 0,
|
||||
double maxSeedTime = -1,
|
||||
int minSeeders = 0,
|
||||
double maxInactiveDays = -1,
|
||||
bool deleteSourceFiles = true)
|
||||
{
|
||||
return new SeedingRuleRequest
|
||||
{
|
||||
Name = name,
|
||||
Categories = categories ?? ["movies"],
|
||||
TrackerPatterns = trackerPatterns ?? [],
|
||||
TagsAny = tagsAny ?? [],
|
||||
TagsAll = tagsAll ?? [],
|
||||
Priority = priority,
|
||||
PrivacyType = TorrentPrivacyType.Both,
|
||||
MaxRatio = maxRatio,
|
||||
MinSeedTime = minSeedTime,
|
||||
MaxSeedTime = maxSeedTime,
|
||||
MinSeeders = minSeeders,
|
||||
MaxInactiveDays = maxInactiveDays,
|
||||
DeleteSourceFiles = deleteSourceFiles,
|
||||
};
|
||||
}
|
||||
|
||||
private static List<SeedingRuleResponse> GetRulesFromOk(IActionResult result)
|
||||
{
|
||||
var okResult = result.ShouldBeOfType<OkObjectResult>();
|
||||
IEnumerable<SeedingRuleResponse> rules = okResult.Value.ShouldBeAssignableTo<IEnumerable<SeedingRuleResponse>>()!;
|
||||
return rules.ToList();
|
||||
}
|
||||
|
||||
private static T GetCreatedRule<T>(IActionResult result) where T : ISeedingRule
|
||||
{
|
||||
var createdResult = result.ShouldBeOfType<CreatedAtActionResult>();
|
||||
return createdResult.Value.ShouldBeOfType<T>();
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// GetSeedingRules
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GetSeedingRules_EmptyRules_ReturnsEmptyList()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
|
||||
var result = await _controller.GetSeedingRules(client.Id);
|
||||
|
||||
GetRulesFromOk(result).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSeedingRules_ReturnsRulesOrderedByPriority()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, name: "Rule C", priority: 3);
|
||||
SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, name: "Rule A", priority: 1);
|
||||
SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, name: "Rule B", priority: 2);
|
||||
|
||||
var result = await _controller.GetSeedingRules(client.Id);
|
||||
|
||||
List<SeedingRuleResponse> rules = GetRulesFromOk(result);
|
||||
rules.Count.ShouldBe(3);
|
||||
rules[0].Name.ShouldBe("Rule A");
|
||||
rules[1].Name.ShouldBe("Rule B");
|
||||
rules[2].Name.ShouldBe("Rule C");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSeedingRules_NonExistentClient_ReturnsNotFound()
|
||||
{
|
||||
var result = await _controller.GetSeedingRules(Guid.NewGuid());
|
||||
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(StatusCodes.Status404NotFound);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSeedingRules_QBitClient_ReturnsTagFields()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id,
|
||||
tagsAny: ["hd", "private"], tagsAll: ["required"]);
|
||||
|
||||
var result = await _controller.GetSeedingRules(client.Id);
|
||||
|
||||
SeedingRuleResponse rule = GetRulesFromOk(result).Single();
|
||||
rule.TagsAny.ShouldBe(new List<string> { "hd", "private" });
|
||||
rule.TagsAll.ShouldBe(new List<string> { "required" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSeedingRules_ReturnsMinSeeders()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, minSeeders: 5);
|
||||
|
||||
var result = await _controller.GetSeedingRules(client.Id);
|
||||
|
||||
SeedingRuleResponse rule = GetRulesFromOk(result).Single();
|
||||
rule.MinSeeders.ShouldBe(5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSeedingRules_DelugeClient_ReturnsEmptyTagFields()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext, DownloadClientTypeName.Deluge, "Test Deluge");
|
||||
SeedingRulesTestDataFactory.AddDelugeSeedingRule(_dataContext, client.Id);
|
||||
|
||||
var result = await _controller.GetSeedingRules(client.Id);
|
||||
|
||||
SeedingRuleResponse rule = GetRulesFromOk(result).Single();
|
||||
rule.TagsAny.ShouldBeEmpty();
|
||||
rule.TagsAll.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// CreateSeedingRule
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSeedingRule_ValidRequest_ReturnsCreated()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
var request = CreateValidRequest(name: "Movies Rule", categories: ["movies", "films"]);
|
||||
|
||||
var result = await _controller.CreateSeedingRule(client.Id, request);
|
||||
|
||||
var createdResult = result.ShouldBeOfType<CreatedAtActionResult>();
|
||||
createdResult.StatusCode.ShouldBe(201);
|
||||
|
||||
QBitSeedingRule rule = GetCreatedRule<QBitSeedingRule>(result);
|
||||
rule.Name.ShouldBe("Movies Rule");
|
||||
rule.Categories.ShouldBe(new List<string> { "movies", "films" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSeedingRule_AutoAssignsPriority_WhenNotProvided()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
var request = CreateValidRequest();
|
||||
|
||||
var result = await _controller.CreateSeedingRule(client.Id, request);
|
||||
|
||||
GetCreatedRule<QBitSeedingRule>(result).Priority.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSeedingRule_SetsMinSeeders()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
var request = CreateValidRequest(minSeeders: 5);
|
||||
|
||||
var result = await _controller.CreateSeedingRule(client.Id, request);
|
||||
|
||||
GetCreatedRule<QBitSeedingRule>(result).MinSeeders.ShouldBe(5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSeedingRule_SetsMaxInactiveDays()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
var request = CreateValidRequest(maxInactiveDays: 30);
|
||||
|
||||
var result = await _controller.CreateSeedingRule(client.Id, request);
|
||||
|
||||
GetCreatedRule<QBitSeedingRule>(result).MaxInactiveDays.ShouldBe(30);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSeedingRule_AutoAssignsSequentialPriority()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, priority: 1);
|
||||
|
||||
var request = CreateValidRequest(name: "Second Rule", categories: ["tv"]);
|
||||
|
||||
var result = await _controller.CreateSeedingRule(client.Id, request);
|
||||
|
||||
GetCreatedRule<QBitSeedingRule>(result).Priority.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSeedingRule_DuplicatePriority_ReturnsBadRequest()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, priority: 1);
|
||||
|
||||
var request = CreateValidRequest(priority: 1);
|
||||
|
||||
var result = await _controller.CreateSeedingRule(client.Id, request);
|
||||
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(StatusCodes.Status400BadRequest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSeedingRule_NonExistentClient_ReturnsNotFound()
|
||||
{
|
||||
var request = CreateValidRequest();
|
||||
|
||||
var result = await _controller.CreateSeedingRule(Guid.NewGuid(), request);
|
||||
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(StatusCodes.Status404NotFound);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSeedingRule_EmptyCategories_ReturnsBadRequest()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
var request = CreateValidRequest(categories: []);
|
||||
|
||||
await Should.ThrowAsync<ValidationException>(() => _controller.CreateSeedingRule(client.Id, request));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSeedingRule_SanitizesWhitespaceInLists()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
var request = CreateValidRequest(
|
||||
trackerPatterns: ["", " ", "valid.com", " trimmed.com "]);
|
||||
|
||||
var result = await _controller.CreateSeedingRule(client.Id, request);
|
||||
|
||||
QBitSeedingRule rule = GetCreatedRule<QBitSeedingRule>(result);
|
||||
rule.TrackerPatterns.ShouldBe(new List<string> { "valid.com", "trimmed.com" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSeedingRule_ForTransmission_CreatesTransmissionRule()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext,
|
||||
DownloadClientTypeName.Transmission, "Test Transmission");
|
||||
var request = CreateValidRequest(tagsAny: ["tag1"]);
|
||||
|
||||
var result = await _controller.CreateSeedingRule(client.Id, request);
|
||||
|
||||
GetCreatedRule<TransmissionSeedingRule>(result).TagsAny.ShouldBe(new List<string> { "tag1" });
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// UpdateSeedingRule
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSeedingRule_ValidRequest_ReturnsOk()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
var rule = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id);
|
||||
|
||||
var request = CreateValidRequest(name: "Updated Name", categories: ["tv", "anime"]);
|
||||
|
||||
var result = await _controller.UpdateSeedingRule(rule.Id, request);
|
||||
|
||||
var okResult = result.ShouldBeOfType<OkObjectResult>();
|
||||
var updated = okResult.Value.ShouldBeOfType<QBitSeedingRule>();
|
||||
updated.Name.ShouldBe("Updated Name");
|
||||
updated.Categories.ShouldBe(new List<string> { "tv", "anime" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSeedingRule_DoesNotChangePriority()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
var rule = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, priority: 5);
|
||||
|
||||
var request = CreateValidRequest(priority: 1);
|
||||
|
||||
var result = await _controller.UpdateSeedingRule(rule.Id, request);
|
||||
|
||||
var okResult = result.ShouldBeOfType<OkObjectResult>();
|
||||
var updated = okResult.Value.ShouldBeOfType<QBitSeedingRule>();
|
||||
updated.Priority.ShouldBe(5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSeedingRule_UpdatesTagsForTagFilterableClient()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
var rule = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id);
|
||||
|
||||
var request = CreateValidRequest(tagsAny: ["new-tag"], tagsAll: ["must-have"]);
|
||||
|
||||
var result = await _controller.UpdateSeedingRule(rule.Id, request);
|
||||
|
||||
var okResult = result.ShouldBeOfType<OkObjectResult>();
|
||||
var updated = okResult.Value.ShouldBeOfType<QBitSeedingRule>();
|
||||
updated.TagsAny.ShouldBe(new List<string> { "new-tag" });
|
||||
updated.TagsAll.ShouldBe(new List<string> { "must-have" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSeedingRule_UpdatesMinSeeders()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
var rule = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id);
|
||||
|
||||
var request = CreateValidRequest(minSeeders: 5);
|
||||
|
||||
var result = await _controller.UpdateSeedingRule(rule.Id, request);
|
||||
|
||||
var okResult = result.ShouldBeOfType<OkObjectResult>();
|
||||
var updated = okResult.Value.ShouldBeOfType<QBitSeedingRule>();
|
||||
updated.MinSeeders.ShouldBe(5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSeedingRule_UpdatesMaxInactiveDays()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
var rule = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id);
|
||||
|
||||
var request = CreateValidRequest(maxInactiveDays: 30);
|
||||
|
||||
var result = await _controller.UpdateSeedingRule(rule.Id, request);
|
||||
|
||||
var okResult = result.ShouldBeOfType<OkObjectResult>();
|
||||
var updated = okResult.Value.ShouldBeOfType<QBitSeedingRule>();
|
||||
updated.MaxInactiveDays.ShouldBe(30);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSeedingRule_NonExistentRule_ReturnsNotFound()
|
||||
{
|
||||
var request = CreateValidRequest();
|
||||
|
||||
var result = await _controller.UpdateSeedingRule(Guid.NewGuid(), request);
|
||||
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(StatusCodes.Status404NotFound);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSeedingRule_ValidationFailure_ReturnsBadRequest()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
var rule = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id);
|
||||
|
||||
// Both maxRatio and maxSeedTime negative → validation failure
|
||||
var request = CreateValidRequest(maxRatio: -1, maxSeedTime: -1);
|
||||
|
||||
await Should.ThrowAsync<ValidationException>(() => _controller.UpdateSeedingRule(rule.Id, request));
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// ReorderSeedingRules
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task ReorderSeedingRules_ValidRequest_ReturnsNoContent()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
var rule1 = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, name: "A", priority: 1);
|
||||
var rule2 = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, name: "B", priority: 2);
|
||||
|
||||
var request = new ReorderSeedingRulesRequest { OrderedIds = [rule2.Id, rule1.Id] };
|
||||
|
||||
var result = await _controller.ReorderSeedingRules(client.Id, request);
|
||||
result.ShouldBeOfType<NoContentResult>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReorderSeedingRules_AssignsSequentialPriorities()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
var rule1 = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, name: "A", priority: 1);
|
||||
var rule2 = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, name: "B", priority: 2);
|
||||
var rule3 = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, name: "C", priority: 3);
|
||||
|
||||
// Reverse order
|
||||
var request = new ReorderSeedingRulesRequest { OrderedIds = [rule3.Id, rule2.Id, rule1.Id] };
|
||||
await _controller.ReorderSeedingRules(client.Id, request);
|
||||
|
||||
List<SeedingRuleResponse> rules = GetRulesFromOk(await _controller.GetSeedingRules(client.Id));
|
||||
|
||||
rules[0].Name.ShouldBe("C");
|
||||
rules[0].Priority.ShouldBe(1);
|
||||
rules[1].Name.ShouldBe("B");
|
||||
rules[1].Priority.ShouldBe(2);
|
||||
rules[2].Name.ShouldBe("A");
|
||||
rules[2].Priority.ShouldBe(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReorderSeedingRules_NonExistentClient_ReturnsNotFound()
|
||||
{
|
||||
var request = new ReorderSeedingRulesRequest { OrderedIds = [Guid.NewGuid()] };
|
||||
|
||||
var result = await _controller.ReorderSeedingRules(Guid.NewGuid(), request);
|
||||
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(StatusCodes.Status404NotFound);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReorderSeedingRules_DuplicateIds_ReturnsBadRequest()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
var rule1 = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, name: "A", priority: 1);
|
||||
var rule2 = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, name: "B", priority: 2);
|
||||
|
||||
var request = new ReorderSeedingRulesRequest { OrderedIds = [rule1.Id, rule1.Id] };
|
||||
|
||||
var result = await _controller.ReorderSeedingRules(client.Id, request);
|
||||
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(StatusCodes.Status400BadRequest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReorderSeedingRules_WrongCount_ReturnsBadRequest()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
var rule1 = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, name: "A", priority: 1);
|
||||
SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, name: "B", priority: 2);
|
||||
|
||||
// Only send 1 of 2 IDs
|
||||
var request = new ReorderSeedingRulesRequest { OrderedIds = [rule1.Id] };
|
||||
|
||||
var result = await _controller.ReorderSeedingRules(client.Id, request);
|
||||
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(StatusCodes.Status400BadRequest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReorderSeedingRules_UnknownRuleId_ReturnsBadRequest()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
var rule1 = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, name: "A", priority: 1);
|
||||
SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, name: "B", priority: 2);
|
||||
|
||||
var request = new ReorderSeedingRulesRequest { OrderedIds = [rule1.Id, Guid.NewGuid()] };
|
||||
|
||||
var result = await _controller.ReorderSeedingRules(client.Id, request);
|
||||
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(StatusCodes.Status400BadRequest);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// DeleteSeedingRule
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteSeedingRule_ExistingRule_ReturnsNoContent()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
var rule = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id);
|
||||
|
||||
var result = await _controller.DeleteSeedingRule(rule.Id);
|
||||
result.ShouldBeOfType<NoContentResult>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteSeedingRule_VerifiesRuleRemoved()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
var rule = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id);
|
||||
|
||||
await _controller.DeleteSeedingRule(rule.Id);
|
||||
|
||||
GetRulesFromOk(await _controller.GetSeedingRules(client.Id)).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteSeedingRule_NonExistentRule_ReturnsNotFound()
|
||||
{
|
||||
var result = await _controller.DeleteSeedingRule(Guid.NewGuid());
|
||||
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(StatusCodes.Status404NotFound);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
|
||||
using Cleanuparr.Persistence.Models.Configuration.General;
|
||||
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
|
||||
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Seeker;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.DownloadCleaner.TestHelpers;
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating SQLite in-memory contexts for SeedingRulesController tests
|
||||
/// </summary>
|
||||
public static class SeedingRulesTestDataFactory
|
||||
{
|
||||
public static DataContext CreateDataContext()
|
||||
{
|
||||
var connection = new SqliteConnection("DataSource=:memory:");
|
||||
connection.Open();
|
||||
|
||||
var options = new DbContextOptionsBuilder<DataContext>()
|
||||
.UseSqlite(connection)
|
||||
.Options;
|
||||
|
||||
var context = new DataContext(options);
|
||||
context.Database.EnsureCreated();
|
||||
|
||||
SeedDefaultData(context);
|
||||
return context;
|
||||
}
|
||||
|
||||
private static void SeedDefaultData(DataContext context)
|
||||
{
|
||||
context.GeneralConfigs.Add(new GeneralConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
DryRun = false,
|
||||
IgnoredDownloads = [],
|
||||
Log = new LoggingConfig()
|
||||
});
|
||||
|
||||
context.ArrConfigs.AddRange(
|
||||
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Sonarr, Instances = [], FailedImportMaxStrikes = 3 },
|
||||
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Radarr, Instances = [], FailedImportMaxStrikes = 3 },
|
||||
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Lidarr, Instances = [], FailedImportMaxStrikes = 3 },
|
||||
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Readarr, Instances = [], FailedImportMaxStrikes = 3 },
|
||||
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Whisparr, Instances = [], FailedImportMaxStrikes = 3 }
|
||||
);
|
||||
|
||||
context.QueueCleanerConfigs.Add(new QueueCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
IgnoredDownloads = [],
|
||||
FailedImport = new FailedImportConfig()
|
||||
});
|
||||
|
||||
context.ContentBlockerConfigs.Add(new ContentBlockerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
IgnoredDownloads = [],
|
||||
DeletePrivate = false,
|
||||
Sonarr = new BlocklistSettings { Enabled = false },
|
||||
Radarr = new BlocklistSettings { Enabled = false },
|
||||
Lidarr = new BlocklistSettings { Enabled = false },
|
||||
Readarr = new BlocklistSettings { Enabled = false },
|
||||
Whisparr = new BlocklistSettings { Enabled = false }
|
||||
});
|
||||
|
||||
context.DownloadCleanerConfigs.Add(new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
IgnoredDownloads = []
|
||||
});
|
||||
|
||||
context.SeekerConfigs.Add(new SeekerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
SearchEnabled = true,
|
||||
ProactiveSearchEnabled = false
|
||||
});
|
||||
|
||||
context.SaveChanges();
|
||||
}
|
||||
|
||||
public static DownloadClientConfig AddDownloadClient(
|
||||
DataContext context,
|
||||
DownloadClientTypeName typeName = DownloadClientTypeName.qBittorrent,
|
||||
string name = "Test qBittorrent")
|
||||
{
|
||||
var config = new DownloadClientConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = name,
|
||||
TypeName = typeName,
|
||||
Type = DownloadClientType.Torrent,
|
||||
Enabled = true,
|
||||
Host = new Uri("http://localhost:8080"),
|
||||
Username = "admin",
|
||||
Password = "admin"
|
||||
};
|
||||
|
||||
context.DownloadClients.Add(config);
|
||||
context.SaveChanges();
|
||||
return config;
|
||||
}
|
||||
|
||||
public static QBitSeedingRule AddQBitSeedingRule(
|
||||
DataContext context,
|
||||
Guid downloadClientId,
|
||||
string name = "Test Rule",
|
||||
int priority = 1,
|
||||
List<string>? categories = null,
|
||||
List<string>? trackerPatterns = null,
|
||||
List<string>? tagsAny = null,
|
||||
List<string>? tagsAll = null,
|
||||
double maxRatio = 2.0,
|
||||
double minSeedTime = 0,
|
||||
double maxSeedTime = -1,
|
||||
int minSeeders = 0)
|
||||
{
|
||||
var rule = new QBitSeedingRule
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
DownloadClientConfigId = downloadClientId,
|
||||
Name = name,
|
||||
Priority = priority,
|
||||
Categories = categories ?? ["movies"],
|
||||
TrackerPatterns = trackerPatterns ?? [],
|
||||
TagsAny = tagsAny ?? [],
|
||||
TagsAll = tagsAll ?? [],
|
||||
PrivacyType = TorrentPrivacyType.Both,
|
||||
MaxRatio = maxRatio,
|
||||
MinSeedTime = minSeedTime,
|
||||
MaxSeedTime = maxSeedTime,
|
||||
MinSeeders = minSeeders,
|
||||
DeleteSourceFiles = true,
|
||||
};
|
||||
|
||||
context.QBitSeedingRules.Add(rule);
|
||||
context.SaveChanges();
|
||||
return rule;
|
||||
}
|
||||
|
||||
public static DelugeSeedingRule AddDelugeSeedingRule(
|
||||
DataContext context,
|
||||
Guid downloadClientId,
|
||||
string name = "Test Rule",
|
||||
int priority = 1,
|
||||
List<string>? categories = null,
|
||||
double maxRatio = 2.0,
|
||||
double maxSeedTime = -1,
|
||||
int minSeeders = 0)
|
||||
{
|
||||
var rule = new DelugeSeedingRule
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
DownloadClientConfigId = downloadClientId,
|
||||
Name = name,
|
||||
Priority = priority,
|
||||
Categories = categories ?? ["movies"],
|
||||
TrackerPatterns = [],
|
||||
PrivacyType = TorrentPrivacyType.Both,
|
||||
MaxRatio = maxRatio,
|
||||
MinSeedTime = 0,
|
||||
MaxSeedTime = maxSeedTime,
|
||||
MinSeeders = minSeeders,
|
||||
DeleteSourceFiles = true,
|
||||
};
|
||||
|
||||
context.DelugeSeedingRules.Add(rule);
|
||||
context.SaveChanges();
|
||||
return rule;
|
||||
}
|
||||
|
||||
public static TransmissionSeedingRule AddTransmissionSeedingRule(
|
||||
DataContext context,
|
||||
Guid downloadClientId,
|
||||
string name = "Test Rule",
|
||||
int priority = 1,
|
||||
List<string>? categories = null,
|
||||
double maxRatio = 2.0,
|
||||
double maxSeedTime = -1,
|
||||
int minSeeders = 0)
|
||||
{
|
||||
var rule = new TransmissionSeedingRule
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
DownloadClientConfigId = downloadClientId,
|
||||
Name = name,
|
||||
Priority = priority,
|
||||
Categories = categories ?? ["movies"],
|
||||
TrackerPatterns = [],
|
||||
TagsAny = [],
|
||||
TagsAll = [],
|
||||
PrivacyType = TorrentPrivacyType.Both,
|
||||
MaxRatio = maxRatio,
|
||||
MinSeedTime = 0,
|
||||
MaxSeedTime = maxSeedTime,
|
||||
MinSeeders = minSeeders,
|
||||
DeleteSourceFiles = true,
|
||||
};
|
||||
|
||||
context.TransmissionSeedingRules.Add(rule);
|
||||
context.SaveChanges();
|
||||
return rule;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests;
|
||||
using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Responses;
|
||||
using Cleanuparr.Api.Features.DownloadCleaner.Controllers;
|
||||
using Cleanuparr.Domain.Exceptions;
|
||||
using Cleanuparr.Api.Tests.TestHelpers;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.DownloadCleaner;
|
||||
|
||||
public class UnlinkedConfigControllerTests : IDisposable
|
||||
{
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly UnlinkedConfigController _controller;
|
||||
|
||||
public UnlinkedConfigControllerTests()
|
||||
{
|
||||
_dataContext = ConfigControllerTestDataFactory.CreateDataContext();
|
||||
var logger = Substitute.For<ILogger<UnlinkedConfigController>>();
|
||||
_controller = new UnlinkedConfigController(logger, _dataContext);
|
||||
ConfigControllerTestDataFactory.ConfigureProblemDetails(_controller);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_dataContext.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetUnlinkedConfig_ClientNotFound_ReturnsNotFound()
|
||||
{
|
||||
// Act
|
||||
var result = await _controller.GetUnlinkedConfig(Guid.NewGuid());
|
||||
|
||||
// Assert
|
||||
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(404);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetUnlinkedConfig_ClientWithoutConfig_ReturnsOkWithNull()
|
||||
{
|
||||
// Arrange — add a client but no UnlinkedConfig
|
||||
var client = AddDownloadClient();
|
||||
|
||||
// Act
|
||||
var result = await _controller.GetUnlinkedConfig(client.Id);
|
||||
|
||||
// Assert
|
||||
var ok = result.ShouldBeOfType<OkObjectResult>();
|
||||
ok.Value.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetUnlinkedConfig_ClientWithConfig_ReturnsConfig()
|
||||
{
|
||||
// Arrange
|
||||
var client = AddDownloadClient();
|
||||
_dataContext.UnlinkedConfigs.Add(new UnlinkedConfig
|
||||
{
|
||||
DownloadClientConfigId = client.Id,
|
||||
Enabled = true,
|
||||
TargetCategory = "unlinked-cat",
|
||||
Categories = new List<string> { "regular" },
|
||||
});
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
// Act
|
||||
var result = await _controller.GetUnlinkedConfig(client.Id);
|
||||
|
||||
// Assert
|
||||
var ok = result.ShouldBeOfType<OkObjectResult>();
|
||||
var config = ok.Value.ShouldBeOfType<UnlinkedConfigResponse>();
|
||||
config.Enabled.ShouldBeTrue();
|
||||
config.TargetCategory.ShouldBe("unlinked-cat");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateUnlinkedConfig_ClientNotFound_ReturnsNotFound()
|
||||
{
|
||||
// Arrange
|
||||
var dto = new UnlinkedConfigRequest { Enabled = false };
|
||||
|
||||
// Act
|
||||
var result = await _controller.UpdateUnlinkedConfig(Guid.NewGuid(), dto);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(404);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateUnlinkedConfig_NewConfig_CreatesAndReturnsIt()
|
||||
{
|
||||
// Arrange
|
||||
var client = AddDownloadClient();
|
||||
var dto = new UnlinkedConfigRequest
|
||||
{
|
||||
Enabled = false,
|
||||
TargetCategory = "unlinked-cat",
|
||||
Categories = new List<string> { "movies" },
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _controller.UpdateUnlinkedConfig(client.Id, dto);
|
||||
|
||||
// Assert
|
||||
var ok = result.ShouldBeOfType<OkObjectResult>();
|
||||
var saved = await _dataContext.UnlinkedConfigs.AsNoTracking()
|
||||
.FirstAsync(u => u.DownloadClientConfigId == client.Id);
|
||||
saved.TargetCategory.ShouldBe("unlinked-cat");
|
||||
saved.Categories.ShouldContain("movies");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateUnlinkedConfig_ExistingConfig_UpdatesInPlace()
|
||||
{
|
||||
// Arrange
|
||||
var client = AddDownloadClient();
|
||||
_dataContext.UnlinkedConfigs.Add(new UnlinkedConfig
|
||||
{
|
||||
DownloadClientConfigId = client.Id,
|
||||
Enabled = false,
|
||||
TargetCategory = "old-cat",
|
||||
});
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
var dto = new UnlinkedConfigRequest
|
||||
{
|
||||
Enabled = false,
|
||||
TargetCategory = "new-cat",
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _controller.UpdateUnlinkedConfig(client.Id, dto);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeOfType<OkObjectResult>();
|
||||
var rows = await _dataContext.UnlinkedConfigs
|
||||
.Where(u => u.DownloadClientConfigId == client.Id)
|
||||
.ToListAsync();
|
||||
rows.Count.ShouldBe(1);
|
||||
rows[0].TargetCategory.ShouldBe("new-cat");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateUnlinkedConfig_EnabledButNoCategories_ReturnsBadRequest()
|
||||
{
|
||||
// Arrange — enabled requires at least one category per Validate()
|
||||
var client = AddDownloadClient();
|
||||
var dto = new UnlinkedConfigRequest
|
||||
{
|
||||
Enabled = true,
|
||||
TargetCategory = "unlinked-cat",
|
||||
Categories = new List<string>(),
|
||||
};
|
||||
|
||||
// Act + Assert
|
||||
await Should.ThrowAsync<ValidationException>(
|
||||
() => _controller.UpdateUnlinkedConfig(client.Id, dto));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateUnlinkedConfig_TargetInCategories_ReturnsBadRequest()
|
||||
{
|
||||
// Arrange — TargetCategory must not appear in Categories
|
||||
var client = AddDownloadClient();
|
||||
var dto = new UnlinkedConfigRequest
|
||||
{
|
||||
Enabled = true,
|
||||
TargetCategory = "unlinked-cat",
|
||||
Categories = new List<string> { "movies", "unlinked-cat" },
|
||||
};
|
||||
|
||||
// Act + Assert
|
||||
await Should.ThrowAsync<ValidationException>(
|
||||
() => _controller.UpdateUnlinkedConfig(client.Id, dto));
|
||||
}
|
||||
|
||||
private DownloadClientConfig AddDownloadClient()
|
||||
{
|
||||
var client = new DownloadClientConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "test-client",
|
||||
TypeName = DownloadClientTypeName.qBittorrent,
|
||||
Type = DownloadClientType.Torrent,
|
||||
};
|
||||
_dataContext.DownloadClients.Add(client);
|
||||
_dataContext.SaveChanges();
|
||||
return client;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
using Cleanuparr.Api.Features.DownloadClient.Contracts.Requests;
|
||||
using Cleanuparr.Api.Features.DownloadClient.Controllers;
|
||||
using Cleanuparr.Api.Tests.TestHelpers;
|
||||
using Cleanuparr.Domain.Entities.HealthCheck;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient;
|
||||
using Cleanuparr.Infrastructure.Http.DynamicHttpClientSystem;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.DownloadClient;
|
||||
|
||||
public class DownloadClientControllerTests : IDisposable
|
||||
{
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly IDynamicHttpClientFactory _dynamicHttpClientFactory;
|
||||
private readonly IDownloadServiceFactory _downloadServiceFactory;
|
||||
private readonly DownloadClientController _controller;
|
||||
|
||||
public DownloadClientControllerTests()
|
||||
{
|
||||
_dataContext = ConfigControllerTestDataFactory.CreateDataContext();
|
||||
var logger = Substitute.For<ILogger<DownloadClientController>>();
|
||||
_dynamicHttpClientFactory = Substitute.For<IDynamicHttpClientFactory>();
|
||||
_downloadServiceFactory = Substitute.For<IDownloadServiceFactory>();
|
||||
_controller = new DownloadClientController(logger, _dataContext, _dynamicHttpClientFactory, _downloadServiceFactory);
|
||||
ConfigControllerTestDataFactory.ConfigureProblemDetails(_controller);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_dataContext.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetDownloadClientConfig_EmptyDatabase_ReturnsOkWithEmptyClients()
|
||||
{
|
||||
// Act
|
||||
var result = await _controller.GetDownloadClientConfig();
|
||||
|
||||
// Assert
|
||||
var ok = result.ShouldBeOfType<OkObjectResult>();
|
||||
ok.Value.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetDownloadClientConfig_OrdersByTypeThenName()
|
||||
{
|
||||
// Arrange — add 3 clients out of order
|
||||
_dataContext.DownloadClients.AddRange(
|
||||
NewClient("z-client", DownloadClientTypeName.qBittorrent),
|
||||
NewClient("a-client", DownloadClientTypeName.qBittorrent),
|
||||
NewClient("b-client", DownloadClientTypeName.Deluge)
|
||||
);
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
// Act
|
||||
var result = await _controller.GetDownloadClientConfig();
|
||||
|
||||
// Assert
|
||||
var ok = result.ShouldBeOfType<OkObjectResult>();
|
||||
var dict = ok.Value!.GetType().GetProperty("clients")!.GetValue(ok.Value) as List<DownloadClientConfig>;
|
||||
dict.ShouldNotBeNull();
|
||||
dict!.Count.ShouldBe(3);
|
||||
// qBittorrent (0) comes before Deluge (1) by enum value, then alphabetical within type
|
||||
dict![0].Name.ShouldBe("a-client");
|
||||
dict![1].Name.ShouldBe("z-client");
|
||||
dict![2].Name.ShouldBe("b-client");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateDownloadClientConfig_ValidRequest_ReturnsCreated()
|
||||
{
|
||||
// Arrange
|
||||
var request = new CreateDownloadClientRequest
|
||||
{
|
||||
Enabled = true,
|
||||
Name = "my-client",
|
||||
TypeName = DownloadClientTypeName.qBittorrent,
|
||||
Type = DownloadClientType.Torrent,
|
||||
Host = "http://localhost:8080",
|
||||
Username = "user",
|
||||
Password = "pass",
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _controller.CreateDownloadClientConfig(request);
|
||||
|
||||
// Assert
|
||||
var created = result.ShouldBeOfType<CreatedAtActionResult>();
|
||||
var entity = created.Value.ShouldBeOfType<DownloadClientConfig>();
|
||||
entity.Name.ShouldBe("my-client");
|
||||
(await _dataContext.DownloadClients.CountAsync()).ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateDownloadClientConfig_InvalidHost_PropagatesValidationException()
|
||||
{
|
||||
// Arrange
|
||||
var request = new CreateDownloadClientRequest
|
||||
{
|
||||
Name = "x",
|
||||
Host = string.Empty,
|
||||
TypeName = DownloadClientTypeName.qBittorrent,
|
||||
Type = DownloadClientType.Torrent,
|
||||
};
|
||||
|
||||
// Act / Assert — Validate throws, controller's generic catch logs and re-throws
|
||||
await Should.ThrowAsync<Cleanuparr.Domain.Exceptions.ValidationException>(
|
||||
() => _controller.CreateDownloadClientConfig(request));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateDownloadClientConfig_UnknownId_ReturnsNotFound()
|
||||
{
|
||||
// Arrange
|
||||
var request = new UpdateDownloadClientRequest
|
||||
{
|
||||
Name = "x",
|
||||
Host = "http://localhost:8080",
|
||||
TypeName = DownloadClientTypeName.qBittorrent,
|
||||
Type = DownloadClientType.Torrent,
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _controller.UpdateDownloadClientConfig(Guid.NewGuid(), request);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(404);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateDownloadClientConfig_ExistingClient_PersistsChanges()
|
||||
{
|
||||
// Arrange
|
||||
var client = NewClient("orig", DownloadClientTypeName.qBittorrent);
|
||||
_dataContext.DownloadClients.Add(client);
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
var request = new UpdateDownloadClientRequest
|
||||
{
|
||||
Enabled = true,
|
||||
Name = "renamed",
|
||||
Host = "http://newhost:9090",
|
||||
TypeName = DownloadClientTypeName.qBittorrent,
|
||||
Type = DownloadClientType.Torrent,
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _controller.UpdateDownloadClientConfig(client.Id, request);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeOfType<OkObjectResult>();
|
||||
var saved = await _dataContext.DownloadClients.AsNoTracking().FirstAsync(c => c.Id == client.Id);
|
||||
saved.Name.ShouldBe("renamed");
|
||||
saved.Host!.ToString().ShouldContain("newhost");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteDownloadClientConfig_ExistingClient_RemovesAndUnregistersHttpClient()
|
||||
{
|
||||
// Arrange
|
||||
var client = NewClient("doomed", DownloadClientTypeName.qBittorrent);
|
||||
_dataContext.DownloadClients.Add(client);
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
// Act
|
||||
var result = await _controller.DeleteDownloadClientConfig(client.Id);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeOfType<NoContentResult>();
|
||||
(await _dataContext.DownloadClients.CountAsync()).ShouldBe(0);
|
||||
_dynamicHttpClientFactory.Received(1).UnregisterConfiguration($"DownloadClient_{client.Id}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteDownloadClientConfig_UnknownId_ReturnsNotFound()
|
||||
{
|
||||
// Act
|
||||
var result = await _controller.DeleteDownloadClientConfig(Guid.NewGuid());
|
||||
|
||||
// Assert
|
||||
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(404);
|
||||
_dynamicHttpClientFactory.DidNotReceive().UnregisterConfiguration(Arg.Any<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TestDownloadClient_Healthy_ReturnsOkWithResponseTime()
|
||||
{
|
||||
// Arrange
|
||||
var downloadService = Substitute.For<IDownloadService>();
|
||||
downloadService.HealthCheckAsync().Returns(new HealthCheckResult
|
||||
{
|
||||
IsHealthy = true,
|
||||
ResponseTime = TimeSpan.FromMilliseconds(123),
|
||||
});
|
||||
_downloadServiceFactory.GetDownloadService(Arg.Any<DownloadClientConfig>()).Returns(downloadService);
|
||||
|
||||
var request = new TestDownloadClientRequest
|
||||
{
|
||||
TypeName = DownloadClientTypeName.qBittorrent,
|
||||
Type = DownloadClientType.Torrent,
|
||||
Host = "http://localhost:8080",
|
||||
Password = "pass",
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _controller.TestDownloadClient(request);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeOfType<OkObjectResult>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TestDownloadClient_Unhealthy_ReturnsBadRequestWithMessage()
|
||||
{
|
||||
// Arrange
|
||||
var downloadService = Substitute.For<IDownloadService>();
|
||||
downloadService.HealthCheckAsync().Returns(new HealthCheckResult
|
||||
{
|
||||
IsHealthy = false,
|
||||
ErrorMessage = "connection refused",
|
||||
});
|
||||
_downloadServiceFactory.GetDownloadService(Arg.Any<DownloadClientConfig>()).Returns(downloadService);
|
||||
|
||||
var request = new TestDownloadClientRequest
|
||||
{
|
||||
TypeName = DownloadClientTypeName.qBittorrent,
|
||||
Type = DownloadClientType.Torrent,
|
||||
Host = "http://localhost:8080",
|
||||
Password = "pass",
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _controller.TestDownloadClient(request);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(400);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TestDownloadClient_InvalidHost_ReturnsBadRequest()
|
||||
{
|
||||
// Arrange — empty host fails Validate; the controller wraps the exception in BadRequest
|
||||
var request = new TestDownloadClientRequest
|
||||
{
|
||||
TypeName = DownloadClientTypeName.qBittorrent,
|
||||
Type = DownloadClientType.Torrent,
|
||||
Host = string.Empty,
|
||||
Password = "pass",
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _controller.TestDownloadClient(request);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(400);
|
||||
}
|
||||
|
||||
private static DownloadClientConfig NewClient(string name, DownloadClientTypeName typeName) => new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = name,
|
||||
TypeName = typeName,
|
||||
Type = DownloadClientType.Torrent,
|
||||
Host = new Uri("http://localhost:8080"),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
using Cleanuparr.Api.Features.General.Contracts.Requests;
|
||||
using Cleanuparr.Api.Features.General.Controllers;
|
||||
using Cleanuparr.Api.Tests.TestHelpers;
|
||||
using Cleanuparr.Infrastructure.Http.DynamicHttpClientSystem;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration.General;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.General;
|
||||
|
||||
public class GeneralConfigControllerTests : IDisposable
|
||||
{
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly EventsContext _eventsContext;
|
||||
private readonly IDynamicHttpClientFactory _dynamicHttpClientFactory;
|
||||
private readonly GeneralConfigController _controller;
|
||||
|
||||
public GeneralConfigControllerTests()
|
||||
{
|
||||
_dataContext = ConfigControllerTestDataFactory.CreateDataContext();
|
||||
_eventsContext = ConfigControllerTestDataFactory.CreateEventsContext();
|
||||
_dynamicHttpClientFactory = Substitute.For<IDynamicHttpClientFactory>();
|
||||
|
||||
var logger = Substitute.For<ILogger<GeneralConfigController>>();
|
||||
_controller = new GeneralConfigController(logger, _dataContext);
|
||||
|
||||
// Mount a DefaultHttpContext with a ServiceProvider that resolves IDynamicHttpClientFactory
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton(_dynamicHttpClientFactory);
|
||||
_controller.ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = new DefaultHttpContext { RequestServices = services.BuildServiceProvider() },
|
||||
};
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_dataContext.Dispose();
|
||||
_eventsContext.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetGeneralConfig_ReturnsExistingConfig()
|
||||
{
|
||||
// Act
|
||||
var result = await _controller.GetGeneralConfig();
|
||||
|
||||
// Assert
|
||||
var ok = result.ShouldBeOfType<OkObjectResult>();
|
||||
ok.Value.ShouldBeOfType<GeneralConfig>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateGeneralConfig_PersistsChangesAndUpdatesHttpClients()
|
||||
{
|
||||
// Arrange — keep Log defaults matching DB so loggingChanged=false (avoid LoggingConfigManager statics)
|
||||
var existing = await _dataContext.GeneralConfigs.AsNoTracking().FirstAsync();
|
||||
var request = new UpdateGeneralConfigRequest
|
||||
{
|
||||
DisplaySupportBanner = false,
|
||||
DryRun = false,
|
||||
HttpMaxRetries = 5,
|
||||
HttpTimeout = 60,
|
||||
StatusCheckEnabled = false,
|
||||
EncryptionKey = existing.EncryptionKey,
|
||||
IgnoredDownloads = new List<string> { "ignored-item" },
|
||||
StrikeInactivityWindowHours = 48,
|
||||
Log = MatchingLogRequest(existing.Log),
|
||||
Auth = new UpdateAuthConfigRequest(),
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _controller.UpdateGeneralConfig(request, _eventsContext);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeOfType<OkObjectResult>();
|
||||
_dynamicHttpClientFactory.Received(1).UpdateAllClientsFromGeneralConfig(Arg.Any<GeneralConfig>());
|
||||
|
||||
var saved = await _dataContext.GeneralConfigs.AsNoTracking().FirstAsync();
|
||||
saved.DisplaySupportBanner.ShouldBeFalse();
|
||||
saved.HttpMaxRetries.ShouldBe((ushort)5);
|
||||
saved.HttpTimeout.ShouldBe((ushort)60);
|
||||
saved.StrikeInactivityWindowHours.ShouldBe((ushort)48);
|
||||
saved.IgnoredDownloads.ShouldContain("ignored-item");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateGeneralConfig_InvalidHttpTimeout_Throws()
|
||||
{
|
||||
// Arrange — HttpTimeout=0 fails validation
|
||||
var existing = await _dataContext.GeneralConfigs.AsNoTracking().FirstAsync();
|
||||
var request = new UpdateGeneralConfigRequest
|
||||
{
|
||||
HttpTimeout = 0,
|
||||
EncryptionKey = existing.EncryptionKey,
|
||||
StrikeInactivityWindowHours = 24,
|
||||
Log = MatchingLogRequest(existing.Log),
|
||||
Auth = new UpdateAuthConfigRequest(),
|
||||
};
|
||||
|
||||
// Act / Assert
|
||||
await Should.ThrowAsync<Exception>(() => _controller.UpdateGeneralConfig(request, _eventsContext));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateGeneralConfig_InvalidStrikeWindow_Throws()
|
||||
{
|
||||
// Arrange — StrikeInactivityWindowHours > 168 fails validation
|
||||
var existing = await _dataContext.GeneralConfigs.AsNoTracking().FirstAsync();
|
||||
var request = new UpdateGeneralConfigRequest
|
||||
{
|
||||
HttpTimeout = 60,
|
||||
EncryptionKey = existing.EncryptionKey,
|
||||
StrikeInactivityWindowHours = 200,
|
||||
Log = MatchingLogRequest(existing.Log),
|
||||
Auth = new UpdateAuthConfigRequest(),
|
||||
};
|
||||
|
||||
// Act / Assert
|
||||
await Should.ThrowAsync<Exception>(() => _controller.UpdateGeneralConfig(request, _eventsContext));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateGeneralConfig_ConnectivityCheckEnabledWithNoUrls_Throws()
|
||||
{
|
||||
// Arrange — connectivity check enabled but no URLs fails validation
|
||||
var existing = await _dataContext.GeneralConfigs.AsNoTracking().FirstAsync();
|
||||
var request = new UpdateGeneralConfigRequest
|
||||
{
|
||||
HttpTimeout = 60,
|
||||
EncryptionKey = existing.EncryptionKey,
|
||||
StrikeInactivityWindowHours = 24,
|
||||
ConnectivityCheckEnabled = true,
|
||||
ConnectivityCheckUrls = new List<string>(),
|
||||
Log = MatchingLogRequest(existing.Log),
|
||||
Auth = new UpdateAuthConfigRequest(),
|
||||
};
|
||||
|
||||
// Act / Assert
|
||||
await Should.ThrowAsync<Exception>(() => _controller.UpdateGeneralConfig(request, _eventsContext));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PurgeAllStrikes_ReturnsDeletedCounts()
|
||||
{
|
||||
// Act
|
||||
var result = await _controller.PurgeAllStrikes(_eventsContext);
|
||||
|
||||
// Assert — initially empty, but the endpoint still succeeds with zero counts
|
||||
var ok = result.ShouldBeOfType<OkObjectResult>();
|
||||
ok.Value.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
private static UpdateLoggingConfigRequest MatchingLogRequest(LoggingConfig existing) => new()
|
||||
{
|
||||
Level = existing.Level,
|
||||
RollingSizeMB = existing.RollingSizeMB,
|
||||
RetainedFileCount = existing.RetainedFileCount,
|
||||
TimeLimitHours = existing.TimeLimitHours,
|
||||
ArchiveEnabled = existing.ArchiveEnabled,
|
||||
ArchiveRetainedCount = existing.ArchiveRetainedCount,
|
||||
ArchiveTimeLimitHours = existing.ArchiveTimeLimitHours,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
using Cleanuparr.Api.Features.MalwareBlocker.Contracts.Requests;
|
||||
using Cleanuparr.Api.Features.MalwareBlocker.Controllers;
|
||||
using Cleanuparr.Api.Tests.TestHelpers;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.MalwareBlocker;
|
||||
|
||||
public class MalwareBlockerConfigControllerTests : IDisposable
|
||||
{
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly IJobManagementService _jobManagementService;
|
||||
private readonly MalwareBlockerConfigController _controller;
|
||||
|
||||
public MalwareBlockerConfigControllerTests()
|
||||
{
|
||||
_dataContext = ConfigControllerTestDataFactory.CreateDataContext();
|
||||
var logger = Substitute.For<ILogger<MalwareBlockerConfigController>>();
|
||||
_jobManagementService = Substitute.For<IJobManagementService>();
|
||||
_controller = new MalwareBlockerConfigController(logger, _dataContext, _jobManagementService);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_dataContext.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetMalwareBlockerConfig_ReturnsExistingConfig()
|
||||
{
|
||||
// Act
|
||||
var result = await _controller.GetMalwareBlockerConfig();
|
||||
|
||||
// Assert
|
||||
var ok = result.ShouldBeOfType<OkObjectResult>();
|
||||
ok.Value.ShouldBeOfType<ContentBlockerConfig>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateMalwareBlockerConfig_Enabled_StartsJobWithCron()
|
||||
{
|
||||
// Arrange — Enabled requires at least one blocklist with a valid path
|
||||
var request = new UpdateMalwareBlockerConfigRequest
|
||||
{
|
||||
Enabled = true,
|
||||
CronExpression = "0/5 * * * * ?",
|
||||
Sonarr = new BlocklistSettings { Enabled = true, BlocklistPath = "https://example.com/blocklist.txt" },
|
||||
Radarr = new BlocklistSettings { Enabled = false },
|
||||
Lidarr = new BlocklistSettings { Enabled = false },
|
||||
Readarr = new BlocklistSettings { Enabled = false },
|
||||
Whisparr = new BlocklistSettings { Enabled = false },
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _controller.UpdateMalwareBlockerConfig(request);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeOfType<OkObjectResult>();
|
||||
await _jobManagementService.Received(1).StartJob(JobType.MalwareBlocker, null, "0/5 * * * * ?");
|
||||
await _jobManagementService.DidNotReceive().StopJob(Arg.Any<JobType>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateMalwareBlockerConfig_Disabled_StopsJob()
|
||||
{
|
||||
// Arrange — pre-enable
|
||||
var existing = await _dataContext.ContentBlockerConfigs.FirstAsync();
|
||||
existing.Enabled = true;
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
var request = new UpdateMalwareBlockerConfigRequest
|
||||
{
|
||||
Enabled = false,
|
||||
CronExpression = "0/5 * * * * ?",
|
||||
Sonarr = new BlocklistSettings { Enabled = false },
|
||||
Radarr = new BlocklistSettings { Enabled = false },
|
||||
Lidarr = new BlocklistSettings { Enabled = false },
|
||||
Readarr = new BlocklistSettings { Enabled = false },
|
||||
Whisparr = new BlocklistSettings { Enabled = false },
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _controller.UpdateMalwareBlockerConfig(request);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeOfType<OkObjectResult>();
|
||||
await _jobManagementService.Received(1).StopJob(JobType.MalwareBlocker);
|
||||
await _jobManagementService.DidNotReceive().StartJob(Arg.Any<JobType>(), Arg.Any<Cleanuparr.Infrastructure.Models.JobSchedule?>(), Arg.Any<string?>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateMalwareBlockerConfig_InvalidCronExpression_PropagatesValidationException()
|
||||
{
|
||||
// Arrange — CronValidationHelper throws Cleanuparr.Domain.Exceptions.ValidationException,
|
||||
// which the controller's catch (System.ComponentModel.DataAnnotations.ValidationException) does NOT match.
|
||||
var request = new UpdateMalwareBlockerConfigRequest
|
||||
{
|
||||
Enabled = true,
|
||||
CronExpression = "definitely-not-a-cron",
|
||||
Sonarr = new BlocklistSettings { Enabled = false },
|
||||
Radarr = new BlocklistSettings { Enabled = false },
|
||||
Lidarr = new BlocklistSettings { Enabled = false },
|
||||
Readarr = new BlocklistSettings { Enabled = false },
|
||||
Whisparr = new BlocklistSettings { Enabled = false },
|
||||
};
|
||||
|
||||
// Act / Assert
|
||||
await Should.ThrowAsync<Cleanuparr.Domain.Exceptions.ValidationException>(
|
||||
() => _controller.UpdateMalwareBlockerConfig(request));
|
||||
await _jobManagementService.DidNotReceive().StartJob(Arg.Any<JobType>(), Arg.Any<Cleanuparr.Infrastructure.Models.JobSchedule?>(), Arg.Any<string?>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateMalwareBlockerConfig_PersistsChanges()
|
||||
{
|
||||
// Arrange
|
||||
var request = new UpdateMalwareBlockerConfigRequest
|
||||
{
|
||||
Enabled = true,
|
||||
CronExpression = "0/10 * * * * ?",
|
||||
IgnorePrivate = true,
|
||||
DeletePrivate = false,
|
||||
Sonarr = new BlocklistSettings { Enabled = true, BlocklistPath = "https://example.com/list.txt" },
|
||||
Radarr = new BlocklistSettings { Enabled = false },
|
||||
Lidarr = new BlocklistSettings { Enabled = false },
|
||||
Readarr = new BlocklistSettings { Enabled = false },
|
||||
Whisparr = new BlocklistSettings { Enabled = false },
|
||||
IgnoredDownloads = new List<string> { "foo" },
|
||||
};
|
||||
|
||||
// Act
|
||||
await _controller.UpdateMalwareBlockerConfig(request);
|
||||
|
||||
// Assert
|
||||
var saved = await _dataContext.ContentBlockerConfigs.AsNoTracking().FirstAsync();
|
||||
saved.Enabled.ShouldBeTrue();
|
||||
saved.CronExpression.ShouldBe("0/10 * * * * ?");
|
||||
saved.IgnorePrivate.ShouldBeTrue();
|
||||
saved.Sonarr.Enabled.ShouldBeTrue();
|
||||
saved.IgnoredDownloads.ShouldContain("foo");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
using Cleanuparr.Api.Features.QueueCleaner.Contracts.Requests;
|
||||
using Cleanuparr.Api.Features.QueueCleaner.Controllers;
|
||||
using Cleanuparr.Api.Tests.TestHelpers;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.QueueCleaner;
|
||||
|
||||
public class QueueCleanerConfigControllerTests : IDisposable
|
||||
{
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly IJobManagementService _jobManagementService;
|
||||
private readonly QueueCleanerConfigController _controller;
|
||||
|
||||
public QueueCleanerConfigControllerTests()
|
||||
{
|
||||
_dataContext = ConfigControllerTestDataFactory.CreateDataContext();
|
||||
var logger = Substitute.For<ILogger<QueueCleanerConfigController>>();
|
||||
_jobManagementService = Substitute.For<IJobManagementService>();
|
||||
_controller = new QueueCleanerConfigController(logger, _dataContext, _jobManagementService);
|
||||
ConfigControllerTestDataFactory.ConfigureProblemDetails(_controller);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_dataContext.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetQueueCleanerConfig_ReturnsExistingConfig()
|
||||
{
|
||||
// Act
|
||||
var result = await _controller.GetQueueCleanerConfig();
|
||||
|
||||
// Assert
|
||||
var ok = result.ShouldBeOfType<OkObjectResult>();
|
||||
ok.Value.ShouldBeOfType<QueueCleanerConfig>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateQueueCleanerConfig_Enabled_StartsJob()
|
||||
{
|
||||
// Arrange
|
||||
var request = new UpdateQueueCleanerConfigRequest
|
||||
{
|
||||
Enabled = true,
|
||||
CronExpression = "0 0/5 * * * ?",
|
||||
FailedImport = new FailedImportConfig(),
|
||||
IgnoredDownloads = new List<string>(),
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _controller.UpdateQueueCleanerConfig(request);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeOfType<OkObjectResult>();
|
||||
await _jobManagementService.Received(1).StartJob(JobType.QueueCleaner, null, "0 0/5 * * * ?");
|
||||
await _jobManagementService.DidNotReceive().StopJob(Arg.Any<JobType>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateQueueCleanerConfig_Disabled_StopsJob()
|
||||
{
|
||||
// Arrange — pre-enable
|
||||
var existing = await _dataContext.QueueCleanerConfigs.FirstAsync();
|
||||
existing.Enabled = true;
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
var request = new UpdateQueueCleanerConfigRequest
|
||||
{
|
||||
Enabled = false,
|
||||
CronExpression = "0 0/5 * * * ?",
|
||||
FailedImport = new FailedImportConfig(),
|
||||
IgnoredDownloads = new List<string>(),
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _controller.UpdateQueueCleanerConfig(request);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeOfType<OkObjectResult>();
|
||||
await _jobManagementService.Received(1).StopJob(JobType.QueueCleaner);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateQueueCleanerConfig_InvalidCronExpression_PropagatesValidationException()
|
||||
{
|
||||
// Arrange — CronValidationHelper throws Cleanuparr.Domain.Exceptions.ValidationException,
|
||||
// which the controller's catch (System.ComponentModel.DataAnnotations.ValidationException) does NOT match.
|
||||
var request = new UpdateQueueCleanerConfigRequest
|
||||
{
|
||||
Enabled = true,
|
||||
CronExpression = "not-a-cron",
|
||||
FailedImport = new FailedImportConfig(),
|
||||
IgnoredDownloads = new List<string>(),
|
||||
};
|
||||
|
||||
// Act / Assert
|
||||
await Should.ThrowAsync<Cleanuparr.Domain.Exceptions.ValidationException>(
|
||||
() => _controller.UpdateQueueCleanerConfig(request));
|
||||
await _jobManagementService.DidNotReceive().StartJob(Arg.Any<JobType>(), Arg.Any<Cleanuparr.Infrastructure.Models.JobSchedule?>(), Arg.Any<string?>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateQueueCleanerConfig_ConfigValidationFails_ReturnsBadRequest()
|
||||
{
|
||||
// Arrange — DownloadingMetadataMaxStrikes < 3 (and > 0) triggers Validate() exception
|
||||
var request = new UpdateQueueCleanerConfigRequest
|
||||
{
|
||||
Enabled = true,
|
||||
CronExpression = "0 0/5 * * * ?",
|
||||
FailedImport = new FailedImportConfig(),
|
||||
DownloadingMetadataMaxStrikes = 2,
|
||||
IgnoredDownloads = new List<string>(),
|
||||
};
|
||||
|
||||
// Act + Assert
|
||||
await Should.ThrowAsync<System.ComponentModel.DataAnnotations.ValidationException>(
|
||||
() => _controller.UpdateQueueCleanerConfig(request));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateQueueCleanerConfig_PersistsChanges()
|
||||
{
|
||||
// Arrange
|
||||
var request = new UpdateQueueCleanerConfigRequest
|
||||
{
|
||||
Enabled = true,
|
||||
CronExpression = "0 0/10 * * * ?",
|
||||
FailedImport = new FailedImportConfig(),
|
||||
DownloadingMetadataMaxStrikes = 5,
|
||||
ProcessNoContentId = true,
|
||||
IgnoredDownloads = new List<string> { "ignored" },
|
||||
};
|
||||
|
||||
// Act
|
||||
await _controller.UpdateQueueCleanerConfig(request);
|
||||
|
||||
// Assert
|
||||
var saved = await _dataContext.QueueCleanerConfigs.AsNoTracking().FirstAsync();
|
||||
saved.Enabled.ShouldBeTrue();
|
||||
saved.CronExpression.ShouldBe("0 0/10 * * * ?");
|
||||
saved.DownloadingMetadataMaxStrikes.ShouldBe((ushort)5);
|
||||
saved.ProcessNoContentId.ShouldBeTrue();
|
||||
saved.IgnoredDownloads.ShouldContain("ignored");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
using Cleanuparr.Api.Features.QueueCleaner.Contracts.Requests;
|
||||
using Cleanuparr.Api.Features.QueueCleaner.Controllers;
|
||||
using Cleanuparr.Api.Tests.TestHelpers;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Models;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.QueueCleaner;
|
||||
|
||||
public class QueueRulesControllerTests : IDisposable
|
||||
{
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly IRuleIntervalValidator _validator;
|
||||
private readonly QueueRulesController _controller;
|
||||
|
||||
public QueueRulesControllerTests()
|
||||
{
|
||||
_dataContext = ConfigControllerTestDataFactory.CreateDataContext();
|
||||
var logger = Substitute.For<ILogger<QueueRulesController>>();
|
||||
_validator = Substitute.For<IRuleIntervalValidator>();
|
||||
_validator.ValidateStallRuleIntervals(Arg.Any<StallRule>(), Arg.Any<List<StallRule>>()).Returns(ValidationResult.Success());
|
||||
_validator.ValidateSlowRuleIntervals(Arg.Any<SlowRule>(), Arg.Any<List<SlowRule>>()).Returns(ValidationResult.Success());
|
||||
_controller = new QueueRulesController(logger, _dataContext, _validator);
|
||||
ConfigControllerTestDataFactory.ConfigureProblemDetails(_controller);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_dataContext.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
#region Stall Rules
|
||||
|
||||
[Fact]
|
||||
public async Task GetStallRules_EmptyDatabase_ReturnsEmptyList()
|
||||
{
|
||||
// Act
|
||||
var result = await _controller.GetStallRules();
|
||||
|
||||
// Assert
|
||||
var ok = result.ShouldBeOfType<OkObjectResult>();
|
||||
var rules = ok.Value.ShouldBeOfType<List<StallRule>>();
|
||||
rules.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateStallRule_NewName_ReturnsCreatedWithRule()
|
||||
{
|
||||
// Arrange
|
||||
var dto = NewStallDto(name: "default");
|
||||
|
||||
// Act
|
||||
var result = await _controller.CreateStallRule(dto);
|
||||
|
||||
// Assert
|
||||
var created = result.ShouldBeOfType<CreatedAtActionResult>();
|
||||
var rule = created.Value.ShouldBeOfType<StallRule>();
|
||||
rule.Name.ShouldBe("default");
|
||||
rule.Id.ShouldNotBe(Guid.Empty);
|
||||
(await _dataContext.StallRules.CountAsync()).ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateStallRule_DuplicateName_ReturnsBadRequest()
|
||||
{
|
||||
// Arrange — seed existing rule
|
||||
await _controller.CreateStallRule(NewStallDto(name: "rule-a"));
|
||||
|
||||
// Act — try to create another with same name (case-insensitive)
|
||||
var result = await _controller.CreateStallRule(NewStallDto(name: "RULE-A"));
|
||||
|
||||
// Assert
|
||||
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(400);
|
||||
(await _dataContext.StallRules.CountAsync()).ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateStallRule_IntervalValidatorFails_ReturnsBadRequest()
|
||||
{
|
||||
// Arrange
|
||||
_validator.ValidateStallRuleIntervals(Arg.Any<StallRule>(), Arg.Any<List<StallRule>>())
|
||||
.Returns(ValidationResult.Failure("overlaps with existing rule"));
|
||||
|
||||
// Act
|
||||
var result = await _controller.CreateStallRule(NewStallDto(name: "x"));
|
||||
|
||||
// Assert
|
||||
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(400);
|
||||
(await _dataContext.StallRules.CountAsync()).ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateStallRule_NonExistentId_ReturnsNotFound()
|
||||
{
|
||||
// Act
|
||||
var result = await _controller.UpdateStallRule(Guid.NewGuid(), NewStallDto("x"));
|
||||
|
||||
// Assert
|
||||
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(404);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateStallRule_ExistingRule_PersistsChanges()
|
||||
{
|
||||
// Arrange
|
||||
var create = (CreatedAtActionResult)await _controller.CreateStallRule(NewStallDto(name: "orig"));
|
||||
var id = ((StallRule)create.Value!).Id;
|
||||
|
||||
// Act
|
||||
var result = await _controller.UpdateStallRule(id, NewStallDto(name: "renamed"));
|
||||
|
||||
// Assert
|
||||
var ok = result.ShouldBeOfType<OkObjectResult>();
|
||||
var rule = ok.Value.ShouldBeOfType<StallRule>();
|
||||
rule.Name.ShouldBe("renamed");
|
||||
var saved = await _dataContext.StallRules.AsNoTracking().FirstAsync(r => r.Id == id);
|
||||
saved.Name.ShouldBe("renamed");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateStallRule_DuplicateNameOtherRule_ReturnsBadRequest()
|
||||
{
|
||||
// Arrange
|
||||
await _controller.CreateStallRule(NewStallDto(name: "alpha"));
|
||||
var betaCreate = (CreatedAtActionResult)await _controller.CreateStallRule(NewStallDto(name: "beta"));
|
||||
var betaId = ((StallRule)betaCreate.Value!).Id;
|
||||
|
||||
// Act — try to rename beta → alpha
|
||||
var result = await _controller.UpdateStallRule(betaId, NewStallDto(name: "alpha"));
|
||||
|
||||
// Assert
|
||||
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(400);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteStallRule_ExistingRule_ReturnsNoContent()
|
||||
{
|
||||
// Arrange
|
||||
var create = (CreatedAtActionResult)await _controller.CreateStallRule(NewStallDto(name: "doomed"));
|
||||
var id = ((StallRule)create.Value!).Id;
|
||||
|
||||
// Act
|
||||
var result = await _controller.DeleteStallRule(id);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeOfType<NoContentResult>();
|
||||
(await _dataContext.StallRules.CountAsync()).ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteStallRule_NonExistent_ReturnsNotFound()
|
||||
{
|
||||
// Act
|
||||
var result = await _controller.DeleteStallRule(Guid.NewGuid());
|
||||
|
||||
// Assert
|
||||
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(404);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Slow Rules
|
||||
|
||||
[Fact]
|
||||
public async Task GetSlowRules_EmptyDatabase_ReturnsEmptyList()
|
||||
{
|
||||
// Act
|
||||
var result = await _controller.GetSlowRules();
|
||||
|
||||
// Assert
|
||||
var ok = result.ShouldBeOfType<OkObjectResult>();
|
||||
var rules = ok.Value.ShouldBeOfType<List<SlowRule>>();
|
||||
rules.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSlowRule_NewName_ReturnsCreated()
|
||||
{
|
||||
// Arrange
|
||||
var dto = NewSlowDto(name: "slow-default");
|
||||
|
||||
// Act
|
||||
var result = await _controller.CreateSlowRule(dto);
|
||||
|
||||
// Assert
|
||||
var created = result.ShouldBeOfType<CreatedAtActionResult>();
|
||||
var rule = created.Value.ShouldBeOfType<SlowRule>();
|
||||
rule.Name.ShouldBe("slow-default");
|
||||
(await _dataContext.SlowRules.CountAsync()).ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSlowRule_DuplicateName_ReturnsBadRequest()
|
||||
{
|
||||
// Arrange
|
||||
await _controller.CreateSlowRule(NewSlowDto(name: "x"));
|
||||
|
||||
// Act
|
||||
var result = await _controller.CreateSlowRule(NewSlowDto(name: "X"));
|
||||
|
||||
// Assert
|
||||
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(400);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSlowRule_NonExistentId_ReturnsNotFound()
|
||||
{
|
||||
// Act
|
||||
var result = await _controller.UpdateSlowRule(Guid.NewGuid(), NewSlowDto("x"));
|
||||
|
||||
// Assert
|
||||
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(404);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteSlowRule_NonExistent_ReturnsNotFound()
|
||||
{
|
||||
// Act
|
||||
var result = await _controller.DeleteSlowRule(Guid.NewGuid());
|
||||
|
||||
// Assert
|
||||
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(404);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private static StallRuleDto NewStallDto(string name) => new()
|
||||
{
|
||||
Name = name,
|
||||
Enabled = true,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MinCompletionPercentage = 0,
|
||||
MaxCompletionPercentage = 100,
|
||||
ResetStrikesOnProgress = true,
|
||||
};
|
||||
|
||||
private static SlowRuleDto NewSlowDto(string name) => new()
|
||||
{
|
||||
Name = name,
|
||||
Enabled = true,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MinCompletionPercentage = 0,
|
||||
MaxCompletionPercentage = 100,
|
||||
ResetStrikesOnProgress = true,
|
||||
MinSpeed = "1MB",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,543 @@
|
||||
using System.Text.Json;
|
||||
using Cleanuparr.Api.Features.Seeker.Contracts.Responses;
|
||||
using Cleanuparr.Api.Features.Seeker.Controllers;
|
||||
using Cleanuparr.Api.Tests.Features.Seeker.TestHelpers;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.State;
|
||||
using Cleanuparr.Persistence.Providers;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Shouldly;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.Seeker;
|
||||
|
||||
public class CustomFormatScoreControllerTests : IDisposable
|
||||
{
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly EventsContext _eventsContext;
|
||||
private readonly CustomFormatScoreController _controller;
|
||||
|
||||
public CustomFormatScoreControllerTests()
|
||||
{
|
||||
_dataContext = SeekerTestDataFactory.CreateDataContext();
|
||||
_eventsContext = SeekerTestDataFactory.CreateEventsContext();
|
||||
_controller = new CustomFormatScoreController(_dataContext, _eventsContext, new SqliteDatabaseProvider());
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_dataContext.Dispose();
|
||||
_eventsContext.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
private static JsonElement GetResponseBody(IActionResult result)
|
||||
{
|
||||
var okResult = result.ShouldBeOfType<OkObjectResult>();
|
||||
var json = JsonSerializer.Serialize(okResult.Value);
|
||||
return JsonDocument.Parse(json).RootElement;
|
||||
}
|
||||
|
||||
#region GetCustomFormatScores Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetCustomFormatScores_WithPageBelowMinimum_ClampsToOne()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
AddScoreEntry(radarr.Id, 1, "Movie A", currentScore: 100, cutoffScore: 500);
|
||||
AddScoreEntry(radarr.Id, 2, "Movie B", currentScore: 200, cutoffScore: 500);
|
||||
|
||||
var result = await _controller.GetCustomFormatScores(page: -5, pageSize: 50);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("Page").GetInt32().ShouldBe(1);
|
||||
body.GetProperty("Items").GetArrayLength().ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCustomFormatScores_WithPageSizeAboveMaximum_ClampsToHundred()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
AddScoreEntry(radarr.Id, 1, "Movie A", currentScore: 100, cutoffScore: 500);
|
||||
|
||||
var result = await _controller.GetCustomFormatScores(page: 1, pageSize: 999);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("PageSize").GetInt32().ShouldBe(500);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCustomFormatScores_WithHideMetTrue_ExcludesItemsAtOrAboveCutoff()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
AddScoreEntry(radarr.Id, 1, "Below Cutoff", currentScore: 100, cutoffScore: 500);
|
||||
AddScoreEntry(radarr.Id, 2, "At Cutoff", currentScore: 500, cutoffScore: 500);
|
||||
AddScoreEntry(radarr.Id, 3, "Above Cutoff", currentScore: 600, cutoffScore: 500);
|
||||
|
||||
var result = await _controller.GetCustomFormatScores(cutoffFilter: CutoffFilter.Below);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
|
||||
body.GetProperty("Items")[0].GetProperty("Title").GetString().ShouldBe("Below Cutoff");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCustomFormatScores_WithHideUnmonitoredTrue_ExcludesUnmonitoredItems()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
AddScoreEntry(radarr.Id, 1, "Monitored Movie", currentScore: 100, cutoffScore: 500, isMonitored: true);
|
||||
AddScoreEntry(radarr.Id, 2, "Unmonitored Movie", currentScore: 200, cutoffScore: 500, isMonitored: false);
|
||||
AddScoreEntry(radarr.Id, 3, "Another Monitored", currentScore: 300, cutoffScore: 500, isMonitored: true);
|
||||
|
||||
var result = await _controller.GetCustomFormatScores(monitoredFilter: MonitoredFilter.Monitored);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(2);
|
||||
var items = body.GetProperty("Items");
|
||||
items.GetArrayLength().ShouldBe(2);
|
||||
items[0].GetProperty("Title").GetString().ShouldBe("Another Monitored");
|
||||
items[1].GetProperty("Title").GetString().ShouldBe("Monitored Movie");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCustomFormatScores_WithSearchFilter_ReturnsMatchingTitlesOnly()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
AddScoreEntry(radarr.Id, 1, "The Matrix", currentScore: 100, cutoffScore: 500);
|
||||
AddScoreEntry(radarr.Id, 2, "Inception", currentScore: 200, cutoffScore: 500);
|
||||
AddScoreEntry(radarr.Id, 3, "The Matrix Reloaded", currentScore: 300, cutoffScore: 500);
|
||||
|
||||
var result = await _controller.GetCustomFormatScores(search: "matrix");
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCustomFormatScores_WithSortByDate_OrdersByLastSyncedDescending()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
AddScoreEntry(radarr.Id, 1, "Older", currentScore: 100, cutoffScore: 500,
|
||||
lastSynced: DateTime.UtcNow.AddHours(-2));
|
||||
AddScoreEntry(radarr.Id, 2, "Newer", currentScore: 200, cutoffScore: 500,
|
||||
lastSynced: DateTime.UtcNow.AddHours(-1));
|
||||
|
||||
var result = await _controller.GetCustomFormatScores(sortBy: CfScoresSortBy.LastSyncedAt);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("Items")[0].GetProperty("Title").GetString().ShouldBe("Newer");
|
||||
body.GetProperty("Items")[1].GetProperty("Title").GetString().ShouldBe("Older");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCustomFormatScores_WithInstanceIdFilter_ReturnsOnlyThatInstance()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
var sonarr = SeekerTestDataFactory.AddSonarrInstance(_dataContext);
|
||||
AddScoreEntry(radarr.Id, 1, "Movie", currentScore: 100, cutoffScore: 500);
|
||||
AddScoreEntry(sonarr.Id, 2, "Series", currentScore: 200, cutoffScore: 500,
|
||||
itemType: InstanceType.Sonarr);
|
||||
|
||||
var result = await _controller.GetCustomFormatScores(instanceId: radarr.Id);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
|
||||
body.GetProperty("Items")[0].GetProperty("Title").GetString().ShouldBe("Movie");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCustomFormatScores_ReturnsCorrectTotalPagesCalculation()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
for (int i = 1; i <= 7; i++)
|
||||
{
|
||||
AddScoreEntry(radarr.Id, i, $"Movie {i}", currentScore: 100, cutoffScore: 500);
|
||||
}
|
||||
|
||||
var result = await _controller.GetCustomFormatScores(page: 1, pageSize: 3);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(7);
|
||||
body.GetProperty("TotalPages").GetInt32().ShouldBe(3); // ceil(7/3) = 3
|
||||
body.GetProperty("Items").GetArrayLength().ShouldBe(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCustomFormatScores_WithCutoffFilterMet_ExcludesBelowCutoff()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
AddScoreEntry(radarr.Id, 1, "Below", currentScore: 100, cutoffScore: 500);
|
||||
AddScoreEntry(radarr.Id, 2, "At", currentScore: 500, cutoffScore: 500);
|
||||
AddScoreEntry(radarr.Id, 3, "Above", currentScore: 600, cutoffScore: 500);
|
||||
|
||||
var result = await _controller.GetCustomFormatScores(cutoffFilter: CutoffFilter.Met);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCustomFormatScores_WithCutoffFilterAll_IncludesEverything()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
AddScoreEntry(radarr.Id, 1, "Below", currentScore: 100, cutoffScore: 500);
|
||||
AddScoreEntry(radarr.Id, 2, "Above", currentScore: 600, cutoffScore: 500);
|
||||
|
||||
var result = await _controller.GetCustomFormatScores(cutoffFilter: CutoffFilter.All);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCustomFormatScores_WithMonitoredFilterUnmonitored_ReturnsOnlyUnmonitored()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
AddScoreEntry(radarr.Id, 1, "A", currentScore: 100, cutoffScore: 500, isMonitored: true);
|
||||
AddScoreEntry(radarr.Id, 2, "B", currentScore: 100, cutoffScore: 500, isMonitored: false);
|
||||
|
||||
var result = await _controller.GetCustomFormatScores(monitoredFilter: MonitoredFilter.Unmonitored);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
|
||||
body.GetProperty("Items")[0].GetProperty("Title").GetString().ShouldBe("B");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCustomFormatScores_WithQualityProfileFilter_ReturnsOnlyMatchingProfile()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
AddScoreEntry(radarr.Id, 1, "HD Movie", currentScore: 100, cutoffScore: 500, qualityProfileName: "HD");
|
||||
AddScoreEntry(radarr.Id, 2, "UHD Movie", currentScore: 200, cutoffScore: 500, qualityProfileName: "UHD");
|
||||
|
||||
var result = await _controller.GetCustomFormatScores(qualityProfile: "UHD");
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
|
||||
body.GetProperty("Items")[0].GetProperty("Title").GetString().ShouldBe("UHD Movie");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCustomFormatScores_WithExplicitSortDirectionAsc_OverridesDefault()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
AddScoreEntry(radarr.Id, 1, "A", currentScore: 100, cutoffScore: 500);
|
||||
AddScoreEntry(radarr.Id, 2, "B", currentScore: 300, cutoffScore: 500);
|
||||
|
||||
// CurrentScore default is descending; overriding with Asc should flip it.
|
||||
var result = await _controller.GetCustomFormatScores(
|
||||
sortBy: CfScoresSortBy.CurrentScore,
|
||||
sortDirection: Cleanuparr.Domain.Enums.SortDirection.Asc);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
var items = body.GetProperty("Items");
|
||||
items[0].GetProperty("CurrentScore").GetInt32().ShouldBe(100);
|
||||
items[1].GetProperty("CurrentScore").GetInt32().ShouldBe(300);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCustomFormatScores_WithSortByTitleDescending_OrdersReverseAlphabetically()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
AddScoreEntry(radarr.Id, 1, "Apple", currentScore: 100, cutoffScore: 500);
|
||||
AddScoreEntry(radarr.Id, 2, "Banana", currentScore: 200, cutoffScore: 500);
|
||||
|
||||
var result = await _controller.GetCustomFormatScores(
|
||||
sortBy: CfScoresSortBy.Title,
|
||||
sortDirection: Cleanuparr.Domain.Enums.SortDirection.Desc);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("Items")[0].GetProperty("Title").GetString().ShouldBe("Banana");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetRecentUpgrades Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetRecentUpgrades_WithNoHistory_ReturnsEmptyList()
|
||||
{
|
||||
var result = await _controller.GetRecentUpgrades();
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(0);
|
||||
body.GetProperty("Items").GetArrayLength().ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetRecentUpgrades_WithSingleEntryPerItem_ReturnsNoUpgrades()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 100, recordedAt: DateTime.UtcNow.AddDays(-1));
|
||||
|
||||
var result = await _controller.GetRecentUpgrades();
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetRecentUpgrades_WithScoreIncrease_DetectsUpgrade()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 100, recordedAt: DateTime.UtcNow.AddDays(-2));
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 250, recordedAt: DateTime.UtcNow.AddDays(-1));
|
||||
|
||||
var result = await _controller.GetRecentUpgrades();
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
|
||||
var upgrade = body.GetProperty("Items")[0];
|
||||
upgrade.GetProperty("PreviousScore").GetInt32().ShouldBe(100);
|
||||
upgrade.GetProperty("NewScore").GetInt32().ShouldBe(250);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetRecentUpgrades_WithScoreDecrease_DoesNotCountAsUpgrade()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 300, recordedAt: DateTime.UtcNow.AddDays(-2));
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 150, recordedAt: DateTime.UtcNow.AddDays(-1));
|
||||
|
||||
var result = await _controller.GetRecentUpgrades();
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetRecentUpgrades_WithMultipleUpgradesInSameGroup_CountsEach()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
// 100 -> 200 -> 300 = two upgrades for the same item
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 100, recordedAt: DateTime.UtcNow.AddDays(-3));
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 200, recordedAt: DateTime.UtcNow.AddDays(-2));
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 300, recordedAt: DateTime.UtcNow.AddDays(-1));
|
||||
|
||||
var result = await _controller.GetRecentUpgrades();
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetRecentUpgrades_WithDaysFilter_ExcludesOlderHistory()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
// Old upgrade (outside 7-day window)
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 100, recordedAt: DateTime.UtcNow.AddDays(-20));
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 250, recordedAt: DateTime.UtcNow.AddDays(-15));
|
||||
// Recent upgrade (inside 7-day window)
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 2, score: 100, recordedAt: DateTime.UtcNow.AddDays(-3));
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 2, score: 300, recordedAt: DateTime.UtcNow.AddDays(-1));
|
||||
|
||||
var result = await _controller.GetRecentUpgrades(days: 7);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetRecentUpgrades_WithUpgradeCrossingWindowBoundary_IsDetected()
|
||||
{
|
||||
// CR2: pre-window baseline must still participate so the first in-window
|
||||
// row can be recognised as an upgrade.
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 100, recordedAt: DateTime.UtcNow.AddDays(-10));
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 200, recordedAt: DateTime.UtcNow.AddDays(-3));
|
||||
|
||||
var result = await _controller.GetRecentUpgrades(days: 7);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
|
||||
var upgrade = body.GetProperty("Items")[0];
|
||||
upgrade.GetProperty("PreviousScore").GetInt32().ShouldBe(100);
|
||||
upgrade.GetProperty("NewScore").GetInt32().ShouldBe(200);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetRecentUpgrades_WithSortByScoreDeltaDescending_OrdersByLargestDelta()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
// Item 1: +50
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 100, recordedAt: DateTime.UtcNow.AddDays(-3));
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 150, recordedAt: DateTime.UtcNow.AddDays(-2));
|
||||
// Item 2: +400
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 2, score: 100, recordedAt: DateTime.UtcNow.AddDays(-3));
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 2, score: 500, recordedAt: DateTime.UtcNow.AddDays(-2));
|
||||
|
||||
var result = await _controller.GetRecentUpgrades(sortBy: CfUpgradesSortBy.ScoreDelta);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
var items = body.GetProperty("Items");
|
||||
items[0].GetProperty("NewScore").GetInt32().ShouldBe(500);
|
||||
items[1].GetProperty("NewScore").GetInt32().ShouldBe(150);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetRecentUpgrades_WithSortByTitleAscending_OrdersAlphabetically()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 2, score: 100, recordedAt: DateTime.UtcNow.AddDays(-3));
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 2, score: 200, recordedAt: DateTime.UtcNow.AddDays(-2));
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 100, recordedAt: DateTime.UtcNow.AddDays(-3));
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 200, recordedAt: DateTime.UtcNow.AddDays(-2));
|
||||
|
||||
var result = await _controller.GetRecentUpgrades(sortBy: CfUpgradesSortBy.Title);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
var items = body.GetProperty("Items");
|
||||
items[0].GetProperty("Title").GetString().ShouldBe("Item 1");
|
||||
items[1].GetProperty("Title").GetString().ShouldBe("Item 2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetRecentUpgrades_WithSearchFilter_ReturnsMatchingTitlesOnly()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
// AddHistoryEntry titles as "Item {externalItemId}".
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 42, score: 100, recordedAt: DateTime.UtcNow.AddDays(-3));
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 42, score: 200, recordedAt: DateTime.UtcNow.AddDays(-2));
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 99, score: 100, recordedAt: DateTime.UtcNow.AddDays(-3));
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 99, score: 200, recordedAt: DateTime.UtcNow.AddDays(-2));
|
||||
|
||||
var result = await _controller.GetRecentUpgrades(search: "42");
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
|
||||
body.GetProperty("Items")[0].GetProperty("Title").GetString().ShouldBe("Item 42");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetRecentUpgrades_ReturnsSortedByMostRecentFirst()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
// Item 1: upgrade happened 5 days ago
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 100, recordedAt: DateTime.UtcNow.AddDays(-6));
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 200, recordedAt: DateTime.UtcNow.AddDays(-5));
|
||||
// Item 2: upgrade happened 1 day ago
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 2, score: 100, recordedAt: DateTime.UtcNow.AddDays(-2));
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 2, score: 300, recordedAt: DateTime.UtcNow.AddDays(-1));
|
||||
|
||||
var result = await _controller.GetRecentUpgrades();
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
var items = body.GetProperty("Items");
|
||||
items.GetArrayLength().ShouldBe(2);
|
||||
// Most recent upgrade (item 2) should be first
|
||||
items[0].GetProperty("NewScore").GetInt32().ShouldBe(300);
|
||||
items[1].GetProperty("NewScore").GetInt32().ShouldBe(200);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetStats Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetStats_WithNoEntries_ReturnsZeroes()
|
||||
{
|
||||
var result = await _controller.GetStats();
|
||||
var okResult = result.ShouldBeOfType<OkObjectResult>();
|
||||
var stats = okResult.Value.ShouldBeOfType<CustomFormatScoreStatsResponse>();
|
||||
|
||||
stats.TotalTracked.ShouldBe(0);
|
||||
stats.BelowCutoff.ShouldBe(0);
|
||||
stats.AtOrAboveCutoff.ShouldBe(0);
|
||||
stats.RecentUpgrades.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStats_CorrectlyCategorizesBelowAndAboveCutoff()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
AddScoreEntry(radarr.Id, 1, "Below", currentScore: 100, cutoffScore: 500);
|
||||
AddScoreEntry(radarr.Id, 2, "At", currentScore: 500, cutoffScore: 500);
|
||||
AddScoreEntry(radarr.Id, 3, "Above", currentScore: 600, cutoffScore: 500);
|
||||
|
||||
var result = await _controller.GetStats();
|
||||
var okResult = result.ShouldBeOfType<OkObjectResult>();
|
||||
var stats = okResult.Value.ShouldBeOfType<CustomFormatScoreStatsResponse>();
|
||||
|
||||
stats.TotalTracked.ShouldBe(3);
|
||||
stats.BelowCutoff.ShouldBe(1);
|
||||
stats.AtOrAboveCutoff.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStats_CountsRecentUpgradesFromLast7Days()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
AddScoreEntry(radarr.Id, 1, "Movie", currentScore: 300, cutoffScore: 500);
|
||||
|
||||
// Upgrade within 7 days
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 100, recordedAt: DateTime.UtcNow.AddDays(-3));
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 300, recordedAt: DateTime.UtcNow.AddDays(-1));
|
||||
|
||||
// Upgrade outside 7 days (should not be counted)
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 2, score: 50, recordedAt: DateTime.UtcNow.AddDays(-20));
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 2, score: 200, recordedAt: DateTime.UtcNow.AddDays(-15));
|
||||
|
||||
var result = await _controller.GetStats();
|
||||
var okResult = result.ShouldBeOfType<OkObjectResult>();
|
||||
var stats = okResult.Value.ShouldBeOfType<CustomFormatScoreStatsResponse>();
|
||||
|
||||
stats.RecentUpgrades.ShouldBe(1);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helpers
|
||||
|
||||
private void AddScoreEntry(
|
||||
Guid arrInstanceId,
|
||||
long externalItemId,
|
||||
string title,
|
||||
int currentScore,
|
||||
int cutoffScore,
|
||||
InstanceType itemType = InstanceType.Radarr,
|
||||
DateTime? lastSynced = null,
|
||||
bool isMonitored = true,
|
||||
string qualityProfileName = "HD")
|
||||
{
|
||||
_eventsContext.CustomFormatScoreEntries.Add(new CustomFormatScoreEntry
|
||||
{
|
||||
ArrInstanceId = arrInstanceId,
|
||||
ExternalItemId = externalItemId,
|
||||
EpisodeId = 0,
|
||||
ItemType = itemType,
|
||||
Title = title,
|
||||
FileId = externalItemId * 10,
|
||||
CurrentScore = currentScore,
|
||||
CutoffScore = cutoffScore,
|
||||
QualityProfileName = qualityProfileName,
|
||||
IsMonitored = isMonitored,
|
||||
LastSyncedAt = lastSynced ?? DateTime.UtcNow
|
||||
});
|
||||
_eventsContext.SaveChanges();
|
||||
}
|
||||
|
||||
private void AddHistoryEntry(
|
||||
Guid arrInstanceId,
|
||||
long externalItemId,
|
||||
int score,
|
||||
DateTime recordedAt,
|
||||
long episodeId = 0,
|
||||
int cutoffScore = 500,
|
||||
InstanceType itemType = InstanceType.Radarr)
|
||||
{
|
||||
_eventsContext.CustomFormatScoreHistory.Add(new CustomFormatScoreHistory
|
||||
{
|
||||
ArrInstanceId = arrInstanceId,
|
||||
ExternalItemId = externalItemId,
|
||||
EpisodeId = episodeId,
|
||||
ItemType = itemType,
|
||||
Title = $"Item {externalItemId}",
|
||||
Score = score,
|
||||
CutoffScore = cutoffScore,
|
||||
RecordedAt = recordedAt
|
||||
});
|
||||
_eventsContext.SaveChanges();
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
using System.Text.Json;
|
||||
using Cleanuparr.Api.Features.Seeker.Controllers;
|
||||
using Cleanuparr.Api.Tests.Features.Seeker.TestHelpers;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Events;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Shouldly;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.Seeker;
|
||||
|
||||
public class SearchStatsControllerTests : IDisposable
|
||||
{
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly EventsContext _eventsContext;
|
||||
private readonly SearchStatsController _controller;
|
||||
|
||||
public SearchStatsControllerTests()
|
||||
{
|
||||
_dataContext = SeekerTestDataFactory.CreateDataContext();
|
||||
_eventsContext = SeekerTestDataFactory.CreateEventsContext();
|
||||
_controller = new SearchStatsController(_dataContext, _eventsContext);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_dataContext.Dispose();
|
||||
_eventsContext.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
private static JsonElement GetResponseBody(IActionResult result)
|
||||
{
|
||||
var okResult = result.ShouldBeOfType<OkObjectResult>();
|
||||
var json = JsonSerializer.Serialize(okResult.Value);
|
||||
return JsonDocument.Parse(json).RootElement;
|
||||
}
|
||||
|
||||
#region GetEvents with SearchEventData
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvents_WithNoSearchEventData_ReturnsUnknownDefaults()
|
||||
{
|
||||
AddSearchEvent();
|
||||
|
||||
var result = await _controller.GetEvents();
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
var item = body.GetProperty("Items")[0];
|
||||
item.GetProperty("ItemTitle").GetString().ShouldBe("Unknown");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvents_WithSearchEventData_ReturnsAllFields()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
|
||||
AddSearchEvent(
|
||||
arrInstanceId: radarr.Id,
|
||||
itemTitle: "Movie A",
|
||||
searchType: SeekerSearchType.Proactive,
|
||||
searchReason: SeekerSearchReason.Missing,
|
||||
grabbedItems: ["Movie A (2024)"]);
|
||||
|
||||
var result = await _controller.GetEvents();
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
var item = body.GetProperty("Items")[0];
|
||||
item.GetProperty("ArrInstanceId").GetString().ShouldBe(radarr.Id.ToString());
|
||||
item.GetProperty("InstanceType").GetString().ShouldBe(nameof(InstanceType.Radarr));
|
||||
item.GetProperty("ItemTitle").GetString().ShouldBe("Movie A");
|
||||
item.GetProperty("SearchType").GetString().ShouldBe(nameof(SeekerSearchType.Proactive));
|
||||
item.GetProperty("SearchReason").GetString().ShouldBe(nameof(SeekerSearchReason.Missing));
|
||||
item.GetProperty("GrabbedItems")[0].GetString().ShouldBe("Movie A (2024)");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvents_WithReplacementSearchType_ParsesCorrectEnum()
|
||||
{
|
||||
AddSearchEvent(
|
||||
itemTitle: "Series A",
|
||||
searchType: SeekerSearchType.Replacement,
|
||||
searchReason: SeekerSearchReason.Replacement);
|
||||
|
||||
var result = await _controller.GetEvents();
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
var item = body.GetProperty("Items")[0];
|
||||
item.GetProperty("SearchType").GetString().ShouldBe(nameof(SeekerSearchType.Replacement));
|
||||
item.GetProperty("SearchReason").GetString().ShouldBe(nameof(SeekerSearchReason.Replacement));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetEvents Filtering
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvents_WithInstanceIdFilter_FiltersByArrInstanceId()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
var sonarr = SeekerTestDataFactory.AddSonarrInstance(_dataContext);
|
||||
|
||||
AddSearchEvent(arrInstanceId: radarr.Id, itemTitle: "Radarr Movie");
|
||||
AddSearchEvent(arrInstanceId: sonarr.Id, itemTitle: "Sonarr Series");
|
||||
|
||||
var result = await _controller.GetEvents(instanceId: radarr.Id);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
|
||||
body.GetProperty("Items")[0].GetProperty("ArrInstanceId").GetString().ShouldBe(radarr.Id.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvents_WithCycleIdFilter_ReturnsOnlyMatchingCycle()
|
||||
{
|
||||
var cycleA = Guid.NewGuid();
|
||||
var cycleB = Guid.NewGuid();
|
||||
|
||||
AddSearchEvent(cycleId: cycleA, itemTitle: "Cycle A Movie");
|
||||
AddSearchEvent(cycleId: cycleB, itemTitle: "Cycle B Movie");
|
||||
|
||||
var result = await _controller.GetEvents(cycleId: cycleA);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
|
||||
body.GetProperty("Items")[0].GetProperty("ItemTitle").GetString().ShouldBe("Cycle A Movie");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvents_WithSearchFilter_FiltersOnItemTitle()
|
||||
{
|
||||
AddSearchEvent(itemTitle: "The Matrix");
|
||||
AddSearchEvent(itemTitle: "Breaking Bad");
|
||||
|
||||
var result = await _controller.GetEvents(search: "matrix");
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvents_WithPagination_ReturnsCorrectPageAndCount()
|
||||
{
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
AddSearchEvent(itemTitle: $"Event {i}");
|
||||
}
|
||||
|
||||
var result = await _controller.GetEvents(page: 2, pageSize: 2);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(5);
|
||||
body.GetProperty("TotalPages").GetInt32().ShouldBe(3);
|
||||
body.GetProperty("Page").GetInt32().ShouldBe(2);
|
||||
body.GetProperty("Items").GetArrayLength().ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvents_WithSortByTitleAscending_OrdersAlphabetically()
|
||||
{
|
||||
AddSearchEvent(itemTitle: "Charlie");
|
||||
AddSearchEvent(itemTitle: "Alpha");
|
||||
AddSearchEvent(itemTitle: "Bravo");
|
||||
|
||||
var result = await _controller.GetEvents(
|
||||
sortBy: SearchEventsSortBy.Title,
|
||||
sortDirection: Cleanuparr.Domain.Enums.SortDirection.Asc);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
var items = body.GetProperty("Items");
|
||||
items[0].GetProperty("ItemTitle").GetString().ShouldBe("Alpha");
|
||||
items[1].GetProperty("ItemTitle").GetString().ShouldBe("Bravo");
|
||||
items[2].GetProperty("ItemTitle").GetString().ShouldBe("Charlie");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvents_WithSortByTimestampAscending_OldestFirst()
|
||||
{
|
||||
AddSearchEvent(itemTitle: "Newest", timestamp: DateTime.UtcNow);
|
||||
AddSearchEvent(itemTitle: "Oldest", timestamp: DateTime.UtcNow.AddHours(-2));
|
||||
AddSearchEvent(itemTitle: "Middle", timestamp: DateTime.UtcNow.AddHours(-1));
|
||||
|
||||
var result = await _controller.GetEvents(sortDirection: Cleanuparr.Domain.Enums.SortDirection.Asc);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
var items = body.GetProperty("Items");
|
||||
items[0].GetProperty("ItemTitle").GetString().ShouldBe("Oldest");
|
||||
items[2].GetProperty("ItemTitle").GetString().ShouldBe("Newest");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvents_WithSearchStatusFilter_ReturnsOnlyMatchingStatuses()
|
||||
{
|
||||
AddSearchEvent(itemTitle: "A", searchStatus: SearchCommandStatus.Completed);
|
||||
AddSearchEvent(itemTitle: "B", searchStatus: SearchCommandStatus.Failed);
|
||||
AddSearchEvent(itemTitle: "C", searchStatus: SearchCommandStatus.TimedOut);
|
||||
|
||||
var result = await _controller.GetEvents(
|
||||
searchStatus: [SearchCommandStatus.Completed, SearchCommandStatus.Failed]);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvents_WithSearchTypeFilter_ReturnsOnlyMatchingType()
|
||||
{
|
||||
AddSearchEvent(itemTitle: "Proactive Movie", searchType: SeekerSearchType.Proactive);
|
||||
AddSearchEvent(itemTitle: "Replacement Movie", searchType: SeekerSearchType.Replacement);
|
||||
|
||||
var result = await _controller.GetEvents(searchType: SeekerSearchType.Replacement);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
|
||||
body.GetProperty("Items")[0].GetProperty("ItemTitle").GetString().ShouldBe("Replacement Movie");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvents_WithSearchReasonFilter_ReturnsOnlyMatchingReason()
|
||||
{
|
||||
AddSearchEvent(itemTitle: "Missing", searchReason: SeekerSearchReason.Missing);
|
||||
AddSearchEvent(itemTitle: "Cutoff", searchReason: SeekerSearchReason.QualityCutoffNotMet);
|
||||
|
||||
var result = await _controller.GetEvents(searchReason: SeekerSearchReason.QualityCutoffNotMet);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
|
||||
body.GetProperty("Items")[0].GetProperty("ItemTitle").GetString().ShouldBe("Cutoff");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvents_WithGrabbedTrue_KeepsOnlyEventsWithGrabbedItems()
|
||||
{
|
||||
AddSearchEvent(itemTitle: "With Grabs", grabbedItems: ["movie (2024)"]);
|
||||
AddSearchEvent(itemTitle: "No Grabs", grabbedItems: []);
|
||||
|
||||
var result = await _controller.GetEvents(grabbed: true);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
|
||||
body.GetProperty("Items")[0].GetProperty("ItemTitle").GetString().ShouldBe("With Grabs");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvents_WithGrabbedFalse_KeepsOnlyEventsWithoutGrabbedItems()
|
||||
{
|
||||
AddSearchEvent(itemTitle: "With Grabs", grabbedItems: ["movie (2024)"]);
|
||||
AddSearchEvent(itemTitle: "No Grabs", grabbedItems: []);
|
||||
|
||||
var result = await _controller.GetEvents(grabbed: false);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
|
||||
body.GetProperty("Items")[0].GetProperty("ItemTitle").GetString().ShouldBe("No Grabs");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helpers
|
||||
|
||||
private void AddSearchEvent(
|
||||
string? itemTitle = null,
|
||||
SeekerSearchType searchType = SeekerSearchType.Proactive,
|
||||
SeekerSearchReason searchReason = SeekerSearchReason.Missing,
|
||||
List<string>? grabbedItems = null,
|
||||
Guid? arrInstanceId = null,
|
||||
Guid? cycleId = null,
|
||||
SearchCommandStatus? searchStatus = null,
|
||||
DateTime? timestamp = null)
|
||||
{
|
||||
var appEvent = new AppEvent
|
||||
{
|
||||
EventType = EventType.SearchTriggered,
|
||||
Message = "Search triggered",
|
||||
Severity = EventSeverity.Information,
|
||||
ArrInstanceId = arrInstanceId,
|
||||
CycleId = cycleId,
|
||||
SearchStatus = searchStatus,
|
||||
Timestamp = timestamp ?? DateTime.UtcNow
|
||||
};
|
||||
|
||||
if (itemTitle is not null)
|
||||
{
|
||||
appEvent.ItemTitle = itemTitle;
|
||||
appEvent.SearchType = searchType;
|
||||
appEvent.SearchReason = searchReason;
|
||||
appEvent.GrabbedItems = grabbedItems ?? [];
|
||||
}
|
||||
|
||||
_eventsContext.Events.Add(appEvent);
|
||||
_eventsContext.SaveChanges();
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
using Cleanuparr.Api.Features.Seeker.Contracts.Requests;
|
||||
using Cleanuparr.Api.Features.Seeker.Contracts.Responses;
|
||||
using Cleanuparr.Api.Features.Seeker.Controllers;
|
||||
using Cleanuparr.Api.Tests.Features.Seeker.TestHelpers;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Seeker;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using Shouldly;
|
||||
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.Seeker;
|
||||
|
||||
public class SeekerConfigControllerTests : IDisposable
|
||||
{
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly ILogger<SeekerConfigController> _logger;
|
||||
private readonly IJobManagementService _jobManagementService;
|
||||
private readonly SeekerConfigController _controller;
|
||||
|
||||
public SeekerConfigControllerTests()
|
||||
{
|
||||
_dataContext = SeekerTestDataFactory.CreateDataContext();
|
||||
_logger = Substitute.For<ILogger<SeekerConfigController>>();
|
||||
_jobManagementService = Substitute.For<IJobManagementService>();
|
||||
_controller = new SeekerConfigController(_logger, _dataContext, _jobManagementService);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_dataContext.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
#region GetSeekerConfig Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetSeekerConfig_WithNoSeekerInstanceConfigs_ReturnsDefaults()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
|
||||
var result = await _controller.GetSeekerConfig();
|
||||
var okResult = result.ShouldBeOfType<OkObjectResult>();
|
||||
var response = okResult.Value.ShouldBeOfType<SeekerConfigResponse>();
|
||||
|
||||
var instance = response.Instances.ShouldHaveSingleItem();
|
||||
instance.ArrInstanceId.ShouldBe(radarr.Id);
|
||||
instance.Enabled.ShouldBeFalse();
|
||||
instance.SkipTags.ShouldBeEmpty();
|
||||
instance.ActiveDownloadLimit.ShouldBe(3);
|
||||
instance.MinCycleTimeDays.ShouldBe(7);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSeekerConfig_OnlyReturnsSonarrAndRadarrInstances()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
var sonarr = SeekerTestDataFactory.AddSonarrInstance(_dataContext);
|
||||
var lidarr = SeekerTestDataFactory.AddLidarrInstance(_dataContext);
|
||||
|
||||
var result = await _controller.GetSeekerConfig();
|
||||
var okResult = result.ShouldBeOfType<OkObjectResult>();
|
||||
var response = okResult.Value.ShouldBeOfType<SeekerConfigResponse>();
|
||||
|
||||
response.Instances.Count.ShouldBe(2);
|
||||
response.Instances.ShouldContain(i => i.ArrInstanceId == radarr.Id);
|
||||
response.Instances.ShouldContain(i => i.ArrInstanceId == sonarr.Id);
|
||||
response.Instances.ShouldNotContain(i => i.ArrInstanceId == lidarr.Id);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region UpdateSeekerConfig Tests
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSeekerConfig_WithProactiveEnabledAndNoInstancesEnabled_ThrowsValidationException()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
var request = new UpdateSeekerConfigRequest
|
||||
{
|
||||
SearchEnabled = true,
|
||||
SearchInterval = 5,
|
||||
ProactiveSearchEnabled = true,
|
||||
Instances =
|
||||
[
|
||||
new UpdateSeekerInstanceConfigRequest
|
||||
{
|
||||
ArrInstanceId = radarr.Id,
|
||||
Enabled = false // No instances enabled
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
await Should.ThrowAsync<ValidationException>(() => _controller.UpdateSeekerConfig(request));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSeekerConfig_WhenIntervalChanges_ReschedulesSeeker()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
_dataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
|
||||
{
|
||||
ArrInstanceId = radarr.Id,
|
||||
Enabled = true
|
||||
});
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
// Default interval is 3, change to 5
|
||||
var request = new UpdateSeekerConfigRequest
|
||||
{
|
||||
SearchEnabled = true,
|
||||
SearchInterval = 5,
|
||||
ProactiveSearchEnabled = true,
|
||||
Instances =
|
||||
[
|
||||
new UpdateSeekerInstanceConfigRequest { ArrInstanceId = radarr.Id, Enabled = true }
|
||||
]
|
||||
};
|
||||
|
||||
await _controller.UpdateSeekerConfig(request);
|
||||
|
||||
await _jobManagementService.Received(1)
|
||||
.StartJob(JobType.Seeker, null, Arg.Any<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSeekerConfig_WhenIntervalUnchanged_DoesNotReschedule()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
_dataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
|
||||
{
|
||||
ArrInstanceId = radarr.Id,
|
||||
Enabled = true
|
||||
});
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
// Keep interval at default (10)
|
||||
var request = new UpdateSeekerConfigRequest
|
||||
{
|
||||
SearchEnabled = true,
|
||||
SearchInterval = 10,
|
||||
ProactiveSearchEnabled = true,
|
||||
Instances =
|
||||
[
|
||||
new UpdateSeekerInstanceConfigRequest { ArrInstanceId = radarr.Id, Enabled = true }
|
||||
]
|
||||
};
|
||||
|
||||
await _controller.UpdateSeekerConfig(request);
|
||||
|
||||
await _jobManagementService.DidNotReceive()
|
||||
.StartJob(Arg.Any<JobType>(), null, Arg.Any<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSeekerConfig_WhenCustomFormatScoreEnabled_StartsAndTriggersSyncerJob()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
_dataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
|
||||
{
|
||||
ArrInstanceId = radarr.Id,
|
||||
Enabled = true
|
||||
});
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
// UseCustomFormatScore was false (default), now enable it on the instance
|
||||
var request = new UpdateSeekerConfigRequest
|
||||
{
|
||||
SearchEnabled = true,
|
||||
SearchInterval = 3,
|
||||
ProactiveSearchEnabled = true,
|
||||
Instances =
|
||||
[
|
||||
new UpdateSeekerInstanceConfigRequest { ArrInstanceId = radarr.Id, Enabled = true, UseCustomFormatScore = true }
|
||||
]
|
||||
};
|
||||
|
||||
await _controller.UpdateSeekerConfig(request);
|
||||
|
||||
await _jobManagementService.Received(1)
|
||||
.StartJob(JobType.CustomFormatScoreSyncer, null, Arg.Any<string>());
|
||||
await _jobManagementService.Received(1)
|
||||
.TriggerJobOnce(JobType.CustomFormatScoreSyncer);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSeekerConfig_WhenCustomFormatScoreDisabled_StopsSyncerJob()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
_dataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
|
||||
{
|
||||
ArrInstanceId = radarr.Id,
|
||||
Enabled = true,
|
||||
UseCustomFormatScore = true
|
||||
});
|
||||
|
||||
// Syncer was running: both proactive and CF score were enabled
|
||||
var config = await _dataContext.SeekerConfigs.FirstAsync();
|
||||
config.ProactiveSearchEnabled = true;
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
// Disable CF score — syncer conditions no longer met
|
||||
var request = new UpdateSeekerConfigRequest
|
||||
{
|
||||
SearchEnabled = true,
|
||||
SearchInterval = 3,
|
||||
ProactiveSearchEnabled = true,
|
||||
Instances =
|
||||
[
|
||||
new UpdateSeekerInstanceConfigRequest { ArrInstanceId = radarr.Id, Enabled = true, UseCustomFormatScore = false }
|
||||
]
|
||||
};
|
||||
|
||||
await _controller.UpdateSeekerConfig(request);
|
||||
|
||||
await _jobManagementService.Received(1)
|
||||
.StopJob(JobType.CustomFormatScoreSyncer);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSeekerConfig_WhenProactiveSearchDisabled_StopsSyncerJob()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
_dataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
|
||||
{
|
||||
ArrInstanceId = radarr.Id,
|
||||
Enabled = true,
|
||||
UseCustomFormatScore = true
|
||||
});
|
||||
|
||||
// Syncer was running: both proactive and CF score were enabled
|
||||
var config = await _dataContext.SeekerConfigs.FirstAsync();
|
||||
config.ProactiveSearchEnabled = true;
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
// Disable proactive search — syncer should stop even though CF score is still enabled
|
||||
var request = new UpdateSeekerConfigRequest
|
||||
{
|
||||
SearchEnabled = true,
|
||||
SearchInterval = 3,
|
||||
ProactiveSearchEnabled = false,
|
||||
Instances =
|
||||
[
|
||||
new UpdateSeekerInstanceConfigRequest { ArrInstanceId = radarr.Id, Enabled = true, UseCustomFormatScore = true }
|
||||
]
|
||||
};
|
||||
|
||||
await _controller.UpdateSeekerConfig(request);
|
||||
|
||||
await _jobManagementService.Received(1)
|
||||
.StopJob(JobType.CustomFormatScoreSyncer);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSeekerConfig_WhenProactiveSearchEnabled_WithCfScoreActive_StartsAndTriggersSyncer()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
_dataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
|
||||
{
|
||||
ArrInstanceId = radarr.Id,
|
||||
Enabled = true,
|
||||
UseCustomFormatScore = true
|
||||
});
|
||||
|
||||
// Syncer was NOT running: CF score enabled but proactive was off (default)
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
// Enable proactive search — syncer should start
|
||||
var request = new UpdateSeekerConfigRequest
|
||||
{
|
||||
SearchEnabled = true,
|
||||
SearchInterval = 3,
|
||||
ProactiveSearchEnabled = true,
|
||||
Instances =
|
||||
[
|
||||
new UpdateSeekerInstanceConfigRequest { ArrInstanceId = radarr.Id, Enabled = true, UseCustomFormatScore = true }
|
||||
]
|
||||
};
|
||||
|
||||
await _controller.UpdateSeekerConfig(request);
|
||||
|
||||
await _jobManagementService.Received(1)
|
||||
.StartJob(JobType.CustomFormatScoreSyncer, null, Arg.Any<string>());
|
||||
await _jobManagementService.Received(1)
|
||||
.TriggerJobOnce(JobType.CustomFormatScoreSyncer);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSeekerConfig_WhenCustomFormatScoreEnabledButProactiveDisabled_DoesNotStartSyncer()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
_dataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
|
||||
{
|
||||
ArrInstanceId = radarr.Id,
|
||||
Enabled = true,
|
||||
UseCustomFormatScore = false
|
||||
});
|
||||
|
||||
// ProactiveSearchEnabled stays false (default)
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
// Enable CF score but keep proactive disabled — syncer should NOT start
|
||||
var request = new UpdateSeekerConfigRequest
|
||||
{
|
||||
SearchEnabled = true,
|
||||
SearchInterval = 3,
|
||||
ProactiveSearchEnabled = false,
|
||||
Instances =
|
||||
[
|
||||
new UpdateSeekerInstanceConfigRequest { ArrInstanceId = radarr.Id, Enabled = true, UseCustomFormatScore = true }
|
||||
]
|
||||
};
|
||||
|
||||
await _controller.UpdateSeekerConfig(request);
|
||||
|
||||
await _jobManagementService.DidNotReceive()
|
||||
.StartJob(JobType.CustomFormatScoreSyncer, null, Arg.Any<string>());
|
||||
await _jobManagementService.DidNotReceive()
|
||||
.TriggerJobOnce(JobType.CustomFormatScoreSyncer);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSeekerConfig_SyncsExistingAndCreatesNewInstanceConfigs()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
var sonarr = SeekerTestDataFactory.AddSonarrInstance(_dataContext);
|
||||
|
||||
// Radarr already has a config
|
||||
_dataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
|
||||
{
|
||||
ArrInstanceId = radarr.Id,
|
||||
Enabled = false,
|
||||
SkipTags = ["old-tag"],
|
||||
ActiveDownloadLimit = 2,
|
||||
MinCycleTimeDays = 5
|
||||
});
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
var request = new UpdateSeekerConfigRequest
|
||||
{
|
||||
SearchEnabled = true,
|
||||
SearchInterval = 3,
|
||||
ProactiveSearchEnabled = true,
|
||||
Instances =
|
||||
[
|
||||
// Update existing radarr config
|
||||
new UpdateSeekerInstanceConfigRequest
|
||||
{
|
||||
ArrInstanceId = radarr.Id,
|
||||
Enabled = true,
|
||||
SkipTags = ["new-tag"],
|
||||
ActiveDownloadLimit = 5,
|
||||
MinCycleTimeDays = 14
|
||||
},
|
||||
// Create new sonarr config
|
||||
new UpdateSeekerInstanceConfigRequest
|
||||
{
|
||||
ArrInstanceId = sonarr.Id,
|
||||
Enabled = true,
|
||||
SkipTags = ["sonarr-tag"],
|
||||
ActiveDownloadLimit = 3,
|
||||
MinCycleTimeDays = 7
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
await _controller.UpdateSeekerConfig(request);
|
||||
|
||||
var configs = await _dataContext.SeekerInstanceConfigs.ToListAsync();
|
||||
configs.Count.ShouldBe(2);
|
||||
|
||||
var radarrConfig = configs.First(c => c.ArrInstanceId == radarr.Id);
|
||||
radarrConfig.Enabled.ShouldBeTrue();
|
||||
radarrConfig.SkipTags.ShouldContain("new-tag");
|
||||
radarrConfig.ActiveDownloadLimit.ShouldBe(5);
|
||||
radarrConfig.MinCycleTimeDays.ShouldBe(14);
|
||||
|
||||
var sonarrConfig = configs.First(c => c.ArrInstanceId == sonarr.Id);
|
||||
sonarrConfig.Enabled.ShouldBeTrue();
|
||||
sonarrConfig.SkipTags.ShouldContain("sonarr-tag");
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
|
||||
using Cleanuparr.Persistence.Models.Configuration.General;
|
||||
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
|
||||
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Seeker;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.Seeker.TestHelpers;
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating SQLite in-memory contexts for Seeker controller tests
|
||||
/// </summary>
|
||||
public static class SeekerTestDataFactory
|
||||
{
|
||||
public static DataContext CreateDataContext()
|
||||
{
|
||||
var connection = new SqliteConnection("DataSource=:memory:");
|
||||
connection.Open();
|
||||
|
||||
var options = new DbContextOptionsBuilder<DataContext>()
|
||||
.UseSqlite(connection)
|
||||
.UseLowerCaseNamingConvention()
|
||||
.UseSnakeCaseNamingConvention()
|
||||
.Options;
|
||||
|
||||
var context = new DataContext(options);
|
||||
context.Database.EnsureCreated();
|
||||
|
||||
SeedDefaultData(context);
|
||||
return context;
|
||||
}
|
||||
|
||||
public static EventsContext CreateEventsContext()
|
||||
{
|
||||
var connection = new SqliteConnection("DataSource=:memory:");
|
||||
connection.Open();
|
||||
|
||||
var options = new DbContextOptionsBuilder<EventsContext>()
|
||||
.UseSqlite(connection)
|
||||
.UseLowerCaseNamingConvention()
|
||||
.UseSnakeCaseNamingConvention()
|
||||
.Options;
|
||||
|
||||
var context = new EventsContext(options);
|
||||
context.Database.EnsureCreated();
|
||||
return context;
|
||||
}
|
||||
|
||||
private static void SeedDefaultData(DataContext context)
|
||||
{
|
||||
context.GeneralConfigs.Add(new GeneralConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
DryRun = false,
|
||||
IgnoredDownloads = [],
|
||||
Log = new LoggingConfig()
|
||||
});
|
||||
|
||||
context.ArrConfigs.AddRange(
|
||||
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Sonarr, Instances = [], FailedImportMaxStrikes = 3 },
|
||||
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Radarr, Instances = [], FailedImportMaxStrikes = 3 },
|
||||
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Lidarr, Instances = [], FailedImportMaxStrikes = 3 },
|
||||
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Readarr, Instances = [], FailedImportMaxStrikes = 3 },
|
||||
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Whisparr, Instances = [], FailedImportMaxStrikes = 3 }
|
||||
);
|
||||
|
||||
context.QueueCleanerConfigs.Add(new QueueCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
IgnoredDownloads = [],
|
||||
FailedImport = new FailedImportConfig()
|
||||
});
|
||||
|
||||
context.ContentBlockerConfigs.Add(new ContentBlockerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
IgnoredDownloads = [],
|
||||
DeletePrivate = false,
|
||||
Sonarr = new BlocklistSettings { Enabled = false },
|
||||
Radarr = new BlocklistSettings { Enabled = false },
|
||||
Lidarr = new BlocklistSettings { Enabled = false },
|
||||
Readarr = new BlocklistSettings { Enabled = false },
|
||||
Whisparr = new BlocklistSettings { Enabled = false }
|
||||
});
|
||||
|
||||
context.DownloadCleanerConfigs.Add(new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
IgnoredDownloads = []
|
||||
});
|
||||
|
||||
context.SeekerConfigs.Add(new SeekerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
SearchEnabled = true,
|
||||
ProactiveSearchEnabled = false
|
||||
});
|
||||
|
||||
context.SaveChanges();
|
||||
}
|
||||
|
||||
public static ArrInstance AddSonarrInstance(DataContext context, bool enabled = true)
|
||||
{
|
||||
var arrConfig = context.ArrConfigs.First(x => x.Type == InstanceType.Sonarr);
|
||||
var instance = new ArrInstance
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Test Sonarr",
|
||||
Url = new Uri("http://sonarr:8989"),
|
||||
ApiKey = "test-api-key",
|
||||
Enabled = enabled,
|
||||
ArrConfigId = arrConfig.Id,
|
||||
ArrConfig = arrConfig
|
||||
};
|
||||
|
||||
arrConfig.Instances.Add(instance);
|
||||
context.ArrInstances.Add(instance);
|
||||
context.SaveChanges();
|
||||
return instance;
|
||||
}
|
||||
|
||||
public static ArrInstance AddRadarrInstance(DataContext context, bool enabled = true)
|
||||
{
|
||||
var arrConfig = context.ArrConfigs.First(x => x.Type == InstanceType.Radarr);
|
||||
var instance = new ArrInstance
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Test Radarr",
|
||||
Url = new Uri("http://radarr:7878"),
|
||||
ApiKey = "test-api-key",
|
||||
Enabled = enabled,
|
||||
ArrConfigId = arrConfig.Id,
|
||||
ArrConfig = arrConfig
|
||||
};
|
||||
|
||||
arrConfig.Instances.Add(instance);
|
||||
context.ArrInstances.Add(instance);
|
||||
context.SaveChanges();
|
||||
return instance;
|
||||
}
|
||||
|
||||
public static ArrInstance AddLidarrInstance(DataContext context, bool enabled = true)
|
||||
{
|
||||
var arrConfig = context.ArrConfigs.First(x => x.Type == InstanceType.Lidarr);
|
||||
var instance = new ArrInstance
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Test Lidarr",
|
||||
Url = new Uri("http://lidarr:8686"),
|
||||
ApiKey = "test-api-key",
|
||||
Enabled = enabled,
|
||||
ArrConfigId = arrConfig.Id,
|
||||
ArrConfig = arrConfig
|
||||
};
|
||||
|
||||
arrConfig.Instances.Add(instance);
|
||||
context.ArrInstances.Add(instance);
|
||||
context.SaveChanges();
|
||||
return instance;
|
||||
}
|
||||
}
|
||||