Compare commits
484
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2517baedea | ||
|
|
2117ec6181 | ||
|
|
fd6f0e3353 | ||
|
|
9bc149f3d9 | ||
|
|
f981e72ff8 | ||
|
|
199d82c613 | ||
|
|
0914c935d9 | ||
|
|
5a2c1585ae | ||
|
|
7a604c2fec | ||
|
|
be10adc24b | ||
|
|
6457fbaf72 | ||
|
|
8de8883acf | ||
|
|
0a7fbd6f48 | ||
|
|
df556d8208 | ||
|
|
d95814ded3 | ||
|
|
17274bb868 | ||
|
|
f42720ac64 | ||
|
|
320f31086d | ||
|
|
dc353b0f3f | ||
|
|
2a6e4b9ae5 | ||
|
|
505fc48b23 | ||
|
|
57d83fd9af | ||
|
|
0772f06d25 | ||
|
|
1601548e6c | ||
|
|
740213f0a4 | ||
|
|
bc6ce41762 | ||
|
|
f3c9d4b4e8 | ||
|
|
300866ca67 | ||
|
|
431fec05ad | ||
|
|
97fbdf19fb | ||
|
|
a36cee4f85 | ||
|
|
14947608be | ||
|
|
03a6040360 | ||
|
|
93a468b435 | ||
|
|
bb3aef517c | ||
|
|
cbdddc095e | ||
|
|
5a0d9e0e17 | ||
|
|
19619ef817 | ||
|
|
a28ae5c88d | ||
|
|
1c209408cc | ||
|
|
93f020f2b5 | ||
|
|
a3ecac3507 | ||
|
|
db3b5b3d62 | ||
|
|
f69e659d94 | ||
|
|
dbeefb395c | ||
|
|
8acaeddda5 | ||
|
|
361fdaab39 | ||
|
|
4d1fb05e49 | ||
|
|
679e0f9116 | ||
|
|
9cd6356892 | ||
|
|
4f05a232fe | ||
|
|
69516ae165 | ||
|
|
f24a2d6c22 | ||
|
|
da13898046 | ||
|
|
1a2d42a244 | ||
|
|
208fc2c4d6 | ||
|
|
18969a0f01 | ||
|
|
843f35bbfc | ||
|
|
7ce73213ae | ||
|
|
31f939002f | ||
|
|
f83b36167e | ||
|
|
d5d8e3e173 | ||
|
|
44c0e5640f | ||
|
|
e7ccd7b222 | ||
|
|
b1de0da512 | ||
|
|
25d44452c0 | ||
|
|
b8db1d109c | ||
|
|
bbecd1e5ec | ||
|
|
ffe690691b | ||
|
|
0708a1f414 | ||
|
|
c8e85817aa | ||
|
|
f45adc6362 | ||
|
|
5275f2ba38 | ||
|
|
0b83db8fce | ||
|
|
4b5e193d9f | ||
|
|
b9f797789f | ||
|
|
6aaefe210a | ||
|
|
fb21d5249c | ||
|
|
b03ed9a2f2 | ||
|
|
72839921f2 | ||
|
|
25981fc762 | ||
|
|
f2629942e4 | ||
|
|
ad1ad11fb5 | ||
|
|
66dd6c8096 | ||
|
|
1205526422 | ||
|
|
29ac420876 | ||
|
|
5ffa371963 | ||
|
|
0be70f0051 | ||
|
|
f07dfbfe74 | ||
|
|
b4b1deafb8 | ||
|
|
217f411f74 | ||
|
|
4fde2a6d83 | ||
|
|
3d88f0b960 | ||
|
|
6aea6a4e2c | ||
|
|
b10bf3e8c7 | ||
|
|
8c3273a0fc | ||
|
|
aec226af1a | ||
|
|
1c1f5cf1bb | ||
|
|
9e4cc4efd0 | ||
|
|
7b0b2793a6 | ||
|
|
077afaca56 | ||
|
|
388b62b277 | ||
|
|
5da97b2e5d | ||
|
|
45e3a256e2 | ||
|
|
64ef691d64 | ||
|
|
a4dbe79d0b | ||
|
|
1002cbca93 | ||
|
|
65f6bdf075 | ||
|
|
4faa217822 | ||
|
|
5cdfe5a8dd | ||
|
|
1cd8557b58 | ||
|
|
b4c956aed4 | ||
|
|
7caa46c2da | ||
|
|
5329bf35a9 | ||
|
|
80e5d5b0d1 | ||
|
|
b3a429d7e1 | ||
|
|
d8179bb066 | ||
|
|
960305dfa9 | ||
|
|
16bbeec5a3 | ||
|
|
39f9c39360 | ||
|
|
011b6d14e4 | ||
|
|
d4661e7f04 | ||
|
|
27a93ba2ea | ||
|
|
7afad5dcde | ||
|
|
929a862e74 | ||
|
|
0e55dafb1e | ||
|
|
cc8084a713 | ||
|
|
3ab294bf81 | ||
|
|
e944f11415 | ||
|
|
708598687e | ||
|
|
aac7c72900 | ||
|
|
fe5ab9e5f8 | ||
|
|
249b1dfe93 | ||
|
|
17216e9cc7 | ||
|
|
841db91a29 | ||
|
|
1eb9c92d6a | ||
|
|
1fa8ee3a59 | ||
|
|
72d167005f | ||
|
|
bf2c2825ac | ||
|
|
cd119a74da | ||
|
|
14445a7c3f | ||
|
|
e6aceae0da | ||
|
|
44b98f3fc3 | ||
|
|
61681aac23 | ||
|
|
7a9a66fcc5 | ||
|
|
2df3ecbf36 | ||
|
|
9da193e718 | ||
|
|
7f64b92b9d | ||
|
|
b9645752f5 | ||
|
|
9c8337d0a8 | ||
|
|
da1f689263 | ||
|
|
298330a3a8 | ||
|
|
0614795ca2 | ||
|
|
6aa958c78f | ||
|
|
3514b4dc1c | ||
|
|
ae87c0cd6a | ||
|
|
b6f507b9e5 | ||
|
|
ce3ae31ced | ||
|
|
75c1d9bb95 | ||
|
|
905218dd4b | ||
|
|
94dad5e089 | ||
|
|
2366faccce | ||
|
|
e40530824e | ||
|
|
5a6c361c11 | ||
|
|
7b1c809ae3 | ||
|
|
f7c4dfe2e8 | ||
|
|
b1eda696f1 | ||
|
|
27b3dcb69f | ||
|
|
5c5593644d | ||
|
|
9e516b96cc | ||
|
|
44735554f7 | ||
|
|
311049379d | ||
|
|
f65db5ab4f | ||
|
|
7ddcdd0756 | ||
|
|
b927ed9988 | ||
|
|
e2953c4a01 | ||
|
|
d429534b12 | ||
|
|
f70579b4fc | ||
|
|
1f5c5a28bd | ||
|
|
d4c52e9a6a | ||
|
|
acbb6c7981 | ||
|
|
91c8be8562 | ||
|
|
6859f81144 | ||
|
|
078c4b1f3f | ||
|
|
c49a9605ff | ||
|
|
df90938c5c | ||
|
|
ffcdab36a0 | ||
|
|
44a0f8c7a4 | ||
|
|
8ca95db08a | ||
|
|
9a60196f1d | ||
|
|
7274b5196c | ||
|
|
868003331c | ||
|
|
e310fa6a9d | ||
|
|
e35d394736 | ||
|
|
2a7ac881fd | ||
|
|
26f1f98736 | ||
|
|
54d0550dec | ||
|
|
995ccb50bb | ||
|
|
4ab947f7db | ||
|
|
f07c93e582 | ||
|
|
178fb2eb37 | ||
|
|
dafcadd211 | ||
|
|
50ec2bebf2 | ||
|
|
6e9b3528d8 | ||
|
|
97e546b2a4 | ||
|
|
1585aa1073 | ||
|
|
f6e257e497 | ||
|
|
0670d904a0 | ||
|
|
922336c064 | ||
|
|
243d369d21 | ||
|
|
e500039d1b | ||
|
|
94be6edceb | ||
|
|
1a07635605 | ||
|
|
206db93587 | ||
|
|
fd4c357d39 | ||
|
|
dc68d03360 | ||
|
|
6cd0fb226a | ||
|
|
f283903e2b | ||
|
|
34a27a5951 | ||
|
|
1905feceea | ||
|
|
6fc4107e3c | ||
|
|
5e73b0a4f6 | ||
|
|
eb09a5d939 | ||
|
|
a05b0167ad | ||
|
|
40d70513da | ||
|
|
bd7d71a284 | ||
|
|
24ea9d8a38 | ||
|
|
a962342ee0 | ||
|
|
14d1a158a0 | ||
|
|
612f888683 | ||
|
|
ba95cc3e8b | ||
|
|
03cb4c7869 | ||
|
|
8004cb81a5 | ||
|
|
77990d0dc0 | ||
|
|
0c39e04e4a | ||
|
|
8599e5c250 | ||
|
|
830741cd65 | ||
|
|
48af5d3e14 | ||
|
|
5584bdefb5 | ||
|
|
60a94df8e1 | ||
|
|
9b171a0060 | ||
|
|
b7a9b470a7 | ||
|
|
f5d9578375 | ||
|
|
04e2a069d6 | ||
|
|
05c7431d86 | ||
|
|
9a62c10803 | ||
|
|
8d65e03555 | ||
|
|
ab465d8e0e | ||
|
|
1461273162 | ||
|
|
598d70f735 | ||
|
|
537330e7e0 | ||
|
|
885f6d8187 | ||
|
|
936a48405b | ||
|
|
757398bf15 | ||
|
|
d12911d3c8 | ||
|
|
457a38306d | ||
|
|
bc47450653 | ||
|
|
89ea310414 | ||
|
|
4b152bbe4e | ||
|
|
64ad10b3f2 | ||
|
|
a42232e67a | ||
|
|
006cc0c2c9 | ||
|
|
706be09dec | ||
|
|
a87c8afc22 | ||
|
|
2976dad4e5 | ||
|
|
acc9d08c60 | ||
|
|
e09c55cecc | ||
|
|
6f1c13998c | ||
|
|
0a0caa9533 | ||
|
|
4716790564 | ||
|
|
8e2c1a4b3a | ||
|
|
4a870a7f18 | ||
|
|
40dabfd788 | ||
|
|
ef3c05714a | ||
|
|
816ee0ec80 | ||
|
|
73a2852ee3 | ||
|
|
f67232a20b | ||
|
|
7b465ac97e | ||
|
|
033a012069 | ||
|
|
9165c4eb3f | ||
|
|
9ef6c5b6ed | ||
|
|
987ece38f0 | ||
|
|
c5c2270007 | ||
|
|
b23f6b0bab | ||
|
|
8ea70ea7f8 | ||
|
|
07578e0206 | ||
|
|
f402a3ee03 | ||
|
|
f3f908eb7e | ||
|
|
94a4facc6c | ||
|
|
11617c1860 | ||
|
|
d0040fe428 | ||
|
|
59d5de3607 | ||
|
|
fffe0b17e6 | ||
|
|
451aa48aec | ||
|
|
5ac407057e | ||
|
|
bf83492bf7 | ||
|
|
33c69e7c71 | ||
|
|
c2f72ca785 | ||
|
|
ca8c592496 | ||
|
|
1f5b0d0164 | ||
|
|
13cc689bff | ||
|
|
9920fd47b9 | ||
|
|
26f695f480 | ||
|
|
05370d236a | ||
|
|
114a4574b0 | ||
|
|
3c9e4d8126 | ||
|
|
e7c4c931ee | ||
|
|
bac84e24ec | ||
|
|
de8f71b2be | ||
|
|
1129fcae6f | ||
|
|
76c6826920 | ||
|
|
75b33fdae6 | ||
|
|
635a9439cb | ||
|
|
3b15766149 | ||
|
|
2a15effc3e | ||
|
|
78176c57a5 | ||
|
|
980aaee0d9 | ||
|
|
acfb298b74 | ||
|
|
ce3ab8bcbc | ||
|
|
6d44ae884c | ||
|
|
f6d98dd7cc | ||
|
|
e584200369 | ||
|
|
9bd23fc1d7 | ||
|
|
762181c123 | ||
|
|
d11a623f9f | ||
|
|
caaf27427e | ||
|
|
423a1c0bf7 | ||
|
|
b1d2ccc87c | ||
|
|
3170aa6b77 | ||
|
|
f75d68bf66 | ||
|
|
f37de6c07c | ||
|
|
cea8e64da2 | ||
|
|
307a43f6b0 | ||
|
|
d5018936a0 | ||
|
|
3a54dda5c5 | ||
|
|
3c2a504747 | ||
|
|
b0dc0c591e | ||
|
|
0f225c39b9 | ||
|
|
bc59159cde | ||
|
|
f57d64563b | ||
|
|
1f8c3c21ae | ||
|
|
5d22021e8e | ||
|
|
8e4412544d | ||
|
|
d764f820b2 | ||
|
|
39a2829cc8 | ||
|
|
795f10d2af | ||
|
|
44f38803dc | ||
|
|
b2b1804aa3 | ||
|
|
c82f20c3f2 | ||
|
|
5a76fed099 | ||
|
|
342c2f88b2 | ||
|
|
854a888306 | ||
|
|
a4b91393a6 | ||
|
|
0af2ee4951 | ||
|
|
d78a182f0d | ||
|
|
13260d8c1f | ||
|
|
6fcd7f4400 | ||
|
|
6e8986bea5 | ||
|
|
87da5c3cf5 | ||
|
|
fb9427a864 | ||
|
|
0af940c7b3 | ||
|
|
f8745da6b2 | ||
|
|
604a2458e9 | ||
|
|
666b72c4fd | ||
|
|
7221906d53 | ||
|
|
0a9cde0fc8 | ||
|
|
d3a510d2e0 | ||
|
|
a6f5d8d693 | ||
|
|
6a352a6afb | ||
|
|
e7a4f0f758 | ||
|
|
64d3e8d272 | ||
|
|
8e48aa4334 | ||
|
|
2fa3764df5 | ||
|
|
faef4d9fff | ||
|
|
a2fc2613b4 | ||
|
|
f23ee6e66a | ||
|
|
73fc609d7b | ||
|
|
5dca78789d | ||
|
|
37e092c2a8 | ||
|
|
4f7794767d | ||
|
|
d61abb1be1 | ||
|
|
1afc202ba9 | ||
|
|
5caebcfe45 | ||
|
|
b1e85dca79 | ||
|
|
b8dc4e87d5 | ||
|
|
0855dd7b3b | ||
|
|
d0460885ff | ||
|
|
ced90cd1f4 | ||
|
|
4b43bd04ba | ||
|
|
553a1dc19b | ||
|
|
f3cacd1b16 | ||
|
|
b829b7fe22 | ||
|
|
d42d09b8db | ||
|
|
71832bee24 | ||
|
|
504f145f64 | ||
|
|
46e0744802 | ||
|
|
a93be75408 | ||
|
|
dc07a2f19f | ||
|
|
fa38ca0d1b | ||
|
|
ae2e03d499 | ||
|
|
65c08fb523 | ||
|
|
0680e051c2 | ||
|
|
03156e7866 | ||
|
|
4f2e1e36bf | ||
|
|
36b2afeed7 | ||
|
|
b088ec97f6 | ||
|
|
a92739d210 | ||
|
|
f199918775 | ||
|
|
a3504f1ae5 | ||
|
|
355ae5f7a9 | ||
|
|
ddc14e3314 | ||
|
|
a57694b738 | ||
|
|
3bcae0abf5 | ||
|
|
cce7ad4907 | ||
|
|
c5cda015b3 | ||
|
|
b83e319a34 | ||
|
|
004b761381 | ||
|
|
9405afa2c5 | ||
|
|
d48404e800 | ||
|
|
ed68f92f4a | ||
|
|
607ce8ce3a | ||
|
|
ff0517d038 | ||
|
|
e14bbeabdd | ||
|
|
a9f8e23ddd | ||
|
|
540fb147d2 | ||
|
|
ffaa561cb7 | ||
|
|
4305c77df4 | ||
|
|
1da7765466 | ||
|
|
5da91b9c7c | ||
|
|
f87fc45377 | ||
|
|
5be6fec408 | ||
|
|
55a9ec00e1 | ||
|
|
c592c745c3 | ||
|
|
e5726e12be | ||
|
|
f589bedad5 | ||
|
|
27e9a654bf | ||
|
|
caf50ade31 | ||
|
|
dd1ddff08d | ||
|
|
1863fb3380 | ||
|
|
bc58103c62 | ||
|
|
37f84dd3ea | ||
|
|
2736409a79 | ||
|
|
94102af4d7 | ||
|
|
cb214b16b2 | ||
|
|
4fe36ba0a5 | ||
|
|
283b2f2ed7 | ||
|
|
f699899408 | ||
|
|
e87f481988 | ||
|
|
d68f72f21b | ||
|
|
d400377474 | ||
|
|
7cd88b6107 | ||
|
|
d38804e910 | ||
|
|
192c978a38 | ||
|
|
44ec2f496a | ||
|
|
d3ddecb840 | ||
|
|
da1732285f | ||
|
|
0269403a5d | ||
|
|
a4962a87b2 | ||
|
|
35c8ffe33e | ||
|
|
70d8ffd528 | ||
|
|
3a3fa8763e | ||
|
|
4119a5e38e | ||
|
|
86f44230c2 | ||
|
|
3c3c4e8bf5 | ||
|
|
133ca1fdaa | ||
|
|
c7f0eb406a | ||
|
|
2fdf894216 | ||
|
|
17f2dc3120 | ||
|
|
a06c85e72a | ||
|
|
e389df92c3 | ||
|
|
ad27902790 | ||
|
|
855ef161d8 | ||
|
|
a6700f73e0 | ||
|
|
8baecad379 | ||
|
|
9ccff320a1 | ||
|
|
d8d6334052 | ||
|
|
5b2d105609 | ||
|
|
18811cea1b | ||
|
|
3e73a582ba | ||
|
|
8aeae33167 | ||
|
|
3cecb04e8d | ||
|
|
6820208a36 | ||
|
|
77cbbf600b | ||
|
|
4e326dfc86 |
No files matched your search
@@ -20,6 +20,12 @@ logs/
|
|||||||
|
|
||||||
# Dependencies (install fresh in container)
|
# Dependencies (install fresh in container)
|
||||||
node_modules/
|
node_modules/
|
||||||
|
# Local-only lockfile: a stale one pins old foliate-js commits into builds
|
||||||
|
# (npm reuses name@version without re-resolving the git pin)
|
||||||
|
package-lock.json
|
||||||
|
|
||||||
# Environment
|
# Environment
|
||||||
.env
|
.env
|
||||||
|
|
||||||
|
# Media uploads (bind-mounted at runtime)
|
||||||
|
uploads/
|
||||||
@@ -10,6 +10,21 @@ JWT_SECRET=your-secure-jwt-secret-key-here
|
|||||||
# Generate with: openssl rand -hex 16
|
# Generate with: openssl rand -hex 16
|
||||||
DBPASS=your-secure-database-password-here
|
DBPASS=your-secure-database-password-here
|
||||||
|
|
||||||
|
# Networking: change a port if it conflicts on your host
|
||||||
|
# Postgres port, host + container (e.g. another local DB already uses 5432)
|
||||||
|
# DB_PORT=15432
|
||||||
|
# App web port, host + container
|
||||||
|
# SERVER_PORT=8765
|
||||||
|
|
||||||
|
# Deployment
|
||||||
|
# External URL for device sync (must include protocol; defaults to http://localhost:8765)
|
||||||
|
# Examples: https://bookhoard.example.com | http://192.168.1.10:8765
|
||||||
|
# BASE_URL=https://bookhoard.example.com
|
||||||
|
# Mark session cookies Secure — set true behind a TLS-terminating reverse proxy (Caddy/nginx/traefik)
|
||||||
|
# COOKIE_SECURE=true
|
||||||
|
# Pin or rollback a specific published image version (defaults to "latest")
|
||||||
|
# IMAGE_TAG=1.0.0
|
||||||
|
|
||||||
# Optional: Override Defaults (defaults are set in docker-compose.yml)
|
# Optional: Override Defaults (defaults are set in docker-compose.yml)
|
||||||
# Test Mode: WARNING - Only set to true for integration testing
|
# Test Mode: WARNING - Only set to true for integration testing
|
||||||
# TEST_MODE=true
|
# TEST_MODE=true
|
||||||
@@ -20,3 +35,9 @@ DBPASS=your-secure-database-password-here
|
|||||||
# BOOKHOARD_CONVERSION_TOOL=/usr/bin/ebook-convert
|
# BOOKHOARD_CONVERSION_TOOL=/usr/bin/ebook-convert
|
||||||
# Conversion Cache TTL: Override default 24h
|
# Conversion Cache TTL: Override default 24h
|
||||||
# BOOKHOARD_CONVERSION_CACHE_TTL=48h
|
# BOOKHOARD_CONVERSION_CACHE_TTL=48h
|
||||||
|
# Archive Retention: days to keep archived items (files missing from disk for
|
||||||
|
# 2+ scans) before they are purged, deleting their reading history with them.
|
||||||
|
# Set 0 to keep archived items until purged manually on the library admin page.
|
||||||
|
# ARCHIVE_RETENTION_DAYS=90
|
||||||
|
# System timezone (fallback for server-side time operations, defaults to UTC)
|
||||||
|
# TZ=America/New_York
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
# Overrides the default run name (the tagged commit's message) so the Actions
|
||||||
|
# runs list shows "Release v0.3.0" instead.
|
||||||
|
run-name: "Release ${{ gitea.event.inputs.tag || gitea.ref_name }}"
|
||||||
|
|
||||||
|
# Publishes the Bookhoard container image to the Gitea container registry AND
|
||||||
|
# creates a Gitea Release whose body is the annotated tag's message (generated
|
||||||
|
# locally by `make release VERSION=...` via git-cliff). Triggered by a version
|
||||||
|
# tag push, or manually via workflow_dispatch with a tag. Pushing to main does
|
||||||
|
# nothing, so work-in-progress commits never ship. Each release publishes two
|
||||||
|
# image tags: the version (e.g. v0.3.0) and "latest".
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: 'Tag to release (e.g. v0.3.0)'
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
env:
|
||||||
|
# Resolve the target tag for both triggers: explicit input on manual
|
||||||
|
# dispatch, otherwise the pushed tag ref.
|
||||||
|
TAG: ${{ gitea.event.inputs.tag || gitea.ref_name }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
# Full history ensures the tag annotation (the release notes) is present.
|
||||||
|
fetch-depth: 0
|
||||||
|
ref: ${{ gitea.event.inputs.tag || gitea.ref }}
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Login to Gitea Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: git.linuxhg.com
|
||||||
|
username: ${{ gitea.actor }}
|
||||||
|
# PAT stored as a repo Actions secret (auto GITHUB_TOKEN lacks package scope in Gitea)
|
||||||
|
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and push image
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./Dockerfile
|
||||||
|
push: true
|
||||||
|
# Bake the version into the binary (Admin → Settings → About).
|
||||||
|
build-args: |
|
||||||
|
APP_VERSION=${{ env.TAG }}
|
||||||
|
# Publishes both the exact version (e.g. v0.2.0) and the movable "latest" tag.
|
||||||
|
# Deployments default to "latest" via ${IMAGE_TAG:-latest} in docker-compose.yml;
|
||||||
|
# pin or roll back by setting IMAGE_TAG in .env.
|
||||||
|
tags: |
|
||||||
|
git.linuxhg.com/bookhoard/bookhoard:${{ env.TAG }}
|
||||||
|
git.linuxhg.com/bookhoard/bookhoard:latest
|
||||||
|
|
||||||
|
- name: Create Gitea Release
|
||||||
|
env:
|
||||||
|
# REGISTRY_TOKEN is reused for release creation because Gitea's auto
|
||||||
|
# GITHUB_TOKEN cannot create releases on this instance. The PAT must
|
||||||
|
# carry write:repository scope. Idempotent: re-runs update an existing
|
||||||
|
# release for this tag instead of failing with 409. On any HTTP error
|
||||||
|
# the API response body is printed so a 403 names the missing scope.
|
||||||
|
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
REPO: ${{ gitea.repository }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
: "${TAG:?TAG is required}"
|
||||||
|
API="https://git.linuxhg.com/api/v1/repos/${REPO}/releases"
|
||||||
|
AUTH="Authorization: token ${TOKEN}"
|
||||||
|
# Release body = the annotated tag's message (the git-cliff notes).
|
||||||
|
BODY="$(git tag -l --format='%(contents)' "${TAG}")"
|
||||||
|
|
||||||
|
# Tags containing a '-' (e.g. v0.3.0-rc1) are published as pre-releases.
|
||||||
|
PRE="false"; case "${TAG}" in *-*) PRE="true";; esac
|
||||||
|
|
||||||
|
PAYLOAD=$(jq -n \
|
||||||
|
--arg t "${TAG}" --arg n "${TAG}" --arg b "${BODY}" --argjson p "${PRE}" \
|
||||||
|
'{tag_name:$t, name:$n, body:$b, draft:false, prerelease:$p}')
|
||||||
|
|
||||||
|
# POST/PATCH the release, surfacing Gitea's error message on failure
|
||||||
|
# (e.g. "token does not have write scope") instead of failing silently.
|
||||||
|
api_call() {
|
||||||
|
local method="$1" url="$2" resp code rbody
|
||||||
|
resp="$(curl -sS -w '\n%{http_code}' -X "${method}" \
|
||||||
|
-H "${AUTH}" -H "Content-Type: application/json" \
|
||||||
|
-d "${PAYLOAD}" "${url}")"
|
||||||
|
code="$(printf '%s' "${resp}" | tail -n1)"
|
||||||
|
rbody="$(printf '%s' "${resp}" | sed '$d')"
|
||||||
|
if [ "${code}" -ge 400 ]; then
|
||||||
|
echo "::error::Release API ${code} (${method} ${url}): ${rbody}" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
EXISTING_ID="$(curl -sS -H "${AUTH}" "${API}/tags/${TAG}" | jq -r '.id // empty' 2>/dev/null || true)"
|
||||||
|
if [ -n "${EXISTING_ID}" ]; then
|
||||||
|
api_call PATCH "${API}/${EXISTING_ID}"
|
||||||
|
echo "Updated existing release id=${EXISTING_ID} for ${TAG}"
|
||||||
|
else
|
||||||
|
api_call POST "${API}"
|
||||||
|
echo "Created new release for ${TAG}"
|
||||||
|
fi
|
||||||
@@ -68,6 +68,9 @@ Thumbs.db
|
|||||||
# Uploads
|
# Uploads
|
||||||
uploads/
|
uploads/
|
||||||
|
|
||||||
|
# Vendored build artifacts (copied from node_modules at build time)
|
||||||
|
web/static/vendor/
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
*.db
|
*.db
|
||||||
*.sqlite
|
*.sqlite
|
||||||
@@ -76,3 +79,6 @@ uploads/
|
|||||||
VALID_TOKEN
|
VALID_TOKEN
|
||||||
fresh_token.txt
|
fresh_token.txt
|
||||||
session-*.md
|
session-*.md
|
||||||
|
|
||||||
|
# Local prod-export scratch: personal annotation/reading data, never commit
|
||||||
|
/export/
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
# AGENTS.md — Bookhoard server
|
||||||
|
|
||||||
|
Standing rules for any agent or developer working in this repo.
|
||||||
|
Project-wide context lives in the sibling repos' AGENTS.md files
|
||||||
|
(../bookhoard-app/AGENTS.md is the Android client's).
|
||||||
|
|
||||||
|
## Library visibility (family-lens rule) — ALWAYS apply
|
||||||
|
|
||||||
|
Per-user library visibility (`library_visibility` table, default-ALLOW:
|
||||||
|
every user sees every library unless an admin hides one) must be
|
||||||
|
enforced on **every endpoint that returns media metadata or content**.
|
||||||
|
It is NOT automatic — enforcement is per-handler.
|
||||||
|
|
||||||
|
- **New endpoints that list/filter media or series** MUST filter by the
|
||||||
|
requesting user's visible-library set, and MUST reject an explicit
|
||||||
|
`library_id` outside that set with 403 (never silently pass).
|
||||||
|
- Helpers (do not hand-roll):
|
||||||
|
- `handlers`: `(*MediaHandler).visibleLibrarySet` /
|
||||||
|
`(*SeriesHandler).visibleLibrarySet` — membership map + id slice.
|
||||||
|
- `router`: `visibleLibraryIDsFor` — same for SSR routes.
|
||||||
|
- `resolveLibrary` (helpers.go) only honors requested library ids
|
||||||
|
inside the visible set.
|
||||||
|
- SQL pattern: `AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR
|
||||||
|
library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))` — see
|
||||||
|
`ListMediaItems`, `GetDistinctSeries`, `SearchMediaItems`.
|
||||||
|
- Content endpoints (file bytes, page images) additionally gate per item
|
||||||
|
like `ReaderHandler.ensureItemVisible` / the download endpoint.
|
||||||
|
- Tests must pin visibility: hidden library → 403 on the endpoint, and
|
||||||
|
absent from unscoped aggregates.
|
||||||
|
|
||||||
|
History: 2026-10-13 (`431fec0`) the series/media listing surfaces
|
||||||
|
leaked hidden libraries to any authenticated account — series endpoints
|
||||||
|
were name-only across ALL libraries. Fixed; do not regress.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- Use `setupTestServer()` from `cmd/server/tests/test_helpers_test.go`
|
||||||
|
(see the Conventions section below for the pool/cleanup rules).
|
||||||
|
- Integration tests run against the SHARED dev database: never write
|
||||||
|
destructive SQL without preserving the dev rows (libraries Manga/
|
||||||
|
Comics/Ebooks, testuser's annotations and progress are real data).
|
||||||
|
- Test-created users must use `@tests.bookhoard.internal` or
|
||||||
|
`@example.com` emails and "test"-named collections/libraries so the
|
||||||
|
harness cleanup can find them.
|
||||||
|
- **NEVER rename or edit the shared admin (`testuser`) in tests** — a
|
||||||
|
renamed admin escapes the cleanup preserve-list and cascades real
|
||||||
|
data (this wiped the dev annotations once; see user_test.go
|
||||||
|
TestUpdateProfileSelfEdit for the dedicated-user pattern).
|
||||||
|
- **`testuser` is a LIVE reader**: the dev account genuinely reads in
|
||||||
|
the web reader while the suite runs, so `reading_history` /
|
||||||
|
`reading_progress` rows accrue on it mid-test. Exact-total
|
||||||
|
assertions against that account flap (three stats/analytics tests
|
||||||
|
broke on 2026-10-14 within hours of session capture shipping).
|
||||||
|
Tests that need deterministic data use `setup.RegularToken` +
|
||||||
|
`getRegularUserID` (the fresh per-test user), or assert ranges
|
||||||
|
instead of equals. Related: **no table-wide DELETEs in test
|
||||||
|
cleanups** — sync_integration's `DELETE FROM reading_history WHERE
|
||||||
|
true` destroyed the live reader's stats data on every suite run
|
||||||
|
until scoped to the test-sync users (cascade via user delete).
|
||||||
|
|
||||||
|
## Timezones
|
||||||
|
|
||||||
|
- The server owns display conversion: clients send instants (RFC3339),
|
||||||
|
the server renders in the requesting user's timezone.
|
||||||
|
- Use `utils.FormatTimeInTimezone` / `utils.UserLocation` — never
|
||||||
|
`time.Now().Format(...)` bare (that is server-UTC).
|
||||||
|
- Analytics day-buckets must bucket in the user's timezone
|
||||||
|
(`CreatedAt.In(loc)`), and date-range end bounds must be end-of-day.
|
||||||
|
|
||||||
|
## Known gotchas (do not relearn)
|
||||||
|
|
||||||
|
1. **templ does NOT interpolate `{ expr }` inside quoted attribute
|
||||||
|
values** — it emits the literal text. `x-init="f('{ col.ID }')"`
|
||||||
|
shipped a request to `/api/collections/{ collection.ID }` found by
|
||||||
|
the request logs; the whole attribute value must BE an expression:
|
||||||
|
`x-init={ "f('" + col.ID + "')" }`. Note
|
||||||
|
`class="theme-{ user.Theme }"` has always been literal (harmlessly
|
||||||
|
masked by the JS theme engine).
|
||||||
|
2. **SettingsRegistry caches**: writes must go through the settings
|
||||||
|
API (Reload on save). Direct SQL writes to system_settings leave
|
||||||
|
the cache stale until the next API-path write — never "preview"
|
||||||
|
rule changes via psql and expect them applied (2026-10-14: a live
|
||||||
|
curl test flip looked ignored until the API path was used).
|
||||||
|
3. **User collections are not dashboard sections**: nothing sets
|
||||||
|
collection show_on_dashboard for user rows, and GetDashboardSections
|
||||||
|
skips empty user sections. Collection detail (JSON + SSR) must NOT
|
||||||
|
route user rows through the dashboard-section path — a 7ce7321
|
||||||
|
regression did exactly that (static detail without library_id
|
||||||
|
returned zero books until 0a7fbd6). Only real system query types
|
||||||
|
(continue-reading etc.) route there; `query_type='filter'` is the
|
||||||
|
schema DEFAULT on every user row, not a query type.
|
||||||
|
4. **End-of-day arithmetic**: anchor the END bound to the START of the
|
||||||
|
day, never to NOW(). `now.AddDate(0,0,1) − 1ns` carries this
|
||||||
|
instant's clock time into tomorrow — a range "ending today"
|
||||||
|
resolves tomorrow's calendar date and today's rows land in
|
||||||
|
yesterday's bucket (found twice now: analytics 1601548 and stats
|
||||||
|
2026-10-14). Same trap, two deaths.
|
||||||
|
5. **Test binaries run against the LIVE dev DB**: hand-probes made in
|
||||||
|
psql during debugging (seed rows, fake sessions) will silently
|
||||||
|
pollute the next integration run — DELETE them when done (this
|
||||||
|
burned the first stats suite run: a stray 30-min probe row shipped
|
||||||
|
a phantom "185 minutes" failure).
|
||||||
|
6. **SaveProgressRequest.DeviceID is NOT always a device**: the
|
||||||
|
web/app progress paths have historically stuffed the USER id into
|
||||||
|
it (it only fed a broadcast label before stats). Anything that
|
||||||
|
dereferences it (reading_history device_id FK, device joins) must
|
||||||
|
probe DeviceExistsForUser first — stats capture does.
|
||||||
|
|
||||||
|
## Conventions (absorbed from the retired PROJECT_GUIDELINES.md —
|
||||||
|
## only the rules that still matched reality)
|
||||||
|
|
||||||
|
- **Runtime is Docker** (Makefile auto-detects docker, falls back to
|
||||||
|
podman). The old "podman only" rule is dead.
|
||||||
|
- **The server binary is only built inside the Docker build**
|
||||||
|
(`make rebuild-app`); host-side Go builds are for tests/vet only.
|
||||||
|
- **Schema changes go in place** — edit `database/schema/schema.sql`,
|
||||||
|
never create migration files, until a release splits them.
|
||||||
|
- **Business logic lives in services**, not handlers; handlers parse
|
||||||
|
requests and call services (all DB access goes through services).
|
||||||
|
- **Integration tests**: one `setupTestServer()` per test function;
|
||||||
|
test pools use `max_conns=1`; never create a `pgxpool` per subtest
|
||||||
|
(connection exhaustion); use `t.Cleanup()`-based helpers; table-driven
|
||||||
|
subtests over duplicate functions.
|
||||||
@@ -1065,7 +1065,7 @@ If there's a section about documentation features, mention the new search functi
|
|||||||
|
|
||||||
## Git Commit Strategy
|
## Git Commit Strategy
|
||||||
|
|
||||||
Follow PROJECT_GUIDELINES.md - make multiple logical commits:
|
Follow AGENTS.md conventions - make multiple logical commits:
|
||||||
|
|
||||||
1. **Backend implementation** (search.go, http_handler.go, router changes)
|
1. **Backend implementation** (search.go, http_handler.go, router changes)
|
||||||
2. **Unit tests** (search_test.go)
|
2. **Unit tests** (search_test.go)
|
||||||
|
|||||||
+31
-9
@@ -1,5 +1,5 @@
|
|||||||
# Build stage
|
# Build stage
|
||||||
FROM golang:1.25-alpine AS builder
|
FROM golang:1.26-alpine AS builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -7,14 +7,19 @@ WORKDIR /app
|
|||||||
RUN apk add --no-cache nodejs npm curl git
|
RUN apk add --no-cache nodejs npm curl git
|
||||||
|
|
||||||
# Install Go tools (cached well)
|
# Install Go tools (cached well)
|
||||||
|
RUN wget -O /tmp/sqlc.tar.gz https://github.com/sqlc-dev/sqlc/releases/download/v1.31.0/sqlc_1.31.0_linux_amd64.tar.gz && \
|
||||||
|
tar -xzf /tmp/sqlc.tar.gz -C /usr/local/bin sqlc && \
|
||||||
|
rm /tmp/sqlc.tar.gz
|
||||||
RUN --mount=type=cache,target=/root/go/pkg/mod \
|
RUN --mount=type=cache,target=/root/go/pkg/mod \
|
||||||
go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest && \
|
go install github.com/a-h/templ/cmd/templ@v0.3.1020
|
||||||
go install github.com/a-h/templ/cmd/templ@latest
|
|
||||||
|
|
||||||
# Copy package files and install npm dependencies (cached unless package.json changes)
|
# Copy package files and install npm dependencies (cached unless package.json changes)
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN --mount=type=cache,target=/root/.npm \
|
# npm cache mount renamed from /root/.npm: the old volume held a stale
|
||||||
npm ci
|
# foliate-js tarball (name@version reuse) that --no-cache and builder prune
|
||||||
|
# could not evict; a new mount id guarantees a clean fetch.
|
||||||
|
RUN --mount=type=cache,target=/root/.npm-cache2 \
|
||||||
|
npm install
|
||||||
|
|
||||||
# Copy Go mod files (cached unless go.mod changes)
|
# Copy Go mod files (cached unless go.mod changes)
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
@@ -32,21 +37,38 @@ RUN cd templates && templ generate
|
|||||||
RUN npm run build:css:prod
|
RUN npm run build:css:prod
|
||||||
RUN npm run build:ts
|
RUN npm run build:ts
|
||||||
|
|
||||||
|
# App version baked into the binary (shown in Admin → Settings → About).
|
||||||
|
# Declared after `COPY . .` so a version bump only invalidates this layer.
|
||||||
|
# The release workflow passes the git tag, e.g. APP_VERSION=v1.5.
|
||||||
|
ARG APP_VERSION=dev
|
||||||
|
|
||||||
# Build Go binary (cached unless Go files or generated code changes)
|
# Build Go binary (cached unless Go files or generated code changes)
|
||||||
RUN --mount=type=cache,target=/root/go/pkg/mod \
|
RUN --mount=type=cache,target=/root/go/pkg/mod \
|
||||||
CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main ./cmd/server
|
--mount=type=cache,target=/root/.cache/go-build \
|
||||||
|
CGO_ENABLED=0 GOOS=linux go build -installsuffix cgo \
|
||||||
|
-ldflags "-X bookhoard/internal/version.Version=${APP_VERSION}" \
|
||||||
|
-o main ./cmd/server
|
||||||
|
|
||||||
|
# Materialize the Go module cache into an image layer so the test-runner
|
||||||
|
# stage can restore it. GOMODCACHE is /go/pkg/mod; cache mounts elsewhere are
|
||||||
|
# ignored by the go tool, and without this copy `go test` in the tests
|
||||||
|
# container re-downloads every dependency on each invocation.
|
||||||
|
RUN cp -r /go/pkg/mod /app/go-module-cache
|
||||||
|
|
||||||
# Test runner stage - includes Go runtime and test dependencies
|
# Test runner stage - includes Go runtime and test dependencies
|
||||||
# This stage is ONLY used for running tests, never deployed to production
|
# This stage is ONLY used for running tests, never deployed to production
|
||||||
FROM golang:1.25-alpine AS test-runner
|
FROM golang:1.26-alpine AS test-runner
|
||||||
|
|
||||||
RUN apk --no-cache add ca-certificates curl
|
RUN apk --no-cache add ca-certificates curl poppler-utils
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy everything from builder (includes compiled binary, source code, and dependencies)
|
# Copy everything from builder (includes compiled binary, source code, and dependencies)
|
||||||
COPY --from=builder /app ./
|
COPY --from=builder /app ./
|
||||||
|
|
||||||
|
# Restore the materialized Go module cache so `go test` works offline.
|
||||||
|
RUN mkdir -p /go/pkg/mod && cp -r /app/go-module-cache/. /go/pkg/mod/ && rm -rf /app/go-module-cache
|
||||||
|
|
||||||
# Install kepubify for conversion tests
|
# Install kepubify for conversion tests
|
||||||
RUN wget -O /usr/bin/kepubify https://github.com/pgaskin/kepubify/releases/latest/download/kepubify-linux-64bit \
|
RUN wget -O /usr/bin/kepubify https://github.com/pgaskin/kepubify/releases/latest/download/kepubify-linux-64bit \
|
||||||
&& chmod +x /usr/bin/kepubify
|
&& chmod +x /usr/bin/kepubify
|
||||||
@@ -62,7 +84,7 @@ CMD ["go", "test", "./cmd/server/tests", "-v", "-timeout", "5m", "-parallel=1",
|
|||||||
# Final stage
|
# Final stage
|
||||||
FROM alpine:latest
|
FROM alpine:latest
|
||||||
|
|
||||||
RUN apk --no-cache add ca-certificates curl
|
RUN apk --no-cache add ca-certificates curl poppler-utils
|
||||||
|
|
||||||
# Install kepubify for EPUB→KEPUB conversion
|
# Install kepubify for EPUB→KEPUB conversion
|
||||||
RUN wget -O /usr/bin/kepubify https://github.com/pgaskin/kepubify/releases/latest/download/kepubify-linux-64bit \
|
RUN wget -O /usr/bin/kepubify https://github.com/pgaskin/kepubify/releases/latest/download/kepubify-linux-64bit \
|
||||||
|
|||||||
@@ -1,232 +1,661 @@
|
|||||||
GNU GENERAL PUBLIC LICENSE
|
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||||
Version 3, 29 June 2007
|
Version 3, 19 November 2007
|
||||||
|
|
||||||
Copyright © 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed.
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
Preamble
|
Preamble
|
||||||
|
|
||||||
The GNU General Public License is a free, copyleft license for software and other kinds of works.
|
The GNU Affero General Public License is a free, copyleft license for
|
||||||
|
software and other kinds of works, specifically designed to ensure
|
||||||
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.
|
cooperation with the community in the case of network server software.
|
||||||
|
|
||||||
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.
|
The licenses for most software and other practical works are designed
|
||||||
|
to take away your freedom to share and change the works. By contrast,
|
||||||
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.
|
our General Public Licenses are intended to guarantee your freedom to
|
||||||
|
share and change all versions of a program--to make sure it remains free
|
||||||
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.
|
software for all its users.
|
||||||
|
|
||||||
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.
|
When we speak of free software, we are referring to freedom, not
|
||||||
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
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.
|
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
|
||||||
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.
|
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.
|
||||||
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.
|
|
||||||
|
Developers that use our General Public Licenses protect your rights
|
||||||
The precise terms and conditions for copying, distribution and modification follow.
|
with two steps: (1) assert copyright on the software, and (2) offer
|
||||||
|
you this License which gives you legal permission to copy, distribute
|
||||||
TERMS AND CONDITIONS
|
and/or modify the software.
|
||||||
|
|
||||||
0. Definitions.
|
A secondary benefit of defending all users' freedom is that
|
||||||
|
improvements made in alternate versions of the program, if they
|
||||||
“This License” refers to version 3 of the GNU General Public License.
|
receive widespread use, become available for other developers to
|
||||||
|
incorporate. Many developers of free software are heartened and
|
||||||
“Copyright” also means copyright-like laws that apply to other kinds of works, such as semiconductor masks.
|
encouraged by the resulting cooperation. However, in the case of
|
||||||
|
software used on network servers, this result may fail to come about.
|
||||||
“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.
|
The GNU General Public License permits making a modified version and
|
||||||
|
letting the public access it on a server without ever releasing its
|
||||||
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.
|
source code to the public.
|
||||||
|
|
||||||
A “covered work” means either the unmodified Program or a work based on the Program.
|
The GNU Affero General Public License is designed specifically to
|
||||||
|
ensure that, in such cases, the modified source code becomes available
|
||||||
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 the community. It requires the operator of a network server to
|
||||||
|
provide the source code of the modified version running there to the
|
||||||
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.
|
users of that server. Therefore, public use of a modified version, on
|
||||||
|
a publicly accessible server, gives the public access to the source
|
||||||
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.
|
code of the modified version.
|
||||||
|
|
||||||
1. Source Code.
|
An older license, called the Affero General Public License and
|
||||||
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.
|
published by Affero, was designed to accomplish similar goals. This is
|
||||||
|
a different license, not a version of the Affero GPL, but Affero has
|
||||||
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.
|
released a new version of the Affero GPL which permits relicensing under
|
||||||
|
this license.
|
||||||
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 precise terms and conditions for copying, distribution and
|
||||||
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.
|
modification follow.
|
||||||
|
|
||||||
The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source.
|
TERMS AND CONDITIONS
|
||||||
|
|
||||||
The Corresponding Source for a work in source code form is that same work.
|
0. Definitions.
|
||||||
|
|
||||||
2. Basic Permissions.
|
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||||
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.
|
|
||||||
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
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.
|
works, such as semiconductor masks.
|
||||||
|
|
||||||
Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary.
|
"The Program" refers to any copyrightable work licensed under this
|
||||||
|
License. Each licensee is addressed as "you". "Licensees" and
|
||||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
"recipients" may be individuals or organizations.
|
||||||
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.
|
|
||||||
|
To "modify" a work means to copy from or adapt all or part of the work
|
||||||
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.
|
in a fashion requiring copyright permission, other than the making of an
|
||||||
|
exact copy. The resulting work is called a "modified version" of the
|
||||||
4. Conveying Verbatim Copies.
|
earlier work or a work "based on" the earlier work.
|
||||||
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.
|
|
||||||
|
A "covered work" means either the unmodified Program or a work based
|
||||||
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.
|
on the Program.
|
||||||
|
|
||||||
5. Conveying Modified Source Versions.
|
To "propagate" a work means to do anything with it that, without
|
||||||
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:
|
permission, would make you directly or secondarily liable for
|
||||||
|
infringement under applicable copyright law, except executing it on a
|
||||||
a) The work must carry prominent notices stating that you modified it, and giving a relevant date.
|
computer or modifying a private copy. Propagation includes copying,
|
||||||
|
distribution (with or without modification), making available to the
|
||||||
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”.
|
public, and in some countries other activities as well.
|
||||||
|
|
||||||
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.
|
To "convey" a work means any kind of propagation that enables other
|
||||||
|
parties to make or receive copies. Mere interaction with a user through
|
||||||
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 computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
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.
|
An interactive user interface displays "Appropriate Legal Notices"
|
||||||
|
to the extent that it includes a convenient and prominently visible
|
||||||
6. Conveying Non-Source Forms.
|
feature that (1) displays an appropriate copyright notice, and (2)
|
||||||
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:
|
tells the user that there is no warranty for the work (except to the
|
||||||
|
extent that warranties are provided), that licensees may convey the
|
||||||
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.
|
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
|
||||||
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.
|
menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
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.
|
1. Source Code.
|
||||||
|
|
||||||
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.
|
The "source code" for a work means the preferred form of the work
|
||||||
|
for making modifications to it. "Object code" means any non-source
|
||||||
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.
|
form of a work.
|
||||||
|
|
||||||
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 "Standard Interface" means an interface that either is an official
|
||||||
|
standard defined by a recognized standards body, or, in the case of
|
||||||
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.
|
interfaces specified for a particular programming language, one that
|
||||||
|
is widely used among developers working in that language.
|
||||||
“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.
|
|
||||||
|
The "System Libraries" of an executable work include anything, other
|
||||||
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).
|
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
|
||||||
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.
|
Component, and (b) serves only to enable use of the work with that
|
||||||
|
Major Component, or to implement a Standard Interface for which an
|
||||||
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.
|
implementation is available to the public in source code form. A
|
||||||
|
"Major Component", in this context, means a major essential component
|
||||||
7. Additional Terms.
|
(kernel, window system, and so on) of the specific operating system
|
||||||
“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.
|
(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.
|
||||||
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.
|
|
||||||
|
The "Corresponding Source" for a work in object code form means all
|
||||||
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:
|
the source code needed to generate, install, and (for an executable
|
||||||
|
work) run the object code and to modify the work, including scripts to
|
||||||
a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or
|
control those activities. However, it does not include the work's
|
||||||
|
System Libraries, or general-purpose tools or generally available free
|
||||||
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
|
programs which are used unmodified in performing those activities but
|
||||||
|
which are not part of the work. For example, Corresponding Source
|
||||||
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
|
includes interface definition files associated with source files for
|
||||||
|
the work, and the source code for shared libraries and dynamically
|
||||||
d) Limiting the use for publicity purposes of names of licensors or authors of the material; or
|
linked subprograms that the work is specifically designed to require,
|
||||||
|
such as by intimate data communication or control flow between those
|
||||||
e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or
|
subprograms and other parts of the work.
|
||||||
|
|
||||||
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.
|
The Corresponding Source need not include anything that users
|
||||||
|
can regenerate automatically from other parts of the Corresponding
|
||||||
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.
|
Source.
|
||||||
|
|
||||||
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.
|
The Corresponding Source for a work in source code form is that
|
||||||
|
same work.
|
||||||
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.
|
|
||||||
|
2. Basic Permissions.
|
||||||
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).
|
All rights granted under this License are granted for the term of
|
||||||
|
copyright on the Program, and are irrevocable provided the stated
|
||||||
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.
|
conditions are met. This License explicitly affirms your unlimited
|
||||||
|
permission to run the unmodified Program. The output from running a
|
||||||
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.
|
covered work is covered by this License only if the output, given its
|
||||||
|
content, constitutes a covered work. This License acknowledges your
|
||||||
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.
|
rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
9. Acceptance Not Required for Having Copies.
|
You may make, run and propagate covered works that you do not
|
||||||
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.
|
convey, without conditions so long as your license otherwise remains
|
||||||
|
in force. You may convey covered works to others for the sole purpose
|
||||||
10. Automatic Licensing of Downstream Recipients.
|
of having them make modifications exclusively for you, or provide you
|
||||||
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.
|
with facilities for running those works, provided that you comply with
|
||||||
|
the terms of this License in conveying all material for which you do
|
||||||
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.
|
not control copyright. Those thus making or running the covered works
|
||||||
|
for you must do so exclusively on your behalf, under your direction
|
||||||
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.
|
and control, on terms that prohibit them from making any copies of
|
||||||
|
your copyrighted material outside their relationship with you.
|
||||||
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”.
|
Conveying under any other circumstances is permitted solely under
|
||||||
|
the conditions stated below. Sublicensing is not allowed; section 10
|
||||||
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.
|
makes it unnecessary.
|
||||||
|
|
||||||
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.
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
|
||||||
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.
|
No covered work shall be deemed part of an effective technological
|
||||||
|
measure under any applicable law fulfilling obligations under article
|
||||||
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.
|
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||||
|
similar laws prohibiting or restricting circumvention of such
|
||||||
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.
|
measures.
|
||||||
|
|
||||||
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.
|
When you convey a covered work, you waive any legal power to forbid
|
||||||
|
circumvention of technological measures to the extent such circumvention
|
||||||
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.
|
is effected by exercising rights under this License with respect to
|
||||||
|
the covered work, and you disclaim any intention to limit operation or
|
||||||
12. No Surrender of Others' Freedom.
|
modification of the work as a means of enforcing, against the work's
|
||||||
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.
|
users, your or third parties' legal rights to forbid circumvention of
|
||||||
|
technological measures.
|
||||||
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.
|
4. Conveying Verbatim Copies.
|
||||||
|
|
||||||
14. Revised Versions of this License.
|
You may convey verbatim copies of the Program's source code as you
|
||||||
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.
|
receive it, in any medium, provided that you conspicuously and
|
||||||
|
appropriately publish on each copy an appropriate copyright notice;
|
||||||
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.
|
keep intact all notices stating that this License and any
|
||||||
|
non-permissive terms added in accord with section 7 apply to the code;
|
||||||
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.
|
keep intact all notices of the absence of any warranty; and give all
|
||||||
|
recipients a copy of this License along with 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.
|
|
||||||
|
You may charge any price or no price for each copy that you convey,
|
||||||
15. Disclaimer of Warranty.
|
and you may offer support or warranty protection for a fee.
|
||||||
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.
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
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.
|
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
|
||||||
17. Interpretation of Sections 15 and 16.
|
terms of section 4, provided that you also meet all of these conditions:
|
||||||
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.
|
|
||||||
|
a) The work must carry prominent notices stating that you modified
|
||||||
END OF TERMS AND CONDITIONS
|
it, and giving a relevant date.
|
||||||
|
|
||||||
How to Apply These Terms to Your New Programs
|
b) The work must carry prominent notices stating that it is
|
||||||
|
released under this License and any conditions added under section
|
||||||
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.
|
7. This requirement modifies the requirement in section 4 to
|
||||||
|
"keep intact all notices".
|
||||||
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.
|
|
||||||
|
c) You must license the entire work, as a whole, under this
|
||||||
bookhoard
|
License to anyone who comes into possession of a copy. This
|
||||||
Copyright (C) 2026 john-okeefe
|
License will therefore apply, along with any applicable section 7
|
||||||
|
additional terms, to the whole of the work, and all its parts,
|
||||||
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.
|
regardless of how they are packaged. This License gives no
|
||||||
|
permission to license the work in any other way, but it does not
|
||||||
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.
|
invalidate such permission if you have separately received it.
|
||||||
|
|
||||||
You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
|
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. Remote Network Interaction; Use with the GNU General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, if you modify the
|
||||||
|
Program, your modified version must prominently offer all users
|
||||||
|
interacting with it remotely through a computer network (if your version
|
||||||
|
supports such interaction) an opportunity to receive the Corresponding
|
||||||
|
Source of your version by providing access to the Corresponding Source
|
||||||
|
from a network server at no charge, through some standard or customary
|
||||||
|
means of facilitating copying of software. This Corresponding Source
|
||||||
|
shall include the Corresponding Source for any work covered by version 3
|
||||||
|
of the GNU General Public License that is incorporated pursuant to the
|
||||||
|
following paragraph.
|
||||||
|
|
||||||
|
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 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 work with which it is combined will remain governed by version
|
||||||
|
3 of the GNU General Public License.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
|
the GNU Affero 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 Affero 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 Affero 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 Affero 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 Affero 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 Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero 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.
|
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:
|
If your software can interact with users remotely through a computer
|
||||||
|
network, you should also make sure that it provides a way for users to
|
||||||
|
get its source. For example, if your program is a web application, its
|
||||||
|
interface could display a "Source" link that leads users to an archive
|
||||||
|
of the code. There are many ways you could offer source, and different
|
||||||
|
solutions will be better for different programs; see section 13 for the
|
||||||
|
specific requirements.
|
||||||
|
|
||||||
bookhoard Copyright (C) 2026 john-okeefe
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details.
|
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||||
|
<https://www.gnu.org/licenses/>.
|
||||||
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/philosophy/why-not-lgpl.html>.
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: help test test-integration test-all rebuild rebuild-force rebuild-app rebuild-app-force rebuild-force-db clean restart up down logs ps test-env-up test-env-down verify-guidelines verify-quick
|
.PHONY: help test test-integration test-all rebuild-app rebuild-app-force rebuild rebuild-force rebuild-force-db clean restart up down logs ps test-env-up test-env-down release
|
||||||
|
|
||||||
# Include .env file for environment variables (single source of truth)
|
# Include .env file for environment variables (single source of truth)
|
||||||
# Ignore if .env doesn't exist yet
|
# Ignore if .env doesn't exist yet
|
||||||
@@ -7,6 +7,14 @@ ifneq (,$(wildcard ./.env))
|
|||||||
export
|
export
|
||||||
endif
|
endif
|
||||||
|
|
||||||
|
# Auto-detect container runtime: prefer docker, fall back to podman
|
||||||
|
# Override with: CONTAINER_RUNTIME=podman make rebuild-app
|
||||||
|
CONTAINER_RUNTIME ?= $(shell command -v docker 2>/dev/null || command -v podman 2>/dev/null)
|
||||||
|
|
||||||
|
# Dev compose stack: base prod file merged with the dev override (local build + tests).
|
||||||
|
# Prod deploy does NOT use this — it runs plain `docker compose` against the base file only.
|
||||||
|
COMPOSE := $(CONTAINER_RUNTIME) compose -f docker-compose.yml -f docker-compose.dev.yml
|
||||||
|
|
||||||
# Default target
|
# Default target
|
||||||
help:
|
help:
|
||||||
@echo "Available targets:"
|
@echo "Available targets:"
|
||||||
@@ -34,8 +42,9 @@ help:
|
|||||||
@echo " make test-env-down - Stop test environment"
|
@echo " make test-env-down - Stop test environment"
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo "Verification:"
|
@echo "Verification:"
|
||||||
@echo " make verify-guidelines - Run comprehensive guidelines check"
|
@echo ""
|
||||||
@echo " make verify-quick - Run quick guidelines check"
|
@echo "Release:"
|
||||||
|
@echo " ./release v0.3.0 - Tag, push, and release (notes auto-generated from commits)"
|
||||||
|
|
||||||
# Run unit tests locally (fast, no containers)
|
# Run unit tests locally (fast, no containers)
|
||||||
test:
|
test:
|
||||||
@@ -44,23 +53,23 @@ test:
|
|||||||
# Run integration tests in containers (matches production environment)
|
# Run integration tests in containers (matches production environment)
|
||||||
test-integration:
|
test-integration:
|
||||||
@echo "Building test containers..."
|
@echo "Building test containers..."
|
||||||
podman compose --profile tests build
|
$(COMPOSE) --profile tests build
|
||||||
@echo "Starting application containers..."
|
@echo "Starting application containers..."
|
||||||
podman compose up -d db app
|
$(COMPOSE) up -d db app
|
||||||
@echo "Waiting for services to be healthy..."
|
@echo "Waiting for services to be healthy..."
|
||||||
@until podman exec bookhoard_db pg_isready -U postgres > /dev/null 2>&1; do \
|
@until $(CONTAINER_RUNTIME) exec bookhoard_db pg_isready -U postgres > /dev/null 2>&1; do \
|
||||||
echo " Database not ready yet..."; \
|
echo " Database not ready yet..."; \
|
||||||
sleep 2; \
|
sleep 2; \
|
||||||
done; \
|
done; \
|
||||||
echo " ✓ Database is ready"
|
echo " ✓ Database is ready"
|
||||||
@until podman exec bookhoard curl -sf http://localhost:8765/health > /dev/null 2>&1; do \
|
@until $(CONTAINER_RUNTIME) exec bookhoard curl -sf http://localhost:8765/health > /dev/null 2>&1; do \
|
||||||
echo " Application not ready yet..."; \
|
echo " Application not ready yet..."; \
|
||||||
sleep 2; \
|
sleep 2; \
|
||||||
done; \
|
done; \
|
||||||
echo " ✓ Application is ready"
|
echo " ✓ Application is ready"
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo "Running integration tests in container..."
|
@echo "Running integration tests in container..."
|
||||||
podman compose --profile tests run --rm tests
|
$(COMPOSE) --profile tests run --rm tests
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo "✅ Integration tests completed!"
|
@echo "✅ Integration tests completed!"
|
||||||
@echo "📝 Containers are still running. Use 'make logs' to view logs or 'make clean' to stop."
|
@echo "📝 Containers are still running. Use 'make logs' to view logs or 'make clean' to stop."
|
||||||
@@ -71,82 +80,99 @@ test-all: test test-integration
|
|||||||
# Rebuild app container only (preserve DB, with cache)
|
# Rebuild app container only (preserve DB, with cache)
|
||||||
rebuild-app:
|
rebuild-app:
|
||||||
@echo "Rebuilding app container (database stays running)..."
|
@echo "Rebuilding app container (database stays running)..."
|
||||||
podman compose up --build --force-recreate -d app
|
$(COMPOSE) up --build --force-recreate -d app
|
||||||
@echo "✓ App container rebuilt and restarted"
|
@echo "✓ App container rebuilt and restarted"
|
||||||
|
|
||||||
# Rebuild app container only (preserve DB, no cache)
|
# Rebuild app container only (preserve DB, no cache)
|
||||||
rebuild-app-force:
|
rebuild-app-force:
|
||||||
@echo "Force rebuilding app container (database stays running, no cache)..."
|
@echo "Force rebuilding app container (database stays running, no cache)..."
|
||||||
podman compose build --no-cache app
|
$(COMPOSE) build --no-cache app
|
||||||
podman compose up --force-recreate -d app
|
$(COMPOSE) up --force-recreate -d app
|
||||||
@echo "✓ App container rebuilt and restarted"
|
@echo "✓ App container rebuilt and restarted"
|
||||||
|
|
||||||
# Rebuild all containers (preserve DB, with cache)
|
# Rebuild all containers (preserve DB, with cache)
|
||||||
rebuild:
|
rebuild:
|
||||||
@echo "Rebuilding all containers (database preserved)..."
|
@echo "Rebuilding all containers (database preserved)..."
|
||||||
podman compose up --build --force-recreate -d
|
$(COMPOSE) up --build --force-recreate -d
|
||||||
@echo "✓ All containers rebuilt and restarted"
|
@echo "✓ All containers rebuilt and restarted"
|
||||||
|
|
||||||
# Rebuild all containers (preserve DB, no cache)
|
# Rebuild all containers (preserve DB, no cache)
|
||||||
rebuild-force:
|
rebuild-force:
|
||||||
@echo "Force rebuilding all containers (database preserved, no cache)..."
|
@echo "Force rebuilding all containers (database preserved, no cache)..."
|
||||||
podman compose build --no-cache
|
$(COMPOSE) build --no-cache
|
||||||
podman compose up --force-recreate -d
|
$(COMPOSE) up --force-recreate -d
|
||||||
@echo "✓ All containers rebuilt and restarted"
|
@echo "✓ All containers rebuilt and restarted"
|
||||||
|
|
||||||
# Rebuild all containers (remove DB, no cache)
|
# Rebuild all containers (remove DB, no cache)
|
||||||
rebuild-force-db:
|
rebuild-force-db:
|
||||||
@echo "Force rebuilding all containers (database will be DELETED, no cache)..."
|
@echo "Force rebuilding all containers (database will be DELETED, no cache)..."
|
||||||
podman compose down -v
|
$(COMPOSE) down -v
|
||||||
podman compose build --no-cache
|
$(COMPOSE) build --no-cache
|
||||||
podman compose up --force-recreate -d
|
$(COMPOSE) up --force-recreate -d
|
||||||
@echo "✓ All containers rebuilt and restarted"
|
@echo "✓ All containers rebuilt and restarted"
|
||||||
|
|
||||||
# Stop and remove containers
|
# Stop and remove containers
|
||||||
clean:
|
clean:
|
||||||
podman compose down -v
|
$(COMPOSE) down -v
|
||||||
|
|
||||||
# Quick start (if already built)
|
# Quick start (if already built)
|
||||||
up:
|
up:
|
||||||
podman compose up -d
|
$(COMPOSE) up -d
|
||||||
|
|
||||||
# Stop all containers (alias for clean)
|
# Stop all containers (alias for clean)
|
||||||
down:
|
down:
|
||||||
podman compose down
|
$(COMPOSE) down
|
||||||
|
|
||||||
# Restart app container (preserves database)
|
# Restart app container (preserves database)
|
||||||
restart:
|
restart:
|
||||||
@echo "Restarting app container (database stays running)..."
|
@echo "Restarting app container (database stays running)..."
|
||||||
podman compose restart app
|
$(COMPOSE) restart app
|
||||||
@echo "✓ App container restarted"
|
@echo "✓ App container restarted"
|
||||||
|
|
||||||
# Show container status
|
# Show container status
|
||||||
ps:
|
ps:
|
||||||
podman compose ps
|
$(COMPOSE) ps
|
||||||
|
|
||||||
# Show container logs
|
# Show container logs
|
||||||
logs:
|
logs:
|
||||||
podman compose logs -f
|
$(COMPOSE) logs -f
|
||||||
|
|
||||||
# Start containers with test mode enabled for manual testing
|
# Start containers with test mode enabled for manual testing
|
||||||
test-env-up:
|
test-env-up:
|
||||||
@echo "Starting containers with test mode enabled..."
|
@echo "Starting containers with test mode enabled..."
|
||||||
TEST_MODE=true RATE_LIMIT_ENABLED=false REQUESTS_PER_MINUTE=1000 podman compose up --build --force-recreate -d
|
TEST_MODE=true RATE_LIMIT_ENABLED=false REQUESTS_PER_MINUTE=1000 $(COMPOSE) up --build --force-recreate -d
|
||||||
@echo "Waiting for services to be ready..."
|
@echo "Waiting for services to be ready..."
|
||||||
@until podman exec bookhoard_db pg_isready -U postgres > /dev/null 2>&1; do sleep 1; done
|
@until $(CONTAINER_RUNTIME) exec bookhoard_db pg_isready -U postgres > /dev/null 2>&1; do sleep 1; done
|
||||||
@until podman exec bookhoard curl -sf http://localhost:8765/health > /dev/null 2>&1; do sleep 1; done
|
@until $(CONTAINER_RUNTIME) exec bookhoard curl -sf http://localhost:8765/health > /dev/null 2>&1; do sleep 1; done
|
||||||
@echo "✓ Test environment is ready!"
|
@echo "✓ Test environment is ready!"
|
||||||
@echo "Application available at http://localhost:8765"
|
@echo "Application available at http://localhost:8765"
|
||||||
|
|
||||||
# Stop test environment
|
# Stop test environment
|
||||||
test-env-down:
|
test-env-down:
|
||||||
podman compose down -v
|
$(COMPOSE) down -v
|
||||||
|
|
||||||
# Verify project guidelines compliance
|
|
||||||
verify-guidelines:
|
|
||||||
@echo "Running comprehensive project guidelines verification..."
|
|
||||||
@./scripts/verify-guidelines.sh
|
|
||||||
|
|
||||||
verify-quick:
|
|
||||||
@echo "Running quick project guidelines verification..."
|
# Create an annotated version tag carrying auto-generated release notes (git-cliff)
|
||||||
@./scripts/verify-quick.sh
|
# and push it. The tag push triggers .gitea/workflows/release.yml, which builds the
|
||||||
|
# image and publishes a Gitea Release whose body is this tag's message. Notes come
|
||||||
|
# entirely from Conventional Commits — no hand-written message required.
|
||||||
|
#
|
||||||
|
# git-cliff's --latest needs the tag to exist to scope the notes, so we create a
|
||||||
|
# throwaway lightweight tag, generate the notes, replace it with an annotated tag,
|
||||||
|
# then push. --cleanup=verbatim keeps the markdown "###" group headers (git's
|
||||||
|
# default cleanup would strip lines starting with "#").
|
||||||
|
#
|
||||||
|
# Requires git-cliff: https://git-cliff.org/install
|
||||||
|
# Usage: make release VERSION=v0.3.0
|
||||||
|
release:
|
||||||
|
@test -n "$(VERSION)" || { echo "Usage: make release VERSION=v0.3.0"; exit 1; }
|
||||||
|
@command -v git-cliff >/dev/null 2>&1 || { echo "git-cliff not found — install: https://git-cliff.org/install"; exit 1; }
|
||||||
|
@if git rev-parse "$(VERSION)" >/dev/null 2>&1; then echo "Tag $(VERSION) already exists locally — delete it first: git tag -d $(VERSION)"; exit 1; fi
|
||||||
|
@echo "Generating release notes for $(VERSION)..."
|
||||||
|
@git tag "$(VERSION)" HEAD && \
|
||||||
|
(git cliff --latest --config cliff.toml > .release-notes.tmp && git tag -d "$(VERSION)" >/dev/null) || \
|
||||||
|
{ git tag -d "$(VERSION)" >/dev/null 2>&1; rm -f .release-notes.tmp; echo "git-cliff failed"; exit 1; }
|
||||||
|
@git tag -a --cleanup=verbatim -F .release-notes.tmp "$(VERSION)" HEAD && rm -f .release-notes.tmp
|
||||||
|
@git push origin "$(VERSION)"
|
||||||
|
@echo "Pushed $(VERSION) — Gitea Actions will build the image and publish the Release."
|
||||||
@@ -1,777 +0,0 @@
|
|||||||
# Panel Detection Implementation Plan
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Multi-tier panel detection system with fallback chain:
|
|
||||||
**OpenCV → ML (COCO-SSD) → Grid → Manual Editor**
|
|
||||||
|
|
||||||
Designed for a constantly growing library - handles any comic style without custom training.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Detection Pipeline
|
|
||||||
|
|
||||||
```
|
|
||||||
1. OpenCV Edge Detection (Primary)
|
|
||||||
├─ Fast, lightweight (~500KB lazy-loaded)
|
|
||||||
├─ Works on 80% of comics with clear panel borders
|
|
||||||
└─ Future-proof: works on unknown future comics
|
|
||||||
|
|
||||||
2. ML Detection (COCO-SSD Fallback)
|
|
||||||
├─ Pre-trained on millions of diverse images
|
|
||||||
├─ Handles irregular layouts
|
|
||||||
└─ ~2MB (TensorFlow.js) + ~2MB (model), lazy-loaded
|
|
||||||
|
|
||||||
3. Grid Detection (Baseline)
|
|
||||||
└─ Always works as final fallback
|
|
||||||
|
|
||||||
4. Manual Editor (Last Resort)
|
|
||||||
└─ User manually draws panels
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Dependencies
|
|
||||||
|
|
||||||
Add to `package.json`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"dependencies": {
|
|
||||||
"@techstark/opencv-js": "^4.12.0",
|
|
||||||
"@tensorflow/tfjs": "^4.22.0",
|
|
||||||
"@tensorflow-models/coco-ssd": "^2.2.3"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Bundle sizes:**
|
|
||||||
- OpenCV.js: ~500KB (lazy-loaded)
|
|
||||||
- TensorFlow.js: ~2MB (lazy-loaded)
|
|
||||||
- COCO-SSD model: ~2MB (lazy-loaded, cached after first load)
|
|
||||||
- **Total: ~4.5MB** (acceptable for modern networks)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## File Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
web/src/reader/comic/
|
|
||||||
├── panel-detection.service.ts [NEW] - Main detection service with fallback chain
|
|
||||||
├── panel-detection.opencv.ts [NEW] - OpenCV edge detection
|
|
||||||
├── panel-detection.ml.ts [NEW] - COCO-SSD ML detection
|
|
||||||
├── panel-detector.ts [MODIFY] - Add export for grid detection
|
|
||||||
├── panel-editor.ts [MODIFY] - Add re-detect, connect to service
|
|
||||||
├── page-cache.ts [OPTIONAL] - On-demand detection
|
|
||||||
├── background-color.ts [KEEP]
|
|
||||||
├── chapter-markers.ts [KEEP]
|
|
||||||
├── page-order.ts [KEEP]
|
|
||||||
├── page-scrubber.ts [KEEP]
|
|
||||||
└── panel-gap.ts [KEEP]
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Implementation
|
|
||||||
|
|
||||||
### 1. Panel Detection Service (`panel-detection.service.ts`)
|
|
||||||
|
|
||||||
Create this file in `web/src/reader/comic/`:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Main panel detection service with fallback chain
|
|
||||||
// Priority: OpenCV → ML → Grid → Manual Editor
|
|
||||||
|
|
||||||
interface DetectionResult {
|
|
||||||
panels: Panel[];
|
|
||||||
method: "opencv" | "ml" | "grid" | "manual";
|
|
||||||
confidence: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Panel {
|
|
||||||
id: string;
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
reading_order: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function detectPanels(
|
|
||||||
imageData: ImageData,
|
|
||||||
allowManual: boolean = true
|
|
||||||
): Promise<DetectionResult> {
|
|
||||||
|
|
||||||
// Tier 1: OpenCV Edge Detection
|
|
||||||
try {
|
|
||||||
const panels = await detectPanelsOpenCV(imageData);
|
|
||||||
if (validatePanels(panels, imageData)) {
|
|
||||||
return { panels, method: "opencv", confidence: 0.85 };
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("OpenCV detection failed:", e);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tier 2: ML Detection (COCO-SSD)
|
|
||||||
try {
|
|
||||||
const panels = await detectPanelsML(imageData);
|
|
||||||
if (validatePanels(panels, imageData)) {
|
|
||||||
return { panels, method: "ml", confidence: 0.9 };
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("ML detection failed:", e);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tier 3: Grid Detection (baseline)
|
|
||||||
const panels = detectPanelsGrid(imageData);
|
|
||||||
return { panels, method: "grid", confidence: 0.5 };
|
|
||||||
}
|
|
||||||
|
|
||||||
function validatePanels(panels: Panel[], imageData: ImageData): boolean {
|
|
||||||
// Must have at least 1 panel
|
|
||||||
if (panels.length === 0) return false;
|
|
||||||
|
|
||||||
// Should not have too many panels (probably noise)
|
|
||||||
if (panels.length > 30) return false;
|
|
||||||
|
|
||||||
// Panels should cover reasonable area (not all empty space)
|
|
||||||
let totalArea = panels.reduce((sum, p) => sum + (p.width * p.height), 0);
|
|
||||||
if (totalArea < 10 || totalArea > 100) return false;
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Import detection methods from other files
|
|
||||||
async function detectPanelsOpenCV(imageData: ImageData): Promise<Panel[]>;
|
|
||||||
async function detectPanelsML(imageData: ImageData): Promise<Panel[]>;
|
|
||||||
function detectPanelsGrid(imageData: ImageData, config?: { rows: number; cols: number }): Panel[];
|
|
||||||
|
|
||||||
export { detectPanels, DetectionResult, Panel };
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2. OpenCV Detection (`panel-detection.opencv.ts`)
|
|
||||||
|
|
||||||
Create this file in `web/src/reader/comic/`:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// OpenCV.js-based edge detection for panel boundaries
|
|
||||||
|
|
||||||
interface Panel {
|
|
||||||
id: string;
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
reading_order: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
let openCVLoaded = false;
|
|
||||||
|
|
||||||
async function loadOpenCV(): Promise<void> {
|
|
||||||
if (openCVLoaded) return;
|
|
||||||
|
|
||||||
// OpenCV.js loads asynchronously and registers globally
|
|
||||||
await import("@techstark/opencv-js");
|
|
||||||
|
|
||||||
// Wait for OpenCV to be ready
|
|
||||||
return new Promise<void>((resolve) => {
|
|
||||||
const check = () => {
|
|
||||||
if ((window as any).cv && (window as any).cv.Mat) {
|
|
||||||
openCVLoaded = true;
|
|
||||||
resolve();
|
|
||||||
} else {
|
|
||||||
setTimeout(check, 50);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
check();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function detectPanelsOpenCV(imageData: ImageData): Promise<Panel[]> {
|
|
||||||
await loadOpenCV();
|
|
||||||
|
|
||||||
const cv = (window as any).cv;
|
|
||||||
|
|
||||||
// Create matrices from ImageData
|
|
||||||
const src = cv.matFromImageData(imageData);
|
|
||||||
const gray = new cv.Mat();
|
|
||||||
const blurred = new cv.Mat();
|
|
||||||
const edges = new cv.Mat();
|
|
||||||
const contours = new cv.Mat();
|
|
||||||
const hierarchy = new cv.Mat();
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Convert to grayscale
|
|
||||||
cv.cvtColor(src, gray, cv.COLOR_RGBA2GRAY, 0);
|
|
||||||
|
|
||||||
// Apply Gaussian blur to reduce noise
|
|
||||||
cv.GaussianBlur(gray, blurred, new cv.Size(5, 5), 0, 0, cv.BORDER_DEFAULT);
|
|
||||||
|
|
||||||
// Detect edges using Canny
|
|
||||||
cv.Canny(blurred, edges, 50, 150, 3, false);
|
|
||||||
|
|
||||||
// Find contours
|
|
||||||
cv.findContours(
|
|
||||||
edges,
|
|
||||||
contours,
|
|
||||||
hierarchy,
|
|
||||||
cv.RETR_EXTERNAL,
|
|
||||||
cv.CHAIN_APPROX_SIMPLE
|
|
||||||
);
|
|
||||||
|
|
||||||
// Convert contours to panels
|
|
||||||
const panels: Panel[] = [];
|
|
||||||
const imgWidth = imageData.width;
|
|
||||||
const imgHeight = imageData.height;
|
|
||||||
|
|
||||||
for (let i = 0; i < contours.size(); i++) {
|
|
||||||
const rect = cv.boundingRect(contours.get(i));
|
|
||||||
const aspectRatio = rect.width / rect.height;
|
|
||||||
|
|
||||||
// Filter: reject very small or very thin contours
|
|
||||||
const minSize = Math.min(imgWidth, imgHeight) * 0.05;
|
|
||||||
if (rect.width < minSize || rect.height < minSize) continue;
|
|
||||||
if (aspectRatio < 0.1 || aspectRatio > 10) continue;
|
|
||||||
|
|
||||||
panels.push({
|
|
||||||
id: `opencv-panel-${i}`,
|
|
||||||
x: (rect.x / imgWidth) * 100,
|
|
||||||
y: (rect.y / imgHeight) * 100,
|
|
||||||
width: (rect.width / imgWidth) * 100,
|
|
||||||
height: (rect.height / imgHeight) * 100,
|
|
||||||
reading_order: i,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort panels by reading order (top-left to bottom-right)
|
|
||||||
panels.sort((a, b) => {
|
|
||||||
const rowA = Math.floor(a.y / 25);
|
|
||||||
const rowB = Math.floor(b.y / 25);
|
|
||||||
if (rowA !== rowB) return rowA - rowB;
|
|
||||||
return a.x - b.x;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Reassign reading order after sorting
|
|
||||||
panels.forEach((p, i) => (p.reading_order = i));
|
|
||||||
|
|
||||||
return panels;
|
|
||||||
|
|
||||||
} finally {
|
|
||||||
// Clean up OpenCV matrices
|
|
||||||
src.delete();
|
|
||||||
gray.delete();
|
|
||||||
blurred.delete();
|
|
||||||
edges.delete();
|
|
||||||
contours.delete();
|
|
||||||
hierarchy.delete();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export { detectPanelsOpenCV, loadOpenCV };
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3. ML Detection (`panel-detection.ml.ts`)
|
|
||||||
|
|
||||||
Create this file in `web/src/reader/comic/`:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// ML-based panel detection using COCO-SSD pre-trained model
|
|
||||||
|
|
||||||
interface Panel {
|
|
||||||
id: string;
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
reading_order: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
let model: any = null;
|
|
||||||
let tfLoaded = false;
|
|
||||||
|
|
||||||
async function loadTF(): Promise<void> {
|
|
||||||
if (tfLoaded) return;
|
|
||||||
|
|
||||||
// Load TensorFlow.js
|
|
||||||
await import("@tensorflow/tfjs");
|
|
||||||
tfLoaded = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadModel(): Promise<void> {
|
|
||||||
if (model) return;
|
|
||||||
|
|
||||||
await loadTF();
|
|
||||||
|
|
||||||
// Load COCO-SSD model (pre-trained on millions of images)
|
|
||||||
const cocoSsd = await import("@tensorflow-models/coco-ssd");
|
|
||||||
model = await cocoSsd.load({
|
|
||||||
base: "lite_mobilenet_v2", // Smaller, faster model
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function detectPanelsML(imageData: ImageData): Promise<Panel[]> {
|
|
||||||
await loadModel();
|
|
||||||
|
|
||||||
// Create HTMLCanvasElement to run model inference
|
|
||||||
const canvas = document.createElement("canvas");
|
|
||||||
canvas.width = imageData.width;
|
|
||||||
canvas.height = imageData.height;
|
|
||||||
const ctx = canvas.getContext("2d")!;
|
|
||||||
ctx.putImageData(imageData, 0, 0);
|
|
||||||
|
|
||||||
// Run COCO-SSD model
|
|
||||||
const predictions = await model.detect(canvas);
|
|
||||||
|
|
||||||
// Filter predictions to find rectangular regions (panels)
|
|
||||||
// COCO-SSD detects common objects, we look for rectangular ones
|
|
||||||
const panels: Panel[] = [];
|
|
||||||
const imgWidth = imageData.width;
|
|
||||||
const imgHeight = imageData.height;
|
|
||||||
|
|
||||||
for (let i = 0; i < predictions.length; i++) {
|
|
||||||
const pred = predictions[i];
|
|
||||||
|
|
||||||
// COCO-SSD detects "book" and similar objects
|
|
||||||
// We filter for reasonable panel-like detections
|
|
||||||
const [x, y, w, h] = pred.bbox;
|
|
||||||
const aspectRatio = w / h;
|
|
||||||
|
|
||||||
const isRectangular =
|
|
||||||
aspectRatio > 0.3 && // Not too tall/thin
|
|
||||||
aspectRatio < 5 && // Not too wide
|
|
||||||
w > imgWidth * 0.05 && // Not too small
|
|
||||||
h > imgHeight * 0.05;
|
|
||||||
|
|
||||||
if (isRectangular) {
|
|
||||||
panels.push({
|
|
||||||
id: `ml-panel-${i}`,
|
|
||||||
x: (x / imgWidth) * 100,
|
|
||||||
y: (y / imgHeight) * 100,
|
|
||||||
width: (w / imgWidth) * 100,
|
|
||||||
height: (h / imgHeight) * 100,
|
|
||||||
reading_order: i,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort panels by reading order
|
|
||||||
panels.sort((a, b) => {
|
|
||||||
const rowA = Math.floor(a.y / 25);
|
|
||||||
const rowB = Math.floor(b.y / 25);
|
|
||||||
if (rowA !== rowB) return rowA - rowB;
|
|
||||||
return a.x - b.x;
|
|
||||||
});
|
|
||||||
|
|
||||||
panels.forEach((p, i) => (p.reading_order = i));
|
|
||||||
|
|
||||||
return panels;
|
|
||||||
}
|
|
||||||
|
|
||||||
export { detectPanelsML, loadModel };
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4. Grid Detection (`panel-detector.ts` - Update)
|
|
||||||
|
|
||||||
Modify the existing `panel-detector.ts` to add the export at the end:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Grid-based panel detection (fast, lightweight)
|
|
||||||
// Keep as final fallback
|
|
||||||
|
|
||||||
interface Panel {
|
|
||||||
id: string;
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
reading_order: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GridConfig {
|
|
||||||
rows: number;
|
|
||||||
cols: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function detectPanelsGrid(
|
|
||||||
imageData: ImageData,
|
|
||||||
config: GridConfig = { rows: 3, cols: 3 },
|
|
||||||
): Panel[] {
|
|
||||||
const panels: Panel[] = [];
|
|
||||||
const cellWidth = imageData.width / config.cols;
|
|
||||||
const cellHeight = imageData.height / config.rows;
|
|
||||||
|
|
||||||
for (let y = 0; y < config.rows; y++) {
|
|
||||||
for (let x = 0; x < config.cols; x++) {
|
|
||||||
const cell = extractCell(imageData, x, y, cellWidth, cellHeight);
|
|
||||||
|
|
||||||
if (!isEmpty(cell)) {
|
|
||||||
panels.push({
|
|
||||||
id: `panel-${panels.length}`,
|
|
||||||
x: (x / config.cols) * 100,
|
|
||||||
y: (y / config.rows) * 100,
|
|
||||||
width: (1 / config.cols) * 100,
|
|
||||||
height: (1 / config.rows) * 100,
|
|
||||||
reading_order: panels.length,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return mergeAdjacentPanels(panels);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isEmpty(cellData: ImageData): boolean {
|
|
||||||
let emptyPixels = 0;
|
|
||||||
const totalPixels = cellData.width * cellData.height;
|
|
||||||
const threshold = 0.95;
|
|
||||||
|
|
||||||
for (let i = 0; i < cellData.data.length; i += 4) {
|
|
||||||
const r = cellData.data[i];
|
|
||||||
const g = cellData.data[i + 1];
|
|
||||||
const b = cellData.data[i + 2];
|
|
||||||
const a = cellData.data[i + 3];
|
|
||||||
|
|
||||||
if (a < 10 || (r > 250 && g > 250 && b > 250)) {
|
|
||||||
emptyPixels++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return emptyPixels / totalPixels > threshold;
|
|
||||||
}
|
|
||||||
|
|
||||||
function mergeAdjacentPanels(panels: Panel[]): Panel[] {
|
|
||||||
const merged: Panel[] = [];
|
|
||||||
const used = new Set<number>();
|
|
||||||
|
|
||||||
for (let i = 0; i < panels.length; i++) {
|
|
||||||
if (used.has(i)) continue;
|
|
||||||
|
|
||||||
let current = { ...panels[i] };
|
|
||||||
used.add(i);
|
|
||||||
|
|
||||||
for (let j = i + 1; j < panels.length; j++) {
|
|
||||||
if (used.has(j)) continue;
|
|
||||||
if (isAdjacent(current, panels[j])) {
|
|
||||||
current = mergePanels(current, panels[j]);
|
|
||||||
used.add(j);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
merged.push(current);
|
|
||||||
}
|
|
||||||
|
|
||||||
return merged;
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractCell(
|
|
||||||
imageData: ImageData,
|
|
||||||
gridX: number,
|
|
||||||
gridY: number,
|
|
||||||
cellWidth: number,
|
|
||||||
cellHeight: number,
|
|
||||||
): ImageData {
|
|
||||||
const startX = Math.floor(gridX * cellWidth);
|
|
||||||
const startY = Math.floor(gridY * cellHeight);
|
|
||||||
const width = Math.floor(cellWidth);
|
|
||||||
const height = Math.floor(cellHeight);
|
|
||||||
|
|
||||||
const cellData = new Uint8ClampedArray(width * height * 4);
|
|
||||||
for (let y = 0; y < height; y++) {
|
|
||||||
for (let x = 0; x < width; x++) {
|
|
||||||
const srcIdx = ((startY + y) * imageData.width + (startX + x)) * 4;
|
|
||||||
const destIdx = (y * width + x) * 4;
|
|
||||||
cellData[destIdx] = imageData.data[srcIdx];
|
|
||||||
cellData[destIdx + 1] = imageData.data[srcIdx + 1];
|
|
||||||
cellData[destIdx + 2] = imageData.data[srcIdx + 2];
|
|
||||||
cellData[destIdx + 3] = imageData.data[srcIdx + 3];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return new ImageData(cellData, width, height);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isAdjacent(p1: Panel, p2: Panel): boolean {
|
|
||||||
const tolerance = 5;
|
|
||||||
if (Math.abs(p1.y - p2.y) < tolerance && Math.abs(p1.height - p2.height) < tolerance) {
|
|
||||||
return Math.abs(p1.x + p1.width - p2.x) < tolerance || Math.abs(p2.x + p2.width - p1.x) < tolerance;
|
|
||||||
}
|
|
||||||
if (Math.abs(p1.x - p2.x) < tolerance && Math.abs(p1.width - p2.width) < tolerance) {
|
|
||||||
return Math.abs(p1.y + p1.height - p2.y) < tolerance || Math.abs(p2.y + p2.height - p1.y) < tolerance;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function mergePanels(p1: Panel, p2: Panel): Panel {
|
|
||||||
const minX = Math.min(p1.x, p2.x);
|
|
||||||
const minY = Math.min(p1.y, p2.y);
|
|
||||||
const maxX = Math.max(p1.x + p1.width, p2.x + p2.width);
|
|
||||||
const maxY = Math.max(p1.y + p1.height, p2.y + p2.height);
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: p1.id,
|
|
||||||
x: minX,
|
|
||||||
y: minY,
|
|
||||||
width: maxX - minX,
|
|
||||||
height: maxY - minY,
|
|
||||||
reading_order: Math.min(p1.reading_order, p2.reading_order),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ADD THIS EXPORT AT THE END OF THE FILE
|
|
||||||
export { detectPanelsGrid, isEmpty, mergeAdjacentPanels, extractCell, isAdjacent, mergePanels };
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5. Panel Editor Updates (`panel-editor.ts`)
|
|
||||||
|
|
||||||
Modify the existing `panel-editor.ts` to add imports and re-detect function:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Manual panel editor for admins/power users
|
|
||||||
|
|
||||||
import { Alpine } from "../../alpine";
|
|
||||||
import { apiPut } from "../../api";
|
|
||||||
import { detectPanels, Panel } from "./panel-detection.service";
|
|
||||||
|
|
||||||
async function loadImageForPage(pageNumber: number): Promise<HTMLImageElement> {
|
|
||||||
const mediaItemId = document.body.dataset.mediaItemId;
|
|
||||||
if (!mediaItemId) {
|
|
||||||
throw new Error("No mediaItemId found");
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = localStorage.getItem("token");
|
|
||||||
const response = await fetch(`/readers/${mediaItemId}/pages/${pageNumber}`, {
|
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to load page ${pageNumber}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const blob = await response.blob();
|
|
||||||
const img = new Image();
|
|
||||||
img.src = URL.createObjectURL(blob);
|
|
||||||
|
|
||||||
await new Promise<void>((resolve) => {
|
|
||||||
img.onload = () => resolve();
|
|
||||||
});
|
|
||||||
|
|
||||||
return img;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCurrentPageNumber(): number {
|
|
||||||
const Alpine = (window as any).Alpine;
|
|
||||||
if (Alpine) {
|
|
||||||
const readerEl = document.querySelector('[x-data="readerShell"]');
|
|
||||||
if (readerEl) {
|
|
||||||
const readerShell = Alpine.$data(readerEl);
|
|
||||||
if (readerShell?.currentPage) {
|
|
||||||
return readerShell.currentPage;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const content = document.getElementById("reader-content");
|
|
||||||
const pageFromDataset = content?.dataset.currentPage;
|
|
||||||
if (pageFromDataset) {
|
|
||||||
return parseInt(pageFromDataset, 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadPage(pageNumber: number): void {
|
|
||||||
window.dispatchEvent(
|
|
||||||
new CustomEvent("navigate-to-page", { detail: { page: pageNumber } }),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function openPanelEditor(pageNumber: number): void {
|
|
||||||
const modal = document.getElementById("panel-editor-modal");
|
|
||||||
modal?.classList.remove("hidden");
|
|
||||||
|
|
||||||
const canvas = document.getElementById("panel-editor-canvas") as HTMLCanvasElement;
|
|
||||||
const ctx = canvas?.getContext("2d");
|
|
||||||
|
|
||||||
loadImageForPage(pageNumber).then((image) => {
|
|
||||||
canvas!.width = image.width;
|
|
||||||
canvas!.height = image.height;
|
|
||||||
ctx?.drawImage(image, 0, 0);
|
|
||||||
|
|
||||||
enablePanelDrawing(canvas!);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function enablePanelDrawing(canvas: HTMLCanvasElement): void {
|
|
||||||
let isDrawing = false;
|
|
||||||
let startX = 0;
|
|
||||||
let startY = 0;
|
|
||||||
|
|
||||||
canvas.addEventListener("mousedown", (e) => {
|
|
||||||
isDrawing = true;
|
|
||||||
startX = e.offsetX;
|
|
||||||
startY = e.offsetY;
|
|
||||||
});
|
|
||||||
|
|
||||||
canvas.addEventListener("mousemove", (e) => {
|
|
||||||
if (!isDrawing) return;
|
|
||||||
|
|
||||||
const ctx = canvas.getContext("2d");
|
|
||||||
// Clear and redraw to show selection rectangle
|
|
||||||
ctx?.clearRect(0, 0, canvas.width, canvas.height);
|
|
||||||
ctx?.drawImage(canvas, 0, 0);
|
|
||||||
ctx?.strokeRect(startX, startY, e.offsetX - startX, e.offsetY - startY);
|
|
||||||
});
|
|
||||||
|
|
||||||
canvas.addEventListener("mouseup", (e) => {
|
|
||||||
if (!isDrawing) return;
|
|
||||||
isDrawing = false;
|
|
||||||
|
|
||||||
const panel: Panel = {
|
|
||||||
id: `manual-${Date.now()}`,
|
|
||||||
x: (startX / canvas.width) * 100,
|
|
||||||
y: (startY / canvas.height) * 100,
|
|
||||||
width: ((e.offsetX - startX) / canvas.width) * 100,
|
|
||||||
height: ((e.offsetY - startY) / canvas.height) * 100,
|
|
||||||
reading_order: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
saveManualPanel(panel);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveManualPanel(panel: Panel): Promise<void> {
|
|
||||||
const mediaItemId = document.body.dataset.mediaItemId;
|
|
||||||
const pageNumber = getCurrentPageNumber();
|
|
||||||
|
|
||||||
await apiPut(`/readers/${mediaItemId}/panels/${pageNumber}`, {
|
|
||||||
detection_method: "manual",
|
|
||||||
panels: [panel],
|
|
||||||
});
|
|
||||||
|
|
||||||
loadPage(pageNumber);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-detect panels using detection service
|
|
||||||
async function reDetectPanels(pageNumber: number): Promise<Panel[]> {
|
|
||||||
const image = await loadImageForPage(pageNumber);
|
|
||||||
|
|
||||||
const canvas = document.createElement("canvas");
|
|
||||||
canvas.width = image.width;
|
|
||||||
canvas.height = image.height;
|
|
||||||
const ctx = canvas.getContext("2d")!;
|
|
||||||
ctx.drawImage(image, 0, 0);
|
|
||||||
|
|
||||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
|
||||||
const result = await detectPanels(imageData, true);
|
|
||||||
|
|
||||||
return result.panels;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Alpine component
|
|
||||||
Alpine.data("panelEditor", () => ({
|
|
||||||
get isComicOrManga(): boolean {
|
|
||||||
const libraryType = document.body.dataset.mediaType;
|
|
||||||
return libraryType === "comic" || libraryType === "manga";
|
|
||||||
},
|
|
||||||
|
|
||||||
openPanelEditor(pageNumber: number) {
|
|
||||||
openPanelEditor(pageNumber);
|
|
||||||
},
|
|
||||||
|
|
||||||
async reDetectPanels(pageNumber: number) {
|
|
||||||
const panels = await reDetectPanels(pageNumber);
|
|
||||||
return panels;
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
export { openPanelEditor, reDetectPanels };
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 6. Page Cache Integration (`page-cache.ts` - Optional)
|
|
||||||
|
|
||||||
Optional: Add on-demand panel detection to page-cache.ts:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Add this import at the top
|
|
||||||
import { detectPanels } from "./panel-detection.service";
|
|
||||||
|
|
||||||
// Add to PageCacheState interface
|
|
||||||
interface PageCacheState {
|
|
||||||
cache: Map<number, HTMLImageElement>;
|
|
||||||
loading: Set<number>;
|
|
||||||
maxAhead: number;
|
|
||||||
mediaItemId: string;
|
|
||||||
panelData: Map<number, { panels: any[]; method: string; confidence: number }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add this function
|
|
||||||
async function detectPagePanels(
|
|
||||||
state: PageCacheState,
|
|
||||||
pageNumber: number
|
|
||||||
): Promise<any[]> {
|
|
||||||
// Check if already detected
|
|
||||||
if (state.panelData?.has(pageNumber)) {
|
|
||||||
return state.panelData.get(pageNumber)!.panels;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get or create image
|
|
||||||
let image: HTMLImageElement;
|
|
||||||
if (state.cache.has(pageNumber)) {
|
|
||||||
image = state.cache.get(pageNumber)!;
|
|
||||||
} else {
|
|
||||||
image = await loadComicPage(state, pageNumber);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run detection on demand
|
|
||||||
const canvas = document.createElement("canvas");
|
|
||||||
canvas.width = image.width;
|
|
||||||
canvas.height = image.height;
|
|
||||||
const ctx = canvas.getContext("2d")!;
|
|
||||||
ctx.drawImage(image, 0, 0);
|
|
||||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
|
||||||
|
|
||||||
const result = await detectPanels(imageData, true);
|
|
||||||
|
|
||||||
if (!state.panelData) {
|
|
||||||
state.panelData = new Map();
|
|
||||||
}
|
|
||||||
state.panelData.set(pageNumber, result);
|
|
||||||
|
|
||||||
return result.panels;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Export the new function
|
|
||||||
export { createPageCache, getCachedPage, loadComicPage, detectPagePanels };
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Implementation Order
|
|
||||||
|
|
||||||
1. **Add dependencies to `package.json`** and run `npm install`
|
|
||||||
2. **Create `panel-detection.service.ts`**
|
|
||||||
3. **Create `panel-detection.opencv.ts`**
|
|
||||||
4. **Create `panel-detection.ml.ts`**
|
|
||||||
5. **Update `panel-detector.ts`** - add export statement (one line at the end)
|
|
||||||
6. **Update `panel-editor.ts`** - add imports and re-detect function
|
|
||||||
7. **(Optional) Update `page-cache.ts`** - add on-demand detection
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Future Enhancements
|
|
||||||
|
|
||||||
1. **User feedback loop:** Store user corrections to improve detection
|
|
||||||
2. **Per-comic detection:** Different methods for different comic styles
|
|
||||||
3. **Batch detection:** Pre-detect pages in background
|
|
||||||
4. **Detection history:** Track which method works best per comic
|
|
||||||
5. **Panel preview:** Show detected panels before entering panel view
|
|
||||||
@@ -1,480 +0,0 @@
|
|||||||
# Combined Project Guidelines for Bookhoard
|
|
||||||
|
|
||||||
## 🚨 CRITICAL PROHIBITIONS (Never violate these)
|
|
||||||
|
|
||||||
### Backend & Database
|
|
||||||
|
|
||||||
- ❌ **NEVER modify backend code when working on frontend-only tasks*
|
|
||||||
- ❌ **NEVER modify database schema** unless explicitly instructed for full-stack changes
|
|
||||||
- ❌ **NEVER use Docker** - use Podman only
|
|
||||||
- ❌ **NEVER build server binaries locally** - all builds through Dockerfile/docker-compose
|
|
||||||
- ❌ **NEVER create new migration files** - merge changes into current one until release
|
|
||||||
- ❌ **NEVER use `git checkout` on schema files** without checking what will be lost
|
|
||||||
- ❌ **NEVER break existing functionality** unless explicitly instructed
|
|
||||||
- ❌ **NEVER duplicate business logic** - keep logic in services, not handlers
|
|
||||||
- ❌ **NEVER bypass service layer** - all database operations must go through services
|
|
||||||
|
|
||||||
### Testing
|
|
||||||
|
|
||||||
- ✅ **ALWAYS use `setupTestServer()` helper from `cmd/server/tests/test_helpers_test.go`*
|
|
||||||
- ✅ **Share one test setup across all subtests** - call `setupTestServer()` once at test function level, not per subtest
|
|
||||||
- ✅ **Prefer table-driven tests** - use `t.Run()` with test cases instead of duplicate test functions
|
|
||||||
- ✅ **Configure database pools efficiently** - use `max_conns=1` for test pools (via `pgxpool.ParseConfig()`) to prevent connection exhaustion
|
|
||||||
- ❌ **NEVER create separate `pgxpool` per test** - each pool creates 4 connections by default; 78 tests = 312 potential connections > PostgreSQL's 100 limit
|
|
||||||
- ❌ **NEVER call `setupTestServer()` in loops or within subtests** - creates unnecessary database pools and exhausts connections
|
|
||||||
- ✅ **DO verify tests pass** - run full test suite before completing work
|
|
||||||
- ✅ **Use `t.Cleanup()` properly** - the `TestServerSetup` pattern automatically handles cleanup via `t.Cleanup()`
|
|
||||||
|
|
||||||
### Frontend & Styling
|
|
||||||
|
|
||||||
- ❌ **NEVER modify backend/API for frontend features without user confirmation*
|
|
||||||
- ❌ **NEVER use custom CSS** - TailwindCSS classes only
|
|
||||||
- **⚠️ EXCEPTION**: `templates/error.templ` may have inline CSS because error pages must work when main app fails (404, server errors, CSS fails to load)
|
|
||||||
- ❌ **NEVER use JavaScript** - convert all to TypeScript
|
|
||||||
- **⚠️ EXCEPTION**: Inline JS function calls in HTML attributes (e.g., `onclick="myFunction()"`) are acceptable for simple interactions
|
|
||||||
- ❌ **NEVER use Object-Oriented Programming** (no classes, inheritance, or this-capture)
|
|
||||||
- ✅ **DO use procedural/imperative style** as your default
|
|
||||||
- ✅ **DO borrow functional techniques** when they simplify code
|
|
||||||
- ✅ **DO avoid ideological purity** - the best paradigm is the one that fits the problem
|
|
||||||
- ❌ **NEVER add new Dockerfiles without user confirmation*
|
|
||||||
- ❌ **NEVER fetch initial data via AJAX on page load** - use server-side rendering instead
|
|
||||||
- ❌ **NEVER break progressive enhancement** - pages must work without JavaScript
|
|
||||||
|
|
||||||
**Note:** Go methods in the backend are fine and encouraged. This guideline applies to TypeScript/JavaScript frontend code only.
|
|
||||||
|
|
||||||
### General
|
|
||||||
|
|
||||||
- ❌ **NEVER skip pre-commit hooks** unless explicitly requested
|
|
||||||
- ❌ **NEVER force push to main/master** branches
|
|
||||||
- ❌ **NEVER commit files with secrets** (.env, credentials.json, etc.)
|
|
||||||
- ❌ **NEVER make assumptions** - ask clarifying questions when uncertain
|
|
||||||
- ❌ **NEVER delete code without reading full context first** (minimum 20 lines before/after)
|
|
||||||
- ❌ **NEVER make cascading fix-up edits without git diff review*
|
|
||||||
- After compilation error: STOP, review `git diff`, understand full impact
|
|
||||||
- Use revert/reapply pattern instead of blind fixes
|
|
||||||
- ❌ **NEVER skip post-edit verification** - must compile after each file edit
|
|
||||||
- ❌ **NEVER run git commands concurrently** - always run sequentially:
|
|
||||||
- Run `git add <files>` and wait for completion
|
|
||||||
- Run `git commit -m "<message>"` and wait for completion
|
|
||||||
- Run `git push` and wait for completion
|
|
||||||
- Never use `&&` to chain git commands together
|
|
||||||
- ✅ **Make good organized git commits for the entire project (not just what you changed) and push - run git add, commit, push sequentially as separate commands, no need to repeatedly check status*
|
|
||||||
- ✅ **Indentation is always 2 spaces unless the language prohibits it*
|
|
||||||
|
|
||||||
### Cascading Fix-up Pattern (PROHIBITED)
|
|
||||||
|
|
||||||
**WHAT NOT TO DO** - This caused critical bugs:
|
|
||||||
|
|
||||||
```go
|
|
||||||
// ❌ WRONG: Blindly making fixes after compilation error
|
|
||||||
|
|
||||||
Edit 1: Delete deprecated code
|
|
||||||
[Compilation error: undefined Register]
|
|
||||||
|
|
||||||
Edit 2: Try to fix error (over-broad deletion)
|
|
||||||
[More errors: undefined Login, GetProfile, etc.]
|
|
||||||
|
|
||||||
Edit 3: Try to fix again (worse damage)
|
|
||||||
[Even more errors: major functionality broken]
|
|
||||||
```
|
|
||||||
|
|
||||||
**CORRECT APPROACH**:
|
|
||||||
|
|
||||||
```go
|
|
||||||
// ✅ CORRECT: Stop, understand, then fix deliberately
|
|
||||||
|
|
||||||
Edit 1: Delete deprecated code
|
|
||||||
[Compilation error: undefined Register]
|
|
||||||
|
|
||||||
STOP → Review git diff → Understand Register was accidentally deleted
|
|
||||||
RESTORE → Get exact Register function from git history
|
|
||||||
VERIFY → Compile successfully
|
|
||||||
```
|
|
||||||
|
|
||||||
**Key Principle**: When compilation errors occur after edits:
|
|
||||||
|
|
||||||
1. STOP - Don't make more edits
|
|
||||||
2. ANALYZE - Use `git diff` to understand what was changed
|
|
||||||
3. RECOVER - Restore what was accidentally deleted/broken
|
|
||||||
4. VERIFY - Compile and test before proceeding
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 CONTEXT-SPECIFIC RULES
|
|
||||||
|
|
||||||
### When Working on Frontend-Only Tasks
|
|
||||||
|
|
||||||
- **DO NOT touch backend code** - handlers, services, database layer
|
|
||||||
- **DO NOT modify API routes** - use existing endpoints only
|
|
||||||
- **DO NOT change database schema** - work with existing structure
|
|
||||||
- **If backend change seems necessary**:
|
|
||||||
1. Identify the required change
|
|
||||||
2. Explain why you need it
|
|
||||||
3. Provide impact analysis
|
|
||||||
4. **ASK FOR USER CONFIRMATION before proceeding*
|
|
||||||
|
|
||||||
### When Working on Full-Stack Tasks
|
|
||||||
|
|
||||||
- Backend changes are allowed when explicitly part of the task
|
|
||||||
- Still follow all database protocols (atomic changes, validation, etc.)
|
|
||||||
- **If modifying database schema:** Update local database after schema.sql changes (see Database Operations section)
|
|
||||||
- Still use Podman for all builds
|
|
||||||
- Still include Bruno requests for API changes
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✅ MANDATORY REQUIREMENTS
|
|
||||||
|
|
||||||
### Database Operations (Full-Stack Tasks Only)
|
|
||||||
|
|
||||||
- ✅ Follow **pgx v5 standards** for all database operations
|
|
||||||
- ✅ Treat schema changes as **ATOMIC** - complete success or complete rejection
|
|
||||||
- ✅ **⚠️ CRITICAL: This is a pre-production application (NO production deployments exist)*
|
|
||||||
- When `database/schema/schema.sql` is updated, local databases must be updated
|
|
||||||
- **Option 1 (Recommended):** Recreate database with fresh schema:
|
|
||||||
```bash
|
|
||||||
podman compose down -v # Delete volumes (WARNING: loses all data)
|
|
||||||
podman compose up -d # Start fresh with new schema
|
|
||||||
```
|
|
||||||
- **Option 2:** Manually apply schema changes to existing database using psql
|
|
||||||
- **DO NOT create migration files** - no legacy schema support needed
|
|
||||||
- ✅ Use **pre-change checklist**: read schema → identify columns → plan changes → verify → read back
|
|
||||||
- ✅ **Post-change validation**: ensure schema.sql, models.go, and queries.sql are in sync
|
|
||||||
|
|
||||||
### Build & Deployment
|
|
||||||
|
|
||||||
- ✅ Use **Podman** exclusively (not Docker)
|
|
||||||
- ✅ All builds through existing **Dockerfile** and **docker-compose.yml*
|
|
||||||
- ✅ Stop building server binaries - everything goes through containers
|
|
||||||
|
|
||||||
### API Changes (Full-Stack Tasks Only)
|
|
||||||
|
|
||||||
- ✅ Include **Bruno OpenCollection YAML requests** with all API documentation
|
|
||||||
- ✅ Integration Tests (cmd/server/tests) must be **comprehensive and cover three contexts**: no user, user, and admin
|
|
||||||
- ✅ Maintain backward compatibility for mobile apps and external consumers
|
|
||||||
|
|
||||||
### Frontend & Styling
|
|
||||||
|
|
||||||
- ✅ Always use **TailwindCSS classes** for all styling
|
|
||||||
- ✅ Convert all JavaScript to **TypeScript**
|
|
||||||
- ✅ **Never use Object-Oriented Programming** (no classes, inheritance, or this-capture)
|
|
||||||
- ✅ **Use procedural/imperative style** as your default
|
|
||||||
- ✅ **Borrow functional techniques** when they simplify code
|
|
||||||
- ✅ **Avoid ideological purity** - the best paradigm is the one that fits the problem
|
|
||||||
- ✅ **Extract domain concepts/types only when clearly beneficial** - apply YAGNI, avoid over-engineering
|
|
||||||
- ✅ **Render initial data server-side** in Go templates for fast page loads
|
|
||||||
- ✅ **Use JavaScript/HTMX for CRUD operations** (create, update, delete)
|
|
||||||
- ✅ **Ensure progressive enhancement** - pages work without JavaScript
|
|
||||||
|
|
||||||
### Service Layer Architecture
|
|
||||||
|
|
||||||
- ✅ **All business logic in services** - never in handlers
|
|
||||||
- ✅ **Services must be reusable** by both SSR handlers and API endpoints
|
|
||||||
- ✅ **Database operations through services only** - never direct from handlers
|
|
||||||
- ✅ **When adding features**: Add service logic → Create API endpoint → Use SSR for initial render → Use JS for updates
|
|
||||||
|
|
||||||
### Code Organization
|
|
||||||
|
|
||||||
- ✅ Minimize project structure changes
|
|
||||||
- ✅ Place new files in **contextually appropriate directories*
|
|
||||||
- ✅ Follow **KISS**, **DRY**, and **YAGNI** principles
|
|
||||||
- ✅ Use **multiple, logical git commits** with clear messages
|
|
||||||
- ❌ **NEVER duplicate types between handlers and templates** - define data types in handlers, reuse directly in templates
|
|
||||||
- ✅ **DO share handler types with templates** - templates should use handlers.CollectionData, handlers.BookInfo, etc. directly
|
|
||||||
- ❌ **NEVER create parallel type systems** - no templates.XxxData types for data that originates from handlers
|
|
||||||
- ✅ **DO enhance handler types with template-specific fields** when needed (e.g., add Icon, FormattedDate fields to handlers structs)
|
|
||||||
- ✅ **DO keep template-only types in templates package** - PageData, UnsafeHTML, and template-specific utilities are appropriate
|
|
||||||
- ❌ **NEVER create conversion helper functions** to map between handler and template types - use handler types directly
|
|
||||||
|
|
||||||
### Configuration & Environment
|
|
||||||
|
|
||||||
- ✅ If **.env is missing**, auto-generate secure values
|
|
||||||
- ✅ Never commit secrets to repository
|
|
||||||
|
|
||||||
### Code Modification Safety
|
|
||||||
|
|
||||||
- ✅ **Post-Edit Verification (MANDATORY for ALL file modifications)**:
|
|
||||||
- Run `go build` for affected packages immediately after each edit
|
|
||||||
- Review `git diff filename` to verify only intended changes
|
|
||||||
- Validate functionality still works as expected
|
|
||||||
- Never proceed to next file until current edit is verified
|
|
||||||
- ✅ **Backup Before Large Changes**:
|
|
||||||
- Create a stash: `git stash push -m "Pre-cleanup snapshot"` before removing >50 lines
|
|
||||||
- Or create a backup branch: `git branch backup-before-cleanup`
|
|
||||||
- This allows instant recovery if mistakes occur
|
|
||||||
- ✅ **Large Deletion Safety Pattern**:
|
|
||||||
- Read at least 20 lines before/after deletion target
|
|
||||||
- Include unique identifiers in match (function signatures, specific comments)
|
|
||||||
- Use narrow matches - avoid generic patterns
|
|
||||||
- Verify line numbers match intended section
|
|
||||||
|
|
||||||
### Documentation
|
|
||||||
|
|
||||||
**Documentation Structure** (updated with full docs system):
|
|
||||||
|
|
||||||
- ✅ **README.md** - Project overview, quick start, and setup instructions only
|
|
||||||
- ✅ **docs/** - Comprehensive documentation system with search
|
|
||||||
- ✅ **docs/developer/api/** - API reference documentation (split by endpoint/category)
|
|
||||||
- ✅ **docs/user/** - User-facing features, guides, and workflows
|
|
||||||
- ✅ **docs/user/devices/** - Device setup guides (KOBO, KOReader, etc.)
|
|
||||||
- ✅ **docs/contributing/** - Development and contribution guides
|
|
||||||
|
|
||||||
**Where to document changes**:
|
|
||||||
|
|
||||||
| Change Type | Location | Examples |
|
|
||||||
| ----------------------------------- | ------------------------------------------------------------- | --------------------------------------------------------- |
|
|
||||||
| **User-facing features** | `docs/user/` | New features, UI changes, workflows |
|
|
||||||
| **API endpoints** | `docs/developer/api/<category>/<endpoint>.md` | New endpoints, modified responses, authentication changes |
|
|
||||||
| **API behavior** | Update existing `docs/developer/api/` files | Parameter changes, error codes, rate limits |
|
|
||||||
| **Device setup** | `docs/user/devices/` | New device support, setup instructions |
|
|
||||||
| **Development** | `docs/contributing/` | Build changes, architecture decisions |
|
|
||||||
| **Quick start/setup** | `README.md` | Installation, environment setup, first-run |
|
|
||||||
| **Breaking changes** | Both `README.md` and relevant `docs/` | Migration guides, deprecation notices |
|
|
||||||
| **Bug fixes** | Update relevant `docs/` only if user-visible | Clarifications, troubleshooting additions |
|
|
||||||
| **Bruno OpenCollection YAML requests** | `.yml` files in bruno folder in appropriate folder/sub-folder | API contract testing, examples |
|
|
||||||
|
|
||||||
**Documentation Update Workflow**:
|
|
||||||
|
|
||||||
1. **Identify the audience** (end users, developers, API consumers)
|
|
||||||
2. **Choose appropriate location** based on table above
|
|
||||||
3. **Update documentation** before or with code changes
|
|
||||||
4. **Verify documentation renders** at `/docs` endpoint
|
|
||||||
5. **Test search** finds new/updated content
|
|
||||||
6. **For API changes**: Update both `docs/developer/api/` files, integration test files (cmd/server/tests) AND Bruno OpenCollection YAML `.yml` files
|
|
||||||
7. **Commit separately** with clear message: `docs: <description>`
|
|
||||||
|
|
||||||
**When in doubt**:
|
|
||||||
|
|
||||||
- End-user visible → `docs/user/`
|
|
||||||
- API reference → `docs/developer/api/`
|
|
||||||
- Setup/onboarding → `README.md`
|
|
||||||
- Development related → `docs/contributing/`
|
|
||||||
|
|
||||||
### Process & Continuity
|
|
||||||
|
|
||||||
- ✅ If mid-task and receive "no response", **continue the task*
|
|
||||||
- ✅ Verify no regressions before modifying/removing code
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔧 TECHNICAL STANDARDS
|
|
||||||
|
|
||||||
### Backend Stack
|
|
||||||
|
|
||||||
- **Language**: Go 1.25+
|
|
||||||
- **Database**: PostgreSQL 15+ with **pgx v5 driver** only
|
|
||||||
- **Authentication**: JWT tokens with bcrypt password hashing
|
|
||||||
- **Architecture**: Service layer pattern (handlers → services → database)
|
|
||||||
|
|
||||||
### Frontend Stack
|
|
||||||
|
|
||||||
- **Rendering**: SSR-first - Go templates render initial page with all data
|
|
||||||
- **Interactivity**: Alpine.js for UI state (modals, dropdowns, transitions)
|
|
||||||
- **Dynamic Updates**: HTMX for CRUD operations (no full page reloads)
|
|
||||||
- **Styling**: TailwindCSS (no custom CSS)
|
|
||||||
- **Language**: TypeScript (no JavaScript)
|
|
||||||
- **Patterns**: Procedural/imperative with functional techniques where helpful (no OOP)
|
|
||||||
|
|
||||||
### SSR-First Principles
|
|
||||||
|
|
||||||
- ❌ **NEVER fetch initial data via AJAX** - server renders complete page
|
|
||||||
- ❌ **NEVER fetch data in Alpine x-init** - data already SSR'd
|
|
||||||
- ✅ **Use Alpine.js only for UI state** - modal visibility, dropdown toggles
|
|
||||||
- ✅ **Use HTMX for dynamic operations** - form submissions, partial page updates
|
|
||||||
- ✅ **x-init is for setup only** - event listeners, not data fetching
|
|
||||||
- ✅ **Data fetching happens after user actions** - not on page load
|
|
||||||
|
|
||||||
### Page Type Classifications
|
|
||||||
|
|
||||||
1. **Type 1: 80% SSR (most pages)**
|
|
||||||
- Backend provides all initial data
|
|
||||||
- Alpine handles modals/dropdowns only
|
|
||||||
- x-init NEVER fetches data
|
|
||||||
|
|
||||||
2. **Type 2: SSR + Interactive (dashboard, bookshelf)**
|
|
||||||
- Backend provides initial data
|
|
||||||
- Alpine handles interactivity (drag-drop, filtering)
|
|
||||||
- x-init ONLY sets up event listeners
|
|
||||||
|
|
||||||
3. **Type 3: 80% TypeScript (analytics, complex dashboards)**
|
|
||||||
- Some client-side data fetching acceptable
|
|
||||||
- Still prefer SSR when possible
|
|
||||||
|
|
||||||
### Containerization
|
|
||||||
|
|
||||||
- **Runtime**: Podman (not Docker)
|
|
||||||
- **Build**: Existing Dockerfile and docker-compose.yml only
|
|
||||||
- **No local builds** allowed
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔄 ERROR RECOVERY PROTOCOL
|
|
||||||
|
|
||||||
When code modification mistakes occur (deleted wrong code, broke compilation, etc.):
|
|
||||||
|
|
||||||
### Immediate Actions
|
|
||||||
|
|
||||||
1. **STOP** - Don't make more edits
|
|
||||||
2. **ASSESS** - What was deleted? Is it critical?
|
|
||||||
3. **REVIEW** - Run `git diff` to see exact changes
|
|
||||||
4. **RESTORE** - Choose appropriate recovery method:
|
|
||||||
- **Recent mistake**: `git checkout -- filename`
|
|
||||||
- **Complex restoration**: Use `git show HEAD:filename` to recover deleted code
|
|
||||||
- **Multiple files**: `git reset HEAD~1` (if safe)
|
|
||||||
5. **VERIFY** - Compile and test restored code
|
|
||||||
6. **DOCUMENT** - Note what went wrong for future reference
|
|
||||||
|
|
||||||
### Recovery Examples
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Recover a deleted function from original file
|
|
||||||
git show HEAD:internal/handlers/auth.go | sed -n '70,275p' > recovery.txt
|
|
||||||
|
|
||||||
# Revert entire file to original state
|
|
||||||
git checkout HEAD -- internal/handlers/auth.go
|
|
||||||
|
|
||||||
# Use git stash to save current state before rollback
|
|
||||||
git stash push -m "Broken state before fix"
|
|
||||||
git checkout -- internal/handlers/auth.go
|
|
||||||
```
|
|
||||||
|
|
||||||
### Prevention (Learn From Mistakes)
|
|
||||||
|
|
||||||
- Why did the mistake happen?
|
|
||||||
- Was it too-broad matching?
|
|
||||||
- Was it insufficient context reading?
|
|
||||||
- Was it cascading fix-up attempts?
|
|
||||||
- Update guidelines to prevent recurrence
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📋 WORKFLOW CHECKLISTS
|
|
||||||
|
|
||||||
### Before Making Frontend-Only Changes
|
|
||||||
|
|
||||||
- [ ] Identify if backend modification could make implementation simpler
|
|
||||||
- [ ] Plan to use existing API endpoints only
|
|
||||||
- [ ] If backend change seems necessary, prepare confirmation request:
|
|
||||||
- Required change description
|
|
||||||
- Why it would help
|
|
||||||
- Impact analysis
|
|
||||||
- Alternative approaches considered
|
|
||||||
- [ ] Plan git commit structure (multiple logical commits)
|
|
||||||
- [ ] **Identify documentation location** (see Documentation section):
|
|
||||||
- [ ] User-facing feature → `docs/user/`
|
|
||||||
- [ ] UI/workflow changes → `docs/user/`
|
|
||||||
- [ ] Setup instructions → `README.md`
|
|
||||||
|
|
||||||
### Before Making Full-Stack Changes
|
|
||||||
|
|
||||||
- [ ] Read current schema completely (if database changes)
|
|
||||||
- [ ] Identify all columns that must be preserved
|
|
||||||
- [ ] Plan exact changes needed
|
|
||||||
- [ ] Verify Podman will be used for builds
|
|
||||||
- [ ] Plan git commit structure (multiple logical commits)
|
|
||||||
- [ ] **Identify documentation location**:
|
|
||||||
- [ ] API changes → `docs/developer/api/<category>/`
|
|
||||||
- [ ] New endpoints → Create new `.md` file in `docs/developer/api/`
|
|
||||||
- [ ] API behavior → Update existing `docs/developer/api/` files
|
|
||||||
- [ ] Breaking changes → Both `README.md` + relevant `docs/`
|
|
||||||
- [ ] Bruno OpenCollection YAML `.yml` files → Update/create alongside API changes
|
|
||||||
- [ ] Integration tests (cmd/server/tests) → Update/create alongside API changes
|
|
||||||
|
|
||||||
### During Schema Changes (Full-Stack Only)
|
|
||||||
|
|
||||||
- [ ] Read current schema completely
|
|
||||||
- [ ] Identify all columns that must be preserved
|
|
||||||
- [ ] Plan exact changes needed
|
|
||||||
- [ ] Set up verification step
|
|
||||||
- [ ] Make intended changes
|
|
||||||
- [ ] Immediately verify by reading back modified sections
|
|
||||||
- [ ] Confirm ALL expected columns are present
|
|
||||||
- [ ] Verify schema.sql, models.go, and queries.sql are in sync
|
|
||||||
- [ ] **Update local database** (choose ONE):
|
|
||||||
- [ ] **Option 1 - Recreate database** (recommended, loses data):
|
|
||||||
```bash
|
|
||||||
podman compose down -v # Delete all volumes
|
|
||||||
podman compose up -d # Start with fresh schema
|
|
||||||
```
|
|
||||||
- [ ] **Option 2 - Manual SQL migration** (preserves data):
|
|
||||||
```bash
|
|
||||||
podman exec bookhoard_db psql -U postgres -d bookhoard -c "YOUR SQL HERE"
|
|
||||||
```
|
|
||||||
- [ ] Verify database has new schema (check column types, indexes, etc.)
|
|
||||||
|
|
||||||
### After API Changes
|
|
||||||
|
|
||||||
- [ ] Create/update Bruno OpenCollection YAML requests
|
|
||||||
- [ ] Create/update Integration tests (cmd/server/tests)
|
|
||||||
- [ ] Test with no user context
|
|
||||||
- [ ] Test with regular user context
|
|
||||||
- [ ] Test with admin context
|
|
||||||
- [ ] Verify backward compatibility
|
|
||||||
|
|
||||||
### Before Committing
|
|
||||||
|
|
||||||
- [ ] **Run verification script**: `bash scripts/verify-guidelines.sh`
|
|
||||||
- [ ] **Fix any errors** - verification must pass (0 errors) to commit
|
|
||||||
- [ ] **Note warnings** - informational only, do not auto-fix
|
|
||||||
- [ ] Run tests: `go test ./... -v`
|
|
||||||
- [ ] Run lint/typecheck if available
|
|
||||||
- [ ] Ensure no secrets in changes
|
|
||||||
- [ ] Verify logical commit structure
|
|
||||||
- [ ] **Update documentation** (see Documentation section):
|
|
||||||
- [ ] User-facing changes → `docs/user/`
|
|
||||||
- [ ] API changes → `docs/developer/api/` + Integration tests + Bruno OpenCollection YAML `.yml` files
|
|
||||||
- [ ] Setup/onboarding → `README.md`
|
|
||||||
- [ ] Development changes → `docs/contributing/`
|
|
||||||
- [ ] **Verify docs render** at `/docs` endpoint
|
|
||||||
- [ ] **Test docs search** finds new content
|
|
||||||
|
|
||||||
### Error Recovery Protocol (If Code Mistakes Occur)
|
|
||||||
|
|
||||||
- [ ] **Stop immediately** - don't make more edits
|
|
||||||
- [ ] **Assess impact**: What was deleted? Is it critical?
|
|
||||||
- [ ] **Review git diff**: See exact changes made
|
|
||||||
- [ ] **Restore strategy**:
|
|
||||||
- If recent mistake: `git checkout -- filename`
|
|
||||||
- If complex: Reconstruct from git diff using `git show HEAD:filename`
|
|
||||||
- [ ] **Verify recovery**: Compile and test restored code
|
|
||||||
- [ ] **Document mistake**: Note what went wrong for future reference
|
|
||||||
|
|
||||||
### Phase Completion Verification (Before Declaring "Complete")
|
|
||||||
|
|
||||||
- [ ] All target code is removed/intact as intended
|
|
||||||
- [ ] No unintended code was deleted
|
|
||||||
- [ ] All affected files compile successfully
|
|
||||||
- [ ] Run `go build ./...` for entire project
|
|
||||||
- [ ] **Run verification script**: `bash scripts/verify-guidelines.sh` - must pass (0 errors)
|
|
||||||
- [ ] No critical functionality was broken
|
|
||||||
- [ ] Git diff shows only intended changes
|
|
||||||
- [ ] Review all modified files with `git diff --stat`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🏗 ARCHITECTURAL PATTERNS
|
|
||||||
|
|
||||||
### Current: SSR-First with Alpine.js + HTMX
|
|
||||||
|
|
||||||
```
|
|
||||||
Browser → Go template (SSR with all data) → Display instantly
|
|
||||||
↓
|
|
||||||
Alpine.js for UI state (modals, dropdowns)
|
|
||||||
↓
|
|
||||||
HTMX for CRUD (forms, updates)
|
|
||||||
↓
|
|
||||||
Shared service layer (Go)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Layer Responsibilities:**
|
|
||||||
|
|
||||||
| Layer | Responsibility |
|
|
||||||
|-------|---------------|
|
|
||||||
| **Go Template** | SSR initial page with real data |
|
|
||||||
| **Alpine.js** | UI state only (x-data, x-show, transitions) |
|
|
||||||
| **HTMX** | Dynamic updates without page reload |
|
|
||||||
| **TypeScript** | Pure business logic (API calls, data processing) |
|
|
||||||
|
|
||||||
**See also:** `SSR_FIRST_ALPINE_GUIDE.md` and `ALPINE_COMPLETION_GUIDE.md`
|
|
||||||
|
|
||||||
Ultimately, whenever you are unsure just ask for confirmation.
|
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
# 📚 Bookhoard
|
# <img src="web/static/favicon.svg" width="32" alt="Bookhoard logo"> Bookhoard
|
||||||
|
|
||||||
A modern self-hosted media library system built with Go, PostgreSQL, HTMX, and Tailwind CSS featuring **universal cross-device sync**, beautiful dark themes, and comprehensive media management.
|
A modern self-hosted media library system built with Go, PostgreSQL, HTMX, and Tailwind CSS featuring **universal cross-device sync**, beautiful dark themes, and comprehensive media management.
|
||||||
|
|
||||||
## ✨ Why Bookhoard?
|
## ✨ Why Bookhoard?
|
||||||
|
|
||||||
**🔄 Universal Sync**: Your reading progress, highlights, and notes sync automatically across all your devices - KOReader, Kobo, web, and mobile.
|
**🔄 Universal Sync**: Your reading position, bookmarks, highlights, and notes sync automatically between KOReader and the web - with native Kobo sync and mobile apps coming later.
|
||||||
|
|
||||||
**📱 Multi-Library**: Organize your ebooks, comics, and manga with per-library folders and smart collections.
|
**📱 Multi-Library**: Organize your ebooks, comics, and manga with per-library folders and smart collections.
|
||||||
|
|
||||||
@@ -25,7 +25,7 @@ A modern self-hosted media library system built with Go, PostgreSQL, HTMX, and T
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Clone the repository
|
# 1. Clone the repository
|
||||||
git clone https://github.com/yourusername/bookhoard.git
|
git clone https://git.linuxhg.com/Bookhoard/bookhoard.git
|
||||||
cd bookhoard
|
cd bookhoard
|
||||||
|
|
||||||
# 2. Set up environment
|
# 2. Set up environment
|
||||||
@@ -35,8 +35,10 @@ cp .env.example .env
|
|||||||
# DBPASS: openssl rand -hex 16
|
# DBPASS: openssl rand -hex 16
|
||||||
# Edit .env with your generated values
|
# Edit .env with your generated values
|
||||||
|
|
||||||
# 3. Start the server
|
# 3. Pull images and start the server
|
||||||
podman-compose up --build -d # or: docker-compose up --build -d
|
docker compose pull
|
||||||
|
docker compose up -d
|
||||||
|
# Optionally pin a specific version: set IMAGE_TAG in .env (defaults to "latest")
|
||||||
|
|
||||||
# 4. Open your browser
|
# 4. Open your browser
|
||||||
open http://localhost:8765
|
open http://localhost:8765
|
||||||
@@ -50,13 +52,13 @@ The first user to register automatically becomes an admin.
|
|||||||
|
|
||||||
### Universal Cross-Platform Sync
|
### Universal Cross-Platform Sync
|
||||||
|
|
||||||
- **Real-Time Progress**: Turn a page on your Kindle, see it on your phone
|
- **Real-Time Progress**: Turn a page on your e-reader, see it in your browser
|
||||||
- **Format-Aware**: EPUB CFI, page numbers, percentages - all handled correctly
|
- **Format-Aware**: EPUB CFI, page numbers, percentages - all handled correctly
|
||||||
- **Offline Queue**: Changes sync when you reconnect, priority-processed
|
- **Offline Queue**: Changes sync when you reconnect, priority-processed
|
||||||
- **Conflict Resolution**: Smart handling when same book read on multiple devices
|
- **Conflict Resolution**: Smart handling when same book read on multiple devices
|
||||||
- **Book Matching**: Automatic matching using SHA-256, ISBN, UUID
|
- **Book Matching**: Automatic matching using SHA-256, ISBN, UUID
|
||||||
- **OPDS Catalog**: Wireless book delivery to e-readers over Wi-Fi
|
- **OPDS Catalog**: Wireless book delivery to e-readers over Wi-Fi
|
||||||
- **Format Conversion**: On-the-fly EPUB→KEPUB for Kobo devices
|
- **Format Conversion**: On-the-fly EPUB→KEPUB conversion (for upcoming native Kobo support)
|
||||||
|
|
||||||
### Media Management
|
### Media Management
|
||||||
|
|
||||||
@@ -72,7 +74,7 @@ The first user to register automatically becomes an admin.
|
|||||||
### Smart Collections
|
### Smart Collections
|
||||||
|
|
||||||
- **Auto-Assign Rules**: Automatically add books based on genre, author, series, tags, language, publisher, year
|
- **Auto-Assign Rules**: Automatically add books based on genre, author, series, tags, language, publisher, year
|
||||||
- **Device Shelf Mappings**: Sync collections to Kobo shelves and KOReader categories
|
- **Device Shelf Mappings**: Map collections to device shelves (used by native Kobo sync, coming soon)
|
||||||
- **Test Before Creating**: Preview which books match your rules
|
- **Test Before Creating**: Preview which books match your rules
|
||||||
|
|
||||||
### Library Organization
|
### Library Organization
|
||||||
@@ -100,8 +102,8 @@ The first user to register automatically becomes an admin.
|
|||||||
|
|
||||||
- **[docs/user/calibre-integration.md](docs/user/calibre-integration.md)** - Calibre library integration
|
- **[docs/user/calibre-integration.md](docs/user/calibre-integration.md)** - Calibre library integration
|
||||||
- **[docs/user/sync-guide.md](docs/user/sync-guide.md)** - Understanding and using universal sync
|
- **[docs/user/sync-guide.md](docs/user/sync-guide.md)** - Understanding and using universal sync
|
||||||
- **[docs/user/devices/kobo-setup.md](docs/user/devices/kobo-setup.md)** - Kobo e-reader configuration
|
|
||||||
- **[docs/user/devices/koreader-setup.md](docs/user/devices/koreader-setup.md)** - KOReader configuration
|
- **[docs/user/devices/koreader-setup.md](docs/user/devices/koreader-setup.md)** - KOReader configuration
|
||||||
|
- **[docs/user/devices/kobo-setup.md](docs/user/devices/kobo-setup.md)** - Kobo e-reader configuration (coming soon)
|
||||||
- **[docs/user/user-guide.md](docs/user/user-guide.md)** - General user guide
|
- **[docs/user/user-guide.md](docs/user/user-guide.md)** - General user guide
|
||||||
- **[docs/user/admin-guide.md](docs/user/admin-guide.md)** - Admin features and configuration
|
- **[docs/user/admin-guide.md](docs/user/admin-guide.md)** - Admin features and configuration
|
||||||
- **[docs/user/settings-guide.md](docs/user/settings-guide.md)** - Settings and preferences
|
- **[docs/user/settings-guide.md](docs/user/settings-guide.md)** - Settings and preferences
|
||||||
@@ -109,18 +111,45 @@ The first user to register automatically becomes an admin.
|
|||||||
### For Developers
|
### For Developers
|
||||||
|
|
||||||
- **[docs/developer/api/api-reference.md](docs/developer/api/api-reference.md)** - Complete API documentation
|
- **[docs/developer/api/api-reference.md](docs/developer/api/api-reference.md)** - Complete API documentation
|
||||||
- **[docs/contributing/DEVELOPMENT.md](docs/contributing/DEVELOPMENT.md)** - Development workflow
|
- **[docs/developer/android-app.md](docs/developer/android-app.md)** - Android app design & roadmap
|
||||||
|
- **[docs/contributing/development.md](docs/contributing/development.md)** - Development workflow
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🎯 Supported Devices
|
## 🎯 Supported Devices
|
||||||
|
|
||||||
| Platform | Sync | OPDS | Status |
|
| Platform | Sync | OPDS | Status |
|
||||||
| ---------------- | ---- | ---- | ------------------------ |
|
| ---------------- | ---- | ---- | ------------------------------------------------------------- |
|
||||||
| **Web Browser** | ✅ | ✅ | Full support |
|
| **Web Browser** | ✅ | ✅ | Full support |
|
||||||
| **KOReader** | ✅ | ✅ | Kindle, Kobo, PocketBook |
|
| **KOReader** | ✅ | ✅ | Runs on Kindle, Kobo, PocketBook hardware |
|
||||||
| **Kobo Devices** | ✅ | ✅ | Clara, Libra, Sage, etc. |
|
| **Kobo Devices** | 🚧 | 🚧 | Native Kobo sync coming soon (use KOReader on Kobo today) |
|
||||||
| **Mobile Apps** | 🚧 | 🚧 | Coming Q2 2026 |
|
| **Mobile Apps** | 🚧 | 🚧 | Native Android app in design ([docs](docs/developer/android-app.md)); iOS later |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💾 Network Storage (NAS / NFS / SMB)
|
||||||
|
|
||||||
|
Libraries on network mounts are supported for everyday use — serving,
|
||||||
|
reading, progress, and annotation sync all work over the mount — with a
|
||||||
|
few behaviors worth knowing:
|
||||||
|
|
||||||
|
- **Outage hysteresis.** When the mount drops, files miss one scan
|
||||||
|
(marked missing, hidden) then a second (archived, still recoverable).
|
||||||
|
When the mount returns — by path or by identical content elsewhere —
|
||||||
|
everything is restored automatically: annotations, progress, and
|
||||||
|
covers included.
|
||||||
|
- **Retention purge.** Archived items older than `ARCHIVE_RETENTION_DAYS`
|
||||||
|
(default **90**, `0` = never auto-purge) are deleted for good by
|
||||||
|
library scans, annotations included. A NAS offline longer than the
|
||||||
|
retention window loses those books' metadata on return.
|
||||||
|
- **Deleting while the storage is down is refused.** Deleting a book
|
||||||
|
cascades its annotations, so deletes are rejected with a clear error
|
||||||
|
while the item's library storage is unreachable. Archive the item
|
||||||
|
instead, or retry when the mount is back.
|
||||||
|
- **Watch mode does not fire on network mounts** (inotify doesn't work
|
||||||
|
over NFS/SMB). The periodic scan is the change detector.
|
||||||
|
- **Latency** — folder reachability checks are cached ~30s; large
|
||||||
|
libraries on slow mounts will still feel slower than local disk.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -153,20 +182,20 @@ bruno run
|
|||||||
## 📊 Project Status
|
## 📊 Project Status
|
||||||
|
|
||||||
**Version**: 1.0
|
**Version**: 1.0
|
||||||
**License**: GPL-3.0
|
**License**: AGPL-3.0
|
||||||
**Status**: Production-ready ✅
|
**Status**: Production-ready ✅
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🤝 Contributing
|
## 🤝 Contributing
|
||||||
|
|
||||||
We welcome contributions! Please see [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for guidelines.
|
We welcome contributions! Please see [docs/developer/development.md](docs/developer/development.md) for guidelines.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📄 License
|
## 📄 License
|
||||||
|
|
||||||
GPL-3.0 - See [LICENSE](LICENSE) file for details.
|
AGPL-3.0 - See [LICENSE](LICENSE) file for details.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
bru run --env Bookhoard --delay 500 "NewDevDBSetup/RegisterUser.yml" "NewDevDBSetup/CreateEbookLibrary.yml" "NewDevDBSetup/CreateComicLibrary.yml" "NewDevDBSetup/CreateMangaLibrary.yml" "NewDevDBSetup/AddEbookLibraryFolder.yml" "NewDevDBSetup/AddComicLibraryFolder.yml" "NewDevDBSetup/AddMangaLibraryFolder.yml" "NewDevDBSetup/ScanAllLibraries.yml"
|
bru run --env Bookhoard --delay 500 "NewDevDBSetup/RegisterUser.yml" "NewDevDBSetup/SetBaseUrl.yml" "NewDevDBSetup/CreateEbookLibrary.yml" "NewDevDBSetup/CreateComicLibrary.yml" "NewDevDBSetup/CreateMangaLibrary.yml" "NewDevDBSetup/AddEbookLibraryFolder.yml" "NewDevDBSetup/AddComicLibraryFolder.yml" "NewDevDBSetup/AddMangaLibraryFolder.yml" "NewDevDBSetup/ScanAllLibraries.yml"
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
info:
|
||||||
|
name: SetBaseUrl
|
||||||
|
type: http
|
||||||
|
seq: 3
|
||||||
|
|
||||||
|
http:
|
||||||
|
method: PUT
|
||||||
|
url: '{{base_url}}/api/system/config'
|
||||||
|
auth: inherit
|
||||||
|
body:
|
||||||
|
type: json
|
||||||
|
jsonBody: |-
|
||||||
|
{
|
||||||
|
"base_url": "http://localhost:8765"
|
||||||
|
}
|
||||||
|
headers:
|
||||||
|
- key: Authorization
|
||||||
|
value: Bearer {{token}}
|
||||||
|
- key: Content-Type
|
||||||
|
value: application/json
|
||||||
|
|
||||||
|
settings:
|
||||||
|
encodeUrl: true
|
||||||
|
timeout: 0
|
||||||
|
followRedirects: true
|
||||||
|
maxRedirects: 5
|
||||||
|
|
||||||
|
docs: |-
|
||||||
|
## Set Base URL
|
||||||
|
|
||||||
|
Configures the server's base_url during initial dev database setup.
|
||||||
|
|
||||||
|
Must be run after RegisterUser (which provides the auth token) and before
|
||||||
|
any library/device creation (which require setup to be complete).
|
||||||
|
|
||||||
|
**Method:** PUT
|
||||||
|
**Endpoint:** /api/system/config
|
||||||
|
**Auth:** Bearer token (from RegisterUser)
|
||||||
@@ -2,20 +2,20 @@ name: Bookhoard
|
|||||||
variables:
|
variables:
|
||||||
- name: base_url
|
- name: base_url
|
||||||
value: http://localhost:8765
|
value: http://localhost:8765
|
||||||
- name: media_item_id
|
- secret: true
|
||||||
value: 8bd13107-e1e5-4357-b893-bfc77f1e087c
|
name: media_item_id
|
||||||
- name: fake_book_id
|
- secret: true
|
||||||
value: 123e4567-e89b-12d3-a456-426614174000
|
name: fake_book_id
|
||||||
- name: user_id
|
- secret: true
|
||||||
value: c51118f0-31fc-4c32-827d-517d6599bf21
|
name: user_id
|
||||||
- name: highlight_id
|
- secret: true
|
||||||
value: 660f9501-f29b-51d4-b716-446655440001
|
name: highlight_id
|
||||||
- name: note_id
|
- secret: true
|
||||||
value: 7710a602-g29b-61d4-c716-446655440002
|
name: note_id
|
||||||
- name: ebook_library_id
|
- secret: true
|
||||||
value: 0df0ea2b-1965-494a-a0a3-cce8536d5f28
|
name: ebook_library_id
|
||||||
- name: job_id
|
- secret: true
|
||||||
value: 709e0d8e-b866-496c-b260-a59ab8e2014c
|
name: job_id
|
||||||
- name: rating
|
- name: rating
|
||||||
value: "5"
|
value: "5"
|
||||||
- name: is_visible
|
- name: is_visible
|
||||||
@@ -32,11 +32,11 @@ variables:
|
|||||||
name: kobo_device_token
|
name: kobo_device_token
|
||||||
- secret: true
|
- secret: true
|
||||||
name: other_device_id
|
name: other_device_id
|
||||||
- name: collection_id
|
- secret: true
|
||||||
value: 412c03c3-0843-4bd4-b764-cc9457bb9df2
|
name: collection_id
|
||||||
- name: library_folder_id
|
- secret: true
|
||||||
value: da1f9d91-0c4c-40cc-a050-86f795dfc967
|
name: library_folder_id
|
||||||
- name: comic_library_id
|
- secret: true
|
||||||
value: 7b6d0c8c-73dc-4346-804c-5f6e11c3d658
|
name: comic_library_id
|
||||||
- name: manga_library_id
|
- secret: true
|
||||||
value: b134d16d-9668-4867-b50e-8350037f1a4a
|
name: manga_library_id
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
info:
|
||||||
|
name: Rescan Media Item
|
||||||
|
type: http
|
||||||
|
seq: 9
|
||||||
|
|
||||||
|
http:
|
||||||
|
method: POST
|
||||||
|
url: "{{base_url}}/api/media-items/{{media_item_id}}/rescan"
|
||||||
|
headers:
|
||||||
|
- name: ""
|
||||||
|
value: application/json
|
||||||
|
auth: inherit
|
||||||
|
|
||||||
|
runtime:
|
||||||
|
scripts:
|
||||||
|
- type: tests
|
||||||
|
code: |-
|
||||||
|
test_rescan_media_item_success(status, headers, body) {
|
||||||
|
if (status !== 200) {
|
||||||
|
throw new Error("Expected status 200, got " + status);
|
||||||
|
|
||||||
|
settings:
|
||||||
|
encodeUrl: true
|
||||||
|
timeout: 0
|
||||||
|
followRedirects: true
|
||||||
|
maxRedirects: 5
|
||||||
|
|
||||||
|
docs: |-
|
||||||
|
## Rescan Media Item
|
||||||
|
|
||||||
|
Re-extracts metadata and cover art for a single media item from the file on disk.
|
||||||
|
|
||||||
|
**Method:** POST
|
||||||
|
|
||||||
|
**Endpoint:** /api/media-items/{id}/rescan
|
||||||
@@ -47,7 +47,7 @@ docs: |-
|
|||||||
- `id` (string, required): Media item UUID
|
- `id` (string, required): Media item UUID
|
||||||
|
|
||||||
**Request Body:**
|
**Request Body:**
|
||||||
- `rating` (number, required): Rating value (typically 1-5)
|
- `rating` (number, required): Rating value (1-10 integer scale; displayed as 1-5 stars with half-star precision)
|
||||||
- `review` (string, optional): Review text
|
- `review` (string, optional): Review text
|
||||||
|
|
||||||
**Response:** Updated rating object
|
**Response:** Updated rating object
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ docs: |-
|
|||||||
- `id` (string): Media item ID
|
- `id` (string): Media item ID
|
||||||
|
|
||||||
**Request Body:**
|
**Request Body:**
|
||||||
- `content` (string): Note content (required, 1-10000 chars)
|
- `content` (string): Note content (required, 1-100000 chars)
|
||||||
- `position` (string): Optional position reference (max 100 chars)
|
- `position` (string): Optional position reference (max 100 chars)
|
||||||
|
|
||||||
**Response:**
|
**Response:**
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ docs: |-
|
|||||||
- `noteId` (string): Note ID
|
- `noteId` (string): Note ID
|
||||||
|
|
||||||
**Request Body:**
|
**Request Body:**
|
||||||
- `content` (string): Updated note content (required, 1-10000 chars)
|
- `content` (string): Updated note content (required, 1-100000 chars)
|
||||||
- `position` (string): Updated position reference (optional, max 100 chars)
|
- `position` (string): Updated position reference (optional, max 100 chars)
|
||||||
|
|
||||||
**Response:**
|
**Response:**
|
||||||
|
|||||||
@@ -83,9 +83,10 @@ docs:
|
|||||||
- **Update Highlight**: PUT /api/highlights/:id - Update highlight
|
- **Update Highlight**: PUT /api/highlights/:id - Update highlight
|
||||||
- **Delete Highlight**: DELETE /api/highlights/:id - Remove highlight
|
- **Delete Highlight**: DELETE /api/highlights/:id - Remove highlight
|
||||||
Ratings (All Users)
|
Ratings (All Users)
|
||||||
- **Get Rating**: GET /api/ratings/:media_id - User's rating (returns 0 if unrated)
|
- **Get Rating**: GET /api/media-items/:id/rating - User's rating (returns null if unrated)
|
||||||
- **Create/Update Rating**: POST /api/ratings - Rate media item (1-5 stars, half-star precision)
|
- **Create/Update Rating**: POST /api/media-items/:id/rating - Rate media item (1-10 scale, displayed as 1-5 stars with half-star precision). POST upserts; PUT also available.
|
||||||
- **Delete Rating**: DELETE /api/ratings/:media_id - Remove rating
|
- **Update Rating**: PUT /api/media-items/:id/rating - Update rating (upsert)
|
||||||
|
- **Delete Rating**: DELETE /api/media-items/:id/rating - Remove rating
|
||||||
Collections (All Users)
|
Collections (All Users)
|
||||||
- **List Collections**: GET /api/collections - Get user's collections
|
- **List Collections**: GET /api/collections - Get user's collections
|
||||||
- **Get Collection**: GET /api/collections/:id - Collection details with media items
|
- **Get Collection**: GET /api/collections/:id - Collection details with media items
|
||||||
|
|||||||
+37
@@ -0,0 +1,37 @@
|
|||||||
|
# git-cliff configuration — generates the body of each Gitea Release from
|
||||||
|
# Conventional Commits accumulated since the previous tag. Invoked in CI by
|
||||||
|
# orhun/git-cliff-action with --latest so only the current tag's section is
|
||||||
|
# emitted (no full history, no header — the Gitea Release title is the tag).
|
||||||
|
# Docs: https://git-cliff.org/docs/configuration
|
||||||
|
|
||||||
|
[changelog]
|
||||||
|
header = ""
|
||||||
|
body = """
|
||||||
|
{% for group, commits in commits | group_by(attribute="group") %}\
|
||||||
|
### {{ group | upper_first }}
|
||||||
|
{% for commit in commits %}\
|
||||||
|
- {% if commit.scope %}*({{ commit.scope }})* {% endif %}{{ commit.message | upper_first }} ({{ commit.id | truncate(length=7, end="") }})
|
||||||
|
{% endfor %}\
|
||||||
|
{% endfor %}\
|
||||||
|
"""
|
||||||
|
trim = true
|
||||||
|
footer = ""
|
||||||
|
|
||||||
|
[git]
|
||||||
|
conventional_commits = true
|
||||||
|
filter_unconventional = false
|
||||||
|
require_conventional = false
|
||||||
|
split_commits = false
|
||||||
|
commit_parsers = [
|
||||||
|
{ message = "^feat", group = "Features" },
|
||||||
|
{ message = "^fix", group = "Bug Fixes" },
|
||||||
|
{ message = "^perf", group = "Performance" },
|
||||||
|
{ message = "^refactor", group = "Refactor" },
|
||||||
|
{ message = "^docs", group = "Documentation" },
|
||||||
|
{ message = "^test", group = "Tests" },
|
||||||
|
{ message = "^chore|^ci", group = "Miscellaneous Tasks" },
|
||||||
|
{ message = ".*", group = "Other" },
|
||||||
|
]
|
||||||
|
filter_commits = false
|
||||||
|
tag_pattern = "v[0-9].*"
|
||||||
|
sort_commits = "oldest"
|
||||||
+99
-7
@@ -10,10 +10,13 @@ import (
|
|||||||
"bookhoard/internal/router"
|
"bookhoard/internal/router"
|
||||||
"bookhoard/internal/services"
|
"bookhoard/internal/services"
|
||||||
"bookhoard/internal/sync"
|
"bookhoard/internal/sync"
|
||||||
|
"bookhoard/templates"
|
||||||
"context"
|
"context"
|
||||||
"log"
|
"log"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
_ "time/tzdata"
|
||||||
|
|
||||||
"github.com/go-playground/validator/v10"
|
"github.com/go-playground/validator/v10"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
"github.com/labstack/echo/v5"
|
"github.com/labstack/echo/v5"
|
||||||
@@ -48,46 +51,127 @@ func main() {
|
|||||||
}
|
}
|
||||||
log.Println("✅ Database schema initialized and verified, starting server...")
|
log.Println("✅ Database schema initialized and verified, starting server...")
|
||||||
|
|
||||||
// Create login attempt tracker: 5 failed attempts = 15 minute lockout
|
// Load tunable settings from the DB into the registry. All values fall back
|
||||||
loginAttemptTracker := ratelimit.NewLoginAttemptTracker(5, 15*time.Minute, 5*time.Minute)
|
// to compiled defaults if a row is missing, so this never blocks startup.
|
||||||
|
registry := database.NewSettingsRegistry(queries)
|
||||||
|
if err := registry.Load(ctx); err != nil {
|
||||||
|
log.Printf("⚠️ Could not load system settings (using defaults): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public signup surfaces follow the registration_enabled flag, with the
|
||||||
|
// first-user exception: an unconfigured server (zero admins) keeps its
|
||||||
|
// signup links and registration route reachable for bootstrapping.
|
||||||
|
templates.SetSignupVisibility(func() bool {
|
||||||
|
if registry.RegistrationEnabled() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
count, err := queries.CountAdmins(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
return true // fail open, matching setupstatus
|
||||||
|
}
|
||||||
|
return count == 0
|
||||||
|
})
|
||||||
|
// Wire the registry into the package-level password validator so live
|
||||||
|
// rule changes apply to the echo struct-tag validator and ValidatePassword.
|
||||||
|
middleware.SetDefaultPasswordSettings(registry)
|
||||||
|
|
||||||
|
// Seed base_url from env var if not already configured. Uses conditional
|
||||||
|
// UPDATE so admin-set values are never overwritten on restart.
|
||||||
|
if cfg.BaseURL != "" {
|
||||||
|
_, err = dbPool.Exec(ctx, `
|
||||||
|
INSERT INTO system_config (key, value)
|
||||||
|
VALUES ('base_url', $1)
|
||||||
|
ON CONFLICT (key) DO UPDATE
|
||||||
|
SET value = EXCLUDED.value
|
||||||
|
WHERE system_config.value = ''
|
||||||
|
`, cfg.BaseURL)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("⚠️ Could not seed base_url: %v", err)
|
||||||
|
} else {
|
||||||
|
// Also seed derived URLs
|
||||||
|
for key, suffix := range map[string]string{
|
||||||
|
"opds_base_url": "/opds",
|
||||||
|
"api_base_url": "/api",
|
||||||
|
} {
|
||||||
|
_, _ = dbPool.Exec(ctx, `
|
||||||
|
INSERT INTO system_config (key, value)
|
||||||
|
VALUES ($1, $2)
|
||||||
|
ON CONFLICT (key) DO UPDATE
|
||||||
|
SET value = EXCLUDED.value
|
||||||
|
WHERE system_config.value = ''
|
||||||
|
`, key, cfg.BaseURL+suffix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create login attempt tracker from configured (or default) lockout policy.
|
||||||
|
loginMaxAttempts, loginLockout := registry.LoginLockout()
|
||||||
|
loginAttemptTracker := ratelimit.NewLoginAttemptTracker(loginMaxAttempts, loginLockout, 5*time.Minute)
|
||||||
|
|
||||||
authHandler := handlers.NewAuthHandler(queries, cfg.JWTSecret, loginAttemptTracker)
|
authHandler := handlers.NewAuthHandler(queries, cfg.JWTSecret, loginAttemptTracker)
|
||||||
|
authHandler.SetSettings(registry)
|
||||||
systemSettingsHandler := handlers.NewSystemSettingsHandler(queries)
|
systemSettingsHandler := handlers.NewSystemSettingsHandler(queries)
|
||||||
|
systemSettingsHandler.SetSettings(registry)
|
||||||
sidecarHandler := handlers.NewSidecarHandler(queries, cfg)
|
sidecarHandler := handlers.NewSidecarHandler(queries, cfg)
|
||||||
|
sidecarHandler.SetSettings(registry)
|
||||||
libraryHandler := handlers.NewLibraryHandler(queries)
|
libraryHandler := handlers.NewLibraryHandler(queries)
|
||||||
deviceHandler := handlers.NewDeviceHandler(queries, cfg.JWTSecret, cfg)
|
deviceHandler := handlers.NewDeviceHandler(queries, cfg.JWTSecret, cfg)
|
||||||
deviceAuthMiddleware := middleware.NewDeviceAuthMiddleware(queries)
|
deviceAuthMiddleware := middleware.NewDeviceAuthMiddleware(queries)
|
||||||
|
deviceAuthMiddleware.SetSettings(registry)
|
||||||
processingIssuesHandler := handlers.NewProcessingIssuesHandler(queries)
|
processingIssuesHandler := handlers.NewProcessingIssuesHandler(queries)
|
||||||
|
hashConflictsHandler := handlers.NewHashConflictsHandler(queries)
|
||||||
|
|
||||||
// Create WebSocket connection manager
|
// Create WebSocket connection manager
|
||||||
connManager := sync.NewConnectionManager()
|
connManager := sync.NewConnectionManager()
|
||||||
|
|
||||||
// Create sync queue processor
|
progressService := sync.NewProgressService(queries, connManager)
|
||||||
queueProcessor := sync.NewSyncQueueProcessor(queries)
|
annotationService := sync.NewAnnotationService(queries, connManager)
|
||||||
|
annotationService.SetSettings(registry)
|
||||||
|
maintenanceCancel := annotationService.StartDailyMaintenance()
|
||||||
|
defer maintenanceCancel()
|
||||||
|
|
||||||
|
queueProcessor := sync.NewSyncQueueProcessorWithConfig(queries, registry.SyncQueueConfig().Interval, registry.SyncQueueConfig().BatchSize)
|
||||||
|
queueProcessor.SetProgressService(progressService)
|
||||||
|
queueProcessor.SetAnnotationService(annotationService)
|
||||||
|
|
||||||
// Create library service
|
// Create library service
|
||||||
libraryService := services.NewLibraryService(queries)
|
libraryService := services.NewLibraryService(queries)
|
||||||
|
// The progress service verifies submitted anchors against the book
|
||||||
|
// itself — it needs to resolve library-relative file paths.
|
||||||
|
progressService.SetMediaPathResolver(libraryService)
|
||||||
|
|
||||||
|
// Sync Go AllowedExtensions into DB so API clients see correct extensions
|
||||||
|
libraryService.SyncAllowedExtensions(context.Background())
|
||||||
|
|
||||||
// Create worker for background tasks
|
// Create worker for background tasks
|
||||||
worker := services.NewWorker(3, connManager)
|
workerCfg := registry.WorkerPoolConfig()
|
||||||
|
worker := services.NewWorkerWithConfig(workerCfg.Size, workerCfg.QueueCap, connManager)
|
||||||
services.WorkerInstance = worker
|
services.WorkerInstance = worker
|
||||||
|
|
||||||
koreaderHandler := handlers.NewKOReaderHandler(queries, connManager, queueProcessor)
|
koreaderHandler := handlers.NewKOReaderHandler(queries, connManager, queueProcessor)
|
||||||
|
koreaderHandler.SetProgressService(progressService)
|
||||||
|
koreaderHandler.SetAnnotationService(annotationService)
|
||||||
|
koreaderHandler.SetLibraryService(libraryService)
|
||||||
wsHandler := handlers.NewWSHandler(queries, connManager, cfg.JWTSecret, deviceAuthMiddleware)
|
wsHandler := handlers.NewWSHandler(queries, connManager, cfg.JWTSecret, deviceAuthMiddleware)
|
||||||
conflictHandler := handlers.NewConflictHandler(queries, connManager)
|
conflictHandler := handlers.NewConflictHandler(queries, connManager)
|
||||||
analyticsHandler := handlers.NewAnalyticsHandler(queries)
|
analyticsHandler := handlers.NewAnalyticsHandler(queries)
|
||||||
|
statisticsService := services.NewStatisticsService(queries)
|
||||||
|
statsHandler := handlers.NewStatsHandler(statisticsService)
|
||||||
queueHandler := handlers.NewQueueHandler(queries, queueProcessor)
|
queueHandler := handlers.NewQueueHandler(queries, queueProcessor)
|
||||||
|
|
||||||
// Create conversion service for EPUB→KEPUB conversion
|
|
||||||
conversionService := services.NewConversionService(queries, "/var/bookhoard/cache/kepub")
|
conversionService := services.NewConversionService(queries, "/var/bookhoard/cache/kepub")
|
||||||
|
conversionService.SetSettings(registry)
|
||||||
opdsHandler := handlers.NewOPDSHandler(queries, libraryService, conversionService)
|
opdsHandler := handlers.NewOPDSHandler(queries, libraryService, conversionService)
|
||||||
|
opdsHandler.SetSettings(registry)
|
||||||
|
|
||||||
// NEW: Create refactored handlers
|
|
||||||
collectionHandler := handlers.NewCollectionHandler(queries, libraryService, connManager)
|
collectionHandler := handlers.NewCollectionHandler(queries, libraryService, connManager)
|
||||||
dashboardService := services.NewDashboardService(queries)
|
dashboardService := services.NewDashboardService(queries)
|
||||||
dashboardHandler := handlers.NewDashboardHandler(queries)
|
dashboardHandler := handlers.NewDashboardHandler(queries)
|
||||||
|
seriesHandler := handlers.NewSeriesHandler(queries)
|
||||||
filtersHandler := handlers.NewFiltersHandler(queries)
|
filtersHandler := handlers.NewFiltersHandler(queries)
|
||||||
mediaHandler := handlers.NewMediaHandler(queries, libraryService, worker)
|
mediaHandler := handlers.NewMediaHandler(queries, libraryService, worker)
|
||||||
|
mediaHandler.SetProgressService(progressService)
|
||||||
|
mediaHandler.SetAnnotationService(annotationService)
|
||||||
matchingHandler := handlers.NewMatchingHandler(queries, connManager)
|
matchingHandler := handlers.NewMatchingHandler(queries, connManager)
|
||||||
jobsHandler := handlers.NewJobsHandler(queries, worker)
|
jobsHandler := handlers.NewJobsHandler(queries, worker)
|
||||||
|
|
||||||
@@ -131,6 +215,7 @@ func main() {
|
|||||||
Echo: e,
|
Echo: e,
|
||||||
Queries: queries,
|
Queries: queries,
|
||||||
Cfg: cfg,
|
Cfg: cfg,
|
||||||
|
Settings: registry,
|
||||||
DBPool: dbPool,
|
DBPool: dbPool,
|
||||||
AuthHandler: authHandler,
|
AuthHandler: authHandler,
|
||||||
LibraryHandler: libraryHandler,
|
LibraryHandler: libraryHandler,
|
||||||
@@ -138,24 +223,31 @@ func main() {
|
|||||||
MediaHandler: mediaHandler,
|
MediaHandler: mediaHandler,
|
||||||
MatchingHandler: matchingHandler,
|
MatchingHandler: matchingHandler,
|
||||||
ProcessingIssuesHandler: processingIssuesHandler,
|
ProcessingIssuesHandler: processingIssuesHandler,
|
||||||
|
HashConflictsHandler: hashConflictsHandler,
|
||||||
KOReaderHandler: koreaderHandler,
|
KOReaderHandler: koreaderHandler,
|
||||||
WSHandler: wsHandler,
|
WSHandler: wsHandler,
|
||||||
ConflictHandler: conflictHandler,
|
ConflictHandler: conflictHandler,
|
||||||
AnalyticsHandler: analyticsHandler,
|
AnalyticsHandler: analyticsHandler,
|
||||||
|
StatsHandler: statsHandler,
|
||||||
|
StatisticsService: statisticsService,
|
||||||
QueueHandler: queueHandler,
|
QueueHandler: queueHandler,
|
||||||
CollectionHandler: collectionHandler,
|
CollectionHandler: collectionHandler,
|
||||||
FiltersHandler: filtersHandler,
|
FiltersHandler: filtersHandler,
|
||||||
DashboardHandler: dashboardHandler,
|
DashboardHandler: dashboardHandler,
|
||||||
DashboardService: dashboardService,
|
DashboardService: dashboardService,
|
||||||
|
SeriesHandler: seriesHandler,
|
||||||
OPDSHandler: opdsHandler,
|
OPDSHandler: opdsHandler,
|
||||||
Worker: worker,
|
Worker: worker,
|
||||||
SystemSettingsHandler: systemSettingsHandler,
|
SystemSettingsHandler: systemSettingsHandler,
|
||||||
SidecarHandler: sidecarHandler,
|
SidecarHandler: sidecarHandler,
|
||||||
ConnManager: connManager,
|
ConnManager: connManager,
|
||||||
QueueProcessor: queueProcessor,
|
QueueProcessor: queueProcessor,
|
||||||
|
ProgressService: progressService,
|
||||||
|
AnnotationService: annotationService,
|
||||||
DeviceAuthMiddleware: deviceAuthMiddleware,
|
DeviceAuthMiddleware: deviceAuthMiddleware,
|
||||||
JobsHandler: jobsHandler,
|
JobsHandler: jobsHandler,
|
||||||
LoginTracker: loginAttemptTracker,
|
LoginTracker: loginAttemptTracker,
|
||||||
|
LibraryService: libraryService,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register all routes and get ebook handler
|
// Register all routes and get ebook handler
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bookhoard/internal/handlers"
|
"bookhoard/internal/handlers"
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -21,7 +22,9 @@ func TestAnalyticsReadingStats(t *testing.T) {
|
|||||||
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/analytics/reading-stats", nil)
|
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/analytics/reading-stats", nil)
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -32,12 +35,15 @@ func TestAnalyticsReadingStats(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var result handlers.ReadingStatsResponse
|
var result handlers.ReadingStatsResponse
|
||||||
json.NewDecoder(resp.Body).Decode(&result)
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
assert.GreaterOrEqual(t, result.TotalBooksRead, 0)
|
assert.GreaterOrEqual(t, result.TotalBooksRead, 0)
|
||||||
assert.GreaterOrEqual(t, result.TotalPagesRead, 0)
|
assert.GreaterOrEqual(t, result.TotalPagesRead, 0)
|
||||||
@@ -45,15 +51,17 @@ func TestAnalyticsReadingStats(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("GetReadingStats_WithCustomDateRange", func(t *testing.T) {
|
t.Run("GetReadingStats_WithCustomDateRange", func(t *testing.T) {
|
||||||
startDate := time.Now().AddDate(0, -2, 0).Format("2006-01-02")
|
startDate := time.Now().AddDate(0, -2, 0).Format("01-02-2006")
|
||||||
endDate := time.Now().Format("2006-01-02")
|
endDate := time.Now().Format("01-02-2006")
|
||||||
|
|
||||||
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/analytics/reading-stats?start_date="+startDate+"&end_date="+endDate, nil)
|
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/analytics/reading-stats?start_date="+startDate+"&end_date="+endDate, nil)
|
||||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -64,7 +72,9 @@ func TestAnalyticsReadingStats(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -75,23 +85,32 @@ func TestAnalyticsReadingStats(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("GetReadingStats_EmptyHistory", func(t *testing.T) {
|
t.Run("GetReadingStats_EmptyHistory", func(t *testing.T) {
|
||||||
|
// The REGULAR user, deliberately: the shared dev DB carries a
|
||||||
|
// live reader on `testuser` whose real sessions accrue even
|
||||||
|
// while the suite runs, so "empty history" only holds for a
|
||||||
|
// freshly-created account.
|
||||||
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/analytics/reading-stats", nil)
|
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/analytics/reading-stats", nil)
|
||||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
req.Header.Set("Authorization", "Bearer "+setup.RegularToken)
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var result map[string]interface{}
|
var result map[string]interface{}
|
||||||
json.NewDecoder(resp.Body).Decode(&result)
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Should return zero values for empty history
|
// Should return zero values for empty history
|
||||||
assert.Equal(t, 0.0, result["total_books_read"])
|
assert.Equal(t, 0.0, result["total_books_read"])
|
||||||
@@ -108,7 +127,9 @@ func TestAnalyticsDeviceUsage(t *testing.T) {
|
|||||||
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/analytics/device-usage", nil)
|
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/analytics/device-usage", nil)
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -119,19 +140,19 @@ func TestAnalyticsDeviceUsage(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var result handlers.DeviceUsageResponse
|
var result handlers.DeviceUsageResponse
|
||||||
json.NewDecoder(resp.Body).Decode(&result)
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
assert.NotNil(t, result.Devices)
|
assert.NotNil(t, result.Devices)
|
||||||
assert.Equal(t, 0, len(result.Devices))
|
assert.Equal(t, 0, len(result.Devices))
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("GetDeviceUsage_WithAuth_WithDevices", func(t *testing.T) {
|
t.Run("GetDeviceUsage_WithAuth_WithDevices", func(t *testing.T) {
|
||||||
// First create a device
|
// First, create a device
|
||||||
deviceReq := map[string]interface{}{
|
deviceReq := map[string]interface{}{
|
||||||
"device_name": "Test Kobo",
|
"device_name": "Test Kobo",
|
||||||
"device_type": "kobo",
|
"device_type": "kobo",
|
||||||
@@ -144,7 +165,9 @@ func TestAnalyticsDeviceUsage(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(deviceReqHTTP)
|
resp, err := client.Do(deviceReqHTTP)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
// Now get device usage
|
// Now get device usage
|
||||||
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/analytics/device-usage", nil)
|
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/analytics/device-usage", nil)
|
||||||
@@ -152,12 +175,15 @@ func TestAnalyticsDeviceUsage(t *testing.T) {
|
|||||||
|
|
||||||
resp, err = client.Do(req)
|
resp, err = client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var result map[string]interface{}
|
var result map[string]interface{}
|
||||||
json.NewDecoder(resp.Body).Decode(&result)
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
devices, ok := result["devices"].([]interface{})
|
devices, ok := result["devices"].([]interface{})
|
||||||
assert.True(t, ok)
|
assert.True(t, ok)
|
||||||
@@ -171,12 +197,15 @@ func TestAnalyticsDeviceUsage(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var result map[string]interface{}
|
var result map[string]interface{}
|
||||||
json.NewDecoder(resp.Body).Decode(&result)
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
assert.Contains(t, result, "devices")
|
assert.Contains(t, result, "devices")
|
||||||
|
|
||||||
@@ -203,7 +232,9 @@ func TestAnalyticsPopularBooks(t *testing.T) {
|
|||||||
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/analytics/popular-books", nil)
|
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/analytics/popular-books", nil)
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -214,12 +245,15 @@ func TestAnalyticsPopularBooks(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var result handlers.PopularBooksResponse
|
var result handlers.PopularBooksResponse
|
||||||
json.NewDecoder(resp.Body).Decode(&result)
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
assert.NotNil(t, result.Books)
|
assert.NotNil(t, result.Books)
|
||||||
// Default limit is 10, but may be fewer if no reading history
|
// Default limit is 10, but may be fewer if no reading history
|
||||||
@@ -232,12 +266,15 @@ func TestAnalyticsPopularBooks(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var result map[string]interface{}
|
var result map[string]interface{}
|
||||||
json.NewDecoder(resp.Body).Decode(&result)
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
books := result["books"].([]interface{})
|
books := result["books"].([]interface{})
|
||||||
assert.True(t, len(books) <= 5)
|
assert.True(t, len(books) <= 5)
|
||||||
@@ -249,20 +286,23 @@ func TestAnalyticsPopularBooks(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
// Should default to 10 on invalid limit
|
// Should default to 10 on invalid limit
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var result map[string]interface{}
|
var result map[string]interface{}
|
||||||
json.NewDecoder(resp.Body).Decode(&result)
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
books := result["books"].([]interface{})
|
books := result["books"].([]interface{})
|
||||||
assert.True(t, len(books) <= 10)
|
assert.True(t, len(books) <= 10)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("GetPopularBooks_ResponseStructure", func(t *testing.T) {
|
t.Run("GetPopularBooks_ResponseStructure", func(t *testing.T) {
|
||||||
// First create a book and some reading history
|
// First, create a book and some reading history
|
||||||
bookID := createTestMediaItemID(t, setup)
|
bookID := createTestMediaItemID(t, setup)
|
||||||
|
|
||||||
// Create reading history for the book
|
// Create reading history for the book
|
||||||
@@ -280,7 +320,9 @@ func TestAnalyticsPopularBooks(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(historyHTTP)
|
resp, err := client.Do(historyHTTP)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
// Now get popular books
|
// Now get popular books
|
||||||
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/analytics/popular-books", nil)
|
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/analytics/popular-books", nil)
|
||||||
@@ -288,12 +330,15 @@ func TestAnalyticsPopularBooks(t *testing.T) {
|
|||||||
|
|
||||||
resp, err = client.Do(req)
|
resp, err = client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var result map[string]interface{}
|
var result map[string]interface{}
|
||||||
json.NewDecoder(resp.Body).Decode(&result)
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
books := result["books"].([]interface{})
|
books := result["books"].([]interface{})
|
||||||
|
|
||||||
@@ -310,17 +355,23 @@ func TestAnalyticsPopularBooks(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("GetPopularBooks_NoReadingHistory", func(t *testing.T) {
|
t.Run("GetPopularBooks_NoReadingHistory", func(t *testing.T) {
|
||||||
|
// The REGULAR user, deliberately: the live dev-reader's sessions
|
||||||
|
// accrue on `testuser` while the suite runs, so an empty-history
|
||||||
|
// assertion only holds for a fresh account.
|
||||||
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/analytics/popular-books", nil)
|
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/analytics/popular-books", nil)
|
||||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
req.Header.Set("Authorization", "Bearer "+setup.RegularToken)
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var result map[string]interface{}
|
var result map[string]interface{}
|
||||||
json.NewDecoder(resp.Body).Decode(&result)
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
books := result["books"].([]interface{})
|
books := result["books"].([]interface{})
|
||||||
// Should return empty array if no reading history
|
// Should return empty array if no reading history
|
||||||
@@ -334,21 +385,24 @@ func TestAnalyticsEdgeCases(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
|
|
||||||
t.Run("ReadingStats_FutureDateRange", func(t *testing.T) {
|
t.Run("ReadingStats_FutureDateRange", func(t *testing.T) {
|
||||||
startDate := time.Now().AddDate(0, 0, 7).Format("2006-01-02")
|
startDate := time.Now().AddDate(0, 0, 7).Format("01-02-2006")
|
||||||
endDate := time.Now().AddDate(0, 0, 14).Format("2006-01-02")
|
endDate := time.Now().AddDate(0, 0, 14).Format("01-02-2006")
|
||||||
|
|
||||||
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/analytics/reading-stats?start_date="+startDate+"&end_date="+endDate, nil)
|
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/analytics/reading-stats?start_date="+startDate+"&end_date="+endDate, nil)
|
||||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
// Should succeed but return empty stats
|
// Should succeed but return empty stats
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var result map[string]interface{}
|
var result map[string]interface{}
|
||||||
json.NewDecoder(resp.Body).Decode(&result)
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
assert.Equal(t, 0.0, result["total_books_read"])
|
assert.Equal(t, 0.0, result["total_books_read"])
|
||||||
})
|
})
|
||||||
@@ -359,13 +413,16 @@ func TestAnalyticsEdgeCases(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
// Should handle limit=0 gracefully
|
// Should handle limit=0 gracefully
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var result map[string]interface{}
|
var result map[string]interface{}
|
||||||
json.NewDecoder(resp.Body).Decode(&result)
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
books := result["books"].([]interface{})
|
books := result["books"].([]interface{})
|
||||||
assert.Equal(t, 0, len(books))
|
assert.Equal(t, 0, len(books))
|
||||||
@@ -377,7 +434,9 @@ func TestAnalyticsEdgeCases(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
// Should handle large limit
|
// Should handle large limit
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -27,7 +28,9 @@ func TestBookMatchingQueryBooks(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -47,12 +50,15 @@ func TestBookMatchingQueryBooks(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var result map[string]interface{}
|
var result map[string]interface{}
|
||||||
json.NewDecoder(resp.Body).Decode(&result)
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
assert.Contains(t, result, "matches")
|
assert.Contains(t, result, "matches")
|
||||||
assert.Contains(t, result, "action")
|
assert.Contains(t, result, "action")
|
||||||
@@ -67,7 +73,9 @@ func TestBookMatchingQueryBooks(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -85,12 +93,15 @@ func TestBookMatchingQueryBooks(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var result map[string]interface{}
|
var result map[string]interface{}
|
||||||
json.NewDecoder(resp.Body).Decode(&result)
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
var matches []interface{}
|
var matches []interface{}
|
||||||
if matchesIf, ok := result["matches"]; ok && matchesIf != nil {
|
if matchesIf, ok := result["matches"]; ok && matchesIf != nil {
|
||||||
@@ -124,7 +135,9 @@ func TestBookMatchingBulkLink(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -142,12 +155,15 @@ func TestBookMatchingBulkLink(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var result map[string]interface{}
|
var result map[string]interface{}
|
||||||
json.NewDecoder(resp.Body).Decode(&result)
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
assert.Equal(t, 0.0, result["total"])
|
assert.Equal(t, 0.0, result["total"])
|
||||||
assert.Equal(t, 0.0, result["successful"])
|
assert.Equal(t, 0.0, result["successful"])
|
||||||
@@ -175,12 +191,15 @@ func TestBookMatchingBulkLink(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var result map[string]interface{}
|
var result map[string]interface{}
|
||||||
json.NewDecoder(resp.Body).Decode(&result)
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
assert.Contains(t, result, "results")
|
assert.Contains(t, result, "results")
|
||||||
assert.Contains(t, result, "total")
|
assert.Contains(t, result, "total")
|
||||||
@@ -221,12 +240,15 @@ func TestBookMatchingBulkLink(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var result map[string]interface{}
|
var result map[string]interface{}
|
||||||
json.NewDecoder(resp.Body).Decode(&result)
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
assert.Equal(t, 3.0, result["total"])
|
assert.Equal(t, 3.0, result["total"])
|
||||||
results := result["results"].([]interface{})
|
results := result["results"].([]interface{})
|
||||||
@@ -251,7 +273,9 @@ func TestBookMatchingAutoLink(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -267,12 +291,15 @@ func TestBookMatchingAutoLink(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var result map[string]interface{}
|
var result map[string]interface{}
|
||||||
json.NewDecoder(resp.Body).Decode(&result)
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
assert.Contains(t, result, "auto_linked")
|
assert.Contains(t, result, "auto_linked")
|
||||||
assert.Contains(t, result, "results")
|
assert.Contains(t, result, "results")
|
||||||
@@ -292,12 +319,15 @@ func TestBookMatchingAutoLink(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var result map[string]interface{}
|
var result map[string]interface{}
|
||||||
json.NewDecoder(resp.Body).Decode(&result)
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
assert.Contains(t, result, "auto_linked")
|
assert.Contains(t, result, "auto_linked")
|
||||||
})
|
})
|
||||||
@@ -315,12 +345,15 @@ func TestBookMatchingAutoLink(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var result map[string]interface{}
|
var result map[string]interface{}
|
||||||
json.NewDecoder(resp.Body).Decode(&result)
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Should succeed even with no books to link
|
// Should succeed even with no books to link
|
||||||
assert.Contains(t, result, "auto_linked")
|
assert.Contains(t, result, "auto_linked")
|
||||||
@@ -338,7 +371,9 @@ func TestBookMatchingSuggestions(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -350,7 +385,9 @@ func TestBookMatchingSuggestions(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -363,7 +400,9 @@ func TestBookMatchingSuggestions(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
|
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -378,7 +417,9 @@ func TestBookMatchingSuggestions(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
// Even when book not found, we expect 404
|
// Even when book not found, we expect 404
|
||||||
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
|
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
|
||||||
@@ -396,7 +437,9 @@ func TestBookMatchingDeviceFileAliases(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -409,12 +452,15 @@ func TestBookMatchingDeviceFileAliases(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var result map[string]interface{}
|
var result map[string]interface{}
|
||||||
json.NewDecoder(resp.Body).Decode(&result)
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
assert.Contains(t, result, "device_id")
|
assert.Contains(t, result, "device_id")
|
||||||
assert.Contains(t, result, "aliases")
|
assert.Contains(t, result, "aliases")
|
||||||
@@ -439,7 +485,9 @@ func TestBookMatchingDeviceFileAliases(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -462,7 +510,9 @@ func TestBookMatchingDeviceFileAliases(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -485,7 +535,9 @@ func TestBookMatchingDeviceFileAliases(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -505,7 +557,9 @@ func TestBookMatchingDeviceFileAliases(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -519,7 +573,9 @@ func TestBookMatchingDeviceFileAliases(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -535,7 +591,9 @@ func TestBookMatchingGetBookMatches(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -547,12 +605,15 @@ func TestBookMatchingGetBookMatches(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var result map[string]interface{}
|
var result map[string]interface{}
|
||||||
json.NewDecoder(resp.Body).Decode(&result)
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
assert.Contains(t, result, "matches")
|
assert.Contains(t, result, "matches")
|
||||||
assert.Contains(t, result, "action")
|
assert.Contains(t, result, "action")
|
||||||
@@ -565,7 +626,9 @@ func TestBookMatchingGetBookMatches(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -577,12 +640,15 @@ func TestBookMatchingGetBookMatches(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var result map[string]interface{}
|
var result map[string]interface{}
|
||||||
json.NewDecoder(resp.Body).Decode(&result)
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
assert.Contains(t, result, "matches")
|
assert.Contains(t, result, "matches")
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ func TestCalibreLibraryScan(t *testing.T) {
|
|||||||
// Create scanner and configure it
|
// Create scanner and configure it
|
||||||
scanner := services.NewMediaScanner(setup.DB)
|
scanner := services.NewMediaScanner(setup.DB)
|
||||||
scanner.SetAdminID(adminID)
|
scanner.SetAdminID(adminID)
|
||||||
err = scanner.SetFolders([]string{tmpDir})
|
err = scanner.SetFolders([]string{tmpDir}, false)
|
||||||
require.NoError(t, err, "Failed to set scanner folders")
|
require.NoError(t, err, "Failed to set scanner folders")
|
||||||
|
|
||||||
// Scan library
|
// Scan library
|
||||||
@@ -167,7 +167,7 @@ func TestCalibreLibraryScanWithoutSidecar(t *testing.T) {
|
|||||||
// Create scanner and configure it
|
// Create scanner and configure it
|
||||||
scanner := services.NewMediaScanner(setup.DB)
|
scanner := services.NewMediaScanner(setup.DB)
|
||||||
scanner.SetAdminID(adminID)
|
scanner.SetAdminID(adminID)
|
||||||
err = scanner.SetFolders([]string{tmpDir})
|
err = scanner.SetFolders([]string{tmpDir}, false)
|
||||||
require.NoError(t, err, "Failed to set scanner folders")
|
require.NoError(t, err, "Failed to set scanner folders")
|
||||||
|
|
||||||
// Scan library
|
// Scan library
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -44,7 +45,9 @@ func TestCollectionsBulkOperations(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -61,7 +64,9 @@ func TestCollectionsBulkOperations(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -85,12 +90,15 @@ func TestCollectionsBulkOperations(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var result map[string]interface{}
|
var result map[string]interface{}
|
||||||
json.NewDecoder(resp.Body).Decode(&result)
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
assert.Contains(t, result, "results")
|
assert.Contains(t, result, "results")
|
||||||
assert.Contains(t, result, "total")
|
assert.Contains(t, result, "total")
|
||||||
@@ -122,10 +130,13 @@ func TestCollectionsBulkOperations(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(collectionHTTP)
|
resp, err := client.Do(collectionHTTP)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
var collectionResult map[string]interface{}
|
var collectionResult map[string]interface{}
|
||||||
json.NewDecoder(resp.Body).Decode(&collectionResult)
|
err = json.NewDecoder(resp.Body).Decode(&collectionResult)
|
||||||
|
require.NoError(t, err)
|
||||||
collectionID := collectionResult["id"].(string)
|
collectionID := collectionResult["id"].(string)
|
||||||
|
|
||||||
// Now try to add invalid book IDs
|
// Now try to add invalid book IDs
|
||||||
@@ -145,12 +156,15 @@ func TestCollectionsBulkOperations(t *testing.T) {
|
|||||||
|
|
||||||
resp, err = client.Do(addHTTP)
|
resp, err = client.Do(addHTTP)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var result map[string]interface{}
|
var result map[string]interface{}
|
||||||
json.NewDecoder(resp.Body).Decode(&result)
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
results := result["results"].([]interface{})
|
results := result["results"].([]interface{})
|
||||||
firstResult := results[0].(map[string]interface{})
|
firstResult := results[0].(map[string]interface{})
|
||||||
@@ -171,10 +185,13 @@ func TestCollectionsBulkOperations(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(collectionHTTP)
|
resp, err := client.Do(collectionHTTP)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
var collectionResult map[string]interface{}
|
var collectionResult map[string]interface{}
|
||||||
json.NewDecoder(resp.Body).Decode(&collectionResult)
|
err = json.NewDecoder(resp.Body).Decode(&collectionResult)
|
||||||
|
require.NoError(t, err)
|
||||||
collectionID := collectionResult["id"].(string)
|
collectionID := collectionResult["id"].(string)
|
||||||
|
|
||||||
// Create a book
|
// Create a book
|
||||||
@@ -197,12 +214,15 @@ func TestCollectionsBulkOperations(t *testing.T) {
|
|||||||
|
|
||||||
resp, err = client.Do(addHTTP)
|
resp, err = client.Do(addHTTP)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var result map[string]interface{}
|
var result map[string]interface{}
|
||||||
json.NewDecoder(resp.Body).Decode(&result)
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
assert.Contains(t, result, "results")
|
assert.Contains(t, result, "results")
|
||||||
assert.Contains(t, result, "total")
|
assert.Contains(t, result, "total")
|
||||||
@@ -228,10 +248,13 @@ func TestCollectionsBulkOperations(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(collectionHTTP)
|
resp, err := client.Do(collectionHTTP)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
var collectionResult map[string]interface{}
|
var collectionResult map[string]interface{}
|
||||||
json.NewDecoder(resp.Body).Decode(&collectionResult)
|
err = json.NewDecoder(resp.Body).Decode(&collectionResult)
|
||||||
|
require.NoError(t, err)
|
||||||
collectionID := collectionResult["id"].(string)
|
collectionID := collectionResult["id"].(string)
|
||||||
|
|
||||||
// Create multiple books
|
// Create multiple books
|
||||||
@@ -256,12 +279,15 @@ func TestCollectionsBulkOperations(t *testing.T) {
|
|||||||
|
|
||||||
resp, err = client.Do(addHTTP)
|
resp, err = client.Do(addHTTP)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var result map[string]interface{}
|
var result map[string]interface{}
|
||||||
json.NewDecoder(resp.Body).Decode(&result)
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
assert.Equal(t, 3.0, result["total"])
|
assert.Equal(t, 3.0, result["total"])
|
||||||
assert.True(t, result["added"].(float64) > 0)
|
assert.True(t, result["added"].(float64) > 0)
|
||||||
@@ -281,10 +307,13 @@ func TestCollectionsBulkOperations(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(collectionHTTP)
|
resp, err := client.Do(collectionHTTP)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
var collectionResult1 map[string]interface{}
|
var collectionResult1 map[string]interface{}
|
||||||
json.NewDecoder(resp.Body).Decode(&collectionResult1)
|
err = json.NewDecoder(resp.Body).Decode(&collectionResult1)
|
||||||
|
require.NoError(t, err)
|
||||||
collectionID1 := collectionResult1["id"].(string)
|
collectionID1 := collectionResult1["id"].(string)
|
||||||
|
|
||||||
collectionReq2 := map[string]interface{}{
|
collectionReq2 := map[string]interface{}{
|
||||||
@@ -299,10 +328,13 @@ func TestCollectionsBulkOperations(t *testing.T) {
|
|||||||
|
|
||||||
resp, err = client.Do(collectionHTTP2)
|
resp, err = client.Do(collectionHTTP2)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
var collectionResult2 map[string]interface{}
|
var collectionResult2 map[string]interface{}
|
||||||
json.NewDecoder(resp.Body).Decode(&collectionResult2)
|
err = json.NewDecoder(resp.Body).Decode(&collectionResult2)
|
||||||
|
require.NoError(t, err)
|
||||||
collectionID2 := collectionResult2["id"].(string)
|
collectionID2 := collectionResult2["id"].(string)
|
||||||
|
|
||||||
// Create books
|
// Create books
|
||||||
@@ -330,12 +362,15 @@ func TestCollectionsBulkOperations(t *testing.T) {
|
|||||||
|
|
||||||
resp, err = client.Do(addHTTP)
|
resp, err = client.Do(addHTTP)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var result map[string]interface{}
|
var result map[string]interface{}
|
||||||
json.NewDecoder(resp.Body).Decode(&result)
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
assert.Contains(t, result, "results")
|
assert.Contains(t, result, "results")
|
||||||
assert.Equal(t, 3.0, result["total"])
|
assert.Equal(t, 3.0, result["total"])
|
||||||
@@ -355,10 +390,13 @@ func TestCollectionsBulkOperations(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(collectionHTTP)
|
resp, err := client.Do(collectionHTTP)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
var collectionResult map[string]interface{}
|
var collectionResult map[string]interface{}
|
||||||
json.NewDecoder(resp.Body).Decode(&collectionResult)
|
err = json.NewDecoder(resp.Body).Decode(&collectionResult)
|
||||||
|
require.NoError(t, err)
|
||||||
collectionID := collectionResult["id"].(string)
|
collectionID := collectionResult["id"].(string)
|
||||||
|
|
||||||
// Create a book
|
// Create a book
|
||||||
@@ -381,7 +419,9 @@ func TestCollectionsBulkOperations(t *testing.T) {
|
|||||||
|
|
||||||
resp, err = client.Do(addHTTP)
|
resp, err = client.Do(addHTTP)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
// Try to add same book again - create new request with fresh body
|
// Try to add same book again - create new request with fresh body
|
||||||
addBody2, _ := json.Marshal(addReq)
|
addBody2, _ := json.Marshal(addReq)
|
||||||
@@ -391,7 +431,9 @@ func TestCollectionsBulkOperations(t *testing.T) {
|
|||||||
|
|
||||||
resp2, err := client.Do(addHTTP2)
|
resp2, err := client.Do(addHTTP2)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp2.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp2.Body)
|
||||||
|
|
||||||
// Should handle duplicate gracefully (either succeed or return error)
|
// Should handle duplicate gracefully (either succeed or return error)
|
||||||
assert.Equal(t, http.StatusOK, resp2.StatusCode)
|
assert.Equal(t, http.StatusOK, resp2.StatusCode)
|
||||||
@@ -405,7 +447,9 @@ func TestCollectionsBulkOperations(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -112,7 +112,8 @@ func TestPreviewCollection(t *testing.T) {
|
|||||||
assert.Equal(t, http.StatusOK, rec.Code)
|
assert.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
|
||||||
var result map[string]interface{}
|
var result map[string]interface{}
|
||||||
json.NewDecoder(rec.Body).Decode(&result)
|
err := json.NewDecoder(rec.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
items := result["items"].([]interface{})
|
items := result["items"].([]interface{})
|
||||||
assert.Equal(t, 0, len(items), "Empty rules should return no matched items")
|
assert.Equal(t, 0, len(items), "Empty rules should return no matched items")
|
||||||
@@ -139,7 +140,8 @@ func TestPreviewCollection(t *testing.T) {
|
|||||||
assert.Equal(t, http.StatusOK, rec.Code)
|
assert.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
|
||||||
var result map[string]interface{}
|
var result map[string]interface{}
|
||||||
json.NewDecoder(rec.Body).Decode(&result)
|
err := json.NewDecoder(rec.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
items := result["items"].([]interface{})
|
items := result["items"].([]interface{})
|
||||||
assert.Equal(t, 2, len(items), "Should return exactly 2 manually selected books")
|
assert.Equal(t, 2, len(items), "Should return exactly 2 manually selected books")
|
||||||
@@ -171,7 +173,8 @@ func TestPreviewCollection(t *testing.T) {
|
|||||||
assert.Equal(t, http.StatusOK, rec.Code)
|
assert.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
|
||||||
var result map[string]interface{}
|
var result map[string]interface{}
|
||||||
json.NewDecoder(rec.Body).Decode(&result)
|
err := json.NewDecoder(rec.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
items := result["items"].([]interface{})
|
items := result["items"].([]interface{})
|
||||||
assert.Greater(t, len(items), 0, "Should return books matching the genre rule")
|
assert.Greater(t, len(items), 0, "Should return books matching the genre rule")
|
||||||
@@ -203,7 +206,8 @@ func TestPreviewCollection(t *testing.T) {
|
|||||||
assert.Equal(t, http.StatusOK, rec.Code)
|
assert.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
|
||||||
var result map[string]interface{}
|
var result map[string]interface{}
|
||||||
json.NewDecoder(rec.Body).Decode(&result)
|
err := json.NewDecoder(rec.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
items := result["items"].([]interface{})
|
items := result["items"].([]interface{})
|
||||||
assert.Greater(t, len(items), 0, "Should return books from rules and manual selection")
|
assert.Greater(t, len(items), 0, "Should return books from rules and manual selection")
|
||||||
@@ -233,7 +237,8 @@ func TestPreviewCollection(t *testing.T) {
|
|||||||
assert.Equal(t, http.StatusOK, rec.Code)
|
assert.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
|
||||||
var result map[string]interface{}
|
var result map[string]interface{}
|
||||||
json.NewDecoder(rec.Body).Decode(&result)
|
err := json.NewDecoder(rec.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
items := result["items"].([]interface{})
|
items := result["items"].([]interface{})
|
||||||
assert.LessOrEqual(t, len(items), 2, "Should respect limit parameter")
|
assert.LessOrEqual(t, len(items), 2, "Should respect limit parameter")
|
||||||
@@ -259,7 +264,8 @@ func TestPreviewCollection(t *testing.T) {
|
|||||||
assert.Equal(t, http.StatusOK, rec.Code)
|
assert.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
|
||||||
var result map[string]interface{}
|
var result map[string]interface{}
|
||||||
json.NewDecoder(rec.Body).Decode(&result)
|
err := json.NewDecoder(rec.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
items := result["items"].([]interface{})
|
items := result["items"].([]interface{})
|
||||||
assert.Equal(t, 1, len(items), "Should still return items when limit exceeds max")
|
assert.Equal(t, 1, len(items), "Should still return items when limit exceeds max")
|
||||||
@@ -285,7 +291,8 @@ func TestPreviewCollection(t *testing.T) {
|
|||||||
assert.Equal(t, http.StatusOK, rec.Code)
|
assert.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
|
||||||
var result map[string]interface{}
|
var result map[string]interface{}
|
||||||
json.NewDecoder(rec.Body).Decode(&result)
|
err := json.NewDecoder(rec.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
items := result["items"].([]interface{})
|
items := result["items"].([]interface{})
|
||||||
assert.Equal(t, 1, len(items), "Limit 0 should default to 20 and still return matched items")
|
assert.Equal(t, 1, len(items), "Limit 0 should default to 20 and still return matched items")
|
||||||
@@ -312,7 +319,8 @@ func TestPreviewCollection(t *testing.T) {
|
|||||||
assert.Equal(t, http.StatusOK, rec.Code)
|
assert.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
|
||||||
var result map[string]interface{}
|
var result map[string]interface{}
|
||||||
json.NewDecoder(rec.Body).Decode(&result)
|
err := json.NewDecoder(rec.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
items := result["items"].([]interface{})
|
items := result["items"].([]interface{})
|
||||||
assert.Equal(t, 1, len(items), "Invalid book IDs should be skipped, valid ones included")
|
assert.Equal(t, 1, len(items), "Invalid book IDs should be skipped, valid ones included")
|
||||||
@@ -340,7 +348,8 @@ func TestPreviewCollection(t *testing.T) {
|
|||||||
assert.Equal(t, http.StatusOK, rec.Code)
|
assert.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
|
||||||
var result map[string]interface{}
|
var result map[string]interface{}
|
||||||
json.NewDecoder(rec.Body).Decode(&result)
|
err := json.NewDecoder(rec.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
items := result["items"].([]interface{})
|
items := result["items"].([]interface{})
|
||||||
assert.Equal(t, 1, len(items), "Duplicate book IDs should result in unique items")
|
assert.Equal(t, 1, len(items), "Duplicate book IDs should result in unique items")
|
||||||
|
|||||||
@@ -0,0 +1,353 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"bookhoard/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HTTP helpers -----------------------------------------------------------
|
||||||
|
|
||||||
|
func smartCreateCollection(t *testing.T, setup *TestServerSetup, token, name string, collectionType string) string {
|
||||||
|
t.Helper()
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"name": name,
|
||||||
|
"description": "smart collections test",
|
||||||
|
"color": "blue",
|
||||||
|
"icon": "📚",
|
||||||
|
"collection_type": collectionType,
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(payload)
|
||||||
|
req, _ := http.NewRequest("POST", setup.Server.URL+"/api/collections", bytes.NewBuffer(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body)
|
||||||
|
require.Equal(t, http.StatusCreated, resp.StatusCode, "collection creation should succeed")
|
||||||
|
|
||||||
|
var result map[string]interface{}
|
||||||
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&result))
|
||||||
|
return result["id"].(string)
|
||||||
|
}
|
||||||
|
|
||||||
|
func smartGetCollection(t *testing.T, setup *TestServerSetup, token, collectionID string) map[string]interface{} {
|
||||||
|
t.Helper()
|
||||||
|
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/collections/"+collectionID, nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body)
|
||||||
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
var result map[string]interface{}
|
||||||
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&result))
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func smartPostRule(t *testing.T, setup *TestServerSetup, token, collectionID, field, operator, value string, enabled *bool) *http.Response {
|
||||||
|
t.Helper()
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"field": field,
|
||||||
|
"operator": operator,
|
||||||
|
"value": value,
|
||||||
|
}
|
||||||
|
if enabled != nil {
|
||||||
|
payload["enabled"] = *enabled
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(payload)
|
||||||
|
req, _ := http.NewRequest("POST", setup.Server.URL+"/api/collections/"+collectionID+"/rules", bytes.NewBuffer(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
func smartDecodeRuleID(t *testing.T, resp *http.Response) string {
|
||||||
|
t.Helper()
|
||||||
|
defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body)
|
||||||
|
var result map[string]interface{}
|
||||||
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&result))
|
||||||
|
rule := result["rule"].(map[string]interface{})
|
||||||
|
return rule["id"].(string)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Data helpers -----------------------------------------------------------
|
||||||
|
|
||||||
|
func smartCreateLibraryAndBooks(t *testing.T, setup *TestServerSetup, userID uuid.UUID, titlesByGenre map[string]string) (libraryID string, bookIDs []string) {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
libType, err := setup.DB.GetLibraryTypeByName(ctx, "ebooks")
|
||||||
|
require.NoError(t, err)
|
||||||
|
uniqueName := fmt.Sprintf("Test Smart Rules Library %d", time.Now().UnixNano())
|
||||||
|
library, err := setup.DB.CreateLibrary(ctx, database.CreateLibraryParams{
|
||||||
|
Name: uniqueName,
|
||||||
|
Description: pgtype.Text{String: "smart collections test library", Valid: true},
|
||||||
|
LibraryTypeID: libType.ID,
|
||||||
|
CreatedByAdminID: pgtype.UUID{Bytes: [16]byte(userID), Valid: true},
|
||||||
|
})
|
||||||
|
require.NoError(t, err, "should create test library")
|
||||||
|
libUUID, err := uuid.FromBytes(library.ID.Bytes[0:16])
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = setup.DB.DeleteLibrary(ctx, library.ID)
|
||||||
|
})
|
||||||
|
|
||||||
|
for title, genre := range titlesByGenre {
|
||||||
|
media, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
||||||
|
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
|
||||||
|
Title: title,
|
||||||
|
Author: pgtype.Text{String: "Smart Rule Author", Valid: true},
|
||||||
|
Genre: pgtype.Text{String: genre, Valid: true},
|
||||||
|
FilePath: fmt.Sprintf("/tmp/%s-%d.epub", title, time.Now().UnixNano()),
|
||||||
|
FileSize: pgtype.Int8{Int64: 1024, Valid: true},
|
||||||
|
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
|
||||||
|
})
|
||||||
|
require.NoError(t, err, "should create test media item")
|
||||||
|
mediaUUID, err := uuid.FromBytes(media.ID.Bytes[0:16])
|
||||||
|
require.NoError(t, err)
|
||||||
|
bookIDs = append(bookIDs, mediaUUID.String())
|
||||||
|
}
|
||||||
|
return libUUID.String(), bookIDs
|
||||||
|
}
|
||||||
|
|
||||||
|
func smartLibraryTitleSet(t *testing.T, coll map[string]interface{}) map[string]bool {
|
||||||
|
t.Helper()
|
||||||
|
books, _ := coll["books"].([]interface{})
|
||||||
|
set := make(map[string]bool, len(books))
|
||||||
|
for _, b := range books {
|
||||||
|
if m, ok := b.(map[string]interface{}); ok {
|
||||||
|
if title, ok := m["title"].(string); ok {
|
||||||
|
set[title] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return set
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tests ------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestSmartCollections(t *testing.T) {
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
adminID := getTestUserID(t, setup.DB)
|
||||||
|
|
||||||
|
t.Run("FirstRuleConvertsCollectionToSmart_LiveMembership", func(t *testing.T) {
|
||||||
|
_, bookIDs := smartCreateLibraryAndBooks(t, setup, adminID, map[string]string{
|
||||||
|
"Smart SF Book One": "Science Fiction",
|
||||||
|
"Smart SF Book Two": "Science Fiction",
|
||||||
|
"Smart FANT Book": "Fantasy",
|
||||||
|
})
|
||||||
|
|
||||||
|
// Rule evaluation ignores stored links: a static collection's own
|
||||||
|
// added item must NOT show up once the collection is smart.
|
||||||
|
collID := smartCreateCollection(t, setup, setup.Token, "Test Smart Live "+uuid.NewString()[:8], "static")
|
||||||
|
|
||||||
|
// Add one hand-picked book (a Fantasy one that the smart rule
|
||||||
|
// will not match — so it must vanish from the render).
|
||||||
|
addBody, _ := json.Marshal(map[string]interface{}{"book_ids": []string{bookIDs[2]}})
|
||||||
|
addReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/collections/"+collID+"/books", bytes.NewBuffer(addBody))
|
||||||
|
addReq.Header.Set("Content-Type", "application/json")
|
||||||
|
addReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
|
addResp, err := http.DefaultClient.Do(addReq)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, http.StatusNoContent, addResp.StatusCode)
|
||||||
|
_ = addResp.Body.Close()
|
||||||
|
|
||||||
|
staticDetail := smartGetCollection(t, setup, setup.Token, collID)
|
||||||
|
assert.Equal(t, false, staticDetail["is_smart"])
|
||||||
|
assert.Equal(t, float64(1), staticDetail["book_count"], "static collection shows its stored link")
|
||||||
|
|
||||||
|
resp := smartPostRule(t, setup, setup.Token, collID, "genre", "equals", "Science Fiction", nil)
|
||||||
|
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
|
||||||
|
smartDetail := smartGetCollection(t, setup, setup.Token, collID)
|
||||||
|
assert.Equal(t, true, smartDetail["is_smart"], "rules presence derives the type")
|
||||||
|
assert.Equal(t, float64(2), smartDetail["book_count"], "live rule matches only")
|
||||||
|
|
||||||
|
memberTitles := smartLibraryTitleSet(t, smartDetail)
|
||||||
|
assert.True(t, memberTitles["Smart SF Book One"])
|
||||||
|
assert.True(t, memberTitles["Smart SF Book Two"])
|
||||||
|
assert.False(t, memberTitles["Smart FANT Book"], "hand-picked link is ignored under smart evaluation (v1 no hybrid)")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("DisabledRuleExcludedFromLiveSet", func(t *testing.T) {
|
||||||
|
smartCreateLibraryAndBooks(t, setup, adminID, map[string]string{
|
||||||
|
"Smart Diss Book": "Science Fiction",
|
||||||
|
})
|
||||||
|
collID := smartCreateCollection(t, setup, setup.Token, "Test Smart Disabled "+uuid.NewString()[:8], "smart")
|
||||||
|
|
||||||
|
resp := smartPostRule(t, setup, setup.Token, collID, "genre", "equals", "Science Fiction", nil)
|
||||||
|
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||||
|
ruleID := smartDecodeRuleID(t, resp)
|
||||||
|
|
||||||
|
assert.Equal(t, float64(1), smartGetCollection(t, setup, setup.Token, collID)["book_count"])
|
||||||
|
|
||||||
|
// Disable: membership drops to zero at once.
|
||||||
|
falseFlag := false
|
||||||
|
dBody, _ := json.Marshal(map[string]interface{}{"enabled": falseFlag})
|
||||||
|
dReq, _ := http.NewRequest("PUT", setup.Server.URL+"/api/collections/"+collID+"/rules/"+ruleID, bytes.NewBuffer(dBody))
|
||||||
|
dReq.Header.Set("Content-Type", "application/json")
|
||||||
|
dReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
|
dResp, err := http.DefaultClient.Do(dReq)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, http.StatusOK, dResp.StatusCode)
|
||||||
|
_ = dResp.Body.Close()
|
||||||
|
|
||||||
|
assert.Equal(t, float64(0), smartGetCollection(t, setup, setup.Token, collID)["book_count"], "disabled rules never match")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("SmartCollectionsRejectMembershipMutations", func(t *testing.T) {
|
||||||
|
smartCreateLibraryAndBooks(t, setup, adminID, map[string]string{
|
||||||
|
"Smart Guard Book": "Science Fiction",
|
||||||
|
})
|
||||||
|
collID := smartCreateCollection(t, setup, setup.Token, "Test Smart Guard "+uuid.NewString()[:8], "smart")
|
||||||
|
|
||||||
|
resp := smartPostRule(t, setup, setup.Token, collID, "genre", "equals", "Science Fiction", nil)
|
||||||
|
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
|
||||||
|
addBody, _ := json.Marshal(map[string]interface{}{"book_ids": []string{uuid.NewString()}})
|
||||||
|
addReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/collections/"+collID+"/books", bytes.NewBuffer(addBody))
|
||||||
|
addReq.Header.Set("Content-Type", "application/json")
|
||||||
|
addReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
|
addResp, err := http.DefaultClient.Do(addReq)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, http.StatusBadRequest, addResp.StatusCode)
|
||||||
|
_ = addResp.Body.Close()
|
||||||
|
|
||||||
|
removeReq, _ := http.NewRequest("DELETE", setup.Server.URL+"/api/collections/"+collID+"/books/"+uuid.NewString(), nil)
|
||||||
|
removeReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
|
removeResp, err := http.DefaultClient.Do(removeReq)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, http.StatusBadRequest, removeResp.StatusCode)
|
||||||
|
_ = removeResp.Body.Close()
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("RemoveLastRuleRevertsToStatic", func(t *testing.T) {
|
||||||
|
smartCreateLibraryAndBooks(t, setup, adminID, map[string]string{
|
||||||
|
"Smart Back Book": "Science Fiction",
|
||||||
|
})
|
||||||
|
collID := smartCreateCollection(t, setup, setup.Token, "Test Smart Revert "+uuid.NewString()[:8], "smart")
|
||||||
|
|
||||||
|
resp := smartPostRule(t, setup, setup.Token, collID, "tags", "contains", "no-such-tag", nil)
|
||||||
|
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||||
|
ruleID := smartDecodeRuleID(t, resp)
|
||||||
|
|
||||||
|
smartDetail := smartGetCollection(t, setup, setup.Token, collID)
|
||||||
|
assert.Equal(t, true, smartDetail["is_smart"])
|
||||||
|
|
||||||
|
delReq, _ := http.NewRequest("DELETE", setup.Server.URL+"/api/collections/"+collID+"/rules/"+ruleID, nil)
|
||||||
|
delReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
|
delResp, err := http.DefaultClient.Do(delReq)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, http.StatusOK, delResp.StatusCode)
|
||||||
|
_ = delResp.Body.Close()
|
||||||
|
|
||||||
|
staticDetail := smartGetCollection(t, setup, setup.Token, collID)
|
||||||
|
assert.Equal(t, false, staticDetail["is_smart"], "removing the last rule reverts to static")
|
||||||
|
|
||||||
|
// Membership mutations work again on the reverted static.
|
||||||
|
addBody, _ := json.Marshal(map[string]interface{}{"book_ids": []string{uuid.NewString()}})
|
||||||
|
addReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/collections/"+collID+"/books", bytes.NewBuffer(addBody))
|
||||||
|
addReq.Header.Set("Content-Type", "application/json")
|
||||||
|
addReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
|
addResp, err := http.DefaultClient.Do(addReq)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, http.StatusNoContent, addResp.StatusCode)
|
||||||
|
_ = addResp.Body.Close()
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("RuleValidationAndSystemCollections", func(t *testing.T) {
|
||||||
|
collID := smartCreateCollection(t, setup, setup.Token, "Test Smart Valid "+uuid.NewString()[:8], "static")
|
||||||
|
|
||||||
|
resp := smartPostRule(t, setup, setup.Token, collID, "not_a_field", "equals", "x", nil)
|
||||||
|
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
|
||||||
|
resp = smartPostRule(t, setup, setup.Token, collID, "genre", "equals", "Science Fiction", nil)
|
||||||
|
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
|
||||||
|
// System collections may never carry rules.
|
||||||
|
colls, err := setup.DB.GetCollectionsByUser(context.Background(), pgtype.UUID{Bytes: [16]byte(adminID), Valid: true})
|
||||||
|
require.NoError(t, err)
|
||||||
|
var systemID string
|
||||||
|
for _, c := range colls {
|
||||||
|
if c.IsSystemCollection.Bool {
|
||||||
|
systemID = uuid.UUID(c.ID.Bytes).String()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require.NotEmpty(t, systemID, "admin user should have system collections")
|
||||||
|
|
||||||
|
sysResp := smartPostRule(t, setup, setup.Token, systemID, "genre", "equals", "x", nil)
|
||||||
|
require.Equal(t, http.StatusBadRequest, sysResp.StatusCode)
|
||||||
|
_ = sysResp.Body.Close()
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("CollectionsListRespectsIncludeAutoWithLiveCounts", func(t *testing.T) {
|
||||||
|
_, _ = smartCreateLibraryAndBooks(t, setup, adminID, map[string]string{
|
||||||
|
"Smart List Book": "Science Fiction",
|
||||||
|
})
|
||||||
|
collID := smartCreateCollection(t, setup, setup.Token, "Test Smart List "+uuid.NewString()[:8], "smart")
|
||||||
|
|
||||||
|
resp := smartPostRule(t, setup, setup.Token, collID, "genre", "equals", "Science Fiction", nil)
|
||||||
|
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
|
||||||
|
// Default list (the Android app's read): smart collections hidden.
|
||||||
|
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/collections", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
|
defaultResp, err := http.DefaultClient.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func(Body io.ReadCloser) { _ = Body.Close() }(defaultResp.Body)
|
||||||
|
var defaultData struct {
|
||||||
|
Collections []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
} `json:"collections"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.NewDecoder(defaultResp.Body).Decode(&defaultData))
|
||||||
|
for _, c := range defaultData.Collections {
|
||||||
|
assert.NotEqual(t, collID, c.ID, "default listing excludes rule-driven collections (app compat)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Opt-in listing includes them with LIVE counts.
|
||||||
|
scopeReq, _ := http.NewRequest("GET", setup.Server.URL+"/api/collections?include_auto=true", nil)
|
||||||
|
scopeReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
|
scopeResp, err := http.DefaultClient.Do(scopeReq)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func(Body io.ReadCloser) { _ = Body.Close() }(scopeResp.Body)
|
||||||
|
var scopeData struct {
|
||||||
|
Collections []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
BookCount int `json:"book_count"`
|
||||||
|
IsSmart bool `json:"is_smart"`
|
||||||
|
} `json:"collections"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.NewDecoder(scopeResp.Body).Decode(&scopeData))
|
||||||
|
found := false
|
||||||
|
for _, c := range scopeData.Collections {
|
||||||
|
if c.ID == collID {
|
||||||
|
found = true
|
||||||
|
assert.True(t, c.IsSmart)
|
||||||
|
assert.Equal(t, 1, c.BookCount, "smart card counts rule matches live")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.True(t, found, "include_auto=true must surface smart collections")
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -132,13 +132,14 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
|||||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
body := readBody(resp)
|
body := readBody(resp)
|
||||||
|
|
||||||
// Verify reading direction badge is displayed
|
// Verify reading direction badge is displayed
|
||||||
assert.Contains(t, body, "📖", "Should display reading direction icon")
|
|
||||||
assert.Contains(t, body, "RTL", "Should display RTL reading direction")
|
assert.Contains(t, body, "RTL", "Should display RTL reading direction")
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -152,7 +153,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
|||||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
body := readBody(resp)
|
body := readBody(resp)
|
||||||
@@ -169,7 +172,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
|||||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
body := readBody(resp)
|
body := readBody(resp)
|
||||||
@@ -190,7 +195,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
|||||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
body := readBody(resp)
|
body := readBody(resp)
|
||||||
@@ -210,7 +217,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
|||||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
body := readBody(resp)
|
body := readBody(resp)
|
||||||
@@ -227,7 +236,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
|||||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
body := readBody(resp)
|
body := readBody(resp)
|
||||||
@@ -244,12 +255,15 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
|||||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
body := readBody(resp)
|
body := readBody(resp)
|
||||||
|
|
||||||
assert.Contains(t, body, "📚", "Should display story arc icon")
|
// The redesigned badge renders the arc NAME in a text badge
|
||||||
|
// (no 📚 icon) — assert the name.
|
||||||
assert.Contains(t, body, "The Dark Phoenix Saga", "Should display story arc name")
|
assert.Contains(t, body, "The Dark Phoenix Saga", "Should display story arc name")
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -262,7 +276,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
|||||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
body := readBody(resp)
|
body := readBody(resp)
|
||||||
@@ -280,7 +296,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
|||||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
body := readBody(resp)
|
body := readBody(resp)
|
||||||
@@ -298,7 +316,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
|||||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
body := readBody(resp)
|
body := readBody(resp)
|
||||||
@@ -316,7 +336,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
|||||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
body := readBody(resp)
|
body := readBody(resp)
|
||||||
@@ -334,7 +356,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
|||||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
body := readBody(resp)
|
body := readBody(resp)
|
||||||
@@ -353,7 +377,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
|||||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
body := readBody(resp)
|
body := readBody(resp)
|
||||||
@@ -371,7 +397,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
|||||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
body := readBody(resp)
|
body := readBody(resp)
|
||||||
@@ -389,7 +417,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
|||||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
body := readBody(resp)
|
body := readBody(resp)
|
||||||
@@ -439,7 +469,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
|||||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
body := readBody(resp)
|
body := readBody(resp)
|
||||||
@@ -469,12 +501,15 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
|||||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
body := readBody(resp)
|
body := readBody(resp)
|
||||||
|
|
||||||
// Verify all manga-specific fields are displayed
|
// The redesigned badge (07578e0) renders the direction text chip
|
||||||
|
// without the 📖 emoji — assert the chip text.
|
||||||
assert.Contains(t, body, "RTL", "Should display RTL reading direction")
|
assert.Contains(t, body, "RTL", "Should display RTL reading direction")
|
||||||
assert.Contains(t, body, "Teen", "Should display age rating")
|
assert.Contains(t, body, "Teen", "Should display age rating")
|
||||||
assert.Contains(t, body, "8.5 / 10", "Should display community rating")
|
assert.Contains(t, body, "8.5 / 10", "Should display community rating")
|
||||||
@@ -500,7 +535,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
|||||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
body := readBody(resp)
|
body := readBody(resp)
|
||||||
@@ -526,7 +563,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
|||||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
body := readBody(resp)
|
body := readBody(resp)
|
||||||
@@ -567,7 +606,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
|||||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
body := readBody(resp)
|
body := readBody(resp)
|
||||||
@@ -579,9 +620,11 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
|||||||
assert.Contains(t, body, "Everyone", "Should display age rating")
|
assert.Contains(t, body, "Everyone", "Should display age rating")
|
||||||
assert.Contains(t, body, "goodreads.com", "Should display web URL")
|
assert.Contains(t, body, "goodreads.com", "Should display web URL")
|
||||||
|
|
||||||
// Verify comic-specific fields are NOT displayed
|
// Verify comic-specific fields are NOT displayed. The page body
|
||||||
assert.NotContains(t, body, "Manga Type", "Should not display manga type for ebook")
|
// always contains the admin edit modal's "Manga Type"/"Scan Info"
|
||||||
assert.NotContains(t, body, "Scan Info", "Should not display scan info for ebook")
|
// LABELS — assert the metadata VALUES (what the display block
|
||||||
|
// renders) are absent instead.
|
||||||
|
assert.NotContains(t, body, "yes and right to left", "Should not display manga type value for ebook")
|
||||||
assert.NotContains(t, body, "B&W", "Should not display B&W badge for ebook")
|
assert.NotContains(t, body, "B&W", "Should not display B&W badge for ebook")
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -594,7 +637,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
|||||||
req, _ := http.NewRequest("GET", setup.Server.URL+"/media/"+mediaUUID, nil)
|
req, _ := http.NewRequest("GET", setup.Server.URL+"/media/"+mediaUUID, nil)
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode, "Should require authentication")
|
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode, "Should require authentication")
|
||||||
})
|
})
|
||||||
@@ -610,7 +655,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
|||||||
req.Header.Set("Authorization", "Bearer "+setup.RegularToken)
|
req.Header.Set("Authorization", "Bearer "+setup.RegularToken)
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
body := readBody(resp)
|
body := readBody(resp)
|
||||||
@@ -629,16 +676,20 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
|||||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
body := readBody(resp)
|
body := readBody(resp)
|
||||||
|
|
||||||
// Page should still render successfully
|
// Page should still render successfully
|
||||||
assert.Contains(t, body, "Minimal Comic", "Should display title")
|
assert.Contains(t, body, "Minimal Comic", "Should display title")
|
||||||
// Use "Community Rating:" with colon to avoid matching the HTML comment
|
// The admin edit modal always carries "Manga Type" as a form
|
||||||
|
// LABEL — the display block would render the capitalized VALUE,
|
||||||
|
// which is absent here.
|
||||||
assert.NotContains(t, body, "Community Rating:", "Should not show community rating when not set")
|
assert.NotContains(t, body, "Community Rating:", "Should not show community rating when not set")
|
||||||
assert.NotContains(t, body, "Manga Type", "Should not show manga type when unknown")
|
assert.NotContains(t, body, "yes and right to left", "Should not show a manga type value when unknown")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -719,7 +770,9 @@ func TestComicMetadataDisplay_AllFieldsTogether(t *testing.T) {
|
|||||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
body := readBody(resp)
|
body := readBody(resp)
|
||||||
|
|||||||
+550
-257
@@ -1,289 +1,582 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bookhoard/internal/database"
|
||||||
|
"bookhoard/internal/handlers"
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http/httptest"
|
"io"
|
||||||
|
"net/http"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestConflictDetection_TriggeringConditions(t *testing.T) {
|
type conflictTestEnv struct {
|
||||||
t.Run("conflict detected when different devices sync within 5 minutes", func(t *testing.T) {
|
setup *TestServerSetup
|
||||||
conflictData := map[string]map[string]interface{}{
|
mediaID string
|
||||||
"koreader": {
|
userID pgtype.UUID
|
||||||
"source": "koreader",
|
mediaPGID pgtype.UUID
|
||||||
"timestamp": "2026-01-30T20:10:00Z",
|
|
||||||
"data": map[string]interface{}{
|
|
||||||
"percentage": 0.45,
|
|
||||||
"epubcfi": "epubcfi(/6/4/2:15)",
|
|
||||||
"chapter": 3,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"kobo": {
|
|
||||||
"source": "kobo",
|
|
||||||
"timestamp": "2026-01-30T20:05:00Z",
|
|
||||||
"data": map[string]interface{}{
|
|
||||||
"percentage": 0.42,
|
|
||||||
"page": 89,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
body, err := json.Marshal(conflictData)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
req := httptest.NewRequest("POST", "/api/sync/koreader/progress", bytes.NewReader(body))
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
|
|
||||||
assert.Equal(t, "POST", req.Method)
|
|
||||||
assert.Contains(t, string(body), "koreader")
|
|
||||||
assert.Contains(t, string(body), "kobo")
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("no conflict when progress difference is less than 1%", func(t *testing.T) {
|
|
||||||
progressData := map[string]interface{}{
|
|
||||||
"percentage": 0.45,
|
|
||||||
}
|
|
||||||
|
|
||||||
existingProgress := map[string]interface{}{
|
|
||||||
"percentage": 0.451,
|
|
||||||
}
|
|
||||||
|
|
||||||
diff := progressData["percentage"].(float64) - existingProgress["percentage"].(float64)
|
|
||||||
if diff < 0 {
|
|
||||||
diff = -diff
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Less(t, diff, 0.01, "Should not trigger conflict for small differences")
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("no conflict when sync timestamps are more than 5 minutes apart", func(t *testing.T) {
|
|
||||||
timestamp1 := "2026-01-30T20:00:00Z"
|
|
||||||
timestamp2 := "2026-01-30T20:10:00Z"
|
|
||||||
|
|
||||||
var conflictDetected bool
|
|
||||||
if timestamp2 > timestamp1 {
|
|
||||||
conflictDetected = false
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.False(t, conflictDetected, "Should not trigger conflict for old syncs")
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestConflictResolution_ChoosingWinner(t *testing.T) {
|
func setupConflictTest(t *testing.T) *conflictTestEnv {
|
||||||
t.Run("resolve conflict by choosing koreader source", func(t *testing.T) {
|
t.Helper()
|
||||||
conflictID := uuid.New()
|
|
||||||
|
|
||||||
reqBody := map[string]interface{}{
|
setup := setupTestServer(t)
|
||||||
"winner": "koreader",
|
mediaID := createTestMediaItemID(t, setup)
|
||||||
"manual_data": nil,
|
|
||||||
"apply_to_all_future_conflicts": false,
|
|
||||||
"reason": "More recent progress",
|
|
||||||
}
|
|
||||||
|
|
||||||
body, err := json.Marshal(reqBody)
|
ctx := context.Background()
|
||||||
assert.NoError(t, err)
|
user, err := setup.DB.GetUserByEmail(ctx, "testuser@tests.bookhoard.internal")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
req := httptest.NewRequest("POST", "/api/conflicts/"+conflictID.String()+"/resolve", bytes.NewReader(body))
|
mediaUUID, err := uuid.Parse(mediaID)
|
||||||
req.Header.Set("Content-Type", "application/json")
|
require.NoError(t, err)
|
||||||
|
|
||||||
assert.Equal(t, "POST", req.Method)
|
return &conflictTestEnv{
|
||||||
assert.Contains(t, req.URL.Path, conflictID.String())
|
setup: setup,
|
||||||
assert.Contains(t, string(body), "koreader")
|
mediaID: mediaID,
|
||||||
|
userID: user.ID,
|
||||||
|
mediaPGID: pgtype.UUID{Bytes: [16]byte(mediaUUID), Valid: true},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func createTestConflict(t *testing.T, env *conflictTestEnv, conflictData map[string]interface{}) database.SyncConflicts {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
dataJSON, err := json.Marshal(conflictData)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
conflict, err := env.setup.DB.CreateSyncConflict(ctx, database.CreateSyncConflictParams{
|
||||||
|
MediaItemID: env.mediaPGID,
|
||||||
|
UserID: env.userID,
|
||||||
|
ConflictType: "progress",
|
||||||
|
ConflictData: dataJSON,
|
||||||
})
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
t.Run("resolve conflict with manual merge data", func(t *testing.T) {
|
return conflict
|
||||||
conflictID := uuid.New()
|
}
|
||||||
|
|
||||||
manualData := map[string]interface{}{
|
func makeConflictData(koreaderPct, koboPct float64) map[string]interface{} {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"koreader": map[string]interface{}{
|
||||||
|
"source": "koreader",
|
||||||
|
"timestamp": time.Date(2026, 1, 30, 20, 10, 0, 0, time.UTC),
|
||||||
|
"data": map[string]interface{}{
|
||||||
|
"percentage": koreaderPct,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"kobo": map[string]interface{}{
|
||||||
|
"source": "kobo",
|
||||||
|
"timestamp": time.Date(2026, 1, 30, 20, 5, 0, 0, time.UTC),
|
||||||
|
"data": map[string]interface{}{
|
||||||
|
"percentage": koboPct,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConflictList_Empty(t *testing.T) {
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
client := &http.Client{}
|
||||||
|
|
||||||
|
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/conflicts", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
var result handlers.ConflictListResponse
|
||||||
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, 0, result.Total)
|
||||||
|
assert.Empty(t, result.Conflicts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConflictList_WithConflicts(t *testing.T) {
|
||||||
|
env := setupConflictTest(t)
|
||||||
|
client := &http.Client{}
|
||||||
|
|
||||||
|
createTestConflict(t, env, makeConflictData(0.45, 0.42))
|
||||||
|
|
||||||
|
req, _ := http.NewRequest("GET", env.setup.Server.URL+"/api/conflicts?status=all", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+env.setup.Token)
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
var result handlers.ConflictListResponse
|
||||||
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.GreaterOrEqual(t, result.Total, 1)
|
||||||
|
require.NotEmpty(t, result.Conflicts)
|
||||||
|
|
||||||
|
conflict := result.Conflicts[0]
|
||||||
|
assert.Equal(t, "progress", conflict.ConflictType)
|
||||||
|
assert.Equal(t, "unresolved", conflict.ResolutionStatus)
|
||||||
|
assert.Contains(t, conflict.ConflictData, "koreader")
|
||||||
|
assert.Contains(t, conflict.ConflictData, "kobo")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConflictList_UnresolvedCount(t *testing.T) {
|
||||||
|
env := setupConflictTest(t)
|
||||||
|
client := &http.Client{}
|
||||||
|
|
||||||
|
createTestConflict(t, env, makeConflictData(0.45, 0.42))
|
||||||
|
|
||||||
|
req, _ := http.NewRequest("GET", env.setup.Server.URL+"/api/conflicts", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+env.setup.Token)
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
|
var result handlers.ConflictListResponse
|
||||||
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.GreaterOrEqual(t, result.Unresolved, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConflictGet_ByID(t *testing.T) {
|
||||||
|
env := setupConflictTest(t)
|
||||||
|
client := &http.Client{}
|
||||||
|
|
||||||
|
conflict := createTestConflict(t, env, makeConflictData(0.50, 0.30))
|
||||||
|
conflictID := uuid.UUID(conflict.ID.Bytes).String()
|
||||||
|
|
||||||
|
req, _ := http.NewRequest("GET", env.setup.Server.URL+"/api/conflicts/"+conflictID, nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+env.setup.Token)
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
var detail handlers.ConflictDetailResponse
|
||||||
|
err = json.NewDecoder(resp.Body).Decode(&detail)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, conflictID, detail.ID)
|
||||||
|
assert.Equal(t, env.mediaID, detail.MediaItemID)
|
||||||
|
assert.Equal(t, "progress", detail.ConflictType)
|
||||||
|
assert.Contains(t, detail.ConflictData, "koreader")
|
||||||
|
assert.Contains(t, detail.ConflictData, "kobo")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConflictGet_NotFound(t *testing.T) {
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
client := &http.Client{}
|
||||||
|
|
||||||
|
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/conflicts/"+uuid.New().String(), nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConflictGet_InvalidID(t *testing.T) {
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
client := &http.Client{}
|
||||||
|
|
||||||
|
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/conflicts/not-a-uuid", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConflictResolve_ByKOReader(t *testing.T) {
|
||||||
|
env := setupConflictTest(t)
|
||||||
|
client := &http.Client{}
|
||||||
|
|
||||||
|
conflict := createTestConflict(t, env, makeConflictData(0.75, 0.30))
|
||||||
|
conflictID := uuid.UUID(conflict.ID.Bytes).String()
|
||||||
|
|
||||||
|
resolveReq := map[string]interface{}{
|
||||||
|
"winner": "koreader",
|
||||||
|
"manual_data": nil,
|
||||||
|
"apply_to_all_future_conflicts": false,
|
||||||
|
"reason": "More recent progress",
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(resolveReq)
|
||||||
|
|
||||||
|
req, _ := http.NewRequest("POST", env.setup.Server.URL+"/api/conflicts/"+conflictID+"/resolve", bytes.NewBuffer(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+env.setup.Token)
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
var result handlers.ConflictResolveResponse
|
||||||
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.True(t, result.ConflictResolved)
|
||||||
|
assert.Equal(t, map[string]bool{"progress": true, "annotations": false}, result.AppliedTo)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConflictResolve_ByKobo(t *testing.T) {
|
||||||
|
env := setupConflictTest(t)
|
||||||
|
client := &http.Client{}
|
||||||
|
|
||||||
|
conflict := createTestConflict(t, env, makeConflictData(0.30, 0.75))
|
||||||
|
conflictID := uuid.UUID(conflict.ID.Bytes).String()
|
||||||
|
|
||||||
|
resolveReq := map[string]interface{}{
|
||||||
|
"winner": "kobo",
|
||||||
|
"apply_to_all_future_conflicts": false,
|
||||||
|
"reason": "Higher progress",
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(resolveReq)
|
||||||
|
|
||||||
|
req, _ := http.NewRequest("POST", env.setup.Server.URL+"/api/conflicts/"+conflictID+"/resolve", bytes.NewBuffer(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+env.setup.Token)
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
var result handlers.ConflictResolveResponse
|
||||||
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.True(t, result.ConflictResolved)
|
||||||
|
assert.Equal(t, map[string]bool{"progress": true, "annotations": false}, result.AppliedTo)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConflictResolve_WithManualData(t *testing.T) {
|
||||||
|
env := setupConflictTest(t)
|
||||||
|
client := &http.Client{}
|
||||||
|
|
||||||
|
conflict := createTestConflict(t, env, makeConflictData(0.45, 0.42))
|
||||||
|
conflictID := uuid.UUID(conflict.ID.Bytes).String()
|
||||||
|
|
||||||
|
resolveReq := map[string]interface{}{
|
||||||
|
"winner": "manual",
|
||||||
|
"manual_data": map[string]interface{}{
|
||||||
"percentage": 0.43,
|
"percentage": 0.43,
|
||||||
"epubcfi": "epubcfi(/6/4/2:20)",
|
"epubcfi": "epubcfi(/6/4/2:20)",
|
||||||
"chapter": 3,
|
"chapter": 3,
|
||||||
"page": 90,
|
},
|
||||||
}
|
"apply_to_all_future_conflicts": false,
|
||||||
|
"reason": "Custom merged position",
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(resolveReq)
|
||||||
|
|
||||||
reqBody := map[string]interface{}{
|
req, _ := http.NewRequest("POST", env.setup.Server.URL+"/api/conflicts/"+conflictID+"/resolve", bytes.NewBuffer(body))
|
||||||
"winner": "manual",
|
req.Header.Set("Content-Type", "application/json")
|
||||||
"manual_data": manualData,
|
req.Header.Set("Authorization", "Bearer "+env.setup.Token)
|
||||||
"apply_to_all_future_conflicts": false,
|
|
||||||
"reason": "Custom merged position",
|
|
||||||
}
|
|
||||||
|
|
||||||
body, err := json.Marshal(reqBody)
|
resp, err := client.Do(req)
|
||||||
assert.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
req := httptest.NewRequest("POST", "/api/conflicts/"+conflictID.String()+"/resolve", bytes.NewReader(body))
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
var result handlers.ConflictResolveResponse
|
||||||
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.True(t, result.ConflictResolved)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConflictResolve_ManualWithoutData(t *testing.T) {
|
||||||
|
env := setupConflictTest(t)
|
||||||
|
client := &http.Client{}
|
||||||
|
|
||||||
|
conflict := createTestConflict(t, env, makeConflictData(0.45, 0.42))
|
||||||
|
conflictID := uuid.UUID(conflict.ID.Bytes).String()
|
||||||
|
|
||||||
|
resolveReq := map[string]interface{}{
|
||||||
|
"winner": "manual",
|
||||||
|
"manual_data": nil,
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(resolveReq)
|
||||||
|
|
||||||
|
req, _ := http.NewRequest("POST", env.setup.Server.URL+"/api/conflicts/"+conflictID+"/resolve", bytes.NewBuffer(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+env.setup.Token)
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConflictResolve_AlreadyResolved(t *testing.T) {
|
||||||
|
env := setupConflictTest(t)
|
||||||
|
client := &http.Client{}
|
||||||
|
|
||||||
|
conflict := createTestConflict(t, env, makeConflictData(0.50, 0.30))
|
||||||
|
conflictID := uuid.UUID(conflict.ID.Bytes).String()
|
||||||
|
|
||||||
|
resolveReq := map[string]interface{}{
|
||||||
|
"winner": "koreader",
|
||||||
|
"reason": "First resolution",
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(resolveReq)
|
||||||
|
|
||||||
|
req, _ := http.NewRequest("POST", env.setup.Server.URL+"/api/conflicts/"+conflictID+"/resolve", bytes.NewBuffer(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+env.setup.Token)
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
req2, _ := http.NewRequest("POST", env.setup.Server.URL+"/api/conflicts/"+conflictID+"/resolve", bytes.NewBuffer(body))
|
||||||
|
req2.Header.Set("Content-Type", "application/json")
|
||||||
|
req2.Header.Set("Authorization", "Bearer "+env.setup.Token)
|
||||||
|
|
||||||
|
resp2, err := client.Do(req2)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp2.Body)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusBadRequest, resp2.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConflictResolve_InvalidWinner(t *testing.T) {
|
||||||
|
env := setupConflictTest(t)
|
||||||
|
client := &http.Client{}
|
||||||
|
|
||||||
|
conflict := createTestConflict(t, env, makeConflictData(0.45, 0.42))
|
||||||
|
conflictID := uuid.UUID(conflict.ID.Bytes).String()
|
||||||
|
|
||||||
|
resolveReq := map[string]interface{}{
|
||||||
|
"winner": "nonexistent_source",
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(resolveReq)
|
||||||
|
|
||||||
|
req, _ := http.NewRequest("POST", env.setup.Server.URL+"/api/conflicts/"+conflictID+"/resolve", bytes.NewBuffer(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+env.setup.Token)
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConflictResolve_NotFound(t *testing.T) {
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
client := &http.Client{}
|
||||||
|
|
||||||
|
resolveReq := map[string]interface{}{
|
||||||
|
"winner": "koreader",
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(resolveReq)
|
||||||
|
|
||||||
|
req, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/"+uuid.New().String()+"/resolve", bytes.NewBuffer(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConflictDelete(t *testing.T) {
|
||||||
|
env := setupConflictTest(t)
|
||||||
|
client := &http.Client{}
|
||||||
|
|
||||||
|
conflict := createTestConflict(t, env, makeConflictData(0.45, 0.42))
|
||||||
|
conflictID := uuid.UUID(conflict.ID.Bytes).String()
|
||||||
|
|
||||||
|
req, _ := http.NewRequest("DELETE", env.setup.Server.URL+"/api/conflicts/"+conflictID, nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+env.setup.Token)
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusNoContent, resp.StatusCode)
|
||||||
|
|
||||||
|
req2, _ := http.NewRequest("GET", env.setup.Server.URL+"/api/conflicts/"+conflictID, nil)
|
||||||
|
req2.Header.Set("Authorization", "Bearer "+env.setup.Token)
|
||||||
|
|
||||||
|
resp2, err := client.Do(req2)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp2.Body)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusNotFound, resp2.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConflictDelete_NotFound(t *testing.T) {
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
client := &http.Client{}
|
||||||
|
|
||||||
|
req, _ := http.NewRequest("DELETE", setup.Server.URL+"/api/conflicts/"+uuid.New().String(), nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConflictDismissAllResolved(t *testing.T) {
|
||||||
|
env := setupConflictTest(t)
|
||||||
|
client := &http.Client{}
|
||||||
|
|
||||||
|
conflict := createTestConflict(t, env, makeConflictData(0.50, 0.30))
|
||||||
|
conflictID := uuid.UUID(conflict.ID.Bytes).String()
|
||||||
|
|
||||||
|
resolveReq := map[string]interface{}{
|
||||||
|
"winner": "koreader",
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(resolveReq)
|
||||||
|
|
||||||
|
req, _ := http.NewRequest("POST", env.setup.Server.URL+"/api/conflicts/"+conflictID+"/resolve", bytes.NewBuffer(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+env.setup.Token)
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
req2, _ := http.NewRequest("POST", env.setup.Server.URL+"/api/conflicts/dismiss-all", nil)
|
||||||
|
req2.Header.Set("Authorization", "Bearer "+env.setup.Token)
|
||||||
|
|
||||||
|
resp2, err := client.Do(req2)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp2.Body)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
var result map[string]interface{}
|
||||||
|
err = json.NewDecoder(resp2.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
deleted, ok := result["deleted"].(float64)
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.GreaterOrEqual(t, int(deleted), 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConflictEndpoints_RequireAuth(t *testing.T) {
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
client := &http.Client{}
|
||||||
|
|
||||||
|
t.Run("list conflicts requires auth", func(t *testing.T) {
|
||||||
|
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/conflicts", nil)
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("get conflict requires auth", func(t *testing.T) {
|
||||||
|
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/conflicts/"+uuid.New().String(), nil)
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("resolve conflict requires auth", func(t *testing.T) {
|
||||||
|
req, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/"+uuid.New().String()+"/resolve", bytes.NewBuffer([]byte(`{}`)))
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp, err := client.Do(req)
|
||||||
assert.Contains(t, string(body), "manual")
|
require.NoError(t, err)
|
||||||
assert.Contains(t, string(body), "0.43")
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("error when winner is manual but no manual_data provided", func(t *testing.T) {
|
t.Run("delete conflict requires auth", func(t *testing.T) {
|
||||||
conflictID := uuid.New()
|
req, _ := http.NewRequest("DELETE", setup.Server.URL+"/api/conflicts/"+uuid.New().String(), nil)
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||||
|
})
|
||||||
|
|
||||||
reqBody := map[string]interface{}{
|
t.Run("dismiss-all requires auth", func(t *testing.T) {
|
||||||
"winner": "manual",
|
req, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/dismiss-all", nil)
|
||||||
"manual_data": nil,
|
resp, err := client.Do(req)
|
||||||
"apply_to_all_future_conflicts": false,
|
require.NoError(t, err)
|
||||||
"reason": "Test",
|
defer func(Body io.ReadCloser) {
|
||||||
}
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
body, err := json.Marshal(reqBody)
|
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
req := httptest.NewRequest("POST", "/api/conflicts/"+conflictID.String()+"/resolve", bytes.NewReader(body))
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
|
|
||||||
assert.Contains(t, string(body), "manual")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConflictListing_Filtering(t *testing.T) {
|
|
||||||
t.Run("list only unresolved conflicts", func(t *testing.T) {
|
|
||||||
req := httptest.NewRequest("GET", "/api/conflicts?status=unresolved", nil)
|
|
||||||
assert.Equal(t, "GET", req.Method)
|
|
||||||
assert.Contains(t, req.URL.Query().Get("status"), "unresolved")
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("list all conflicts regardless of status", func(t *testing.T) {
|
|
||||||
req := httptest.NewRequest("GET", "/api/conflicts?status=all", nil)
|
|
||||||
assert.Equal(t, "GET", req.Method)
|
|
||||||
assert.Contains(t, req.URL.Query().Get("status"), "all")
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("list only resolved conflicts", func(t *testing.T) {
|
|
||||||
req := httptest.NewRequest("GET", "/api/conflicts?status=user_resolved", nil)
|
|
||||||
assert.Equal(t, "GET", req.Method)
|
|
||||||
assert.Contains(t, req.URL.Query().Get("status"), "user_resolved")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConflictResponse_Structure(t *testing.T) {
|
|
||||||
t.Run("conflict detail response includes all required fields", func(t *testing.T) {
|
|
||||||
conflictResponse := map[string]interface{}{
|
|
||||||
"id": "conflict-uuid-123",
|
|
||||||
"media_item_id": "book-uuid-456",
|
|
||||||
"media_item_title": "Test Book Title",
|
|
||||||
"conflict_type": "progress",
|
|
||||||
"resolution_status": "unresolved",
|
|
||||||
"created_at": "2026-01-30T20:10:00Z",
|
|
||||||
"conflict_data": map[string]interface{}{
|
|
||||||
"koreader": map[string]interface{}{
|
|
||||||
"source": "koreader",
|
|
||||||
"data": map[string]interface{}{
|
|
||||||
"percentage": 0.45,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"kobo": map[string]interface{}{
|
|
||||||
"source": "kobo",
|
|
||||||
"data": map[string]interface{}{
|
|
||||||
"percentage": 0.42,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
body, err := json.Marshal(conflictResponse)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
var parsed map[string]interface{}
|
|
||||||
err = json.Unmarshal(body, &parsed)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
assert.Contains(t, parsed, "id")
|
|
||||||
assert.Contains(t, parsed, "media_item_id")
|
|
||||||
assert.Contains(t, parsed, "conflict_data")
|
|
||||||
assert.Contains(t, parsed["conflict_data"].(map[string]interface{}), "koreader")
|
|
||||||
assert.Contains(t, parsed["conflict_data"].(map[string]interface{}), "kobo")
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("conflict list response includes summary counts", func(t *testing.T) {
|
|
||||||
listResponse := map[string]interface{}{
|
|
||||||
"conflicts": []interface{}{
|
|
||||||
map[string]string{"id": "conflict-1", "resolution_status": "unresolved"},
|
|
||||||
map[string]string{"id": "conflict-2", "resolution_status": "unresolved"},
|
|
||||||
},
|
|
||||||
"total": 2,
|
|
||||||
"unresolved": 2,
|
|
||||||
}
|
|
||||||
|
|
||||||
body, err := json.Marshal(listResponse)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
var parsed map[string]interface{}
|
|
||||||
err = json.Unmarshal(body, &parsed)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
assert.Equal(t, float64(2), parsed["total"])
|
|
||||||
assert.Equal(t, float64(2), parsed["unresolved"])
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConflictDeletion(t *testing.T) {
|
|
||||||
t.Run("delete single conflict by ID", func(t *testing.T) {
|
|
||||||
conflictID := uuid.New()
|
|
||||||
|
|
||||||
req := httptest.NewRequest("DELETE", "/api/conflicts/"+conflictID.String(), nil)
|
|
||||||
assert.Equal(t, "DELETE", req.Method)
|
|
||||||
assert.Contains(t, req.URL.Path, conflictID.String())
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("dismiss all resolved conflicts", func(t *testing.T) {
|
|
||||||
req := httptest.NewRequest("POST", "/api/conflicts/dismiss-all", nil)
|
|
||||||
assert.Equal(t, "POST", req.Method)
|
|
||||||
assert.Contains(t, req.URL.Path, "dismiss-all")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConflictNotification_WebSocketBroadcast(t *testing.T) {
|
|
||||||
t.Run("conflict detection notification", func(t *testing.T) {
|
|
||||||
notification := map[string]interface{}{
|
|
||||||
"type": "conflict",
|
|
||||||
"timestamp": "2026-01-30T20:10:00Z",
|
|
||||||
"data": map[string]interface{}{
|
|
||||||
"book_id": "book-uuid-123",
|
|
||||||
"notification_type": "detection",
|
|
||||||
"conflict_id": "",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
body, err := json.Marshal(notification)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
var parsed map[string]interface{}
|
|
||||||
err = json.Unmarshal(body, &parsed)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
data := parsed["data"].(map[string]interface{})
|
|
||||||
assert.Equal(t, "detection", data["notification_type"])
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("conflict resolved notification", func(t *testing.T) {
|
|
||||||
conflictID := uuid.New()
|
|
||||||
|
|
||||||
notification := map[string]interface{}{
|
|
||||||
"type": "conflict",
|
|
||||||
"timestamp": "2026-01-30T20:15:00Z",
|
|
||||||
"data": map[string]interface{}{
|
|
||||||
"book_id": "book-uuid-123",
|
|
||||||
"notification_type": "resolved",
|
|
||||||
"conflict_id": conflictID.String(),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
body, err := json.Marshal(notification)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
var parsed map[string]interface{}
|
|
||||||
err = json.Unmarshal(body, &parsed)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
data := parsed["data"].(map[string]interface{})
|
|
||||||
assert.Equal(t, "resolved", data["notification_type"])
|
|
||||||
assert.Equal(t, conflictID.String(), data["conflict_id"])
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bookhoard/internal/handlers"
|
"bookhoard/internal/handlers"
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -12,414 +13,506 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestConflictsBulkOperations tests bulk conflict resolution operations
|
func TestBulkResolve_MostRecentStrategy(t *testing.T) {
|
||||||
func TestConflictsBulkOperations(t *testing.T) {
|
env := setupConflictTest(t)
|
||||||
|
client := &http.Client{}
|
||||||
|
|
||||||
|
conflict1 := createTestConflict(t, env, makeConflictData(0.45, 0.42))
|
||||||
|
conflict2 := createTestConflict(t, env, makeConflictData(0.60, 0.55))
|
||||||
|
|
||||||
|
id1 := uuid.UUID(conflict1.ID.Bytes).String()
|
||||||
|
id2 := uuid.UUID(conflict2.ID.Bytes).String()
|
||||||
|
|
||||||
|
req := handlers.BulkResolveRequest{
|
||||||
|
ConflictIDs: []string{id1, id2},
|
||||||
|
Strategy: "most_recent",
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(req)
|
||||||
|
|
||||||
|
httpReq, _ := http.NewRequest("POST", env.setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body))
|
||||||
|
httpReq.Header.Set("Content-Type", "application/json")
|
||||||
|
httpReq.Header.Set("Authorization", "Bearer "+env.setup.Token)
|
||||||
|
|
||||||
|
resp, err := client.Do(httpReq)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
var result handlers.BulkResolveResponse
|
||||||
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, 2, result.Total)
|
||||||
|
assert.Equal(t, 2, result.Success)
|
||||||
|
assert.Equal(t, 0, result.Failed)
|
||||||
|
require.Len(t, result.Results, 2)
|
||||||
|
|
||||||
|
for _, r := range result.Results {
|
||||||
|
assert.Equal(t, "success", r.Status)
|
||||||
|
assert.Equal(t, "koreader", r.Winner)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBulkResolve_HighestProgressStrategy(t *testing.T) {
|
||||||
|
env := setupConflictTest(t)
|
||||||
|
client := &http.Client{}
|
||||||
|
|
||||||
|
conflictDataHighKobo := makeConflictData(0.30, 0.90)
|
||||||
|
|
||||||
|
conflict := createTestConflict(t, env, conflictDataHighKobo)
|
||||||
|
conflictID := uuid.UUID(conflict.ID.Bytes).String()
|
||||||
|
|
||||||
|
req := handlers.BulkResolveRequest{
|
||||||
|
ConflictIDs: []string{conflictID},
|
||||||
|
Strategy: "highest_progress",
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(req)
|
||||||
|
|
||||||
|
httpReq, _ := http.NewRequest("POST", env.setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body))
|
||||||
|
httpReq.Header.Set("Content-Type", "application/json")
|
||||||
|
httpReq.Header.Set("Authorization", "Bearer "+env.setup.Token)
|
||||||
|
|
||||||
|
resp, err := client.Do(httpReq)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
var result handlers.BulkResolveResponse
|
||||||
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, 1, result.Total)
|
||||||
|
assert.Equal(t, 1, result.Success)
|
||||||
|
assert.Equal(t, 0, result.Failed)
|
||||||
|
assert.Equal(t, "kobo", result.Results[0].Winner)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBulkResolve_ManualStrategy(t *testing.T) {
|
||||||
|
env := setupConflictTest(t)
|
||||||
|
client := &http.Client{}
|
||||||
|
|
||||||
|
conflict := createTestConflict(t, env, makeConflictData(0.45, 0.42))
|
||||||
|
conflictID := uuid.UUID(conflict.ID.Bytes).String()
|
||||||
|
|
||||||
|
req := handlers.BulkResolveRequest{
|
||||||
|
ConflictIDs: []string{conflictID},
|
||||||
|
Strategy: "manual",
|
||||||
|
WinningSource: "koreader",
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(req)
|
||||||
|
|
||||||
|
httpReq, _ := http.NewRequest("POST", env.setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body))
|
||||||
|
httpReq.Header.Set("Content-Type", "application/json")
|
||||||
|
httpReq.Header.Set("Authorization", "Bearer "+env.setup.Token)
|
||||||
|
|
||||||
|
resp, err := client.Do(httpReq)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
var result handlers.BulkResolveResponse
|
||||||
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, 1, result.Total)
|
||||||
|
assert.Equal(t, 1, result.Success)
|
||||||
|
assert.Equal(t, "koreader", result.Results[0].Winner)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBulkResolve_ManualStrategy_WithoutWinner(t *testing.T) {
|
||||||
|
env := setupConflictTest(t)
|
||||||
|
client := &http.Client{}
|
||||||
|
|
||||||
|
conflict := createTestConflict(t, env, makeConflictData(0.45, 0.42))
|
||||||
|
conflictID := uuid.UUID(conflict.ID.Bytes).String()
|
||||||
|
|
||||||
|
req := handlers.BulkResolveRequest{
|
||||||
|
ConflictIDs: []string{conflictID},
|
||||||
|
Strategy: "manual",
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(req)
|
||||||
|
|
||||||
|
httpReq, _ := http.NewRequest("POST", env.setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body))
|
||||||
|
httpReq.Header.Set("Content-Type", "application/json")
|
||||||
|
httpReq.Header.Set("Authorization", "Bearer "+env.setup.Token)
|
||||||
|
|
||||||
|
resp, err := client.Do(httpReq)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
var result handlers.BulkResolveResponse
|
||||||
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, 1, result.Failed)
|
||||||
|
assert.Equal(t, "error", result.Results[0].Status)
|
||||||
|
assert.Contains(t, result.Results[0].Error, "winning_source")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBulkResolve_ManualStrategy_InvalidWinner(t *testing.T) {
|
||||||
|
env := setupConflictTest(t)
|
||||||
|
client := &http.Client{}
|
||||||
|
|
||||||
|
conflict := createTestConflict(t, env, makeConflictData(0.45, 0.42))
|
||||||
|
conflictID := uuid.UUID(conflict.ID.Bytes).String()
|
||||||
|
|
||||||
|
req := handlers.BulkResolveRequest{
|
||||||
|
ConflictIDs: []string{conflictID},
|
||||||
|
Strategy: "manual",
|
||||||
|
WinningSource: "nonexistent_device",
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(req)
|
||||||
|
|
||||||
|
httpReq, _ := http.NewRequest("POST", env.setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body))
|
||||||
|
httpReq.Header.Set("Content-Type", "application/json")
|
||||||
|
httpReq.Header.Set("Authorization", "Bearer "+env.setup.Token)
|
||||||
|
|
||||||
|
resp, err := client.Do(httpReq)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
var result handlers.BulkResolveResponse
|
||||||
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, 1, result.Failed)
|
||||||
|
assert.Contains(t, result.Results[0].Error, "invalid winning source")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBulkResolve_ConflictNotFound(t *testing.T) {
|
||||||
setup := setupTestServer(t)
|
setup := setupTestServer(t)
|
||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
|
|
||||||
t.Run("BulkResolveConflicts_WithoutAuth", func(t *testing.T) {
|
req := handlers.BulkResolveRequest{
|
||||||
req := handlers.BulkResolveRequest{
|
ConflictIDs: []string{uuid.New().String()},
|
||||||
ConflictIDs: []string{uuid.New().String()},
|
Strategy: "most_recent",
|
||||||
Strategy: "most_recent",
|
}
|
||||||
}
|
body, _ := json.Marshal(req)
|
||||||
body, _ := json.Marshal(req)
|
|
||||||
|
|
||||||
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body))
|
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body))
|
||||||
httpReq.Header.Set("Content-Type", "application/json")
|
httpReq.Header.Set("Content-Type", "application/json")
|
||||||
|
httpReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
|
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("BulkResolveConflicts_EmptyConflictIDs", func(t *testing.T) {
|
var result handlers.BulkResolveResponse
|
||||||
req := handlers.BulkResolveRequest{
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
ConflictIDs: []string{},
|
require.NoError(t, err)
|
||||||
Strategy: "most_recent",
|
|
||||||
}
|
|
||||||
body, _ := json.Marshal(req)
|
|
||||||
|
|
||||||
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body))
|
assert.Equal(t, 1, result.Total)
|
||||||
httpReq.Header.Set("Content-Type", "application/json")
|
assert.Equal(t, 0, result.Success)
|
||||||
httpReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
assert.Equal(t, 1, result.Failed)
|
||||||
|
assert.Contains(t, result.Results[0].Error, "conflict not found")
|
||||||
resp, err := client.Do(httpReq)
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("BulkResolveConflicts_InvalidConflictID", func(t *testing.T) {
|
|
||||||
req := handlers.BulkResolveRequest{
|
|
||||||
ConflictIDs: []string{"invalid-uuid"},
|
|
||||||
Strategy: "most_recent",
|
|
||||||
}
|
|
||||||
body, _ := json.Marshal(req)
|
|
||||||
|
|
||||||
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body))
|
|
||||||
httpReq.Header.Set("Content-Type", "application/json")
|
|
||||||
httpReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
|
||||||
|
|
||||||
resp, err := client.Do(httpReq)
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
||||||
|
|
||||||
var result handlers.BulkResolveResponse
|
|
||||||
json.NewDecoder(resp.Body).Decode(&result)
|
|
||||||
|
|
||||||
assert.NotEmpty(t, result.Results, "Should have results")
|
|
||||||
assert.Equal(t, 1, result.Total)
|
|
||||||
assert.Equal(t, 0, result.Success)
|
|
||||||
assert.Greater(t, result.Failed, 0)
|
|
||||||
|
|
||||||
firstResult := result.Results[0]
|
|
||||||
assert.Equal(t, "error", firstResult.Status)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("BulkResolveConflicts_InvalidStrategy", func(t *testing.T) {
|
|
||||||
req := handlers.BulkResolveRequest{
|
|
||||||
ConflictIDs: []string{uuid.New().String()},
|
|
||||||
Strategy: "invalid_strategy",
|
|
||||||
}
|
|
||||||
body, _ := json.Marshal(req)
|
|
||||||
|
|
||||||
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body))
|
|
||||||
httpReq.Header.Set("Content-Type", "application/json")
|
|
||||||
httpReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
|
||||||
|
|
||||||
resp, err := client.Do(httpReq)
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
// Bulk operations return 200 OK with individual error results
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
||||||
|
|
||||||
var result handlers.BulkResolveResponse
|
|
||||||
json.NewDecoder(resp.Body).Decode(&result)
|
|
||||||
|
|
||||||
assert.NotEmpty(t, result.Results, "Should have results")
|
|
||||||
assert.Greater(t, result.Total, 0)
|
|
||||||
assert.Greater(t, result.Failed, 0)
|
|
||||||
|
|
||||||
firstResult := result.Results[0]
|
|
||||||
assert.Equal(t, "error", firstResult.Status)
|
|
||||||
// The error will be "conflict not found" since we're using a random UUID
|
|
||||||
// The invalid strategy would be caught for valid conflict IDs
|
|
||||||
assert.Contains(t, firstResult.Error, "conflict")
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("BulkResolveConflicts_MostRecentStrategy", func(t *testing.T) {
|
|
||||||
req := handlers.BulkResolveRequest{
|
|
||||||
ConflictIDs: []string{uuid.New().String(), uuid.New().String()},
|
|
||||||
Strategy: "most_recent",
|
|
||||||
}
|
|
||||||
body, _ := json.Marshal(req)
|
|
||||||
|
|
||||||
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body))
|
|
||||||
httpReq.Header.Set("Content-Type", "application/json")
|
|
||||||
httpReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
|
||||||
|
|
||||||
resp, err := client.Do(httpReq)
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
||||||
|
|
||||||
var result handlers.BulkResolveResponse
|
|
||||||
json.NewDecoder(resp.Body).Decode(&result)
|
|
||||||
|
|
||||||
assert.NotEmpty(t, result.Results, "Should have results")
|
|
||||||
assert.Equal(t, 2, result.Total)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("BulkResolveConflicts_HighestProgressStrategy", func(t *testing.T) {
|
|
||||||
req := handlers.BulkResolveRequest{
|
|
||||||
ConflictIDs: []string{uuid.New().String(), uuid.New().String()},
|
|
||||||
Strategy: "highest_progress",
|
|
||||||
}
|
|
||||||
body, _ := json.Marshal(req)
|
|
||||||
|
|
||||||
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body))
|
|
||||||
httpReq.Header.Set("Content-Type", "application/json")
|
|
||||||
httpReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
|
||||||
|
|
||||||
resp, err := client.Do(httpReq)
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
||||||
|
|
||||||
var result handlers.BulkResolveResponse
|
|
||||||
json.NewDecoder(resp.Body).Decode(&result)
|
|
||||||
|
|
||||||
assert.NotEmpty(t, result.Results, "Should have results")
|
|
||||||
assert.Equal(t, 2, result.Total)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("BulkResolveConflicts_ManualStrategy_WithoutWinner", func(t *testing.T) {
|
|
||||||
req := handlers.BulkResolveRequest{
|
|
||||||
ConflictIDs: []string{uuid.New().String()},
|
|
||||||
Strategy: "manual",
|
|
||||||
}
|
|
||||||
body, _ := json.Marshal(req)
|
|
||||||
|
|
||||||
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body))
|
|
||||||
httpReq.Header.Set("Content-Type", "application/json")
|
|
||||||
httpReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
|
||||||
|
|
||||||
resp, err := client.Do(httpReq)
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
||||||
|
|
||||||
var result handlers.BulkResolveResponse
|
|
||||||
json.NewDecoder(resp.Body).Decode(&result)
|
|
||||||
|
|
||||||
assert.NotEmpty(t, result.Results, "Should have results")
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("BulkResolveConflicts_ManualStrategy_WithWinner", func(t *testing.T) {
|
|
||||||
req := handlers.BulkResolveRequest{
|
|
||||||
ConflictIDs: []string{uuid.New().String()},
|
|
||||||
Strategy: "manual",
|
|
||||||
WinningSource: "device",
|
|
||||||
}
|
|
||||||
body, _ := json.Marshal(req)
|
|
||||||
|
|
||||||
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body))
|
|
||||||
httpReq.Header.Set("Content-Type", "application/json")
|
|
||||||
httpReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
|
||||||
|
|
||||||
resp, err := client.Do(httpReq)
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("BulkResolveConflicts_InvalidRequestBody", func(t *testing.T) {
|
|
||||||
// Send invalid JSON
|
|
||||||
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer([]byte("invalid json")))
|
|
||||||
httpReq.Header.Set("Content-Type", "application/json")
|
|
||||||
httpReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
|
||||||
|
|
||||||
resp, err := client.Do(httpReq)
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestConflictsBulkDismiss tests bulk dismiss operations
|
func TestBulkResolve_EmptyConflictIDs(t *testing.T) {
|
||||||
func TestConflictsBulkDismiss(t *testing.T) {
|
|
||||||
setup := setupTestServer(t)
|
setup := setupTestServer(t)
|
||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
|
|
||||||
t.Run("BulkDismissConflicts_WithoutAuth", func(t *testing.T) {
|
req := handlers.BulkResolveRequest{
|
||||||
req := map[string]interface{}{
|
ConflictIDs: []string{},
|
||||||
"conflict_ids": []string{uuid.New().String()},
|
Strategy: "most_recent",
|
||||||
}
|
}
|
||||||
body, _ := json.Marshal(req)
|
body, _ := json.Marshal(req)
|
||||||
|
|
||||||
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-dismiss", bytes.NewBuffer(body))
|
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body))
|
||||||
httpReq.Header.Set("Content-Type", "application/json")
|
httpReq.Header.Set("Content-Type", "application/json")
|
||||||
|
httpReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
|
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("BulkDismissConflicts_EmptyConflictIDs", func(t *testing.T) {
|
|
||||||
req := map[string]interface{}{
|
|
||||||
"conflict_ids": []string{},
|
|
||||||
}
|
|
||||||
body, _ := json.Marshal(req)
|
|
||||||
|
|
||||||
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-dismiss", bytes.NewBuffer(body))
|
|
||||||
httpReq.Header.Set("Content-Type", "application/json")
|
|
||||||
httpReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
|
||||||
|
|
||||||
resp, err := client.Do(httpReq)
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("BulkDismissConflicts_InvalidConflictID", func(t *testing.T) {
|
|
||||||
req := map[string]interface{}{
|
|
||||||
"conflict_ids": []string{"invalid-uuid", uuid.New().String()},
|
|
||||||
}
|
|
||||||
body, _ := json.Marshal(req)
|
|
||||||
|
|
||||||
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-dismiss", bytes.NewBuffer(body))
|
|
||||||
httpReq.Header.Set("Content-Type", "application/json")
|
|
||||||
httpReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
|
||||||
|
|
||||||
resp, err := client.Do(httpReq)
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
||||||
|
|
||||||
var result map[string]interface{}
|
|
||||||
json.NewDecoder(resp.Body).Decode(&result)
|
|
||||||
|
|
||||||
assert.Contains(t, result, "results")
|
|
||||||
assert.Contains(t, result, "total")
|
|
||||||
assert.Contains(t, result, "success")
|
|
||||||
assert.Contains(t, result, "failed")
|
|
||||||
|
|
||||||
results := result["results"].([]interface{})
|
|
||||||
firstResult := results[0].(map[string]interface{})
|
|
||||||
assert.Equal(t, "error", firstResult["status"])
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("BulkDismissConflicts_MultipleConflicts", func(t *testing.T) {
|
|
||||||
req := map[string]interface{}{
|
|
||||||
"conflict_ids": []string{
|
|
||||||
uuid.New().String(),
|
|
||||||
uuid.New().String(),
|
|
||||||
uuid.New().String(),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
body, _ := json.Marshal(req)
|
|
||||||
|
|
||||||
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-dismiss", bytes.NewBuffer(body))
|
|
||||||
httpReq.Header.Set("Content-Type", "application/json")
|
|
||||||
httpReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
|
||||||
|
|
||||||
resp, err := client.Do(httpReq)
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
||||||
|
|
||||||
var result map[string]interface{}
|
|
||||||
json.NewDecoder(resp.Body).Decode(&result)
|
|
||||||
|
|
||||||
assert.Contains(t, result, "results")
|
|
||||||
assert.Equal(t, float64(3), result["total"])
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("BulkDismissConflicts_InvalidRequestBody", func(t *testing.T) {
|
|
||||||
// Send invalid JSON
|
|
||||||
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-dismiss", bytes.NewBuffer([]byte("invalid json")))
|
|
||||||
httpReq.Header.Set("Content-Type", "application/json")
|
|
||||||
httpReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
|
||||||
|
|
||||||
resp, err := client.Do(httpReq)
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestConflictsBulkEscalate tests bulk escalate operations
|
func TestBulkResolve_InvalidConflictID(t *testing.T) {
|
||||||
// NOTE: This test is commented out because the /api/conflicts/bulk-escalate endpoint
|
|
||||||
// does not exist yet. It was planned in TEST_RELIABILITY_PLAN.md but never implemented.
|
|
||||||
// Uncomment and update when the endpoint is added.
|
|
||||||
/*
|
|
||||||
func TestConflictsBulkEscalate(t *testing.T) {
|
|
||||||
setup := setupTestServer(t)
|
setup := setupTestServer(t)
|
||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
|
|
||||||
t.Run("BulkEscalateConflicts_WithoutAuth", func(t *testing.T) {
|
req := handlers.BulkResolveRequest{
|
||||||
req := map[string]interface{}{
|
ConflictIDs: []string{"not-a-uuid"},
|
||||||
"conflict_ids": []string{uuid.New().String()},
|
Strategy: "most_recent",
|
||||||
}
|
}
|
||||||
body, _ := json.Marshal(req)
|
body, _ := json.Marshal(req)
|
||||||
|
|
||||||
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-escalate", bytes.NewBuffer(body))
|
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body))
|
||||||
httpReq.Header.Set("Content-Type", "application/json")
|
httpReq.Header.Set("Content-Type", "application/json")
|
||||||
|
httpReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
|
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("BulkEscalateConflicts_EmptyConflictIDs", func(t *testing.T) {
|
var result handlers.BulkResolveResponse
|
||||||
req := map[string]interface{}{
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
"conflict_ids": []string{},
|
require.NoError(t, err)
|
||||||
}
|
|
||||||
body, _ := json.Marshal(req)
|
|
||||||
|
|
||||||
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-escalate", bytes.NewBuffer(body))
|
assert.Equal(t, 1, result.Failed)
|
||||||
httpReq.Header.Set("Content-Type", "application/json")
|
assert.Contains(t, result.Results[0].Error, "invalid conflict ID")
|
||||||
httpReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
}
|
||||||
|
|
||||||
resp, err := client.Do(httpReq)
|
func TestBulkResolve_InvalidRequestBody(t *testing.T) {
|
||||||
require.NoError(t, err)
|
setup := setupTestServer(t)
|
||||||
defer resp.Body.Close()
|
client := &http.Client{}
|
||||||
|
|
||||||
// Allow queue processor to process the item before querying for conflicts
|
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer([]byte("invalid json")))
|
||||||
time.Sleep(3 * time.Second)
|
httpReq.Header.Set("Content-Type", "application/json")
|
||||||
|
httpReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
||||||
|
resp, err := client.Do(httpReq)
|
||||||
var result map[string]interface{}
|
require.NoError(t, err)
|
||||||
json.NewDecoder(resp.Body).Decode(&result)
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
assert.Contains(t, result, "results")
|
}(resp.Body)
|
||||||
assert.Contains(t, result, "total")
|
|
||||||
assert.Contains(t, result, "failed")
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
|
}
|
||||||
results := result["results"].([]interface{})
|
|
||||||
firstResult := results[0].(map[string]interface{})
|
func TestBulkResolve_RequiresAuth(t *testing.T) {
|
||||||
assert.Equal(t, "error", firstResult["status"])
|
setup := setupTestServer(t)
|
||||||
})
|
client := &http.Client{}
|
||||||
|
|
||||||
t.Run("BulkEscalateConflicts_MultipleConflicts", func(t *testing.T) {
|
req := handlers.BulkResolveRequest{
|
||||||
conflictIDs := []string{
|
ConflictIDs: []string{uuid.New().String()},
|
||||||
uuid.New().String(),
|
Strategy: "most_recent",
|
||||||
uuid.New().String(),
|
}
|
||||||
}
|
body, _ := json.Marshal(req)
|
||||||
req := map[string]interface{}{
|
|
||||||
"conflict_ids": conflictIDs,
|
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body))
|
||||||
}
|
httpReq.Header.Set("Content-Type", "application/json")
|
||||||
body, _ := json.Marshal(req)
|
|
||||||
|
resp, err := client.Do(httpReq)
|
||||||
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-escalate", bytes.NewBuffer(body))
|
require.NoError(t, err)
|
||||||
httpReq.Header.Set("Content-Type", "application/json")
|
defer func(Body io.ReadCloser) {
|
||||||
httpReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
resp, err := client.Do(httpReq)
|
|
||||||
require.NoError(t, err)
|
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||||
defer resp.Body.Close()
|
}
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
func TestBulkDismiss_RealConflicts(t *testing.T) {
|
||||||
|
env := setupConflictTest(t)
|
||||||
var result map[string]interface{}
|
client := &http.Client{}
|
||||||
json.NewDecoder(resp.Body).Decode(&result)
|
|
||||||
|
conflict1 := createTestConflict(t, env, makeConflictData(0.45, 0.42))
|
||||||
assert.Contains(t, result, "results")
|
conflict2 := createTestConflict(t, env, makeConflictData(0.60, 0.55))
|
||||||
assert.Equal(t, float64(2), result["total"])
|
|
||||||
|
id1 := uuid.UUID(conflict1.ID.Bytes).String()
|
||||||
// NEW: Database verification - verify conflicts were escalated
|
id2 := uuid.UUID(conflict2.ID.Bytes).String()
|
||||||
for _, conflictID := range conflictIDs {
|
|
||||||
pgID, err := uuid.Parse(conflictID)
|
req := map[string]interface{}{
|
||||||
if err != nil {
|
"conflict_ids": []string{id1, id2},
|
||||||
continue // Skip invalid UUIDs
|
}
|
||||||
}
|
body, _ := json.Marshal(req)
|
||||||
|
|
||||||
conflict, err := setup.DB.GetSyncConflict(context.Background(), pgtype.UUID{Bytes: [16]byte(pgID), Valid: true})
|
httpReq, _ := http.NewRequest("POST", env.setup.Server.URL+"/api/conflicts/bulk-dismiss", bytes.NewBuffer(body))
|
||||||
if err == nil {
|
httpReq.Header.Set("Content-Type", "application/json")
|
||||||
// If conflict exists, verify it was escalated
|
httpReq.Header.Set("Authorization", "Bearer "+env.setup.Token)
|
||||||
assert.Equal(t, "escalated", conflict.ResolutionStatus.String, "Conflict should be escalated")
|
|
||||||
}
|
resp, err := client.Do(httpReq)
|
||||||
}
|
require.NoError(t, err)
|
||||||
})
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
var result map[string]interface{}
|
||||||
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, float64(2), result["total"])
|
||||||
|
assert.Equal(t, float64(2), result["success"])
|
||||||
|
assert.Equal(t, float64(0), result["failed"])
|
||||||
|
|
||||||
|
results := result["results"].([]interface{})
|
||||||
|
require.Len(t, results, 2)
|
||||||
|
for _, r := range results {
|
||||||
|
entry := r.(map[string]interface{})
|
||||||
|
assert.Equal(t, "success", entry["status"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBulkDismiss_NotFoundConflict(t *testing.T) {
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
client := &http.Client{}
|
||||||
|
|
||||||
|
req := map[string]interface{}{
|
||||||
|
"conflict_ids": []string{uuid.New().String()},
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(req)
|
||||||
|
|
||||||
|
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-dismiss", bytes.NewBuffer(body))
|
||||||
|
httpReq.Header.Set("Content-Type", "application/json")
|
||||||
|
httpReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
|
|
||||||
|
resp, err := client.Do(httpReq)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
var result map[string]interface{}
|
||||||
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, float64(1), result["total"])
|
||||||
|
assert.Equal(t, float64(0), result["success"])
|
||||||
|
assert.Equal(t, float64(1), result["failed"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBulkDismiss_InvalidConflictID(t *testing.T) {
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
client := &http.Client{}
|
||||||
|
|
||||||
|
req := map[string]interface{}{
|
||||||
|
"conflict_ids": []string{"invalid-uuid"},
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(req)
|
||||||
|
|
||||||
|
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-dismiss", bytes.NewBuffer(body))
|
||||||
|
httpReq.Header.Set("Content-Type", "application/json")
|
||||||
|
httpReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
|
|
||||||
|
resp, err := client.Do(httpReq)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
var result map[string]interface{}
|
||||||
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, float64(1), result["total"])
|
||||||
|
assert.Equal(t, float64(1), result["failed"])
|
||||||
|
assert.Contains(t, result["results"].([]interface{})[0].(map[string]interface{})["error"], "invalid conflict ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBulkDismiss_EmptyConflictIDs(t *testing.T) {
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
client := &http.Client{}
|
||||||
|
|
||||||
|
req := map[string]interface{}{
|
||||||
|
"conflict_ids": []string{},
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(req)
|
||||||
|
|
||||||
|
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-dismiss", bytes.NewBuffer(body))
|
||||||
|
httpReq.Header.Set("Content-Type", "application/json")
|
||||||
|
httpReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
|
|
||||||
|
resp, err := client.Do(httpReq)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBulkDismiss_InvalidRequestBody(t *testing.T) {
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
client := &http.Client{}
|
||||||
|
|
||||||
|
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-dismiss", bytes.NewBuffer([]byte("invalid json")))
|
||||||
|
httpReq.Header.Set("Content-Type", "application/json")
|
||||||
|
httpReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
|
|
||||||
|
resp, err := client.Do(httpReq)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBulkDismiss_RequiresAuth(t *testing.T) {
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
client := &http.Client{}
|
||||||
|
|
||||||
|
req := map[string]interface{}{
|
||||||
|
"conflict_ids": []string{uuid.New().String()},
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(req)
|
||||||
|
|
||||||
|
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-dismiss", bytes.NewBuffer(body))
|
||||||
|
httpReq.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := client.Do(httpReq)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBulkResolve_MixedSuccessAndFailure(t *testing.T) {
|
||||||
|
env := setupConflictTest(t)
|
||||||
|
client := &http.Client{}
|
||||||
|
|
||||||
|
conflict := createTestConflict(t, env, makeConflictData(0.45, 0.42))
|
||||||
|
realID := uuid.UUID(conflict.ID.Bytes).String()
|
||||||
|
|
||||||
|
req := handlers.BulkResolveRequest{
|
||||||
|
ConflictIDs: []string{realID, uuid.New().String()},
|
||||||
|
Strategy: "most_recent",
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(req)
|
||||||
|
|
||||||
|
httpReq, _ := http.NewRequest("POST", env.setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body))
|
||||||
|
httpReq.Header.Set("Content-Type", "application/json")
|
||||||
|
httpReq.Header.Set("Authorization", "Bearer "+env.setup.Token)
|
||||||
|
|
||||||
|
resp, err := client.Do(httpReq)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
var result handlers.BulkResolveResponse
|
||||||
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, 2, result.Total)
|
||||||
|
assert.Equal(t, 1, result.Success)
|
||||||
|
assert.Equal(t, 1, result.Failed)
|
||||||
}
|
}
|
||||||
*/
|
|
||||||
@@ -0,0 +1,639 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"bookhoard/internal/database"
|
||||||
|
"bookhoard/internal/services"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Cross-library SHA move detection + archived-twin merge integration tests.
|
||||||
|
// These cover the scenario where identical book content moves between
|
||||||
|
// libraries of the same type: the row must be repointed (history follows)
|
||||||
|
// instead of duplicated, deliberate copies stay independent, type rules are
|
||||||
|
// enforced, and archived twins can be merged into their active copy.
|
||||||
|
|
||||||
|
const (
|
||||||
|
moveTestAdminEmail = "testuser@tests.bookhoard.internal"
|
||||||
|
moveTestFileName = "Test Book.epub"
|
||||||
|
)
|
||||||
|
|
||||||
|
// createEbooksTestLibrary creates a library of the given type via the API and
|
||||||
|
// swaps its default folder for a fresh temp directory the test controls.
|
||||||
|
func createTypedTestLibrary(t *testing.T, setup *TestServerSetup, name, libType string) (pgtype.UUID, string) {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
libBody, _ := json.Marshal(map[string]interface{}{"name": name, "type": libType})
|
||||||
|
req, _ := http.NewRequest("POST", setup.Server.URL+"/api/libraries", bytes.NewBuffer(libBody))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
require.Equal(t, http.StatusCreated, resp.StatusCode, "library creation should succeed")
|
||||||
|
|
||||||
|
var libResp map[string]interface{}
|
||||||
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&libResp))
|
||||||
|
libID, err := uuid.Parse(libResp["id"].(string))
|
||||||
|
require.NoError(t, err)
|
||||||
|
pgLib := pgtype.UUID{Bytes: libID, Valid: true}
|
||||||
|
|
||||||
|
folders, err := setup.DB.GetLibraryFolders(ctx, pgLib)
|
||||||
|
require.NoError(t, err)
|
||||||
|
for _, f := range folders {
|
||||||
|
_, err = setup.DB.DeleteLibraryFolder(ctx, database.DeleteLibraryFolderParams{
|
||||||
|
LibraryID: pgLib,
|
||||||
|
FolderPath: f.FolderPath,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
_, err = setup.DB.AddLibraryFolder(ctx, database.AddLibraryFolderParams{
|
||||||
|
LibraryID: pgLib,
|
||||||
|
FolderPath: tmpDir,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
return pgLib, tmpDir
|
||||||
|
}
|
||||||
|
|
||||||
|
// deleteLibraries removes libraries (cascading to media items, progress,
|
||||||
|
// annotations, and processing issues) so reruns never see leftovers from a
|
||||||
|
// previous run — stale rows with identical content hashes would otherwise be
|
||||||
|
// picked up as move candidates. Call via defer AFTER the setup.Close defer so
|
||||||
|
// it executes while the pool is still open (defers run LIFO).
|
||||||
|
func deleteLibraries(t *testing.T, setup *TestServerSetup, libs ...pgtype.UUID) {
|
||||||
|
t.Helper()
|
||||||
|
for _, lib := range libs {
|
||||||
|
_ = setup.DB.DeleteLibrary(context.Background(), lib)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// scanTestFolder runs a synchronous scan over one folder.
|
||||||
|
func scanTestFolder(t *testing.T, setup *TestServerSetup, adminID pgtype.UUID, folder string) {
|
||||||
|
t.Helper()
|
||||||
|
scanner := services.NewMediaScanner(setup.DB)
|
||||||
|
defer func() { _ = scanner.Close() }()
|
||||||
|
scanner.SetAdminID(adminID)
|
||||||
|
require.NoError(t, scanner.SetFolders([]string{folder}, false))
|
||||||
|
require.NoError(t, scanner.ScanFolders(context.Background()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitForCond polls cond until true or the timeout elapses.
|
||||||
|
func waitForCond(t *testing.T, timeout time.Duration, msg string, cond func() bool) {
|
||||||
|
t.Helper()
|
||||||
|
deadline := time.Now().Add(timeout)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if cond() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
}
|
||||||
|
t.Fatalf("condition not met within %v: %s", timeout, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func moveTestAdminID(t *testing.T, setup *TestServerSetup) (pgtype.UUID, pgtype.UUID) {
|
||||||
|
t.Helper()
|
||||||
|
admin, err := setup.DB.GetUserByEmail(context.Background(), moveTestAdminEmail)
|
||||||
|
require.NoError(t, err)
|
||||||
|
pgAdmin := pgtype.UUID{Bytes: admin.ID.Bytes, Valid: true}
|
||||||
|
return pgAdmin, pgAdmin
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeTestEbook places an identical dummy epub under folder/Author/.
|
||||||
|
func writeTestEbook(t *testing.T, folder string, content []byte) string {
|
||||||
|
t.Helper()
|
||||||
|
authorDir := filepath.Join(folder, "Author")
|
||||||
|
require.NoError(t, os.MkdirAll(authorDir, 0755))
|
||||||
|
bookPath := filepath.Join(authorDir, moveTestFileName)
|
||||||
|
require.NoError(t, os.WriteFile(bookPath, content, 0644))
|
||||||
|
return bookPath
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCrossLibraryMovePreservesHistory is the core scenario: a book moves
|
||||||
|
// from library A to library B (same type). The existing row must be repointed
|
||||||
|
// to library B keeping its ID so progress and annotations survive, instead of
|
||||||
|
// creating a duplicate while the old row goes missing.
|
||||||
|
func TestCrossLibraryMovePreservesHistory(t *testing.T) {
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
defer func() { _ = setup.Close() }()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
adminID, adminPgID := moveTestAdminID(t, setup)
|
||||||
|
libA, folderA := createTypedTestLibrary(t, setup, "Move Src Library", "ebooks")
|
||||||
|
libB, folderB := createTypedTestLibrary(t, setup, "Move Dst Library", "ebooks")
|
||||||
|
defer deleteLibraries(t, setup, libA, libB)
|
||||||
|
|
||||||
|
content := []byte("cross library move test epub content")
|
||||||
|
writeTestEbook(t, folderA, content)
|
||||||
|
|
||||||
|
scanTestFolder(t, setup, adminID, folderA)
|
||||||
|
|
||||||
|
var original database.ListMediaItemsByLibraryRow
|
||||||
|
waitForCond(t, 5*time.Second, "book should be imported into library A", func() bool {
|
||||||
|
books, err := setup.DB.ListMediaItemsByLibrary(ctx, libA)
|
||||||
|
if err != nil || len(books) != 1 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
original = books[0]
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
// Give the book real user data at its original location.
|
||||||
|
_, err := setup.DB.UpdateReadingProgress(ctx, database.UpdateReadingProgressParams{
|
||||||
|
MediaItemID: original.ID,
|
||||||
|
UserID: adminPgID,
|
||||||
|
CurrentPage: pgtype.Int4{Int32: 12, Valid: true},
|
||||||
|
TotalPages: pgtype.Int4{Int32: 300, Valid: true},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = setup.DB.CreateMediaHighlight(ctx, database.CreateMediaHighlightParams{
|
||||||
|
MediaItemID: original.ID,
|
||||||
|
UserID: adminPgID,
|
||||||
|
SelectionText: "a line worth keeping",
|
||||||
|
Color: pgtype.Text{String: "#ffff00", Valid: true},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Move the file: same bytes, new library folder, old copy deleted.
|
||||||
|
writeTestEbook(t, folderB, content)
|
||||||
|
require.NoError(t, os.Remove(filepath.Join(folderA, "Author", moveTestFileName)))
|
||||||
|
|
||||||
|
scanTestFolder(t, setup, adminID, folderB)
|
||||||
|
|
||||||
|
var moved database.ListMediaItemsByLibraryRow
|
||||||
|
waitForCond(t, 5*time.Second, "book should be repointed into library B", func() bool {
|
||||||
|
books, err := setup.DB.ListMediaItemsByLibrary(ctx, libB)
|
||||||
|
if err != nil || len(books) != 1 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
moved = books[0]
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.Equal(t, original.ID, moved.ID, "row ID must be preserved so history follows the book")
|
||||||
|
assert.Equal(t, libB, moved.LibraryID, "row must now belong to library B")
|
||||||
|
assert.Equal(t, filepath.ToSlash(filepath.Join("Author", moveTestFileName)), moved.FilePath, "path must be repointed relative to library B")
|
||||||
|
assert.False(t, moved.ArchivedAt.Valid, "moved row must not be archived")
|
||||||
|
assert.Equal(t, int32(0), moved.MissingScanCount, "moved row must not be marked missing")
|
||||||
|
assert.True(t, moved.LibraryTypeName.Valid && moved.LibraryTypeName.String == "ebooks", "library_type_name must stay truthful after library change")
|
||||||
|
|
||||||
|
// History followed the row.
|
||||||
|
progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{MediaItemID: original.ID, UserID: adminPgID})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, int32(12), progress.CurrentPage.Int32, "reading progress must survive the move")
|
||||||
|
|
||||||
|
highlights, err := setup.DB.GetMediaHighlights(ctx, database.GetMediaHighlightsParams{MediaItemID: original.ID, UserID: adminPgID})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, highlights, 1, "annotation must survive the move")
|
||||||
|
|
||||||
|
// Library A no longer holds the row: no duplicate, no archived ghost.
|
||||||
|
booksA, err := setup.DB.ListMediaItemsByLibrary(ctx, libA)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, booksA, "library A must not keep a duplicate row after the move")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCrossLibraryCopyStaysIndependent verifies that placing identical
|
||||||
|
// content in two libraries on purpose creates two independent rows: the
|
||||||
|
// second import is NOT blocked, and neither row is repointed or archived.
|
||||||
|
func TestCrossLibraryCopyStaysIndependent(t *testing.T) {
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
defer func() { _ = setup.Close() }()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
adminID, adminPgID := moveTestAdminID(t, setup)
|
||||||
|
libA, folderA := createTypedTestLibrary(t, setup, "Copy Lib A", "ebooks")
|
||||||
|
libB, folderB := createTypedTestLibrary(t, setup, "Copy Lib B", "ebooks")
|
||||||
|
defer deleteLibraries(t, setup, libA, libB)
|
||||||
|
|
||||||
|
content := []byte("deliberate duplicate content")
|
||||||
|
writeTestEbook(t, folderA, content)
|
||||||
|
writeTestEbook(t, folderB, content)
|
||||||
|
|
||||||
|
scanTestFolder(t, setup, adminID, folderA)
|
||||||
|
scanTestFolder(t, setup, adminID, folderB)
|
||||||
|
|
||||||
|
var itemA, itemB database.ListMediaItemsByLibraryRow
|
||||||
|
waitForCond(t, 5*time.Second, "both libraries should hold their own copy", func() bool {
|
||||||
|
booksA, errA := setup.DB.ListMediaItemsByLibrary(ctx, libA)
|
||||||
|
booksB, errB := setup.DB.ListMediaItemsByLibrary(ctx, libB)
|
||||||
|
if errA != nil || errB != nil || len(booksA) != 1 || len(booksB) != 1 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
itemA, itemB = booksA[0], booksB[0]
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.NotEqual(t, itemA.ID, itemB.ID, "deliberate copies must stay independent rows")
|
||||||
|
assert.False(t, itemA.ArchivedAt.Valid && itemB.ArchivedAt.Valid, "no copy should be archived")
|
||||||
|
|
||||||
|
// History on one copy does not leak into the other.
|
||||||
|
_, err := setup.DB.UpdateReadingProgress(ctx, database.UpdateReadingProgressParams{
|
||||||
|
MediaItemID: itemA.ID,
|
||||||
|
UserID: adminPgID,
|
||||||
|
CurrentPage: pgtype.Int4{Int32: 7, Valid: true},
|
||||||
|
TotalPages: pgtype.Int4{Int32: 100, Valid: true},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{MediaItemID: itemB.ID, UserID: adminPgID})
|
||||||
|
assert.Error(t, err, "copy B must not inherit copy A's progress")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCrossLibraryMoveRejectedForTypeMismatch verifies the type guard: the
|
||||||
|
// cross-library lookup only matches same-type libraries, so a reflowable EPUB
|
||||||
|
// registered in a manga library whose file moves to ANOTHER manga library is
|
||||||
|
// detected as a move but must be rejected by the type rules (manga libraries
|
||||||
|
// only accept fixed-layout/image content). The move is rejected, a processing
|
||||||
|
// issue is recorded, and the file stays untracked rather than being
|
||||||
|
// force-imported.
|
||||||
|
func TestCrossLibraryMoveRejectedForTypeMismatch(t *testing.T) {
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
defer func() { _ = setup.Close() }()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
adminID, _ := moveTestAdminID(t, setup)
|
||||||
|
libA, folderA := createTypedTestLibrary(t, setup, "Type Src Manga", "manga")
|
||||||
|
libM, folderM := createTypedTestLibrary(t, setup, "Type Dst Manga", "manga")
|
||||||
|
defer deleteLibraries(t, setup, libA, libM)
|
||||||
|
|
||||||
|
content := []byte("reflowable epub that manga libraries must not adopt via move")
|
||||||
|
writeTestEbook(t, folderA, content)
|
||||||
|
scanTestFolder(t, setup, adminID, folderA)
|
||||||
|
|
||||||
|
var original database.ListMediaItemsByLibraryRow
|
||||||
|
waitForCond(t, 5*time.Second, "book should be imported into the first manga library", func() bool {
|
||||||
|
books, err := setup.DB.ListMediaItemsByLibrary(ctx, libA)
|
||||||
|
if err != nil || len(books) != 1 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
original = books[0]
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
require.Equal(t, "reflowable", original.FormatGroup, "dummy epub must scan as reflowable for this test")
|
||||||
|
|
||||||
|
// Move into the second manga library: same hash, same type, but the
|
||||||
|
// reflowable format violates manga library rules.
|
||||||
|
writeTestEbook(t, folderM, content)
|
||||||
|
require.NoError(t, os.Remove(filepath.Join(folderA, "Author", moveTestFileName)))
|
||||||
|
|
||||||
|
scanTestFolder(t, setup, adminID, folderM)
|
||||||
|
|
||||||
|
// The second manga library must not adopt the item.
|
||||||
|
booksM, err := setup.DB.ListMediaItemsByLibrary(ctx, libM)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, booksM, "reflowable epub must not be repointed between manga libraries")
|
||||||
|
|
||||||
|
// The original row is untouched in the first manga library.
|
||||||
|
after, err := setup.DB.GetMediaItem(ctx, original.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, libA, after.LibraryID, "original row must stay in its library")
|
||||||
|
|
||||||
|
// A processing issue explains the rejection.
|
||||||
|
waitForCond(t, 5*time.Second, "processing issue should be recorded", func() bool {
|
||||||
|
issues, err := setup.DB.ListProcessingIssuesByLibrary(ctx, libM)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, issue := range issues {
|
||||||
|
if issue.IssueType == "library_type_mismatch" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestListHiddenMediaItemsMatchesActiveTwin verifies the archived-page query:
|
||||||
|
// an archived row with an active same-hash twin reports it in match_* columns;
|
||||||
|
// an archived row without a twin reports no match.
|
||||||
|
func TestListHiddenMediaItemsMatchesActiveTwin(t *testing.T) {
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
defer func() { _ = setup.Close() }()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
adminID, _ := moveTestAdminID(t, setup)
|
||||||
|
libA, folderA := createTypedTestLibrary(t, setup, "Twin Lib A", "ebooks")
|
||||||
|
libB, folderB := createTypedTestLibrary(t, setup, "Twin Lib B", "ebooks")
|
||||||
|
libC, folderC := createTypedTestLibrary(t, setup, "Twin Lib C", "ebooks")
|
||||||
|
defer deleteLibraries(t, setup, libA, libB, libC)
|
||||||
|
|
||||||
|
twinContent := []byte("twin content shared by two libraries")
|
||||||
|
writeTestEbook(t, folderA, twinContent)
|
||||||
|
writeTestEbook(t, folderB, twinContent)
|
||||||
|
lonelyContent := []byte("unique content with no twin anywhere")
|
||||||
|
writeTestEbook(t, folderC, lonelyContent)
|
||||||
|
|
||||||
|
scanTestFolder(t, setup, adminID, folderA)
|
||||||
|
scanTestFolder(t, setup, adminID, folderB)
|
||||||
|
scanTestFolder(t, setup, adminID, folderC)
|
||||||
|
|
||||||
|
booksA, err := setup.DB.ListMediaItemsByLibrary(ctx, libA)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, booksA, 1)
|
||||||
|
twin := booksA[0]
|
||||||
|
|
||||||
|
booksC, err := setup.DB.ListMediaItemsByLibrary(ctx, libC)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, booksC, 1)
|
||||||
|
lonely := booksC[0]
|
||||||
|
|
||||||
|
// Archive both: the A row now has an active twin in B, the C row has none.
|
||||||
|
require.NoError(t, setup.DB.ArchiveMediaItem(ctx, twin.ID))
|
||||||
|
require.NoError(t, setup.DB.ArchiveMediaItem(ctx, lonely.ID))
|
||||||
|
|
||||||
|
rows, err := setup.DB.ListHiddenMediaItems(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
foundTwin, foundLonely := false, false
|
||||||
|
for _, row := range rows {
|
||||||
|
if row.ID == twin.ID {
|
||||||
|
foundTwin = true
|
||||||
|
assert.True(t, row.MatchID.Valid, "twin row must report its active match")
|
||||||
|
if row.MatchID.Valid {
|
||||||
|
assert.Equal(t, findTwinTargetID(t, setup, libB), uuid.UUID(row.MatchID.Bytes).String())
|
||||||
|
}
|
||||||
|
assert.Equal(t, "Test Book", row.MatchTitle)
|
||||||
|
}
|
||||||
|
if row.ID == lonely.ID {
|
||||||
|
foundLonely = true
|
||||||
|
assert.False(t, row.MatchID.Valid, "row without a twin must report no match")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require.True(t, foundTwin, "twin row should be listed")
|
||||||
|
require.True(t, foundLonely, "lonely row should be listed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// findTwinTargetID returns the single active item in the given library.
|
||||||
|
func findTwinTargetID(t *testing.T, setup *TestServerSetup, libID pgtype.UUID) string {
|
||||||
|
t.Helper()
|
||||||
|
books, err := setup.DB.ListMediaItemsByLibrary(context.Background(), libID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, books, 1)
|
||||||
|
return uuid.UUID(books[0].ID.Bytes).String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// mergeRequest issues POST /api/media-items/{source}/merge.
|
||||||
|
func mergeRequest(t *testing.T, setup *TestServerSetup, token, sourceID string, body map[string]interface{}) *http.Response {
|
||||||
|
t.Helper()
|
||||||
|
payload, _ := json.Marshal(body)
|
||||||
|
req, _ := http.NewRequest("POST", setup.Server.URL+"/api/media-items/"+sourceID+"/merge", bytes.NewBuffer(payload))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
// archiveAndIsolate imports one book into the library and archives it,
|
||||||
|
// simulating a file that was copied elsewhere then deleted from here.
|
||||||
|
func archiveAndIsolate(t *testing.T, setup *TestServerSetup, adminID, adminPgID pgtype.UUID, libID pgtype.UUID, folder, content string) database.ListMediaItemsByLibraryRow {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
writeTestEbook(t, folder, []byte(content))
|
||||||
|
scanTestFolder(t, setup, adminID, folder)
|
||||||
|
|
||||||
|
books, err := setup.DB.ListMediaItemsByLibrary(ctx, libID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, books, 1)
|
||||||
|
item := books[0]
|
||||||
|
|
||||||
|
// Reading history that must survive the merge.
|
||||||
|
_, err = setup.DB.UpdateReadingProgress(ctx, database.UpdateReadingProgressParams{
|
||||||
|
MediaItemID: item.ID,
|
||||||
|
UserID: adminPgID,
|
||||||
|
CurrentPage: pgtype.Int4{Int32: 42, Valid: true},
|
||||||
|
TotalPages: pgtype.Int4{Int32: 200, Valid: true},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = setup.DB.CreateMediaHighlight(ctx, database.CreateMediaHighlightParams{
|
||||||
|
MediaItemID: item.ID,
|
||||||
|
UserID: adminPgID,
|
||||||
|
SelectionText: "annotation on the old copy",
|
||||||
|
Color: pgtype.Text{String: "#00ff00", Valid: true},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
require.NoError(t, setup.DB.ArchiveMediaItem(ctx, item.ID))
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMergeArchivedItemIntoActiveTwin covers the copy-then-delete-later
|
||||||
|
// workflow: the active copy exists in another library, the archived twin
|
||||||
|
// holds the real reading history. Merging moves progress + annotations onto
|
||||||
|
// the active row and removes the archived row.
|
||||||
|
func TestMergeArchivedItemIntoActiveTwin(t *testing.T) {
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
defer func() { _ = setup.Close() }()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
adminID, adminPgID := moveTestAdminID(t, setup)
|
||||||
|
libOld, folderOld := createTypedTestLibrary(t, setup, "Merge Old Lib", "ebooks")
|
||||||
|
libNew, folderNew := createTypedTestLibrary(t, setup, "Merge New Lib", "ebooks")
|
||||||
|
defer deleteLibraries(t, setup, libOld, libNew)
|
||||||
|
|
||||||
|
content := "merge workflow epub content"
|
||||||
|
|
||||||
|
// Old library: book + history, then archived (file "deleted").
|
||||||
|
oldItem := archiveAndIsolate(t, setup, adminID, adminPgID, libOld, folderOld, content)
|
||||||
|
|
||||||
|
// New library: the surviving active copy, no history of its own.
|
||||||
|
writeTestEbook(t, folderNew, []byte(content))
|
||||||
|
scanTestFolder(t, setup, adminID, folderNew)
|
||||||
|
booksNew, err := setup.DB.ListMediaItemsByLibrary(ctx, libNew)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, booksNew, 1)
|
||||||
|
active := booksNew[0]
|
||||||
|
|
||||||
|
oldID := uuid.UUID(oldItem.ID.Bytes).String()
|
||||||
|
activeID := uuid.UUID(active.ID.Bytes).String()
|
||||||
|
resp := mergeRequest(t, setup, setup.Token, oldID, map[string]interface{}{"target_id": activeID})
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
require.Equal(t, http.StatusOK, resp.StatusCode, "merge should succeed")
|
||||||
|
|
||||||
|
var mergeResp map[string]interface{}
|
||||||
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&mergeResp))
|
||||||
|
|
||||||
|
// Archived row is gone.
|
||||||
|
_, err = setup.DB.GetMediaItem(ctx, oldItem.ID)
|
||||||
|
assert.Error(t, err, "archived row must be removed after merge")
|
||||||
|
|
||||||
|
// History landed on the active row.
|
||||||
|
progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{MediaItemID: active.ID, UserID: adminPgID})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, int32(42), progress.CurrentPage.Int32, "progress must merge into the active copy")
|
||||||
|
|
||||||
|
highlights, err := setup.DB.GetMediaHighlights(ctx, database.GetMediaHighlightsParams{MediaItemID: active.ID, UserID: adminPgID})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, highlights, 1, "annotations must merge into the active copy")
|
||||||
|
|
||||||
|
booksOld, err := setup.DB.ListMediaItemsByLibrary(ctx, libOld)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, booksOld, "old library must be empty after merge")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergeArchivedItemRejections(t *testing.T) {
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
defer func() { _ = setup.Close() }()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
adminID, adminPgID := moveTestAdminID(t, setup)
|
||||||
|
libA, folderA := createTypedTestLibrary(t, setup, "Reject Lib A", "ebooks")
|
||||||
|
libB, folderB := createTypedTestLibrary(t, setup, "Reject Lib B", "ebooks")
|
||||||
|
defer deleteLibraries(t, setup, libA, libB)
|
||||||
|
|
||||||
|
t.Run("requires admin", func(t *testing.T) {
|
||||||
|
writeTestEbook(t, folderA, []byte("admin check content"))
|
||||||
|
scanTestFolder(t, setup, adminID, folderA)
|
||||||
|
booksA, err := setup.DB.ListMediaItemsByLibrary(ctx, libA)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, booksA, 1)
|
||||||
|
itemID := uuid.UUID(booksA[0].ID.Bytes).String()
|
||||||
|
|
||||||
|
resp := mergeRequest(t, setup, setup.RegularToken, itemID, map[string]interface{}{"target_id": itemID})
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("rejects active source", func(t *testing.T) {
|
||||||
|
writeTestEbook(t, folderA, []byte("active source content"))
|
||||||
|
scanTestFolder(t, setup, adminID, folderA)
|
||||||
|
booksA, err := setup.DB.ListMediaItemsByLibrary(ctx, libA)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, booksA, 1)
|
||||||
|
sourceID := uuid.UUID(booksA[0].ID.Bytes).String()
|
||||||
|
|
||||||
|
writeTestEbook(t, folderB, []byte("active source content"))
|
||||||
|
scanTestFolder(t, setup, adminID, folderB)
|
||||||
|
targetID := findTwinTargetID(t, setup, libB)
|
||||||
|
|
||||||
|
resp := mergeRequest(t, setup, setup.Token, sourceID, map[string]interface{}{"target_id": targetID})
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode, "source is not archived/missing")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("rejects hash mismatch", func(t *testing.T) {
|
||||||
|
writeTestEbook(t, folderA, []byte("hash mismatch content A"))
|
||||||
|
scanTestFolder(t, setup, adminID, folderA)
|
||||||
|
booksA, err := setup.DB.ListMediaItemsByLibrary(ctx, libA)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, booksA, 1)
|
||||||
|
source := booksA[0]
|
||||||
|
require.NoError(t, setup.DB.ArchiveMediaItem(ctx, source.ID))
|
||||||
|
sourceID := uuid.UUID(source.ID.Bytes).String()
|
||||||
|
|
||||||
|
writeTestEbook(t, folderB, []byte("hash mismatch content B - different bytes"))
|
||||||
|
scanTestFolder(t, setup, adminID, folderB)
|
||||||
|
targetID := findTwinTargetID(t, setup, libB)
|
||||||
|
|
||||||
|
resp := mergeRequest(t, setup, setup.Token, sourceID, map[string]interface{}{"target_id": targetID})
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode, "different content must not merge")
|
||||||
|
|
||||||
|
// Both rows still exist.
|
||||||
|
_, err = setup.DB.GetMediaItem(ctx, source.ID)
|
||||||
|
assert.NoError(t, err, "source must survive a rejected merge")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("requires target_id", func(t *testing.T) {
|
||||||
|
writeTestEbook(t, folderA, []byte("missing target content"))
|
||||||
|
scanTestFolder(t, setup, adminID, folderA)
|
||||||
|
booksA, err := setup.DB.ListMediaItemsByLibrary(ctx, libA)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, booksA, 1)
|
||||||
|
require.NoError(t, setup.DB.ArchiveMediaItem(ctx, booksA[0].ID))
|
||||||
|
sourceID := uuid.UUID(booksA[0].ID.Bytes).String()
|
||||||
|
|
||||||
|
resp := mergeRequest(t, setup, setup.Token, sourceID, map[string]interface{}{})
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("rejects self merge", func(t *testing.T) {
|
||||||
|
writeTestEbook(t, folderA, []byte("self merge content"))
|
||||||
|
scanTestFolder(t, setup, adminID, folderA)
|
||||||
|
booksA, err := setup.DB.ListMediaItemsByLibrary(ctx, libA)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, booksA, 1)
|
||||||
|
require.NoError(t, setup.DB.ArchiveMediaItem(ctx, booksA[0].ID))
|
||||||
|
sourceID := uuid.UUID(booksA[0].ID.Bytes).String()
|
||||||
|
|
||||||
|
resp := mergeRequest(t, setup, setup.Token, sourceID, map[string]interface{}{"target_id": sourceID})
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("rejects archived target", func(t *testing.T) {
|
||||||
|
content := []byte("archived target content")
|
||||||
|
writeTestEbook(t, folderA, content)
|
||||||
|
scanTestFolder(t, setup, adminID, folderA)
|
||||||
|
booksA, err := setup.DB.ListMediaItemsByLibrary(ctx, libA)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, booksA, 1)
|
||||||
|
require.NoError(t, setup.DB.ArchiveMediaItem(ctx, booksA[0].ID))
|
||||||
|
sourceID := uuid.UUID(booksA[0].ID.Bytes).String()
|
||||||
|
|
||||||
|
writeTestEbook(t, folderB, content)
|
||||||
|
scanTestFolder(t, setup, adminID, folderB)
|
||||||
|
booksB, err := setup.DB.ListMediaItemsByLibrary(ctx, libB)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, booksB, 1)
|
||||||
|
require.NoError(t, setup.DB.ArchiveMediaItem(ctx, booksB[0].ID))
|
||||||
|
targetID := uuid.UUID(booksB[0].ID.Bytes).String()
|
||||||
|
|
||||||
|
resp := mergeRequest(t, setup, setup.Token, sourceID, map[string]interface{}{"target_id": targetID})
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode, "target must be active")
|
||||||
|
})
|
||||||
|
|
||||||
|
_ = adminPgID
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLibraryTypeNameTriggerOnLibraryChange proves the schema trigger keeps
|
||||||
|
// media_items.library_type_name in sync when a row is repointed to a library
|
||||||
|
// of a different type (the INSERT-only trigger previously left it stale).
|
||||||
|
func TestLibraryTypeNameTriggerOnLibraryChange(t *testing.T) {
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
defer func() { _ = setup.Close() }()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
adminID, _ := moveTestAdminID(t, setup)
|
||||||
|
libE, folderE := createTypedTestLibrary(t, setup, "Trigger Ebooks", "ebooks")
|
||||||
|
libC, _ := createTypedTestLibrary(t, setup, "Trigger Comics", "comics")
|
||||||
|
defer deleteLibraries(t, setup, libE, libC)
|
||||||
|
|
||||||
|
writeTestEbook(t, folderE, []byte("trigger check content"))
|
||||||
|
scanTestFolder(t, setup, adminID, folderE)
|
||||||
|
|
||||||
|
books, err := setup.DB.ListMediaItemsByLibrary(ctx, libE)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, books, 1)
|
||||||
|
item := books[0]
|
||||||
|
require.True(t, item.LibraryTypeName.Valid && item.LibraryTypeName.String == "ebooks")
|
||||||
|
|
||||||
|
require.NoError(t, setup.DB.MoveMediaItemToLibrary(ctx, database.MoveMediaItemToLibraryParams{
|
||||||
|
ID: item.ID,
|
||||||
|
LibraryID: libC,
|
||||||
|
FilePath: "Author/Test Book.epub",
|
||||||
|
FileSize: pgtype.Int8{Int64: 27, Valid: true},
|
||||||
|
}))
|
||||||
|
|
||||||
|
after, err := setup.DB.GetMediaItem(ctx, item.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, libC, after.LibraryID)
|
||||||
|
assert.True(t, after.LibraryTypeName.Valid && after.LibraryTypeName.String == "comics",
|
||||||
|
"trigger must refresh library_type_name when library_id changes")
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -40,7 +41,9 @@ func (s *DashboardIntegrationTestSuite) TestGetSections_EndToEndFlow() {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(s.T(), err)
|
require.NoError(s.T(), err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(s.T(), http.StatusOK, resp.StatusCode)
|
assert.Equal(s.T(), http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
@@ -50,7 +53,7 @@ func (s *DashboardIntegrationTestSuite) TestGetSections_EndToEndFlow() {
|
|||||||
|
|
||||||
sections, ok := response["sections"].([]interface{})
|
sections, ok := response["sections"].([]interface{})
|
||||||
require.True(s.T(), ok, "sections should be an array")
|
require.True(s.T(), ok, "sections should be an array")
|
||||||
require.Len(s.T(), sections, 4, "Should have 4 system collections")
|
require.Len(s.T(), sections, 5, "Should have 5 system collections")
|
||||||
|
|
||||||
// Verify response structure
|
// Verify response structure
|
||||||
sectionMap := make(map[string]map[string]interface{})
|
sectionMap := make(map[string]map[string]interface{})
|
||||||
@@ -66,14 +69,16 @@ func (s *DashboardIntegrationTestSuite) TestGetSections_EndToEndFlow() {
|
|||||||
assert.IsType(s.T(), float64(0), section["priority"], "priority should be number")
|
assert.IsType(s.T(), float64(0), section["priority"], "priority should be number")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify all system collections exist
|
// Verify all system collections exist (names are the section ids;
|
||||||
assert.Contains(s.T(), sectionMap, "continue-reading")
|
// the canonical seed names are Title-case).
|
||||||
assert.Contains(s.T(), sectionMap, "recently-added")
|
assert.Contains(s.T(), sectionMap, "Continue Reading")
|
||||||
assert.Contains(s.T(), sectionMap, "recently-read")
|
assert.Contains(s.T(), sectionMap, "Recently Added")
|
||||||
assert.Contains(s.T(), sectionMap, "not-started")
|
assert.Contains(s.T(), sectionMap, "Recently Read")
|
||||||
|
assert.Contains(s.T(), sectionMap, "Not Started")
|
||||||
|
assert.Contains(s.T(), sectionMap, "Continue Series")
|
||||||
|
|
||||||
// Verify continue-reading is a system collection
|
// Verify continue-reading is a system collection
|
||||||
continueReading := sectionMap["continue-reading"]
|
continueReading := sectionMap["Continue Reading"]
|
||||||
assert.True(s.T(), continueReading["is_system"].(bool), "continue-reading should be system collection")
|
assert.True(s.T(), continueReading["is_system"].(bool), "continue-reading should be system collection")
|
||||||
assert.Equal(s.T(), "📖", continueReading["icon"], "icon should match")
|
assert.Equal(s.T(), "📖", continueReading["icon"], "icon should match")
|
||||||
}
|
}
|
||||||
@@ -81,15 +86,26 @@ func (s *DashboardIntegrationTestSuite) TestGetSections_EndToEndFlow() {
|
|||||||
func (s *DashboardIntegrationTestSuite) TestGetSections_MissingLibraryID() {
|
func (s *DashboardIntegrationTestSuite) TestGetSections_MissingLibraryID() {
|
||||||
token := s.setup.Token
|
token := s.setup.Token
|
||||||
|
|
||||||
|
// No library_id = the All Libraries view: the endpoint aggregates
|
||||||
|
// sections across every library (200), it does not 400.
|
||||||
req, _ := http.NewRequest("GET", s.setup.Server.URL+"/api/dashboard/sections", nil)
|
req, _ := http.NewRequest("GET", s.setup.Server.URL+"/api/dashboard/sections", nil)
|
||||||
req.Header.Set("Authorization", "Bearer "+token)
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
|
||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(s.T(), err)
|
require.NoError(s.T(), err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(s.T(), http.StatusBadRequest, resp.StatusCode)
|
assert.Equal(s.T(), http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
var response map[string]interface{}
|
||||||
|
err = json.NewDecoder(resp.Body).Decode(&response)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
sections, ok := response["sections"].([]interface{})
|
||||||
|
require.True(s.T(), ok, "sections should be an array")
|
||||||
|
assert.NotEmpty(s.T(), sections, "All Libraries view should aggregate sections")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *DashboardIntegrationTestSuite) TestGetSections_InvalidLibraryID() {
|
func (s *DashboardIntegrationTestSuite) TestGetSections_InvalidLibraryID() {
|
||||||
@@ -101,7 +117,9 @@ func (s *DashboardIntegrationTestSuite) TestGetSections_InvalidLibraryID() {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(s.T(), err)
|
require.NoError(s.T(), err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(s.T(), http.StatusBadRequest, resp.StatusCode)
|
assert.Equal(s.T(), http.StatusBadRequest, resp.StatusCode)
|
||||||
}
|
}
|
||||||
@@ -112,7 +130,9 @@ func (s *DashboardIntegrationTestSuite) TestGetSections_Unauthorized() {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(s.T(), err)
|
require.NoError(s.T(), err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(s.T(), http.StatusUnauthorized, resp.StatusCode)
|
assert.Equal(s.T(), http.StatusUnauthorized, resp.StatusCode)
|
||||||
}
|
}
|
||||||
@@ -136,7 +156,9 @@ func (s *DashboardIntegrationTestSuite) TestUpdatePreferences_Success() {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(s.T(), err)
|
require.NoError(s.T(), err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(s.T(), http.StatusOK, resp.StatusCode)
|
assert.Equal(s.T(), http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
@@ -164,7 +186,9 @@ func (s *DashboardIntegrationTestSuite) TestUpdatePreferences_Unauthorized() {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(s.T(), err)
|
require.NoError(s.T(), err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(s.T(), http.StatusUnauthorized, resp.StatusCode)
|
assert.Equal(s.T(), http.StatusUnauthorized, resp.StatusCode)
|
||||||
}
|
}
|
||||||
@@ -184,7 +208,9 @@ func (s *DashboardIntegrationTestSuite) TestRestoreSystemCollection_InvalidName(
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(s.T(), err)
|
require.NoError(s.T(), err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(s.T(), http.StatusBadRequest, resp.StatusCode)
|
assert.Equal(s.T(), http.StatusBadRequest, resp.StatusCode)
|
||||||
}
|
}
|
||||||
@@ -201,7 +227,9 @@ func (s *DashboardIntegrationTestSuite) TestRestoreSystemCollection_Unauthorized
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(s.T(), err)
|
require.NoError(s.T(), err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(s.T(), http.StatusUnauthorized, resp.StatusCode)
|
assert.Equal(s.T(), http.StatusUnauthorized, resp.StatusCode)
|
||||||
}
|
}
|
||||||
@@ -209,7 +237,7 @@ func (s *DashboardIntegrationTestSuite) TestRestoreSystemCollection_Unauthorized
|
|||||||
func (s *DashboardIntegrationTestSuite) TestRestoreSystemCollection_ValidNames() {
|
func (s *DashboardIntegrationTestSuite) TestRestoreSystemCollection_ValidNames() {
|
||||||
token := s.setup.Token
|
token := s.setup.Token
|
||||||
|
|
||||||
validCollections := []string{"Continue Reading", "Recently Added", "Recently Read", "Not Started"}
|
validCollections := []string{"Continue Reading", "Recently Added", "Recently Read", "Not Started", "Continue Series"}
|
||||||
|
|
||||||
for _, collName := range validCollections {
|
for _, collName := range validCollections {
|
||||||
s.T().Run(collName, func(t *testing.T) {
|
s.T().Run(collName, func(t *testing.T) {
|
||||||
@@ -224,20 +252,279 @@ func (s *DashboardIntegrationTestSuite) TestRestoreSystemCollection_ValidNames()
|
|||||||
|
|
||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(s.T(), err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var response map[string]interface{}
|
var response map[string]interface{}
|
||||||
err = json.NewDecoder(resp.Body).Decode(&response)
|
err = json.NewDecoder(resp.Body).Decode(&response)
|
||||||
require.NoError(t, err)
|
require.NoError(s.T(), err)
|
||||||
|
|
||||||
assert.Contains(t, response, "message")
|
assert.Contains(t, response, "message")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *DashboardIntegrationTestSuite) TestUpdatePreferences_GlobalScope() {
|
||||||
|
token := s.setup.Token
|
||||||
|
|
||||||
|
// Absent library_id (JSON null after omit) = the global All Libraries
|
||||||
|
// scope: stored in user_dashboard_preferences_global, one row per user.
|
||||||
|
reqBody := map[string]interface{}{
|
||||||
|
"hidden_collections": []string{"not-started"},
|
||||||
|
"collection_order": []string{"continue-reading", "recently-added"},
|
||||||
|
"items_per_section": 15,
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(reqBody)
|
||||||
|
req, _ := http.NewRequest("PUT", s.setup.Server.URL+"/api/dashboard/preferences", bytes.NewBuffer(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
|
||||||
|
client := &http.Client{}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
|
assert.Equal(s.T(), http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
// Second global save with different values: must UPDATE the same row
|
||||||
|
// (the old ON CONFLICT (user_id, library_id) never fired for NULLs and
|
||||||
|
// would have minted a duplicate per save).
|
||||||
|
reqBody["items_per_section"] = 25
|
||||||
|
body, _ = json.Marshal(reqBody)
|
||||||
|
req, _ = http.NewRequest("PUT", s.setup.Server.URL+"/api/dashboard/preferences", bytes.NewBuffer(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
resp, err = client.Do(req)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
assert.Equal(s.T(), http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
// Reading prefs without library_id returns the global row (not some
|
||||||
|
// arbitrary library's).
|
||||||
|
req, _ = http.NewRequest("GET", s.setup.Server.URL+"/api/dashboard/preferences", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
resp, err = client.Do(req)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
assert.Equal(s.T(), http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
var prefs map[string]interface{}
|
||||||
|
err = json.NewDecoder(resp.Body).Decode(&prefs)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
assert.Equal(s.T(), float64(25), prefs["items_per_section"], "global row should carry the second save")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DashboardIntegrationTestSuite) TestUpdatePreferences_EmptyLibraryIDRejected() {
|
||||||
|
token := s.setup.Token
|
||||||
|
|
||||||
|
// Empty string is a LOST value (a client bug), never a scope — must
|
||||||
|
// stay a loud 400 rather than silently writing the global scope.
|
||||||
|
reqBody := map[string]interface{}{
|
||||||
|
"library_id": "",
|
||||||
|
"hidden_collections": []string{},
|
||||||
|
"collection_order": []string{},
|
||||||
|
"items_per_section": 20,
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(reqBody)
|
||||||
|
req, _ := http.NewRequest("PUT", s.setup.Server.URL+"/api/dashboard/preferences", bytes.NewBuffer(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
|
||||||
|
client := &http.Client{}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
|
assert.Equal(s.T(), http.StatusBadRequest, resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DashboardIntegrationTestSuite) TestGetPreferences_GlobalAndPerLibraryIndependent() {
|
||||||
|
token := s.setup.Token
|
||||||
|
libraryID := createTestLibraryWithFolder(s.T(), s.setup.Server, token, "Prefs Scope Test Library", false)
|
||||||
|
|
||||||
|
// Global scope: 15.
|
||||||
|
globalBody, _ := json.Marshal(map[string]interface{}{
|
||||||
|
"hidden_collections": []string{},
|
||||||
|
"collection_order": []string{},
|
||||||
|
"items_per_section": 15,
|
||||||
|
})
|
||||||
|
req, _ := http.NewRequest("PUT", s.setup.Server.URL+"/api/dashboard/preferences", bytes.NewBuffer(globalBody))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
client := &http.Client{}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
resp.Body.Close()
|
||||||
|
require.Equal(s.T(), http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
// Per-library scope: 30. Must not bleed into global or vice versa.
|
||||||
|
libBody, _ := json.Marshal(map[string]interface{}{
|
||||||
|
"library_id": libraryID,
|
||||||
|
"hidden_collections": []string{},
|
||||||
|
"collection_order": []string{},
|
||||||
|
"items_per_section": 30,
|
||||||
|
})
|
||||||
|
req, _ = http.NewRequest("PUT", s.setup.Server.URL+"/api/dashboard/preferences", bytes.NewBuffer(libBody))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
resp, err = client.Do(req)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
resp.Body.Close()
|
||||||
|
require.Equal(s.T(), http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
// Per-library read.
|
||||||
|
req, _ = http.NewRequest("GET", s.setup.Server.URL+"/api/dashboard/preferences?library_id="+libraryID, nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
resp, err = client.Do(req)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
require.Equal(s.T(), http.StatusOK, resp.StatusCode)
|
||||||
|
var libPrefs map[string]interface{}
|
||||||
|
err = json.NewDecoder(resp.Body).Decode(&libPrefs)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
assert.Equal(s.T(), float64(30), libPrefs["items_per_section"], "per-library scope should read its own row")
|
||||||
|
|
||||||
|
// Global read.
|
||||||
|
req, _ = http.NewRequest("GET", s.setup.Server.URL+"/api/dashboard/preferences", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
resp, err = client.Do(req)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
require.Equal(s.T(), http.StatusOK, resp.StatusCode)
|
||||||
|
var globalPrefs map[string]interface{}
|
||||||
|
err = json.NewDecoder(resp.Body).Decode(&globalPrefs)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
assert.Equal(s.T(), float64(15), globalPrefs["items_per_section"], "global scope should stay independent")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDeleteSystemCollection_TombstonesAndRestores pins the system-
|
||||||
|
// collection permanence contract: DELETE hides the section everywhere
|
||||||
|
// (tombstone) and restore brings back the EXACT SAME row (id stable),
|
||||||
|
// never a fresh one.
|
||||||
|
func newHTTPClient() *http.Client { return &http.Client{} }
|
||||||
|
func (s *DashboardIntegrationTestSuite) TestDeleteSystemCollection_TombstonesAndRestores() {
|
||||||
|
token := s.setup.Token
|
||||||
|
libraryID := createTestLibraryWithFolder(s.T(), s.setup.Server, token, "Test Library", false)
|
||||||
|
|
||||||
|
collectionID := func(sectionID string) string {
|
||||||
|
url := fmt.Sprintf("%s/api/dashboard/sections?library_id=%s", s.setup.Server.URL, libraryID)
|
||||||
|
req, _ := http.NewRequest("GET", url, nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
resp, err := newHTTPClient().Do(req)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body)
|
||||||
|
require.Equal(s.T(), http.StatusOK, resp.StatusCode)
|
||||||
|
var response map[string]interface{}
|
||||||
|
require.NoError(s.T(), json.NewDecoder(resp.Body).Decode(&response))
|
||||||
|
for _, sec := range response["sections"].([]interface{}) {
|
||||||
|
section := sec.(map[string]interface{})
|
||||||
|
if section["id"] == sectionID {
|
||||||
|
return section["collection_id"].(string)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
hasSection := func(sectionID string) bool {
|
||||||
|
url := fmt.Sprintf("%s/api/dashboard/sections?library_id=%s", s.setup.Server.URL, libraryID)
|
||||||
|
req, _ := http.NewRequest("GET", url, nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
resp, err := newHTTPClient().Do(req)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body)
|
||||||
|
var response map[string]interface{}
|
||||||
|
require.NoError(s.T(), json.NewDecoder(resp.Body).Decode(&response))
|
||||||
|
for _, sec := range response["sections"].([]interface{}) {
|
||||||
|
if sec.(map[string]interface{})["id"] == sectionID {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
idBefore := collectionID("Continue Reading")
|
||||||
|
require.NotEmpty(s.T(), idBefore, "Continue Reading should exist")
|
||||||
|
|
||||||
|
// DELETE a system collection = tombstone (204, behaves like a delete).
|
||||||
|
delReq, _ := http.NewRequest("DELETE", s.setup.Server.URL+"/api/collections/"+idBefore, nil)
|
||||||
|
delReq.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
delResp, err := newHTTPClient().Do(delReq)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
defer func(Body io.ReadCloser) { _ = Body.Close() }(delResp.Body)
|
||||||
|
assert.Equal(s.T(), http.StatusNoContent, delResp.StatusCode)
|
||||||
|
|
||||||
|
// Invisible on the dashboard.
|
||||||
|
assert.False(s.T(), hasSection("Continue Reading"), "tombstoned section must vanish")
|
||||||
|
|
||||||
|
// Restore brings back the EXACT SAME row.
|
||||||
|
restoreBody, _ := json.Marshal(map[string]string{"collection_name": "Continue Reading"})
|
||||||
|
req, _ := http.NewRequest("POST", s.setup.Server.URL+"/api/dashboard/restore-system-collection", bytes.NewBuffer(restoreBody))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
restoreResp, err := newHTTPClient().Do(req)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
defer func(Body io.ReadCloser) { _ = Body.Close() }(restoreResp.Body)
|
||||||
|
require.Equal(s.T(), http.StatusOK, restoreResp.StatusCode)
|
||||||
|
|
||||||
|
idAfter := collectionID("Continue Reading")
|
||||||
|
assert.Equal(s.T(), idBefore, idAfter, "restore must resurrect the SAME row id")
|
||||||
|
assert.True(s.T(), hasSection("Continue Reading"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDeleteUserCollection_HardDelete pins the user-collection contract:
|
||||||
|
// user collections hard-delete exactly as before (no tombstone, no restore).
|
||||||
|
func (s *DashboardIntegrationTestSuite) TestDeleteUserCollection_HardDelete() {
|
||||||
|
token := s.setup.Token
|
||||||
|
|
||||||
|
createBody, _ := json.Marshal(map[string]interface{}{"name": "Test User Shelf", "type": "ebooks"})
|
||||||
|
req, _ := http.NewRequest("POST", s.setup.Server.URL+"/api/collections", bytes.NewBuffer(createBody))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
createResp, err := newHTTPClient().Do(req)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
defer func(Body io.ReadCloser) { _ = Body.Close() }(createResp.Body)
|
||||||
|
require.Equal(s.T(), http.StatusCreated, createResp.StatusCode)
|
||||||
|
|
||||||
|
var created map[string]interface{}
|
||||||
|
require.NoError(s.T(), json.NewDecoder(createResp.Body).Decode(&created))
|
||||||
|
collectionID := created["id"].(string)
|
||||||
|
|
||||||
|
delReq, _ := http.NewRequest("DELETE", s.setup.Server.URL+"/api/collections/"+collectionID, nil)
|
||||||
|
delReq.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
delResp, err := newHTTPClient().Do(delReq)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
defer func(Body io.ReadCloser) { _ = Body.Close() }(delResp.Body)
|
||||||
|
assert.Equal(s.T(), http.StatusNoContent, delResp.StatusCode)
|
||||||
|
|
||||||
|
listReq, _ := http.NewRequest("GET", s.setup.Server.URL+"/api/collections", nil)
|
||||||
|
listReq.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
listResp, err := newHTTPClient().Do(listReq)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
defer func(Body io.ReadCloser) { _ = Body.Close() }(listResp.Body)
|
||||||
|
var list struct {
|
||||||
|
Collections []map[string]interface{} `json:"collections"`
|
||||||
|
}
|
||||||
|
require.NoError(s.T(), json.NewDecoder(listResp.Body).Decode(&list))
|
||||||
|
for _, c := range list.Collections {
|
||||||
|
assert.NotEqual(s.T(), collectionID, c["id"], "hard-deleted user collection must be gone")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDashboardIntegrationTestSuite(t *testing.T) {
|
func TestDashboardIntegrationTestSuite(t *testing.T) {
|
||||||
suite.Run(t, new(DashboardIntegrationTestSuite))
|
suite.Run(t, new(DashboardIntegrationTestSuite))
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -68,7 +69,9 @@ func TestUpdateUserMaxDevices(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, tt.expectedStatus, resp.StatusCode, "expected status code")
|
assert.Equal(t, tt.expectedStatus, resp.StatusCode, "expected status code")
|
||||||
|
|
||||||
@@ -134,7 +137,9 @@ func TestUpdateUserMaxDevicesValidation(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, tt.expectedStatus, resp.StatusCode, "expected validation error")
|
assert.Equal(t, tt.expectedStatus, resp.StatusCode, "expected validation error")
|
||||||
})
|
})
|
||||||
@@ -166,7 +171,9 @@ func TestUpdateUserMaxDevicesAuth(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -190,7 +197,9 @@ func TestUpdateUserMaxDevicesAuth(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -221,7 +230,9 @@ func TestUpdateUserMaxDevicesNonExistentUser(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
// Should return 500 or 404 depending on implementation
|
// Should return 500 or 404 depending on implementation
|
||||||
assert.True(t, resp.StatusCode == http.StatusInternalServerError || resp.StatusCode == http.StatusNotFound)
|
assert.True(t, resp.StatusCode == http.StatusInternalServerError || resp.StatusCode == http.StatusNotFound)
|
||||||
@@ -250,7 +261,9 @@ func TestUpdateUserMaxDevicesMissingUserID(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
}
|
}
|
||||||
@@ -271,12 +284,15 @@ func TestListUsersIncludesMaxDevices(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var users []map[string]interface{}
|
var users []map[string]interface{}
|
||||||
json.NewDecoder(resp.Body).Decode(&users)
|
err = json.NewDecoder(resp.Body).Decode(&users)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Verify max_devices and device_count fields are present in response
|
// Verify max_devices and device_count fields are present in response
|
||||||
if len(users) > 0 {
|
if len(users) > 0 {
|
||||||
@@ -306,7 +322,7 @@ func createAdminUser(t *testing.T, ts *httptest.Server, token string) {
|
|||||||
|
|
||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, _ := client.Do(req)
|
resp, _ := client.Do(req)
|
||||||
resp.Body.Close()
|
_ = resp.Body.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to create test user for max devices tests
|
// Helper function to create test user for max devices tests
|
||||||
@@ -320,14 +336,16 @@ func createTestUserForMaxDevices(t *testing.T, ts *httptest.Server, adminToken s
|
|||||||
}
|
}
|
||||||
|
|
||||||
jsonData, _ := json.Marshal(createUserPayload)
|
jsonData, _ := json.Marshal(createUserPayload)
|
||||||
req, _ := http.NewRequest("POST", ts.URL+"/api/auth/register", bytes.NewBuffer(jsonData))
|
req, _ := http.NewRequest("POST", ts.URL+"/api/auth/users", bytes.NewBuffer(jsonData))
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
req.Header.Set("Authorization", "Bearer "+adminToken)
|
req.Header.Set("Authorization", "Bearer "+adminToken)
|
||||||
|
|
||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
// Check if user creation succeeded or already exists (409 Conflict)
|
// Check if user creation succeeded or already exists (409 Conflict)
|
||||||
if resp.StatusCode == http.StatusConflict {
|
if resp.StatusCode == http.StatusConflict {
|
||||||
@@ -343,10 +361,13 @@ func createTestUserForMaxDevices(t *testing.T, ts *httptest.Server, adminToken s
|
|||||||
|
|
||||||
loginResp, err := client.Do(loginReq)
|
loginResp, err := client.Do(loginReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer loginResp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(loginResp.Body)
|
||||||
|
|
||||||
var loginResult map[string]interface{}
|
var loginResult map[string]interface{}
|
||||||
json.NewDecoder(loginResp.Body).Decode(&loginResult)
|
err = json.NewDecoder(loginResp.Body).Decode(&loginResult)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Extract user_id from JWT or response
|
// Extract user_id from JWT or response
|
||||||
// The access_token contains the user ID in the JWT claims
|
// The access_token contains the user ID in the JWT claims
|
||||||
@@ -392,16 +413,22 @@ func createTestUserForMaxDevices(t *testing.T, ts *httptest.Server, adminToken s
|
|||||||
}
|
}
|
||||||
|
|
||||||
var result map[string]interface{}
|
var result map[string]interface{}
|
||||||
json.NewDecoder(resp.Body).Decode(&result)
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Check if user creation was successful
|
// Admin create returns the user object at the top level; the legacy
|
||||||
if result["user"] == nil {
|
// register endpoint nested it under "user". Accept both.
|
||||||
|
userObj, ok := result["user"].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
userObj = result
|
||||||
|
}
|
||||||
|
if userObj["id"] == nil {
|
||||||
// User creation failed for another reason
|
// User creation failed for another reason
|
||||||
t.Logf("User creation failed, response: %+v", result)
|
t.Logf("User creation failed, response: %+v", result)
|
||||||
return uuid.UUID{}
|
return uuid.UUID{}
|
||||||
}
|
}
|
||||||
|
|
||||||
userIDStr := result["user"].(map[string]interface{})["id"].(string)
|
userIDStr, _ := userObj["id"].(string)
|
||||||
userID, _ := uuid.Parse(userIDStr)
|
userID, _ := uuid.Parse(userIDStr)
|
||||||
|
|
||||||
return userID
|
return userID
|
||||||
@@ -422,10 +449,13 @@ func getAdminToken(t *testing.T, ts *httptest.Server, userID uuid.UUID) string {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
var result map[string]interface{}
|
var result map[string]interface{}
|
||||||
json.NewDecoder(resp.Body).Decode(&result)
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Safe type assertion with check
|
// Safe type assertion with check
|
||||||
if accessToken, ok := result["access_token"].(string); ok {
|
if accessToken, ok := result["access_token"].(string); ok {
|
||||||
@@ -450,10 +480,13 @@ func loginTestUserByCredentials(t *testing.T, ts *httptest.Server, email, passwo
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
var result map[string]interface{}
|
var result map[string]interface{}
|
||||||
json.NewDecoder(resp.Body).Decode(&result)
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Safe type assertion with check
|
// Safe type assertion with check
|
||||||
if accessToken, ok := result["access_token"].(string); ok {
|
if accessToken, ok := result["access_token"].(string); ok {
|
||||||
|
|||||||
@@ -147,10 +147,12 @@ func TestUpdateDevice(t *testing.T) {
|
|||||||
device := setup.CreateDevice(t, "Test Device", "koreader", "test-device-123")
|
device := setup.CreateDevice(t, "Test Device", "koreader", "test-device-123")
|
||||||
|
|
||||||
// Update device
|
// Update device
|
||||||
|
syncEnabled := false
|
||||||
|
syncFreq := int32(10)
|
||||||
updateRequest := handlers.DeviceUpdateRequest{
|
updateRequest := handlers.DeviceUpdateRequest{
|
||||||
DeviceName: "Updated Device Name",
|
DeviceName: "Updated Device Name",
|
||||||
SyncEnabled: new(false),
|
SyncEnabled: &syncEnabled,
|
||||||
SyncFrequencyMinutes: new(int32(10)),
|
SyncFrequencyMinutes: &syncFreq,
|
||||||
}
|
}
|
||||||
updateBody, _ := json.Marshal(updateRequest)
|
updateBody, _ := json.Marshal(updateRequest)
|
||||||
|
|
||||||
@@ -250,6 +252,88 @@ func TestListPendingRegistrations(t *testing.T) {
|
|||||||
assert.NotNil(t, pending, "Pending registrations should not be nil")
|
assert.NotNil(t, pending, "Pending registrations should not be nil")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Regression test for the "approve returns 500 after app reinstall" bug:
|
||||||
|
// an app reinstall that preserves data (e.g. Android Studio installDebug
|
||||||
|
// over an existing install) re-registers with the SAME device_identifier,
|
||||||
|
// and the blind INSERT in ApproveDevice hit the UNIQUE(device_identifier)
|
||||||
|
// constraint. Re-approval must be idempotent: same row (id unchanged),
|
||||||
|
// rotated token, old token invalidated.
|
||||||
|
func TestApproveDeviceReapprovalRotatesToken(t *testing.T) {
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
userID := getTestUserID(t, setup.DB)
|
||||||
|
|
||||||
|
identifier := fmt.Sprintf("repro-reinstall-%s", uuid.New().String())
|
||||||
|
|
||||||
|
registerAndApprove := func() string {
|
||||||
|
regRequest := map[string]interface{}{
|
||||||
|
"device_name": "Reinstall Device",
|
||||||
|
"device_type": "mobile",
|
||||||
|
"device_identifier": identifier,
|
||||||
|
}
|
||||||
|
regBody, _ := json.Marshal(regRequest)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("POST", "/api/devices/register", bytes.NewReader(regBody))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
setup.Server.Config.Handler.ServeHTTP(rec, req)
|
||||||
|
require.Equal(t, http.StatusCreated, rec.Code, "Should initiate registration")
|
||||||
|
|
||||||
|
var regResponse map[string]interface{}
|
||||||
|
json.Unmarshal(rec.Body.Bytes(), ®Response)
|
||||||
|
registrationID, ok := regResponse["registration_id"].(string)
|
||||||
|
require.True(t, ok, "Should have registration_id")
|
||||||
|
|
||||||
|
req = httptest.NewRequest("GET", fmt.Sprintf("/api/devices/approve/%s", registrationID), nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
setup.Server.Config.Handler.ServeHTTP(rec, req)
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code,
|
||||||
|
"Approve must succeed even when the identifier already exists (was the reinstall 500)")
|
||||||
|
|
||||||
|
// The status response is single-use and carries the credentials.
|
||||||
|
statusBody, _ := json.Marshal(map[string]string{"registration_id": registrationID})
|
||||||
|
req = httptest.NewRequest("POST", "/api/devices/register/status", bytes.NewReader(statusBody))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
setup.Server.Config.Handler.ServeHTTP(rec, req)
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
|
||||||
|
var statusResponse map[string]interface{}
|
||||||
|
json.Unmarshal(rec.Body.Bytes(), &statusResponse)
|
||||||
|
require.Equal(t, "approved", statusResponse["status"])
|
||||||
|
token, ok := statusResponse["auth_token"].(string)
|
||||||
|
require.True(t, ok, "Should have auth_token")
|
||||||
|
require.NotEmpty(t, token)
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
token1 := registerAndApprove()
|
||||||
|
token2 := registerAndApprove()
|
||||||
|
|
||||||
|
assert.NotEqual(t, token1, token2, "Re-approval must rotate the auth token (fresh install = fresh credentials)")
|
||||||
|
|
||||||
|
devices, err := setup.DB.ListDevicesByUser(context.Background(),
|
||||||
|
pgtype.UUID{Bytes: [16]byte(userID), Valid: true})
|
||||||
|
require.NoError(t, err)
|
||||||
|
rows := 0
|
||||||
|
for _, d := range devices {
|
||||||
|
if d.DeviceIdentifier == identifier {
|
||||||
|
rows++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.Equal(t, 1, rows, "Re-approval must reuse the existing devices row, not duplicate it")
|
||||||
|
|
||||||
|
// The rotated-in token must be the live one.
|
||||||
|
var live *database.Devices
|
||||||
|
for i := range devices {
|
||||||
|
if devices[i].DeviceIdentifier == identifier {
|
||||||
|
live = &devices[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require.NotNil(t, live)
|
||||||
|
assert.Equal(t, token2, live.AuthToken, "The devices row must carry the newly rotated token")
|
||||||
|
}
|
||||||
|
|
||||||
func TestApproveDeviceRegistration(t *testing.T) {
|
func TestApproveDeviceRegistration(t *testing.T) {
|
||||||
setup := setupTestServer(t)
|
setup := setupTestServer(t)
|
||||||
|
|
||||||
@@ -428,7 +512,7 @@ func TestRegenerateDeviceToken_Forbidden(t *testing.T) {
|
|||||||
defer setup.Server.Close()
|
defer setup.Server.Close()
|
||||||
|
|
||||||
// Create device for user 1
|
// Create device for user 1
|
||||||
device1 := setup.CreateDevice(t, "User1 Device", "koreader", "user1-device")
|
device1 := setup.CreateDevice(t, "User1 Device", "koreader", "user1-test-device")
|
||||||
|
|
||||||
// Create a second user with different credentials
|
// Create a second user with different credentials
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -440,6 +524,7 @@ func TestRegenerateDeviceToken_Forbidden(t *testing.T) {
|
|||||||
FirstName: pgtype.Text{String: "Different", Valid: true},
|
FirstName: pgtype.Text{String: "Different", Valid: true},
|
||||||
LastName: pgtype.Text{String: "User", Valid: true},
|
LastName: pgtype.Text{String: "User", Valid: true},
|
||||||
Role: "user",
|
Role: "user",
|
||||||
|
Timezone: pgtype.Text{String: "UTC", Valid: true},
|
||||||
})
|
})
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -19,7 +20,9 @@ func TestSavedFilters(t *testing.T) {
|
|||||||
httpReq, _ := http.NewRequest("GET", setup.Server.URL+"/api/saved-filters?resource_type=media-items", nil)
|
httpReq, _ := http.NewRequest("GET", setup.Server.URL+"/api/saved-filters?resource_type=media-items", nil)
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -30,18 +33,21 @@ func TestSavedFilters(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var filters []map[string]interface{}
|
var filters []map[string]interface{}
|
||||||
json.NewDecoder(resp.Body).Decode(&filters)
|
err = json.NewDecoder(resp.Body).Decode(&filters)
|
||||||
|
require.NoError(t, err)
|
||||||
assert.Equal(t, 0, len(filters))
|
assert.Equal(t, 0, len(filters))
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("POST /api/saved-filters creates filter", func(t *testing.T) {
|
t.Run("POST /api/saved-filters creates filter", func(t *testing.T) {
|
||||||
reqBody := map[string]interface{}{
|
reqBody := map[string]interface{}{
|
||||||
"name": "My Sci-Fi Books",
|
"name": "My Sci-Fi Test Books",
|
||||||
"resource_type": "media-items",
|
"resource_type": "media-items",
|
||||||
"filters": map[string]string{
|
"filters": map[string]string{
|
||||||
"genre_filter": "Science Fiction",
|
"genre_filter": "Science Fiction",
|
||||||
@@ -56,13 +62,16 @@ func TestSavedFilters(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||||
|
|
||||||
var filter map[string]interface{}
|
var filter map[string]interface{}
|
||||||
json.NewDecoder(resp.Body).Decode(&filter)
|
err = json.NewDecoder(resp.Body).Decode(&filter)
|
||||||
assert.Equal(t, "My Sci-Fi Books", filter["name"])
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "My Sci-Fi Test Books", filter["name"])
|
||||||
assert.Equal(t, "media-items", filter["resource_type"])
|
assert.Equal(t, "media-items", filter["resource_type"])
|
||||||
assert.NotEmpty(t, filter["id"])
|
assert.NotEmpty(t, filter["id"])
|
||||||
// Filters field will be JSON object (JSONB as raw bytes)
|
// Filters field will be JSON object (JSONB as raw bytes)
|
||||||
@@ -84,7 +93,7 @@ func TestSavedFilters(t *testing.T) {
|
|||||||
|
|
||||||
resp1, err := client.Do(httpReq1)
|
resp1, err := client.Do(httpReq1)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
resp1.Body.Close()
|
_ = resp1.Body.Close()
|
||||||
|
|
||||||
assert.Equal(t, http.StatusCreated, resp1.StatusCode)
|
assert.Equal(t, http.StatusCreated, resp1.StatusCode)
|
||||||
|
|
||||||
@@ -96,7 +105,7 @@ func TestSavedFilters(t *testing.T) {
|
|||||||
|
|
||||||
resp2, err := client.Do(httpReq2)
|
resp2, err := client.Do(httpReq2)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
resp2.Body.Close()
|
_ = resp2.Body.Close()
|
||||||
|
|
||||||
assert.Equal(t, http.StatusConflict, resp2.StatusCode)
|
assert.Equal(t, http.StatusConflict, resp2.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -104,7 +113,7 @@ func TestSavedFilters(t *testing.T) {
|
|||||||
t.Run("PUT /api/saved-filters/:id updates filter", func(t *testing.T) {
|
t.Run("PUT /api/saved-filters/:id updates filter", func(t *testing.T) {
|
||||||
// Create filter first
|
// Create filter first
|
||||||
createReq := map[string]interface{}{
|
createReq := map[string]interface{}{
|
||||||
"name": "Original Name",
|
"name": "Original Test Name",
|
||||||
"resource_type": "media-items",
|
"resource_type": "media-items",
|
||||||
"filters": map[string]string{"search": "original"},
|
"filters": map[string]string{"search": "original"},
|
||||||
}
|
}
|
||||||
@@ -116,17 +125,22 @@ func TestSavedFilters(t *testing.T) {
|
|||||||
|
|
||||||
createResp, err := client.Do(createHTTP)
|
createResp, err := client.Do(createHTTP)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer createResp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(createResp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusCreated, createResp.StatusCode)
|
assert.Equal(t, http.StatusCreated, createResp.StatusCode)
|
||||||
|
|
||||||
var createdFilter map[string]interface{}
|
var createdFilter map[string]interface{}
|
||||||
json.NewDecoder(createResp.Body).Decode(&createdFilter)
|
err = json.NewDecoder(createResp.Body).Decode(&createdFilter)
|
||||||
filterID := createdFilter["id"].(string)
|
require.NoError(t, err)
|
||||||
|
filterIDRaw, ok := createdFilter["id"]
|
||||||
|
require.True(t, ok, "create filter response should carry id: %v", createdFilter)
|
||||||
|
filterID := filterIDRaw.(string)
|
||||||
|
|
||||||
// Update filter
|
// Update filter
|
||||||
updateReq := map[string]interface{}{
|
updateReq := map[string]interface{}{
|
||||||
"name": "Updated Name",
|
"name": "Updated Test Name",
|
||||||
"resource_type": "media-items",
|
"resource_type": "media-items",
|
||||||
"filters": map[string]string{"search": "updated"},
|
"filters": map[string]string{"search": "updated"},
|
||||||
}
|
}
|
||||||
@@ -138,13 +152,16 @@ func TestSavedFilters(t *testing.T) {
|
|||||||
|
|
||||||
updateResp, err := client.Do(updateHTTP)
|
updateResp, err := client.Do(updateHTTP)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer updateResp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(updateResp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, updateResp.StatusCode)
|
assert.Equal(t, http.StatusOK, updateResp.StatusCode)
|
||||||
|
|
||||||
var updatedFilter map[string]interface{}
|
var updatedFilter map[string]interface{}
|
||||||
json.NewDecoder(updateResp.Body).Decode(&updatedFilter)
|
err = json.NewDecoder(updateResp.Body).Decode(&updatedFilter)
|
||||||
assert.Equal(t, "Updated Name", updatedFilter["name"])
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "Updated Test Name", updatedFilter["name"])
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("DELETE /api/saved-filters/:id deletes filter", func(t *testing.T) {
|
t.Run("DELETE /api/saved-filters/:id deletes filter", func(t *testing.T) {
|
||||||
@@ -162,11 +179,16 @@ func TestSavedFilters(t *testing.T) {
|
|||||||
|
|
||||||
createResp, err := client.Do(createHTTP)
|
createResp, err := client.Do(createHTTP)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer createResp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(createResp.Body)
|
||||||
|
|
||||||
var createdFilter map[string]interface{}
|
var createdFilter map[string]interface{}
|
||||||
json.NewDecoder(createResp.Body).Decode(&createdFilter)
|
err = json.NewDecoder(createResp.Body).Decode(&createdFilter)
|
||||||
filterID := createdFilter["id"].(string)
|
require.NoError(t, err)
|
||||||
|
filterIDRaw, ok := createdFilter["id"]
|
||||||
|
require.True(t, ok, "create filter response should carry id: %v", createdFilter)
|
||||||
|
filterID := filterIDRaw.(string)
|
||||||
|
|
||||||
// Delete filter
|
// Delete filter
|
||||||
deleteHTTP, _ := http.NewRequest("DELETE", setup.Server.URL+"/api/saved-filters/"+filterID, nil)
|
deleteHTTP, _ := http.NewRequest("DELETE", setup.Server.URL+"/api/saved-filters/"+filterID, nil)
|
||||||
@@ -174,7 +196,7 @@ func TestSavedFilters(t *testing.T) {
|
|||||||
|
|
||||||
deleteResp, err := client.Do(deleteHTTP)
|
deleteResp, err := client.Do(deleteHTTP)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
deleteResp.Body.Close()
|
_ = deleteResp.Body.Close()
|
||||||
|
|
||||||
assert.Equal(t, http.StatusNoContent, deleteResp.StatusCode)
|
assert.Equal(t, http.StatusNoContent, deleteResp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -198,11 +220,16 @@ func TestSavedFilters(t *testing.T) {
|
|||||||
|
|
||||||
createResp, err := client.Do(createHTTP)
|
createResp, err := client.Do(createHTTP)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer createResp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(createResp.Body)
|
||||||
|
|
||||||
var createdFilter map[string]interface{}
|
var createdFilter map[string]interface{}
|
||||||
json.NewDecoder(createResp.Body).Decode(&createdFilter)
|
err = json.NewDecoder(createResp.Body).Decode(&createdFilter)
|
||||||
filterID := createdFilter["id"].(string)
|
require.NoError(t, err)
|
||||||
|
filterIDRaw, ok := createdFilter["id"]
|
||||||
|
require.True(t, ok, "create filter response should carry id: %v", createdFilter)
|
||||||
|
filterID := filterIDRaw.(string)
|
||||||
|
|
||||||
// Admin user tries to delete regular user's filter
|
// Admin user tries to delete regular user's filter
|
||||||
deleteHTTP, _ := http.NewRequest("DELETE", setup.Server.URL+"/api/saved-filters/"+filterID, nil)
|
deleteHTTP, _ := http.NewRequest("DELETE", setup.Server.URL+"/api/saved-filters/"+filterID, nil)
|
||||||
@@ -210,7 +237,7 @@ func TestSavedFilters(t *testing.T) {
|
|||||||
|
|
||||||
deleteResp, err := client.Do(deleteHTTP)
|
deleteResp, err := client.Do(deleteHTTP)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
deleteResp.Body.Close()
|
_ = deleteResp.Body.Close()
|
||||||
|
|
||||||
assert.Equal(t, http.StatusNotFound, deleteResp.StatusCode)
|
assert.Equal(t, http.StatusNotFound, deleteResp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -230,13 +257,18 @@ func TestSavedFilters(t *testing.T) {
|
|||||||
|
|
||||||
createResp, err := client.Do(createReq)
|
createResp, err := client.Do(createReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer createResp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(createResp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusCreated, createResp.StatusCode)
|
assert.Equal(t, http.StatusCreated, createResp.StatusCode)
|
||||||
|
|
||||||
var createdFilter map[string]interface{}
|
var createdFilter map[string]interface{}
|
||||||
json.NewDecoder(createResp.Body).Decode(&createdFilter)
|
err = json.NewDecoder(createResp.Body).Decode(&createdFilter)
|
||||||
filterID := createdFilter["id"].(string)
|
require.NoError(t, err)
|
||||||
|
filterIDRaw, ok := createdFilter["id"]
|
||||||
|
require.True(t, ok, "create filter response should carry id: %v", createdFilter)
|
||||||
|
filterID := filterIDRaw.(string)
|
||||||
|
|
||||||
// Now retrieve the filter by ID
|
// Now retrieve the filter by ID
|
||||||
getReq, _ := http.NewRequest("GET", setup.Server.URL+"/api/saved-filters/"+filterID, nil)
|
getReq, _ := http.NewRequest("GET", setup.Server.URL+"/api/saved-filters/"+filterID, nil)
|
||||||
@@ -244,12 +276,15 @@ func TestSavedFilters(t *testing.T) {
|
|||||||
|
|
||||||
getResp, err := client.Do(getReq)
|
getResp, err := client.Do(getReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer getResp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(getResp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, getResp.StatusCode)
|
assert.Equal(t, http.StatusOK, getResp.StatusCode)
|
||||||
|
|
||||||
var retrievedFilter map[string]interface{}
|
var retrievedFilter map[string]interface{}
|
||||||
json.NewDecoder(getResp.Body).Decode(&retrievedFilter)
|
err = json.NewDecoder(getResp.Body).Decode(&retrievedFilter)
|
||||||
|
require.NoError(t, err)
|
||||||
assert.Equal(t, "Test Filter", retrievedFilter["name"])
|
assert.Equal(t, "Test Filter", retrievedFilter["name"])
|
||||||
assert.Equal(t, "media-items", retrievedFilter["resource_type"])
|
assert.Equal(t, "media-items", retrievedFilter["resource_type"])
|
||||||
assert.Equal(t, filterID, retrievedFilter["id"])
|
assert.Equal(t, filterID, retrievedFilter["id"])
|
||||||
@@ -260,7 +295,9 @@ func TestSavedFilters(t *testing.T) {
|
|||||||
httpReq, _ := http.NewRequest("GET", setup.Server.URL+"/api/saved-filters/550e8400-e29b-41d4-a716-446655440000", nil)
|
httpReq, _ := http.NewRequest("GET", setup.Server.URL+"/api/saved-filters/550e8400-e29b-41d4-a716-446655440000", nil)
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -271,7 +308,9 @@ func TestSavedFilters(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -283,7 +322,9 @@ func TestSavedFilters(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
|
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -291,7 +332,7 @@ func TestSavedFilters(t *testing.T) {
|
|||||||
t.Run("GET /api/saved-filters/:id from different user returns 404", func(t *testing.T) {
|
t.Run("GET /api/saved-filters/:id from different user returns 404", func(t *testing.T) {
|
||||||
// Create filter with admin user (setup.Token)
|
// Create filter with admin user (setup.Token)
|
||||||
reqBody := map[string]interface{}{
|
reqBody := map[string]interface{}{
|
||||||
"name": "Admin Filter",
|
"name": "Admin Test Filter",
|
||||||
"resource_type": "media-items",
|
"resource_type": "media-items",
|
||||||
"filters": map[string]string{"search": "test"},
|
"filters": map[string]string{"search": "test"},
|
||||||
}
|
}
|
||||||
@@ -301,16 +342,23 @@ func TestSavedFilters(t *testing.T) {
|
|||||||
createReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
createReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
createResp, err := client.Do(createReq)
|
createResp, err := client.Do(createReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer createResp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(createResp.Body)
|
||||||
var createdFilter map[string]interface{}
|
var createdFilter map[string]interface{}
|
||||||
json.NewDecoder(createResp.Body).Decode(&createdFilter)
|
err = json.NewDecoder(createResp.Body).Decode(&createdFilter)
|
||||||
filterID := createdFilter["id"].(string)
|
require.NoError(t, err)
|
||||||
|
filterIDRaw, ok := createdFilter["id"]
|
||||||
|
require.True(t, ok, "create filter response should carry id: %v", createdFilter)
|
||||||
|
filterID := filterIDRaw.(string)
|
||||||
// Try to access with regular user (setup.RegularToken)
|
// Try to access with regular user (setup.RegularToken)
|
||||||
httpReq, _ := http.NewRequest("GET", setup.Server.URL+"/api/saved-filters/"+filterID, nil)
|
httpReq, _ := http.NewRequest("GET", setup.Server.URL+"/api/saved-filters/"+filterID, nil)
|
||||||
httpReq.Header.Set("Authorization", "Bearer "+setup.RegularToken)
|
httpReq.Header.Set("Authorization", "Bearer "+setup.RegularToken)
|
||||||
getResp, err := client.Do(httpReq)
|
getResp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer getResp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(getResp.Body)
|
||||||
// Should return 404 (not 403 - hide existence)
|
// Should return 404 (not 403 - hide existence)
|
||||||
assert.Equal(t, http.StatusNotFound, getResp.StatusCode)
|
assert.Equal(t, http.StatusNotFound, getResp.StatusCode)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -31,10 +32,13 @@ func TestFSNotify_BulkFileDetection(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
libResp, err := client.Do(libReq)
|
libResp, err := client.Do(libReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer libResp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(libResp.Body)
|
||||||
require.Equal(t, http.StatusCreated, libResp.StatusCode)
|
require.Equal(t, http.StatusCreated, libResp.StatusCode)
|
||||||
var libResult map[string]interface{}
|
var libResult map[string]interface{}
|
||||||
json.NewDecoder(libResp.Body).Decode(&libResult)
|
err = json.NewDecoder(libResp.Body).Decode(&libResult)
|
||||||
|
require.NoError(t, err)
|
||||||
libraryID := libResult["id"].(string)
|
libraryID := libResult["id"].(string)
|
||||||
// Add folder to library
|
// Add folder to library
|
||||||
folderURL := fmt.Sprintf("%s/api/libraries/%s/folders", setup.Server.URL, libraryID)
|
folderURL := fmt.Sprintf("%s/api/libraries/%s/folders", setup.Server.URL, libraryID)
|
||||||
@@ -47,7 +51,9 @@ func TestFSNotify_BulkFileDetection(t *testing.T) {
|
|||||||
folderHTTPReq.Header.Set("Authorization", "Bearer "+token)
|
folderHTTPReq.Header.Set("Authorization", "Bearer "+token)
|
||||||
folderResp, err := client.Do(folderHTTPReq)
|
folderResp, err := client.Do(folderHTTPReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer folderResp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(folderResp.Body)
|
||||||
require.Equal(t, http.StatusCreated, folderResp.StatusCode, "Folder should be added to library")
|
require.Equal(t, http.StatusCreated, folderResp.StatusCode, "Folder should be added to library")
|
||||||
// Create 20 test files simultaneously
|
// Create 20 test files simultaneously
|
||||||
for i := 0; i < 20; i++ {
|
for i := 0; i < 20; i++ {
|
||||||
@@ -61,10 +67,13 @@ func TestFSNotify_BulkFileDetection(t *testing.T) {
|
|||||||
scanHTTPReq.Header.Set("Authorization", "Bearer "+token)
|
scanHTTPReq.Header.Set("Authorization", "Bearer "+token)
|
||||||
scanResp, err := client.Do(scanHTTPReq)
|
scanResp, err := client.Do(scanHTTPReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer scanResp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(scanResp.Body)
|
||||||
require.Equal(t, http.StatusAccepted, scanResp.StatusCode, "Scan should be accepted")
|
require.Equal(t, http.StatusAccepted, scanResp.StatusCode, "Scan should be accepted")
|
||||||
var scanResponse map[string]interface{}
|
var scanResponse map[string]interface{}
|
||||||
json.NewDecoder(scanResp.Body).Decode(&scanResponse)
|
err = json.NewDecoder(scanResp.Body).Decode(&scanResponse)
|
||||||
|
require.NoError(t, err)
|
||||||
jobID, ok := scanResponse["job_id"].(string)
|
jobID, ok := scanResponse["job_id"].(string)
|
||||||
require.True(t, ok, "job_id should be string")
|
require.True(t, ok, "job_id should be string")
|
||||||
require.NotEmpty(t, jobID, "job_id should not be empty")
|
require.NotEmpty(t, jobID, "job_id should not be empty")
|
||||||
@@ -81,16 +90,17 @@ func TestFSNotify_BulkFileDetection(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
if statusResp.StatusCode == http.StatusNotFound {
|
if statusResp.StatusCode == http.StatusNotFound {
|
||||||
statusResp.Body.Close()
|
_ = statusResp.Body.Close()
|
||||||
break // Job completed
|
break // Job completed
|
||||||
}
|
}
|
||||||
|
|
||||||
var status map[string]interface{}
|
var status map[string]interface{}
|
||||||
json.NewDecoder(statusResp.Body).Decode(&status)
|
err = json.NewDecoder(statusResp.Body).Decode(&status)
|
||||||
statusResp.Body.Close()
|
require.NoError(t, err)
|
||||||
|
_ = statusResp.Body.Close()
|
||||||
|
|
||||||
if status["status"] == "completed" || status["status"] == "failed" {
|
if status["status"] == "completed" || status["status"] == "failed" {
|
||||||
statusResp.Body.Close()
|
_ = statusResp.Body.Close()
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -99,9 +109,12 @@ func TestFSNotify_BulkFileDetection(t *testing.T) {
|
|||||||
req.Header.Set("Authorization", "Bearer "+token)
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
itemsResp, err := client.Do(req)
|
itemsResp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer itemsResp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(itemsResp.Body)
|
||||||
var itemsResult map[string]interface{}
|
var itemsResult map[string]interface{}
|
||||||
json.NewDecoder(itemsResp.Body).Decode(&itemsResult)
|
err = json.NewDecoder(itemsResp.Body).Decode(&itemsResult)
|
||||||
|
require.NoError(t, err)
|
||||||
items, ok := itemsResult["data"].([]interface{})
|
items, ok := itemsResult["data"].([]interface{})
|
||||||
if !ok || items == nil {
|
if !ok || items == nil {
|
||||||
items = []interface{}{} // Handle nil or wrong type
|
items = []interface{}{} // Handle nil or wrong type
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -33,7 +34,9 @@ func TestJobsHandler_CreateJob(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
require.Equal(t, http.StatusAccepted, resp.StatusCode)
|
require.Equal(t, http.StatusAccepted, resp.StatusCode)
|
||||||
|
|
||||||
@@ -65,7 +68,9 @@ func TestJobsHandler_CreateJob_InvalidType(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
}
|
}
|
||||||
@@ -88,7 +93,9 @@ func TestJobsHandler_GetJobStatus(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
createResp, err := client.Do(createReq)
|
createResp, err := client.Do(createReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer createResp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(createResp.Body)
|
||||||
|
|
||||||
var createResponse map[string]interface{}
|
var createResponse map[string]interface{}
|
||||||
err = json.NewDecoder(createResp.Body).Decode(&createResponse)
|
err = json.NewDecoder(createResp.Body).Decode(&createResponse)
|
||||||
@@ -103,7 +110,9 @@ func TestJobsHandler_GetJobStatus(t *testing.T) {
|
|||||||
|
|
||||||
getResp, err := client.Do(getReq)
|
getResp, err := client.Do(getReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer getResp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(getResp.Body)
|
||||||
|
|
||||||
require.Equal(t, http.StatusOK, getResp.StatusCode)
|
require.Equal(t, http.StatusOK, getResp.StatusCode)
|
||||||
|
|
||||||
@@ -128,7 +137,9 @@ func TestJobsHandler_GetJobStatus_NotFound(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
require.Equal(t, http.StatusNotFound, resp.StatusCode)
|
require.Equal(t, http.StatusNotFound, resp.StatusCode)
|
||||||
}
|
}
|
||||||
@@ -151,7 +162,9 @@ func TestJobsHandler_CreateAndTrackJob(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
createResp, err := client.Do(createReq)
|
createResp, err := client.Do(createReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer createResp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(createResp.Body)
|
||||||
|
|
||||||
var createResponse map[string]interface{}
|
var createResponse map[string]interface{}
|
||||||
err = json.NewDecoder(createResp.Body).Decode(&createResponse)
|
err = json.NewDecoder(createResp.Body).Decode(&createResponse)
|
||||||
@@ -172,7 +185,7 @@ func TestJobsHandler_CreateAndTrackJob(t *testing.T) {
|
|||||||
|
|
||||||
var statusResponse map[string]interface{}
|
var statusResponse map[string]interface{}
|
||||||
err = json.NewDecoder(getResp.Body).Decode(&statusResponse)
|
err = json.NewDecoder(getResp.Body).Decode(&statusResponse)
|
||||||
getResp.Body.Close()
|
_ = getResp.Body.Close()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
if statusResponse["status"] != nil {
|
if statusResponse["status"] != nil {
|
||||||
@@ -202,7 +215,9 @@ func TestJobsHandler_CreateJob_Unauthorized(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
require.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
require.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||||
}
|
}
|
||||||
@@ -218,7 +233,9 @@ func TestJobsHandler_GetJobStatus_Unauthorized(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
require.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
require.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||||
}
|
}
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -37,7 +38,9 @@ func TestKoboInitialization(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -65,7 +68,9 @@ func TestKoboLibrarySync(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -129,11 +134,14 @@ func TestKoboMarkupSync(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
var result map[string]interface{}
|
var result map[string]interface{}
|
||||||
json.NewDecoder(resp.Body).Decode(&result)
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
assert.Contains(t, result, "Status")
|
assert.Contains(t, result, "Status")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -182,11 +190,14 @@ func TestKoboBookmarkSync(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
var result map[string]interface{}
|
var result map[string]interface{}
|
||||||
json.NewDecoder(resp.Body).Decode(&result)
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
assert.Contains(t, result, "Status")
|
assert.Contains(t, result, "Status")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -224,11 +235,14 @@ func TestKoboAnalyticsGettests(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
var result map[string]interface{}
|
var result map[string]interface{}
|
||||||
json.NewDecoder(resp.Body).Decode(&result)
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
assert.Contains(t, result, "Status")
|
assert.Contains(t, result, "Status")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -28,7 +30,21 @@ func TestBrowseLibraryFoldersEndpoint(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("GET /api/libraries/browse - admin can browse directories", func(t *testing.T) {
|
t.Run("GET /api/libraries/browse - admin can browse directories", func(t *testing.T) {
|
||||||
req := httptest.NewRequest("GET", "/api/libraries/browse?path=/tmp", nil)
|
// Self-created fixture dir: the container's /tmp may be empty, and
|
||||||
|
// the endpoint lists DIRECTORIES only — seed two so the listing is
|
||||||
|
// deterministic regardless of environment.
|
||||||
|
root := t.TempDir()
|
||||||
|
for _, name := range []string{"alpha", "beta"} {
|
||||||
|
if err := os.Mkdir(filepath.Join(root, name), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A decoy file must NOT appear in the directory listing.
|
||||||
|
if err := os.WriteFile(filepath.Join(root, "stray.txt"), []byte("x"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/api/libraries/browse?path="+root, nil)
|
||||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
setup.Server.Config.Handler.ServeHTTP(rec, req)
|
setup.Server.Config.Handler.ServeHTTP(rec, req)
|
||||||
@@ -41,8 +57,8 @@ func TestBrowseLibraryFoldersEndpoint(t *testing.T) {
|
|||||||
}
|
}
|
||||||
err := json.Unmarshal(rec.Body.Bytes(), &data)
|
err := json.Unmarshal(rec.Body.Bytes(), &data)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, "/tmp", data.CurrentPath)
|
assert.Equal(t, filepath.Clean(root), data.CurrentPath)
|
||||||
assert.NotEmpty(t, data.Directories)
|
assert.ElementsMatch(t, []string{"alpha", "beta"}, data.Directories)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("GET /api/libraries/browse - blocks path traversal attempts", func(t *testing.T) {
|
t.Run("GET /api/libraries/browse - blocks path traversal attempts", func(t *testing.T) {
|
||||||
|
|||||||
@@ -182,13 +182,13 @@ func TestLibraryTypesResponse(t *testing.T) {
|
|||||||
"id": "test-id-2",
|
"id": "test-id-2",
|
||||||
"name": "comics",
|
"name": "comics",
|
||||||
"description": "Comic book archives and image formats",
|
"description": "Comic book archives and image formats",
|
||||||
"allowed_extensions": []string{".cbz", ".cbr", ".cb7", ".cbt", ".pdf"},
|
"allowed_extensions": []string{".cbz", ".cbr", ".cb7", ".cbt", ".epub", ".pdf"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "test-id-3",
|
"id": "test-id-3",
|
||||||
"name": "manga",
|
"name": "manga",
|
||||||
"description": "Manga files including archives and image folders",
|
"description": "Manga files including archives and image folders",
|
||||||
"allowed_extensions": []string{".cbz", ".cbr", ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp"},
|
"allowed_extensions": []string{".cbz", ".cbr", ".epub", ".pdf", ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".avif", ".tiff", ".tif"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -556,7 +556,7 @@ func TestLibraryTypes(t *testing.T) {
|
|||||||
"id": uuid.New().String(),
|
"id": uuid.New().String(),
|
||||||
"name": "comics",
|
"name": "comics",
|
||||||
"description": "Comic book archives and image formats",
|
"description": "Comic book archives and image formats",
|
||||||
"allowed_extensions": []string{".cbz", ".cbr", ".pdf"},
|
"allowed_extensions": []string{".cbz", ".cbr", ".cb7", ".cbt", ".epub", ".pdf"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -31,7 +32,9 @@ func TestMediaBulkOperations(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||||
|
|
||||||
@@ -58,7 +61,9 @@ func TestMediaBulkOperations(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -76,12 +81,15 @@ func TestMediaBulkOperations(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var result map[string]interface{}
|
var result map[string]interface{}
|
||||||
json.NewDecoder(resp.Body).Decode(&result)
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
assert.Contains(t, result, "results")
|
assert.Contains(t, result, "results")
|
||||||
assert.Contains(t, result, "total")
|
assert.Contains(t, result, "total")
|
||||||
@@ -106,12 +114,15 @@ func TestMediaBulkOperations(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var result map[string]interface{}
|
var result map[string]interface{}
|
||||||
json.NewDecoder(resp.Body).Decode(&result)
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
assert.Contains(t, result, "results")
|
assert.Contains(t, result, "results")
|
||||||
assert.Equal(t, 3.0, result["total"])
|
assert.Equal(t, 3.0, result["total"])
|
||||||
@@ -130,7 +141,9 @@ func TestMediaBulkOperations(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -154,7 +167,9 @@ func TestMediaBulkOperations(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -179,7 +194,9 @@ func TestMediaBulkOperations(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -204,12 +221,15 @@ func TestMediaBulkOperations(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var result map[string]interface{}
|
var result map[string]interface{}
|
||||||
json.NewDecoder(resp.Body).Decode(&result)
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
assert.Contains(t, result, "results")
|
assert.Contains(t, result, "results")
|
||||||
assert.Contains(t, result, "total")
|
assert.Contains(t, result, "total")
|
||||||
@@ -246,12 +266,15 @@ func TestMediaBulkOperations(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var result map[string]interface{}
|
var result map[string]interface{}
|
||||||
json.NewDecoder(resp.Body).Decode(&result)
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
assert.Contains(t, result, "results")
|
assert.Contains(t, result, "results")
|
||||||
assert.Equal(t, 2.0, result["total"])
|
assert.Equal(t, 2.0, result["total"])
|
||||||
@@ -304,12 +327,15 @@ func TestMediaBulkOperations(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var result map[string]interface{}
|
var result map[string]interface{}
|
||||||
json.NewDecoder(resp.Body).Decode(&result)
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
assert.Contains(t, result, "results")
|
assert.Contains(t, result, "results")
|
||||||
assert.Contains(t, result, "total")
|
assert.Contains(t, result, "total")
|
||||||
@@ -352,12 +378,15 @@ func TestMediaBulkOperations(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var result map[string]interface{}
|
var result map[string]interface{}
|
||||||
json.NewDecoder(resp.Body).Decode(&result)
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
assert.Contains(t, result, "results")
|
assert.Contains(t, result, "results")
|
||||||
})
|
})
|
||||||
@@ -371,7 +400,9 @@ func TestMediaBulkOperations(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -31,12 +33,15 @@ func createTestLibrary(t *testing.T, ts *httptest.Server, token, name string) st
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||||
|
|
||||||
var result map[string]interface{}
|
var result map[string]interface{}
|
||||||
json.NewDecoder(resp.Body).Decode(&result)
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
return result["id"].(string)
|
return result["id"].(string)
|
||||||
}
|
}
|
||||||
@@ -147,11 +152,15 @@ func TestMediaItemISBNNormalization(t *testing.T) {
|
|||||||
|
|
||||||
for _, tc := range testCases {
|
for _, tc := range testCases {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
// Unique file_path per case: CreateMediaItem upserts ON
|
||||||
|
// CONFLICT (library_id, file_path), so a constant path would
|
||||||
|
// return the FIRST row's ISBN for every later case.
|
||||||
|
slug := strings.ReplaceAll(strings.ToLower(tc.name), " ", "-")
|
||||||
payload := map[string]interface{}{
|
payload := map[string]interface{}{
|
||||||
"title": fmt.Sprintf("Test Book %s", tc.name),
|
"title": fmt.Sprintf("Test Book %s", tc.name),
|
||||||
"isbn": tc.input,
|
"isbn": tc.input,
|
||||||
"library_id": libID,
|
"library_id": libID,
|
||||||
"file_path": "/test/path.epub",
|
"file_path": "/test/" + slug + ".epub",
|
||||||
"file_size": 1024,
|
"file_size": 1024,
|
||||||
"mime_type": "application/epub+zip",
|
"mime_type": "application/epub+zip",
|
||||||
}
|
}
|
||||||
@@ -164,7 +173,9 @@ func TestMediaItemISBNNormalization(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
// Check if this is an invalid ISBN case that should return 422
|
// Check if this is an invalid ISBN case that should return 422
|
||||||
if tc.expected == "" && (tc.input == "---" || tc.input == " ") {
|
if tc.expected == "" && (tc.input == "---" || tc.input == " ") {
|
||||||
@@ -174,7 +185,8 @@ func TestMediaItemISBNNormalization(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var response map[string]interface{}
|
var response map[string]interface{}
|
||||||
json.NewDecoder(resp.Body).Decode(&response)
|
err = json.NewDecoder(resp.Body).Decode(&response)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
// For valid ISBN responses, verify normalization worked correctly
|
// For valid ISBN responses, verify normalization worked correctly
|
||||||
if resp.StatusCode == http.StatusCreated {
|
if resp.StatusCode == http.StatusCreated {
|
||||||
@@ -196,7 +208,7 @@ func TestMediaItemISBNEdgeCases(t *testing.T) {
|
|||||||
payload := map[string]interface{}{
|
payload := map[string]interface{}{
|
||||||
"title": "Book without ISBN",
|
"title": "Book without ISBN",
|
||||||
"library_id": libID,
|
"library_id": libID,
|
||||||
"file_path": "/test/path.epub",
|
"file_path": "/test/book-without-isbn.epub",
|
||||||
"file_size": 1024,
|
"file_size": 1024,
|
||||||
"mime_type": "application/epub+zip",
|
"mime_type": "application/epub+zip",
|
||||||
}
|
}
|
||||||
@@ -209,7 +221,9 @@ func TestMediaItemISBNEdgeCases(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -219,7 +233,7 @@ func TestMediaItemISBNEdgeCases(t *testing.T) {
|
|||||||
"title": "Multi-Hyphen ISBN",
|
"title": "Multi-Hyphen ISBN",
|
||||||
"isbn": "978-0-306-40615-7",
|
"isbn": "978-0-306-40615-7",
|
||||||
"library_id": libID,
|
"library_id": libID,
|
||||||
"file_path": "/test/path.epub",
|
"file_path": "/test/multi-hyphen-isbn.epub",
|
||||||
"file_size": 1024,
|
"file_size": 1024,
|
||||||
"mime_type": "application/epub+zip",
|
"mime_type": "application/epub+zip",
|
||||||
}
|
}
|
||||||
@@ -232,10 +246,13 @@ func TestMediaItemISBNEdgeCases(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
var response map[string]interface{}
|
var response map[string]interface{}
|
||||||
json.NewDecoder(resp.Body).Decode(&response)
|
err = json.NewDecoder(resp.Body).Decode(&response)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||||
assert.Equal(t, "9780306406157", response["isbn"])
|
assert.Equal(t, "9780306406157", response["isbn"])
|
||||||
@@ -246,7 +263,7 @@ func TestMediaItemISBNEdgeCases(t *testing.T) {
|
|||||||
"title": "Trailing Hyphen ISBN",
|
"title": "Trailing Hyphen ISBN",
|
||||||
"isbn": "978-0-596-00965-2",
|
"isbn": "978-0-596-00965-2",
|
||||||
"library_id": libID,
|
"library_id": libID,
|
||||||
"file_path": "/test/path.epub",
|
"file_path": "/test/trailing-hyphen-isbn.epub",
|
||||||
"file_size": 1024,
|
"file_size": 1024,
|
||||||
"mime_type": "application/epub+zip",
|
"mime_type": "application/epub+zip",
|
||||||
}
|
}
|
||||||
@@ -259,10 +276,13 @@ func TestMediaItemISBNEdgeCases(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
var response map[string]interface{}
|
var response map[string]interface{}
|
||||||
json.NewDecoder(resp.Body).Decode(&response)
|
err = json.NewDecoder(resp.Body).Decode(&response)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||||
assert.Equal(t, "9780596009652", response["isbn"])
|
assert.Equal(t, "9780596009652", response["isbn"])
|
||||||
@@ -277,13 +297,15 @@ func TestMediaItemsPagination(t *testing.T) {
|
|||||||
libID := createTestLibrary(t, setup.Server, token, "test-pagination-lib")
|
libID := createTestLibrary(t, setup.Server, token, "test-pagination-lib")
|
||||||
addFolderToLibrary(t, setup, libID, "/app/uploads")
|
addFolderToLibrary(t, setup, libID, "/app/uploads")
|
||||||
|
|
||||||
// Create some test media items
|
// Create some test media items. Unique file_path per item: the create
|
||||||
|
// endpoint upserts ON CONFLICT (library_id, file_path), so a constant
|
||||||
|
// path would collapse all five creates onto one row.
|
||||||
for i := 1; i <= 5; i++ {
|
for i := 1; i <= 5; i++ {
|
||||||
payload := map[string]interface{}{
|
payload := map[string]interface{}{
|
||||||
"title": fmt.Sprintf("Book %d", i),
|
"title": fmt.Sprintf("Book %d", i),
|
||||||
"isbn": fmt.Sprintf("978030640615%d", i),
|
"isbn": fmt.Sprintf("978030640615%d", i),
|
||||||
"library_id": libID,
|
"library_id": libID,
|
||||||
"file_path": "/test/path.epub",
|
"file_path": fmt.Sprintf("/test/book-%d.epub", i),
|
||||||
"file_size": 1024,
|
"file_size": 1024,
|
||||||
"mime_type": "application/epub+zip",
|
"mime_type": "application/epub+zip",
|
||||||
}
|
}
|
||||||
@@ -296,7 +318,7 @@ func TestMediaItemsPagination(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
resp.Body.Close()
|
_ = resp.Body.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Small delay to allow database to commit before pagination queries
|
// Small delay to allow database to commit before pagination queries
|
||||||
@@ -309,12 +331,15 @@ func TestMediaItemsPagination(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var response map[string]interface{}
|
var response map[string]interface{}
|
||||||
json.NewDecoder(resp.Body).Decode(&response)
|
err = json.NewDecoder(resp.Body).Decode(&response)
|
||||||
|
require.NoError(t, err)
|
||||||
data := response["data"].([]interface{})
|
data := response["data"].([]interface{})
|
||||||
|
|
||||||
// Should get 2 items
|
// Should get 2 items
|
||||||
@@ -328,12 +353,15 @@ func TestMediaItemsPagination(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var response map[string]interface{}
|
var response map[string]interface{}
|
||||||
json.NewDecoder(resp.Body).Decode(&response)
|
err = json.NewDecoder(resp.Body).Decode(&response)
|
||||||
|
require.NoError(t, err)
|
||||||
data := response["data"].([]interface{})
|
data := response["data"].([]interface{})
|
||||||
|
|
||||||
// Should get 2 items starting from offset 2
|
// Should get 2 items starting from offset 2
|
||||||
@@ -347,7 +375,9 @@ func TestMediaItemsPagination(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
// Should return 400 Bad Request or handle it gracefully
|
// Should return 400 Bad Request or handle it gracefully
|
||||||
assert.NotEqual(t, http.StatusOK, resp.StatusCode)
|
assert.NotEqual(t, http.StatusOK, resp.StatusCode)
|
||||||
@@ -360,7 +390,9 @@ func TestMediaItemsPagination(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
// Should return 400 Bad Request or handle it gracefully
|
// Should return 400 Bad Request or handle it gracefully
|
||||||
assert.NotEqual(t, http.StatusOK, resp.StatusCode)
|
assert.NotEqual(t, http.StatusOK, resp.StatusCode)
|
||||||
@@ -373,7 +405,9 @@ func TestMediaItemsPagination(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
// Should be capped at maximum or return error
|
// Should be capped at maximum or return error
|
||||||
// The application uses maxPaginationLimit = 1000
|
// The application uses maxPaginationLimit = 1000
|
||||||
@@ -381,6 +415,62 @@ func TestMediaItemsPagination(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestMediaItemCreateDuplicatePath pins the create endpoint's duplicate
|
||||||
|
// behavior: a POST with a (library_id, file_path) that already exists
|
||||||
|
// answers 200 OK with the EXISTING item and duplicate=true — never a
|
||||||
|
// 201 with the old row pretending to be new, and nothing overwritten.
|
||||||
|
func TestMediaItemCreateDuplicatePath(t *testing.T) {
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
|
||||||
|
token := setup.Token
|
||||||
|
libID := createTestLibrary(t, setup.Server, token, "test-dup-create-lib")
|
||||||
|
addFolderToLibrary(t, setup, libID, "/app/uploads")
|
||||||
|
|
||||||
|
createPayload := func(title string) []byte {
|
||||||
|
body, _ := json.Marshal(map[string]interface{}{
|
||||||
|
"title": title,
|
||||||
|
"author": "First Author",
|
||||||
|
"isbn": "9780306406157",
|
||||||
|
"library_id": libID,
|
||||||
|
"file_path": "/test/dup-target.epub",
|
||||||
|
"file_size": 1024,
|
||||||
|
"mime_type": "application/epub+zip",
|
||||||
|
})
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
post := func(body []byte) (*http.Response, []byte) {
|
||||||
|
req, _ := http.NewRequest("POST", setup.Server.URL+"/api/media-items", bytes.NewBuffer(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
resp, err := (&http.Client{}).Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
raw, err := io.ReadAll(resp.Body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return resp, raw
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, raw := post(createPayload("Original Title"))
|
||||||
|
require.Equal(t, http.StatusCreated, resp.StatusCode, "first create should be 201")
|
||||||
|
var created map[string]interface{}
|
||||||
|
require.NoError(t, json.Unmarshal(raw, &created))
|
||||||
|
originalID := created["id"].(string)
|
||||||
|
|
||||||
|
resp, raw = post(createPayload("Different Title"))
|
||||||
|
require.Equal(t, http.StatusOK, resp.StatusCode, "duplicate create should be 200, not a 201 lie")
|
||||||
|
|
||||||
|
var dup map[string]interface{}
|
||||||
|
require.NoError(t, json.Unmarshal(raw, &dup))
|
||||||
|
assert.Equal(t, true, dup["duplicate"], "response must flag the duplicate")
|
||||||
|
assert.Equal(t, originalID, dup["item"].(map[string]interface{})["id"].(string),
|
||||||
|
"duplicate response carries the existing item")
|
||||||
|
assert.Equal(t, "Original Title", dup["item"].(map[string]interface{})["title"],
|
||||||
|
"the second create's payload must NOT overwrite the existing row")
|
||||||
|
}
|
||||||
|
|
||||||
// TestMediaItemLibraryRequirement tests that media items require a library
|
// TestMediaItemLibraryRequirement tests that media items require a library
|
||||||
func TestMediaItemLibraryRequirement(t *testing.T) {
|
func TestMediaItemLibraryRequirement(t *testing.T) {
|
||||||
setup := setupTestServer(t)
|
setup := setupTestServer(t)
|
||||||
@@ -404,7 +494,9 @@ func TestMediaItemLibraryRequirement(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
// Should fail - library_id is required
|
// Should fail - library_id is required
|
||||||
assert.NotEqual(t, http.StatusCreated, resp.StatusCode)
|
assert.NotEqual(t, http.StatusCreated, resp.StatusCode)
|
||||||
@@ -431,12 +523,15 @@ func TestMediaItemLibraryRequirement(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||||
|
|
||||||
var response map[string]interface{}
|
var response map[string]interface{}
|
||||||
json.NewDecoder(resp.Body).Decode(&response)
|
err = json.NewDecoder(resp.Body).Decode(&response)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
// VerifyISBN was normalized
|
// VerifyISBN was normalized
|
||||||
assert.Equal(t, "9780306406157", response["isbn"])
|
assert.Equal(t, "9780306406157", response["isbn"])
|
||||||
@@ -472,8 +567,9 @@ func TestUpdateMediaItemISBN(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
var createResponse map[string]interface{}
|
var createResponse map[string]interface{}
|
||||||
json.NewDecoder(resp.Body).Decode(&createResponse)
|
err = json.NewDecoder(resp.Body).Decode(&createResponse)
|
||||||
resp.Body.Close()
|
require.NoError(t, err)
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
|
||||||
mediaItemID := createResponse["id"].(string)
|
mediaItemID := createResponse["id"].(string)
|
||||||
|
|
||||||
@@ -491,7 +587,9 @@ func TestUpdateMediaItemISBN(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusUnprocessableEntity, resp.StatusCode)
|
assert.Equal(t, http.StatusUnprocessableEntity, resp.StatusCode)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestMediaNoteLengthLimits verifies the note content validator cap
|
||||||
|
// (internal/handlers/media.go): content up to 100,000 chars is accepted,
|
||||||
|
// longer content is rejected with 400.
|
||||||
|
func TestMediaNoteLengthLimits(t *testing.T) {
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
|
||||||
|
token := setup.Token
|
||||||
|
mediaItemID := createTestMediaItemID(t, setup)
|
||||||
|
|
||||||
|
postNote := func(content string) int {
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"content": content,
|
||||||
|
"position": "page:1",
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(payload)
|
||||||
|
req, _ := http.NewRequest("POST", setup.Server.URL+"/api/media-items/"+mediaItemID+"/notes", bytes.NewBuffer(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
|
||||||
|
client := &http.Client{}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
return resp.StatusCode
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("50k-char note accepted", func(t *testing.T) {
|
||||||
|
assert.Equal(t, http.StatusCreated, postNote(strings.Repeat("a", 50000)))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("100k-char note accepted at boundary", func(t *testing.T) {
|
||||||
|
assert.Equal(t, http.StatusCreated, postNote(strings.Repeat("b", 100000)))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("over-limit note rejected", func(t *testing.T) {
|
||||||
|
assert.Equal(t, http.StatusBadRequest, postNote(strings.Repeat("c", 100001)))
|
||||||
|
})
|
||||||
|
}
|
||||||
+186
-23
@@ -6,6 +6,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
@@ -26,7 +27,9 @@ func TestOPDSEndpoints(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
// OPDS endpoints require device authentication via devices.auth_token
|
// OPDS endpoints require device authentication via devices.auth_token
|
||||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||||
@@ -37,7 +40,9 @@ func TestOPDSEndpoints(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
// Should return 400 for invalid UUID
|
// Should return 400 for invalid UUID
|
||||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
@@ -53,7 +58,9 @@ func TestOPDSEndpoints(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
// Should return 200 with catalog (even if empty)
|
// Should return 200 with catalog (even if empty)
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
@@ -69,7 +76,9 @@ func TestOPDSEndpoints(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
// Should return 200 with catalog (even if empty)
|
// Should return 200 with catalog (even if empty)
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
@@ -80,7 +89,9 @@ func TestOPDSEndpoints(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -95,7 +106,9 @@ func TestOPDSEndpoints(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
// Should return 200 (even if empty results)
|
// Should return 200 (even if empty results)
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
@@ -106,7 +119,9 @@ func TestOPDSEndpoints(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -120,7 +135,9 @@ func TestOPDSEndpoints(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
// Should return navigation or 404
|
// Should return navigation or 404
|
||||||
assert.True(t, resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusNotFound)
|
assert.True(t, resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusNotFound)
|
||||||
@@ -131,7 +148,9 @@ func TestOPDSEndpoints(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -142,7 +161,9 @@ func TestOPDSEndpoints(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -156,7 +177,9 @@ func TestOPDSEndpoints(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
// May return 404 if device/book not linked, or 500 for file not found
|
// May return 404 if device/book not linked, or 500 for file not found
|
||||||
// Should not return 400 (invalid IDs)
|
// Should not return 400 (invalid IDs)
|
||||||
@@ -168,7 +191,9 @@ func TestOPDSEndpoints(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -178,7 +203,9 @@ func TestOPDSEndpoints(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -192,7 +219,9 @@ func TestOPDSEndpoints(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
// May return 404 if no cover, but not 400
|
// May return 404 if no cover, but not 400
|
||||||
assert.NotEqual(t, http.StatusBadRequest, resp.StatusCode)
|
assert.NotEqual(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
@@ -204,7 +233,9 @@ func TestOPDSEndpoints(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -218,7 +249,9 @@ func TestOPDSEndpoints(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
// Should return formats list or 404
|
// Should return formats list or 404
|
||||||
assert.True(t, resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusNotFound)
|
assert.True(t, resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusNotFound)
|
||||||
@@ -245,7 +278,9 @@ func TestOPDSConversion(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
// Should attempt conversion (may fail if file doesn't exist)
|
// Should attempt conversion (may fail if file doesn't exist)
|
||||||
// Important: Should not return 400 for invalid IDs
|
// Important: Should not return 400 for invalid IDs
|
||||||
@@ -262,7 +297,9 @@ func TestOPDSConversion(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
// Should attempt to download original format
|
// Should attempt to download original format
|
||||||
assert.NotEqual(t, http.StatusBadRequest, resp.StatusCode)
|
assert.NotEqual(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
@@ -278,7 +315,9 @@ func TestOPDSConversion(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
// Should handle gracefully (either 400 for unsupported format or 404/500)
|
// Should handle gracefully (either 400 for unsupported format or 404/500)
|
||||||
assert.True(t, resp.StatusCode >= 400 && resp.StatusCode < 600)
|
assert.True(t, resp.StatusCode >= 400 && resp.StatusCode < 600)
|
||||||
@@ -300,7 +339,9 @@ func TestOPDSEdgeCases(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
// Should return empty catalog, not error
|
// Should return empty catalog, not error
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
@@ -316,7 +357,9 @@ func TestOPDSEdgeCases(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
// Should handle special characters
|
// Should handle special characters
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
@@ -331,7 +374,9 @@ func TestOPDSEdgeCases(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
// Should handle empty query
|
// Should handle empty query
|
||||||
assert.True(t, resp.StatusCode >= 200 && resp.StatusCode < 500)
|
assert.True(t, resp.StatusCode >= 200 && resp.StatusCode < 500)
|
||||||
@@ -371,7 +416,9 @@ func TestOPDSSearchAcrossLibraries(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
t.Logf("OPDS Search Status: %d", resp.StatusCode)
|
t.Logf("OPDS Search Status: %d", resp.StatusCode)
|
||||||
|
|
||||||
@@ -389,3 +436,119 @@ func TestOPDSSearchAcrossLibraries(t *testing.T) {
|
|||||||
t.Logf("❌ FAILED - OPDS returned %d", resp.StatusCode)
|
t.Logf("❌ FAILED - OPDS returned %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestOPDSLibraryFolders covers the library-folder navigation feed: the root
|
||||||
|
// catalog is a navigation feed with an "All Books" entry plus one folder per
|
||||||
|
// visible library, the scoped catalog only lists that library's books, and
|
||||||
|
// the library-scoped search parameter restricts results to that library.
|
||||||
|
// TestOPDSLibraryFolders covers the library-folder navigation feed: the root
|
||||||
|
// catalog is a navigation feed with an "All Books" entry plus one folder per
|
||||||
|
// visible library, the scoped catalog only lists that library's books, and
|
||||||
|
// the library-scoped search parameter restricts results to that library.
|
||||||
|
// NOTE: library names deliberately avoid the word "test" — setupTestServer's
|
||||||
|
// setup-time cleanup deletes every library whose name contains it (and
|
||||||
|
// setupDeviceTest re-runs that cleanup mid-test).
|
||||||
|
func TestOPDSLibraryFolders(t *testing.T) {
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
client := &http.Client{}
|
||||||
|
|
||||||
|
// Two libraries, one distinct book each.
|
||||||
|
lib1Resp := createLibrary(t, client, setup, "OPDS Folder Lib Alpha")
|
||||||
|
lib2Resp := createLibrary(t, client, setup, "OPDS Folder Lib Beta")
|
||||||
|
lib1ID := lib1Resp["id"].(string)
|
||||||
|
lib2ID := lib2Resp["id"].(string)
|
||||||
|
// Real directories: the folder API validates that the path exists on the
|
||||||
|
// host running the server (container path /app/uploads won't exist when
|
||||||
|
// tests run on the host).
|
||||||
|
addFolderToLibrary(t, setup, lib1ID, t.TempDir())
|
||||||
|
addFolderToLibrary(t, setup, lib2ID, t.TempDir())
|
||||||
|
createTestMediaItemIDInLibrary(t, client, setup, lib1ID, "OPDS Folder Book 1")
|
||||||
|
createTestMediaItemIDInLibrary(t, client, setup, lib2ID, "OPDS Folder Book 2")
|
||||||
|
|
||||||
|
deviceSetup := setupDeviceTest(t)
|
||||||
|
device := deviceSetup.CreateDevice(t, "OPDS Folders Device", "koreader", "opds-folders-"+uuid.NewString())
|
||||||
|
base := setup.Server.URL + "/opds/devices/" + device.ID.String()
|
||||||
|
|
||||||
|
// Explicit cleanup: these names don't contain "test", so the harness
|
||||||
|
// cleanup skips them; remove them ourselves. Registered as a defer AFTER
|
||||||
|
// the setup.Close defer so LIFO ordering runs it while the pool is open.
|
||||||
|
defer deleteLibraries(t, setup,
|
||||||
|
pgtype.UUID{Bytes: uuid.MustParse(lib1ID), Valid: true},
|
||||||
|
pgtype.UUID{Bytes: uuid.MustParse(lib2ID), Valid: true})
|
||||||
|
|
||||||
|
getFeed := func(t *testing.T, url string) (int, string, string) {
|
||||||
|
t.Helper()
|
||||||
|
req, _ := http.NewRequest("GET", url, nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+device.AuthToken)
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
contentType := resp.Header.Get("Content-Type")
|
||||||
|
bodyBytes, err := io.ReadAll(resp.Body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return resp.StatusCode, contentType, string(bodyBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("root catalog is a navigation feed with All Books and library folders", func(t *testing.T) {
|
||||||
|
status, contentType, body := getFeed(t, base+"/catalog")
|
||||||
|
require.Equal(t, http.StatusOK, status)
|
||||||
|
assert.Contains(t, contentType, "kind=navigation")
|
||||||
|
assert.Contains(t, body, "All Books")
|
||||||
|
assert.Contains(t, body, "OPDS Folder Lib Alpha")
|
||||||
|
assert.Contains(t, body, "OPDS Folder Lib Beta")
|
||||||
|
assert.Contains(t, body, "/library/"+lib1ID+"/catalog")
|
||||||
|
assert.Contains(t, body, "/library/"+lib2ID+"/catalog")
|
||||||
|
// The nav feed itself must not inline book entries.
|
||||||
|
assert.NotContains(t, body, "OPDS Folder Book 1")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("all=1 keeps the flat cross-library catalog", func(t *testing.T) {
|
||||||
|
// Explicit large page: the shared test DB accumulates items, and
|
||||||
|
// the freshly created books sort last — without a generous
|
||||||
|
// per_page they fall onto a later page and the assertion reads
|
||||||
|
// tea leaves instead of the contract.
|
||||||
|
status, contentType, body := getFeed(t, base+"/catalog?all=1&per_page=200")
|
||||||
|
require.Equal(t, http.StatusOK, status)
|
||||||
|
assert.Contains(t, contentType, "kind=acquisition")
|
||||||
|
assert.Contains(t, body, "OPDS Folder Book 1")
|
||||||
|
assert.Contains(t, body, "OPDS Folder Book 2")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("scoped catalog lists only its own library's books", func(t *testing.T) {
|
||||||
|
status, contentType, body := getFeed(t, base+"/library/"+lib1ID+"/catalog")
|
||||||
|
require.Equal(t, http.StatusOK, status)
|
||||||
|
assert.Contains(t, contentType, "kind=acquisition")
|
||||||
|
assert.Contains(t, body, "OPDS Folder Book 1")
|
||||||
|
assert.NotContains(t, body, "OPDS Folder Book 2")
|
||||||
|
// Scoped search discovery: template pins the library.
|
||||||
|
assert.Contains(t, body, "library_id="+lib1ID)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("scoped catalog rejects libraries outside device visibility", func(t *testing.T) {
|
||||||
|
status, _, _ := getFeed(t, base+"/library/11111111-2222-3333-4444-555555555555/catalog")
|
||||||
|
assert.Equal(t, http.StatusForbidden, status)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("scoped search restricts results to the library", func(t *testing.T) {
|
||||||
|
status, _, body := getFeed(t, base+"/search?q=OPDS+Folder&library_id="+lib2ID)
|
||||||
|
require.Equal(t, http.StatusOK, status)
|
||||||
|
assert.Contains(t, body, "OPDS Folder Book 2")
|
||||||
|
assert.NotContains(t, body, "OPDS Folder Book 1")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("scoped search rejects inaccessible library", func(t *testing.T) {
|
||||||
|
status, _, _ := getFeed(t, base+"/search?q=OPDS&library_id=11111111-2222-3333-4444-555555555555")
|
||||||
|
assert.Equal(t, http.StatusForbidden, status)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("global search labels entries with their library", func(t *testing.T) {
|
||||||
|
status, _, body := getFeed(t, base+"/search?q=OPDS+Folder")
|
||||||
|
require.Equal(t, http.StatusOK, status)
|
||||||
|
assert.Contains(t, body, "OPDS Folder Book 1")
|
||||||
|
assert.Contains(t, body, "OPDS Folder Book 2")
|
||||||
|
assert.Contains(t, body, "OPDS Folder Lib Alpha")
|
||||||
|
assert.Contains(t, body, "OPDS Folder Lib Beta")
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"bookhoard/internal/database"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/stretchr/testify/suite"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OwnershipAndAdminTestSuite pins the 2026-10-13 hardening: per-user
|
||||||
|
// annotation mutations are ownership-scoped (no cross-user delete/update by
|
||||||
|
// bare row id), and admins can create users even with self-service
|
||||||
|
// registration disabled.
|
||||||
|
type OwnershipAndAdminTestSuite struct {
|
||||||
|
suite.Suite
|
||||||
|
setup *TestServerSetup
|
||||||
|
token string
|
||||||
|
itemID string
|
||||||
|
libraryID string
|
||||||
|
absFile string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OwnershipAndAdminTestSuite) SetupSuite() {
|
||||||
|
s.setup = setupTestServer(s.T())
|
||||||
|
s.token = s.setup.Token
|
||||||
|
var libID string
|
||||||
|
s.itemID, libID, s.absFile = s.createItemViaDB(true)
|
||||||
|
s.libraryID = libID
|
||||||
|
// createTestMediaItemID's t.Cleanup path can't be used at suite level
|
||||||
|
// (it fires after TearDownSuite has closed the server), so the item is
|
||||||
|
// created directly through the DB handle; suite cleanup relies on the
|
||||||
|
// standard "@tests.bookhoard.internal / test-named rows" passes of the
|
||||||
|
// next run's setupTestServer.
|
||||||
|
}
|
||||||
|
|
||||||
|
// createItemViaDB makes a throwaway library + media item directly through
|
||||||
|
// the test DB handle — no HTTP, no t.Cleanup entanglement.
|
||||||
|
// With withFolder=true the library gets a REAL temp folder row and the
|
||||||
|
// file is created on disk inside it, so deletes exercise real file
|
||||||
|
// removal; the folder path is returned alongside the item id.
|
||||||
|
func (s *OwnershipAndAdminTestSuite) createItemViaDB(withFolder bool) (itemID, libraryID, absFile string) {
|
||||||
|
ctx := s.setup.QueueCtx
|
||||||
|
db := s.setup.DB
|
||||||
|
|
||||||
|
libraryTypeRow, err := db.GetLibraryTypeByName(ctx, "ebooks")
|
||||||
|
require.NoError(s.T(), err, "should find ebooks library type")
|
||||||
|
|
||||||
|
admin, err := db.GetUserByEmail(ctx, "testuser@tests.bookhoard.internal")
|
||||||
|
require.NoError(s.T(), err, "dev admin user should exist")
|
||||||
|
|
||||||
|
library, err := db.CreateLibrary(ctx, database.CreateLibraryParams{
|
||||||
|
Name: fmt.Sprintf("Ownership Suite Test Library %d", time.Now().UnixNano()),
|
||||||
|
Description: pgtype.Text{String: "annotation ownership suite", Valid: true},
|
||||||
|
LibraryTypeID: libraryTypeRow.ID,
|
||||||
|
CreatedByAdminID: admin.ID,
|
||||||
|
})
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
|
||||||
|
relPath := fmt.Sprintf("ownership-suite-%d.epub", time.Now().UnixNano())
|
||||||
|
absFile = ""
|
||||||
|
if withFolder {
|
||||||
|
dir, dirErr := os.MkdirTemp("", "ownership-suite-*")
|
||||||
|
require.NoError(s.T(), dirErr)
|
||||||
|
_, ferr := db.AddLibraryFolder(ctx, database.AddLibraryFolderParams{
|
||||||
|
LibraryID: library.ID,
|
||||||
|
FolderPath: dir,
|
||||||
|
})
|
||||||
|
require.NoError(s.T(), ferr)
|
||||||
|
absFile = filepath.Join(dir, relPath)
|
||||||
|
if werr := os.WriteFile(absFile, []byte("restored book"), 0o644); werr != nil {
|
||||||
|
require.NoError(s.T(), werr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
item, err := db.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
||||||
|
LibraryID: library.ID,
|
||||||
|
Title: "Ownership Suite Item",
|
||||||
|
Author: pgtype.Text{String: "Suite Author", Valid: true},
|
||||||
|
FilePath: relPath,
|
||||||
|
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
|
||||||
|
})
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
|
||||||
|
return uuid.UUID(item.ID.Bytes).String(), uuid.UUID(library.ID.Bytes).String(), absFile
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OwnershipAndAdminTestSuite) TearDownSuite() {
|
||||||
|
s.setup.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OwnershipAndAdminTestSuite) getItem(itemID string) map[string]interface{} {
|
||||||
|
req, _ := http.NewRequest("GET", s.setup.Server.URL+"/api/media-items/"+itemID, nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+s.token)
|
||||||
|
resp, err := (&http.Client{}).Do(req)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
require.Equal(s.T(), http.StatusOK, resp.StatusCode)
|
||||||
|
var body map[string]interface{}
|
||||||
|
require.NoError(s.T(), json.NewDecoder(resp.Body).Decode(&body))
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OwnershipAndAdminTestSuite) createHighlight(token string) map[string]interface{} {
|
||||||
|
payload, _ := json.Marshal(map[string]interface{}{
|
||||||
|
"selection_text": "owned annotation text",
|
||||||
|
"start_position": "p1",
|
||||||
|
"end_position": "p1",
|
||||||
|
"color": "#ffff00",
|
||||||
|
})
|
||||||
|
req, _ := http.NewRequest("POST", s.setup.Server.URL+"/api/media-items/"+s.itemID+"/highlights", bytes.NewBuffer(payload))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
resp, err := (&http.Client{}).Do(req)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
require.Equal(s.T(), http.StatusCreated, resp.StatusCode, "highlight create should succeed")
|
||||||
|
var body map[string]interface{}
|
||||||
|
require.NoError(s.T(), json.NewDecoder(resp.Body).Decode(&body))
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OwnershipAndAdminTestSuite) highlightExists(token, highlightID string) bool {
|
||||||
|
req, _ := http.NewRequest("GET", s.setup.Server.URL+"/api/media-items/"+s.itemID+"/highlights", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
resp, err := (&http.Client{}).Do(req)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
var body []map[string]interface{}
|
||||||
|
require.NoError(s.T(), json.NewDecoder(resp.Body).Decode(&body))
|
||||||
|
for _, h := range body {
|
||||||
|
if h["id"] == highlightID {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHighlightDelete_CrossUserNoOp pins the ownership predicate: user B's
|
||||||
|
// DELETE of user A's highlight by bare id must not remove A's row.
|
||||||
|
func (s *OwnershipAndAdminTestSuite) TestHighlightDelete_CrossUserNoOp() {
|
||||||
|
regular := s.setup.RegularToken
|
||||||
|
|
||||||
|
created := s.createHighlight(regular)
|
||||||
|
highlightID, ok := created["id"].(string)
|
||||||
|
require.True(s.T(), ok, "create response carries the highlight id")
|
||||||
|
|
||||||
|
// The ADMIN (a different user) tries to delete it by id.
|
||||||
|
req, _ := http.NewRequest("DELETE", s.setup.Server.URL+"/api/media-items/"+s.itemID+"/highlights/"+highlightID, nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+s.token)
|
||||||
|
resp, err := (&http.Client{}).Do(req)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
io.Copy(io.Discard, resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
assert.True(s.T(), s.highlightExists(regular, highlightID),
|
||||||
|
"another user's delete-by-id must not remove the owner's highlight")
|
||||||
|
|
||||||
|
// The owner deletes their own highlight — gone.
|
||||||
|
req, _ = http.NewRequest("DELETE", s.setup.Server.URL+"/api/media-items/"+s.itemID+"/highlights/"+highlightID, nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+regular)
|
||||||
|
resp, err = (&http.Client{}).Do(req)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
io.Copy(io.Discard, resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
assert.False(s.T(), s.highlightExists(regular, highlightID), "owner delete removes the row")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAdminCreateUser pins the admin counterpart to registration: admins
|
||||||
|
// create accounts even with self-service registration disabled, regular
|
||||||
|
// users cannot, and duplicates conflict.
|
||||||
|
func (s *OwnershipAndAdminTestSuite) TestAdminCreateUser() {
|
||||||
|
suffix := fmt.Sprintf("%d", s.setup.DBPool.Config().MaxConns) // stable-ish uniquifier
|
||||||
|
unique := fmt.Sprintf("admincreated%s", suffix)
|
||||||
|
|
||||||
|
// Regular user token → 403 (AdminMiddleware).
|
||||||
|
payload, _ := json.Marshal(map[string]interface{}{
|
||||||
|
"email": unique + "@tests.bookhoard.internal",
|
||||||
|
"username": unique,
|
||||||
|
"password": "Test@Pass123!",
|
||||||
|
})
|
||||||
|
req, _ := http.NewRequest("POST", s.setup.Server.URL+"/api/auth/users", bytes.NewBuffer(payload))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+s.setup.RegularToken)
|
||||||
|
resp, err := (&http.Client{}).Do(req)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
io.Copy(io.Discard, resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
require.Equal(s.T(), http.StatusForbidden, resp.StatusCode)
|
||||||
|
|
||||||
|
// Admin → 201, and the account is immediately usable (login works)
|
||||||
|
// even though self-service registration is off for new servers.
|
||||||
|
req, _ = http.NewRequest("POST", s.setup.Server.URL+"/api/auth/users", bytes.NewBuffer(payload))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+s.token)
|
||||||
|
resp, err = (&http.Client{}).Do(req)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
require.Equal(s.T(), http.StatusCreated, resp.StatusCode)
|
||||||
|
var created map[string]interface{}
|
||||||
|
require.NoError(s.T(), json.NewDecoder(resp.Body).Decode(&created))
|
||||||
|
assert.Equal(s.T(), unique, created["username"])
|
||||||
|
assert.Equal(s.T(), "user", created["role"])
|
||||||
|
|
||||||
|
// New users inherit the system-wide default timezone (this was the
|
||||||
|
// point of the setting; before 2026-10-13 every new user got UTC
|
||||||
|
// regardless of it).
|
||||||
|
expectedTZ := "UTC"
|
||||||
|
var settingTZ string
|
||||||
|
err = s.setup.DBPool.QueryRow(s.setup.QueueCtx,
|
||||||
|
"SELECT setting_value FROM system_settings WHERE setting_key = 'default_timezone'").Scan(&settingTZ)
|
||||||
|
if err == nil {
|
||||||
|
if _, locErr := time.LoadLocation(settingTZ); locErr == nil && settingTZ != "" {
|
||||||
|
expectedTZ = settingTZ
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.Equal(s.T(), expectedTZ, created["timezone"],
|
||||||
|
"created user must carry the system default timezone")
|
||||||
|
|
||||||
|
// Duplicate username → 409.
|
||||||
|
req, _ = http.NewRequest("POST", s.setup.Server.URL+"/api/auth/users", bytes.NewBuffer(payload))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+s.token)
|
||||||
|
resp, err = (&http.Client{}).Do(req)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
assert.Equal(s.T(), http.StatusConflict, resp.StatusCode)
|
||||||
|
|
||||||
|
// The created user can log in.
|
||||||
|
loginPayload, _ := json.Marshal(map[string]interface{}{
|
||||||
|
"login": unique + "@tests.bookhoard.internal",
|
||||||
|
"password": "Test@Pass123!",
|
||||||
|
})
|
||||||
|
req, _ = http.NewRequest("POST", s.setup.Server.URL+"/api/auth/login", bytes.NewBuffer(loginPayload))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp, err = (&http.Client{}).Do(req)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
assert.Equal(s.T(), http.StatusOK, resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDelete_StorageDownRefused pins the NAS guard: deleting a media item
|
||||||
|
// while its library storage is unreachable is REFUSED (row + annotations
|
||||||
|
// survive) instead of cascading annotations for a file that will re-import
|
||||||
|
// bare on the next scan.
|
||||||
|
func (s *OwnershipAndAdminTestSuite) TestDelete_StorageDownRefused() {
|
||||||
|
ctx := s.setup.QueueCtx
|
||||||
|
db := s.setup.DB
|
||||||
|
|
||||||
|
libraryTypeRow, err := db.GetLibraryTypeByName(ctx, "ebooks")
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
admin, err := db.GetUserByEmail(ctx, "testuser@tests.bookhoard.internal")
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
|
||||||
|
library, err := db.CreateLibrary(ctx, database.CreateLibraryParams{
|
||||||
|
Name: fmt.Sprintf("Ownership Suite Test Offline %d", time.Now().UnixNano()),
|
||||||
|
Description: pgtype.Text{String: "storage-down suite", Valid: true},
|
||||||
|
LibraryTypeID: libraryTypeRow.ID,
|
||||||
|
CreatedByAdminID: admin.ID,
|
||||||
|
})
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
// Folder row pointing at a mount that does not exist.
|
||||||
|
_, err = db.AddLibraryFolder(ctx, database.AddLibraryFolderParams{
|
||||||
|
LibraryID: library.ID,
|
||||||
|
FolderPath: "/nonexistent-mount-xyz",
|
||||||
|
})
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
|
||||||
|
item, err := db.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
||||||
|
LibraryID: library.ID,
|
||||||
|
Title: "Offline Storage Item",
|
||||||
|
FilePath: "offline.epub",
|
||||||
|
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
|
||||||
|
})
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
itemID := uuid.UUID(item.ID.Bytes).String()
|
||||||
|
|
||||||
|
payload, _ := json.Marshal(map[string]interface{}{"media_item_ids": []string{itemID}})
|
||||||
|
req, _ := http.NewRequest("POST", s.setup.Server.URL+"/api/media-items/bulk-delete", bytes.NewBuffer(payload))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+s.token)
|
||||||
|
resp, err := (&http.Client{}).Do(req)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
require.Equal(s.T(), http.StatusOK, resp.StatusCode, "bulk endpoint answers 200 with per-item errors")
|
||||||
|
|
||||||
|
var body map[string]interface{}
|
||||||
|
require.NoError(s.T(), json.NewDecoder(resp.Body).Decode(&body))
|
||||||
|
results := body["results"].([]interface{})
|
||||||
|
require.Len(s.T(), results, 1)
|
||||||
|
first := results[0].(map[string]interface{})
|
||||||
|
assert.Contains(s.T(), first["error"], "storage unreachable")
|
||||||
|
|
||||||
|
// The row survived the refused delete.
|
||||||
|
check, err := db.GetMediaItem(ctx, item.ID)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
assert.Equal(s.T(), "Offline Storage Item", check.Title)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOwnershipAndAdminTestSuite(t *testing.T) {
|
||||||
|
suite.Run(t, new(OwnershipAndAdminTestSuite))
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -15,8 +16,7 @@ func TestProcessingIssuesListAuthentication(t *testing.T) {
|
|||||||
setup := setupTestServer(t)
|
setup := setupTestServer(t)
|
||||||
|
|
||||||
// Create a test library
|
// Create a test library
|
||||||
deviceSetup := setupDeviceTest(t)
|
libraryID := createLibraryDirect(t, setup.DB, getTestUserID(t, setup.DB), "Test Auth Library", "manga")
|
||||||
libraryID := deviceSetup.CreateLibrary(t, "Test Auth Library", "manga")
|
|
||||||
|
|
||||||
t.Run("GET /api/libraries/:id/issues/list - No authentication", func(t *testing.T) {
|
t.Run("GET /api/libraries/:id/issues/list - No authentication", func(t *testing.T) {
|
||||||
req := httptest.NewRequest("GET", "/api/libraries/"+libraryID+"/issues/list", nil)
|
req := httptest.NewRequest("GET", "/api/libraries/"+libraryID+"/issues/list", nil)
|
||||||
@@ -54,8 +54,7 @@ func TestProcessingIssuesListResponseStructure(t *testing.T) {
|
|||||||
setup := setupTestServer(t)
|
setup := setupTestServer(t)
|
||||||
|
|
||||||
// Create a test library
|
// Create a test library
|
||||||
deviceSetup := setupDeviceTest(t)
|
libraryID := createLibraryDirect(t, setup.DB, getTestUserID(t, setup.DB), "Test Structure Library", "comics")
|
||||||
libraryID := deviceSetup.CreateLibrary(t, "Test Structure Library", "comics")
|
|
||||||
|
|
||||||
t.Run("Returns JSON array for empty library", func(t *testing.T) {
|
t.Run("Returns JSON array for empty library", func(t *testing.T) {
|
||||||
token := setup.Token
|
token := setup.Token
|
||||||
@@ -108,8 +107,8 @@ func TestProcessingIssuesListInputValidation(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "Empty UUID",
|
name: "Empty UUID",
|
||||||
libraryID: "",
|
libraryID: "",
|
||||||
expectedStatus: http.StatusNotFound,
|
expectedStatus: http.StatusBadRequest,
|
||||||
description: "Should return 404 for empty ID",
|
description: "Should return 400 for empty ID",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "UUID with extra path traversal",
|
name: "UUID with extra path traversal",
|
||||||
@@ -139,7 +138,7 @@ func TestProcessingIssuesListInputValidation(t *testing.T) {
|
|||||||
|
|
||||||
for _, tc := range testCases {
|
for _, tc := range testCases {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
req := httptest.NewRequest("GET", "/api/libraries/"+tc.libraryID+"/issues/list", nil)
|
req := httptest.NewRequest("GET", "/api/libraries/"+url.PathEscape(tc.libraryID)+"/issues/list", nil)
|
||||||
req.Header.Set("Authorization", "Bearer "+token)
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
@@ -162,8 +161,7 @@ func TestProcessingIssueStatsAuthentication(t *testing.T) {
|
|||||||
setup := setupTestServer(t)
|
setup := setupTestServer(t)
|
||||||
|
|
||||||
// Create a test library
|
// Create a test library
|
||||||
deviceSetup := setupDeviceTest(t)
|
libraryID := createLibraryDirect(t, setup.DB, getTestUserID(t, setup.DB), "Test Stats Auth Library", "manga")
|
||||||
libraryID := deviceSetup.CreateLibrary(t, "Test Stats Auth Library", "manga")
|
|
||||||
|
|
||||||
t.Run("GET /api/libraries/:id/issues/stats - No authentication", func(t *testing.T) {
|
t.Run("GET /api/libraries/:id/issues/stats - No authentication", func(t *testing.T) {
|
||||||
req := httptest.NewRequest("GET", "/api/libraries/"+libraryID+"/issues/stats", nil)
|
req := httptest.NewRequest("GET", "/api/libraries/"+libraryID+"/issues/stats", nil)
|
||||||
@@ -201,8 +199,7 @@ func TestProcessingIssueStatsResponseStructure(t *testing.T) {
|
|||||||
setup := setupTestServer(t)
|
setup := setupTestServer(t)
|
||||||
|
|
||||||
// Create a test library
|
// Create a test library
|
||||||
deviceSetup := setupDeviceTest(t)
|
libraryID := createLibraryDirect(t, setup.DB, getTestUserID(t, setup.DB), "Test Stats Structure Library", "comics")
|
||||||
libraryID := deviceSetup.CreateLibrary(t, "Test Stats Structure Library", "comics")
|
|
||||||
|
|
||||||
t.Run("Returns correct structure for empty library", func(t *testing.T) {
|
t.Run("Returns correct structure for empty library", func(t *testing.T) {
|
||||||
token := setup.Token
|
token := setup.Token
|
||||||
@@ -284,8 +281,8 @@ func TestProcessingIssueStatsInputValidation(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "Empty UUID",
|
name: "Empty UUID",
|
||||||
libraryID: "",
|
libraryID: "",
|
||||||
expectedStatus: http.StatusNotFound,
|
expectedStatus: http.StatusBadRequest,
|
||||||
description: "Should return 404 for empty ID",
|
description: "Should return 400 for empty ID",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "UUID with extra path traversal",
|
name: "UUID with extra path traversal",
|
||||||
@@ -315,7 +312,7 @@ func TestProcessingIssueStatsInputValidation(t *testing.T) {
|
|||||||
|
|
||||||
for _, tc := range testCases {
|
for _, tc := range testCases {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
req := httptest.NewRequest("GET", "/api/libraries/"+tc.libraryID+"/issues/stats", nil)
|
req := httptest.NewRequest("GET", "/api/libraries/"+url.PathEscape(tc.libraryID)+"/issues/stats", nil)
|
||||||
req.Header.Set("Authorization", "Bearer "+token)
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
@@ -342,11 +339,9 @@ func TestProcessingIssuesCrossLibraryIsolation(t *testing.T) {
|
|||||||
setup := setupTestServer(t)
|
setup := setupTestServer(t)
|
||||||
|
|
||||||
// Create two different libraries with different types
|
// Create two different libraries with different types
|
||||||
deviceSetup1 := setupDeviceTest(t)
|
library1ID := createLibraryDirect(t, setup.DB, getTestUserID(t, setup.DB), "Isolation Test Library 1", "manga")
|
||||||
library1ID := deviceSetup1.CreateLibrary(t, "Isolation Test Library 1", "manga")
|
|
||||||
|
|
||||||
deviceSetup2 := setupDeviceTest(t)
|
library2ID := createLibraryDirect(t, setup.DB, getTestUserID(t, setup.DB), "Isolation Test Library 2", "comics")
|
||||||
library2ID := deviceSetup2.CreateLibrary(t, "Isolation Test Library 2", "comics")
|
|
||||||
|
|
||||||
token := setup.Token
|
token := setup.Token
|
||||||
|
|
||||||
@@ -388,7 +383,8 @@ func TestProcessingIssuesCrossLibraryIsolation(t *testing.T) {
|
|||||||
setup.Server.Config.Handler.ServeHTTP(rec1, req1)
|
setup.Server.Config.Handler.ServeHTTP(rec1, req1)
|
||||||
|
|
||||||
var stats1 map[string]interface{}
|
var stats1 map[string]interface{}
|
||||||
json.NewDecoder(rec1.Body).Decode(&stats1)
|
err := json.NewDecoder(rec1.Body).Decode(&stats1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Get stats for library 2
|
// Get stats for library 2
|
||||||
req2 := httptest.NewRequest("GET", "/api/libraries/"+library2ID+"/issues/stats", nil)
|
req2 := httptest.NewRequest("GET", "/api/libraries/"+library2ID+"/issues/stats", nil)
|
||||||
@@ -398,7 +394,8 @@ func TestProcessingIssuesCrossLibraryIsolation(t *testing.T) {
|
|||||||
setup.Server.Config.Handler.ServeHTTP(rec2, req2)
|
setup.Server.Config.Handler.ServeHTTP(rec2, req2)
|
||||||
|
|
||||||
var stats2 map[string]interface{}
|
var stats2 map[string]interface{}
|
||||||
json.NewDecoder(rec2.Body).Decode(&stats2)
|
err = json.NewDecoder(rec2.Body).Decode(&stats2)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Both should have zero counts
|
// Both should have zero counts
|
||||||
assert.Equal(t, float64(0), stats1["error_count"])
|
assert.Equal(t, float64(0), stats1["error_count"])
|
||||||
@@ -424,13 +421,11 @@ func TestProcessingIssuesDifferentLibraryTypes(t *testing.T) {
|
|||||||
{"Ebooks library", "ebooks"},
|
{"Ebooks library", "ebooks"},
|
||||||
{"Comics library", "comics"},
|
{"Comics library", "comics"},
|
||||||
{"Manga library", "manga"},
|
{"Manga library", "manga"},
|
||||||
{"Audiobooks library", "audiobooks"},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, lt := range libraryTypes {
|
for _, lt := range libraryTypes {
|
||||||
t.Run(lt.name+" - list endpoint works", func(t *testing.T) {
|
t.Run(lt.name+" - list endpoint works", func(t *testing.T) {
|
||||||
deviceSetup := setupDeviceTest(t)
|
libraryID := createLibraryDirect(t, setup.DB, getTestUserID(t, setup.DB), "Test "+lt.name, lt.libraryType)
|
||||||
libraryID := deviceSetup.CreateLibrary(t, "Test "+lt.name, lt.libraryType)
|
|
||||||
|
|
||||||
req := httptest.NewRequest("GET", "/api/libraries/"+libraryID+"/issues/list", nil)
|
req := httptest.NewRequest("GET", "/api/libraries/"+libraryID+"/issues/list", nil)
|
||||||
req.Header.Set("Authorization", "Bearer "+token)
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
@@ -446,8 +441,7 @@ func TestProcessingIssuesDifferentLibraryTypes(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run(lt.name+" - stats endpoint works", func(t *testing.T) {
|
t.Run(lt.name+" - stats endpoint works", func(t *testing.T) {
|
||||||
deviceSetup := setupDeviceTest(t)
|
libraryID := createLibraryDirect(t, setup.DB, getTestUserID(t, setup.DB), "Test "+lt.name, lt.libraryType)
|
||||||
libraryID := deviceSetup.CreateLibrary(t, "Test "+lt.name, lt.libraryType)
|
|
||||||
|
|
||||||
req := httptest.NewRequest("GET", "/api/libraries/"+libraryID+"/issues/stats", nil)
|
req := httptest.NewRequest("GET", "/api/libraries/"+libraryID+"/issues/stats", nil)
|
||||||
req.Header.Set("Authorization", "Bearer "+token)
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
@@ -472,8 +466,7 @@ func TestProcessingIssuesInvalidTokens(t *testing.T) {
|
|||||||
setup := setupTestServer(t)
|
setup := setupTestServer(t)
|
||||||
|
|
||||||
// Create a test library
|
// Create a test library
|
||||||
deviceSetup := setupDeviceTest(t)
|
libraryID := createLibraryDirect(t, setup.DB, getTestUserID(t, setup.DB), "Test Token Library", "ebooks")
|
||||||
libraryID := deviceSetup.CreateLibrary(t, "Test Token Library", "ebooks")
|
|
||||||
|
|
||||||
t.Run("List endpoint rejects invalid token", func(t *testing.T) {
|
t.Run("List endpoint rejects invalid token", func(t *testing.T) {
|
||||||
req := httptest.NewRequest("GET", "/api/libraries/"+libraryID+"/issues/list", nil)
|
req := httptest.NewRequest("GET", "/api/libraries/"+libraryID+"/issues/list", nil)
|
||||||
|
|||||||
@@ -0,0 +1,837 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"bookhoard/internal/database"
|
||||||
|
wsync "bookhoard/internal/sync"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func pFloat64(v float64) *float64 { return &v }
|
||||||
|
func pStr(v string) *string { return &v }
|
||||||
|
func pInt(v int) *int { return &v }
|
||||||
|
func pInt64(v int64) *int64 { return &v }
|
||||||
|
|
||||||
|
func doReq(t *testing.T, method, url string, body interface{}, token string) *http.Response {
|
||||||
|
t.Helper()
|
||||||
|
var bodyReader io.Reader
|
||||||
|
if body != nil {
|
||||||
|
b, err := json.Marshal(body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
bodyReader = bytes.NewBuffer(b)
|
||||||
|
}
|
||||||
|
req, err := http.NewRequest(method, url, bodyReader)
|
||||||
|
require.NoError(t, err)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
if token != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
}
|
||||||
|
resp, err := (&http.Client{}).Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeJSON(t *testing.T, resp *http.Response) map[string]interface{} {
|
||||||
|
t.Helper()
|
||||||
|
var result map[string]interface{}
|
||||||
|
err := json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func progressURL(serverURL, mediaItemID string) string {
|
||||||
|
return serverURL + "/api/media-items/" + mediaItemID + "/progress"
|
||||||
|
}
|
||||||
|
|
||||||
|
func getFloatField(t *testing.T, data map[string]interface{}, field string) float64 {
|
||||||
|
t.Helper()
|
||||||
|
val, ok := data[field]
|
||||||
|
require.True(t, ok, "%s should be present in response", field)
|
||||||
|
require.NotNil(t, val, "%s should not be null", field)
|
||||||
|
f, ok := val.(float64)
|
||||||
|
require.True(t, ok, "%s should be a number, got %T: %v", field, val, val)
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
func getStringField(t *testing.T, data map[string]interface{}, field string) string {
|
||||||
|
t.Helper()
|
||||||
|
val, ok := data[field]
|
||||||
|
require.True(t, ok, "%s should be present in response", field)
|
||||||
|
require.NotNil(t, val, "%s should not be null", field)
|
||||||
|
s, ok := val.(string)
|
||||||
|
require.True(t, ok, "%s should be a string, got %T: %v", field, val, val)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProgressWeb_AuthContexts(t *testing.T) {
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
mediaItemID := createTestMediaItemID(t, setup)
|
||||||
|
url := progressURL(setup.Server.URL, mediaItemID)
|
||||||
|
|
||||||
|
t.Run("unauthenticated PUT returns 401", func(t *testing.T) {
|
||||||
|
resp := doReq(t, "PUT", url, map[string]interface{}{
|
||||||
|
"percentage": 0.5,
|
||||||
|
}, "")
|
||||||
|
defer resp.Body.Close()
|
||||||
|
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("unauthenticated GET returns 401", func(t *testing.T) {
|
||||||
|
resp := doReq(t, "GET", url, nil, "")
|
||||||
|
defer resp.Body.Close()
|
||||||
|
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("regular user PUT succeeds", func(t *testing.T) {
|
||||||
|
resp := doReq(t, "PUT", url, map[string]interface{}{
|
||||||
|
"percentage": 0.3,
|
||||||
|
}, setup.RegularToken)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("regular user GET succeeds", func(t *testing.T) {
|
||||||
|
resp := doReq(t, "GET", url, nil, setup.RegularToken)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("admin PUT succeeds", func(t *testing.T) {
|
||||||
|
resp := doReq(t, "PUT", url, map[string]interface{}{
|
||||||
|
"percentage": 0.7,
|
||||||
|
}, setup.Token)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("admin GET succeeds", func(t *testing.T) {
|
||||||
|
resp := doReq(t, "GET", url, nil, setup.Token)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("invalid media item ID returns 400", func(t *testing.T) {
|
||||||
|
badURL := setup.Server.URL + "/api/media-items/not-a-uuid/progress"
|
||||||
|
resp := doReq(t, "GET", badURL, nil, setup.Token)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("nonexistent media item GET returns 200 with empty", func(t *testing.T) {
|
||||||
|
fakeID := uuid.New().String()
|
||||||
|
fakeURL := progressURL(setup.Server.URL, fakeID)
|
||||||
|
resp := doReq(t, "GET", fakeURL, nil, setup.Token)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProgressWeb_MergePreservesFields(t *testing.T) {
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
mediaItemID := createTestMediaItemID(t, setup)
|
||||||
|
url := progressURL(setup.Server.URL, mediaItemID)
|
||||||
|
|
||||||
|
t.Run("second PUT with only percentage preserves epubcfi and chapter from first", func(t *testing.T) {
|
||||||
|
resp1 := doReq(t, "PUT", url, map[string]interface{}{
|
||||||
|
"percentage": 0.3,
|
||||||
|
"epubcfi": "epubcfi(/6/4/2:first)",
|
||||||
|
"chapter": 2,
|
||||||
|
}, setup.Token)
|
||||||
|
defer resp1.Body.Close()
|
||||||
|
require.Equal(t, http.StatusOK, resp1.StatusCode)
|
||||||
|
|
||||||
|
resp2 := doReq(t, "PUT", url, map[string]interface{}{
|
||||||
|
"percentage": 0.5,
|
||||||
|
}, setup.Token)
|
||||||
|
defer resp2.Body.Close()
|
||||||
|
require.Equal(t, http.StatusOK, resp2.StatusCode)
|
||||||
|
|
||||||
|
getResp := doReq(t, "GET", url, nil, setup.Token)
|
||||||
|
defer getResp.Body.Close()
|
||||||
|
require.Equal(t, http.StatusOK, getResp.StatusCode)
|
||||||
|
|
||||||
|
result := decodeJSON(t, getResp)
|
||||||
|
pct := getFloatField(t, result, "percentage")
|
||||||
|
assert.InDelta(t, 0.5, pct, 0.01)
|
||||||
|
|
||||||
|
epubcfi := getStringField(t, result, "epubcfi")
|
||||||
|
assert.Equal(t, "epubcfi(/6/4/2:first)", epubcfi, "epubcfi should be preserved from first save")
|
||||||
|
|
||||||
|
chapter := getFloatField(t, result, "chapter")
|
||||||
|
assert.Equal(t, float64(2), chapter, "chapter should be preserved from first save")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("web save preserves koreader character_offset", func(t *testing.T) {
|
||||||
|
mediaItemID2 := createTestMediaItemID(t, setup)
|
||||||
|
url2 := progressURL(setup.Server.URL, mediaItemID2)
|
||||||
|
mediaUUID, _ := uuid.Parse(mediaItemID2)
|
||||||
|
userID := getTestUserID(t, setup.DB)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
charOffset := int64(15000)
|
||||||
|
_, err := setup.ProgressService.SaveProgress(ctx, wsync.SaveProgressRequest{
|
||||||
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||||
|
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||||
|
Source: "koreader",
|
||||||
|
DeviceID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||||
|
Percentage: pFloat64(0.45),
|
||||||
|
CharacterOffset: &charOffset,
|
||||||
|
Chapter: pInt(5),
|
||||||
|
DeviceType: "koreader",
|
||||||
|
DeviceName: "KOReader Test",
|
||||||
|
Broadcast: false,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
resp := doReq(t, "PUT", url2, map[string]interface{}{
|
||||||
|
"percentage": 0.5,
|
||||||
|
"epubcfi": "epubcfi(/6/4/2:10)",
|
||||||
|
}, setup.Token)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{
|
||||||
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||||
|
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, progress.CharacterOffset.Valid, "character_offset should be preserved")
|
||||||
|
assert.Equal(t, int64(15000), progress.CharacterOffset.Int64)
|
||||||
|
assert.True(t, progress.Chapter.Valid, "chapter should be preserved")
|
||||||
|
assert.Equal(t, int32(5), progress.Chapter.Int32)
|
||||||
|
assert.InDelta(t, 0.5, progress.Percentage.Float64, 0.001)
|
||||||
|
assert.Equal(t, "web", progress.LastSyncSource.String)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProgressWeb_EnrichmentComputesFields(t *testing.T) {
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
mediaItemID := createTestMediaItemID(t, setup)
|
||||||
|
url := progressURL(setup.Server.URL, mediaItemID)
|
||||||
|
mediaUUID, _ := uuid.Parse(mediaItemID)
|
||||||
|
userID := getTestUserID(t, setup.DB)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
t.Run("character_offset computed from percentage when total_characters set", func(t *testing.T) {
|
||||||
|
_, err := setup.DBPool.Exec(ctx, "UPDATE media_items SET total_characters = $1 WHERE id = $2", int64(200000), mediaUUID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
resp := doReq(t, "PUT", url, map[string]interface{}{
|
||||||
|
"percentage": 0.5,
|
||||||
|
}, setup.Token)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{
|
||||||
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||||
|
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, progress.CharacterOffset.Valid, "character_offset should be computed from percentage")
|
||||||
|
assert.Equal(t, int64(100000), progress.CharacterOffset.Int64)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("GET returns enriched format_group and total_characters", func(t *testing.T) {
|
||||||
|
getResp := doReq(t, "GET", url, nil, setup.Token)
|
||||||
|
defer getResp.Body.Close()
|
||||||
|
require.Equal(t, http.StatusOK, getResp.StatusCode)
|
||||||
|
|
||||||
|
result := decodeJSON(t, getResp)
|
||||||
|
_, hasFormatGroup := result["format_group"]
|
||||||
|
assert.True(t, hasFormatGroup, "format_group should be present in GET response")
|
||||||
|
_, hasTotalChars := result["total_characters"]
|
||||||
|
assert.True(t, hasTotalChars, "total_characters should be present in GET response")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProgressWeb_ConflictDetection(t *testing.T) {
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
mediaItemID := createTestMediaItemID(t, setup)
|
||||||
|
mediaUUID, _ := uuid.Parse(mediaItemID)
|
||||||
|
userID := getTestUserID(t, setup.DB)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
t.Run("different sources with >1% diff within 5min creates conflict record", func(t *testing.T) {
|
||||||
|
pct1 := 0.3
|
||||||
|
_, err := setup.ProgressService.SaveProgress(ctx, wsync.SaveProgressRequest{
|
||||||
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||||
|
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||||
|
Source: "koreader",
|
||||||
|
DeviceID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||||
|
Percentage: &pct1,
|
||||||
|
DeviceType: "koreader",
|
||||||
|
DeviceName: "KOReader",
|
||||||
|
Broadcast: false,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
pct2 := 0.6
|
||||||
|
_, err = setup.ProgressService.SaveProgress(ctx, wsync.SaveProgressRequest{
|
||||||
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||||
|
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||||
|
Source: "kobo",
|
||||||
|
DeviceID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||||
|
Percentage: &pct2,
|
||||||
|
DeviceType: "kobo",
|
||||||
|
DeviceName: "Kobo",
|
||||||
|
Broadcast: false,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{
|
||||||
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||||
|
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.InDelta(t, 0.6, progress.Percentage.Float64, 0.001)
|
||||||
|
assert.Equal(t, "kobo", progress.LastSyncSource.String)
|
||||||
|
|
||||||
|
conflicts, err := setup.DB.ListSyncConflictsByMediaItem(ctx, database.ListSyncConflictsByMediaItemParams{
|
||||||
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||||
|
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotEmpty(t, conflicts, "conflict should be recorded in sync_conflicts table")
|
||||||
|
assert.Equal(t, "progress", conflicts[0].ConflictType)
|
||||||
|
assert.True(t, conflicts[0].ResolutionStatus.Valid)
|
||||||
|
assert.Equal(t, "unresolved", conflicts[0].ResolutionStatus.String)
|
||||||
|
})
|
||||||
|
|
||||||
|
mediaItemID2 := createTestMediaItemID(t, setup)
|
||||||
|
mediaUUID2, _ := uuid.Parse(mediaItemID2)
|
||||||
|
|
||||||
|
t.Run("same source rapid saves create no conflict", func(t *testing.T) {
|
||||||
|
for _, pct := range []float64{0.1, 0.3, 0.5, 0.7} {
|
||||||
|
_, err := setup.ProgressService.SaveProgress(ctx, wsync.SaveProgressRequest{
|
||||||
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID2, Valid: true},
|
||||||
|
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||||
|
Source: "web",
|
||||||
|
DeviceID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||||
|
Percentage: &pct,
|
||||||
|
DeviceType: "web",
|
||||||
|
DeviceName: "Web",
|
||||||
|
Broadcast: false,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
conflicts, err := setup.DB.ListSyncConflictsByMediaItem(ctx, database.ListSyncConflictsByMediaItemParams{
|
||||||
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID2, Valid: true},
|
||||||
|
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, conflicts, "same-source saves should not create conflicts")
|
||||||
|
})
|
||||||
|
|
||||||
|
mediaItemID3 := createTestMediaItemID(t, setup)
|
||||||
|
mediaUUID3, _ := uuid.Parse(mediaItemID3)
|
||||||
|
|
||||||
|
t.Run("different sources with <1% diff creates no conflict", func(t *testing.T) {
|
||||||
|
pct1 := 0.5
|
||||||
|
_, err := setup.ProgressService.SaveProgress(ctx, wsync.SaveProgressRequest{
|
||||||
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID3, Valid: true},
|
||||||
|
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||||
|
Source: "koreader",
|
||||||
|
DeviceID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||||
|
Percentage: &pct1,
|
||||||
|
DeviceType: "koreader",
|
||||||
|
DeviceName: "KOReader",
|
||||||
|
Broadcast: false,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
pct2 := 0.505
|
||||||
|
_, err = setup.ProgressService.SaveProgress(ctx, wsync.SaveProgressRequest{
|
||||||
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID3, Valid: true},
|
||||||
|
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||||
|
Source: "kobo",
|
||||||
|
DeviceID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||||
|
Percentage: &pct2,
|
||||||
|
DeviceType: "kobo",
|
||||||
|
DeviceName: "Kobo",
|
||||||
|
Broadcast: false,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
conflicts, err := setup.DB.ListSyncConflictsByMediaItem(ctx, database.ListSyncConflictsByMediaItemParams{
|
||||||
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID3, Valid: true},
|
||||||
|
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, conflicts, "small percentage diff should not create conflict")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProgressWeb_KoboIntegration(t *testing.T) {
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
userID := getTestUserID(t, setup.DB)
|
||||||
|
|
||||||
|
koboDeviceToken := fmt.Sprintf("dev_%s", uuid.New().String())
|
||||||
|
pgUserID := pgtype.UUID{Bytes: userID, Valid: true}
|
||||||
|
device, err := setup.DB.CreateDevice(ctx, database.CreateDeviceParams{
|
||||||
|
UserID: pgUserID,
|
||||||
|
DeviceName: "Test Kobo Progress",
|
||||||
|
DeviceType: "kobo",
|
||||||
|
DeviceIdentifier: "kobo-progress-test",
|
||||||
|
AuthToken: koboDeviceToken,
|
||||||
|
SyncEnabled: pgtype.Bool{Bool: true, Valid: true},
|
||||||
|
AutoSync: pgtype.Bool{Bool: true, Valid: true},
|
||||||
|
SyncFrequencyMinutes: pgtype.Int4{Int32: 5, Valid: true},
|
||||||
|
DeviceMetadata: []byte("{}"),
|
||||||
|
})
|
||||||
|
require.NoError(t, err, "Should create kobo device")
|
||||||
|
_ = device
|
||||||
|
|
||||||
|
t.Run("unauthenticated Kobo markup returns 401", func(t *testing.T) {
|
||||||
|
resp := doReq(t, "POST", setup.Server.URL+"/api/sync/kobo/invalid-token/markup", map[string]interface{}{
|
||||||
|
"ReadingSync": []map[string]interface{}{
|
||||||
|
{"ContentId": uuid.New().String(), "PercentRead": 50.0},
|
||||||
|
},
|
||||||
|
}, "")
|
||||||
|
defer resp.Body.Close()
|
||||||
|
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||||
|
})
|
||||||
|
|
||||||
|
mediaItemID := createTestMediaItemID(t, setup)
|
||||||
|
|
||||||
|
t.Run("ReadingSync then last-read-place preserves percentage", func(t *testing.T) {
|
||||||
|
readingSyncBody := map[string]interface{}{
|
||||||
|
"ReadingSync": []map[string]interface{}{
|
||||||
|
{
|
||||||
|
"ContentId": mediaItemID,
|
||||||
|
"PercentRead": 55.0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := doReq(t, "POST", fmt.Sprintf("%s/api/sync/kobo/%s/markup", setup.Server.URL, koboDeviceToken), readingSyncBody, "")
|
||||||
|
defer resp.Body.Close()
|
||||||
|
io.ReadAll(resp.Body)
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|
||||||
|
bookmarkBody := map[string]interface{}{
|
||||||
|
"BookmarkSync": []map[string]interface{}{
|
||||||
|
{
|
||||||
|
"ContentId": mediaItemID,
|
||||||
|
"BookmarkId": "epubcfi(/6/4!/4/2/1:0)",
|
||||||
|
"BookmarkType": "last-read-place",
|
||||||
|
"Chapter": 5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp2 := doReq(t, "POST", fmt.Sprintf("%s/api/sync/kobo/%s/markup", setup.Server.URL, koboDeviceToken), bookmarkBody, "")
|
||||||
|
defer resp2.Body.Close()
|
||||||
|
io.ReadAll(resp2.Body)
|
||||||
|
assert.Equal(t, http.StatusOK, resp2.StatusCode)
|
||||||
|
|
||||||
|
mediaUUID, _ := uuid.Parse(mediaItemID)
|
||||||
|
progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{
|
||||||
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||||
|
UserID: pgUserID,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, progress.Percentage.Valid)
|
||||||
|
assert.InDelta(t, 0.55, progress.Percentage.Float64, 0.01, "percentage should still be 55% from ReadingSync")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Kobo last-read-place without prior ReadingSync sets epubcfi and chapter", func(t *testing.T) {
|
||||||
|
newMediaID := createTestMediaItemID(t, setup)
|
||||||
|
newMediaUUID, _ := uuid.Parse(newMediaID)
|
||||||
|
|
||||||
|
bookmarkBody := map[string]interface{}{
|
||||||
|
"BookmarkSync": []map[string]interface{}{
|
||||||
|
{
|
||||||
|
"ContentId": newMediaID,
|
||||||
|
"BookmarkId": "epubcfi(/6/14!/4/2/1:0)",
|
||||||
|
"BookmarkType": "last-read-place",
|
||||||
|
"Chapter": 3,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := doReq(t, "POST", fmt.Sprintf("%s/api/sync/kobo/%s/markup", setup.Server.URL, koboDeviceToken), bookmarkBody, "")
|
||||||
|
defer resp.Body.Close()
|
||||||
|
io.ReadAll(resp.Body)
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{
|
||||||
|
MediaItemID: pgtype.UUID{Bytes: newMediaUUID, Valid: true},
|
||||||
|
UserID: pgUserID,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, progress.Epubcfi.Valid, "epubcfi should be set from last-read-place")
|
||||||
|
assert.True(t, progress.Chapter.Valid, "chapter should be set from last-read-place")
|
||||||
|
assert.Equal(t, int32(3), progress.Chapter.Int32)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProgressWeb_KOReaderIntegration(t *testing.T) {
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
mediaItemID := createTestMediaItemID(t, setup)
|
||||||
|
userID := getTestUserID(t, setup.DB)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
koreaderDeviceToken := fmt.Sprintf("dev_%s", uuid.New().String())
|
||||||
|
pgUserID := pgtype.UUID{Bytes: userID, Valid: true}
|
||||||
|
_, err := setup.DB.CreateDevice(ctx, database.CreateDeviceParams{
|
||||||
|
UserID: pgUserID,
|
||||||
|
DeviceName: "Test KOReader Progress",
|
||||||
|
DeviceType: "koreader",
|
||||||
|
DeviceIdentifier: "koreader-progress-test",
|
||||||
|
AuthToken: koreaderDeviceToken,
|
||||||
|
SyncEnabled: pgtype.Bool{Bool: true, Valid: true},
|
||||||
|
AutoSync: pgtype.Bool{Bool: true, Valid: true},
|
||||||
|
SyncFrequencyMinutes: pgtype.Int4{Int32: 5, Valid: true},
|
||||||
|
DeviceMetadata: []byte("{}"),
|
||||||
|
})
|
||||||
|
require.NoError(t, err, "Should create koreader device")
|
||||||
|
|
||||||
|
t.Run("KOReader progress sync via HTTP", func(t *testing.T) {
|
||||||
|
progressBody := map[string]interface{}{
|
||||||
|
"books": []map[string]interface{}{
|
||||||
|
{
|
||||||
|
"file_path": "/tmp/test.epub",
|
||||||
|
"percentage": 0.42,
|
||||||
|
"chapter": 3,
|
||||||
|
"device_info": map[string]interface{}{
|
||||||
|
"device_model": "Test Device",
|
||||||
|
"koreader_version": "1.0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyBytes, _ := json.Marshal(progressBody)
|
||||||
|
req, err := http.NewRequest("POST", setup.Server.URL+"/api/sync/koreader/progress", bytes.NewBuffer(bodyBytes))
|
||||||
|
require.NoError(t, err)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+koreaderDeviceToken)
|
||||||
|
|
||||||
|
resp, err := (&http.Client{}).Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
io.ReadAll(resp.Body)
|
||||||
|
assert.Equal(t, http.StatusAccepted, resp.StatusCode)
|
||||||
|
|
||||||
|
mediaUUID, _ := uuid.Parse(mediaItemID)
|
||||||
|
progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{
|
||||||
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||||
|
UserID: pgUserID,
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
assert.InDelta(t, 0.42, progress.Percentage.Float64, 0.01)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("unauthenticated KOReader sync returns 401", func(t *testing.T) {
|
||||||
|
resp := doReq(t, "POST", setup.Server.URL+"/api/sync/koreader/progress", map[string]interface{}{
|
||||||
|
"books": []map[string]interface{}{
|
||||||
|
{"file_path": "/tmp/test.epub", "percentage": 0.5},
|
||||||
|
},
|
||||||
|
}, "")
|
||||||
|
defer resp.Body.Close()
|
||||||
|
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProgressWeb_DeleteProgress(t *testing.T) {
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
mediaItemID := createTestMediaItemID(t, setup)
|
||||||
|
url := progressURL(setup.Server.URL, mediaItemID)
|
||||||
|
|
||||||
|
t.Run("DELETE removes progress", func(t *testing.T) {
|
||||||
|
resp := doReq(t, "PUT", url, map[string]interface{}{
|
||||||
|
"percentage": 0.75,
|
||||||
|
"epubcfi": "epubcfi(/6/4/2:20)",
|
||||||
|
"chapter": 7,
|
||||||
|
}, setup.Token)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
delResp := doReq(t, "DELETE", url, nil, setup.Token)
|
||||||
|
defer delResp.Body.Close()
|
||||||
|
assert.Equal(t, http.StatusOK, delResp.StatusCode)
|
||||||
|
|
||||||
|
getResp := doReq(t, "GET", url, nil, setup.Token)
|
||||||
|
defer getResp.Body.Close()
|
||||||
|
result := decodeJSON(t, getResp)
|
||||||
|
assert.Equal(t, float64(0), result["current_page"], "progress should be cleared after delete")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("DELETE without auth returns 401", func(t *testing.T) {
|
||||||
|
resp := doReq(t, "DELETE", url, nil, "")
|
||||||
|
defer resp.Body.Close()
|
||||||
|
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProgressWeb_EdgeCases(t *testing.T) {
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
mediaItemID := createTestMediaItemID(t, setup)
|
||||||
|
url := progressURL(setup.Server.URL, mediaItemID)
|
||||||
|
|
||||||
|
t.Run("PUT with empty body still succeeds", func(t *testing.T) {
|
||||||
|
resp := doReq(t, "PUT", url, map[string]interface{}{}, setup.Token)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("PUT percentage 0.0", func(t *testing.T) {
|
||||||
|
resp := doReq(t, "PUT", url, map[string]interface{}{
|
||||||
|
"percentage": 0.0,
|
||||||
|
}, setup.Token)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
getResp := doReq(t, "GET", url, nil, setup.Token)
|
||||||
|
defer getResp.Body.Close()
|
||||||
|
require.Equal(t, http.StatusOK, getResp.StatusCode)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("PUT percentage 1.0", func(t *testing.T) {
|
||||||
|
resp := doReq(t, "PUT", url, map[string]interface{}{
|
||||||
|
"percentage": 1.0,
|
||||||
|
}, setup.Token)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("PUT with all fields then GET verifies each", func(t *testing.T) {
|
||||||
|
mediaItemID2 := createTestMediaItemID(t, setup)
|
||||||
|
url2 := progressURL(setup.Server.URL, mediaItemID2)
|
||||||
|
mediaUUID2, _ := uuid.Parse(mediaItemID2)
|
||||||
|
userID := getTestUserID(t, setup.DB)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
resp := doReq(t, "PUT", url2, map[string]interface{}{
|
||||||
|
"percentage": 0.42,
|
||||||
|
"current_page": 84,
|
||||||
|
"total_pages": 200,
|
||||||
|
"epubcfi": "epubcfi(/6/4!/4/2/1:0)",
|
||||||
|
"chapter": 3,
|
||||||
|
"chapter_progress": 0.5,
|
||||||
|
"character_offset": 15000,
|
||||||
|
"reading_mode": "page",
|
||||||
|
"zoom_level": 1.5,
|
||||||
|
"scroll_position_x": 0.0,
|
||||||
|
"scroll_position_y": 100.0,
|
||||||
|
}, setup.Token)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{
|
||||||
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID2, Valid: true},
|
||||||
|
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, progress.Percentage.Valid)
|
||||||
|
assert.InDelta(t, 0.42, progress.Percentage.Float64, 0.01)
|
||||||
|
assert.True(t, progress.Chapter.Valid)
|
||||||
|
assert.Equal(t, int32(3), progress.Chapter.Int32)
|
||||||
|
assert.True(t, progress.Epubcfi.Valid)
|
||||||
|
assert.Equal(t, "epubcfi(/6/4!/4/2/1:0)", progress.Epubcfi.String)
|
||||||
|
assert.True(t, progress.CurrentPage.Valid)
|
||||||
|
assert.Equal(t, int32(84), progress.CurrentPage.Int32)
|
||||||
|
assert.True(t, progress.TotalPages.Valid)
|
||||||
|
assert.Equal(t, int32(200), progress.TotalPages.Int32)
|
||||||
|
assert.True(t, progress.ReadingMode.Valid)
|
||||||
|
assert.Equal(t, "page", progress.ReadingMode.String)
|
||||||
|
assert.True(t, progress.ZoomLevel.Valid)
|
||||||
|
assert.InDelta(t, 1.5, progress.ZoomLevel.Float64, 0.01)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDeletedUserTokenRejected pins the ghost-session guard: a
|
||||||
|
// signature-valid JWT for a user that has been DELETED must stop
|
||||||
|
// authenticating (401 user_not_found), not serve a phantom session.
|
||||||
|
// Before the guard, deleted users kept full API access until token
|
||||||
|
// expiry (observed live on hardware 2026-10-09).
|
||||||
|
func TestDeletedUserTokenRejected(t *testing.T) {
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Create a dedicated user, then log in as it to get a fresh token.
|
||||||
|
// Admin-created (not self-registered) so the test stays deterministic
|
||||||
|
// regardless of the registration_enabled setting.
|
||||||
|
createResp := doReq(t, "POST", setup.Server.URL+"/api/auth/users", map[string]interface{}{
|
||||||
|
"email": "ghost-test@tests.bookhoard.internal",
|
||||||
|
"username": "ghosttest",
|
||||||
|
"password": "Ghost@Pass123!",
|
||||||
|
}, setup.Token)
|
||||||
|
defer createResp.Body.Close()
|
||||||
|
if createResp.StatusCode != http.StatusCreated {
|
||||||
|
t.Skipf("admin user creation unavailable (status %d)", createResp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
loginResp := doReq(t, "POST", setup.Server.URL+"/api/auth/login", map[string]interface{}{
|
||||||
|
"login": "ghost-test@tests.bookhoard.internal",
|
||||||
|
"password": "Ghost@Pass123!",
|
||||||
|
}, "")
|
||||||
|
defer loginResp.Body.Close()
|
||||||
|
require.Equal(t, http.StatusOK, loginResp.StatusCode)
|
||||||
|
var loginBody struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.NewDecoder(loginResp.Body).Decode(&loginBody))
|
||||||
|
require.NotEmpty(t, loginBody.AccessToken)
|
||||||
|
|
||||||
|
ghostToken := loginBody.AccessToken
|
||||||
|
|
||||||
|
// Sanity: the token works while the user exists (user-scoped endpoint
|
||||||
|
// a freshly-registered regular user can access).
|
||||||
|
okResp := doReq(t, "GET", setup.Server.URL+"/api/collections", nil, ghostToken)
|
||||||
|
defer okResp.Body.Close()
|
||||||
|
require.Equal(t, http.StatusOK, okResp.StatusCode, "token must work while the user exists")
|
||||||
|
|
||||||
|
// Delete the user directly via the DB for determinism (same query the
|
||||||
|
// suite's own cleanup uses).
|
||||||
|
ghostID := ""
|
||||||
|
users, err := setup.DB.ListUsers(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
for _, u := range users {
|
||||||
|
if u.Email == "ghost-test@tests.bookhoard.internal" {
|
||||||
|
ghostID = uuid.UUID(u.ID.Bytes).String()
|
||||||
|
require.NoError(t, setup.DB.DeleteUser(ctx, u.ID))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require.NotEmpty(t, ghostID, "ghost user row must exist before deletion")
|
||||||
|
|
||||||
|
// The same token must now be rejected with 401 user_not_found.
|
||||||
|
resp := doReq(t, "GET", setup.Server.URL+"/api/collections", nil, ghostToken)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode, "ghost token must be rejected")
|
||||||
|
|
||||||
|
var errBody map[string]string
|
||||||
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&errBody))
|
||||||
|
assert.Contains(t, errBody["message"], "user_not_found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProgressWeb_LocatorCoherence pins the locator coherence rule: a
|
||||||
|
// percentage-only write that DECLARES no locator (ClearLocator) must clear
|
||||||
|
// any inherited stale anchor instead of re-pairing the new percentage with
|
||||||
|
// the previous writer's CFI/context — the incoherent row shape that fed the
|
||||||
|
// client-side far-anchor restore floor. Request-carried fields always win.
|
||||||
|
func TestProgressWeb_LocatorCoherence(t *testing.T) {
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
userID := getTestUserID(t, setup.DB)
|
||||||
|
pgUserID := pgtype.UUID{Bytes: userID, Valid: true}
|
||||||
|
|
||||||
|
seedAnchor := func(t *testing.T, mediaItemID string) uuid.UUID {
|
||||||
|
mediaUUID, _ := uuid.Parse(mediaItemID)
|
||||||
|
_, err := setup.ProgressService.SaveProgress(ctx, wsync.SaveProgressRequest{
|
||||||
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||||
|
UserID: pgUserID,
|
||||||
|
Source: "web",
|
||||||
|
Percentage: pFloat64(0.3),
|
||||||
|
Epubcfi: pStr("epubcfi(/6/4/2:first)"),
|
||||||
|
ContextText: pStr("some anchor context sentence"),
|
||||||
|
DeviceType: "web",
|
||||||
|
Broadcast: false,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{
|
||||||
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||||
|
UserID: pgUserID,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, progress.Epubcfi.Valid, "seed: anchor stored")
|
||||||
|
return mediaUUID
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("percentage-only kobo write clears the inherited anchor", func(t *testing.T) {
|
||||||
|
mediaItemID := createTestMediaItemID(t, setup)
|
||||||
|
mediaUUID := seedAnchor(t, mediaItemID)
|
||||||
|
|
||||||
|
_, err := setup.ProgressService.SaveProgress(ctx, wsync.SaveProgressRequest{
|
||||||
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||||
|
UserID: pgUserID,
|
||||||
|
Source: "kobo",
|
||||||
|
Percentage: pFloat64(0.6),
|
||||||
|
DeviceType: "kobo",
|
||||||
|
ClearLocator: true,
|
||||||
|
Broadcast: false,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{
|
||||||
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||||
|
UserID: pgUserID,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.InDelta(t, 0.6, progress.Percentage.Float64, 0.001, "percentage updated")
|
||||||
|
assert.False(t, progress.Epubcfi.Valid, "stale anchor cleared, not re-paired")
|
||||||
|
assert.False(t, progress.ContextText.Valid, "stale context cleared")
|
||||||
|
assert.Equal(t, "kobo", progress.LastSyncSource.String)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("request-carried context survives the clear", func(t *testing.T) {
|
||||||
|
mediaItemID := createTestMediaItemID(t, setup)
|
||||||
|
mediaUUID := seedAnchor(t, mediaItemID)
|
||||||
|
|
||||||
|
_, err := setup.ProgressService.SaveProgress(ctx, wsync.SaveProgressRequest{
|
||||||
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||||
|
UserID: pgUserID,
|
||||||
|
Source: "koreader",
|
||||||
|
Percentage: pFloat64(0.7),
|
||||||
|
ContextText: pStr("a brand new context sentence"),
|
||||||
|
DeviceType: "koreader",
|
||||||
|
ClearLocator: true,
|
||||||
|
Broadcast: false,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{
|
||||||
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||||
|
UserID: pgUserID,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.InDelta(t, 0.7, progress.Percentage.Float64, 0.001)
|
||||||
|
assert.True(t, progress.ContextText.Valid, "request-carried context kept")
|
||||||
|
assert.Equal(t, "a brand new context sentence", progress.ContextText.String)
|
||||||
|
// The verify gate is inert in the test harness (no media path
|
||||||
|
// resolver wired), so no anchor can be minted here; the rule
|
||||||
|
// under test is that the STALE one is gone.
|
||||||
|
assert.False(t, progress.Epubcfi.Valid, "stale anchor cleared")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("HTTP merge semantics untouched: key-absent PUT preserves fields", func(t *testing.T) {
|
||||||
|
mediaItemID := createTestMediaItemID(t, setup)
|
||||||
|
mediaUUID := seedAnchor(t, mediaItemID)
|
||||||
|
|
||||||
|
// Service-level save WITHOUT ClearLocator mimics the HTTP merge
|
||||||
|
// path (field absent = inherit): the anchor must survive.
|
||||||
|
_, err := setup.ProgressService.SaveProgress(ctx, wsync.SaveProgressRequest{
|
||||||
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||||
|
UserID: pgUserID,
|
||||||
|
Source: "queue",
|
||||||
|
Percentage: pFloat64(0.4),
|
||||||
|
Broadcast: false,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{
|
||||||
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||||
|
UserID: pgUserID,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, progress.Epubcfi.Valid, "merge path preserves the anchor")
|
||||||
|
assert.Equal(t, "epubcfi(/6/4/2:first)", progress.Epubcfi.String)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -24,7 +25,9 @@ func TestRefreshTokenFlow(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -40,7 +43,9 @@ func TestRefreshTokenFlow(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -58,12 +63,15 @@ func TestRefreshTokenFlow(t *testing.T) {
|
|||||||
|
|
||||||
loginResp, err := client.Do(loginHTTP)
|
loginResp, err := client.Do(loginHTTP)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer loginResp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(loginResp.Body)
|
||||||
|
|
||||||
require.Equal(t, http.StatusOK, loginResp.StatusCode)
|
require.Equal(t, http.StatusOK, loginResp.StatusCode)
|
||||||
|
|
||||||
var loginResult map[string]interface{}
|
var loginResult map[string]interface{}
|
||||||
json.NewDecoder(loginResp.Body).Decode(&loginResult)
|
err = json.NewDecoder(loginResp.Body).Decode(&loginResult)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
refreshToken, ok := loginResult["refresh_token"].(string)
|
refreshToken, ok := loginResult["refresh_token"].(string)
|
||||||
require.True(t, ok, "Should have refresh_token")
|
require.True(t, ok, "Should have refresh_token")
|
||||||
@@ -79,12 +87,15 @@ func TestRefreshTokenFlow(t *testing.T) {
|
|||||||
|
|
||||||
refreshResp, err := client.Do(refreshHTTP)
|
refreshResp, err := client.Do(refreshHTTP)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer refreshResp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(refreshResp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, refreshResp.StatusCode)
|
assert.Equal(t, http.StatusOK, refreshResp.StatusCode)
|
||||||
|
|
||||||
var refreshResult map[string]interface{}
|
var refreshResult map[string]interface{}
|
||||||
json.NewDecoder(refreshResp.Body).Decode(&refreshResult)
|
err = json.NewDecoder(refreshResp.Body).Decode(&refreshResult)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
assert.Contains(t, refreshResult, "access_token")
|
assert.Contains(t, refreshResult, "access_token")
|
||||||
assert.NotEmpty(t, refreshResult["access_token"], "New access token should not be empty")
|
assert.NotEmpty(t, refreshResult["access_token"], "New access token should not be empty")
|
||||||
@@ -102,7 +113,9 @@ func TestRefreshTokenFlow(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -118,7 +131,9 @@ func TestRefreshTokenFlow(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
// Should still work or return appropriate error
|
// Should still work or return appropriate error
|
||||||
assert.True(t, resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusBadRequest || resp.StatusCode == http.StatusUnsupportedMediaType)
|
assert.True(t, resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusBadRequest || resp.StatusCode == http.StatusUnsupportedMediaType)
|
||||||
@@ -143,12 +158,15 @@ func TestRefreshTokenSecurity(t *testing.T) {
|
|||||||
|
|
||||||
loginResp, err := client.Do(loginHTTP)
|
loginResp, err := client.Do(loginHTTP)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer loginResp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(loginResp.Body)
|
||||||
|
|
||||||
require.Equal(t, http.StatusOK, loginResp.StatusCode)
|
require.Equal(t, http.StatusOK, loginResp.StatusCode)
|
||||||
|
|
||||||
var loginResult map[string]interface{}
|
var loginResult map[string]interface{}
|
||||||
json.NewDecoder(loginResp.Body).Decode(&loginResult)
|
err = json.NewDecoder(loginResp.Body).Decode(&loginResult)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
refreshToken := loginResult["refresh_token"].(string)
|
refreshToken := loginResult["refresh_token"].(string)
|
||||||
|
|
||||||
@@ -163,7 +181,9 @@ func TestRefreshTokenSecurity(t *testing.T) {
|
|||||||
|
|
||||||
refreshResp1, err := client.Do(refreshHTTP1)
|
refreshResp1, err := client.Do(refreshHTTP1)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer refreshResp1.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(refreshResp1.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, refreshResp1.StatusCode)
|
assert.Equal(t, http.StatusOK, refreshResp1.StatusCode)
|
||||||
|
|
||||||
@@ -173,7 +193,9 @@ func TestRefreshTokenSecurity(t *testing.T) {
|
|||||||
|
|
||||||
refreshResp2, err := client.Do(refreshHTTP2)
|
refreshResp2, err := client.Do(refreshHTTP2)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer refreshResp2.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(refreshResp2.Body)
|
||||||
|
|
||||||
// May return 401 if token reuse is detected, or 200 if not implemented
|
// May return 401 if token reuse is detected, or 200 if not implemented
|
||||||
// Either is acceptable depending on security requirements
|
// Either is acceptable depending on security requirements
|
||||||
@@ -197,7 +219,9 @@ func TestRefreshTokenEdgeCases(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -213,7 +237,9 @@ func TestRefreshTokenEdgeCases(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -231,12 +257,15 @@ func TestRefreshTokenEdgeCases(t *testing.T) {
|
|||||||
|
|
||||||
loginResp, err := client.Do(loginHTTP)
|
loginResp, err := client.Do(loginHTTP)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer loginResp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(loginResp.Body)
|
||||||
|
|
||||||
require.Equal(t, http.StatusOK, loginResp.StatusCode)
|
require.Equal(t, http.StatusOK, loginResp.StatusCode)
|
||||||
|
|
||||||
var loginResult map[string]interface{}
|
var loginResult map[string]interface{}
|
||||||
json.NewDecoder(loginResp.Body).Decode(&loginResult)
|
err = json.NewDecoder(loginResp.Body).Decode(&loginResult)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
refreshToken := loginResult["refresh_token"].(string)
|
refreshToken := loginResult["refresh_token"].(string)
|
||||||
|
|
||||||
@@ -251,12 +280,15 @@ func TestRefreshTokenEdgeCases(t *testing.T) {
|
|||||||
|
|
||||||
refreshResp, err := client.Do(refreshHTTP)
|
refreshResp, err := client.Do(refreshHTTP)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer refreshResp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(refreshResp.Body)
|
||||||
|
|
||||||
require.Equal(t, http.StatusOK, refreshResp.StatusCode)
|
require.Equal(t, http.StatusOK, refreshResp.StatusCode)
|
||||||
|
|
||||||
var refreshResult map[string]interface{}
|
var refreshResult map[string]interface{}
|
||||||
json.NewDecoder(refreshResp.Body).Decode(&refreshResult)
|
err = json.NewDecoder(refreshResp.Body).Decode(&refreshResult)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Verify response structure
|
// Verify response structure
|
||||||
assert.Contains(t, refreshResult, "access_token")
|
assert.Contains(t, refreshResult, "access_token")
|
||||||
@@ -279,12 +311,15 @@ func TestRefreshTokenEdgeCases(t *testing.T) {
|
|||||||
|
|
||||||
loginResp, err := client.Do(loginHTTP)
|
loginResp, err := client.Do(loginHTTP)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer loginResp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(loginResp.Body)
|
||||||
|
|
||||||
require.Equal(t, http.StatusOK, loginResp.StatusCode)
|
require.Equal(t, http.StatusOK, loginResp.StatusCode)
|
||||||
|
|
||||||
var loginResult map[string]interface{}
|
var loginResult map[string]interface{}
|
||||||
json.NewDecoder(loginResp.Body).Decode(&loginResult)
|
err = json.NewDecoder(loginResp.Body).Decode(&loginResult)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
refreshToken := loginResult["refresh_token"].(string)
|
refreshToken := loginResult["refresh_token"].(string)
|
||||||
|
|
||||||
@@ -299,12 +334,15 @@ func TestRefreshTokenEdgeCases(t *testing.T) {
|
|||||||
|
|
||||||
refreshResp, err := client.Do(refreshHTTP)
|
refreshResp, err := client.Do(refreshHTTP)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer refreshResp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(refreshResp.Body)
|
||||||
|
|
||||||
require.Equal(t, http.StatusOK, refreshResp.StatusCode)
|
require.Equal(t, http.StatusOK, refreshResp.StatusCode)
|
||||||
|
|
||||||
var refreshResult map[string]interface{}
|
var refreshResult map[string]interface{}
|
||||||
json.NewDecoder(refreshResp.Body).Decode(&refreshResult)
|
err = json.NewDecoder(refreshResp.Body).Decode(&refreshResult)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Verify access token is a string
|
// Verify access token is a string
|
||||||
accessToken, ok := refreshResult["access_token"].(string)
|
accessToken, ok := refreshResult["access_token"].(string)
|
||||||
|
|||||||
@@ -25,8 +25,9 @@ func TestScanSettings_GetSettings(t *testing.T) {
|
|||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var response map[string]interface{}
|
var response map[string]interface{}
|
||||||
json.NewDecoder(resp.Body).Decode(&response)
|
err = json.NewDecoder(resp.Body).Decode(&response)
|
||||||
resp.Body.Close()
|
require.NoError(t, err)
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
|
||||||
assert.Contains(t, response, "scan_poll_interval_seconds")
|
assert.Contains(t, response, "scan_poll_interval_seconds")
|
||||||
assert.Contains(t, response, "auto_scan_enabled")
|
assert.Contains(t, response, "auto_scan_enabled")
|
||||||
@@ -63,8 +64,9 @@ func TestScanSettings_UpdateSettings(t *testing.T) {
|
|||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var response map[string]interface{}
|
var response map[string]interface{}
|
||||||
json.NewDecoder(resp.Body).Decode(&response)
|
err = json.NewDecoder(resp.Body).Decode(&response)
|
||||||
resp.Body.Close()
|
require.NoError(t, err)
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
|
||||||
assert.Equal(t, float64(45), response["scan_poll_interval_seconds"])
|
assert.Equal(t, float64(45), response["scan_poll_interval_seconds"])
|
||||||
assert.Equal(t, true, response["auto_scan_enabled"])
|
assert.Equal(t, true, response["auto_scan_enabled"])
|
||||||
@@ -74,8 +76,9 @@ func TestScanSettings_UpdateSettings(t *testing.T) {
|
|||||||
getResp, _ := client.Do(getReq)
|
getResp, _ := client.Do(getReq)
|
||||||
|
|
||||||
var getResponse map[string]interface{}
|
var getResponse map[string]interface{}
|
||||||
json.NewDecoder(getResp.Body).Decode(&getResponse)
|
err = json.NewDecoder(getResp.Body).Decode(&getResponse)
|
||||||
getResp.Body.Close()
|
require.NoError(t, err)
|
||||||
|
_ = getResp.Body.Close()
|
||||||
|
|
||||||
assert.Equal(t, float64(45), getResponse["scan_poll_interval_seconds"])
|
assert.Equal(t, float64(45), getResponse["scan_poll_interval_seconds"])
|
||||||
})
|
})
|
||||||
@@ -99,8 +102,9 @@ func TestScanSettings_UpdateSettings(t *testing.T) {
|
|||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var response map[string]interface{}
|
var response map[string]interface{}
|
||||||
json.NewDecoder(resp.Body).Decode(&response)
|
err = json.NewDecoder(resp.Body).Decode(&response)
|
||||||
resp.Body.Close()
|
require.NoError(t, err)
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
|
||||||
assert.Equal(t, false, response["auto_scan_enabled"])
|
assert.Equal(t, false, response["auto_scan_enabled"])
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -46,8 +46,9 @@ func (s *ScannerIntegrationTestSuite) TestScanProgress_TracksStatistics() {
|
|||||||
require.Equal(s.T(), http.StatusCreated, createLibResp.StatusCode)
|
require.Equal(s.T(), http.StatusCreated, createLibResp.StatusCode)
|
||||||
|
|
||||||
var createLibResponse map[string]interface{}
|
var createLibResponse map[string]interface{}
|
||||||
json.NewDecoder(createLibResp.Body).Decode(&createLibResponse)
|
err = json.NewDecoder(createLibResp.Body).Decode(&createLibResponse)
|
||||||
createLibResp.Body.Close()
|
require.NoError(s.T(), err)
|
||||||
|
_ = createLibResp.Body.Close()
|
||||||
|
|
||||||
libraryID, ok := createLibResponse["id"].(string)
|
libraryID, ok := createLibResponse["id"].(string)
|
||||||
require.True(s.T(), ok, "library_id should be string")
|
require.True(s.T(), ok, "library_id should be string")
|
||||||
@@ -64,7 +65,7 @@ func (s *ScannerIntegrationTestSuite) TestScanProgress_TracksStatistics() {
|
|||||||
|
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(s.T(), err)
|
require.NoError(s.T(), err)
|
||||||
resp.Body.Close()
|
_ = resp.Body.Close()
|
||||||
require.Equal(s.T(), http.StatusCreated, resp.StatusCode, "Folder creation should succeed")
|
require.Equal(s.T(), http.StatusCreated, resp.StatusCode, "Folder creation should succeed")
|
||||||
|
|
||||||
scanURL := fmt.Sprintf("%s/api/libraries/%s/scan", s.setup.Server.URL, libraryID)
|
scanURL := fmt.Sprintf("%s/api/libraries/%s/scan", s.setup.Server.URL, libraryID)
|
||||||
@@ -78,7 +79,7 @@ func (s *ScannerIntegrationTestSuite) TestScanProgress_TracksStatistics() {
|
|||||||
var scanResponse map[string]interface{}
|
var scanResponse map[string]interface{}
|
||||||
err = json.NewDecoder(scanResp.Body).Decode(&scanResponse)
|
err = json.NewDecoder(scanResp.Body).Decode(&scanResponse)
|
||||||
require.NoError(s.T(), err)
|
require.NoError(s.T(), err)
|
||||||
scanResp.Body.Close()
|
_ = scanResp.Body.Close()
|
||||||
|
|
||||||
jobID, ok := scanResponse["job_id"].(string)
|
jobID, ok := scanResponse["job_id"].(string)
|
||||||
require.True(s.T(), ok, "job_id should be string")
|
require.True(s.T(), ok, "job_id should be string")
|
||||||
@@ -104,7 +105,7 @@ func (s *ScannerIntegrationTestSuite) TestScanProgress_TracksStatistics() {
|
|||||||
require.NoError(s.T(), err)
|
require.NoError(s.T(), err)
|
||||||
|
|
||||||
if statusResp.StatusCode == http.StatusNotFound {
|
if statusResp.StatusCode == http.StatusNotFound {
|
||||||
statusResp.Body.Close()
|
_ = statusResp.Body.Close()
|
||||||
if gotProgressUpdate {
|
if gotProgressUpdate {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -113,7 +114,7 @@ func (s *ScannerIntegrationTestSuite) TestScanProgress_TracksStatistics() {
|
|||||||
|
|
||||||
var status map[string]interface{}
|
var status map[string]interface{}
|
||||||
err = json.NewDecoder(statusResp.Body).Decode(&status)
|
err = json.NewDecoder(statusResp.Body).Decode(&status)
|
||||||
statusResp.Body.Close()
|
_ = statusResp.Body.Close()
|
||||||
require.NoError(s.T(), err)
|
require.NoError(s.T(), err)
|
||||||
|
|
||||||
if _, hasError := status["error"]; hasError {
|
if _, hasError := status["error"]; hasError {
|
||||||
@@ -180,8 +181,9 @@ func (s *ScannerIntegrationTestSuite) TestScanProgress_BatchingWorks() {
|
|||||||
require.NoError(s.T(), err)
|
require.NoError(s.T(), err)
|
||||||
|
|
||||||
var createLibResponse map[string]interface{}
|
var createLibResponse map[string]interface{}
|
||||||
json.NewDecoder(createLibResp.Body).Decode(&createLibResponse)
|
err = json.NewDecoder(createLibResp.Body).Decode(&createLibResponse)
|
||||||
createLibResp.Body.Close()
|
require.NoError(s.T(), err)
|
||||||
|
_ = createLibResp.Body.Close()
|
||||||
|
|
||||||
libraryID, ok := createLibResponse["id"].(string)
|
libraryID, ok := createLibResponse["id"].(string)
|
||||||
require.True(s.T(), ok, "library_id should be string")
|
require.True(s.T(), ok, "library_id should be string")
|
||||||
@@ -198,7 +200,7 @@ func (s *ScannerIntegrationTestSuite) TestScanProgress_BatchingWorks() {
|
|||||||
|
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(s.T(), err)
|
require.NoError(s.T(), err)
|
||||||
resp.Body.Close()
|
_ = resp.Body.Close()
|
||||||
|
|
||||||
scanURL := fmt.Sprintf("%s/api/libraries/%s/scan", s.setup.Server.URL, libraryID)
|
scanURL := fmt.Sprintf("%s/api/libraries/%s/scan", s.setup.Server.URL, libraryID)
|
||||||
scanReq, _ := http.NewRequest("POST", scanURL, nil)
|
scanReq, _ := http.NewRequest("POST", scanURL, nil)
|
||||||
@@ -208,8 +210,9 @@ func (s *ScannerIntegrationTestSuite) TestScanProgress_BatchingWorks() {
|
|||||||
require.NoError(s.T(), err)
|
require.NoError(s.T(), err)
|
||||||
|
|
||||||
var scanResponse map[string]interface{}
|
var scanResponse map[string]interface{}
|
||||||
json.NewDecoder(scanResp.Body).Decode(&scanResponse)
|
err = json.NewDecoder(scanResp.Body).Decode(&scanResponse)
|
||||||
scanResp.Body.Close()
|
require.NoError(s.T(), err)
|
||||||
|
_ = scanResp.Body.Close()
|
||||||
|
|
||||||
jobID, ok := scanResponse["job_id"].(string)
|
jobID, ok := scanResponse["job_id"].(string)
|
||||||
require.True(s.T(), ok, "job_id should be string")
|
require.True(s.T(), ok, "job_id should be string")
|
||||||
@@ -231,14 +234,14 @@ func (s *ScannerIntegrationTestSuite) TestScanProgress_BatchingWorks() {
|
|||||||
require.NoError(s.T(), err)
|
require.NoError(s.T(), err)
|
||||||
|
|
||||||
if statusResp.StatusCode == http.StatusNotFound {
|
if statusResp.StatusCode == http.StatusNotFound {
|
||||||
statusResp.Body.Close()
|
_ = statusResp.Body.Close()
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
var status map[string]interface{}
|
var status map[string]interface{}
|
||||||
err = json.NewDecoder(statusResp.Body).Decode(&status)
|
err = json.NewDecoder(statusResp.Body).Decode(&status)
|
||||||
require.NoError(s.T(), err)
|
require.NoError(s.T(), err)
|
||||||
statusResp.Body.Close()
|
_ = statusResp.Body.Close()
|
||||||
|
|
||||||
if _, hasError := status["error"]; hasError {
|
if _, hasError := status["error"]; hasError {
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"bookhoard/internal/config"
|
||||||
|
"bookhoard/internal/database"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestSchemaInitializationIsIdempotent replays the application's production
|
||||||
|
// schema initializer twice against the same database. schema.sql runs on
|
||||||
|
// EVERY app startup, so it must apply cleanly to a database where a previous
|
||||||
|
// startup already applied it. A regression here crash-loops the production
|
||||||
|
// container on its second boot (e.g. SQLSTATE 2BP01: DROP FUNCTION refused
|
||||||
|
// while a trigger created by the previous run still references it).
|
||||||
|
//
|
||||||
|
// Uses a dedicated pool: production Initialize holds the advisory lock on one
|
||||||
|
// connection while executing the schema on another, but the shared test pool
|
||||||
|
// is capped at MaxConns=1 and would deadlock.
|
||||||
|
func TestSchemaInitializationIsIdempotent(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("skipping schema re-initialization in -short mode")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bring up the standard test environment (seeds users, starts server).
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
defer func() { _ = setup.Close() }()
|
||||||
|
|
||||||
|
// Dedicated pool with default connection limits for the initializer.
|
||||||
|
poolCfg, err := pgxpool.ParseConfig(config.LoadConfig().DatabaseURL())
|
||||||
|
require.NoError(t, err, "failed to parse database URL")
|
||||||
|
pool, err := pgxpool.NewWithConfig(context.Background(), poolCfg)
|
||||||
|
require.NoError(t, err, "failed to connect for schema initialization test")
|
||||||
|
defer func() { pool.Close() }()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// First boot: applies the schema to the (possibly fresh) database.
|
||||||
|
if err := database.Initialize(ctx, pool); err != nil {
|
||||||
|
t.Fatalf("first schema initialization failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second boot against the now-initialized database: this is exactly
|
||||||
|
// where non-idempotent statements (bare CREATE TRIGGER, DROP FUNCTION
|
||||||
|
// with dependent objects, etc.) blow up.
|
||||||
|
if err := database.Initialize(ctx, pool); err != nil {
|
||||||
|
t.Fatalf("second schema initialization failed: %v — schema.sql must stay idempotent across restarts", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -384,11 +384,12 @@ func TestCollectionSearchLibraryFilter(t *testing.T) {
|
|||||||
book2ID := createTestMediaItemIDInLibrary(t, client, setup, lib2Resp["id"].(string), "Harry Potter 2")
|
book2ID := createTestMediaItemIDInLibrary(t, client, setup, lib2Resp["id"].(string), "Harry Potter 2")
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
query string
|
query string
|
||||||
libraryID string
|
libraryID string
|
||||||
expectedCount int
|
expectedCount int
|
||||||
shouldContain string // Comma-separated list of book IDs to check
|
expectedStatus int
|
||||||
|
shouldContain string // Comma-separated list of book IDs to check
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "no filter - both test books present",
|
name: "no filter - both test books present",
|
||||||
@@ -412,10 +413,10 @@ func TestCollectionSearchLibraryFilter(t *testing.T) {
|
|||||||
shouldContain: book2ID,
|
shouldContain: book2ID,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "invalid library_id",
|
name: "invalid library_id",
|
||||||
query: "Harry",
|
query: "Harry",
|
||||||
libraryID: "00000000-0000-0000-0000-000000000000",
|
libraryID: "00000000-0000-0000-0000-000000000000",
|
||||||
expectedCount: 0,
|
expectedStatus: http.StatusNotFound,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -431,16 +432,18 @@ func TestCollectionSearchLibraryFilter(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
// Log response for debugging
|
if tt.expectedStatus != 0 {
|
||||||
if resp.StatusCode != http.StatusOK {
|
require.Equal(t, tt.expectedStatus, resp.StatusCode)
|
||||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
return
|
||||||
t.Logf("ERROR %d: %s", resp.StatusCode, string(bodyBytes))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var result []map[string]interface{}
|
var result []map[string]interface{}
|
||||||
json.NewDecoder(resp.Body).Decode(&result)
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
if tt.expectedCount > 0 {
|
if tt.expectedCount > 0 {
|
||||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
@@ -483,11 +486,14 @@ func createLibrary(t *testing.T, client *http.Client, setup *TestServerSetup, na
|
|||||||
|
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||||
|
|
||||||
var result map[string]interface{}
|
var result map[string]interface{}
|
||||||
json.NewDecoder(resp.Body).Decode(&result)
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -509,10 +515,13 @@ func createTestMediaItemIDInLibrary(t *testing.T, client *http.Client, setup *Te
|
|||||||
|
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||||
|
|
||||||
var result map[string]interface{}
|
var result map[string]interface{}
|
||||||
json.NewDecoder(resp.Body).Decode(&result)
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
return result["id"].(string)
|
return result["id"].(string)
|
||||||
}
|
}
|
||||||
@@ -3,8 +3,10 @@ package main
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -30,18 +32,22 @@ func TestUnifiedSearch(t *testing.T) {
|
|||||||
folderHTTP.Header.Set("Authorization", "Bearer "+setup.UserToken)
|
folderHTTP.Header.Set("Authorization", "Bearer "+setup.UserToken)
|
||||||
folderResp, err := client.Do(folderHTTP)
|
folderResp, err := client.Do(folderHTTP)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
folderResp.Body.Close()
|
_ = folderResp.Body.Close()
|
||||||
require.Equal(t, http.StatusCreated, folderResp.StatusCode)
|
require.Equal(t, http.StatusCreated, folderResp.StatusCode)
|
||||||
|
|
||||||
// Helper to create book with fields
|
// Helper to create book with fields. Unique file_path per book: the
|
||||||
|
// create endpoint answers 200/duplicate for a repeated
|
||||||
|
// (library_id, file_path), and the old shared constant made the 2nd/3rd
|
||||||
|
// creates no-ops anyway.
|
||||||
createBook := func(title, author, genre string, year int, hasCover bool) {
|
createBook := func(title, author, genre string, year int, hasCover bool) {
|
||||||
|
slug := strings.ReplaceAll(strings.ToLower(title), " ", "-")
|
||||||
bookReq := map[string]interface{}{
|
bookReq := map[string]interface{}{
|
||||||
"library_id": libraryID,
|
"library_id": libraryID,
|
||||||
"title": title,
|
"title": title,
|
||||||
"author": author,
|
"author": author,
|
||||||
"genre": genre,
|
"genre": genre,
|
||||||
"copyright_year": year,
|
"copyright_year": year,
|
||||||
"file_path": "/tmp/test.epub",
|
"file_path": "/tmp/" + slug + ".epub",
|
||||||
"file_size": 1024,
|
"file_size": 1024,
|
||||||
"mime_type": "application/epub+zip",
|
"mime_type": "application/epub+zip",
|
||||||
}
|
}
|
||||||
@@ -54,7 +60,9 @@ func TestUnifiedSearch(t *testing.T) {
|
|||||||
req.Header.Set("Authorization", "Bearer "+setup.UserToken)
|
req.Header.Set("Authorization", "Bearer "+setup.UserToken)
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,14 +98,14 @@ func TestUnifiedSearch(t *testing.T) {
|
|||||||
assert.Equal(t, http.StatusOK, rec.Code, "Should filter by has_cover")
|
assert.Equal(t, http.StatusOK, rec.Code, "Should filter by has_cover")
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("Missing library_id", func(t *testing.T) {
|
t.Run("Missing library_id searches all libraries", func(t *testing.T) {
|
||||||
req := httptest.NewRequest("GET", "/api/media-items/search?q=test", nil)
|
req := httptest.NewRequest("GET", "/api/media-items/search?q=zzzznonexistent", nil)
|
||||||
req.Header.Set("Authorization", "Bearer "+setup.UserToken)
|
req.Header.Set("Authorization", "Bearer "+setup.UserToken)
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
setup.Server.Config.Handler.ServeHTTP(rec, req)
|
setup.Server.Config.Handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
// library_id is now optional - searches all libraries when omitted
|
// library_id is optional - searches all libraries when omitted
|
||||||
// Returns 404 when no results match the search query
|
// Returns 404 when no results match
|
||||||
assert.Equal(t, http.StatusNotFound, rec.Code, "Should return 404 when no results found")
|
assert.Equal(t, http.StatusNotFound, rec.Code, "Should return 404 when no results match")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,464 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/stretchr/testify/suite"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SeriesIntegrationTestSuite struct {
|
||||||
|
suite.Suite
|
||||||
|
setup *TestServerSetup
|
||||||
|
token string
|
||||||
|
libraryID string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SeriesIntegrationTestSuite) SetupSuite() {
|
||||||
|
s.setup = setupTestServer(s.T())
|
||||||
|
s.token = s.setup.Token
|
||||||
|
s.libraryID = createTestLibraryWithFolder(s.T(), s.setup.Server, s.token, "Test Series Library", false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SeriesIntegrationTestSuite) TearDownSuite() {
|
||||||
|
s.setup.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SeriesIntegrationTestSuite) TestGetSeries_RequiresLibraryID() {
|
||||||
|
// Ground truth (2026-10-13 visibility hardening): library_id stays
|
||||||
|
// optional, but the unscoped aggregate now covers only the requester's
|
||||||
|
// VISIBLE libraries — hidden libraries contribute nothing. Series are
|
||||||
|
// grouped per library (same name in two libraries = two entries), so
|
||||||
|
// every row carries a library_id.
|
||||||
|
req, _ := http.NewRequest("GET", s.setup.Server.URL+"/api/series", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+s.token)
|
||||||
|
|
||||||
|
client := &http.Client{}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
|
assert.Equal(s.T(), http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
var body map[string]interface{}
|
||||||
|
err = json.NewDecoder(resp.Body).Decode(&body)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
assert.Contains(s.T(), body, "series", "response carries the series array")
|
||||||
|
assert.Contains(s.T(), body, "total")
|
||||||
|
if rows, ok := body["series"].([]interface{}); ok {
|
||||||
|
for _, r := range rows {
|
||||||
|
row, ok := r.(map[string]interface{})
|
||||||
|
require.True(s.T(), ok)
|
||||||
|
assert.NotEmpty(s.T(), row["library_id"], "series rows carry their library scope")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SeriesIntegrationTestSuite) TestGetSeries_InvalidLibraryID() {
|
||||||
|
req, _ := http.NewRequest("GET", s.setup.Server.URL+"/api/series?library_id=not-a-uuid", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+s.token)
|
||||||
|
|
||||||
|
client := &http.Client{}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
|
assert.Equal(s.T(), http.StatusBadRequest, resp.StatusCode)
|
||||||
|
|
||||||
|
var body map[string]string
|
||||||
|
err = json.NewDecoder(resp.Body).Decode(&body)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
assert.Equal(s.T(), "invalid library_id", body["error"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SeriesIntegrationTestSuite) TestGetSeries_EmptyLibrary() {
|
||||||
|
url := fmt.Sprintf("%s/api/series?library_id=%s", s.setup.Server.URL, s.libraryID)
|
||||||
|
req, _ := http.NewRequest("GET", url, nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+s.token)
|
||||||
|
|
||||||
|
client := &http.Client{}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
|
assert.Equal(s.T(), http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
var body map[string]interface{}
|
||||||
|
err = json.NewDecoder(resp.Body).Decode(&body)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
|
||||||
|
series, ok := body["series"].([]interface{})
|
||||||
|
require.True(s.T(), ok, "series should be an array")
|
||||||
|
assert.Empty(s.T(), series, "empty library should have no series")
|
||||||
|
|
||||||
|
total, ok := body["total"].(float64)
|
||||||
|
require.True(s.T(), ok, "total should be a number")
|
||||||
|
assert.Equal(s.T(), float64(0), total)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SeriesIntegrationTestSuite) TestGetSeries_Unauthorized() {
|
||||||
|
req, _ := http.NewRequest("GET", s.setup.Server.URL+"/api/series?library_id="+s.libraryID, nil)
|
||||||
|
|
||||||
|
client := &http.Client{}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
|
assert.Equal(s.T(), http.StatusUnauthorized, resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SeriesIntegrationTestSuite) TestGetSeries_PaginationParams() {
|
||||||
|
url := fmt.Sprintf("%s/api/series?library_id=%s&limit=5&offset=0", s.setup.Server.URL, s.libraryID)
|
||||||
|
req, _ := http.NewRequest("GET", url, nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+s.token)
|
||||||
|
|
||||||
|
client := &http.Client{}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
|
assert.Equal(s.T(), http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
var body map[string]interface{}
|
||||||
|
err = json.NewDecoder(resp.Body).Decode(&body)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
|
||||||
|
limit, ok := body["limit"].(float64)
|
||||||
|
require.True(s.T(), ok)
|
||||||
|
assert.Equal(s.T(), float64(5), limit)
|
||||||
|
|
||||||
|
offset, ok := body["offset"].(float64)
|
||||||
|
require.True(s.T(), ok)
|
||||||
|
assert.Equal(s.T(), float64(0), offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SeriesIntegrationTestSuite) TestGetSeries_LimitClampedTo100() {
|
||||||
|
url := fmt.Sprintf("%s/api/series?library_id=%s&limit=999", s.setup.Server.URL, s.libraryID)
|
||||||
|
req, _ := http.NewRequest("GET", url, nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+s.token)
|
||||||
|
|
||||||
|
client := &http.Client{}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
|
assert.Equal(s.T(), http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
var body map[string]interface{}
|
||||||
|
err = json.NewDecoder(resp.Body).Decode(&body)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
|
||||||
|
limit, ok := body["limit"].(float64)
|
||||||
|
require.True(s.T(), ok)
|
||||||
|
assert.Equal(s.T(), float64(100), limit, "limit should be clamped to 100")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SeriesIntegrationTestSuite) TestGetSeriesBooks_LibraryIDRequired() {
|
||||||
|
// Ground truth (2026-10-13 visibility hardening): /api/series/books is
|
||||||
|
// per-library now — library_id is REQUIRED, validated, and must be
|
||||||
|
// visible to the requester. The former name-only contract let any
|
||||||
|
// authenticated account pull series books across ALL libraries
|
||||||
|
// (including hidden ones), and merged same-name series across
|
||||||
|
// libraries (manga adaptations / LN splits).
|
||||||
|
req, _ := http.NewRequest("GET", s.setup.Server.URL+"/api/series/books?name=Test+Series", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+s.token)
|
||||||
|
|
||||||
|
client := &http.Client{}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
|
assert.Equal(s.T(), http.StatusBadRequest, resp.StatusCode)
|
||||||
|
|
||||||
|
var body map[string]string
|
||||||
|
err = json.NewDecoder(resp.Body).Decode(&body)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
assert.Equal(s.T(), "library_id required", body["error"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SeriesIntegrationTestSuite) TestGetSeriesBooks_RequiresName() {
|
||||||
|
url := fmt.Sprintf("%s/api/series/books?library_id=%s", s.setup.Server.URL, s.libraryID)
|
||||||
|
req, _ := http.NewRequest("GET", url, nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+s.token)
|
||||||
|
|
||||||
|
client := &http.Client{}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
|
assert.Equal(s.T(), http.StatusBadRequest, resp.StatusCode)
|
||||||
|
|
||||||
|
var body map[string]string
|
||||||
|
err = json.NewDecoder(resp.Body).Decode(&body)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
assert.Equal(s.T(), "name required", body["error"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SeriesIntegrationTestSuite) TestGetSeriesBooks_InvalidLibraryID() {
|
||||||
|
// library_id is now validated: an unparseable uuid is a 400.
|
||||||
|
req, _ := http.NewRequest("GET", s.setup.Server.URL+"/api/series/books?library_id=bad-uuid&name=Test", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+s.token)
|
||||||
|
|
||||||
|
client := &http.Client{}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
|
assert.Equal(s.T(), http.StatusBadRequest, resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SeriesIntegrationTestSuite) TestGetSeriesBooks_NonexistentSeries() {
|
||||||
|
url := fmt.Sprintf("%s/api/series/books?library_id=%s&name=Nonexistent+Series", s.setup.Server.URL, s.libraryID)
|
||||||
|
req, _ := http.NewRequest("GET", url, nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+s.token)
|
||||||
|
|
||||||
|
client := &http.Client{}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
|
assert.Equal(s.T(), http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
var body map[string]interface{}
|
||||||
|
err = json.NewDecoder(resp.Body).Decode(&body)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
|
||||||
|
books, ok := body["books"].([]interface{})
|
||||||
|
require.True(s.T(), ok, "books should be an array")
|
||||||
|
assert.Empty(s.T(), books, "nonexistent series should return empty books array")
|
||||||
|
|
||||||
|
assert.Equal(s.T(), "Nonexistent Series", body["name"])
|
||||||
|
total, ok := body["total"].(float64)
|
||||||
|
require.True(s.T(), ok)
|
||||||
|
assert.Equal(s.T(), float64(0), total)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SeriesIntegrationTestSuite) TestGetSeriesBooks_Unauthorized() {
|
||||||
|
req, _ := http.NewRequest("GET", s.setup.Server.URL+"/api/series/books?library_id="+s.libraryID+"&name=Test", nil)
|
||||||
|
|
||||||
|
client := &http.Client{}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
|
assert.Equal(s.T(), http.StatusUnauthorized, resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SeriesIntegrationTestSuite) TestGetSeries_SpecialCharactersInName() {
|
||||||
|
seriesName := "Series: Book & Other (Vol. 1)"
|
||||||
|
url := fmt.Sprintf("%s/api/series/books?library_id=%s&name=%s", s.setup.Server.URL, s.libraryID, url.QueryEscape(seriesName))
|
||||||
|
req, _ := http.NewRequest("GET", url, nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+s.token)
|
||||||
|
|
||||||
|
client := &http.Client{}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
|
assert.Equal(s.T(), http.StatusOK, resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SeriesIntegrationTestSuite) TestGetSeries_ResponseStructure() {
|
||||||
|
url := fmt.Sprintf("%s/api/series?library_id=%s", s.setup.Server.URL, s.libraryID)
|
||||||
|
req, _ := http.NewRequest("GET", url, nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+s.token)
|
||||||
|
|
||||||
|
client := &http.Client{}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
|
assert.Equal(s.T(), http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
var body map[string]interface{}
|
||||||
|
err = json.NewDecoder(resp.Body).Decode(&body)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
|
||||||
|
assert.Contains(s.T(), body, "series", "response should contain 'series' key")
|
||||||
|
assert.Contains(s.T(), body, "total", "response should contain 'total' key")
|
||||||
|
assert.Contains(s.T(), body, "limit", "response should contain 'limit' key")
|
||||||
|
assert.Contains(s.T(), body, "offset", "response should contain 'offset' key")
|
||||||
|
|
||||||
|
_, ok := body["series"].([]interface{})
|
||||||
|
assert.True(s.T(), ok, "'series' should be an array")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SeriesIntegrationTestSuite) setLibraryVisibility(token, libraryID string, visible bool) *http.Response {
|
||||||
|
payload, _ := json.Marshal(map[string]interface{}{
|
||||||
|
"library_id": libraryID,
|
||||||
|
"is_visible": visible,
|
||||||
|
})
|
||||||
|
req, _ := http.NewRequest("POST", s.setup.Server.URL+"/api/libraries/visibility", bytes.NewBuffer(payload))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
resp, err := (&http.Client{}).Do(req)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGetSeries_HiddenLibraryScopedOut pins the family-lens contract: a
|
||||||
|
// library hidden from a user contributes NOTHING to that user's series
|
||||||
|
// views, and naming it explicitly is a 403, not a silent pass — across
|
||||||
|
// /api/series, /api/series/books, and /api/media-items.
|
||||||
|
func (s *SeriesIntegrationTestSuite) TestGetSeries_HiddenLibraryScopedOut() {
|
||||||
|
regular := s.setup.RegularToken
|
||||||
|
|
||||||
|
// Baseline: the regular user can see the suite library.
|
||||||
|
resp := s.setLibraryVisibility(regular, s.libraryID, true)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
require.Equal(s.T(), http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
// Hide it from the regular user.
|
||||||
|
resp = s.setLibraryVisibility(regular, s.libraryID, false)
|
||||||
|
require.Equal(s.T(), http.StatusOK, resp.StatusCode)
|
||||||
|
defer func() {
|
||||||
|
r := s.setLibraryVisibility(regular, s.libraryID, true)
|
||||||
|
_ = r.Body.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
client := &http.Client{}
|
||||||
|
|
||||||
|
// Explicit hidden library → 403.
|
||||||
|
for _, path := range []string{
|
||||||
|
"/api/series?library_id=" + s.libraryID,
|
||||||
|
"/api/series/books?library_id=" + s.libraryID + "&name=Test",
|
||||||
|
"/api/media-items?library_id=" + s.libraryID,
|
||||||
|
} {
|
||||||
|
req, _ := http.NewRequest("GET", s.setup.Server.URL+path, nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+regular)
|
||||||
|
r, err := client.Do(req)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
body, _ := io.ReadAll(r.Body)
|
||||||
|
r.Body.Close()
|
||||||
|
require.Equalf(s.T(), http.StatusForbidden, r.StatusCode, "%s must be 403 for a hidden library (body: %s)", path, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unscoped aggregate: the hidden library's series never appear.
|
||||||
|
req, _ := http.NewRequest("GET", s.setup.Server.URL+"/api/series", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+regular)
|
||||||
|
r, err := client.Do(req)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
defer r.Body.Close()
|
||||||
|
require.Equal(s.T(), http.StatusOK, r.StatusCode)
|
||||||
|
var body map[string]interface{}
|
||||||
|
require.NoError(s.T(), json.NewDecoder(r.Body).Decode(&body))
|
||||||
|
if rows, ok := body["series"].([]interface{}); ok {
|
||||||
|
for _, row := range rows {
|
||||||
|
m, ok := row.(map[string]interface{})
|
||||||
|
require.True(s.T(), ok)
|
||||||
|
assert.NotEqual(s.T(), s.libraryID, m["library_id"], "hidden library must not appear in the aggregate")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The admin still sees the library (default-allow; visibility rows are
|
||||||
|
// per-user).
|
||||||
|
req, _ = http.NewRequest("GET", s.setup.Server.URL+"/api/series?library_id="+s.libraryID, nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+s.token)
|
||||||
|
r, err = client.Do(req)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
defer r.Body.Close()
|
||||||
|
assert.Equal(s.T(), http.StatusOK, r.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SeriesIntegrationTestSuite) TestRestoreSystemCollection_ContinueSeries() {
|
||||||
|
reqBody := map[string]interface{}{
|
||||||
|
"collection_name": "Continue Series",
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(reqBody)
|
||||||
|
|
||||||
|
req, _ := http.NewRequest("POST", s.setup.Server.URL+"/api/dashboard/restore-system-collection", bytes.NewBuffer(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+s.token)
|
||||||
|
|
||||||
|
client := &http.Client{}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
|
assert.Equal(s.T(), http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
var response map[string]interface{}
|
||||||
|
err = json.NewDecoder(resp.Body).Decode(&response)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
assert.Contains(s.T(), response, "message")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SeriesIntegrationTestSuite) TestGetSections_IncludesContinueSeries() {
|
||||||
|
url := fmt.Sprintf("%s/api/dashboard/sections?library_id=%s", s.setup.Server.URL, s.libraryID)
|
||||||
|
req, _ := http.NewRequest("GET", url, nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+s.token)
|
||||||
|
|
||||||
|
client := &http.Client{}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
|
assert.Equal(s.T(), http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
var response map[string]interface{}
|
||||||
|
err = json.NewDecoder(resp.Body).Decode(&response)
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
|
||||||
|
sections, ok := response["sections"].([]interface{})
|
||||||
|
require.True(s.T(), ok, "sections should be an array")
|
||||||
|
require.Len(s.T(), sections, 5, "Should have 5 system collections")
|
||||||
|
|
||||||
|
sectionIDs := make(map[string]bool)
|
||||||
|
for _, sec := range sections {
|
||||||
|
section := sec.(map[string]interface{})
|
||||||
|
sectionIDs[section["id"].(string)] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ground truth: the /sections contract keys a section's identity on
|
||||||
|
// the collection NAME — BuildSections sets ID = CollectionName, and
|
||||||
|
// the web/app reorder + hidden-collection preferences persist those
|
||||||
|
// same strings (data-collection-id). The kebab "query_type"
|
||||||
|
// ("continue-series" etc.) is the STORAGE key for system collections
|
||||||
|
// and is not exposed on this endpoint.
|
||||||
|
assert.Contains(s.T(), sectionIDs, "Continue Series", "dashboard should include Continue Series")
|
||||||
|
assert.Contains(s.T(), sectionIDs, "Continue Reading")
|
||||||
|
assert.Contains(s.T(), sectionIDs, "Recently Added")
|
||||||
|
assert.Contains(s.T(), sectionIDs, "Recently Read")
|
||||||
|
assert.Contains(s.T(), sectionIDs, "Not Started")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSeriesIntegrationTestSuite(t *testing.T) {
|
||||||
|
suite.Run(t, new(SeriesIntegrationTestSuite))
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
const baseTestURL = "http://localhost:8765/api"
|
const baseTestURL = "http://localhost:8765/api"
|
||||||
@@ -36,12 +37,15 @@ func TestFullApplicationSetup(t *testing.T) {
|
|||||||
t.Logf("Cleanup: No existing test user to delete (server not available)")
|
t.Logf("Cleanup: No existing test user to delete (server not available)")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
// If login succeeds, try to delete the user
|
// If login succeeds, try to delete the user
|
||||||
if resp.StatusCode == http.StatusOK {
|
if resp.StatusCode == http.StatusOK {
|
||||||
var result map[string]interface{}
|
var result map[string]interface{}
|
||||||
json.NewDecoder(resp.Body).Decode(&result)
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
if token, ok := result["access_token"].(string); ok && token != "" {
|
if token, ok := result["access_token"].(string); ok && token != "" {
|
||||||
// Delete the user using the token
|
// Delete the user using the token
|
||||||
@@ -52,7 +56,9 @@ func TestFullApplicationSetup(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
delResp, err := client.Do(req)
|
delResp, err := client.Do(req)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
defer delResp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(delResp.Body)
|
||||||
if delResp.StatusCode == http.StatusNoContent {
|
if delResp.StatusCode == http.StatusNoContent {
|
||||||
t.Logf("Cleanup: Deleted existing test user")
|
t.Logf("Cleanup: Deleted existing test user")
|
||||||
} else {
|
} else {
|
||||||
@@ -66,10 +72,13 @@ func TestFullApplicationSetup(t *testing.T) {
|
|||||||
|
|
||||||
listResp, err := client.Do(req)
|
listResp, err := client.Do(req)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
defer listResp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(listResp.Body)
|
||||||
if listResp.StatusCode == http.StatusOK {
|
if listResp.StatusCode == http.StatusOK {
|
||||||
var libsResult map[string]interface{}
|
var libsResult map[string]interface{}
|
||||||
json.NewDecoder(listResp.Body).Decode(&libsResult)
|
err = json.NewDecoder(listResp.Body).Decode(&libsResult)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
if data, ok := libsResult["data"].([]interface{}); ok {
|
if data, ok := libsResult["data"].([]interface{}); ok {
|
||||||
for _, lib := range data {
|
for _, lib := range data {
|
||||||
@@ -80,7 +89,7 @@ func TestFullApplicationSetup(t *testing.T) {
|
|||||||
req.Header.Set("Authorization", "Bearer "+token)
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
delLibResp, _ := client.Do(req)
|
delLibResp, _ := client.Do(req)
|
||||||
if delLibResp != nil {
|
if delLibResp != nil {
|
||||||
delLibResp.Body.Close()
|
_ = delLibResp.Body.Close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -108,7 +117,9 @@ func TestFullApplicationSetup(t *testing.T) {
|
|||||||
body, _ := json.Marshal(userReq)
|
body, _ := json.Marshal(userReq)
|
||||||
resp, err := http.Post(baseTestURL+"/auth/register", "application/json", bytes.NewBuffer(body))
|
resp, err := http.Post(baseTestURL+"/auth/register", "application/json", bytes.NewBuffer(body))
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
// Accept 201 (Created) or 409 (Conflict if already exists from previous incomplete test run)
|
// Accept 201 (Created) or 409 (Conflict if already exists from previous incomplete test run)
|
||||||
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusConflict {
|
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusConflict {
|
||||||
@@ -116,7 +127,8 @@ func TestFullApplicationSetup(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var result map[string]interface{}
|
var result map[string]interface{}
|
||||||
json.NewDecoder(resp.Body).Decode(&result)
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
// If we got 409, the user already exists, so we need to login to get the token
|
// If we got 409, the user already exists, so we need to login to get the token
|
||||||
if resp.StatusCode == http.StatusConflict {
|
if resp.StatusCode == http.StatusConflict {
|
||||||
@@ -128,10 +140,13 @@ func TestFullApplicationSetup(t *testing.T) {
|
|||||||
body, _ := json.Marshal(loginReq)
|
body, _ := json.Marshal(loginReq)
|
||||||
resp2, err := http.Post(baseTestURL+"/auth/login", "application/json", bytes.NewBuffer(body))
|
resp2, err := http.Post(baseTestURL+"/auth/login", "application/json", bytes.NewBuffer(body))
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
defer resp2.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp2.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp2.StatusCode)
|
assert.Equal(t, http.StatusOK, resp2.StatusCode)
|
||||||
json.NewDecoder(resp2.Body).Decode(&result)
|
err = json.NewDecoder(resp2.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if result["user"] != nil {
|
if result["user"] != nil {
|
||||||
@@ -155,12 +170,15 @@ func TestFullApplicationSetup(t *testing.T) {
|
|||||||
body, _ := json.Marshal(loginReq)
|
body, _ := json.Marshal(loginReq)
|
||||||
resp, err := http.Post(baseTestURL+"/auth/login", "application/json", bytes.NewBuffer(body))
|
resp, err := http.Post(baseTestURL+"/auth/login", "application/json", bytes.NewBuffer(body))
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var result map[string]interface{}
|
var result map[string]interface{}
|
||||||
json.NewDecoder(resp.Body).Decode(&result)
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
token, ok := result["access_token"].(string)
|
token, ok := result["access_token"].(string)
|
||||||
assert.True(t, ok, "Should have access_token")
|
assert.True(t, ok, "Should have access_token")
|
||||||
@@ -185,7 +203,9 @@ func TestFullApplicationSetup(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||||
|
|
||||||
@@ -225,12 +245,15 @@ func TestFullApplicationSetup(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||||
|
|
||||||
var result map[string]interface{}
|
var result map[string]interface{}
|
||||||
json.NewDecoder(resp.Body).Decode(&result)
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
assert.Equal(t, getUploadPath(), result["folder_path"])
|
assert.Equal(t, getUploadPath(), result["folder_path"])
|
||||||
|
|
||||||
@@ -251,13 +274,16 @@ func TestFullApplicationSetup(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
// Accept 200 or 202
|
// Accept 200 or 202
|
||||||
assert.Contains(t, []int{http.StatusOK, http.StatusAccepted}, resp.StatusCode)
|
assert.Contains(t, []int{http.StatusOK, http.StatusAccepted}, resp.StatusCode)
|
||||||
|
|
||||||
var result map[string]interface{}
|
var result map[string]interface{}
|
||||||
json.NewDecoder(resp.Body).Decode(&result)
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
assert.Equal(t, "success", result["status"])
|
assert.Equal(t, "success", result["status"])
|
||||||
|
|
||||||
@@ -276,12 +302,15 @@ func TestFullApplicationSetup(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var result map[string]interface{}
|
var result map[string]interface{}
|
||||||
json.NewDecoder(resp.Body).Decode(&result)
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
data, ok := result["data"].([]interface{})
|
data, ok := result["data"].([]interface{})
|
||||||
assert.True(t, ok, "Data field should exist")
|
assert.True(t, ok, "Data field should exist")
|
||||||
|
|||||||
@@ -36,7 +36,9 @@ func TestSevenDaySession(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
@@ -81,12 +83,15 @@ func TestSevenDaySession(t *testing.T) {
|
|||||||
|
|
||||||
loginResp, err := http.DefaultClient.Do(loginHTTP)
|
loginResp, err := http.DefaultClient.Do(loginHTTP)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer loginResp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(loginResp.Body)
|
||||||
|
|
||||||
require.Equal(t, http.StatusOK, loginResp.StatusCode)
|
require.Equal(t, http.StatusOK, loginResp.StatusCode)
|
||||||
|
|
||||||
var loginResult map[string]interface{}
|
var loginResult map[string]interface{}
|
||||||
json.NewDecoder(loginResp.Body).Decode(&loginResult)
|
err = json.NewDecoder(loginResp.Body).Decode(&loginResult)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
refreshToken, ok := loginResult["refresh_token"].(string)
|
refreshToken, ok := loginResult["refresh_token"].(string)
|
||||||
require.True(t, ok, "Should have refresh_token")
|
require.True(t, ok, "Should have refresh_token")
|
||||||
@@ -103,12 +108,15 @@ func TestSevenDaySession(t *testing.T) {
|
|||||||
|
|
||||||
refreshResp, err := http.DefaultClient.Do(refreshHTTP)
|
refreshResp, err := http.DefaultClient.Do(refreshHTTP)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer refreshResp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(refreshResp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, refreshResp.StatusCode)
|
assert.Equal(t, http.StatusOK, refreshResp.StatusCode)
|
||||||
|
|
||||||
var refreshResult map[string]interface{}
|
var refreshResult map[string]interface{}
|
||||||
json.NewDecoder(refreshResp.Body).Decode(&refreshResult)
|
err = json.NewDecoder(refreshResp.Body).Decode(&refreshResult)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Verify ExpiresIn is 7 days
|
// Verify ExpiresIn is 7 days
|
||||||
expiresIn, ok := refreshResult["expires_in"].(float64)
|
expiresIn, ok := refreshResult["expires_in"].(float64)
|
||||||
@@ -134,7 +142,9 @@ func TestSevenDaySession(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := http.DefaultClient.Do(req)
|
resp, err := http.DefaultClient.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
var authResponse struct {
|
var authResponse struct {
|
||||||
Token string `json:"access_token"`
|
Token string `json:"access_token"`
|
||||||
@@ -230,7 +240,7 @@ func TestNoClientSideCookies(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := http.DefaultClient.Do(req)
|
resp, err := http.DefaultClient.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
resp.Body.Close()
|
_ = resp.Body.Close()
|
||||||
|
|
||||||
// Accept both 201 (new user) and 409 (already exists from previous run)
|
// Accept both 201 (new user) and 409 (already exists from previous run)
|
||||||
require.True(t, resp.StatusCode == http.StatusCreated || resp.StatusCode == http.StatusConflict,
|
require.True(t, resp.StatusCode == http.StatusCreated || resp.StatusCode == http.StatusConflict,
|
||||||
@@ -253,7 +263,9 @@ func TestNoClientSideCookies(t *testing.T) {
|
|||||||
|
|
||||||
resp2, err := http.DefaultClient.Do(req2)
|
resp2, err := http.DefaultClient.Do(req2)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp2.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp2.Body)
|
||||||
|
|
||||||
body, err := io.ReadAll(resp2.Body)
|
body, err := io.ReadAll(resp2.Body)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -277,7 +289,9 @@ func TestNoClientSideCookies(t *testing.T) {
|
|||||||
|
|
||||||
loginResp, err := http.DefaultClient.Do(loginReq)
|
loginResp, err := http.DefaultClient.Do(loginReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer loginResp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(loginResp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, loginResp.StatusCode)
|
assert.Equal(t, http.StatusOK, loginResp.StatusCode)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,519 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"bookhoard/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Reading-statistics coverage: session CAPTURE at the sync chokepoint,
|
||||||
|
// the summary aggregation (tz buckets, streaks, finished detection),
|
||||||
|
// range validation, the widget, and the per-book card.
|
||||||
|
//
|
||||||
|
// All seeded instants anchor to USER-TZ midnights (the test user is UTC)
|
||||||
|
// so day buckets are deterministic regardless of when the suite runs.
|
||||||
|
|
||||||
|
func pgUser(id uuid.UUID) pgtype.UUID { return pgtype.UUID{Bytes: [16]byte(id), Valid: true} }
|
||||||
|
func pgFloat(v float64) pgtype.Float8 { return pgtype.Float8{Float64: v, Valid: true} }
|
||||||
|
func pgInt4(v int32) pgtype.Int4 { return pgtype.Int4{Int32: v, Valid: true} }
|
||||||
|
func pgTS(v time.Time) pgtype.Timestamptz {
|
||||||
|
return pgtype.Timestamptz{Time: v, Valid: !v.IsZero()}
|
||||||
|
}
|
||||||
|
|
||||||
|
// middayDaysAgo returns noon UTC N calendar days before today — a
|
||||||
|
// bucket-stable instant for any wall-clock time the suite runs at.
|
||||||
|
func middayDaysAgo(daysAgo int) time.Time {
|
||||||
|
midnight := time.Now().UTC()
|
||||||
|
midnight = time.Date(midnight.Year(), midnight.Month(), midnight.Day(), 0, 0, 0, 0, time.UTC)
|
||||||
|
return midnight.AddDate(0, 0, -daysAgo).Add(12 * time.Hour)
|
||||||
|
}
|
||||||
|
|
||||||
|
func statsLogin(t *testing.T, setup *TestServerSetup, token, path string) (*http.Response, []byte) {
|
||||||
|
t.Helper()
|
||||||
|
req, _ := http.NewRequest("GET", setup.Server.URL+path, nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() {
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
t.Setenv("STATS_LAST_BODY_PATH", path) // no-op; keeps body-close ordering explicit
|
||||||
|
}()
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return resp, body
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadingStatsCapture(t *testing.T) {
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
userID := getTestUserID(t, setup.DB)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
bookID := createTestMediaItemID(t, setup)
|
||||||
|
|
||||||
|
push := func(body map[string]interface{}) int {
|
||||||
|
payload, _ := json.Marshal(body)
|
||||||
|
req, _ := http.NewRequest("PUT", setup.Server.URL+"/api/media-items/"+bookID+"/progress", bytes.NewBuffer(payload))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
return resp.StatusCode
|
||||||
|
}
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusOK, push(map[string]interface{}{
|
||||||
|
"percentage": 0.10, "current_page": 10, "total_pages": 100,
|
||||||
|
}))
|
||||||
|
|
||||||
|
time.Sleep(1100 * time.Millisecond) // the second push derives a ~1s span
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusOK, push(map[string]interface{}{
|
||||||
|
"percentage": 0.15, "current_page": 15, "total_pages": 100,
|
||||||
|
}))
|
||||||
|
|
||||||
|
rows, err := setup.DB.GetBookReadingSessions(ctx, database.GetBookReadingSessionsParams{
|
||||||
|
UserID: pgUser(userID),
|
||||||
|
MediaItemID: uuidToPGType(uuid.MustParse(bookID)),
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, rows, 2, "each push records one session row")
|
||||||
|
|
||||||
|
first := rows[0]
|
||||||
|
assert.Equal(t, int32(0), first.TimeSpentSeconds.Int32, "first contact: marker with zero time")
|
||||||
|
assert.InEpsilon(t, 0.10, first.ProgressPercentage.Float64, 0.001)
|
||||||
|
|
||||||
|
second := rows[1]
|
||||||
|
assert.GreaterOrEqual(t, second.TimeSpentSeconds.Int32, int32(1), "the ~1.1s gap is attributed")
|
||||||
|
assert.LessOrEqual(t, second.TimeSpentSeconds.Int32, int32(10), "and bounded by the slept interval")
|
||||||
|
require.True(t, second.PagesRead.Valid, "page info was carried on both pushes")
|
||||||
|
assert.Equal(t, int32(5), second.PagesRead.Int32, "pages: 15 − 10")
|
||||||
|
|
||||||
|
progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{
|
||||||
|
MediaItemID: uuidToPGType(uuid.MustParse(bookID)),
|
||||||
|
UserID: pgUser(userID),
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.InEpsilon(t, 0.15, progress.Percentage.Float64, 0.001, "capture never disturbs stored position")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadingStatsDeviceAttribution(t *testing.T) {
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
userID := getTestUserID(t, setup.DB)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
bookID := createTestMediaItemID(t, setup)
|
||||||
|
|
||||||
|
// A registered device owned by the test user, plus one owned by a
|
||||||
|
// DIFFERENT user (the foreign token must be ignored, never sent).
|
||||||
|
device, err := setup.DB.CreateDevice(ctx, database.CreateDeviceParams{
|
||||||
|
UserID: pgUser(userID),
|
||||||
|
DeviceName: "Pixel 9 Test",
|
||||||
|
DeviceType: "mobile",
|
||||||
|
DeviceIdentifier: "stats-test-android-" + uuid.NewString(),
|
||||||
|
AuthToken: "dev_test_token_" + uuid.NewString(),
|
||||||
|
SyncEnabled: pgtype.Bool{Bool: true, Valid: true},
|
||||||
|
AutoSync: pgtype.Bool{Bool: true, Valid: true},
|
||||||
|
SyncFrequencyMinutes: pgtype.Int4{Int32: 5, Valid: true},
|
||||||
|
DeviceMetadata: []byte("{}"),
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { _ = setup.DB.DeleteDevice(ctx, device.ID) })
|
||||||
|
|
||||||
|
otherUser := getRegularUserID(t, setup.DB)
|
||||||
|
foreign, err := setup.DB.CreateDevice(ctx, database.CreateDeviceParams{
|
||||||
|
UserID: pgUser(otherUser),
|
||||||
|
DeviceName: "Foreign Device",
|
||||||
|
DeviceType: "mobile",
|
||||||
|
DeviceIdentifier: "stats-test-foreign-" + uuid.NewString(),
|
||||||
|
AuthToken: "dev_foreign_token_" + uuid.NewString(),
|
||||||
|
SyncEnabled: pgtype.Bool{Bool: true, Valid: true},
|
||||||
|
AutoSync: pgtype.Bool{Bool: true, Valid: true},
|
||||||
|
SyncFrequencyMinutes: pgtype.Int4{Int32: 5, Valid: true},
|
||||||
|
DeviceMetadata: []byte("{}"),
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { _ = setup.DB.DeleteDevice(ctx, foreign.ID) })
|
||||||
|
|
||||||
|
push := func(body map[string]interface{}, deviceToken string) int {
|
||||||
|
payload, _ := json.Marshal(body)
|
||||||
|
req, _ := http.NewRequest("PUT", setup.Server.URL+"/api/media-items/"+bookID+"/progress", bytes.NewBuffer(payload))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
|
if deviceToken != "" {
|
||||||
|
req.Header.Set("X-Device-Token", deviceToken)
|
||||||
|
}
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
return resp.StatusCode
|
||||||
|
}
|
||||||
|
|
||||||
|
// Registered device push → attributed to the device with its name.
|
||||||
|
require.Equal(t, http.StatusOK, push(map[string]interface{}{
|
||||||
|
"percentage": 0.10, "current_page": 10, "total_pages": 100,
|
||||||
|
}, device.AuthToken))
|
||||||
|
|
||||||
|
rows, err := setup.DB.GetBookReadingSessions(ctx, database.GetBookReadingSessionsParams{
|
||||||
|
UserID: pgUser(userID),
|
||||||
|
MediaItemID: uuidToPGType(uuid.MustParse(bookID)),
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, rows, 1)
|
||||||
|
require.True(t, rows[0].DeviceID.Valid)
|
||||||
|
assert.Equal(t, uuid.UUID(device.ID.Bytes).String(), uuid.UUID(rows[0].DeviceID.Bytes).String())
|
||||||
|
var meta struct {
|
||||||
|
Source string `json:"source"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.Unmarshal(rows[0].DeviceMetadata, &meta))
|
||||||
|
assert.Equal(t, "Pixel 9 Test", meta.Source, "capture metadata carries the registered device name")
|
||||||
|
|
||||||
|
progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{
|
||||||
|
MediaItemID: uuidToPGType(uuid.MustParse(bookID)),
|
||||||
|
UserID: pgUser(userID),
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "Pixel 9 Test", progress.LastSyncSource.String, "last-sync surface shows the device name")
|
||||||
|
|
||||||
|
// Foreign device token → soft-fails: unattributed, plain web source.
|
||||||
|
require.Equal(t, http.StatusOK, push(map[string]interface{}{
|
||||||
|
"percentage": 0.20, "current_page": 20, "total_pages": 100,
|
||||||
|
}, "dev_foreign_token_"+uuid.NewString() /* nonexistent token */))
|
||||||
|
|
||||||
|
// A REAL foreign token must also be ignored, not adopted.
|
||||||
|
require.Equal(t, http.StatusOK, push(map[string]interface{}{
|
||||||
|
"percentage": 0.25, "current_page": 25, "total_pages": 100,
|
||||||
|
}, foreign.AuthToken))
|
||||||
|
|
||||||
|
rows, err = setup.DB.GetBookReadingSessions(ctx, database.GetBookReadingSessionsParams{
|
||||||
|
UserID: pgUser(userID),
|
||||||
|
MediaItemID: uuidToPGType(uuid.MustParse(bookID)),
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, rows, 3)
|
||||||
|
for _, row := range rows[1:] {
|
||||||
|
assert.False(t, row.DeviceID.Valid, "invalid/foreign tokens never attribute a device")
|
||||||
|
require.NoError(t, json.Unmarshal(row.DeviceMetadata, &meta))
|
||||||
|
assert.Equal(t, "web", meta.Source)
|
||||||
|
}
|
||||||
|
progress, err = setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{
|
||||||
|
MediaItemID: uuidToPGType(uuid.MustParse(bookID)),
|
||||||
|
UserID: pgUser(userID),
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "web", progress.LastSyncSource.String)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadingStatsSummary(t *testing.T) {
|
||||||
|
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
// The REGULAR test user, deliberately: the shared dev DB carries a
|
||||||
|
// live reader on `testuser` (the dev account genuinely reads while
|
||||||
|
// the suite runs), and exact-total assertions cannot share an
|
||||||
|
// account with reality. The fresh regular user has no live pushes.
|
||||||
|
userID := getRegularUserID(t, setup.DB)
|
||||||
|
ctx := context.Context(context.Background())
|
||||||
|
|
||||||
|
libIDStr := createLibraryDirect(t, setup.DB, userID, fmt.Sprintf("Test Stats Library %d", time.Now().UnixNano()), "ebooks")
|
||||||
|
libUUID := uuid.MustParse(libIDStr)
|
||||||
|
|
||||||
|
var finishedBookID, offRangeBookID string
|
||||||
|
// A real registered device: attributed sessions carry its id (the
|
||||||
|
// rest fall into the "Web / unsynced" bucket).
|
||||||
|
seedDevice, err := setup.DB.CreateDevice(ctx, database.CreateDeviceParams{
|
||||||
|
UserID: pgUser(userID),
|
||||||
|
DeviceName: "Kindle Papered Test",
|
||||||
|
DeviceType: "koreader",
|
||||||
|
DeviceIdentifier: "stats-test-kindle-" + uuid.NewString(),
|
||||||
|
AuthToken: "dev_kindle_token_" + uuid.NewString(),
|
||||||
|
SyncEnabled: pgtype.Bool{Bool: true, Valid: true},
|
||||||
|
AutoSync: pgtype.Bool{Bool: true, Valid: true},
|
||||||
|
SyncFrequencyMinutes: pgtype.Int4{Int32: 5, Valid: true},
|
||||||
|
DeviceMetadata: []byte("{}"),
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { _ = setup.DB.DeleteDevice(ctx, seedDevice.ID) })
|
||||||
|
deviceID := seedDevice.ID
|
||||||
|
|
||||||
|
insertSession := func(daysAgo int, minutes int32, pages int32, pct float64, title string, attributed bool, reuseMediaID ...string) string {
|
||||||
|
var media database.MediaItems
|
||||||
|
var err error
|
||||||
|
if len(reuseMediaID) > 0 && reuseMediaID[0] != "" {
|
||||||
|
media, err = setup.DB.GetMediaItem(ctx, uuidToPGType(uuid.MustParse(reuseMediaID[0])))
|
||||||
|
require.NoError(t, err, "reuse seeded media item")
|
||||||
|
} else {
|
||||||
|
media, err = setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
||||||
|
LibraryID: pgtype.UUID{Bytes: [16]byte(libUUID), Valid: true},
|
||||||
|
Title: title,
|
||||||
|
FilePath: fmt.Sprintf("/tmp/stats-%s-%d.epub", title, time.Now().UnixNano()),
|
||||||
|
FileSize: pgtype.Int8{Int64: 1024, Valid: true},
|
||||||
|
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
|
||||||
|
})
|
||||||
|
require.NoError(t, err, "seed media item")
|
||||||
|
}
|
||||||
|
|
||||||
|
deviceIDArg := pgtype.UUID{}
|
||||||
|
if attributed {
|
||||||
|
deviceIDArg = deviceID
|
||||||
|
}
|
||||||
|
|
||||||
|
pushAt := middayDaysAgo(daysAgo)
|
||||||
|
_, err = setup.DB.CreateReadingHistory(ctx, database.CreateReadingHistoryParams{
|
||||||
|
UserID: pgUser(userID),
|
||||||
|
MediaItemID: media.ID,
|
||||||
|
DeviceID: deviceIDArg,
|
||||||
|
ProgressPercentage: pgFloat(pct),
|
||||||
|
ReadingSessionStart: pgTS(pushAt.Add(-time.Duration(minutes) * time.Minute)),
|
||||||
|
ReadingSessionEnd: pgTS(pushAt),
|
||||||
|
PagesRead: pgInt4(pages),
|
||||||
|
TimeSpentSeconds: pgInt4(minutes * 60),
|
||||||
|
DeviceMetadata: []byte(`{"source":"test"}`),
|
||||||
|
})
|
||||||
|
require.NoError(t, err, "seed session")
|
||||||
|
|
||||||
|
id, _ := uuid.FromBytes(media.ID.Bytes[0:16])
|
||||||
|
return id.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Device-split scenario: attributed (80 min) + unattributed (75 min).
|
||||||
|
finishedBookID = insertSession(0, 30, 12, 1.0, "Stats Finished Book", false)
|
||||||
|
insertSession(1, 45, 20, 0.5, "Stats Streak Book", false)
|
||||||
|
insertSession(2, 60, 25, 0.42, "Stats Back Book", true)
|
||||||
|
insertSession(2, 20, 8, 0.9, "Stats Finished Book (same book, second session)", true, finishedBookID)
|
||||||
|
offRangeBookID = insertSession(400, 120, 40, 0.2, "Stats OffRange Book", false)
|
||||||
|
|
||||||
|
// Summary -----------------------------------------------------------
|
||||||
|
resp, body := statsLogin(t, setup, setup.RegularToken, "/api/stats/summary?range=7")
|
||||||
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
var summary struct {
|
||||||
|
Range string `json:"range"`
|
||||||
|
TotalMinutes int `json:"total_minutes"`
|
||||||
|
TotalPages int `json:"total_pages"`
|
||||||
|
SessionCount int `json:"session_count"`
|
||||||
|
BooksActive int `json:"books_with_activity"`
|
||||||
|
BooksFinished int `json:"books_finished"`
|
||||||
|
CompletionRate float64 `json:"completion_rate"`
|
||||||
|
Daily []struct {
|
||||||
|
Date string `json:"date"`
|
||||||
|
Minutes int `json:"minutes"`
|
||||||
|
Pages int `json:"pages"`
|
||||||
|
} `json:"daily"`
|
||||||
|
Streak struct {
|
||||||
|
Current int `json:"current"`
|
||||||
|
Longest int `json:"longest"`
|
||||||
|
ActiveToday bool `json:"active_today"`
|
||||||
|
} `json:"streak"`
|
||||||
|
TopBooks []struct {
|
||||||
|
MediaItemID string `json:"media_item_id"`
|
||||||
|
Minutes int `json:"minutes"`
|
||||||
|
} `json:"top_books"`
|
||||||
|
LibraryBreakdown []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Minutes int `json:"minutes"`
|
||||||
|
} `json:"library_breakdown"`
|
||||||
|
DeviceBreakdown []struct {
|
||||||
|
DeviceID string `json:"device_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Minutes int `json:"minutes"`
|
||||||
|
Sessions int `json:"sessions"`
|
||||||
|
} `json:"device_breakdown"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.Unmarshal(body, &summary))
|
||||||
|
|
||||||
|
assert.Equal(t, "7", summary.Range)
|
||||||
|
require.Len(t, summary.Daily, 7, "zero-filled inclusive calendar")
|
||||||
|
assert.Equal(t, 155, summary.TotalMinutes, "30+45+60+20 (the 400-day-day session is out of range)")
|
||||||
|
assert.Equal(t, 65, summary.TotalPages, "12+20+25+8")
|
||||||
|
assert.Equal(t, 4, summary.SessionCount)
|
||||||
|
assert.Equal(t, 3, summary.BooksActive, "distinct books with sessions")
|
||||||
|
assert.Equal(t, 1, summary.BooksFinished, "finished := any push ≥ 99.5%")
|
||||||
|
assert.InEpsilon(t, float64(1)/float64(3), summary.CompletionRate, 0.001)
|
||||||
|
|
||||||
|
// Streaks: today, −1d, −2d are consecutive; the lone −400d day isn't.
|
||||||
|
t.Logf("STREAK DUMP: %+v (values %#v)", summary.Streak, summary.Streak)
|
||||||
|
t.Logf("DAILY DUMP: %+v", summary.Daily)
|
||||||
|
assert.Equal(t, 3, summary.Streak.Current)
|
||||||
|
assert.Equal(t, 3, summary.Streak.Longest)
|
||||||
|
assert.True(t, summary.Streak.ActiveToday)
|
||||||
|
|
||||||
|
// Top books: minutes-ordered — the seeded 60-minute back book tops.
|
||||||
|
require.NotEmpty(t, summary.TopBooks)
|
||||||
|
assert.Equal(t, 60, summary.TopBooks[0].Minutes)
|
||||||
|
for i := 1; i < len(summary.TopBooks); i++ {
|
||||||
|
assert.GreaterOrEqual(t, summary.TopBooks[i-1].Minutes, summary.TopBooks[i].Minutes, "top books sorted by minutes")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Library breakdown carries the seeded library.
|
||||||
|
found := false
|
||||||
|
for _, lib := range summary.LibraryBreakdown {
|
||||||
|
if lib.Minutes >= 155 {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.True(t, found, "library breakdown aggregates the seeded library")
|
||||||
|
|
||||||
|
// Device breakdown: attributed (80) + unattributed (75) reconcile
|
||||||
|
// against the headline total; both buckets present.
|
||||||
|
require.Len(t, summary.DeviceBreakdown, 2, "one device bucket + one unattributed bucket")
|
||||||
|
var kindleFound, unattributedFound bool
|
||||||
|
for _, dev := range summary.DeviceBreakdown {
|
||||||
|
if dev.Name == "Kindle Papered Test" {
|
||||||
|
kindleFound = true
|
||||||
|
assert.Equal(t, 80, dev.Minutes, "60 + 20 attributed sessions")
|
||||||
|
assert.Equal(t, 2, dev.Sessions)
|
||||||
|
assert.NotEqual(t, "", dev.DeviceID, "device bucket carries its id")
|
||||||
|
assert.Equal(t, "koreader", dev.Type)
|
||||||
|
}
|
||||||
|
if dev.Name == "Web" {
|
||||||
|
unattributedFound = true
|
||||||
|
assert.Equal(t, 75, dev.Minutes, "30 + 45 unattributed sessions")
|
||||||
|
assert.Equal(t, 2, dev.Sessions)
|
||||||
|
assert.Equal(t, "", dev.DeviceID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.True(t, kindleFound, "the registered device has its own bucket")
|
||||||
|
assert.True(t, unattributedFound, "device-less pushes aggregate into the Web bucket")
|
||||||
|
deviceMinutes := 0
|
||||||
|
for _, dev := range summary.DeviceBreakdown {
|
||||||
|
deviceMinutes += dev.Minutes
|
||||||
|
}
|
||||||
|
assert.Equal(t, 155, deviceMinutes, "device buckets reconcile with the total")
|
||||||
|
|
||||||
|
// Range validation ---------------------------------------------------
|
||||||
|
badResp, badBody := statsLogin(t, setup, setup.RegularToken, "/api/stats/summary?range=fortnight")
|
||||||
|
assert.Equal(t, http.StatusBadRequest, badResp.StatusCode)
|
||||||
|
assert.Contains(t, string(badBody), "invalid range")
|
||||||
|
|
||||||
|
// Auth gate ----------------------------------------------------------
|
||||||
|
anonym, err := http.Get(setup.Server.URL + "/api/stats/summary")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, http.StatusUnauthorized, anonym.StatusCode)
|
||||||
|
_ = anonym.Body.Close()
|
||||||
|
|
||||||
|
// Widget -------------------------------------------------------------
|
||||||
|
resp, body = statsLogin(t, setup, setup.RegularToken, "/api/stats/this-week")
|
||||||
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
var week struct {
|
||||||
|
WeekMinutes int `json:"week_minutes"`
|
||||||
|
WeekBooks int `json:"week_books"`
|
||||||
|
ActiveToday bool `json:"active_today"`
|
||||||
|
CurrentStreak int `json:"current_streak"`
|
||||||
|
PrevMinutes int `json:"prev_week_minutes"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.Unmarshal(body, &week))
|
||||||
|
assert.Equal(t, 155, week.WeekMinutes)
|
||||||
|
assert.Equal(t, 3, week.WeekBooks)
|
||||||
|
assert.True(t, week.ActiveToday)
|
||||||
|
assert.Equal(t, 3, week.CurrentStreak)
|
||||||
|
assert.Equal(t, 0, week.PrevMinutes)
|
||||||
|
|
||||||
|
// Per-book card (lifetime, so the 400-day book still totals) -----------
|
||||||
|
resp, body = statsLogin(t, setup, setup.RegularToken, "/api/stats/books/"+offRangeBookID)
|
||||||
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
var bookStats struct {
|
||||||
|
TotalMinutes int `json:"total_minutes"`
|
||||||
|
SessionCount int `json:"session_count"`
|
||||||
|
MaxProgress float64 `json:"max_progress"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.Unmarshal(body, &bookStats))
|
||||||
|
assert.Equal(t, 120, bookStats.TotalMinutes)
|
||||||
|
assert.Equal(t, 1, bookStats.SessionCount)
|
||||||
|
assert.InEpsilon(t, 0.2, bookStats.MaxProgress, 0.001)
|
||||||
|
|
||||||
|
_ = finishedBookID
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestReadingStatsWeekLibraryScope: the dashboard widget tracks the
|
||||||
|
// switched library — this-week totals and streaks scope to the
|
||||||
|
// requested library (and stay global without one).
|
||||||
|
func TestReadingStatsWeekLibraryScope(t *testing.T) {
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
userID := getRegularUserID(t, setup.DB) // isolated from the live dev-reader account
|
||||||
|
ctx := context.Context(context.Background())
|
||||||
|
|
||||||
|
libA := uuid.MustParse(createLibraryDirect(t, setup.DB, userID, fmt.Sprintf("Test Scope LibA %d", time.Now().UnixNano()), "ebooks"))
|
||||||
|
libB := uuid.MustParse(createLibraryDirect(t, setup.DB, userID, fmt.Sprintf("Test Scope LibB %d", time.Now().UnixNano()), "ebooks"))
|
||||||
|
|
||||||
|
seed := func(lib uuid.UUID, title string, daysAgo int, minutes int32) {
|
||||||
|
media, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
||||||
|
LibraryID: pgtype.UUID{Bytes: [16]byte(lib), Valid: true},
|
||||||
|
Title: title,
|
||||||
|
FilePath: fmt.Sprintf("/tmp/%s-%d.epub", title, time.Now().UnixNano()),
|
||||||
|
FileSize: pgtype.Int8{Int64: 1024, Valid: true},
|
||||||
|
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
pushAt := middayDaysAgo(daysAgo)
|
||||||
|
_, err = setup.DB.CreateReadingHistory(ctx, database.CreateReadingHistoryParams{
|
||||||
|
UserID: pgUser(userID),
|
||||||
|
MediaItemID: media.ID,
|
||||||
|
ProgressPercentage: pgFloat(0.3),
|
||||||
|
ReadingSessionStart: pgTS(pushAt.Add(-time.Duration(minutes) * time.Minute)),
|
||||||
|
ReadingSessionEnd: pgTS(pushAt),
|
||||||
|
PagesRead: pgInt4(5),
|
||||||
|
TimeSpentSeconds: pgInt4(minutes * 60),
|
||||||
|
DeviceMetadata: []byte(`{"source":"test"}`),
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
seed(libA, "Scope A Today", 0, 30)
|
||||||
|
seed(libA, "Scope A Yesterday", 1, 20)
|
||||||
|
seed(libB, "Scope B Today", 0, 45)
|
||||||
|
|
||||||
|
week := func(libraryID string) map[string]interface{} {
|
||||||
|
url := setup.Server.URL + "/api/stats/this-week"
|
||||||
|
if libraryID != "" {
|
||||||
|
url += "?library_id=" + libraryID
|
||||||
|
}
|
||||||
|
req, _ := http.NewRequest("GET", url, nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+setup.RegularToken)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
var out map[string]interface{}
|
||||||
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&out))
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
num := func(m map[string]interface{}, key string) int { return int(m[key].(float64)) }
|
||||||
|
|
||||||
|
all := week("")
|
||||||
|
assert.Equal(t, 95, num(all, "week_minutes"), "no library filter = all visible libraries")
|
||||||
|
assert.Equal(t, 2, num(all, "current_streak"), "today + yesterday (libA)")
|
||||||
|
|
||||||
|
onlyA := week(libA.String())
|
||||||
|
assert.Equal(t, 50, num(onlyA, "week_minutes"))
|
||||||
|
assert.Equal(t, 2, num(onlyA, "current_streak"))
|
||||||
|
assert.True(t, onlyA["active_today"].(bool))
|
||||||
|
|
||||||
|
onlyB := week(libB.String())
|
||||||
|
assert.Equal(t, 45, num(onlyB, "week_minutes"))
|
||||||
|
assert.Equal(t, 1, num(onlyB, "current_streak"), "libB only has today")
|
||||||
|
|
||||||
|
// A library owned by someone else (invisible to this user) leaks nothing.
|
||||||
|
hidden := uuid.MustParse(createLibraryDirect(t, setup.DB, getTestUserID(t, setup.DB), fmt.Sprintf("Test Scope Hidden %d", time.Now().UnixNano()), "ebooks"))
|
||||||
|
assert.Equal(t, 0, num(week(hidden.String()), "week_minutes"), "invisible library contributes nothing")
|
||||||
|
|
||||||
|
// Malformed scope is a clean 400.
|
||||||
|
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/stats/this-week?library_id=nope", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+setup.RegularToken)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
|
}
|
||||||
@@ -32,12 +32,16 @@ func setupSyncTestDB(t *testing.T) *database.Queries {
|
|||||||
|
|
||||||
db := database.New(dbPool)
|
db := database.New(dbPool)
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
|
// Scoped wipes ONLY: reading_history now carries real captured
|
||||||
|
// sessions for the shared dev accounts, so a table-wide DELETE
|
||||||
|
// here silently destroyed a live reader's stats data on every
|
||||||
|
// suite run (2026-10-14). Sync-test sessions belong to the
|
||||||
|
// test-sync users; deleting those users cascades their rows.
|
||||||
_, _ = dbPool.Exec(ctx, "DELETE FROM sync_queue WHERE true")
|
_, _ = dbPool.Exec(ctx, "DELETE FROM sync_queue WHERE true")
|
||||||
_, _ = dbPool.Exec(ctx, "DELETE FROM reading_history WHERE true")
|
_, _ = dbPool.Exec(ctx, "DELETE FROM users WHERE email LIKE 'test-sync%'")
|
||||||
_, _ = dbPool.Exec(ctx, "DELETE FROM media_items WHERE title LIKE 'Test %'")
|
_, _ = dbPool.Exec(ctx, "DELETE FROM media_items WHERE title LIKE 'Test %'")
|
||||||
_, _ = dbPool.Exec(ctx, "DELETE FROM libraries WHERE name LIKE 'Test %'")
|
_, _ = dbPool.Exec(ctx, "DELETE FROM libraries WHERE name LIKE 'Test %'")
|
||||||
_, _ = dbPool.Exec(ctx, "DELETE FROM devices WHERE device_name LIKE 'Test %'")
|
_, _ = dbPool.Exec(ctx, "DELETE FROM devices WHERE device_name LIKE 'Test %'")
|
||||||
_, _ = dbPool.Exec(ctx, "DELETE FROM users WHERE email LIKE 'test%'")
|
|
||||||
dbPool.Close()
|
dbPool.Close()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -55,6 +59,7 @@ func createSyncTestUser(t *testing.T, db *database.Queries) pgtype.UUID {
|
|||||||
FirstName: pgtype.Text{String: "Test", Valid: true},
|
FirstName: pgtype.Text{String: "Test", Valid: true},
|
||||||
LastName: pgtype.Text{String: "Sync", Valid: true},
|
LastName: pgtype.Text{String: "Sync", Valid: true},
|
||||||
Role: "user",
|
Role: "user",
|
||||||
|
Timezone: pgtype.Text{String: "UTC", Valid: true},
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ package main
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -30,17 +32,21 @@ func TestTagsFilter(t *testing.T) {
|
|||||||
folderHTTP.Header.Set("Authorization", "Bearer "+setup.UserToken)
|
folderHTTP.Header.Set("Authorization", "Bearer "+setup.UserToken)
|
||||||
folderResp, err := client.Do(folderHTTP)
|
folderResp, err := client.Do(folderHTTP)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
folderResp.Body.Close()
|
_ = folderResp.Body.Close()
|
||||||
require.Equal(t, http.StatusCreated, folderResp.StatusCode, "Should add folder to library")
|
require.Equal(t, http.StatusCreated, folderResp.StatusCode, "Should add folder to library")
|
||||||
|
|
||||||
// Helper to create book with tags
|
// Helper to create book with tags. The file_path must be UNIQUE per
|
||||||
|
// book: CreateMediaItem upserts ON CONFLICT (library_id, file_path),
|
||||||
|
// so a constant path collapses every create onto the first row and
|
||||||
|
// the later books' tags never land.
|
||||||
createBook := func(title, author string, tags []string) {
|
createBook := func(title, author string, tags []string) {
|
||||||
|
slug := strings.ReplaceAll(strings.ToLower(title), " ", "-")
|
||||||
bookReq := map[string]interface{}{
|
bookReq := map[string]interface{}{
|
||||||
"library_id": libraryID,
|
"library_id": libraryID,
|
||||||
"title": title,
|
"title": title,
|
||||||
"author": author,
|
"author": author,
|
||||||
"tags": tags,
|
"tags": tags,
|
||||||
"file_path": "/tmp/test.epub",
|
"file_path": "/tmp/" + slug + ".epub",
|
||||||
"file_size": 1024,
|
"file_size": 1024,
|
||||||
"mime_type": "application/epub+zip",
|
"mime_type": "application/epub+zip",
|
||||||
}
|
}
|
||||||
@@ -50,7 +56,9 @@ func TestTagsFilter(t *testing.T) {
|
|||||||
req.Header.Set("Authorization", "Bearer "+setup.UserToken)
|
req.Header.Set("Authorization", "Bearer "+setup.UserToken)
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
require.Equal(t, http.StatusCreated, resp.StatusCode, "Should create book")
|
require.Equal(t, http.StatusCreated, resp.StatusCode, "Should create book")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -98,13 +99,28 @@ func createTestLibraryWithFolder(t *testing.T, ts *httptest.Server, token, name
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(libHTTP)
|
resp, err := client.Do(libHTTP)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
require.Equal(t, http.StatusCreated, resp.StatusCode, "Library creation should succeed")
|
require.Equal(t, http.StatusCreated, resp.StatusCode, "Library creation should succeed")
|
||||||
|
|
||||||
var libResponse map[string]interface{}
|
var libResponse map[string]interface{}
|
||||||
json.NewDecoder(resp.Body).Decode(&libResponse)
|
err = json.NewDecoder(resp.Body).Decode(&libResponse)
|
||||||
|
require.NoError(t, err)
|
||||||
libraryID := libResponse["id"].(string)
|
libraryID := libResponse["id"].(string)
|
||||||
|
|
||||||
|
// Every library a test creates must not outlive that test — delete
|
||||||
|
// it on cleanup so runs leave no residue in the dev database.
|
||||||
|
t.Cleanup(func() {
|
||||||
|
delHTTP, _ := http.NewRequest("DELETE", fmt.Sprintf("%s/api/libraries/%s", ts.URL, libraryID), nil)
|
||||||
|
delHTTP.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
delResp, err := client.Do(delHTTP)
|
||||||
|
if err == nil {
|
||||||
|
_, _ = io.Copy(io.Discard, delResp.Body)
|
||||||
|
_ = delResp.Body.Close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
if withFolder {
|
if withFolder {
|
||||||
folderReq := map[string]interface{}{
|
folderReq := map[string]interface{}{
|
||||||
"folder_path": "/app/uploads",
|
"folder_path": "/app/uploads",
|
||||||
@@ -117,7 +133,9 @@ func createTestLibraryWithFolder(t *testing.T, ts *httptest.Server, token, name
|
|||||||
|
|
||||||
folderResp, err := client.Do(folderHTTP)
|
folderResp, err := client.Do(folderHTTP)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer folderResp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(folderResp.Body)
|
||||||
require.Equal(t, http.StatusCreated, folderResp.StatusCode, "Folder creation should succeed")
|
require.Equal(t, http.StatusCreated, folderResp.StatusCode, "Folder creation should succeed")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -76,19 +77,21 @@ type DeviceTestData struct {
|
|||||||
|
|
||||||
// TestServerSetup manages the lifecycle of a test server with proper resource cleanup
|
// TestServerSetup manages the lifecycle of a test server with proper resource cleanup
|
||||||
type TestServerSetup struct {
|
type TestServerSetup struct {
|
||||||
Server *httptest.Server
|
Server *httptest.Server
|
||||||
DB *database.Queries
|
DB *database.Queries
|
||||||
DBPool *pgxpool.Pool
|
DBPool *pgxpool.Pool
|
||||||
Config *config.Config
|
Config *config.Config
|
||||||
ConnManager *wsync.ConnectionManager
|
ConnManager *wsync.ConnectionManager
|
||||||
QueueProcessor *wsync.SyncQueueProcessor
|
QueueProcessor *wsync.SyncQueueProcessor
|
||||||
CleanupCancel context.CancelFunc
|
ProgressService *wsync.ProgressService
|
||||||
QueueCtx context.Context
|
AnnotationService *wsync.AnnotationService
|
||||||
QueueCancel context.CancelFunc
|
CleanupCancel context.CancelFunc
|
||||||
Token string
|
QueueCtx context.Context
|
||||||
RegularToken string
|
QueueCancel context.CancelFunc
|
||||||
mu sync.Mutex
|
Token string
|
||||||
closed bool
|
RegularToken string
|
||||||
|
mu sync.Mutex
|
||||||
|
closed bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close cleans up all resources in the correct order
|
// Close cleans up all resources in the correct order
|
||||||
@@ -243,6 +246,7 @@ func createRegularUserOnce(t *testing.T, db *database.Queries) UserTestData {
|
|||||||
FirstName: pgtype.Text{String: "Regular", Valid: true},
|
FirstName: pgtype.Text{String: "Regular", Valid: true},
|
||||||
LastName: pgtype.Text{String: "User", Valid: true},
|
LastName: pgtype.Text{String: "User", Valid: true},
|
||||||
Role: "user",
|
Role: "user",
|
||||||
|
Timezone: pgtype.Text{String: "UTC", Valid: true},
|
||||||
})
|
})
|
||||||
require.NoError(t, err, "Should create regular test user")
|
require.NoError(t, err, "Should create regular test user")
|
||||||
|
|
||||||
@@ -272,6 +276,10 @@ func uuidToPGType(u uuid.UUID) pgtype.UUID {
|
|||||||
// createDefaultCollectionsForUser creates the 4 default system collections for a user
|
// createDefaultCollectionsForUser creates the 4 default system collections for a user
|
||||||
func createDefaultCollectionsForUser(t *testing.T, db *database.Queries, userID pgtype.UUID) {
|
func createDefaultCollectionsForUser(t *testing.T, db *database.Queries, userID pgtype.UUID) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
// Canonical Title-case names — the same list the registration seed
|
||||||
|
// (CreateDefaultCollectionsForUser) and RestoreSystemCollection use.
|
||||||
|
// A stale kebab-case copy lived here once and duplicated every
|
||||||
|
// system collection in the shared dev DB.
|
||||||
defaultCollections := []struct {
|
defaultCollections := []struct {
|
||||||
Name string
|
Name string
|
||||||
Description string
|
Description string
|
||||||
@@ -280,13 +288,34 @@ func createDefaultCollectionsForUser(t *testing.T, db *database.Queries, userID
|
|||||||
QueryType string
|
QueryType string
|
||||||
Priority int32
|
Priority int32
|
||||||
}{
|
}{
|
||||||
{"continue-reading", "Books you're currently reading (0 < progress < 1)", "📖", "#7aa2f7", "continue-reading", 1},
|
{"Continue Reading", "Books you're currently reading (0 < progress < 1)", "📖", "#7aa2f7", "continue-reading", 1},
|
||||||
{"recently-added", "Newly added items to this library", "🆕", "#9ece6a", "recently-added", 2},
|
{"Recently Added", "Newly added items to this library", "🆕", "#9ece6a", "recently-added", 2},
|
||||||
{"recently-read", "Books you've finished (progress >= 1)", "✅", "#e0af68", "recently-read", 3},
|
{"Recently Read", "Books you've finished (progress >= 1)", "✅", "#e0af68", "recently-read", 3},
|
||||||
{"not-started", "Books you haven't read yet (progress = 0 or no record)", "📕", "#f7768e", "not-started", 4},
|
{"Not Started", "Books you haven't read yet (progress = 0 or no record)", "📕", "#f7768e", "not-started", 4},
|
||||||
|
{"Continue Series", "Next book in series you're reading", "📚", "#bb9af7", "continue-series", 5},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, col := range defaultCollections {
|
for _, col := range defaultCollections {
|
||||||
|
// Idempotent by the STABLE identity (user + query_type): existing
|
||||||
|
// rows (renamed, tombstoned) heal to canonical metadata in place;
|
||||||
|
// insert only when the query type has no row. A blind insert here
|
||||||
|
// hit UNIQUE(user_id, name) on every preserved-admin rerun.
|
||||||
|
existing, gerr := db.GetSystemCollectionByQueryType(ctx, database.GetSystemCollectionByQueryTypeParams{
|
||||||
|
UserID: userID,
|
||||||
|
QueryType: pgtype.Text{String: col.QueryType, Valid: true},
|
||||||
|
})
|
||||||
|
if gerr == nil {
|
||||||
|
_, uerr := db.RestoreSystemCollectionRow(ctx, database.RestoreSystemCollectionRowParams{
|
||||||
|
ID: existing.ID,
|
||||||
|
Name: col.Name,
|
||||||
|
Description: pgtype.Text{String: col.Description, Valid: true},
|
||||||
|
Icon: pgtype.Text{String: col.Icon, Valid: true},
|
||||||
|
Color: pgtype.Text{String: col.Color, Valid: true},
|
||||||
|
Priority: pgtype.Int4{Int32: col.Priority, Valid: true},
|
||||||
|
})
|
||||||
|
require.NoError(t, uerr, "Should restore default collection: "+col.Name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
_, err := db.CreateSystemCollection(ctx, database.CreateSystemCollectionParams{
|
_, err := db.CreateSystemCollection(ctx, database.CreateSystemCollectionParams{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
Name: col.Name,
|
Name: col.Name,
|
||||||
@@ -315,12 +344,15 @@ func loginUserWithCredentials(t *testing.T, ts *httptest.Server, email, password
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err, "Failed to login")
|
require.NoError(t, err, "Failed to login")
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
require.Equal(t, http.StatusOK, resp.StatusCode, "Login should succeed")
|
require.Equal(t, http.StatusOK, resp.StatusCode, "Login should succeed")
|
||||||
|
|
||||||
var result map[string]interface{}
|
var result map[string]interface{}
|
||||||
json.NewDecoder(resp.Body).Decode(&result)
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
token, ok := result["access_token"].(string)
|
token, ok := result["access_token"].(string)
|
||||||
require.True(t, ok, "Should have access_token")
|
require.True(t, ok, "Should have access_token")
|
||||||
@@ -448,17 +480,23 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
|||||||
connManager := wsync.NewConnectionManager()
|
connManager := wsync.NewConnectionManager()
|
||||||
cleanupCancel := connManager.StartCleanupTask()
|
cleanupCancel := connManager.StartCleanupTask()
|
||||||
|
|
||||||
// Create sync queue processor with cancellable context
|
progressService := wsync.NewProgressService(queries, connManager)
|
||||||
|
annotationService := wsync.NewAnnotationService(queries, connManager)
|
||||||
|
|
||||||
queueProcessor := wsync.NewSyncQueueProcessor(queries)
|
queueProcessor := wsync.NewSyncQueueProcessor(queries)
|
||||||
|
queueProcessor.SetProgressService(progressService)
|
||||||
queueCtx, queueCancel := context.WithCancel(context.Background())
|
queueCtx, queueCancel := context.WithCancel(context.Background())
|
||||||
go queueProcessor.Start(queueCtx)
|
go queueProcessor.Start(queueCtx)
|
||||||
|
|
||||||
koreaderHandler := handlers.NewKOReaderHandler(queries, connManager, queueProcessor)
|
koreaderHandler := handlers.NewKOReaderHandler(queries, connManager, queueProcessor)
|
||||||
|
koreaderHandler.SetProgressService(progressService)
|
||||||
|
koreaderHandler.SetAnnotationService(annotationService)
|
||||||
wsHandler := handlers.NewWSHandler(queries, connManager, cfg.JWTSecret, deviceAuthMiddleware)
|
wsHandler := handlers.NewWSHandler(queries, connManager, cfg.JWTSecret, deviceAuthMiddleware)
|
||||||
conflictHandler := handlers.NewConflictHandler(queries, connManager)
|
conflictHandler := handlers.NewConflictHandler(queries, connManager)
|
||||||
analyticsHandler := handlers.NewAnalyticsHandler(queries)
|
analyticsHandler := handlers.NewAnalyticsHandler(queries)
|
||||||
queueHandler := handlers.NewQueueHandler(queries, queueProcessor)
|
queueHandler := handlers.NewQueueHandler(queries, queueProcessor)
|
||||||
systemSettingsHandler := handlers.NewSystemSettingsHandler(queries)
|
systemSettingsHandler := handlers.NewSystemSettingsHandler(queries)
|
||||||
|
processingIssuesHandler := handlers.NewProcessingIssuesHandler(queries)
|
||||||
|
|
||||||
// Create refactored handlers (matching main.go)
|
// Create refactored handlers (matching main.go)
|
||||||
libraryService := services.NewLibraryService(queries)
|
libraryService := services.NewLibraryService(queries)
|
||||||
@@ -469,7 +507,12 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
|||||||
filtersHandler := handlers.NewFiltersHandler(queries)
|
filtersHandler := handlers.NewFiltersHandler(queries)
|
||||||
dashboardService := services.NewDashboardService(queries)
|
dashboardService := services.NewDashboardService(queries)
|
||||||
dashboardHandler := handlers.NewDashboardHandler(queries)
|
dashboardHandler := handlers.NewDashboardHandler(queries)
|
||||||
|
statisticsService := services.NewStatisticsService(queries)
|
||||||
|
statsHandler := handlers.NewStatsHandler(statisticsService)
|
||||||
|
seriesHandler := handlers.NewSeriesHandler(queries)
|
||||||
mediaHandler := handlers.NewMediaHandler(queries, libraryService, worker)
|
mediaHandler := handlers.NewMediaHandler(queries, libraryService, worker)
|
||||||
|
mediaHandler.SetProgressService(progressService)
|
||||||
|
mediaHandler.SetAnnotationService(annotationService)
|
||||||
matchingHandler := handlers.NewMatchingHandler(queries, connManager)
|
matchingHandler := handlers.NewMatchingHandler(queries, connManager)
|
||||||
|
|
||||||
// Create conversion service for OPDS
|
// Create conversion service for OPDS
|
||||||
@@ -499,31 +542,45 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
|||||||
|
|
||||||
// Setup routes using router package
|
// Setup routes using router package
|
||||||
routerConfig := &router.Config{
|
routerConfig := &router.Config{
|
||||||
Echo: e,
|
Echo: e,
|
||||||
Queries: queries,
|
Queries: queries,
|
||||||
Cfg: cfg,
|
Cfg: cfg,
|
||||||
DBPool: dbPool,
|
DBPool: dbPool,
|
||||||
AuthHandler: authHandler,
|
AuthHandler: authHandler,
|
||||||
LibraryHandler: libraryHandler,
|
LibraryHandler: libraryHandler,
|
||||||
DeviceHandler: deviceHandler,
|
DeviceHandler: deviceHandler,
|
||||||
MediaHandler: mediaHandler,
|
MediaHandler: mediaHandler,
|
||||||
MatchingHandler: matchingHandler,
|
MatchingHandler: matchingHandler,
|
||||||
KOReaderHandler: koreaderHandler,
|
KOReaderHandler: koreaderHandler,
|
||||||
WSHandler: wsHandler,
|
WSHandler: wsHandler,
|
||||||
ConflictHandler: conflictHandler,
|
ConflictHandler: conflictHandler,
|
||||||
AnalyticsHandler: analyticsHandler,
|
AnalyticsHandler: analyticsHandler,
|
||||||
QueueHandler: queueHandler,
|
StatsHandler: statsHandler,
|
||||||
SystemSettingsHandler: systemSettingsHandler,
|
StatisticsService: statisticsService,
|
||||||
CollectionHandler: collectionHandler,
|
QueueHandler: queueHandler,
|
||||||
FiltersHandler: filtersHandler,
|
SystemSettingsHandler: systemSettingsHandler,
|
||||||
DashboardHandler: dashboardHandler,
|
ProcessingIssuesHandler: processingIssuesHandler,
|
||||||
DashboardService: dashboardService,
|
CollectionHandler: collectionHandler,
|
||||||
OPDSHandler: opdsHandler,
|
FiltersHandler: filtersHandler,
|
||||||
JobsHandler: jobsHandler,
|
DashboardHandler: dashboardHandler,
|
||||||
ConnManager: connManager,
|
DashboardService: dashboardService,
|
||||||
QueueProcessor: queueProcessor,
|
SeriesHandler: seriesHandler,
|
||||||
DeviceAuthMiddleware: deviceAuthMiddleware,
|
OPDSHandler: opdsHandler,
|
||||||
LoginTracker: loginAttemptTracker,
|
JobsHandler: jobsHandler,
|
||||||
|
ConnManager: connManager,
|
||||||
|
QueueProcessor: queueProcessor,
|
||||||
|
ProgressService: progressService,
|
||||||
|
AnnotationService: annotationService,
|
||||||
|
DeviceAuthMiddleware: deviceAuthMiddleware,
|
||||||
|
LoginTracker: loginAttemptTracker,
|
||||||
|
// RegisterRoutes dereferences Settings for the auth rate limit;
|
||||||
|
// the main binary wires database.NewSettingsRegistry(queries) —
|
||||||
|
// the test harness must too or route registration nil-panics.
|
||||||
|
Settings: database.NewSettingsRegistry(queries),
|
||||||
|
// main.go:246 passes the shared LibraryService here; omitting it
|
||||||
|
// hands the kobo/kepub CFI converter a typed-nil (kobo markup
|
||||||
|
// panic-recovered as 500s in TestProgressWeb_KoboIntegration).
|
||||||
|
LibraryService: libraryService,
|
||||||
}
|
}
|
||||||
|
|
||||||
router.RegisterRoutes(routerConfig)
|
router.RegisterRoutes(routerConfig)
|
||||||
@@ -543,11 +600,15 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
|||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
// Delete ALL test users (any user with test email domains) to ensure clean state
|
// Delete transient test users but preserve the dev admin user
|
||||||
// This handles users created during tests that may have been promoted to admin, etc.
|
// testuser@tests.bookhoard.internal is the shared dev admin — deleting it
|
||||||
|
// triggers ON DELETE SET NULL on libraries.created_by_admin_id
|
||||||
allUsers, err := queries.ListUsers(ctx)
|
allUsers, err := queries.ListUsers(ctx)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
for _, user := range allUsers {
|
for _, user := range allUsers {
|
||||||
|
if user.Email == "testuser@tests.bookhoard.internal" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if strings.HasSuffix(user.Email, "@example.com") || strings.HasSuffix(user.Email, "@tests.bookhoard.internal") {
|
if strings.HasSuffix(user.Email, "@example.com") || strings.HasSuffix(user.Email, "@tests.bookhoard.internal") {
|
||||||
queries.DeleteUser(ctx, user.ID)
|
queries.DeleteUser(ctx, user.ID)
|
||||||
}
|
}
|
||||||
@@ -564,22 +625,74 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create fresh admin test user
|
// The admin test user doubles as the shared dev admin and is
|
||||||
|
// deliberately PRESERVED by the cleanup above — reuse it when it
|
||||||
|
// already exists instead of re-creating (a blind INSERT hits
|
||||||
|
// users_email_key on every run after the first).
|
||||||
passwordHash := "$2a$10$JjAtK7PPa1WexQC3AUGe8OXLeuseZ/haN1Mz7emMo6CfOvMiTVXWq"
|
passwordHash := "$2a$10$JjAtK7PPa1WexQC3AUGe8OXLeuseZ/haN1Mz7emMo6CfOvMiTVXWq"
|
||||||
adminUser, err := queries.CreateUser(ctx, database.CreateUserParams{
|
var adminUUID uuid.UUID
|
||||||
Email: "testuser@tests.bookhoard.internal",
|
if existing, gerr := queries.GetUserByEmail(ctx, "testuser@tests.bookhoard.internal"); gerr == nil {
|
||||||
Username: "testuser",
|
adminUUID, err = uuid.FromBytes(existing.ID.Bytes[:])
|
||||||
PasswordHash: passwordHash,
|
require.NoError(t, err, "Failed to parse admin user UUID")
|
||||||
FirstName: pgtype.Text{String: "Test", Valid: true},
|
// Reset the password to the known test constant: other tests (e.g.
|
||||||
LastName: pgtype.Text{String: "User", Valid: true},
|
// password-change tests) mutate the shared admin's password, which
|
||||||
Role: "admin",
|
// would otherwise lock every later test out with login 401s until
|
||||||
})
|
// the database is wiped.
|
||||||
require.NoError(t, err, "Failed to create admin test user")
|
if updErr := queries.UpdatePassword(ctx, database.UpdatePasswordParams{
|
||||||
|
ID: existing.ID,
|
||||||
adminUUID, err := uuid.FromBytes(adminUser.ID.Bytes[:])
|
PasswordHash: passwordHash,
|
||||||
require.NoError(t, err, "Failed to parse admin user UUID")
|
}); updErr != nil {
|
||||||
|
require.NoError(t, updErr, "Failed to reset shared admin test password")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
adminUser, cerr := queries.CreateUser(ctx, database.CreateUserParams{
|
||||||
|
Email: "testuser@tests.bookhoard.internal",
|
||||||
|
Username: "testuser",
|
||||||
|
PasswordHash: passwordHash,
|
||||||
|
FirstName: pgtype.Text{String: "Test", Valid: true},
|
||||||
|
LastName: pgtype.Text{String: "User", Valid: true},
|
||||||
|
Role: "admin",
|
||||||
|
})
|
||||||
|
require.NoError(t, cerr, "Failed to create admin test user")
|
||||||
|
adminUUID, err = uuid.FromBytes(adminUser.ID.Bytes[:])
|
||||||
|
require.NoError(t, err, "Failed to parse admin user UUID")
|
||||||
|
}
|
||||||
|
// The canonical system-collection set is ensured on EVERY run: the
|
||||||
|
// helper is idempotent by (user, query_type) and heals tombstoned or
|
||||||
|
// renamed rows, so suite runs can't accumulate naming-drift copies.
|
||||||
createDefaultCollectionsForUser(t, queries, pgtype.UUID{Bytes: adminUUID, Valid: true})
|
createDefaultCollectionsForUser(t, queries, pgtype.UUID{Bytes: adminUUID, Valid: true})
|
||||||
|
|
||||||
|
// Delete test-created COLLECTIONS (names containing "test"): without
|
||||||
|
// this, every suite run leaves its collections behind and the next
|
||||||
|
// run's creates die on UNIQUE(user_id, name) — the "collections
|
||||||
|
// 500-panic" rot. System collections are skipped (permanent rows).
|
||||||
|
// Same convention as the library pass: names containing "test".
|
||||||
|
adminColls, cerr := queries.GetCollectionsByUser(ctx, pgtype.UUID{Bytes: adminUUID, Valid: true})
|
||||||
|
if cerr == nil {
|
||||||
|
for _, coll := range adminColls {
|
||||||
|
if !coll.IsSystemCollection.Bool && strings.Contains(strings.ToLower(coll.Name), "test") {
|
||||||
|
queries.DeleteCollection(ctx, coll.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same passes for test DEVICES (identifier/name containing "test")
|
||||||
|
// and test SAVED FILTERS (name containing "test"): both accumulated
|
||||||
|
// across runs and poisoned later runs (duplicate identifiers, 409s,
|
||||||
|
// non-empty "initially empty" assertions).
|
||||||
|
_, derr := dbPool.Exec(ctx,
|
||||||
|
"DELETE FROM devices WHERE user_id = $1 AND (device_identifier ILIKE '%test%' OR device_name ILIKE '%test%')",
|
||||||
|
adminUUID)
|
||||||
|
if derr != nil {
|
||||||
|
t.Logf("device cleanup failed: %v", derr)
|
||||||
|
}
|
||||||
|
_, ferr := dbPool.Exec(ctx,
|
||||||
|
"DELETE FROM saved_filters WHERE user_id = $1 AND name ILIKE '%test%'",
|
||||||
|
adminUUID)
|
||||||
|
if ferr != nil {
|
||||||
|
t.Logf("saved-filter cleanup failed: %v", ferr)
|
||||||
|
}
|
||||||
|
|
||||||
// Create fresh regular test user
|
// Create fresh regular test user
|
||||||
regularUser, err := queries.CreateUser(ctx, database.CreateUserParams{
|
regularUser, err := queries.CreateUser(ctx, database.CreateUserParams{
|
||||||
Email: "testregularuser@tests.bookhoard.internal",
|
Email: "testregularuser@tests.bookhoard.internal",
|
||||||
@@ -588,6 +701,7 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
|||||||
FirstName: pgtype.Text{String: "Regular", Valid: true},
|
FirstName: pgtype.Text{String: "Regular", Valid: true},
|
||||||
LastName: pgtype.Text{String: "User", Valid: true},
|
LastName: pgtype.Text{String: "User", Valid: true},
|
||||||
Role: "user",
|
Role: "user",
|
||||||
|
Timezone: pgtype.Text{String: "UTC", Valid: true},
|
||||||
})
|
})
|
||||||
require.NoError(t, err, "Failed to create regular test user")
|
require.NoError(t, err, "Failed to create regular test user")
|
||||||
|
|
||||||
@@ -601,17 +715,18 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
|||||||
|
|
||||||
// Create TestServerSetup struct with all resources
|
// Create TestServerSetup struct with all resources
|
||||||
setup := &TestServerSetup{
|
setup := &TestServerSetup{
|
||||||
Server: ts,
|
Server: ts,
|
||||||
DB: queries,
|
DB: queries,
|
||||||
DBPool: dbPool,
|
DBPool: dbPool,
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
ConnManager: connManager,
|
ConnManager: connManager,
|
||||||
QueueProcessor: queueProcessor,
|
QueueProcessor: queueProcessor,
|
||||||
CleanupCancel: cleanupCancel,
|
ProgressService: progressService,
|
||||||
QueueCtx: queueCtx,
|
CleanupCancel: cleanupCancel,
|
||||||
QueueCancel: queueCancel,
|
QueueCtx: queueCtx,
|
||||||
Token: adminToken,
|
QueueCancel: queueCancel,
|
||||||
RegularToken: regularToken,
|
Token: adminToken,
|
||||||
|
RegularToken: regularToken,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register cleanup function to run automatically when test completes
|
// Register cleanup function to run automatically when test completes
|
||||||
@@ -627,6 +742,17 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Same pass for test-created COLLECTIONS (the "collections
|
||||||
|
// 500-panic" rot: they lingered forever and poisoned later runs).
|
||||||
|
adminColls, cerr := queries.GetCollectionsByUser(ctx, pgtype.UUID{Bytes: adminUUID, Valid: true})
|
||||||
|
if cerr == nil {
|
||||||
|
for _, coll := range adminColls {
|
||||||
|
if !coll.IsSystemCollection.Bool && strings.Contains(strings.ToLower(coll.Name), "test") {
|
||||||
|
queries.DeleteCollection(ctx, coll.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err := setup.Close(); err != nil {
|
if err := setup.Close(); err != nil {
|
||||||
t.Errorf("Failed to cleanup test server: %v", err)
|
t.Errorf("Failed to cleanup test server: %v", err)
|
||||||
}
|
}
|
||||||
@@ -648,12 +774,15 @@ func loginWithCredentials(t *testing.T, ts *httptest.Server, email, password str
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err, "Failed to login")
|
require.NoError(t, err, "Failed to login")
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
require.Equal(t, http.StatusOK, resp.StatusCode, "Login should succeed")
|
require.Equal(t, http.StatusOK, resp.StatusCode, "Login should succeed")
|
||||||
|
|
||||||
var result map[string]interface{}
|
var result map[string]interface{}
|
||||||
json.NewDecoder(resp.Body).Decode(&result)
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
token, ok := result["access_token"].(string)
|
token, ok := result["access_token"].(string)
|
||||||
require.True(t, ok, "Should have access_token")
|
require.True(t, ok, "Should have access_token")
|
||||||
@@ -671,6 +800,26 @@ func getTestUserID(t *testing.T, db *database.Queries) uuid.UUID {
|
|||||||
return userUUID
|
return userUUID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// createLibraryDirect inserts a library owned by the given user WITHOUT a
|
||||||
|
// nested test-server setup: a second setupTestServer bootstraps away the
|
||||||
|
// first setup's users, and the ghost-session guard rightly rejects the
|
||||||
|
// outer test's tokens from then on.
|
||||||
|
func createLibraryDirect(t *testing.T, db *database.Queries, ownerID uuid.UUID, name, libraryType string) string {
|
||||||
|
ctx := context.Background()
|
||||||
|
libraryTypeRow, err := db.GetLibraryTypeByName(ctx, libraryType)
|
||||||
|
require.NoError(t, err, "Should find library type")
|
||||||
|
library, err := db.CreateLibrary(ctx, database.CreateLibraryParams{
|
||||||
|
Name: name,
|
||||||
|
Description: pgtype.Text{String: "Test library description", Valid: true},
|
||||||
|
LibraryTypeID: libraryTypeRow.ID,
|
||||||
|
CreatedByAdminID: pgtype.UUID{Bytes: [16]byte(ownerID), Valid: true},
|
||||||
|
})
|
||||||
|
require.NoError(t, err, "Should create library")
|
||||||
|
libraryUUID, err := uuid.FromBytes(library.ID.Bytes[0:16])
|
||||||
|
require.NoError(t, err, "Should parse library ID")
|
||||||
|
return libraryUUID.String()
|
||||||
|
}
|
||||||
|
|
||||||
func getRegularUserID(t *testing.T, db *database.Queries) uuid.UUID {
|
func getRegularUserID(t *testing.T, db *database.Queries) uuid.UUID {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
user, err := db.GetUserByEmail(ctx, "testregularuser@tests.bookhoard.internal")
|
user, err := db.GetUserByEmail(ctx, "testregularuser@tests.bookhoard.internal")
|
||||||
@@ -698,12 +847,15 @@ func createTestMediaItemID(t *testing.T, setup *TestServerSetup) string {
|
|||||||
|
|
||||||
resp, err := httpClient.Do(req)
|
resp, err := httpClient.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||||
|
|
||||||
var libResult map[string]interface{}
|
var libResult map[string]interface{}
|
||||||
json.NewDecoder(resp.Body).Decode(&libResult)
|
err = json.NewDecoder(resp.Body).Decode(&libResult)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
libData := libResult["id"].(string)
|
libData := libResult["id"].(string)
|
||||||
|
|
||||||
@@ -718,7 +870,9 @@ func createTestMediaItemID(t *testing.T, setup *TestServerSetup) string {
|
|||||||
|
|
||||||
folderResp, err := httpClient.Do(folderReqHTTP)
|
folderResp, err := httpClient.Do(folderReqHTTP)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer folderResp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(folderResp.Body)
|
||||||
require.Equal(t, http.StatusCreated, folderResp.StatusCode, "Library folder creation is required before adding media items")
|
require.Equal(t, http.StatusCreated, folderResp.StatusCode, "Library folder creation is required before adding media items")
|
||||||
|
|
||||||
mediaItemReq := map[string]interface{}{
|
mediaItemReq := map[string]interface{}{
|
||||||
@@ -737,12 +891,15 @@ func createTestMediaItemID(t *testing.T, setup *TestServerSetup) string {
|
|||||||
|
|
||||||
resp2, err := httpClient.Do(req2)
|
resp2, err := httpClient.Do(req2)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp2.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp2.Body)
|
||||||
|
|
||||||
require.Equal(t, http.StatusCreated, resp2.StatusCode)
|
require.Equal(t, http.StatusCreated, resp2.StatusCode)
|
||||||
|
|
||||||
var mediaItemResult map[string]interface{}
|
var mediaItemResult map[string]interface{}
|
||||||
json.NewDecoder(resp2.Body).Decode(&mediaItemResult)
|
err = json.NewDecoder(resp2.Body).Decode(&mediaItemResult)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
mediaItemID := mediaItemResult["id"].(string)
|
mediaItemID := mediaItemResult["id"].(string)
|
||||||
|
|
||||||
@@ -771,6 +928,8 @@ func addFolderToLibrary(t *testing.T, setup *TestServerSetup, libraryID string,
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||||
}
|
}
|
||||||
@@ -8,22 +8,65 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"bookhoard/internal/database"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ensureSingleAdmin makes the "last admin" precondition deterministic in
|
||||||
|
// the shared test DB: only the preserved testuser admin remains. Other
|
||||||
|
// suites may have promoted or renamed users; the last-admin semantics
|
||||||
|
// under test require a true singleton.
|
||||||
|
func ensureSingleAdmin(t *testing.T, setup *TestServerSetup) {
|
||||||
|
ctx := context.Background()
|
||||||
|
_, err := setup.DBPool.Exec(ctx,
|
||||||
|
"DELETE FROM users WHERE role = 'admin' AND email != 'testuser@tests.bookhoard.internal'")
|
||||||
|
require.NoError(t, err, "Failed to prune extra admins")
|
||||||
|
|
||||||
|
if _, err := setup.DB.GetUserByEmail(ctx, "testuser@tests.bookhoard.internal"); err != nil {
|
||||||
|
passwordHash := "$2a$10$JjAtK7PPa1WexQC3AUGe8OXLeuseZ/haN1Mz7emMo6CfOvMiTVXWq"
|
||||||
|
_, cerr := setup.DB.CreateUser(ctx, database.CreateUserParams{
|
||||||
|
Email: "testuser@tests.bookhoard.internal",
|
||||||
|
Username: "testuser",
|
||||||
|
PasswordHash: passwordHash,
|
||||||
|
Role: "admin",
|
||||||
|
Timezone: pgtype.Text{String: "UTC", Valid: true},
|
||||||
|
})
|
||||||
|
require.NoError(t, cerr, "Failed to restore the shared admin")
|
||||||
|
}
|
||||||
|
|
||||||
|
users, _ := setup.DB.ListUsers(ctx)
|
||||||
|
adminCount := 0
|
||||||
|
for _, u := range users {
|
||||||
|
if u.Role == "admin" {
|
||||||
|
adminCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require.Equal(t, 1, adminCount, "precondition: exactly one admin")
|
||||||
|
}
|
||||||
|
|
||||||
func TestUpdateProfileSelfEdit(t *testing.T) {
|
func TestUpdateProfileSelfEdit(t *testing.T) {
|
||||||
t.Run("PUT /api/auth/profile - Update all fields successfully", func(t *testing.T) {
|
t.Run("PUT /api/auth/profile - Update all fields successfully", func(t *testing.T) {
|
||||||
setup := setupTestServer(t)
|
setup := setupTestServer(t)
|
||||||
|
|
||||||
_ = createTestUserOnce(t, setup.DB)
|
// Self-edit a DEDICATED user, never the shared admin: renaming
|
||||||
token := setup.Token
|
// testuser here leaked a second admin row into the shared DB (the
|
||||||
|
// renamed email escapes the harness's preserve-list cleanup) and
|
||||||
|
// broke the last-admin tests downstream.
|
||||||
|
selfUser := createRegularUserOnce(t, setup.DB)
|
||||||
|
token := loginUserWithCredentials(t, setup.Server, selfUser.Email, selfUser.Password)
|
||||||
|
|
||||||
|
// Unique rename target: a fixed name collides with stale rows from
|
||||||
|
// earlier runs of this very test (409 username conflict).
|
||||||
|
suffix := uuid.New().String()[:8]
|
||||||
|
newUsername := "updateduser-" + suffix
|
||||||
updateData := map[string]interface{}{
|
updateData := map[string]interface{}{
|
||||||
"username": "updateduser",
|
"username": newUsername,
|
||||||
"email": "updated@tests.bookhoard.internal",
|
"email": "updated-" + suffix + "@tests.bookhoard.internal",
|
||||||
"first_name": "Updated",
|
"first_name": "Updated",
|
||||||
"last_name": "Name",
|
"last_name": "Name",
|
||||||
"theme": "dracula",
|
"theme": "dracula",
|
||||||
@@ -39,9 +82,9 @@ func TestUpdateProfileSelfEdit(t *testing.T) {
|
|||||||
|
|
||||||
assert.Equal(t, http.StatusOK, rec.Code)
|
assert.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
|
||||||
user, err := setup.DB.GetUserByUsername(context.Background(), "updateduser")
|
user, err := setup.DB.GetUserByUsername(context.Background(), newUsername)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, "updated@tests.bookhoard.internal", user.Email)
|
assert.Equal(t, "updated-"+suffix+"@tests.bookhoard.internal", user.Email)
|
||||||
assert.Equal(t, "Updated", user.FirstName.String)
|
assert.Equal(t, "Updated", user.FirstName.String)
|
||||||
assert.Equal(t, "Name", user.LastName.String)
|
assert.Equal(t, "Name", user.LastName.String)
|
||||||
assert.Equal(t, "dracula", user.Theme.String)
|
assert.Equal(t, "dracula", user.Theme.String)
|
||||||
@@ -215,6 +258,10 @@ func TestUpdateProfileAdminMode(t *testing.T) {
|
|||||||
t.Run("PUT /api/auth/profile/:id - Try to demote last admin", func(t *testing.T) {
|
t.Run("PUT /api/auth/profile/:id - Try to demote last admin", func(t *testing.T) {
|
||||||
setup := setupTestServer(t)
|
setup := setupTestServer(t)
|
||||||
|
|
||||||
|
// Deterministic singleton-admin precondition (shared DB may carry
|
||||||
|
// promoted/renamed admin rows from other suites).
|
||||||
|
ensureSingleAdmin(t, setup)
|
||||||
|
|
||||||
// Delete the regular user so we have only 1 admin (testuser)
|
// Delete the regular user so we have only 1 admin (testuser)
|
||||||
regularUser := createRegularUserOnce(t, setup.DB)
|
regularUser := createRegularUserOnce(t, setup.DB)
|
||||||
err := setup.DB.DeleteUser(context.Background(), uuidToPGType(regularUser.ID))
|
err := setup.DB.DeleteUser(context.Background(), uuidToPGType(regularUser.ID))
|
||||||
@@ -435,6 +482,7 @@ func TestDeleteUserConsolidated(t *testing.T) {
|
|||||||
t.Run("DELETE /api/auth/profile - Last admin tries self-delete", func(t *testing.T) {
|
t.Run("DELETE /api/auth/profile - Last admin tries self-delete", func(t *testing.T) {
|
||||||
setup := setupTestServer(t)
|
setup := setupTestServer(t)
|
||||||
|
|
||||||
|
ensureSingleAdmin(t, setup)
|
||||||
_ = createTestUserOnce(t, setup.DB)
|
_ = createTestUserOnce(t, setup.DB)
|
||||||
token := setup.Token
|
token := setup.Token
|
||||||
|
|
||||||
@@ -471,6 +519,7 @@ func TestDeleteUserConsolidated(t *testing.T) {
|
|||||||
t.Run("DELETE /api/auth/profile/:id - Admin tries delete last admin", func(t *testing.T) {
|
t.Run("DELETE /api/auth/profile/:id - Admin tries delete last admin", func(t *testing.T) {
|
||||||
setup := setupTestServer(t)
|
setup := setupTestServer(t)
|
||||||
|
|
||||||
|
ensureSingleAdmin(t, setup)
|
||||||
_ = createTestUserOnce(t, setup.DB)
|
_ = createTestUserOnce(t, setup.DB)
|
||||||
token := setup.Token
|
token := setup.Token
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -83,7 +84,9 @@ func TestWebSocketDeviceAuth(t *testing.T) {
|
|||||||
require.NoError(t, err, "WebSocket connection with device token should succeed")
|
require.NoError(t, err, "WebSocket connection with device token should succeed")
|
||||||
defer ws.Close()
|
defer ws.Close()
|
||||||
if resp != nil {
|
if resp != nil {
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
require.Equal(t, http.StatusSwitchingProtocols, resp.StatusCode, "Should upgrade to WebSocket")
|
require.Equal(t, http.StatusSwitchingProtocols, resp.StatusCode, "Should upgrade to WebSocket")
|
||||||
}
|
}
|
||||||
// Read initial state message
|
// Read initial state message
|
||||||
@@ -116,26 +119,23 @@ func TestWebSocketProgressBroadcast(t *testing.T) {
|
|||||||
ws.SetReadDeadline(time.Now().Add(5 * time.Second))
|
ws.SetReadDeadline(time.Now().Add(5 * time.Second))
|
||||||
_, _, _ = ws.ReadMessage()
|
_, _, _ = ws.ReadMessage()
|
||||||
|
|
||||||
// Update progress via HTTP API
|
// Update progress via HTTP API to new media-item progress endpoint
|
||||||
progressReq := map[string]interface{}{
|
progressReq := map[string]interface{}{
|
||||||
"source": "test",
|
"percentage": 0.5,
|
||||||
"location": map[string]interface{}{
|
"epubcfi": "epubcfi(/6/4/2:10)",
|
||||||
"percentage": 0.5,
|
|
||||||
},
|
|
||||||
"device_metadata": map[string]interface{}{
|
|
||||||
"device_type": "web",
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
body, _ := json.Marshal(progressReq)
|
body, _ := json.Marshal(progressReq)
|
||||||
|
|
||||||
req, _ := http.NewRequest("POST", setup.Server.URL+"/api/progress/"+mediaID, strings.NewReader(string(body)))
|
req, _ := http.NewRequest("PUT", setup.Server.URL+"/api/media-items/"+mediaID+"/progress", bytes.NewBuffer(body))
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
req.Header.Set("Authorization", "Bearer "+token)
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
|
||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
@@ -240,9 +240,11 @@ func TestWebSocketUserScopedBroadcast(t *testing.T) {
|
|||||||
// Use pre-created admin user (setup.Token) and regular user (setup.RegularToken)
|
// Use pre-created admin user (setup.Token) and regular user (setup.RegularToken)
|
||||||
// Both users already created by setupTestServer()
|
// Both users already created by setupTestServer()
|
||||||
|
|
||||||
// Create a collection for admin user
|
// Create a collection for admin user. Unique name: the shared DB keeps
|
||||||
|
// non-"test"-named collections across runs, and UNIQUE(user_id, name)
|
||||||
|
// turns a fixed name into a 409 on the second run.
|
||||||
collectionReq := map[string]interface{}{
|
collectionReq := map[string]interface{}{
|
||||||
"name": "Admin Collection",
|
"name": "Admin Collection " + uuid.New().String()[:8],
|
||||||
"description": "Test collection for WebSocket test",
|
"description": "Test collection for WebSocket test",
|
||||||
}
|
}
|
||||||
collectionBody, _ := json.Marshal(collectionReq)
|
collectionBody, _ := json.Marshal(collectionReq)
|
||||||
@@ -253,11 +255,14 @@ func TestWebSocketUserScopedBroadcast(t *testing.T) {
|
|||||||
|
|
||||||
collectionResp, err := client.Do(collectionHTTP)
|
collectionResp, err := client.Do(collectionHTTP)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer collectionResp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(collectionResp.Body)
|
||||||
require.Equal(t, http.StatusCreated, collectionResp.StatusCode)
|
require.Equal(t, http.StatusCreated, collectionResp.StatusCode)
|
||||||
|
|
||||||
var collectionResult map[string]interface{}
|
var collectionResult map[string]interface{}
|
||||||
json.NewDecoder(collectionResp.Body).Decode(&collectionResult)
|
err = json.NewDecoder(collectionResp.Body).Decode(&collectionResult)
|
||||||
|
require.NoError(t, err)
|
||||||
collectionID := collectionResult["id"].(string)
|
collectionID := collectionResult["id"].(string)
|
||||||
|
|
||||||
// Create a test book via API
|
// Create a test book via API
|
||||||
@@ -289,7 +294,9 @@ func TestWebSocketUserScopedBroadcast(t *testing.T) {
|
|||||||
|
|
||||||
addResp, err := client.Do(addHTTP)
|
addResp, err := client.Do(addHTTP)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer addResp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(addResp.Body)
|
||||||
require.Equal(t, http.StatusNoContent, addResp.StatusCode)
|
require.Equal(t, http.StatusNoContent, addResp.StatusCode)
|
||||||
|
|
||||||
// Admin should receive collection_updated message
|
// Admin should receive collection_updated message
|
||||||
@@ -313,7 +320,7 @@ func connectWebSocketToServer(t *testing.T, serverURL string, token string) *web
|
|||||||
ws, resp, err := websocket.DefaultDialer.Dial(wsURL, nil)
|
ws, resp, err := websocket.DefaultDialer.Dial(wsURL, nil)
|
||||||
require.NoError(t, err, "WebSocket connection should succeed")
|
require.NoError(t, err, "WebSocket connection should succeed")
|
||||||
if resp != nil {
|
if resp != nil {
|
||||||
resp.Body.Close()
|
_ = resp.Body.Close()
|
||||||
}
|
}
|
||||||
require.NotNil(t, ws, "WebSocket connection should be established")
|
require.NotNil(t, ws, "WebSocket connection should be established")
|
||||||
|
|
||||||
|
|||||||
@@ -41,8 +41,9 @@ func TestWorker_DirectoryScanJob(t *testing.T) {
|
|||||||
require.Equal(t, http.StatusCreated, createLibResp.StatusCode)
|
require.Equal(t, http.StatusCreated, createLibResp.StatusCode)
|
||||||
|
|
||||||
var createLibResponse map[string]interface{}
|
var createLibResponse map[string]interface{}
|
||||||
json.NewDecoder(createLibResp.Body).Decode(&createLibResponse)
|
err = json.NewDecoder(createLibResp.Body).Decode(&createLibResponse)
|
||||||
createLibResp.Body.Close()
|
require.NoError(t, err)
|
||||||
|
_ = createLibResp.Body.Close()
|
||||||
|
|
||||||
libraryID, ok := createLibResponse["id"].(string)
|
libraryID, ok := createLibResponse["id"].(string)
|
||||||
require.True(t, ok)
|
require.True(t, ok)
|
||||||
@@ -62,7 +63,7 @@ func TestWorker_DirectoryScanJob(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
resp.Body.Close()
|
_ = resp.Body.Close()
|
||||||
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||||
|
|
||||||
// Create test files in the directory
|
// Create test files in the directory
|
||||||
@@ -141,8 +142,9 @@ func TestWorker_SetFoldersJob(t *testing.T) {
|
|||||||
require.Equal(t, http.StatusCreated, createLibResp.StatusCode)
|
require.Equal(t, http.StatusCreated, createLibResp.StatusCode)
|
||||||
|
|
||||||
var createLibResponse map[string]interface{}
|
var createLibResponse map[string]interface{}
|
||||||
json.NewDecoder(createLibResp.Body).Decode(&createLibResponse)
|
err = json.NewDecoder(createLibResp.Body).Decode(&createLibResponse)
|
||||||
createLibResp.Body.Close()
|
require.NoError(t, err)
|
||||||
|
_ = createLibResp.Body.Close()
|
||||||
|
|
||||||
libraryID, ok := createLibResponse["id"].(string)
|
libraryID, ok := createLibResponse["id"].(string)
|
||||||
require.True(t, ok)
|
require.True(t, ok)
|
||||||
|
|||||||
+364
-16
@@ -17,7 +17,7 @@ CREATE TABLE IF NOT EXISTS library_types (
|
|||||||
INSERT INTO library_types (name, description, allowed_extensions) VALUES
|
INSERT INTO library_types (name, description, allowed_extensions) VALUES
|
||||||
('ebooks', 'Ebook files including EPUB, PDF, MOBI, etc.', ARRAY['.epub', '.pdf', '.mobi', '.azw', '.azw3', '.txt', '.rtf', '.doc', '.docx', '.lit', '.fb2', '.pdb']),
|
('ebooks', 'Ebook files including EPUB, PDF, MOBI, etc.', ARRAY['.epub', '.pdf', '.mobi', '.azw', '.azw3', '.txt', '.rtf', '.doc', '.docx', '.lit', '.fb2', '.pdb']),
|
||||||
('comics', 'Comic book archives and image formats', ARRAY['.cbz', '.cbr', '.cb7', '.cbt', '.epub', '.pdf']),
|
('comics', 'Comic book archives and image formats', ARRAY['.cbz', '.cbr', '.cb7', '.cbt', '.epub', '.pdf']),
|
||||||
('manga', 'Manga files including archives and image folders', ARRAY['.cbz', '.cbr', '.epub', '.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'])
|
('manga', 'Manga files including archives and image folders', ARRAY['.cbz', '.cbr', '.epub', '.pdf', '.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp', '.avif', '.tiff', '.tif'])
|
||||||
ON CONFLICT (name) DO NOTHING;
|
ON CONFLICT (name) DO NOTHING;
|
||||||
|
|
||||||
-- Create users table
|
-- Create users table
|
||||||
@@ -32,6 +32,7 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
theme VARCHAR(50) DEFAULT 'tokyo-night',
|
theme VARCHAR(50) DEFAULT 'tokyo-night',
|
||||||
max_devices INTEGER DEFAULT 10,
|
max_devices INTEGER DEFAULT 10,
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
timezone VARCHAR(50) DEFAULT 'UTC',
|
||||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -44,10 +45,48 @@ CREATE TABLE IF NOT EXISTS system_settings (
|
|||||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Insert default system settings
|
-- Extend system_settings with typed metadata so it can back the admin UI's
|
||||||
INSERT INTO system_settings (setting_key, setting_value, description) VALUES
|
-- configurable tunables. All columns are nullable for backward compatibility
|
||||||
('scan_poll_interval_seconds', '60', 'How often to scan all libraries in minutes'),
|
-- with the original three rows and any pre-existing data.
|
||||||
('auto_scan_enabled', 'true', 'Whether auto-scanning is enabled system-wide')
|
ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS setting_type VARCHAR(20);
|
||||||
|
ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS min_value TEXT;
|
||||||
|
ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS max_value TEXT;
|
||||||
|
ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS requires_restart BOOLEAN DEFAULT FALSE;
|
||||||
|
ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS category VARCHAR(40);
|
||||||
|
|
||||||
|
-- Insert default system settings (original scan/timezone rows + tunables).
|
||||||
|
-- Values match the previous hardcoded literals, so behavior is unchanged on upgrade.
|
||||||
|
-- ON CONFLICT DO NOTHING preserves any admin-modified values.
|
||||||
|
INSERT INTO system_settings (setting_key, setting_value, description, setting_type, min_value, max_value, requires_restart, category) VALUES
|
||||||
|
('scan_poll_interval_seconds', '60', 'How often to scan all libraries (seconds)', 'int', '1', '3600', FALSE, 'scanner'),
|
||||||
|
('auto_scan_enabled', 'true', 'Whether auto-scanning is enabled system-wide', 'bool', NULL, NULL, FALSE, 'scanner'),
|
||||||
|
('default_timezone', 'UTC', 'System default timezone', 'string', NULL, NULL, FALSE, 'general'),
|
||||||
|
-- security / auth (live)
|
||||||
|
('session_duration_seconds', '604800', 'How long a login session stays valid', 'int', '300', '31536000', FALSE, 'security'),
|
||||||
|
('password_min_length', '8', 'Minimum password length', 'int', '1', '128', FALSE, 'security'),
|
||||||
|
('password_require_upper', 'true', 'Require at least one uppercase letter (A-Z)', 'bool', NULL, NULL, FALSE, 'security'),
|
||||||
|
('password_require_lower', 'true', 'Require at least one lowercase letter (a-z)', 'bool', NULL, NULL, FALSE, 'security'),
|
||||||
|
('password_require_number', 'true', 'Require at least one number (0-9)', 'bool', NULL, NULL, FALSE, 'security'),
|
||||||
|
('password_require_special', 'true', 'Require at least one special character', 'bool', NULL, NULL, FALSE, 'security'),
|
||||||
|
('registration_enabled', 'false', 'Allow users to create their own accounts (admins can always create accounts)', 'bool', NULL, NULL, FALSE, 'security'),
|
||||||
|
-- security / auth (restart required)
|
||||||
|
('auth_rate_limit_per_min', '10', 'Global auth API rate limit (requests per minute)', 'int', '1', '10000', TRUE, 'security'),
|
||||||
|
('login_max_attempts', '5', 'Failed login attempts before lockout', 'int', '1', '100', TRUE, 'security'),
|
||||||
|
('login_lockout_minutes', '15', 'Lockout duration after too many failed logins', 'int', '1', '10080', TRUE, 'security'),
|
||||||
|
-- api (live)
|
||||||
|
('opds_default_page_size', '50', 'Default OPDS page size', 'int', '1', '500', FALSE, 'api'),
|
||||||
|
('opds_max_page_size', '200', 'Maximum OPDS page size', 'int', '1', '1000', FALSE, 'api'),
|
||||||
|
('device_rate_sync_per_min', '60', 'Device sync requests per minute', 'int', '1', '10000', FALSE, 'api'),
|
||||||
|
('device_rate_progress_per_min', '120', 'Device progress requests per minute', 'int', '1', '10000', FALSE, 'api'),
|
||||||
|
('device_rate_metadata_per_min', '30', 'Device metadata requests per minute', 'int', '1', '10000', FALSE, 'api'),
|
||||||
|
-- sync / performance (live)
|
||||||
|
('annotation_tombstone_ttl_days', '30', 'How long deleted annotations are kept before purge', 'int', '1', '3650', FALSE, 'sync'),
|
||||||
|
('conversion_cache_ttl_hours', '24', 'How long converted (kepub) files are cached', 'int', '1', '720', FALSE, 'performance'),
|
||||||
|
-- sync / performance (restart required)
|
||||||
|
('sync_queue_interval_seconds', '5', 'How often the sync queue flushes', 'int', '1', '3600', TRUE, 'sync'),
|
||||||
|
('sync_queue_batch_size', '50', 'Maximum items processed per sync queue flush', 'int', '1', '10000', TRUE, 'sync'),
|
||||||
|
('worker_pool_size', '3', 'Number of background worker goroutines', 'int', '1', '100', TRUE, 'performance'),
|
||||||
|
('worker_queue_cap', '100', 'Background worker job queue capacity', 'int', '1', '10000', TRUE, 'performance')
|
||||||
ON CONFLICT (setting_key) DO NOTHING;
|
ON CONFLICT (setting_key) DO NOTHING;
|
||||||
|
|
||||||
-- Create refresh_tokens table
|
-- Create refresh_tokens table
|
||||||
@@ -121,6 +160,7 @@ CREATE TABLE IF NOT EXISTS media_items (
|
|||||||
google_books_id VARCHAR(100), -- Google Books identifier
|
google_books_id VARCHAR(100), -- Google Books identifier
|
||||||
added_by_admin_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
added_by_admin_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
imported_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
-- Universal Sync Format Detection
|
-- Universal Sync Format Detection
|
||||||
format_group VARCHAR(20) NOT NULL DEFAULT 'reflowable',
|
format_group VARCHAR(20) NOT NULL DEFAULT 'reflowable',
|
||||||
@@ -168,6 +208,19 @@ CREATE TABLE IF NOT EXISTS media_items (
|
|||||||
-- Summary (distinct from description - may merge with Calibre description)
|
-- Summary (distinct from description - may merge with Calibre description)
|
||||||
summary TEXT, -- Summary from ComicInfo.xml (may be merged with description from Calibre)
|
summary TEXT, -- Summary from ComicInfo.xml (may be merged with description from Calibre)
|
||||||
|
|
||||||
|
-- Per-field user-override tracking. Column names listed here (e.g.
|
||||||
|
-- 'description', 'tags', 'cover_image_path') are user-customized and MUST
|
||||||
|
-- NOT be overwritten by library scans or per-book rescans; only the
|
||||||
|
-- reset-to-scanned-defaults action clears them.
|
||||||
|
metadata_overrides TEXT[] NOT NULL DEFAULT '{}',
|
||||||
|
|
||||||
|
-- Archive lifecycle: a file missing from disk for two consecutive scans
|
||||||
|
-- gets archived_at set (hidden from all browsing, reading history kept).
|
||||||
|
-- If the file returns, the row is un-archived. Archived rows are purged
|
||||||
|
-- after ARCHIVE_RETENTION_DAYS (0 = manual purge only).
|
||||||
|
missing_scan_count INT NOT NULL DEFAULT 0,
|
||||||
|
archived_at TIMESTAMPTZ,
|
||||||
|
|
||||||
-- Chapter metadata for reader navigation and progress tracking
|
-- Chapter metadata for reader navigation and progress tracking
|
||||||
-- Caches detected chapter structure to avoid re-parsing files
|
-- Caches detected chapter structure to avoid re-parsing files
|
||||||
-- Populated by ReaderService.DetectChapters() on first read
|
-- Populated by ReaderService.DetectChapters() on first read
|
||||||
@@ -193,12 +246,25 @@ CREATE INDEX IF NOT EXISTS idx_media_items_contributors_gin ON media_items USING
|
|||||||
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS tags_search TEXT[];
|
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS tags_search TEXT[];
|
||||||
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS contributors_search TEXT[];
|
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS contributors_search TEXT[];
|
||||||
|
|
||||||
|
-- Per-field user-override tracking (idempotent backfill for existing
|
||||||
|
-- installs; the column is also declared in the media_items table
|
||||||
|
-- definition above for fresh databases)
|
||||||
|
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS metadata_overrides TEXT[] NOT NULL DEFAULT '{}';
|
||||||
|
|
||||||
|
-- Archive lifecycle (idempotent backfill for existing installs)
|
||||||
|
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS missing_scan_count INT NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS archived_at TIMESTAMPTZ;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_media_items_archived ON media_items (archived_at) WHERE archived_at IS NOT NULL;
|
||||||
|
|
||||||
-- Create GIN indexes for fast search field searches
|
-- Create GIN indexes for fast search field searches
|
||||||
CREATE INDEX IF NOT EXISTS idx_media_items_tags_search ON media_items USING GIN (tags_search);
|
CREATE INDEX IF NOT EXISTS idx_media_items_tags_search ON media_items USING GIN (tags_search);
|
||||||
CREATE INDEX IF NOT EXISTS idx_media_items_contributors_search ON media_items USING GIN (contributors_search);
|
CREATE INDEX IF NOT EXISTS idx_media_items_contributors_search ON media_items USING GIN (contributors_search);
|
||||||
|
|
||||||
-- Drop existing trigger if exists
|
-- Drop existing triggers if exists. Both must be dropped BEFORE the function:
|
||||||
|
-- dropping a function that triggers still reference fails (SQLSTATE 2BP01),
|
||||||
|
-- which would abort the schema transaction on every startup after the first.
|
||||||
DROP TRIGGER IF EXISTS set_library_type_name_on_insert ON media_items;
|
DROP TRIGGER IF EXISTS set_library_type_name_on_insert ON media_items;
|
||||||
|
DROP TRIGGER IF EXISTS set_library_type_name_on_library_change ON media_items;
|
||||||
-- Drop existing function if exists
|
-- Drop existing function if exists
|
||||||
DROP FUNCTION IF EXISTS set_library_type_name();
|
DROP FUNCTION IF EXISTS set_library_type_name();
|
||||||
|
|
||||||
@@ -217,6 +283,14 @@ CREATE TRIGGER set_library_type_name_on_insert
|
|||||||
FOR EACH ROW
|
FOR EACH ROW
|
||||||
EXECUTE FUNCTION set_library_type_name();
|
EXECUTE FUNCTION set_library_type_name();
|
||||||
|
|
||||||
|
-- Keep library_type_name truthful when an item is repointed to another
|
||||||
|
-- library (cross-library move detection reuses the same function; the row
|
||||||
|
-- keeps its ID while its library changes).
|
||||||
|
CREATE TRIGGER set_library_type_name_on_library_change
|
||||||
|
BEFORE UPDATE OF library_id ON media_items
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION set_library_type_name();
|
||||||
|
|
||||||
-- Add GIN indexes for pg_trgm fuzzy search performance
|
-- Add GIN indexes for pg_trgm fuzzy search performance
|
||||||
CREATE INDEX IF NOT EXISTS idx_media_items_author_trgm
|
CREATE INDEX IF NOT EXISTS idx_media_items_author_trgm
|
||||||
ON media_items USING GIN (author gin_trgm_ops);
|
ON media_items USING GIN (author gin_trgm_ops);
|
||||||
@@ -242,6 +316,7 @@ CREATE TABLE IF NOT EXISTS reading_progress (
|
|||||||
percentage FLOAT CHECK (percentage >= 0 AND percentage <= 1),
|
percentage FLOAT CHECK (percentage >= 0 AND percentage <= 1),
|
||||||
character_offset BIGINT,
|
character_offset BIGINT,
|
||||||
epubcfi TEXT,
|
epubcfi TEXT,
|
||||||
|
context_text TEXT,
|
||||||
chapter INTEGER,
|
chapter INTEGER,
|
||||||
chapter_progress FLOAT CHECK (chapter_progress >= 0 AND chapter_progress <= 1),
|
chapter_progress FLOAT CHECK (chapter_progress >= 0 AND chapter_progress <= 1),
|
||||||
viewport_x FLOAT DEFAULT 0,
|
viewport_x FLOAT DEFAULT 0,
|
||||||
@@ -270,6 +345,20 @@ CREATE TABLE IF NOT EXISTS media_ratings (
|
|||||||
UNIQUE(media_item_id, user_id)
|
UNIQUE(media_item_id, user_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Create media_reviews table: the user's markdown book review. Sibling of
|
||||||
|
-- media_ratings (same uniqueness + cascade shape) but standalone — a review
|
||||||
|
-- without a rating (and vice versa) must work. Stored as RAW markdown and
|
||||||
|
-- rendered only at display sites (same doctrine as notes).
|
||||||
|
CREATE TABLE IF NOT EXISTS media_reviews (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
review TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
UNIQUE(media_item_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
-- Create media_notes table for user notes on media items
|
-- Create media_notes table for user notes on media items
|
||||||
CREATE TABLE IF NOT EXISTS media_notes (
|
CREATE TABLE IF NOT EXISTS media_notes (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
@@ -295,8 +384,8 @@ CREATE TABLE IF NOT EXISTS media_highlights (
|
|||||||
media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
|
media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
|
||||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
selection_text TEXT NOT NULL,
|
selection_text TEXT NOT NULL,
|
||||||
start_position VARCHAR(100), -- position (page:offset or CFI) where highlight starts
|
start_position TEXT, -- position (page:offset, CFI, or locator JSON) where highlight starts
|
||||||
end_position VARCHAR(100), -- position (page:offset or CFI) where highlight ends
|
end_position TEXT, -- position (page:offset, CFI, or locator JSON) where highlight ends
|
||||||
color VARCHAR(7) DEFAULT '#ffff00', -- hex color code for highlight
|
color VARCHAR(7) DEFAULT '#ffff00', -- hex color code for highlight
|
||||||
note_id UUID REFERENCES media_notes(id) ON DELETE SET NULL, -- optional associated note
|
note_id UUID REFERENCES media_notes(id) ON DELETE SET NULL, -- optional associated note
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
@@ -457,6 +546,7 @@ CREATE TABLE IF NOT EXISTS reading_history (
|
|||||||
-- Create indexes for better query performance
|
-- Create indexes for better query performance
|
||||||
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||||
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_timezone ON users(timezone);
|
||||||
CREATE INDEX IF NOT EXISTS idx_library_types_name ON library_types(name);
|
CREATE INDEX IF NOT EXISTS idx_library_types_name ON library_types(name);
|
||||||
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_token ON refresh_tokens(token);
|
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_token ON refresh_tokens(token);
|
||||||
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user_id ON refresh_tokens(user_id);
|
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user_id ON refresh_tokens(user_id);
|
||||||
@@ -526,6 +616,10 @@ CREATE INDEX IF NOT EXISTS idx_reading_progress_user_id ON reading_progress(user
|
|||||||
CREATE INDEX IF NOT EXISTS idx_media_ratings_media_item_id ON media_ratings(media_item_id);
|
CREATE INDEX IF NOT EXISTS idx_media_ratings_media_item_id ON media_ratings(media_item_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_media_ratings_user_id ON media_ratings(user_id);
|
CREATE INDEX IF NOT EXISTS idx_media_ratings_user_id ON media_ratings(user_id);
|
||||||
|
|
||||||
|
-- Reviews indexes
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_media_reviews_media_item_id ON media_reviews(media_item_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_media_reviews_user_id ON media_reviews(user_id);
|
||||||
|
|
||||||
-- Notes and highlights indexes
|
-- Notes and highlights indexes
|
||||||
CREATE INDEX IF NOT EXISTS idx_media_notes_media_item_id ON media_notes(media_item_id);
|
CREATE INDEX IF NOT EXISTS idx_media_notes_media_item_id ON media_notes(media_item_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_media_notes_user_id ON media_notes(user_id);
|
CREATE INDEX IF NOT EXISTS idx_media_notes_user_id ON media_notes(user_id);
|
||||||
@@ -933,6 +1027,7 @@ BEGIN
|
|||||||
percentage = (book_record->>'percentage')::FLOAT,
|
percentage = (book_record->>'percentage')::FLOAT,
|
||||||
character_offset = CASE WHEN book_record ? 'character' THEN (book_record->>'character')::BIGINT ELSE existing_progress.character_offset END,
|
character_offset = CASE WHEN book_record ? 'character' THEN (book_record->>'character')::BIGINT ELSE existing_progress.character_offset END,
|
||||||
epubcfi = CASE WHEN book_record ? 'epubcfi' THEN (book_record->>'epubcfi')::TEXT ELSE existing_progress.epubcfi END,
|
epubcfi = CASE WHEN book_record ? 'epubcfi' THEN (book_record->>'epubcfi')::TEXT ELSE existing_progress.epubcfi END,
|
||||||
|
context_text = CASE WHEN book_record ? 'context_text' THEN (book_record->>'context_text')::TEXT ELSE existing_progress.context_text END,
|
||||||
chapter = CASE WHEN book_record ? 'chapter' THEN (book_record->>'chapter')::INTEGER ELSE existing_progress.chapter END,
|
chapter = CASE WHEN book_record ? 'chapter' THEN (book_record->>'chapter')::INTEGER ELSE existing_progress.chapter END,
|
||||||
chapter_progress = (book_record->>'percentage')::FLOAT,
|
chapter_progress = (book_record->>'percentage')::FLOAT,
|
||||||
last_sync_device = 'koreader',
|
last_sync_device = 'koreader',
|
||||||
@@ -951,6 +1046,7 @@ BEGIN
|
|||||||
percentage,
|
percentage,
|
||||||
character_offset,
|
character_offset,
|
||||||
epubcfi,
|
epubcfi,
|
||||||
|
context_text,
|
||||||
chapter,
|
chapter,
|
||||||
chapter_progress,
|
chapter_progress,
|
||||||
last_sync_device,
|
last_sync_device,
|
||||||
@@ -967,6 +1063,7 @@ BEGIN
|
|||||||
(book_record->>'percentage')::FLOAT,
|
(book_record->>'percentage')::FLOAT,
|
||||||
CASE WHEN book_record ? 'character' THEN (book_record->>'character')::BIGINT ELSE NULL END,
|
CASE WHEN book_record ? 'character' THEN (book_record->>'character')::BIGINT ELSE NULL END,
|
||||||
CASE WHEN book_record ? 'epubcfi' THEN (book_record->>'epubcfi')::TEXT ELSE NULL END,
|
CASE WHEN book_record ? 'epubcfi' THEN (book_record->>'epubcfi')::TEXT ELSE NULL END,
|
||||||
|
CASE WHEN book_record ? 'context_text' THEN (book_record->>'context_text')::TEXT ELSE NULL END,
|
||||||
CASE WHEN book_record ? 'chapter' THEN (book_record->>'chapter')::INTEGER ELSE NULL END,
|
CASE WHEN book_record ? 'chapter' THEN (book_record->>'chapter')::INTEGER ELSE NULL END,
|
||||||
(book_record->>'percentage')::FLOAT,
|
(book_record->>'percentage')::FLOAT,
|
||||||
'koreader',
|
'koreader',
|
||||||
@@ -1056,9 +1153,15 @@ CREATE TABLE IF NOT EXISTS collections (
|
|||||||
priority INT DEFAULT 100,
|
priority INT DEFAULT 100,
|
||||||
is_system_collection BOOLEAN DEFAULT false,
|
is_system_collection BOOLEAN DEFAULT false,
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
deleted_at TIMESTAMP WITH TIME ZONE,
|
||||||
UNIQUE(user_id, name)
|
UNIQUE(user_id, name)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- System collections (the 5 dashboard sections) are permanent: "delete"
|
||||||
|
-- tombstones the row (deleted_at) and restore un-tombstones it in place —
|
||||||
|
-- the row id (and everything referencing it) never churn.
|
||||||
|
ALTER TABLE collections ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_collections_user_id ON collections(user_id);
|
CREATE INDEX IF NOT EXISTS idx_collections_user_id ON collections(user_id);
|
||||||
|
|
||||||
-- Index for dashboard queries
|
-- Index for dashboard queries
|
||||||
@@ -1081,6 +1184,20 @@ CREATE TABLE IF NOT EXISTS user_dashboard_preferences (
|
|||||||
-- Index for fast lookups
|
-- Index for fast lookups
|
||||||
CREATE INDEX IF NOT EXISTS idx_dashboard_prefs_user_library ON user_dashboard_preferences(user_id, library_id);
|
CREATE INDEX IF NOT EXISTS idx_dashboard_prefs_user_library ON user_dashboard_preferences(user_id, library_id);
|
||||||
|
|
||||||
|
-- Global (all-libraries) dashboard preferences: exactly one row per user.
|
||||||
|
-- Backs the dashboard's "All Libraries" view (library_id NULL convention
|
||||||
|
-- rejected: the upsert's ON CONFLICT cannot dedupe NULLs and intent would
|
||||||
|
-- be invisible — a dedicated table makes the scope structural). System
|
||||||
|
-- and user collections are user-scoped, so collection_order/
|
||||||
|
-- hidden_collections IDs are library-independent and valid in this scope.
|
||||||
|
CREATE TABLE IF NOT EXISTS user_dashboard_preferences_global (
|
||||||
|
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
hidden_collections TEXT[] DEFAULT '{}',
|
||||||
|
collection_order TEXT[] DEFAULT '{}',
|
||||||
|
items_per_section INT DEFAULT 20,
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
-- Create collection_items table (which books belong to each collection)
|
-- Create collection_items table (which books belong to each collection)
|
||||||
CREATE TABLE IF NOT EXISTS collection_items (
|
CREATE TABLE IF NOT EXISTS collection_items (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
@@ -1141,12 +1258,14 @@ CREATE TABLE IF NOT EXISTS system_config (
|
|||||||
updated_by UUID REFERENCES users(id)
|
updated_by UUID REFERENCES users(id)
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Pre-seeded values
|
-- One-time cleanup: clear the old placeholder seed so the startup logic
|
||||||
INSERT INTO system_config (key, value) VALUES
|
-- can re-seed from the BASE_URL env var (or the setup wizard can set it).
|
||||||
('base_url', 'https://bookhoard.example.com'),
|
UPDATE system_config SET value = ''
|
||||||
('opds_base_url', 'https://bookhoard.example.com/opds'),
|
WHERE key = 'base_url' AND value = 'https://bookhoard.example.com';
|
||||||
('api_base_url', 'https://bookhoard.example.com/api')
|
UPDATE system_config SET value = ''
|
||||||
ON CONFLICT (key) DO NOTHING;
|
WHERE key = 'opds_base_url' AND value = 'https://bookhoard.example.com/opds';
|
||||||
|
UPDATE system_config SET value = ''
|
||||||
|
WHERE key = 'api_base_url' AND value = 'https://bookhoard.example.com/api';
|
||||||
|
|
||||||
-- Create opds_tokens table (device-specific OPDS access tokens)
|
-- Create opds_tokens table (device-specific OPDS access tokens)
|
||||||
CREATE TABLE IF NOT EXISTS opds_tokens (
|
CREATE TABLE IF NOT EXISTS opds_tokens (
|
||||||
@@ -1298,9 +1417,238 @@ CREATE TABLE IF NOT EXISTS media_bookmarks (
|
|||||||
title VARCHAR(255) NOT NULL,
|
title VARCHAR(255) NOT NULL,
|
||||||
position VARCHAR(100), -- 'pdf:page:45', 'comic:page:12', 'chapter:3' for consistency
|
position VARCHAR(100), -- 'pdf:page:45', 'comic:page:12', 'chapter:3' for consistency
|
||||||
notes TEXT,
|
notes TEXT,
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
UNIQUE(media_item_id, user_id, title)
|
-- No UNIQUE(media_item_id, user_id, title): bookmarks are identified by
|
||||||
|
-- their location (dedup_key), titles are display labels shared verbatim
|
||||||
|
-- across clients (KOReader auto-labels repeat within a chapter).
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_media_bookmarks_media ON media_bookmarks(media_item_id);
|
CREATE INDEX IF NOT EXISTS idx_media_bookmarks_media ON media_bookmarks(media_item_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_media_bookmarks_user ON media_bookmarks(user_id);
|
CREATE INDEX IF NOT EXISTS idx_media_bookmarks_user ON media_bookmarks(user_id);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- ANNOTATION SYNC MIGRATIONS
|
||||||
|
-- Adds dedup_key, LWW timestamps, soft-delete,
|
||||||
|
-- and device_sync_data to annotation tables.
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
ALTER TABLE media_highlights ADD COLUMN IF NOT EXISTS dedup_key VARCHAR(40);
|
||||||
|
ALTER TABLE media_highlights ADD COLUMN IF NOT EXISTS last_modified_at TIMESTAMPTZ;
|
||||||
|
ALTER TABLE media_highlights ADD COLUMN IF NOT EXISTS last_modified_source VARCHAR(30);
|
||||||
|
ALTER TABLE media_highlights ADD COLUMN IF NOT EXISTS note_text TEXT;
|
||||||
|
ALTER TABLE media_highlights ADD COLUMN IF NOT EXISTS deleted BOOLEAN DEFAULT FALSE;
|
||||||
|
ALTER TABLE media_highlights ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
||||||
|
|
||||||
|
-- Widen position columns for existing databases: the API handlers
|
||||||
|
-- validate up to 1000 characters (full Readium locators, KOReader CRE
|
||||||
|
-- xpointers) but VARCHAR(100) rejected anything longer at the database
|
||||||
|
-- layer. VARCHAR -> TEXT is a metadata-only change, safe to re-run.
|
||||||
|
ALTER TABLE media_highlights ALTER COLUMN start_position TYPE TEXT;
|
||||||
|
ALTER TABLE media_highlights ALTER COLUMN end_position TYPE TEXT;
|
||||||
|
|
||||||
|
ALTER TABLE media_notes ADD COLUMN IF NOT EXISTS dedup_key VARCHAR(40);
|
||||||
|
ALTER TABLE media_notes ADD COLUMN IF NOT EXISTS last_modified_at TIMESTAMPTZ;
|
||||||
|
ALTER TABLE media_notes ADD COLUMN IF NOT EXISTS last_modified_source VARCHAR(30);
|
||||||
|
ALTER TABLE media_notes ADD COLUMN IF NOT EXISTS deleted BOOLEAN DEFAULT FALSE;
|
||||||
|
ALTER TABLE media_notes ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
||||||
|
|
||||||
|
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS dedup_key VARCHAR(40);
|
||||||
|
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS last_modified_at TIMESTAMPTZ;
|
||||||
|
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS last_modified_source VARCHAR(30);
|
||||||
|
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS device_sync_data JSONB;
|
||||||
|
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS percentage_location FLOAT;
|
||||||
|
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS epubcfi_location TEXT;
|
||||||
|
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS chapter_reference INTEGER;
|
||||||
|
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS deleted BOOLEAN DEFAULT FALSE;
|
||||||
|
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
||||||
|
-- Provenance: the client that CREATED the bookmark (unlike
|
||||||
|
-- last_modified_source, which tracks the last writer). Set once at insert.
|
||||||
|
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS origin_source VARCHAR(30);
|
||||||
|
-- Identity is the dedup_key (location), not the title; drop the legacy
|
||||||
|
-- unique-title constraint so same-title bookmarks on different pages can
|
||||||
|
-- coexist (re-creating over a tombstone with a changed position also
|
||||||
|
-- relied on this). Catalog-only change, safe to re-run.
|
||||||
|
ALTER TABLE media_bookmarks DROP CONSTRAINT IF EXISTS media_bookmarks_media_item_id_user_id_title_key;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_media_highlights_dedup
|
||||||
|
ON media_highlights (user_id, media_item_id, dedup_key)
|
||||||
|
WHERE dedup_key IS NOT NULL AND deleted = FALSE;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_media_notes_dedup
|
||||||
|
ON media_notes (user_id, media_item_id, dedup_key)
|
||||||
|
WHERE dedup_key IS NOT NULL AND deleted = FALSE;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_media_bookmarks_dedup
|
||||||
|
ON media_bookmarks (user_id, media_item_id, dedup_key)
|
||||||
|
WHERE dedup_key IS NOT NULL AND deleted = FALSE;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_media_highlights_deleted_at ON media_highlights(deleted_at) WHERE deleted = TRUE;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_media_notes_deleted_at ON media_notes(deleted_at) WHERE deleted = TRUE;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_media_bookmarks_deleted_at ON media_bookmarks(deleted_at) WHERE deleted = TRUE;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
--: MEDIA ITEM DEDUPLICATION + PATH UNIQUENESS
|
||||||
|
-- ============================================
|
||||||
|
-- A read-then-write race in the scanner historically allowed the same
|
||||||
|
-- (library_id, file_path) to be inserted twice. This block is self-healing:
|
||||||
|
-- it collapses any existing path-duplicates (re-parenting child rows onto a
|
||||||
|
-- survivor so no reading history is lost), then enforces uniqueness going
|
||||||
|
-- forward. Idempotent — safe to re-run on every startup.
|
||||||
|
|
||||||
|
-- Move every child row that points at p_source so it points at p_target,
|
||||||
|
-- deleting source rows that would violate a UNIQUE constraint on the target.
|
||||||
|
CREATE OR REPLACE FUNCTION reparent_media_item_children(p_target UUID, p_source UUID)
|
||||||
|
RETURNS void
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
IF p_target IS NULL OR p_source IS NULL OR p_target = p_source THEN
|
||||||
|
RETURN;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
DELETE FROM reading_progress
|
||||||
|
WHERE media_item_id = p_source
|
||||||
|
AND user_id IN (SELECT user_id FROM reading_progress WHERE media_item_id = p_target);
|
||||||
|
UPDATE reading_progress SET media_item_id = p_target WHERE media_item_id = p_source;
|
||||||
|
|
||||||
|
DELETE FROM reading_speed
|
||||||
|
WHERE media_item_id = p_source
|
||||||
|
AND user_id IN (SELECT user_id FROM reading_speed WHERE media_item_id = p_target);
|
||||||
|
UPDATE reading_speed SET media_item_id = p_target WHERE media_item_id = p_source;
|
||||||
|
|
||||||
|
DELETE FROM media_ratings
|
||||||
|
WHERE media_item_id = p_source
|
||||||
|
AND user_id IN (SELECT user_id FROM media_ratings WHERE media_item_id = p_target);
|
||||||
|
UPDATE media_ratings SET media_item_id = p_target WHERE media_item_id = p_source;
|
||||||
|
|
||||||
|
DELETE FROM media_bookmarks
|
||||||
|
WHERE media_item_id = p_source
|
||||||
|
AND (user_id, title) IN (SELECT user_id, title FROM media_bookmarks WHERE media_item_id = p_target);
|
||||||
|
UPDATE media_bookmarks SET media_item_id = p_target WHERE media_item_id = p_source;
|
||||||
|
|
||||||
|
DELETE FROM media_item_formats
|
||||||
|
WHERE media_item_id = p_source
|
||||||
|
AND format_type IN (SELECT format_type FROM media_item_formats WHERE media_item_id = p_target);
|
||||||
|
UPDATE media_item_formats SET media_item_id = p_target WHERE media_item_id = p_source;
|
||||||
|
|
||||||
|
DELETE FROM collection_items
|
||||||
|
WHERE media_item_id = p_source
|
||||||
|
AND collection_id IN (SELECT collection_id FROM collection_items WHERE media_item_id = p_target);
|
||||||
|
UPDATE collection_items SET media_item_id = p_target WHERE media_item_id = p_source;
|
||||||
|
|
||||||
|
DELETE FROM kobo_shelves
|
||||||
|
WHERE media_item_id = p_source
|
||||||
|
AND device_id IN (SELECT device_id FROM kobo_shelves WHERE media_item_id = p_target);
|
||||||
|
UPDATE kobo_shelves SET media_item_id = p_target WHERE media_item_id = p_source;
|
||||||
|
|
||||||
|
DELETE FROM panel_data
|
||||||
|
WHERE media_item_id = p_source
|
||||||
|
AND page_number IN (SELECT page_number FROM panel_data WHERE media_item_id = p_target);
|
||||||
|
UPDATE panel_data SET media_item_id = p_target WHERE media_item_id = p_source;
|
||||||
|
|
||||||
|
DELETE FROM processing_issues
|
||||||
|
WHERE media_item_id = p_source
|
||||||
|
AND issue_type IN (SELECT issue_type FROM processing_issues WHERE media_item_id = p_target);
|
||||||
|
UPDATE processing_issues SET media_item_id = p_target WHERE media_item_id = p_source;
|
||||||
|
|
||||||
|
DELETE FROM device_file_aliases
|
||||||
|
WHERE media_item_id = p_source
|
||||||
|
AND (device_id, file_path) IN (SELECT device_id, file_path FROM device_file_aliases WHERE media_item_id = p_target);
|
||||||
|
UPDATE device_file_aliases SET media_item_id = p_target WHERE media_item_id = p_source;
|
||||||
|
|
||||||
|
-- Tables whose UNIQUE keys do not include media_item_id.
|
||||||
|
UPDATE device_catalogs SET media_item_id = p_target WHERE media_item_id = p_source;
|
||||||
|
UPDATE kobo_entitlements SET media_item_id = p_target WHERE media_item_id = p_source;
|
||||||
|
UPDATE media_highlights SET media_item_id = p_target WHERE media_item_id = p_source;
|
||||||
|
UPDATE media_notes SET media_item_id = p_target WHERE media_item_id = p_source;
|
||||||
|
UPDATE reading_history SET media_item_id = p_target WHERE media_item_id = p_source;
|
||||||
|
UPDATE sync_conflicts SET media_item_id = p_target WHERE media_item_id = p_source;
|
||||||
|
UPDATE sync_queue SET media_item_id = p_target WHERE media_item_id = p_source;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Collapse every (library_id, file_path) group into a single row.
|
||||||
|
-- Survivor = the row with the most user data; ties broken by lowest id.
|
||||||
|
CREATE OR REPLACE FUNCTION dedup_media_items_by_path() RETURNS void
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
g RECORD;
|
||||||
|
v_surv UUID;
|
||||||
|
v_loser UUID;
|
||||||
|
BEGIN
|
||||||
|
FOR g IN
|
||||||
|
SELECT library_id, file_path
|
||||||
|
FROM media_items
|
||||||
|
GROUP BY library_id, file_path
|
||||||
|
HAVING COUNT(*) > 1
|
||||||
|
LOOP
|
||||||
|
SELECT mi.id INTO v_surv
|
||||||
|
FROM media_items mi
|
||||||
|
WHERE mi.library_id = g.library_id AND mi.file_path = g.file_path
|
||||||
|
ORDER BY
|
||||||
|
((SELECT COUNT(*) FROM reading_progress rp WHERE rp.media_item_id = mi.id)
|
||||||
|
+ (SELECT COUNT(*) FROM media_highlights mh WHERE mh.media_item_id = mi.id)
|
||||||
|
+ (SELECT COUNT(*) FROM media_bookmarks mb WHERE mb.media_item_id = mi.id)
|
||||||
|
+ (SELECT COUNT(*) FROM media_notes mn WHERE mn.media_item_id = mi.id)
|
||||||
|
+ (SELECT COUNT(*) FROM reading_history rh WHERE rh.media_item_id = mi.id)
|
||||||
|
+ (SELECT COUNT(*) FROM collection_items ci WHERE ci.media_item_id = mi.id)) DESC,
|
||||||
|
mi.id ASC
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
FOR v_loser IN
|
||||||
|
SELECT id FROM media_items
|
||||||
|
WHERE library_id = g.library_id AND file_path = g.file_path AND id <> v_surv
|
||||||
|
ORDER BY id
|
||||||
|
LOOP
|
||||||
|
PERFORM reparent_media_item_children(v_surv, v_loser);
|
||||||
|
DELETE FROM media_items WHERE id = v_loser;
|
||||||
|
END LOOP;
|
||||||
|
END LOOP;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Collapse any existing path-duplicates so the constraint below can be created.
|
||||||
|
SELECT dedup_media_items_by_path();
|
||||||
|
|
||||||
|
-- Enforce path uniqueness going forward (guarded so re-runs don't error).
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint
|
||||||
|
WHERE conname = 'media_items_library_id_file_path_key'
|
||||||
|
AND conrelid = 'media_items'::regclass
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE media_items
|
||||||
|
ADD CONSTRAINT media_items_library_id_file_path_key UNIQUE (library_id, file_path);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
--: HASH CONFLICTS
|
||||||
|
-- ============================================
|
||||||
|
-- Records content-duplicate groups discovered during hash backfill or rescan:
|
||||||
|
-- two or more media_items in the same library share a file_sha256 but live at
|
||||||
|
-- different file paths (e.g. the same book imported twice under two names on
|
||||||
|
-- a preexisting database). Unlike path duplicates these cannot be auto-collapsed
|
||||||
|
-- (keeping both copies may be intentional), so each group is surfaced on the
|
||||||
|
-- admin Hash Conflicts page for the user to resolve:
|
||||||
|
-- keep_all - both copies are intentional; just stop flagging
|
||||||
|
-- kept:<uuid> - merge every other copy's child rows into the kept item
|
||||||
|
-- (via reparent_media_item_children) and delete the losers
|
||||||
|
CREATE TABLE IF NOT EXISTS hash_conflicts (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
library_id UUID NOT NULL REFERENCES libraries(id) ON DELETE CASCADE,
|
||||||
|
file_sha256 CHAR(64) NOT NULL,
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','resolved')),
|
||||||
|
resolution VARCHAR(50), -- 'keep_all' or 'kept:<media_item_uuid>' (41 chars)
|
||||||
|
resolved_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
resolved_at TIMESTAMPTZ,
|
||||||
|
UNIQUE(library_id, file_sha256)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_hash_conflicts_status ON hash_conflicts(status);
|
||||||
|
|
||||||
|
-- Widen for databases created before the resolution format settled (no-op otherwise)
|
||||||
|
ALTER TABLE hash_conflicts ALTER COLUMN resolution TYPE VARCHAR(50);
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# Development override — merged on top of docker-compose.yml (the base/prod file).
|
||||||
|
# Activated by all `make` targets via:
|
||||||
|
# COMPOSE = <runtime> compose -f docker-compose.yml -f docker-compose.dev.yml
|
||||||
|
#
|
||||||
|
# What this adds over prod:
|
||||||
|
# - Local image BUILDING (prod pulls a prebuilt image from the registry)
|
||||||
|
# - The integration-tests service (dev only, gated behind the "tests" profile)
|
||||||
|
# Everything else (env vars, volumes, ports, healthchecks) is inherited from the base file.
|
||||||
|
services:
|
||||||
|
# Build the app image locally instead of pulling from the registry
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: ./Dockerfile
|
||||||
|
|
||||||
|
# Integration Tests - runs against containerized app and db (dev only)
|
||||||
|
tests:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: ./Dockerfile
|
||||||
|
target: test-runner
|
||||||
|
container_name: bookhoard_tests
|
||||||
|
environment:
|
||||||
|
# Database Configuration
|
||||||
|
DATABASE_HOST: db
|
||||||
|
DATABASE_PORT: ${DB_PORT:-5432}
|
||||||
|
DATABASE_USER: postgres
|
||||||
|
DATABASE_PASSWORD: ${DBPASS}
|
||||||
|
DATABASE_NAME: bookhoard
|
||||||
|
COOKIE_SECURE: false
|
||||||
|
|
||||||
|
# Application Configuration
|
||||||
|
JWT_SECRET: ${JWT_SECRET}
|
||||||
|
SERVER_PORT: ${SERVER_PORT:-8765}
|
||||||
|
|
||||||
|
# Test Configuration
|
||||||
|
TEST_MODE: "true"
|
||||||
|
RATE_LIMIT_ENABLED: "false"
|
||||||
|
REQUESTS_PER_MINUTE: 1000
|
||||||
|
|
||||||
|
# Conversion Service Configuration
|
||||||
|
BOOKHOARD_CONVERSION_CACHE_DIR: /app/cache/kepub
|
||||||
|
BOOKHOARD_CONVERSION_TOOL: /usr/bin/kepubify
|
||||||
|
BOOKHOARD_CONVERSION_CACHE_TTL: 24h
|
||||||
|
|
||||||
|
# Test upload path (inside container)
|
||||||
|
TEST_UPLOAD_PATH: /app/uploads
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
app:
|
||||||
|
condition: service_healthy
|
||||||
|
volumes:
|
||||||
|
- ./uploads:/app/uploads
|
||||||
|
- bookhoard_conversion_cache:/app/cache/kepub
|
||||||
|
profiles:
|
||||||
|
- tests
|
||||||
+27
-57
@@ -1,5 +1,3 @@
|
|||||||
version: "3.8"
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
# PostgreSQL Database
|
# PostgreSQL Database
|
||||||
db:
|
db:
|
||||||
@@ -9,44 +7,50 @@ services:
|
|||||||
POSTGRES_DB: bookhoard
|
POSTGRES_DB: bookhoard
|
||||||
POSTGRES_USER: postgres
|
POSTGRES_USER: postgres
|
||||||
POSTGRES_PASSWORD: ${DBPASS}
|
POSTGRES_PASSWORD: ${DBPASS}
|
||||||
COOKIE_SECURE: false # make true in production with HTTPS
|
# PGPORT makes Postgres listen on DB_PORT (kept in sync with the host mapping + app's DATABASE_PORT)
|
||||||
|
PGPORT: ${DB_PORT:-5432}
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql/data
|
||||||
- ./database/schema:/docker-entrypoint-initdb.d
|
- ./database/schema:/docker-entrypoint-initdb.d
|
||||||
# Make other volumes as needed
|
# Make other volumes as needed
|
||||||
- ./uploads:/app/uploads
|
- ./uploads:/app/uploads
|
||||||
ports:
|
ports:
|
||||||
- "5432:5432"
|
- "127.0.0.1:${DB_PORT:-5432}:${DB_PORT:-5432}"
|
||||||
|
- "[::1]:${DB_PORT:-5432}:${DB_PORT:-5432}"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||||
interval: 5s
|
interval: 30s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
|
|
||||||
# Bookhoard Application
|
# Bookhoard Application
|
||||||
|
# In production this image is pulled from the Gitea container registry.
|
||||||
|
# Override IMAGE_TAG in .env to pin or rollback a specific version (defaults to "latest").
|
||||||
app:
|
app:
|
||||||
build:
|
image: git.linuxhg.com/bookhoard/bookhoard:${IMAGE_TAG:-latest}
|
||||||
context: .
|
|
||||||
dockerfile: ./Dockerfile
|
|
||||||
container_name: bookhoard
|
container_name: bookhoard
|
||||||
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
# Database Configuration
|
# Database Configuration
|
||||||
DATABASE_HOST: db
|
DATABASE_HOST: db
|
||||||
DATABASE_PORT: 5432
|
DATABASE_PORT: ${DB_PORT:-5432}
|
||||||
DATABASE_USER: postgres
|
DATABASE_USER: postgres
|
||||||
DATABASE_PASSWORD: ${DBPASS}
|
DATABASE_PASSWORD: ${DBPASS}
|
||||||
DATABASE_NAME: bookhoard
|
DATABASE_NAME: bookhoard
|
||||||
|
|
||||||
# Application Configuration
|
# Application Configuration
|
||||||
JWT_SECRET: ${JWT_SECRET}
|
JWT_SECRET: ${JWT_SECRET}
|
||||||
SERVER_PORT: 8765
|
SERVER_PORT: ${SERVER_PORT:-8765}
|
||||||
# IMPORTANT: Device sync requires full URL with protocol
|
# IMPORTANT: Device sync requires full URL with protocol
|
||||||
# Local: http://localhost:8765
|
# Local: http://localhost:8765
|
||||||
# Local network: http://192.168.1.X:8765
|
# Local network: http://192.168.1.X:8765
|
||||||
# Domain: https://bookhoard.example.com
|
# Domain: https://bookhoard.example.com
|
||||||
BASE_URL: http://localhost:${SERVER_PORT}
|
BASE_URL: ${BASE_URL:-http://localhost:8765}
|
||||||
|
# Mark session cookies Secure; set true behind a TLS-terminating reverse proxy (Caddy/nginx/traefik)
|
||||||
|
COOKIE_SECURE: ${COOKIE_SECURE:-false}
|
||||||
|
|
||||||
# Rate Limiting Configuration
|
# Rate Limiting Configuration
|
||||||
TEST_MODE: ${TEST_MODE:-false}
|
TEST_MODE: ${TEST_MODE:-false}
|
||||||
@@ -57,8 +61,17 @@ services:
|
|||||||
BOOKHOARD_CONVERSION_CACHE_DIR: ${BOOKHOARD_CONVERSION_CACHE_DIR:-/app/cache/kepub}
|
BOOKHOARD_CONVERSION_CACHE_DIR: ${BOOKHOARD_CONVERSION_CACHE_DIR:-/app/cache/kepub}
|
||||||
BOOKHOARD_CONVERSION_TOOL: ${BOOKHOARD_CONVERSION_TOOL:-/usr/bin/kepubify}
|
BOOKHOARD_CONVERSION_TOOL: ${BOOKHOARD_CONVERSION_TOOL:-/usr/bin/kepubify}
|
||||||
BOOKHOARD_CONVERSION_CACHE_TTL: ${BOOKHOARD_CONVERSION_CACHE_TTL:-24h}
|
BOOKHOARD_CONVERSION_CACHE_TTL: ${BOOKHOARD_CONVERSION_CACHE_TTL:-24h}
|
||||||
|
|
||||||
|
# Library Maintenance
|
||||||
|
# Days an archived item (file missing from disk for 2+ scans) is kept,
|
||||||
|
# with its reading history, before library scans purge it for good.
|
||||||
|
# Set 0 to keep archived items until purged manually on the library admin page.
|
||||||
|
ARCHIVE_RETENTION_DAYS: ${ARCHIVE_RETENTION_DAYS:-90}
|
||||||
|
|
||||||
|
# System timezone (fallback for server-side time operations)
|
||||||
|
TZ: ${TZ:-UTC}
|
||||||
ports:
|
ports:
|
||||||
- "8765:8765"
|
- "${SERVER_PORT:-8765}:${SERVER_PORT:-8765}"
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -66,55 +79,12 @@ services:
|
|||||||
- ./uploads:/app/uploads
|
- ./uploads:/app/uploads
|
||||||
- bookhoard_conversion_cache:/app/cache/kepub
|
- bookhoard_conversion_cache:/app/cache/kepub
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "curl -f http://localhost:8765/health || exit 1"]
|
test: ["CMD-SHELL", "curl -f http://localhost:${SERVER_PORT:-8765}/health || exit 1"]
|
||||||
interval: 10s
|
interval: 30s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 10s
|
start_period: 10s
|
||||||
|
|
||||||
# Integration Tests - runs against containerized app and db
|
|
||||||
tests:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: ./Dockerfile
|
|
||||||
target: test-runner
|
|
||||||
container_name: bookhoard_tests
|
|
||||||
environment:
|
|
||||||
# Database Configuration
|
|
||||||
DATABASE_HOST: db
|
|
||||||
DATABASE_PORT: 5432
|
|
||||||
DATABASE_USER: postgres
|
|
||||||
DATABASE_PASSWORD: ${DBPASS}
|
|
||||||
DATABASE_NAME: bookhoard
|
|
||||||
COOKIE_SECURE: false
|
|
||||||
|
|
||||||
# Application Configuration
|
|
||||||
JWT_SECRET: ${JWT_SECRET}
|
|
||||||
SERVER_PORT: 8765
|
|
||||||
|
|
||||||
# Test Configuration
|
|
||||||
TEST_MODE: "true"
|
|
||||||
RATE_LIMIT_ENABLED: "false"
|
|
||||||
REQUESTS_PER_MINUTE: 1000
|
|
||||||
|
|
||||||
# Conversion Service Configuration
|
|
||||||
BOOKHOARD_CONVERSION_CACHE_DIR: /app/cache/kepub
|
|
||||||
BOOKHOARD_CONVERSION_TOOL: /usr/bin/kepubify
|
|
||||||
BOOKHOARD_CONVERSION_CACHE_TTL: 24h
|
|
||||||
|
|
||||||
# Test upload path (inside container)
|
|
||||||
TEST_UPLOAD_PATH: /app/uploads
|
|
||||||
depends_on:
|
|
||||||
db:
|
|
||||||
condition: service_healthy
|
|
||||||
app:
|
|
||||||
condition: service_healthy
|
|
||||||
volumes:
|
|
||||||
- ./uploads:/app/uploads
|
|
||||||
- bookhoard_conversion_cache:/app/cache/kepub
|
|
||||||
profiles:
|
|
||||||
- tests
|
|
||||||
|
|
||||||
# Named Volumes
|
# Named Volumes
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ Welcome to the Bookhoard contributing documentation. This section contains guide
|
|||||||
|
|
||||||
## 📋 Project Guidelines
|
## 📋 Project Guidelines
|
||||||
|
|
||||||
- **[PROJECT_GUIDELINES.md](../PROJECT_GUIDELINES.md)** - Development rules and standards
|
- **[AGENTS.md](../../AGENTS.md)** - Agent/development rules
|
||||||
- Critical prohibitions
|
- Critical prohibitions
|
||||||
- Mandatory requirements
|
- Mandatory requirements
|
||||||
- Error recovery protocol
|
- Error recovery protocol
|
||||||
|
|||||||
@@ -445,7 +445,7 @@ DEBUG=true
|
|||||||
|
|
||||||
## 📚 Additional Resources
|
## 📚 Additional Resources
|
||||||
|
|
||||||
- [Project Guidelines](../../PROJECT_GUIDELINES.md) - Development rules and standards
|
- [AGENTS.md](../../AGENTS.md) - Agent/development rules
|
||||||
- [API Reference](../API_REFERENCE.md) - Complete API documentation
|
- [API Reference](../API_REFERENCE.md) - Complete API documentation
|
||||||
- [Troubleshooting](../TROUBLESHOOTING.md) - Deployment issues
|
- [Troubleshooting](../TROUBLESHOOTING.md) - Deployment issues
|
||||||
- [Go Documentation](https://go.dev/doc/)
|
- [Go Documentation](https://go.dev/doc/)
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
# Android App — Design & Roadmap
|
||||||
|
|
||||||
|
This document describes the planned native Android client for Bookhoard: a thin, offline-first reading app that treats the Bookhoard server as its backend. The relationship is the same as the audiobookshelf app to an audiobookshelf server, or the Kindle app to Kindle cloud — the server owns the library, sync, and conflict resolution; the app is a dedicated, mobile-first reading frontend with its own UI, designed independently of the web interface.
|
||||||
|
|
||||||
|
**Status**: Planning / pre-development
|
||||||
|
**Companion repo**: `bookhoard-app` (separate repository, AGPL-3.0)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Product Vision
|
||||||
|
|
||||||
|
- An **amazing ereader** first and foremost — rendering polish, latency, and reading UX are the product
|
||||||
|
- **Mobile-first UI** designed from scratch for phones; not a wrapper around the web app
|
||||||
|
- **Thin client**: the server remains authoritative for all sync, book matching, and conflict resolution
|
||||||
|
- **v1 formats**: EPUB (ebooks) and CBZ (comics/manga); PDF comes nearly free via the reader toolkit
|
||||||
|
- **Android first**. iOS is a real roadmap item but unscheduled — likely contributor-driven
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠 Tech Stack
|
||||||
|
|
||||||
|
| Concern | Choice |
|
||||||
|
| -------------- | ------------------------------------------------- |
|
||||||
|
| Language | Kotlin |
|
||||||
|
| UI | Jetpack Compose + Material 3 |
|
||||||
|
| Reader engine | [Readium Kotlin toolkit](https://github.com/readium/kotlin-toolkit) |
|
||||||
|
| Local database | Room |
|
||||||
|
| Networking | OkHttp / Retrofit + WebSocket |
|
||||||
|
| Background | WorkManager |
|
||||||
|
| Images | Coil |
|
||||||
|
| Settings | DataStore |
|
||||||
|
|
||||||
|
### Why native Android
|
||||||
|
|
||||||
|
- The quality bar is the Kindle app. Page-turn latency, text layout fidelity, PDF rendering (`PdfRenderer`), and comic/manga image pipelines are platform-level strengths — and they are the *hard* parts in a WebView, not the easy parts.
|
||||||
|
- Android-first removes the "share one codebase across two platforms simultaneously" constraint that motivates hybrid stacks.
|
||||||
|
- Solo, AI-assisted development compresses the cost of native (code volume), while native's failure modes (well-documented platform APIs) are far easier to debug — alone or with AI — than cross-framework bridge/plugin bugs.
|
||||||
|
- The target audience is the self-hosted community, best reached via GitHub Releases and F-Droid rather than app-store optimization.
|
||||||
|
|
||||||
|
### Alternatives considered
|
||||||
|
|
||||||
|
- **Capacitor / WebView shell** (the audiobookshelf-app model): excellent when a self-contained SPA already exists; a poor fit here. Bookhoard's web UI is server-rendered HTMX and cannot be packaged, comics rendering in a WebView caps the polish target, and deep offline support fights the shell.
|
||||||
|
- **Flutter**: strong middle ground, but no Readium port and a weaker EPUB/PDF plugin ecosystem than the native toolkits.
|
||||||
|
- **Kotlin Multiplatform**: only pays off with a committed near-term iOS effort. Revisit if iOS becomes active; until then it would constrain v1 for a hypothetical.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏗 Architecture
|
||||||
|
|
||||||
|
Thin, offline-first client. The server API is the contract (see [API Reference](api/api-reference.md) and [WebSocket API](websocket-api.md)).
|
||||||
|
|
||||||
|
### Module layout
|
||||||
|
|
||||||
|
```
|
||||||
|
:app Compose UI, navigation, dependency injection
|
||||||
|
:core:domain Pure Kotlin — models, sync logic, use cases (no Android deps)
|
||||||
|
:core:data Room, Retrofit/OkHttp, downloads and file storage
|
||||||
|
:feature:reader Readium navigator integration and reading UI
|
||||||
|
```
|
||||||
|
|
||||||
|
Keeping `:core:domain` free of Android dependencies preserves optionality: a future iOS client, a KMP extraction, or a desktop client can reuse or port the domain logic without touching the UI.
|
||||||
|
|
||||||
|
### Offline-first sync flow
|
||||||
|
|
||||||
|
1. UI writes go to the local Room mirror **first** (never blocked on network)
|
||||||
|
2. A WorkManager queue replays changes to the existing REST endpoints (`/api/progress`, `/api/media-items/:id/notes`, `/highlights`, etc.)
|
||||||
|
3. Conflicts are resolved by the server's existing mechanisms — the client never invents its own merge logic
|
||||||
|
4. While online, a WebSocket connection receives realtime updates pushed by other devices (web reader, KOReader)
|
||||||
|
5. Books are downloaded to app storage for fully offline reading, with storage management UI
|
||||||
|
|
||||||
|
### Authentication & device identity
|
||||||
|
|
||||||
|
- **Primary auth: username/password login** via the existing endpoints (`POST /api/auth/login` + refresh). The app is a full user client — browse, collections, ratings, and annotation management all live behind the user JWT, which device tokens cannot reach
|
||||||
|
- After login, the app registers itself as a **device** (`device_type: mobile`) and **self-approves** its registration using its own JWT — approval only requires a logged-in user. The phone then appears on the Devices page with sync attribution, per-device settings, and individually revocable access, with no QR ceremony
|
||||||
|
- Netflix-style QR pairing as a zero-typing sign-in option: post-v1 (see below)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📖 Reader Engine
|
||||||
|
|
||||||
|
[Readium](https://github.com/readium/kotlin-toolkit) provides EPUB, PDF, and CBZ through one publication model and navigator — production-hardened by real reading apps. This avoids building and maintaining three renderers.
|
||||||
|
|
||||||
|
Planned reading features:
|
||||||
|
|
||||||
|
- Custom fonts (including user-loaded), adjustable margins and line height
|
||||||
|
- Themes including OLED true-black for battery
|
||||||
|
- Paginated and scroll modes; gesture and volume-key page turns
|
||||||
|
- Keep-screen-awake while reading
|
||||||
|
- Highlights, notes, and bookmarks synced via existing APIs (including deleted-annotation restore)
|
||||||
|
- Resume to exact position using EPUB CFI, consistent with universal sync
|
||||||
|
|
||||||
|
### Comics & manga UX
|
||||||
|
|
||||||
|
- RTL reading direction and double-page spreads with correct cover/single-page handling
|
||||||
|
- Per-book reading-mode overrides (a manga library can default to RTL)
|
||||||
|
- Zoom and pan; aggressive preloading of adjacent pages
|
||||||
|
- Webtoon / continuous vertical mode: post-v1
|
||||||
|
|
||||||
|
### Reader settings parity with the web reader
|
||||||
|
|
||||||
|
The web reader (`web/src/reader/`) is the reference implementation for reading ergonomics — its font selection, reading themes, and highlight system are considered well-designed; only its desktop-oriented presentation is being replaced on mobile. The Android reader should reuse the same settings model (stored in the `reader_settings` table and synced via the settings endpoint) rather than inventing a parallel one:
|
||||||
|
|
||||||
|
- **Fonts**: the same roster of variable fonts, self-hosted under `/static/fonts/` — Literata (default), Crimson Pro, Source Serif 4, EB Garamond, Libertinus Serif, Noto Serif, Charis SIL, IBM Plex Serif (`FONT_MAP` in `web/src/reader/reader.ts`)
|
||||||
|
- **Typography**: `font_size` (default 18), `line_height` (1.6), `margin_width`, `double_page_spread`
|
||||||
|
- **Themes**: `chrome_theme` (default `tokyo-night`) for app chrome vs `reading_theme`/`reading_mode` for the page surface, plus the fx stack (`fx_brightness`, `fx_contrast`, `fx_invert`)
|
||||||
|
- **Navigation**: `tap_zones_enabled` + `tap_zone_size`, `reading_direction`, `progress_mode`
|
||||||
|
- **Highlights**: per-annotation color (default `#ffd54f`), matching the web palette
|
||||||
|
|
||||||
|
Settings chosen on one device should follow the user everywhere — mobile changes write back through the same sync.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🍎 iOS Posture
|
||||||
|
|
||||||
|
iOS is a real roadmap item but not near-term. The strategy is **not** to pre-pay for it with KMP or a cross-platform framework. Instead:
|
||||||
|
|
||||||
|
- The documented REST/WebSocket API is the sharing mechanism — a future iOS client is a *new client over the same contract*, never a rewrite of shared logic
|
||||||
|
- A contributor-driven Swift/SwiftUI client is welcome; the server needs no changes to support it
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 Distribution & Licensing
|
||||||
|
|
||||||
|
- **License**: AGPL-3.0, matching the Bookhoard server
|
||||||
|
- **Channels**: GitHub Releases and F-Droid; Play Store optional later
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚧 Milestones
|
||||||
|
|
||||||
|
1. **Scaffold** — app shell, auth + QR device pairing, library browsing, book downloads
|
||||||
|
2. **EPUB reading** — Readium integration, CFI progress sync, offline-first reading
|
||||||
|
3. **Annotations** — highlights/notes/bookmarks sync with offline queue
|
||||||
|
4. **Comics** — CBZ navigator with manga modes (RTL, spreads, zoom)
|
||||||
|
5. **Polish** — OLED themes, gestures, background sync, storage management
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔭 Post-v1 Ideas
|
||||||
|
|
||||||
|
### QR pairing sign-in (Netflix-style)
|
||||||
|
|
||||||
|
"Add device" on the web (while logged in) displays a QR code; a fresh app install scans it and is **fully signed in** — no server URL, no password, nothing typed on the phone.
|
||||||
|
|
||||||
|
- **QR is a full login**: the claim endpoint returns JWT + refresh token (plus the device token for sync identity)
|
||||||
|
- **Typed-code fallback** (GitHub/Netflix device-flow style: app displays a short code, user enters it on the web) for phones with broken cameras or no camera
|
||||||
|
- **KOReader keeps its existing flow unchanged** — no typed-code pairing there; it is already as convenient as it can be
|
||||||
|
- **Use the configured `BASE_URL`, never a detected LAN IP** — if the server is published at `https://public.domain`, pairing must work identically from outside the LAN
|
||||||
|
- Requires small server additions: `pair`/`claim` endpoints backed by single-use pairing sessions with a short TTL (in-memory like `pendingRegistrations`)
|
||||||
|
|
||||||
|
### Other ideas
|
||||||
|
|
||||||
|
- Webtoon / continuous vertical reading mode
|
||||||
|
- Home-screen widgets and app shortcuts ("continue reading")
|
||||||
|
- Text-to-speech
|
||||||
|
- OPDS feed consumption from other servers
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Related Documentation
|
||||||
|
|
||||||
|
- **[API Reference](api/api-reference.md)** - Complete REST API
|
||||||
|
- **[WebSocket API](websocket-api.md)** - Real-time sync events
|
||||||
|
- **[Sync Guide](../user/sync-guide.md)** - How universal sync works
|
||||||
|
- **[Devices API](api/devices/)** - Device registration and approval
|
||||||
+213
-132
@@ -26,14 +26,16 @@ Complete API documentation for Bookhoard v1.0 with Universal Cross-Platform Sync
|
|||||||
8. [Device Management](#device-management)
|
8. [Device Management](#device-management)
|
||||||
9. [Analytics](#analytics)
|
9. [Analytics](#analytics)
|
||||||
10. [Book Matching & Linking](#book-matching--linking)
|
10. [Book Matching & Linking](#book-matching--linking)
|
||||||
11. [Collections](#collections) → See [COLLECTIONS_API.md](COLLECTIONS_API.md)
|
11. [Collections](#collections) → See [Collections API](collections-api.md)
|
||||||
12. [OPDS](#opds-open-publication-distribution-system)
|
12. [OPDS](#opds-open-publication-distribution-system)
|
||||||
13. [Sync Protocol - KOReader](#sync-protocol---koreader)
|
13. [Sync Protocol - KOReader](#sync-protocol---koreader)
|
||||||
14. [Sync Protocol - Kobo](#sync-protocol---kobo)
|
14. [Sync Protocol - Kobo](#sync-protocol---kobo)
|
||||||
15. [Universal Progress](#universal-progress)
|
15. [Universal Progress](#universal-progress)
|
||||||
16. [Conflicts](#conflicts)
|
16. [Conflicts](#conflicts)
|
||||||
17. [Sync Queue](#sync-queue)
|
17. [Sync Queue](#sync-queue)
|
||||||
18. [WebSocket](#websocket)
|
18. [System Settings & Configuration](#system-settings--configuration)
|
||||||
|
19. [Hash Conflicts](#hash-conflicts)
|
||||||
|
20. [WebSocket](#websocket)
|
||||||
|
|
||||||
## Base URL
|
## Base URL
|
||||||
|
|
||||||
@@ -201,7 +203,9 @@ Content-Type: application/json
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Update Scan Settings
|
### Update Scan Settings (Legacy)
|
||||||
|
|
||||||
|
> Superseded by `PUT /api/system/settings` (see [System Settings & Configuration](#system-settings--configuration)); kept for backward compatibility.
|
||||||
|
|
||||||
```http
|
```http
|
||||||
PUT /api/libraries/scan-settings
|
PUT /api/libraries/scan-settings
|
||||||
@@ -390,6 +394,11 @@ Authorization: Bearer <token>
|
|||||||
|
|
||||||
## Reading Progress
|
## Reading Progress
|
||||||
|
|
||||||
|
Full field reference: [api/progress/](api/progress/) — and read the
|
||||||
|
[Position Contract](api/progress/position-contract.md) (verification,
|
||||||
|
healing, the OPF spine numbering hazard, offset currencies) before
|
||||||
|
writing a client.
|
||||||
|
|
||||||
### Get Reading Progress
|
### Get Reading Progress
|
||||||
|
|
||||||
```http
|
```http
|
||||||
@@ -401,57 +410,56 @@ Authorization: Bearer <token>
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
"id": "uuid",
|
||||||
"media_item_id": "uuid",
|
"media_item_id": "uuid",
|
||||||
"user_id": "uuid",
|
"user_id": "uuid",
|
||||||
"current_page": 45,
|
"percentage": 0.045,
|
||||||
"total_pages": 200,
|
"epubcfi": "epubcfi(/6/4!/4/8[_idContainer003]/58/1:598)",
|
||||||
"percentage": 0.225,
|
"context_text": "from day to day, but there was none in which ...",
|
||||||
"character_offset": 15432,
|
"character_offset": 16375,
|
||||||
"epubcfi": "epubcfi(/6/4/2:15)",
|
"current_page": null,
|
||||||
"chapter": 3,
|
"total_pages": null,
|
||||||
"chapter_progress": 0.5,
|
"chapter": null,
|
||||||
"last_read_at": "2026-01-31T10:00:00Z",
|
"chapter_progress": null,
|
||||||
"format_group": "reflowable",
|
"format_group": "reflowable",
|
||||||
"viewport_y": 0.12,
|
"total_characters": 592216,
|
||||||
"zoom_level": 1.0
|
"chapter_count": 1,
|
||||||
|
"last_read_at": "2026-09-26T14:52:34Z",
|
||||||
|
"last_sync_source": "koreader",
|
||||||
|
"css_selector": "body>div:nth-child(4)>p:nth-child(29)",
|
||||||
|
"anchor_href": "1984.xhtml",
|
||||||
|
"char_offset": 598
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`css_selector`, `anchor_href` and `char_offset` are server-derived
|
||||||
|
restore handles, served for convertible reflowable books with a
|
||||||
|
resolvable anchor. `char_offset` is UTF-16 code units within the anchor
|
||||||
|
block's text; `character_offset` is a book-wide rune count. Resolve the
|
||||||
|
document by `anchor_href`, never by the CFI's spine step (OPF numbering
|
||||||
|
includes `linear="no"` items).
|
||||||
|
|
||||||
### Update Reading Progress
|
### Update Reading Progress
|
||||||
|
|
||||||
```http
|
```http
|
||||||
PUT /api/media-items/{media_id}/progress
|
PUT /api/media-items/{media_id}/progress
|
||||||
Authorization: Bearer <token>
|
Authorization: Bearer <token>
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
|
|
||||||
{
|
|
||||||
"source": "web",
|
|
||||||
"location": {
|
|
||||||
"percentage": 0.45678,
|
|
||||||
"epubcfi": "epubcfi(/6/4/2:15)",
|
|
||||||
"character": 15432,
|
|
||||||
"chapter": 3,
|
|
||||||
"page": 89,
|
|
||||||
"total_pages": 200
|
|
||||||
},
|
|
||||||
"device_metadata": {
|
|
||||||
"device_type": "web",
|
|
||||||
"user_agent": "Mozilla/5.0..."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Response** (200):
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"sync_status": "success",
|
"percentage": 0.0415,
|
||||||
"progress_updated": true,
|
"context_text": "was at war with one of these Powers it was generally a",
|
||||||
"devices_notified": ["device-1", "device-2"],
|
"epubcfi": "epubcfi(/6/4!/4/8[_idContainer003]/62/1:456)"
|
||||||
"broadcast": true
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
All fields are optional. The server verifies the submission against the
|
||||||
|
book and heals it on mismatch; the response is the stored row after
|
||||||
|
verification. A `percentage` below 0.005 is ignored while stored
|
||||||
|
progress exceeds 0.01 (first-page anti-clobber).
|
||||||
|
|
||||||
### Delete Reading Progress
|
### Delete Reading Progress
|
||||||
|
|
||||||
```http
|
```http
|
||||||
@@ -665,18 +673,23 @@ Content-Type: application/json
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"device_id": "uuid",
|
|
||||||
"registration_id": "registration-uuid",
|
"registration_id": "registration-uuid",
|
||||||
"auth_url": "https://bookhoard.com/devices/auth/confirm/abc123",
|
"auth_url": "https://bookhoard.com/devices/approve/abc123",
|
||||||
"qr_code": "data:image/png;base64,iVBORw0KG...",
|
"qr_code": "data:image/png;base64,iVBORw0KG...",
|
||||||
"expires_in": 300
|
"expires_in": 300,
|
||||||
|
"poll_interval": 3,
|
||||||
|
"setup_instructions": {
|
||||||
|
"koreader": "Calibre URL: https://bookhoard.com/api/sync/koreader"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Open `auth_url` (or scan the QR code) while logged in to approve; the registration expires after 5 minutes.
|
||||||
|
|
||||||
### Check Registration Status
|
### Check Registration Status
|
||||||
|
|
||||||
```http
|
```http
|
||||||
POST /api/devices/auth/status
|
POST /api/devices/register/status
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -688,13 +701,13 @@ Content-Type: application/json
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"status": "pending|approved|expired",
|
"status": "pending|approved",
|
||||||
"auth_token": "device-bearer-token...",
|
"auth_token": "device-bearer-token...",
|
||||||
"device_id": "uuid",
|
"device_id": "uuid",
|
||||||
"sync_endpoints": {
|
"sync_endpoints": {
|
||||||
"progress": "https://bookhoard.com/api/sync/progress",
|
"progress": "https://bookhoard.com/api/sync/koreader/progress",
|
||||||
"metadata": "https://bookhoard.com/api/sync/metadata",
|
"metadata": "https://bookhoard.com/api/sync/koreader/metadata",
|
||||||
"annotations": "https://bookhoard.com/api/sync/annotations"
|
"bookmarks": "https://bookhoard.com/api/sync/koreader/bookmarks"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -747,6 +760,22 @@ DELETE /api/devices/{device_id}
|
|||||||
Authorization: Bearer <token>
|
Authorization: Bearer <token>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Get Device Sidecar Config
|
||||||
|
|
||||||
|
Returns the `.bookhoard.json` sidecar config for a device (server endpoints, books keyed by per-format SHA-256, collections) used by the KOReader plugin to self-configure.
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/devices/{device_id}/sidecar
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
Also available as a file download:
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/devices/{device_id}/sidecar/download
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
```
|
||||||
|
|
||||||
## Analytics
|
## Analytics
|
||||||
|
|
||||||
### Get Reading Statistics
|
### Get Reading Statistics
|
||||||
@@ -953,7 +982,7 @@ Authorization: Bearer <token>
|
|||||||
|
|
||||||
## Collections
|
## Collections
|
||||||
|
|
||||||
For complete collection management documentation, see **[COLLECTIONS_API.md](COLLECTIONS_API.md)**.
|
For complete collection management documentation, see **[Collections API](collections-api.md)**.
|
||||||
|
|
||||||
**Quick Reference**:
|
**Quick Reference**:
|
||||||
|
|
||||||
@@ -986,25 +1015,39 @@ GET /opds/devices/{deviceId}/catalog?page={page}&per_page={per_page}
|
|||||||
- `page` (optional): Page number (default: 1)
|
- `page` (optional): Page number (default: 1)
|
||||||
- `per_page` (optional): Items per page (default: 50, max: 200)
|
- `per_page` (optional): Items per page (default: 50, max: 200)
|
||||||
|
|
||||||
|
The feed is paginated via standard OPDS link relations. Clients (e.g. KOReader)
|
||||||
|
walk pages by following the `rel="next"` link until it is absent. OpenSearch
|
||||||
|
paging metadata (`totalResults`, `itemsPerPage`, `startIndex`) is also included.
|
||||||
|
|
||||||
**Response** (200 - OPDS 1.2 XML):
|
**Response** (200 - OPDS 1.2 XML):
|
||||||
|
|
||||||
```xml
|
```xml
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<feed xmlns="http://www.w3.org/2005/Atom"
|
<feed xmlns="http://www.w3.org/2005/Atom"
|
||||||
xmlns:opds="http://opds-spec.org/2010/"
|
xmlns:opds="http://opds-spec.org/2010/"
|
||||||
xmlns:dc="http://purl.org/dc/elements/1.1/">
|
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||||
|
xmlns:opensearch="http://a9.com/-/spec/opensearch/1.1/">
|
||||||
<id>urn:uuid:device-id</id>
|
<id>urn:uuid:device-id</id>
|
||||||
<title>Bookhoard Library</title>
|
<title>Bookhoard Library</title>
|
||||||
<updated>2026-02-01T12:00:00Z</updated>
|
<updated>2026-02-01T12:00:00Z</updated>
|
||||||
|
|
||||||
<link rel="self" href="http://localhost:8765/opds/devices/kobo-id/catalog"/>
|
<link rel="self" href="http://localhost:8765/opds/devices/kobo-id/catalog?page=2&per_page=50"/>
|
||||||
<link rel="search" href="http://localhost:8765/opds/devices/kobo-id/search"/>
|
<link rel="start" href="http://localhost:8765/opds/devices/kobo-id/catalog?page=1&per_page=50"/>
|
||||||
<link rel="start" href="http://localhost:8765/opds/devices/kobo-id/nav"/>
|
<link rel="first" href="http://localhost:8765/opds/devices/kobo-id/catalog?page=1&per_page=50"/>
|
||||||
|
<link rel="previous" href="http://localhost:8765/opds/devices/kobo-id/catalog?page=1&per_page=50"/>
|
||||||
|
<link rel="next" href="http://localhost:8765/opds/devices/kobo-id/catalog?page=3&per_page=50"/>
|
||||||
|
<link rel="last" href="http://localhost:8765/opds/devices/kobo-id/catalog?page=37&per_page=50"/>
|
||||||
|
<link rel="search" type="application/opensearchdescription+xml"
|
||||||
|
href="http://localhost:8765/opds/devices/kobo-id/search"/>
|
||||||
|
|
||||||
|
<opensearch:totalResults>1814</opensearch:totalResults>
|
||||||
|
<opensearch:itemsPerPage>50</opensearch:itemsPerPage>
|
||||||
|
<opensearch:startIndex>51</opensearch:startIndex>
|
||||||
|
|
||||||
<entry>
|
<entry>
|
||||||
<id>urn:uuid:bookhoard-uuid-123</id>
|
<id>urn:uuid:bookhoard-uuid-123</id>
|
||||||
<dc:title>The Hobbit</dc:title>
|
<title>The Hobbit</title>
|
||||||
<dc:creator>J.R.R. Tolkien</dc:creator>
|
<author><name>J.R.R. Tolkien</name></author>
|
||||||
<updated>2026-02-01T10:00:00Z</updated>
|
<updated>2026-02-01T10:00:00Z</updated>
|
||||||
|
|
||||||
<link href="http://localhost:8765/opds/devices/kobo-id/download/uuid-123"
|
<link href="http://localhost:8765/opds/devices/kobo-id/download/uuid-123"
|
||||||
@@ -1043,10 +1086,28 @@ GET /opds/devices/{deviceId}/download/{bookId}?format={format}
|
|||||||
### Search OPDS Catalog
|
### Search OPDS Catalog
|
||||||
|
|
||||||
```http
|
```http
|
||||||
GET /opds/devices/{deviceId}/search?q={query}
|
GET /opds/devices/{deviceId}/search # OpenSearch description
|
||||||
|
GET /opds/devices/{deviceId}/search?q={query} # search results feed
|
||||||
```
|
```
|
||||||
|
|
||||||
**Response** (200 - OPDS 1.2 XML with search results)
|
When called **without** a `q` parameter, returns an OpenSearch description
|
||||||
|
document (`application/opensearchdescription+xml`). OPDS clients fetch this to
|
||||||
|
learn the search URL template, then substitute `{searchTerms}`:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/">
|
||||||
|
<ShortName>Bookhoard</ShortName>
|
||||||
|
<Description>Search the Bookhoard library</Description>
|
||||||
|
<InputEncoding>UTF-8</InputEncoding>
|
||||||
|
<OutputEncoding>UTF-8</OutputEncoding>
|
||||||
|
<Url type="application/atom+xml;profile=opds-catalog;kind=acquisition"
|
||||||
|
template="http://localhost:8765/opds/devices/kobo-id/search?q={searchTerms}"/>
|
||||||
|
</OpenSearchDescription>
|
||||||
|
```
|
||||||
|
|
||||||
|
When called **with** a `q` parameter, **Response** (200 - OPDS 1.2 XML with
|
||||||
|
search results, including `opensearch:totalResults`).
|
||||||
|
|
||||||
### List Available Formats
|
### List Available Formats
|
||||||
|
|
||||||
@@ -1171,6 +1232,8 @@ Authorization: Bearer <device_token>
|
|||||||
|
|
||||||
## Sync Protocol - Kobo
|
## Sync Protocol - Kobo
|
||||||
|
|
||||||
|
> **Status: Coming Soon** — Native Kobo sync is implemented server-side but not yet supported on real devices. These endpoints are under active development and may change.
|
||||||
|
|
||||||
### Kobo Markup Sync
|
### Kobo Markup Sync
|
||||||
|
|
||||||
```http
|
```http
|
||||||
@@ -1238,82 +1301,10 @@ Authorization: Bearer <device_token>
|
|||||||
|
|
||||||
## Universal Progress
|
## Universal Progress
|
||||||
|
|
||||||
### Get Universal Progress
|
Universal progress is the media-item progress — one row per (user,
|
||||||
|
media item) shared by every device. See [Reading Progress](#reading-progress)
|
||||||
```http
|
and the [Position Contract](api/progress/position-contract.md). There
|
||||||
GET /api/progress/{book_uuid}
|
are no separate `/api/progress/:id` GET/POST endpoints.
|
||||||
Authorization: Bearer <token>
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response** (200):
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"book_id": "book-uuid",
|
|
||||||
"format_group": "reflowable",
|
|
||||||
"universal_progress": 0.45678,
|
|
||||||
"location_references": {
|
|
||||||
"percentage": 0.45678,
|
|
||||||
"epubcfi": "epubcfi(/6/4/2:15)",
|
|
||||||
"character": 15432,
|
|
||||||
"chapter": 3,
|
|
||||||
"chapter_progress": 0.234,
|
|
||||||
"viewport_y": 0.12
|
|
||||||
},
|
|
||||||
"device_progress": {
|
|
||||||
"koreader": {
|
|
||||||
"percentage": 0.45678,
|
|
||||||
"last_sync": "2026-01-30T20:00:00Z"
|
|
||||||
},
|
|
||||||
"kobo": {
|
|
||||||
"percentage": 45.6,
|
|
||||||
"last_sync": "2026-01-30T19:55:00Z"
|
|
||||||
},
|
|
||||||
"web": {
|
|
||||||
"display_page": 89,
|
|
||||||
"total_pages": 200,
|
|
||||||
"last_sync": "2026-01-30T20:05:00Z"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"annotations": {
|
|
||||||
"highlights": [...],
|
|
||||||
"notes": [...],
|
|
||||||
"bookmarks": [...]
|
|
||||||
},
|
|
||||||
"conflicts": [
|
|
||||||
{
|
|
||||||
"id": "conflict-uuid",
|
|
||||||
"type": "progress",
|
|
||||||
"resolved": false,
|
|
||||||
"sources": ["koreader", "kobo"]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Update Universal Progress
|
|
||||||
|
|
||||||
```http
|
|
||||||
POST /api/progress/{book_uuid}
|
|
||||||
Authorization: Bearer <token>
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
{
|
|
||||||
"source": "web|koreader|kobo|mobile",
|
|
||||||
"location": {
|
|
||||||
"percentage": 0.45678,
|
|
||||||
"epubcfi": "epubcfi(/6/4/2:15)",
|
|
||||||
"character": 15432,
|
|
||||||
"chapter": 3,
|
|
||||||
"page": 89,
|
|
||||||
"total_pages": 200
|
|
||||||
},
|
|
||||||
"device_metadata": {
|
|
||||||
"device_type": "web",
|
|
||||||
"user_agent": "..."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Conflicts
|
## Conflicts
|
||||||
|
|
||||||
@@ -1506,6 +1497,96 @@ Authorization: Bearer <token>
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## System Settings & Configuration
|
||||||
|
|
||||||
|
### List All Settings
|
||||||
|
|
||||||
|
Returns every tunable setting with current value and metadata (type, range, category, group, description, `requires_restart`, `is_default`).
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/system/settings
|
||||||
|
Authorization: Bearer <admin_token>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response** (200):
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"key": "scan_poll_interval_seconds",
|
||||||
|
"value": "60",
|
||||||
|
"type": "int",
|
||||||
|
"min": "1",
|
||||||
|
"max": "3600",
|
||||||
|
"requires_restart": false,
|
||||||
|
"category": "scanner",
|
||||||
|
"group": "Scanning",
|
||||||
|
"description": "How often to scan all libraries (seconds)",
|
||||||
|
"is_default": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Update a Setting
|
||||||
|
|
||||||
|
Type-aware validation (int range, bool parse, IANA timezone for `default_timezone`), persists the value, reloads the registry, and reports whether a restart is needed.
|
||||||
|
|
||||||
|
```http
|
||||||
|
PUT /api/system/settings
|
||||||
|
Authorization: Bearer <admin_token>
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"key": "scan_poll_interval_seconds",
|
||||||
|
"value": "30"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response** (200): the updated entry plus `reload_required`.
|
||||||
|
|
||||||
|
Setting categories: scanner (`scan_poll_interval_seconds`, `auto_scan_enabled`), general (`default_timezone`), security (session duration, password rules, auth rate limit, login lockout), api (OPDS page sizes, device rate limits), sync (annotation tombstone TTL, sync queue interval/batch), performance (conversion cache TTL, worker pool size/capacity). See [System Settings API](api/system/settings.md) for the full catalog.
|
||||||
|
|
||||||
|
### Get / Update Raw System Config
|
||||||
|
|
||||||
|
Flat key/value configuration (e.g. `base_url`), including keys without registry metadata.
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/system/config
|
||||||
|
PUT /api/system/config
|
||||||
|
Authorization: Bearer <admin_token>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Hash Conflicts
|
||||||
|
|
||||||
|
Duplicate content discovered during hashing (import, rescan, or the startup backfill) is grouped into hash conflicts for an explicit keep/merge decision. Files on disk are never deleted.
|
||||||
|
|
||||||
|
### List Hash Conflicts
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/admin/hash-conflicts
|
||||||
|
Authorization: Bearer <admin_token>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response** (200): `{ "conflicts": [ { id, library_id, library_name, sha256, created_at, items: [ { id, title, author, file_path, file_size, created_at, progress_count, highlight_count, bookmark_count, note_count, collection_count } ] } ], "total": n }`
|
||||||
|
|
||||||
|
### Resolve Hash Conflict
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/admin/hash-conflicts/:id/resolve
|
||||||
|
Authorization: Bearer <admin_token>
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"action": "keep",
|
||||||
|
"keep_uuid": "media-item-uuid-to-keep"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `action=keep` — merge every other copy's child rows (progress, highlights, bookmarks, notes, collections) into the kept item, then delete the losers
|
||||||
|
- `action=keep_all` — copies are intentional; dismiss the conflict
|
||||||
|
|
||||||
|
**Errors**: `400` (bad ID / missing `keep_uuid`), `404` (not found), `409` (already resolved).
|
||||||
|
|
||||||
## WebSocket
|
## WebSocket
|
||||||
|
|
||||||
### Connect to WebSocket
|
### Connect to WebSocket
|
||||||
@@ -1661,10 +1742,10 @@ bruno run bruno/devices/
|
|||||||
|
|
||||||
## Additional Resources
|
## Additional Resources
|
||||||
|
|
||||||
- [README.md](README.md) - Getting started guide
|
- [README.md](../../README.md) - Getting started guide
|
||||||
- [UNIVERSAL_SYNC_IMPLEMENTATION_GUIDE.md](UNIVERSAL_SYNC_IMPLEMENTATION_GUIDE.md) - Sync architecture
|
- [Sync Guide](../user/sync-guide.md) - Sync concepts and conflict resolution
|
||||||
- [KOBOREADER_SETUP.md](KOBOREADER_SETUP.md) - KOReader device setup
|
- [KOReader Setup](../user/devices/koreader-setup.md) - KOReader device setup
|
||||||
- [KOBO_SETUP.md](KOBO_SETUP.md) - Kobo device setup
|
- [Kobo Setup](../user/devices/kobo-setup.md) - Kobo device setup (native sync coming soon)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
# Hash Conflicts API
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
When Bookhoard hashes your library (on import, rescan, or the startup backfill), two media items in the same library with the same `file_sha256` indicate duplicate content. Each duplicate group is recorded as a **hash conflict** and exposed here for an explicit keep/merge decision. Conflicts are also surfaced in the admin UI's Hash Conflicts page.
|
||||||
|
|
||||||
|
**Authentication**: Admin JWT token required
|
||||||
|
**Content-Type**: `application/json` (resolve also accepts form-encoded bodies for htmx)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
### List Hash Conflicts
|
||||||
|
|
||||||
|
List all pending conflict groups, each with its member items and per-item usage counts (reading progress, highlights, bookmarks, notes, collections) to help decide which copy to keep.
|
||||||
|
|
||||||
|
**Endpoint**: `GET /api/admin/hash-conflicts`
|
||||||
|
|
||||||
|
**Response**: **200 OK**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"conflicts": [
|
||||||
|
{
|
||||||
|
"id": "conflict-uuid",
|
||||||
|
"library_id": "library-uuid",
|
||||||
|
"library_name": "Ebooks",
|
||||||
|
"sha256": "abc123...",
|
||||||
|
"created_at": "2026-08-14T12:00:00Z",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": "media-item-uuid",
|
||||||
|
"title": "The Hobbit",
|
||||||
|
"author": "J. R. R. Tolkien",
|
||||||
|
"file_path": "/books/hobbit.epub",
|
||||||
|
"file_size": 1048576,
|
||||||
|
"created_at": "2026-01-01T00:00:00Z",
|
||||||
|
"progress_count": 2,
|
||||||
|
"highlight_count": 12,
|
||||||
|
"bookmark_count": 3,
|
||||||
|
"note_count": 1,
|
||||||
|
"collection_count": 2
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X GET https://bookhoard.example.com/api/admin/hash-conflicts \
|
||||||
|
-H "Authorization: Bearer <admin_token>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Resolve Hash Conflict
|
||||||
|
|
||||||
|
Resolve one conflict group.
|
||||||
|
|
||||||
|
**Endpoint**: `POST /api/admin/hash-conflicts/{id}/resolve`
|
||||||
|
|
||||||
|
**Request Body** (JSON or form-encoded):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"action": "keep",
|
||||||
|
"keep_uuid": "media-item-uuid-to-keep"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
| ----------- | ------ | -------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `action` | string | Yes | `keep_all` — both copies are intentional; dismiss the conflict. `keep` — keep `keep_uuid` and delete the other copies. |
|
||||||
|
| `keep_uuid` | string | for `action=keep` | The media item UUID to keep. Must belong to this conflict group. With `keep`, every other copy's child rows (progress, highlights, bookmarks, notes, collections, …) are merged into the kept item before the losers are deleted. |
|
||||||
|
|
||||||
|
**Responses**:
|
||||||
|
|
||||||
|
- `200 OK` — resolved (body is an HTML confirmation snippet for the admin UI page)
|
||||||
|
- `400 Bad Request` — invalid conflict ID, missing `keep_uuid`, or `keep_uuid` not in the group
|
||||||
|
- `404 Not Found` — conflict doesn't exist
|
||||||
|
- `409 Conflict` — conflict already resolved
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST https://bookhoard.example.com/api/admin/hash-conflicts/<id>/resolve \
|
||||||
|
-H "Authorization: Bearer <admin_token>" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"action": "keep", "keep_uuid": "media-item-uuid"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## When Conflicts Are Created
|
||||||
|
|
||||||
|
- **Startup backfill**: items imported before hashing existed are hashed automatically ~30s after startup; duplicates discovered land here.
|
||||||
|
- **Rescan**: hashes are recomputed and content duplicates are flagged.
|
||||||
|
|
||||||
|
Files on disk are never deleted — resolution only affects database rows.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Related Endpoints
|
||||||
|
|
||||||
|
- [System Settings API](../system/settings.md) — scanning configuration
|
||||||
|
- [Scanner API](../scanner/) — triggering scans and watch mode
|
||||||
@@ -21,9 +21,10 @@ Complete reference for Bookhoard REST API endpoints.
|
|||||||
- [Conflicts](conflicts/) - Sync conflict resolution
|
- [Conflicts](conflicts/) - Sync conflict resolution
|
||||||
- [Queue](queue/) - Sync queue management
|
- [Queue](queue/) - Sync queue management
|
||||||
- [Scanner](scanner/) - Library scanning and watch mode (admin)
|
- [Scanner](scanner/) - Library scanning and watch mode (admin)
|
||||||
|
- [System](system/) - Tunable system settings and configuration (admin)
|
||||||
- [OPDS](opds/) - Open Publication Distribution
|
- [OPDS](opds/) - Open Publication Distribution
|
||||||
- [KOReader](koreader/) - KOReader sync protocol
|
- [KOReader](koreader/) - KOReader sync protocol
|
||||||
- [Kobo](kobo/) - Kobo sync protocol
|
- [Kobo](kobo/) - Kobo sync protocol (coming soon)
|
||||||
- [WebSocket](websocket/) - Real-time sync events
|
- [WebSocket](websocket/) - Real-time sync events
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -51,6 +52,8 @@ See [Admin Operations](admin/)
|
|||||||
|
|
||||||
- GET /api/auth/users - List all users (admin)
|
- GET /api/auth/users - List all users (admin)
|
||||||
- PUT /api/auth/users/:id/max-devices - Update user device limit (admin)
|
- PUT /api/auth/users/:id/max-devices - Update user device limit (admin)
|
||||||
|
- GET /api/admin/hash-conflicts - List pending hash conflict groups (admin) — see [Hash Conflicts](admin/hash-conflicts.md)
|
||||||
|
- POST /api/admin/hash-conflicts/:id/resolve - Resolve a conflict (keep / keep_all) (admin)
|
||||||
|
|
||||||
## Users & Profiles
|
## Users & Profiles
|
||||||
|
|
||||||
@@ -71,7 +74,10 @@ See [Library Management](libraries/)
|
|||||||
- DELETE /api/libraries/:id/folders - Delete library folder (admin)
|
- DELETE /api/libraries/:id/folders - Delete library folder (admin)
|
||||||
- GET /api/libraries/:id/stats - Get library statistics (admin)
|
- GET /api/libraries/:id/stats - Get library statistics (admin)
|
||||||
- GET /api/libraries/:id/media-items - Get library media items (admin)
|
- GET /api/libraries/:id/media-items - Get library media items (admin)
|
||||||
|
- GET /api/libraries/browse - Browse server directories (admin)
|
||||||
- POST /api/libraries/:id/scan - Scan library (admin)
|
- POST /api/libraries/:id/scan - Scan library (admin)
|
||||||
|
- GET /api/libraries/scan-settings - Legacy scan settings (admin; superseded by /api/system/settings)
|
||||||
|
- PUT /api/libraries/scan-settings - Legacy scan settings update (admin; superseded by /api/system/settings)
|
||||||
- GET /api/libraries/visibility - Get visible libraries
|
- GET /api/libraries/visibility - Get visible libraries
|
||||||
- POST /api/libraries/visibility - Set library visibility
|
- POST /api/libraries/visibility - Set library visibility
|
||||||
|
|
||||||
@@ -83,7 +89,7 @@ See [Media Item Operations](media-items/)
|
|||||||
- GET /api/media-items/:id - Get media item details
|
- GET /api/media-items/:id - Get media item details
|
||||||
- POST /api/media-items/bulk-delete - Bulk delete media items
|
- POST /api/media-items/bulk-delete - Bulk delete media items
|
||||||
- POST /api/media-items/bulk-update - Bulk update media items (tags/contributors with normalization)
|
- POST /api/media-items/bulk-update - Bulk update media items (tags/contributors with normalization)
|
||||||
- GET /api/media-items/:uuid/download - Download media item file
|
- GET /uploads/library-{library_id}/{file_path} - Download book file / cover (JWT; see [Download Media Item](media-items/download_media_item.md))
|
||||||
- POST /api/media-items/:id/rating - Create rating
|
- POST /api/media-items/:id/rating - Create rating
|
||||||
- GET /api/media-items/:id/rating - Get rating
|
- GET /api/media-items/:id/rating - Get rating
|
||||||
- PUT /api/media-items/:id/rating - Update rating
|
- PUT /api/media-items/:id/rating - Update rating
|
||||||
@@ -101,16 +107,24 @@ See [Media Item Operations](media-items/)
|
|||||||
- GET /api/media-items/:id/highlights/:highlightId - Get highlight
|
- GET /api/media-items/:id/highlights/:highlightId - Get highlight
|
||||||
- PUT /api/media-items/:id/highlights/:highlightId - Update highlight
|
- PUT /api/media-items/:id/highlights/:highlightId - Update highlight
|
||||||
- DELETE /api/media-items/:id/highlights/:highlightId - Delete highlight
|
- DELETE /api/media-items/:id/highlights/:highlightId - Delete highlight
|
||||||
|
- GET /api/media-items/:id/bookmarks - Get bookmarks
|
||||||
|
- GET /api/media-items/:id/annotations/deleted - List deleted annotations (history)
|
||||||
|
- POST /api/media-items/:id/annotations/:annotationId/restore - Restore a deleted annotation
|
||||||
|
- DELETE /api/media-items/:id/annotations/:annotationId?annotation_type=highlight|note|bookmark - Permanently delete a deleted annotation
|
||||||
- POST /api/media-items - Create media item (admin)
|
- POST /api/media-items - Create media item (admin)
|
||||||
- PUT /api/media-items/:id - Update media item (admin)
|
- PUT /api/media-items/:id - Update media item (admin)
|
||||||
- DELETE /api/media-items/:id - Delete media item (admin)
|
- DELETE /api/media-items/:id - Delete media item (admin)
|
||||||
|
|
||||||
## Reading Progress
|
## Reading Progress
|
||||||
|
|
||||||
See [Progress Tracking](progress/)
|
See [Progress Tracking](progress/) — in particular the
|
||||||
|
[Position Contract](progress/position-contract.md) (verification,
|
||||||
|
healing, the OPF spine numbering hazard, and offset currencies) before
|
||||||
|
writing a client.
|
||||||
|
|
||||||
- GET /api/progress/:id - Get universal progress
|
- GET /api/media-items/:id/progress - Get reading progress + restore handles
|
||||||
- POST /api/progress/:id - Update universal progress
|
- PUT /api/media-items/:id/progress - Submit reading progress (verified/healed server-side)
|
||||||
|
- DELETE /api/media-items/:id/progress - Delete reading progress
|
||||||
- GET /api/progress/:id/history - Get progress history
|
- GET /api/progress/:id/history - Get progress history
|
||||||
|
|
||||||
## Notes & Highlights
|
## Notes & Highlights
|
||||||
@@ -134,10 +148,21 @@ See [Device Registration & Sync](devices/)
|
|||||||
- GET /api/devices/pending - List pending registrations (admin)
|
- GET /api/devices/pending - List pending registrations (admin)
|
||||||
- GET /api/devices/approve/:registration_id - Approve registration (admin)
|
- GET /api/devices/approve/:registration_id - Approve registration (admin)
|
||||||
- POST /api/devices/reject/:registration_id - Reject registration (admin)
|
- POST /api/devices/reject/:registration_id - Reject registration (admin)
|
||||||
- POST /api/devices/:id/shelves - Add to shelf (Kobo)
|
- POST /api/devices/:id/shelves - Add to shelf (Kobo; used by native Kobo sync, coming soon)
|
||||||
- GET /api/devices/:id/shelves - Get shelf contents
|
- GET /api/devices/:id/shelves - Get shelf contents
|
||||||
- DELETE /api/devices/:id/shelves - Remove from shelf
|
- DELETE /api/devices/:id/shelves - Remove from shelf
|
||||||
- DELETE /api/devices/:id/shelves/clear - Clear shelf
|
- DELETE /api/devices/:id/shelves/clear - Clear shelf
|
||||||
|
- GET /api/devices/:id/sidecar - Get device sidecar config (.bookhoard.json) — see [Sidecar Config](devices/get_sidecar_config.md)
|
||||||
|
- GET /api/devices/:id/sidecar/download - Download sidecar config as a file
|
||||||
|
|
||||||
|
## System Settings & Configuration
|
||||||
|
|
||||||
|
See [System API](system/)
|
||||||
|
|
||||||
|
- GET /api/system/settings - List all tunable settings with metadata (admin)
|
||||||
|
- PUT /api/system/settings - Validate, persist, and reload a single setting (admin)
|
||||||
|
- GET /api/system/config - Raw key/value system configuration (admin)
|
||||||
|
- PUT /api/system/config - Update raw config values (admin)
|
||||||
|
|
||||||
## Analytics
|
## Analytics
|
||||||
|
|
||||||
@@ -220,12 +245,15 @@ See [OPDS Feeds](opds/)
|
|||||||
See [KOReader Sync](koreader/) and [Sync Protocol](sync/koreader-protocol.md)
|
See [KOReader Sync](koreader/) and [Sync Protocol](sync/koreader-protocol.md)
|
||||||
|
|
||||||
- POST /api/sync/koreader/progress - Sync reading progress
|
- POST /api/sync/koreader/progress - Sync reading progress
|
||||||
|
- GET /api/sync/koreader/resolve?sha256={hash} - Resolve a book UUID by file SHA-256
|
||||||
- GET /api/sync/koreader/metadata/:uuid - Get book metadata
|
- GET /api/sync/koreader/metadata/:uuid - Get book metadata
|
||||||
- GET /api/sync/koreader/library - Get device library
|
- GET /api/sync/koreader/library - Get device library
|
||||||
- POST /api/sync/koreader/bookmarks - Sync bookmarks
|
- POST /api/sync/koreader/bookmarks - Sync bookmarks
|
||||||
|
|
||||||
## Kobo Sync Protocol
|
## Kobo Sync Protocol
|
||||||
|
|
||||||
|
> **Status: Coming Soon** — Native Kobo sync is implemented server-side but not yet supported on real devices. These endpoints are under active development and may change.
|
||||||
|
|
||||||
See [Kobo Sync](kobo/) and [Sync Protocol](sync/kobo-protocol.md)
|
See [Kobo Sync](kobo/) and [Sync Protocol](sync/kobo-protocol.md)
|
||||||
|
|
||||||
- POST /api/sync/kobo/markup - Sync markup highlights
|
- POST /api/sync/kobo/markup - Sync markup highlights
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ Authenticate with email and password.
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||||
"refresh_token": "d4f5g6h7...",
|
"refresh_token": "d4f5g6h7...",
|
||||||
"token_type": "Bearer",
|
"token_type": "Bearer",
|
||||||
"expires_in": 604800,
|
"expires_in": 604800,
|
||||||
@@ -41,6 +41,8 @@ Authenticate with email and password.
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Note: the access token field is `access_token` (not `token`). Nullable profile fields (`first_name`, `last_name`) may be empty strings.
|
||||||
|
|
||||||
**Set-Cookie Header**:
|
**Set-Cookie Header**:
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ The frontend toast.js interceptor:
|
|||||||
- **Backend**: Automatically manages HTTP-only cookie
|
- **Backend**: Automatically manages HTTP-only cookie
|
||||||
- **Frontend**: Store tokens in localStorage for API calls
|
- **Frontend**: Store tokens in localStorage for API calls
|
||||||
|
|
||||||
### Mobile Applications
|
### Mobile Applications (coming later)
|
||||||
|
|
||||||
- Store access token in secure storage (Keychain/Keystore)
|
- Store access token in secure storage (Keychain/Keystore)
|
||||||
- Store refresh token in secure storage
|
- Store refresh token in secure storage
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Check device registration status or get device details.
|
Check device registration status or get device details.
|
||||||
|
|
||||||
**Endpoint**: `POST /api/devices/auth/status` or `GET /api/devices/{device_id}`
|
**Endpoint**: `POST /api/devices/register/status` or `GET /api/devices/{device_id}`
|
||||||
**Auth**: Not required for status check, Required for device details
|
**Auth**: Not required for status check, Required for device details
|
||||||
**Content-Type**: `application/json` (for status check)
|
**Content-Type**: `application/json` (for status check)
|
||||||
|
|
||||||
@@ -24,7 +24,9 @@ Check device registration status or get device details.
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"status": "pending|approved|expired",
|
"status": "pending|approved",
|
||||||
|
"message": "awaiting user approval",
|
||||||
|
"expires_in": 123,
|
||||||
"auth_token": "device-bearer-token...",
|
"auth_token": "device-bearer-token...",
|
||||||
"device_id": "uuid",
|
"device_id": "uuid",
|
||||||
"sync_endpoints": {
|
"sync_endpoints": {
|
||||||
@@ -35,24 +37,16 @@ Check device registration status or get device details.
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Response (200 OK) - Device Details
|
`status` is `pending` or `approved`. While pending, the response includes `message` and `expires_in` (seconds remaining). Once approved, the response includes `auth_token`, `device_id`, and `sync_endpoints`; `auth_token` fields are empty when pending.
|
||||||
|
|
||||||
```json
|
**The approved response is single-use**: the registration is deleted from the pending map once returned, so store the `auth_token` immediately. A repeat status check for the same `registration_id` returns 404.
|
||||||
{
|
|
||||||
"id": "uuid",
|
|
||||||
"device_name": "My Kobo Clara",
|
|
||||||
"device_type": "kobo",
|
|
||||||
"last_sync": "2026-01-31T10:00:00Z",
|
|
||||||
"last_seen": "2026-01-31T10:05:00Z",
|
|
||||||
"sync_enabled": true,
|
|
||||||
"auto_sync": true,
|
|
||||||
"sync_frequency_minutes": 5
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Error Responses
|
## Error Responses
|
||||||
|
|
||||||
| Code | Description |
|
| Code | Description |
|
||||||
| ---- | --------------------------------------------- |
|
| ---- | -------------------------------------------------- |
|
||||||
| 401 | Invalid or expired token (for device details) |
|
| 400 | Invalid or missing `registration_id` |
|
||||||
| 404 | Device or registration not found |
|
| 404 | Registration not found (unknown or already issued) |
|
||||||
|
| 410 | Registration expired (`{"error": "registration expired"}`) |
|
||||||
|
|
||||||
|
Note: expiration is signaled by HTTP 410 Gone, not a `"status": "expired"` value. Pending registrations are held in server memory, so a server restart also invalidates them (subsequent checks return 404).
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# Get Device Sidecar Config
|
||||||
|
|
||||||
|
Returns the KOReader/Kobo sidecar configuration (`.bookhoard.json`) for a device: server endpoints, the user's books (keyed by SHA-256 with UUID fallback), and collections. Used by the Bookhoard KOReader plugin to self-configure after approval.
|
||||||
|
|
||||||
|
**Endpoint**: `GET /api/devices/{id}/sidecar`
|
||||||
|
**Auth**: User JWT (device owner or admin)
|
||||||
|
|
||||||
|
### Response (200 OK)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": "1",
|
||||||
|
"bookhoard": {
|
||||||
|
"opds_catalog": "https://bookhoard.example.com/opds/devices/<device-id>/catalog",
|
||||||
|
"sync_api": "https://bookhoard.example.com/api/sync/kobo",
|
||||||
|
"opds_base_url": "https://bookhoard.example.com/opds",
|
||||||
|
"api_base_url": "https://bookhoard.example.com",
|
||||||
|
"device_id": "<device-id>",
|
||||||
|
"device_token": "dev_..."
|
||||||
|
},
|
||||||
|
"books": {
|
||||||
|
"abc123sha256...": {
|
||||||
|
"bookhoard_uuid": "media-item-uuid",
|
||||||
|
"title": "The Hobbit",
|
||||||
|
"author": "J. R. R. Tolkien",
|
||||||
|
"available_formats": ["epub", "kepub"],
|
||||||
|
"sha256": "abc123sha256...",
|
||||||
|
"file_path": "/books/hobbit.epub"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"collections": [
|
||||||
|
{ "name": "Favorites", "shelf_mapping": "Favorites" }
|
||||||
|
],
|
||||||
|
"opds_enabled": true,
|
||||||
|
"sidecar_enabled": true,
|
||||||
|
"last_updated": "2026-08-20T12:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Notes**:
|
||||||
|
|
||||||
|
- The `books` map is keyed by per-format SHA-256 (falling back to the item UUID), so a book downloaded in a different format (e.g. KEPUB) still matches its primary entry. Each entry lists `available_formats` for the item.
|
||||||
|
- `available_formats` includes `kepub` when the source is an EPUB (conversion available).
|
||||||
|
|
||||||
|
### Example Request
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl https://bookhoard.example.com/api/devices/<device-id>/sidecar \
|
||||||
|
-H "Authorization: Bearer <token>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Download Device Sidecar Config
|
||||||
|
|
||||||
|
Generates the same configuration as a downloadable `.bookhoard.json` file for manual device setup.
|
||||||
|
|
||||||
|
**Endpoint**: `GET /api/devices/{id}/sidecar/download`
|
||||||
|
**Auth**: User JWT (device owner or admin)
|
||||||
|
|
||||||
|
### Response (200 OK)
|
||||||
|
|
||||||
|
**Headers**:
|
||||||
|
|
||||||
|
- `Content-Type`: `application/json`
|
||||||
|
- `Content-Disposition`: attachment; filename="<device-name>.bookhoard.json"
|
||||||
|
|
||||||
|
**Body**: the sidecar JSON (same shape as above).
|
||||||
@@ -28,14 +28,19 @@ Register a new device for sync.
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"device_id": "uuid",
|
|
||||||
"registration_id": "registration-uuid",
|
"registration_id": "registration-uuid",
|
||||||
"auth_url": "https://bookhoard.com/devices/auth/confirm/abc123",
|
"auth_url": "https://bookhoard.com/devices/approve/abc123",
|
||||||
"qr_code": "data:image/png;base64,iVBORw0KG...",
|
"qr_code": "data:image/png;base64,iVBORw0KG...",
|
||||||
"expires_in": 300
|
"expires_in": 300,
|
||||||
|
"poll_interval": 3,
|
||||||
|
"setup_instructions": {
|
||||||
|
"koreader": "Calibre URL: https://bookhoard.com/api/sync/koreader"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Open `auth_url` (or scan the QR code) while logged in to approve; the registration expires after 5 minutes. Poll `POST /api/devices/register/status` at `poll_interval` seconds until `status` is `approved`, at which point the response includes the device's `auth_token`, `device_id`, and `sync_endpoints`.
|
||||||
|
|
||||||
## Error Responses
|
## Error Responses
|
||||||
|
|
||||||
| Code | Description |
|
| Code | Description |
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# Analytics GetTests
|
# Analytics GetTests
|
||||||
|
|
||||||
|
> **Status: Coming Soon** — Native Kobo sync is not yet supported on real devices; this endpoint is under active development and may change.
|
||||||
|
|
||||||
Kobo analytics endpoint (device compatibility).
|
Kobo analytics endpoint (device compatibility).
|
||||||
|
|
||||||
**Endpoint**: `POST /api/sync/kobo/v1/analytics/gettests`
|
**Endpoint**: `POST /api/sync/kobo/v1/analytics/gettests`
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# Bookmark Sync
|
# Bookmark Sync
|
||||||
|
|
||||||
|
> **Status: Coming Soon** — Native Kobo sync is not yet supported on real devices; this endpoint is under active development and may change.
|
||||||
|
|
||||||
Sync bookmarks from Kobo device.
|
Sync bookmarks from Kobo device.
|
||||||
|
|
||||||
**Endpoint**: `POST /api/sync/kobo/bookmark`
|
**Endpoint**: `POST /api/sync/kobo/bookmark`
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# Kobo Initialization
|
# Kobo Initialization
|
||||||
|
|
||||||
|
> **Status: Coming Soon** — Native Kobo sync is not yet supported on real devices; this endpoint is under active development and may change.
|
||||||
|
|
||||||
Initialize Kobo device sync.
|
Initialize Kobo device sync.
|
||||||
|
|
||||||
**Endpoint**: `GET /api/sync/kobo/v1/initialization`
|
**Endpoint**: `GET /api/sync/kobo/v1/initialization`
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# Markup Sync
|
# Markup Sync
|
||||||
|
|
||||||
|
> **Status: Coming Soon** — Native Kobo sync is not yet supported on real devices; this endpoint is under active development and may change.
|
||||||
|
|
||||||
Sync markup highlights and annotations from Kobo device.
|
Sync markup highlights and annotations from Kobo device.
|
||||||
|
|
||||||
**Endpoint**: `POST /api/sync/kobo/markup`
|
**Endpoint**: `POST /api/sync/kobo/markup`
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# Sync From Server
|
# Sync From Server
|
||||||
|
|
||||||
|
> **Status: Coming Soon** — Native Kobo sync is not yet supported on real devices; this endpoint is under active development and may change.
|
||||||
|
|
||||||
Push content and metadata to Kobo device.
|
Push content and metadata to Kobo device.
|
||||||
|
|
||||||
**Endpoint**: `POST /api/sync/kobo/sync-from-server`
|
**Endpoint**: `POST /api/sync/kobo/sync-from-server`
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
# Get Metadata
|
# Get Metadata
|
||||||
|
|
||||||
Get metadata for a book from KOReader device.
|
Get a book's stored progress and annotations for a KOReader device —
|
||||||
|
the pull half of the device sync. The reference client calls this after
|
||||||
|
linking a book via [Resolve Book](resolve_book.md) and navigates to the
|
||||||
|
returned position.
|
||||||
|
|
||||||
**Endpoint**: `GET /api/sync/koreader/metadata/:uuid`
|
**Endpoint**: `GET /api/sync/koreader/metadata/:uuid`
|
||||||
**Auth**: Required (Device authentication)
|
**Auth**: Required (Device authentication — `Authorization: Bearer {device_token}`)
|
||||||
|
|
||||||
## Path Parameters
|
## Path Parameters
|
||||||
|
|
||||||
@@ -11,41 +14,55 @@ Get metadata for a book from KOReader device.
|
|||||||
| --------- | ------------- | -------- | ----------- |
|
| --------- | ------------- | -------- | ----------- |
|
||||||
| uuid | string (UUID) | Yes | Book UUID |
|
| uuid | string (UUID) | Yes | Book UUID |
|
||||||
|
|
||||||
## Device Authentication
|
|
||||||
|
|
||||||
This endpoint requires device authentication (not user JWT). Devices authenticate using their device credentials.
|
|
||||||
|
|
||||||
## Request Headers
|
|
||||||
|
|
||||||
| Header | Type | Required | Description |
|
|
||||||
| ------------ | ------ | -------- | ------------------------- |
|
|
||||||
| X-Device-ID | string | Yes | Device UUID |
|
|
||||||
| X-Device-Key | string | Yes | Device authentication key |
|
|
||||||
|
|
||||||
### Example Request
|
### Example Request
|
||||||
|
|
||||||
```http
|
```http
|
||||||
GET /api/sync/koreader/metadata/550e8400-e29b-41d4-a716-446655440000
|
GET /api/sync/koreader/metadata/774641f9-317b-4087-8e04-53bb4392ae56
|
||||||
X-Device-ID: 550e8400-e29b-41d4-a716-446655440000
|
Authorization: Bearer {device_token}
|
||||||
X-Device-Key: device-auth-key
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Response (200 OK)
|
## Response (200 OK)
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"id": "uuid",
|
"uuid": "774641f9-317b-4087-8e04-53bb4392ae56",
|
||||||
"title": "Book Title",
|
"title": "1984",
|
||||||
"authors": ["Author Name"],
|
"author": "George Orwell",
|
||||||
"path": "/path/to/book.epub",
|
"progress": {
|
||||||
"file_size": 1234567,
|
"percentage": 0.045,
|
||||||
"modified_at": "2026-02-08T10:00:00Z"
|
"koreader_xpointer": "/body/DocFragment[1]/body/p[29]/text().598",
|
||||||
|
"chapter": null,
|
||||||
|
"chapter_progress": null,
|
||||||
|
"page": null,
|
||||||
|
"total_pages": null
|
||||||
|
},
|
||||||
|
"annotations": {
|
||||||
|
"highlights": []
|
||||||
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Progress Object
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
| ----- | ---- | ----------- |
|
||||||
|
| `percentage` | float | Stored position as a book fraction. |
|
||||||
|
| `koreader_xpointer` | string | The stored canonical position converted back to a CRE xpointer (UTF-16 `text().N` offset). **The device should navigate to this.** Reflowable books only. |
|
||||||
|
| `epubcfi` | string | The stored canonical CFI, when the conversion to a CRE xpointer is unavailable. Fallback after `koreader_xpointer`. |
|
||||||
|
| `character` | int | Book-wide rune offset (internal currency). |
|
||||||
|
| `chapter`, `chapter_progress` | int, float | Chapter position when known. |
|
||||||
|
| `page`, `total_pages` | int | Fixed-layout page position — the canonical locator for image-based books (CFI/xpointer are omitted for them). |
|
||||||
|
|
||||||
|
`progress` is `null` when the book has no stored progress.
|
||||||
|
|
||||||
|
The `annotations` object carries device-format highlights/bookmarks/notes
|
||||||
|
synced from other clients; its presence depends on annotation sync being
|
||||||
|
enabled.
|
||||||
|
|
||||||
## Error Responses
|
## Error Responses
|
||||||
|
|
||||||
| Code | Description |
|
| Code | Description |
|
||||||
| ---- | ---------------------------- |
|
| ---- | ----------- |
|
||||||
|
| 400 | Invalid book UUID |
|
||||||
| 401 | Device authentication failed |
|
| 401 | Device authentication failed |
|
||||||
| 404 | Book or device not found |
|
| 404 | Book not found |
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# Resolve Book
|
||||||
|
|
||||||
|
Map a book's file SHA-256 to its Bookhoard UUID without touching progress
|
||||||
|
state. Used by devices to link a freshly downloaded book before their first
|
||||||
|
pull, so the device's first-page position is never pushed (which would
|
||||||
|
conflict with server-side progress for books already mid-read).
|
||||||
|
|
||||||
|
**Endpoint**: `GET /api/sync/koreader/resolve`
|
||||||
|
**Auth**: Required (Device authentication)
|
||||||
|
|
||||||
|
## Query Parameters
|
||||||
|
|
||||||
|
| Parameter | Type | Required | Description |
|
||||||
|
| --------- | ------ | -------- | ------------------------------------ |
|
||||||
|
| sha256 | string | Yes | File content hash (64 hex characters) |
|
||||||
|
|
||||||
|
Resolution is format-aware: the hash is checked against both
|
||||||
|
`media_items.file_sha256` and `media_item_formats.file_sha256`, so a
|
||||||
|
converted file (KEPUB/PDF) matches its media item too.
|
||||||
|
|
||||||
|
## Device Authentication
|
||||||
|
|
||||||
|
This endpoint requires device authentication (not user JWT). Devices
|
||||||
|
authenticate using their device credentials.
|
||||||
|
|
||||||
|
### Example Request
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/sync/koreader/resolve?sha256=e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
|
||||||
|
Authorization: Bearer {device_token}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Response (200 OK)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"book_uuid": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||||
|
"title": "Book Title",
|
||||||
|
"author": "Author Name"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Responses
|
||||||
|
|
||||||
|
| Code | Description |
|
||||||
|
| ---- | -------------------------------------------- |
|
||||||
|
| 400 | Missing or malformed `sha256` parameter |
|
||||||
|
| 401 | Device authentication failed |
|
||||||
|
| 404 | No book in the library matches the given hash |
|
||||||
@@ -1,49 +1,81 @@
|
|||||||
# Sync Bookmarks
|
# Sync Bookmarks
|
||||||
|
|
||||||
Sync bookmarks from KOReader device.
|
Sync bookmarks, notes, and highlights from a KOReader device (bidirectional — the response also returns the server's current state for the book so the device can reconcile).
|
||||||
|
|
||||||
**Endpoint**: `POST /api/sync/koreader/bookmarks`
|
**Endpoint**: `POST /api/sync/koreader/bookmarks`
|
||||||
**Auth**: Required (Device authentication)
|
**Auth**: Device token (Bearer)
|
||||||
|
|
||||||
## Device Authentication
|
|
||||||
|
|
||||||
This endpoint requires device authentication (not user JWT). Devices authenticate using their device credentials.
|
|
||||||
|
|
||||||
## Request Body
|
## Request Body
|
||||||
|
|
||||||
| Field | Type | Required | Description |
|
| Field | Type | Required | Description |
|
||||||
| --------- | ------------- | -------- | ------------------------- |
|
| ------------ | ------ | --------------------- | ----------------------------------------------------------------- |
|
||||||
| device_id | string (UUID) | Yes | Device UUID |
|
| book_uuid | string | one of uuid/sha | Book UUID (highest-confidence match) |
|
||||||
| bookmarks | array | Yes | Array of bookmark objects |
|
| book_sha256 | string | one of uuid/sha | Full-file SHA-256 (64 hex chars); format-aware (also matches `media_item_formats`, so a KEPUB/PDF download matches) |
|
||||||
|
| bookmarks | array | No | Bookmark objects |
|
||||||
|
| notes | array | No | Note objects |
|
||||||
|
| highlights | array | No | Highlight objects |
|
||||||
|
|
||||||
### Bookmark Object
|
At least one of `book_uuid` or `book_sha256` is required; `book_sha256` resolves through the shared BookResolver.
|
||||||
|
|
||||||
| Field | Type | Required | Description |
|
### Bookmark / Note / Highlight Object
|
||||||
| ---------------- | ------- | -------- | -------------------------- |
|
|
||||||
| book | string | Yes | Book identifier |
|
All three types share the same KOReader annotation shape:
|
||||||
| chapter | string | No | Chapter title |
|
|
||||||
| page | integer | No | Page number |
|
| Field | Type | Required | Description |
|
||||||
| position | float | Yes | Position in document (0-1) |
|
| ------------ | ------- | -------- | ---------------------------------------------------- |
|
||||||
| notes | string | No | Bookmark notes |
|
| chapter | int | No | Chapter index |
|
||||||
| highlighted_text | string | No | Highlighted text |
|
| datetime | string | No | ISO 8601 creation/edit timestamp |
|
||||||
| time | string | Yes | ISO 8601 timestamp |
|
| pos0 / pos1 | string | No | Start/end xpointer (or `page:N` / bare page) |
|
||||||
| created_at | string | Yes | ISO 8601 timestamp |
|
| page | int | No | Page number (fallback location when `pos0` is empty) |
|
||||||
|
| text | string | No | Highlighted text |
|
||||||
|
| notes | string | No | Note text attached to the annotation |
|
||||||
|
| type | string | No | Annotation type (`highlight`, `note`, `bookmark`) |
|
||||||
|
| color | string | No | Highlight color (highlights only) — KOReader palette name, see below |
|
||||||
|
| percentage | float | No | Position within the book (0-1) |
|
||||||
|
| book_sha256 | string | No | Per-annotation SHA-256; overrides the request-level book match |
|
||||||
|
| dedup_key | string | No | Stable echo key; an entry whose content is unchanged from what the server previously served is recognized as an echo rather than a new edit |
|
||||||
|
|
||||||
|
### Color Semantics
|
||||||
|
|
||||||
|
KOReader paints highlights from a fixed palette of color names; the web reader uses hex swatches. Colors are mapped at the boundary (unmappable values fall back to yellow on both sides):
|
||||||
|
|
||||||
|
| KOReader name | Web hex |
|
||||||
|
| ------------- | --------- |
|
||||||
|
| yellow, orange | `#ffd54f` |
|
||||||
|
| green, olive | `#a5d6a7` |
|
||||||
|
| cyan, blue | `#90caf9` |
|
||||||
|
| purple | `#ce93d8` |
|
||||||
|
| red | `#f48fb1` |
|
||||||
|
|
||||||
|
- An echo (device re-reporting an annotation it received from the server) carries **no color**, so the stored web color is never clobbered.
|
||||||
|
- A non-empty color means the user edited the highlight on the device; it is mapped to the nearest web swatch.
|
||||||
|
|
||||||
### Example Request
|
### Example Request
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"device_id": "550e8400-e29b-41d4-a716-446655440000",
|
"book_sha256": "64-hex-char-sha256",
|
||||||
"bookmarks": [
|
"bookmarks": [
|
||||||
{
|
{
|
||||||
"book": "book.epub",
|
"chapter": 3,
|
||||||
"chapter": "Chapter 1",
|
"datetime": "2026-08-20T10:00:00Z",
|
||||||
|
"pos0": "/body/Doc[4]/Sec[2]",
|
||||||
"page": 25,
|
"page": 25,
|
||||||
"position": 0.125,
|
"text": "",
|
||||||
"notes": "Important section",
|
"type": "bookmark",
|
||||||
"highlighted_text": "Text to remember",
|
"percentage": 0.125
|
||||||
"time": "2026-02-08T10:00:00Z",
|
}
|
||||||
"created_at": "2026-02-08T10:00:00Z"
|
],
|
||||||
|
"highlights": [
|
||||||
|
{
|
||||||
|
"datetime": "2026-08-20T10:05:00Z",
|
||||||
|
"pos0": "/body/Doc[4]/Sec[2]/text()[3]:0",
|
||||||
|
"pos1": "/body/Doc[4]/Sec[2]/text()[3]:42",
|
||||||
|
"text": "Text to remember",
|
||||||
|
"notes": "Why this matters",
|
||||||
|
"type": "highlight",
|
||||||
|
"color": "blue",
|
||||||
|
"dedup_key": "echo-key-from-server"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -53,15 +85,17 @@ This endpoint requires device authentication (not user JWT). Devices authenticat
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"message": "Bookmarks synced successfully",
|
"sync_status": "ok",
|
||||||
"synced_count": 1
|
"bookmarks_synced": 1,
|
||||||
|
"notes_synced": 0,
|
||||||
|
"highlights_synced": 1
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Error Responses
|
## Error Responses
|
||||||
|
|
||||||
| Code | Description |
|
| Code | Description |
|
||||||
| ---- | ---------------------------- |
|
| ---- | -------------------------------------------------- |
|
||||||
| 401 | Device authentication failed |
|
| 400 | Invalid request, or neither uuid nor SHA provided |
|
||||||
| 400 | Invalid request data |
|
| 401 | Missing/invalid device token |
|
||||||
| 404 | Device not found |
|
| 404 | Book not found by SHA-256 |
|
||||||
@@ -1,63 +1,87 @@
|
|||||||
# Sync Progress
|
# Sync Progress
|
||||||
|
|
||||||
Sync reading progress from KOReader device.
|
Push reading progress from a KOReader device.
|
||||||
|
|
||||||
**Endpoint**: `POST /api/sync/koreader/progress`
|
**Endpoint**: `POST /api/sync/koreader/progress`
|
||||||
**Auth**: Required (Device authentication)
|
**Auth**: Required (Device authentication — `Authorization: Bearer {device_token}`)
|
||||||
|
|
||||||
## Device Authentication
|
This is the device-native tier of the [Position
|
||||||
|
Contract](../progress/position-contract.md): the KOReader payload carries
|
||||||
This endpoint requires device authentication (not user JWT). Devices authenticate using their device credentials.
|
a CRE xpointer and the server converts it to the canonical standard CFI,
|
||||||
|
verifies it against the submitted `context_text`, and heals it on
|
||||||
|
mismatch — exactly like every other client.
|
||||||
|
|
||||||
## Request Body
|
## Request Body
|
||||||
|
|
||||||
| Field | Type | Required | Description |
|
| Field | Type | Required | Description |
|
||||||
| --------- | ------------- | -------- | ------------------------- |
|
| --------- | ------ | -------- | ----------- |
|
||||||
| device_id | string (UUID) | Yes | Device UUID |
|
| `books` | array | Yes | One book object (the reference client sends a single-element array). |
|
||||||
| progress | array | Yes | Array of progress objects |
|
| `sync_mode`| string | No | `immediate` (default) or `manual`. |
|
||||||
|
|
||||||
### Progress Object
|
### Book Object
|
||||||
|
|
||||||
| Field | Type | Required | Description |
|
| Field | Type | Required | Description |
|
||||||
| ----------- | ------- | -------- | ---------------------------------- |
|
| ----- | ---- | -------- | ----------- |
|
||||||
| book | string | Yes | Book identifier (filename or UUID) |
|
| `uuid` | string (UUID) | No | Bookhoard UUID, once the device has linked the book via [Resolve Book](resolve_book.md). |
|
||||||
| percent | float | Yes | Progress percentage (0-100) |
|
| `sha256` | string | Yes | File content hash (64 hex chars) — the primary book identity. |
|
||||||
| page | integer | No | Current page number |
|
| `title` | string | No | Document title. |
|
||||||
| total_pages | integer | No | Total pages in document |
|
| `authors` | array | No | Author names. |
|
||||||
| date_read | string | No | ISO 8601 timestamp of last read |
|
| `percentage` | float | Yes | Position as a fraction of the book (0..1). |
|
||||||
| updated_at | string | Yes | ISO 8601 timestamp |
|
| `context_text` | string | No | Up to 100 whitespace-normalized chars from the current position — enables the server's verification/healing. Strongly recommended. |
|
||||||
|
| `page` | int | No | Current page (fixed-layout books). |
|
||||||
|
| `total_pages` | int | No | Page count (fixed-layout books). |
|
||||||
|
| `epubcfi` | string | Reflowable only | **A CRE xpointer** (`/body/DocFragment[N]/body/...`), not a CFI — the field name is historical. Fixed-layout books must omit it and carry their position in `page`/`total_pages`. |
|
||||||
|
| `file_path` | string | No | Device-local file path (informational). |
|
||||||
|
| `device_info` | object | No | `{ koreader_version, device_model }`. |
|
||||||
|
|
||||||
### Example Request
|
### Example Request
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/sync/koreader/progress
|
||||||
|
Authorization: Bearer {device_token}
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"device_id": "550e8400-e29b-41d4-a716-446655440000",
|
"books": [
|
||||||
"progress": [
|
|
||||||
{
|
{
|
||||||
"book": "book.epub",
|
"uuid": "774641f9-317b-4087-8e04-53bb4392ae56",
|
||||||
"percent": 75.5,
|
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||||
"page": 150,
|
"title": "1984",
|
||||||
"total_pages": 200,
|
"authors": ["George Orwell"],
|
||||||
"date_read": "2026-02-08T10:00:00Z",
|
"percentage": 0.045,
|
||||||
"updated_at": "2026-02-08T10:00:00Z"
|
"context_text": "from day to day, but there was none in which Goldstein was not the principal figure. He was the prim",
|
||||||
|
"page": 14,
|
||||||
|
"total_pages": 311,
|
||||||
|
"epubcfi": "/body/DocFragment[1]/body/p[29]/text().598",
|
||||||
|
"device_info": {
|
||||||
|
"koreader_version": "v2026.07.1",
|
||||||
|
"device_model": "emulator"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"sync_mode": "immediate"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Response (200 OK)
|
## Response (202 Accepted)
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"message": "Progress synced successfully",
|
"sync_status": "ok",
|
||||||
"synced_count": 1
|
"books_synced": 1,
|
||||||
|
"timestamp": "2026-09-26T21:25:09Z"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Per-book results and any detected sync conflicts are carried in
|
||||||
|
`book_results` and `conflicts` when present.
|
||||||
|
|
||||||
## Error Responses
|
## Error Responses
|
||||||
|
|
||||||
| Code | Description |
|
| Code | Description |
|
||||||
| ---- | ---------------------------- |
|
| ---- | ----------- |
|
||||||
|
| 400 | Invalid request format |
|
||||||
| 401 | Device authentication failed |
|
| 401 | Device authentication failed |
|
||||||
| 400 | Invalid request data |
|
| 500 | Database error |
|
||||||
| 404 | Device not found |
|
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Retrieve all libraries visible to the current user.
|
Retrieve all libraries visible to the current user.
|
||||||
|
|
||||||
**Endpoint**: `GET /api/libraries/visible`
|
**Endpoint**: `GET /api/libraries/visibility`
|
||||||
**Auth**: Required
|
**Auth**: Required
|
||||||
|
|
||||||
## Request Headers
|
## Request Headers
|
||||||
@@ -14,26 +14,33 @@ Retrieve all libraries visible to the current user.
|
|||||||
### Example Request
|
### Example Request
|
||||||
|
|
||||||
```http
|
```http
|
||||||
GET /api/libraries/visible
|
GET /api/libraries/visibility
|
||||||
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
||||||
```
|
```
|
||||||
|
|
||||||
## Response (200 OK)
|
## Response (200 OK)
|
||||||
|
|
||||||
|
A top-level JSON **array** of library rows:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
[
|
||||||
"libraries": [
|
{
|
||||||
{
|
"id": "uuid",
|
||||||
"id": "uuid",
|
"name": "My Ebooks",
|
||||||
"name": "My Ebooks",
|
"description": "Ebook collection",
|
||||||
"description": "Ebook collection",
|
"library_type_id": "uuid",
|
||||||
"type_name": "ebooks",
|
"created_by_admin_id": "uuid",
|
||||||
"is_visible": true
|
"created_at": "2026-01-31T10:00:00Z",
|
||||||
}
|
"updated_at": "2026-01-31T10:00:00Z",
|
||||||
]
|
"type_name": "ebooks",
|
||||||
}
|
"type_description": "Ebook libraries",
|
||||||
|
"is_visible": true
|
||||||
|
}
|
||||||
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Nullable columns (`description`, `type_description`) serialize as `null` when unset. Timestamps are RFC 3339.
|
||||||
|
|
||||||
## Error Responses
|
## Error Responses
|
||||||
|
|
||||||
| Code | Description |
|
| Code | Description |
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# Deleted Annotations History
|
||||||
|
|
||||||
|
List, restore, or permanently delete tombstoned annotations (highlights,
|
||||||
|
notes, bookmarks) for a book. Deletions — from the web or propagated from a
|
||||||
|
synced device — are soft-deleted and retained for the sync retention window
|
||||||
|
(default 30 days), powering the book page's "Recently deleted" list. A
|
||||||
|
restore returns the row to the active set on every synced device; a purge
|
||||||
|
removes it immediately and irreversibly.
|
||||||
|
|
||||||
|
All endpoints require user JWT authentication and operate only on the
|
||||||
|
caller's own annotations.
|
||||||
|
|
||||||
|
## List Deleted Annotations
|
||||||
|
|
||||||
|
**Endpoint**: `GET /api/media-items/:id/annotations/deleted`
|
||||||
|
|
||||||
|
Returns tombstoned annotations for the book, newest deletion first.
|
||||||
|
|
||||||
|
### Response (200 OK)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"deleted_annotations": [
|
||||||
|
{
|
||||||
|
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"annotation_type": "highlight",
|
||||||
|
"display_text": "the chosen text",
|
||||||
|
"secondary_text": "user note",
|
||||||
|
"color": "#ffd54f",
|
||||||
|
"deleted_at": "2026-08-22T15:04:05Z",
|
||||||
|
"created_at": "2026-08-01T10:00:00Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Description |
|
||||||
|
| --------------- | ------------------------------------------------------ |
|
||||||
|
| annotation_type | `highlight`, `note`, or `bookmark` |
|
||||||
|
| display_text | Highlighted text / note content / bookmark title |
|
||||||
|
| secondary_text | Note text (highlights) or notes field (bookmarks) |
|
||||||
|
|
||||||
|
## Restore Deleted Annotation
|
||||||
|
|
||||||
|
**Endpoint**: `POST /api/media-items/:id/annotations/:annotationId/restore`
|
||||||
|
|
||||||
|
Body (or query param) `annotation_type` must be `highlight`, `note`, or
|
||||||
|
`bookmark`. Clears the tombstone; the annotation reappears in the active
|
||||||
|
set and re-syncs to devices on their next pull.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "annotation_type": "highlight" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response (200 OK)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "restored": true }
|
||||||
|
```
|
||||||
|
|
||||||
|
404 when no matching *deleted* annotation exists for this user and book.
|
||||||
|
|
||||||
|
## Permanently Delete Annotation
|
||||||
|
|
||||||
|
**Endpoint**: `DELETE /api/media-items/:id/annotations/:annotationId?annotation_type=highlight|note|bookmark`
|
||||||
|
|
||||||
|
Removes the tombstoned row from the history immediately. Irreversible —
|
||||||
|
unlike the tombstone itself, which is restorable until the retention window
|
||||||
|
lapses and the daily maintenance sweep purges it.
|
||||||
|
|
||||||
|
### Response (200 OK)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "purged": true }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Responses
|
||||||
|
|
||||||
|
| Code | Description |
|
||||||
|
| ---- | -------------------------------------------------- |
|
||||||
|
| 400 | Invalid IDs or missing/unknown `annotation_type` |
|
||||||
|
| 401 | Not authenticated |
|
||||||
|
| 404 | No matching deleted annotation |
|
||||||
@@ -2,15 +2,19 @@
|
|||||||
|
|
||||||
Download a media item file (EPUB, PDF, etc.) from the Bookhoard server.
|
Download a media item file (EPUB, PDF, etc.) from the Bookhoard server.
|
||||||
|
|
||||||
**Endpoint**: `GET /api/media-items/:uuid/download`
|
Book files are served by the authenticated file route, the same one the web reader uses. Build the URL from the media item's `library_id` and relative `file_path` (both returned by the media item list/get endpoints):
|
||||||
**Auth**: None (public endpoint for Kobo devices)
|
|
||||||
**Content-Type**: Binary file download
|
**Endpoint**: `GET /uploads/library-{library_id}/{file_path}`
|
||||||
|
**Auth**: Required (JWT - Bearer header or session cookie)
|
||||||
|
|
||||||
|
The `file_path` segments are URL-escaped individually; slashes are preserved. `cover_image_path` uses the same route.
|
||||||
|
|
||||||
## Path Parameters
|
## Path Parameters
|
||||||
|
|
||||||
| Parameter | Type | Required | Description |
|
| Parameter | Type | Required | Description |
|
||||||
| --------- | ------ | -------- | --------------- |
|
| ----------- | ------ | -------- | ------------------------------------ |
|
||||||
| uuid | string | Yes | Media item UUID |
|
| library_id | string | Yes | Library UUID (the item's library) |
|
||||||
|
| file_path | string | Yes | The item's relative `file_path` |
|
||||||
|
|
||||||
## Response
|
## Response
|
||||||
|
|
||||||
@@ -18,25 +22,27 @@ Download a media item file (EPUB, PDF, etc.) from the Bookhoard server.
|
|||||||
|
|
||||||
**Response Headers**:
|
**Response Headers**:
|
||||||
|
|
||||||
- `Content-Type`: `application/epub+zip`, `application/pdf`, or appropriate MIME type
|
- `Content-Type`: MIME type by file extension (`application/epub+zip`, `application/pdf`, …; `application/octet-stream` fallback)
|
||||||
- `Content-Disposition`: `attachment; filename="filename.epub"`
|
- `Cache-Control`: `public, max-age=86400`
|
||||||
|
|
||||||
## Error Responses
|
## Error Responses
|
||||||
|
|
||||||
| Code | Description |
|
| Code | Description |
|
||||||
| ---- | --------------------------------- |
|
| ---- | --------------------------- |
|
||||||
| 404 | Media item not found |
|
| 400 | Invalid library ID or path |
|
||||||
| 500 | Server error during file download |
|
| 401 | Missing/invalid token |
|
||||||
|
| 404 | File not found on disk |
|
||||||
|
|
||||||
## Example
|
## Example
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -O http://localhost:8765/api/media-items/550e8400-e29b-41d4-a716-446655440000/download
|
curl -O -H "Authorization: Bearer $TOKEN" \
|
||||||
|
"http://localhost:8765/uploads/library/550e8400-.../books/1984.epub"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
(URL shape: `/uploads/library-{uuid}/{escaped-relative-path}`.)
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- **Public endpoint**: No authentication required for Kobo device downloads
|
- **Do not rely on `GET /api/media-items/:id/download`** — it appears in older docs but is **not registered**; `MediaHandler.DownloadBook` exists as dead code. Use the file route above.
|
||||||
- **File format**: Returns the original file format (EPUB, PDF, etc.)
|
- OPDS-capable devices may alternatively use the device-authenticated `GET /opds/devices/{deviceId}/download/{bookId}`, which supports on-the-fly format conversion (epub, kepub, pdf, cbz).
|
||||||
- **Kobo integration**: Designed for direct downloads from Kobo e-readers
|
|
||||||
- **Cover images**: Use `/api/media-items/:uuid/cover` for cover images
|
|
||||||
@@ -1,17 +1,22 @@
|
|||||||
# List Media Items
|
# List Media Items
|
||||||
|
|
||||||
Retrieve a paginated list of media items from a library.
|
Retrieve a paginated list of media items, scoped to a library or across all libraries.
|
||||||
|
|
||||||
**Endpoint**: `GET /api/media-items`
|
**Endpoint**: `GET /api/media-items`
|
||||||
**Auth**: Required
|
**Auth**: Required
|
||||||
|
|
||||||
## Query Parameters
|
## Query Parameters
|
||||||
|
|
||||||
| Parameter | Type | Required | Description |
|
| Parameter | Type | Required | Description |
|
||||||
| ---------- | ------- | -------- | ----------------------------------------------- |
|
| ---------- | ------ | -------- | ------------------------------------------------------ |
|
||||||
| library_id | string | Yes | Library UUID |
|
| library_id | string | No | Library UUID. If omitted, items from all libraries are returned |
|
||||||
| limit | integer | No | Number of items to return (max 100, default 20) |
|
| limit | int | No | Items to return (default 50, max 1000) |
|
||||||
| offset | integer | No | Number of items to skip |
|
| offset | int | No | Items to skip (must be >= 0) |
|
||||||
|
| sort | string | No | Sort expression, default `created_at DESC` |
|
||||||
|
|
||||||
|
### Allowed sort expressions
|
||||||
|
|
||||||
|
`created_at`, `title`, `author`, `series`, `date_published`, `copyright_year`, `page_count`, `genre` — each with ` ASC` or ` DESC` (e.g. `title ASC`). Any other value silently falls back to `created_at DESC`.
|
||||||
|
|
||||||
## Request Headers
|
## Request Headers
|
||||||
|
|
||||||
@@ -22,46 +27,121 @@ Retrieve a paginated list of media items from a library.
|
|||||||
### Example Request
|
### Example Request
|
||||||
|
|
||||||
```http
|
```http
|
||||||
GET /api/media-items?library_id=uuid&limit=20&offset=0
|
GET /api/media-items?library_id=uuid&limit=20&offset=0&sort=title%20ASC
|
||||||
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
||||||
```
|
```
|
||||||
|
|
||||||
## Response (200 OK)
|
## Response (200 OK)
|
||||||
|
|
||||||
|
The response body is `{"data": [...]}` in both modes. The item shape differs by mode.
|
||||||
|
|
||||||
|
**No total is returned** — page until fewer items than `limit` come back.
|
||||||
|
|
||||||
|
### With `library_id` — full database rows
|
||||||
|
|
||||||
|
Nullable columns serialize as `null`.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"media_items": [
|
"data": [
|
||||||
{
|
{
|
||||||
"id": "uuid",
|
"id": "uuid",
|
||||||
"library_id": "uuid",
|
"library_id": "uuid",
|
||||||
"title": "Book Title",
|
"title": "Book Title",
|
||||||
"author": "Author Name",
|
"author": "Author Name",
|
||||||
|
"isbn": "978-...",
|
||||||
"description": "Book description",
|
"description": "Book description",
|
||||||
"file_path": "/path/to/book.epub",
|
"file_path": "relative/path/book.epub",
|
||||||
"file_size": 1024000,
|
"file_size": 1024000,
|
||||||
"mime_type": "application/epub+zip",
|
"mime_type": "application/epub+zip",
|
||||||
"cover_image_path": "/path/to/cover.jpg",
|
"cover_image_path": "relative/path/cover.jpg",
|
||||||
"series": "Series Name",
|
"series": "Series Name",
|
||||||
"series_number": 1,
|
"series_number": 1,
|
||||||
"tags": ["sci-fi", "space opera"],
|
"tags": ["sci-fi"],
|
||||||
"tags_search": ["sci fi", "space opera"],
|
"asin": null,
|
||||||
"contributors": ["Author Name", "ACME CORP."],
|
"date_published": "2023-06-01",
|
||||||
"contributors_search": ["author name", "acme corp"],
|
"publisher": null,
|
||||||
|
"contributors": ["Author Name"],
|
||||||
"language": "en",
|
"language": "en",
|
||||||
|
"edition": null,
|
||||||
"page_count": 350,
|
"page_count": 350,
|
||||||
"genre": "Science Fiction",
|
"genre": "Science Fiction",
|
||||||
"copyright_year": 2023,
|
"copyright_year": 2023,
|
||||||
"created_at": "2026-01-31T10:00:00Z"
|
"goodreads_id": null,
|
||||||
|
"openlibrary_id": null,
|
||||||
|
"google_books_id": null,
|
||||||
|
"added_by_admin_id": "uuid",
|
||||||
|
"created_at": "2026-01-31T10:00:00Z",
|
||||||
|
"imported_at": "2026-01-31T10:00:00Z",
|
||||||
|
"updated_at": "2026-01-31T10:00:00Z",
|
||||||
|
"format_group": "epub",
|
||||||
|
"format_mimetype": "application/epub+zip",
|
||||||
|
"is_reflowable": true,
|
||||||
|
"has_fixed_layout": false,
|
||||||
|
"total_characters": 480000,
|
||||||
|
"chapter_count": 24
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
"total": 100
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: in this mode `file_path` and `cover_image_path` are the raw relative storage paths, not URLs.
|
||||||
|
|
||||||
|
### Without `library_id` — curated items with resolved URLs
|
||||||
|
|
||||||
|
Across all libraries; file and cover paths are resolved to fetchable URL paths (`/uploads/...` or library-scoped paths):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"library_id": "uuid",
|
||||||
|
"title": "Book Title",
|
||||||
|
"author": "Author Name",
|
||||||
|
"isbn": "978-...",
|
||||||
|
"description": "Book description",
|
||||||
|
"file_path": "/api/libraries/<uuid>/files/...",
|
||||||
|
"file_size": 1024000,
|
||||||
|
"mime_type": "application/epub+zip",
|
||||||
|
"cover_image_path": "/api/libraries/<uuid>/files/.../cover.jpg",
|
||||||
|
"series": "Series Name",
|
||||||
|
"series_number": 1,
|
||||||
|
"tags": ["sci-fi"],
|
||||||
|
"asin": null,
|
||||||
|
"date_published": "2023-06-01",
|
||||||
|
"publisher": null,
|
||||||
|
"contributors": ["Author Name"],
|
||||||
|
"language": "en",
|
||||||
|
"edition": null,
|
||||||
|
"page_count": 350,
|
||||||
|
"genre": "Science Fiction",
|
||||||
|
"created_at": "2026-01-31T10:00:00Z",
|
||||||
|
"updated_at": "2026-01-31T10:00:00Z",
|
||||||
|
"format_group": "epub",
|
||||||
|
"manga_type": null,
|
||||||
|
"reading_direction": null,
|
||||||
|
"series_count": null,
|
||||||
|
"volume": null,
|
||||||
|
"imprint": null,
|
||||||
|
"age_rating": null,
|
||||||
|
"web_url": null,
|
||||||
|
"metadata_notes": null,
|
||||||
|
"community_rating": null,
|
||||||
|
"story_arc": null,
|
||||||
|
"is_black_and_white": false,
|
||||||
|
"alternate_info": null,
|
||||||
|
"scan_information": null,
|
||||||
|
"summary": null
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Error Responses
|
## Error Responses
|
||||||
|
|
||||||
| Code | Description |
|
| Code | Description |
|
||||||
| ---- | ----------------------------------------- |
|
| ---- | ------------------------------------------ |
|
||||||
| 400 | Invalid query parameters |
|
| 400 | Invalid `library_id`, `offset` < 0 |
|
||||||
| 401 | Invalid or expired token |
|
| 401 | Invalid or expired token |
|
||||||
| 403 | User does not have access to this library |
|
| 500 | Query failure (returned as `{"error": …}`) |
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# Delete Media Item Progress
|
||||||
|
|
||||||
|
Delete the stored reading progress for a media item.
|
||||||
|
|
||||||
|
**Endpoint**: `DELETE /api/media-items/:id/progress`
|
||||||
|
**Auth**: Required (Bearer token)
|
||||||
|
|
||||||
|
## Path Parameters
|
||||||
|
|
||||||
|
| Parameter | Type | Required | Description |
|
||||||
|
| --------- | ------------- | -------- | ---------------- |
|
||||||
|
| id | string (UUID) | Yes | Media item UUID |
|
||||||
|
|
||||||
|
### Example Request
|
||||||
|
|
||||||
|
```http
|
||||||
|
DELETE /api/media-items/774641f9-317b-4087-8e04-53bb4392ae56/progress
|
||||||
|
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Response (200 OK)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": "reading progress deleted"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Responses
|
||||||
|
|
||||||
|
| Code | Description |
|
||||||
|
| ---- | ----------- |
|
||||||
|
| 400 | Invalid media item id |
|
||||||
|
| 401 | Invalid or expired token |
|
||||||
|
| 500 | Database error |
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
# Delete Reading Progress
|
|
||||||
|
|
||||||
Delete reading progress for a media item.
|
|
||||||
|
|
||||||
**Endpoint**: `DELETE /api/media-items/{media_id}/progress`
|
|
||||||
**Auth**: Required
|
|
||||||
|
|
||||||
## Path Parameters
|
|
||||||
|
|
||||||
| Parameter | Type | Required | Description |
|
|
||||||
| --------- | ------ | -------- | --------------- |
|
|
||||||
| media_id | string | Yes | Media item UUID |
|
|
||||||
|
|
||||||
## Request Headers
|
|
||||||
|
|
||||||
| Header | Type | Required | Description |
|
|
||||||
| ------------- | ------ | -------- | ------------ |
|
|
||||||
| Authorization | string | Yes | Bearer token |
|
|
||||||
|
|
||||||
### Example Request
|
|
||||||
|
|
||||||
```http
|
|
||||||
DELETE /api/media-items/uuid/progress
|
|
||||||
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
|
||||||
```
|
|
||||||
|
|
||||||
## Response (204 No Content)
|
|
||||||
|
|
||||||
Progress deleted successfully.
|
|
||||||
|
|
||||||
## Error Responses
|
|
||||||
|
|
||||||
| Code | Description |
|
|
||||||
| ---- | ------------------------ |
|
|
||||||
| 401 | Invalid or expired token |
|
|
||||||
| 404 | Media item not found |
|
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
# Get Media Item Progress
|
||||||
|
|
||||||
|
Get the stored reading progress for a media item, plus the
|
||||||
|
server-derived restore handles for reflowable books.
|
||||||
|
|
||||||
|
**Endpoint**: `GET /api/media-items/:id/progress`
|
||||||
|
**Auth**: Required (Bearer token)
|
||||||
|
|
||||||
|
See [Position Contract](position-contract.md) for the semantics of every
|
||||||
|
field — what is verified, what the currencies are, and how clients
|
||||||
|
should restore.
|
||||||
|
|
||||||
|
## Path Parameters
|
||||||
|
|
||||||
|
| Parameter | Type | Required | Description |
|
||||||
|
| --------- | ------------- | -------- | --------------- |
|
||||||
|
| id | string (UUID) | Yes | Media item UUID |
|
||||||
|
|
||||||
|
### Example Request
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/media-items/774641f9-317b-4087-8e04-53bb4392ae56/progress
|
||||||
|
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Response (200 OK) — no progress stored
|
||||||
|
|
||||||
|
When the item has no progress row, an empty fixed-layout-style stub is
|
||||||
|
returned (not 404):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"current_page": 0,
|
||||||
|
"total_pages": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Response (200 OK) — progress stored
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "e8239659-0ed6-42ae-a946-8440d7655b42",
|
||||||
|
"media_item_id": "774641f9-317b-4087-8e04-53bb4392ae56",
|
||||||
|
"user_id": "1b64992e-3408-4d84-9e03-e2dc4950e1dd",
|
||||||
|
"current_page": null,
|
||||||
|
"total_pages": null,
|
||||||
|
"last_read_at": "2026-09-26T14:52:34.806446Z",
|
||||||
|
"percentage": 0.045,
|
||||||
|
"character_offset": 16375,
|
||||||
|
"epubcfi": "epubcfi(/6/4!/4/8[_idContainer003]/58/1:598)",
|
||||||
|
"chapter": null,
|
||||||
|
"chapter_progress": null,
|
||||||
|
"format_group": "reflowable",
|
||||||
|
"total_characters": 592216,
|
||||||
|
"chapter_count": 1,
|
||||||
|
"last_sync_device": "web",
|
||||||
|
"last_sync_source": "koreader",
|
||||||
|
"last_sync_timestamp": "2026-09-26T14:52:34.806446Z",
|
||||||
|
"css_selector": "body>div:nth-child(4)>p:nth-child(29)",
|
||||||
|
"anchor_href": "1984.xhtml",
|
||||||
|
"char_offset": 598,
|
||||||
|
"context_text": "from day to day, but there was none in which Goldstein was not the principal figure. He was the prim"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Field Reference
|
||||||
|
|
||||||
|
Base fields (always present when a row exists):
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
| ----- | ---- | ----------- |
|
||||||
|
| `percentage` | float | Position as a fraction of the whole book (0..1). |
|
||||||
|
| `epubcfi` | string | The stored canonical standard CFI. **Spine steps index the OPF spine as written, including `linear="no"` items — do not resolve them against a readium reading order.** See the [Position Contract](position-contract.md#spine-numbering-hazard--read-this-before-parsing-a-stored-cfi). |
|
||||||
|
| `context_text` | string | The stored verification context (≤100 whitespace-normalized chars from the anchor). |
|
||||||
|
| `character_offset` | int | Book-wide rune offset. Internal currency — consistent with `total_characters`. |
|
||||||
|
| `current_page`, `total_pages` | int | Fixed-layout page position (null for reflowable). |
|
||||||
|
| `chapter`, `chapter_progress` | int, float | Chapter index and within-chapter fraction, when known. |
|
||||||
|
| `format_group` | string | `reflowable`, `fixed_layout`, `comic_archive`, … Gates the restore handles. |
|
||||||
|
| `total_characters`, `chapter_count` | int | Book metrics, for client-side fraction math. |
|
||||||
|
| `last_sync_device`, `last_sync_source`, `last_sync_timestamp` | — | Which client last wrote the row. |
|
||||||
|
|
||||||
|
Restore handles (conditional — served only for convertible reflowable
|
||||||
|
books whose stored anchor re-resolves at GET time):
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
| ----- | ---- | ----------- |
|
||||||
|
| `anchor_href` | string | The spine document containing the anchor (e.g. `1984.xhtml`). **Resolve your resource by this**, never by the CFI's spine step. |
|
||||||
|
| `css_selector` | string | Body-relative chain to the anchor's block element. |
|
||||||
|
| `char_offset` | int | Anchor offset within the block's concatenated text, in UTF-16 code units. |
|
||||||
|
| `epubcfi` | string | Re-served (possibly healed) canonical CFI — present whenever re-verification produced one. |
|
||||||
|
|
||||||
|
## Error Responses
|
||||||
|
|
||||||
|
| Code | Description |
|
||||||
|
| ---- | ----------- |
|
||||||
|
| 400 | Invalid media item id |
|
||||||
|
| 401 | Invalid or expired token |
|
||||||
|
| 500 | Database error |
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
# Get Reading Progress
|
|
||||||
|
|
||||||
Retrieve reading progress for a specific media item.
|
|
||||||
|
|
||||||
**Endpoint**: `GET /api/media-items/{media_id}/progress`
|
|
||||||
**Auth**: Required
|
|
||||||
|
|
||||||
## Path Parameters
|
|
||||||
|
|
||||||
| Parameter | Type | Required | Description |
|
|
||||||
| --------- | ------ | -------- | --------------- |
|
|
||||||
| media_id | string | Yes | Media item UUID |
|
|
||||||
|
|
||||||
## Request Headers
|
|
||||||
|
|
||||||
| Header | Type | Required | Description |
|
|
||||||
| ------------- | ------ | -------- | ------------ |
|
|
||||||
| Authorization | string | Yes | Bearer token |
|
|
||||||
|
|
||||||
### Example Request
|
|
||||||
|
|
||||||
```http
|
|
||||||
GET /api/media-items/uuid/progress
|
|
||||||
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
|
||||||
```
|
|
||||||
|
|
||||||
## Response (200 OK)
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"media_item_id": "uuid",
|
|
||||||
"user_id": "uuid",
|
|
||||||
"current_page": 45,
|
|
||||||
"total_pages": 200,
|
|
||||||
"percentage": 0.225,
|
|
||||||
"character_offset": 15432,
|
|
||||||
"epubcfi": "epubcfi(/6/4/2:15)",
|
|
||||||
"chapter": 3,
|
|
||||||
"chapter_progress": 0.5,
|
|
||||||
"last_read_at": "2026-01-31T10:00:00Z",
|
|
||||||
"format_group": "reflowable",
|
|
||||||
"viewport_y": 0.12,
|
|
||||||
"zoom_level": 1.0
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Error Responses
|
|
||||||
|
|
||||||
| Code | Description |
|
|
||||||
| ---- | ------------------------ |
|
|
||||||
| 401 | Invalid or expired token |
|
|
||||||
| 404 | Media item not found |
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
# Get Universal Progress
|
|
||||||
|
|
||||||
Get universal (device-agnostic) reading progress for a media item.
|
|
||||||
|
|
||||||
**Endpoint**: `GET /api/progress/:id`
|
|
||||||
**Auth**: Required
|
|
||||||
|
|
||||||
## Path Parameters
|
|
||||||
|
|
||||||
| Parameter | Type | Required | Description |
|
|
||||||
| --------- | ------------- | -------- | --------------- |
|
|
||||||
| id | string (UUID) | Yes | Media item UUID |
|
|
||||||
|
|
||||||
## Request Headers
|
|
||||||
|
|
||||||
| Header | Type | Required | Description |
|
|
||||||
| ------------- | ------ | -------- | ------------ |
|
|
||||||
| Authorization | string | Yes | Bearer token |
|
|
||||||
|
|
||||||
### Example Request
|
|
||||||
|
|
||||||
```http
|
|
||||||
GET /api/progress/550e8400-e29b-41d4-a716-446655440000
|
|
||||||
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
|
||||||
```
|
|
||||||
|
|
||||||
## Response (200 OK)
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"media_item_id": "uuid",
|
|
||||||
"percentage": 75.5,
|
|
||||||
"position": 1234,
|
|
||||||
"page": 150,
|
|
||||||
"total_pages": 200,
|
|
||||||
"finished": false,
|
|
||||||
"updated_at": "2026-02-08T10:00:00Z",
|
|
||||||
"device": {
|
|
||||||
"id": "device-uuid",
|
|
||||||
"name": "My Kobo"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Error Responses
|
|
||||||
|
|
||||||
| Code | Description |
|
|
||||||
| ---- | ------------------------ |
|
|
||||||
| 401 | Invalid or expired token |
|
|
||||||
| 404 | Media item not found |
|
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
# Position Contract
|
||||||
|
|
||||||
|
How reading positions are represented, submitted, verified, and restored
|
||||||
|
across clients. This is the contract every client speaks — readium-based
|
||||||
|
apps, web (foliate), KOReader, and Kobo devices alike.
|
||||||
|
|
||||||
|
**The server is the position authority.** Every submission is
|
||||||
|
independently verified against the book itself before it is stored; a
|
||||||
|
client whose locator math is wrong cannot poison the stored position.
|
||||||
|
In exchange, the server hands back handles clients can apply directly,
|
||||||
|
so they never have to parse or trust CFIs themselves.
|
||||||
|
|
||||||
|
## The three-tier submission
|
||||||
|
|
||||||
|
Clients submit what their renderer can reliably observe. All three tiers
|
||||||
|
are accepted on `PUT /api/media-items/:id/progress` (and on the KOReader
|
||||||
|
device endpoints):
|
||||||
|
|
||||||
|
| Tier | Field | Currency | Notes |
|
||||||
|
| ------ | -------------- | ----------------------------------------------------- | ----- |
|
||||||
|
| 1 | `percentage` | Float 0..1 of the whole book | Universal. The only field every client can supply. |
|
||||||
|
| 2 | `context_text` | Up to 100 chars, whitespace-normalized, starting at the anchor | The verification anchor. The server extracts the text at the submitted structural anchor and compares. |
|
||||||
|
| 3 | `epubcfi` | Standard wrapped CFI, terminals in UTF-16 code units | Optional structural anchor. KOReader submits a CRE xpointer and the server converts it. |
|
||||||
|
|
||||||
|
Tier 2 is what makes the system self-correcting: percentages alone
|
||||||
|
cannot distinguish "the reader's locator is right" from "the reader
|
||||||
|
silently reported wherever it is currently scrolled".
|
||||||
|
|
||||||
|
## Verification and healing (ingest side)
|
||||||
|
|
||||||
|
On every progress save for a convertible reflowable book that carries a
|
||||||
|
`context_text`, the server:
|
||||||
|
|
||||||
|
1. Parses the submitted `epubcfi` (if any) and resolves it against the
|
||||||
|
book's own XHTML.
|
||||||
|
2. Extracts the text at the resolved anchor and cross-checks it with the
|
||||||
|
submitted `context_text`.
|
||||||
|
3. On a mismatch — or an unresolvable anchor, or no anchor at all —
|
||||||
|
heals the position by text search, using the submitted `percentage`
|
||||||
|
to disambiguate repeated phrases. The healed CFI and a recomputed
|
||||||
|
percentage are stored in place of the submitted ones.
|
||||||
|
4. Refreshes the book-wide `character_offset` column from the verified
|
||||||
|
anchor on every verified save, so it never goes stale behind the
|
||||||
|
anchor.
|
||||||
|
|
||||||
|
If verification fails outright (book file unreadable, context not found
|
||||||
|
and percentage cannot disambiguate), the error is logged and the
|
||||||
|
submission is stored as-is — verification never rejects a save, it only
|
||||||
|
corrects.
|
||||||
|
|
||||||
|
## The restore handles
|
||||||
|
|
||||||
|
`GET /api/media-items/:id/progress` serves, alongside the raw stored
|
||||||
|
fields, the server-derived handles for reflowable books with a
|
||||||
|
resolvable anchor:
|
||||||
|
|
||||||
|
| Field | Currency | Meaning |
|
||||||
|
| ------------- | ----------------------- | ------- |
|
||||||
|
| `anchor_href` | — | The spine document the anchor lives in (e.g. `1984.xhtml`). **This is how a client finds the right resource.** |
|
||||||
|
| `css_selector`| — | Body-relative `tag:nth-child(k)` chain of the anchor's block element (e.g. `body>div:nth-child(4)>p:nth-child(29)`). |
|
||||||
|
| `char_offset` | UTF-16 code units | Offset of the anchor within the concatenated text of that block. |
|
||||||
|
| `epubcfi` | UTF-16 terminals | The stored canonical CFI — re-served healed if the GET-time re-verification improved it. |
|
||||||
|
| `context_text`| — | The stored verification context (≤100 normalized chars from the anchor). |
|
||||||
|
|
||||||
|
**Recommended restore sequence for a client:**
|
||||||
|
|
||||||
|
1. Open the book and jump to the stored `percentage` (coarse floor).
|
||||||
|
2. Resolve `anchor_href` against your own spine (an ends-with match on
|
||||||
|
document hrefs) and open that document if you are not already there.
|
||||||
|
3. Query `css_selector` in that document, walk its text nodes counting
|
||||||
|
UTF-16 units to `char_offset`, and scroll that position into view.
|
||||||
|
4. If the anchor cannot be measured (renderer-specific laziness), the
|
||||||
|
percentage floor stands.
|
||||||
|
|
||||||
|
## SPINE NUMBERING HAZARD — read this before parsing a stored CFI
|
||||||
|
|
||||||
|
**`epubcfi` spine steps index the OPF spine AS WRITTEN, including
|
||||||
|
`linear="no"` items.** Several rendering engines (notably readium)
|
||||||
|
number their reading order EXCLUDING `linear="no"` items. When a book's
|
||||||
|
cover (or any other item) is `linear="no"`, the two numberings differ by
|
||||||
|
a constant offset from that item onward — a client resolving a stored
|
||||||
|
CFI's spine step against its own numbering lands in the WRONG DOCUMENT.
|
||||||
|
|
||||||
|
This is not hypothetical: "1984" epubs commonly have a `linear="no"`
|
||||||
|
cover, which makes readium spine 0 = OPF spine 1. The server heals such
|
||||||
|
numbering mismatches at ingest (that is what the context check is for),
|
||||||
|
but the durable rule for client authors is:
|
||||||
|
|
||||||
|
> **Never resolve a stored CFI's spine step yourself.** Resolve the
|
||||||
|
> document by `anchor_href`, then land with `css_selector` +
|
||||||
|
> `char_offset`. Treat the CFI as opaque server currency.
|
||||||
|
|
||||||
|
## Offset currencies
|
||||||
|
|
||||||
|
Two counting systems are in play, and they are deliberately kept apart:
|
||||||
|
|
||||||
|
| Quantity | Currency | Why |
|
||||||
|
| -------- | -------- | --- |
|
||||||
|
| CFI terminal offsets (`…/1:456`) | UTF-16 code units | The EPUB CFI spec, and what every client observes (JavaScript `.length`). |
|
||||||
|
| `char_offset` (block-relative handle) | UTF-16 code units | Same reason — clients walk DOM text with JS semantics. |
|
||||||
|
| KOReader CRE `text().N` offsets | UTF-16 code units | crengine is UCS-16 internally. |
|
||||||
|
| `character_offset` (book-wide column) | Unicode runes | Internal, consistent with `total_characters` and the percentage derivations. |
|
||||||
|
|
||||||
|
For all-BMP text the two currencies are identical. They diverge on
|
||||||
|
astral-plane characters (emoji, rare CJK ideographs): one rune, two
|
||||||
|
UTF-16 units. The server converts at every wire boundary; internal
|
||||||
|
arithmetic never crosses.
|
||||||
|
|
||||||
|
## `context_text` rules
|
||||||
|
|
||||||
|
- Starts at the anchor position (it may begin mid-word).
|
||||||
|
- Whitespace-normalized (all runs of whitespace collapse to single
|
||||||
|
spaces).
|
||||||
|
- At most 100 characters.
|
||||||
|
- Comparison is containment-based (client and server suffixes of the
|
||||||
|
same block verify in either direction); contexts shorter than 12
|
||||||
|
chars never match.
|
||||||
|
|
||||||
|
## Engine-specific notes
|
||||||
|
|
||||||
|
- **readium-based clients** (the Android app): submit
|
||||||
|
percentage + `context_text` + a CFI generated from the laid-out
|
||||||
|
WebView. Their locally-generated CFI spine steps use readium
|
||||||
|
numbering — the server heals the difference; nothing to do.
|
||||||
|
- **Web (foliate)**: submit all three tiers; foliate CFIs are the same
|
||||||
|
currency the server stores.
|
||||||
|
- **KOReader**: submits a CRE xpointer in the `epubcfi` field of the
|
||||||
|
device payload (historical field name; it is a CRE xpointer, not a
|
||||||
|
CFI). The server converts CRE → canonical at ingest and canonical →
|
||||||
|
CRE on pull (`koreader_xpointer` in the metadata response).
|
||||||
|
- **Kobo**: submits kepub CFI locators, converted server-side the same
|
||||||
|
way.
|
||||||
|
- **Fixed-layout content** (PDF/CBZ): the page index is the canonical
|
||||||
|
locator; CFI/xpointer are meaningless and neither submitted nor
|
||||||
|
served.
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# Update Media Item Progress
|
||||||
|
|
||||||
|
Submit reading progress for a media item. This is the position-authority
|
||||||
|
ingest point: the server independently verifies the submission against
|
||||||
|
the book and heals it when the client's projection is wrong.
|
||||||
|
|
||||||
|
**Endpoint**: `PUT /api/media-items/:id/progress`
|
||||||
|
**Auth**: Required (Bearer token)
|
||||||
|
**Content-Type**: `application/json`
|
||||||
|
|
||||||
|
See the [Position Contract](position-contract.md) for the three-tier
|
||||||
|
submission model, the verification/healing semantics, and the offset
|
||||||
|
currencies.
|
||||||
|
|
||||||
|
## Path Parameters
|
||||||
|
|
||||||
|
| Parameter | Type | Required | Description |
|
||||||
|
| --------- | ------------- | -------- | ---------------- |
|
||||||
|
| id | string (UUID) | Yes | Media item UUID |
|
||||||
|
|
||||||
|
## Request Body
|
||||||
|
|
||||||
|
All fields are optional; submit what your renderer can observe.
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
| ----- | ---- | ----------- |
|
||||||
|
| `percentage` | float | Position as a fraction of the whole book (0..1). Tier 1 — the universal field. |
|
||||||
|
| `context_text` | string | Up to 100 whitespace-normalized chars starting at the anchor. Tier 2 — enables verification and healing. |
|
||||||
|
| `epubcfi` | string | Standard wrapped CFI (UTF-16 terminals). Tier 3 — the structural anchor. Note: the server re-derives the stored canonical CFI; a client's own spine numbering is healed if it disagrees with the OPF spine. |
|
||||||
|
| `character_offset` | int | Book-wide rune offset. Accepted but recomputed server-side from the verified anchor on every verified save. |
|
||||||
|
| `current_page`, `total_pages` | int | Fixed-layout position. For fixed-layout formats the page index is the canonical locator. |
|
||||||
|
| `chapter`, `chapter_progress` | int, float | Chapter index and within-chapter fraction. |
|
||||||
|
| `reading_mode` | string | `paged` or `scrolled` (fixed-layout reader state). |
|
||||||
|
| `zoom_level`, `scroll_position_x`, `scroll_position_y` | float | Fixed-layout viewport state. |
|
||||||
|
|
||||||
|
### Example Request (reflowable, all three tiers)
|
||||||
|
|
||||||
|
```http
|
||||||
|
PUT /api/media-items/774641f9-317b-4087-8e04-53bb4392ae56/progress
|
||||||
|
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"percentage": 0.0415,
|
||||||
|
"context_text": "was at war with one of these Powers it was generally at peace with the other. But what was strange w",
|
||||||
|
"epubcfi": "epubcfi(/6/4!/4/8[_idContainer003]/62/1:456)"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example Request (fixed-layout)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"percentage": 0.15,
|
||||||
|
"current_page": 30,
|
||||||
|
"total_pages": 194,
|
||||||
|
"reading_mode": "paged"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Behavior
|
||||||
|
|
||||||
|
- The submission is verified against the book's XHTML when
|
||||||
|
`context_text` is present and the book is convertible reflowable: the
|
||||||
|
CFI is resolved, the text at the anchor is compared with
|
||||||
|
`context_text`, and mismatches heal by text search (percentage
|
||||||
|
disambiguates repeats). See [Position Contract](position-contract.md).
|
||||||
|
- **Anti-clobber guard**: a submission with `percentage < 0.005` is
|
||||||
|
ignored with `{"status": "ignored"}` when the stored row already holds
|
||||||
|
a percentage above `0.01` — re-opening a book at its first page does
|
||||||
|
not wipe real progress.
|
||||||
|
- The response is the stored row after verification, including the
|
||||||
|
healed `epubcfi` and the refreshed `character_offset` when
|
||||||
|
verification ran.
|
||||||
|
|
||||||
|
## Response (200 OK)
|
||||||
|
|
||||||
|
The saved progress row (same shape as
|
||||||
|
[GET](get_media_progress.md), minus the GET-time handles). The
|
||||||
|
`epubcfi` and `percentage` in the response are the server-verified
|
||||||
|
values, which may differ from the submitted ones when healing occurred.
|
||||||
|
|
||||||
|
## Error Responses
|
||||||
|
|
||||||
|
| Code | Description |
|
||||||
|
| ---- | ----------- |
|
||||||
|
| 400 | Invalid request data |
|
||||||
|
| 401 | Invalid or expired token |
|
||||||
|
| 500 | Database error |
|
||||||
Loaded 100 of 399 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user