Compare commits
12 Commits
master
...
da72ab1446
| Author | SHA1 | Date | |
|---|---|---|---|
| da72ab1446 | |||
| 93c65cd155 | |||
| ba42d3d445 | |||
| 819332522a | |||
| 79a06e6844 | |||
| 97a496553a | |||
| 5a58625265 | |||
| 6d9ac07dfc | |||
| b6a7cf9417 | |||
| c4b39c9410 | |||
| f60cc368b6 | |||
| 65bab7fd44 |
2
.cargo/config.toml
Normal file
2
.cargo/config.toml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
[env]
|
||||||
|
SQLX_OFFLINE = "true"
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
DATABASE_URL=sqlite:./dev.db
|
||||||
|
PORT=3000
|
||||||
|
JWT_SECRET=
|
||||||
|
JWT_TTL_SECONDS=
|
||||||
|
ALLOW_REGISTRATION=true
|
||||||
|
OMDB_API_KEY=
|
||||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -5,4 +5,8 @@
|
|||||||
.vscode
|
.vscode
|
||||||
|
|
||||||
.env
|
.env
|
||||||
.env.prod
|
.env.prod
|
||||||
|
|
||||||
|
*.db
|
||||||
|
|
||||||
|
.worktrees/
|
||||||
92
.sqlx/query-01a08873b7fa815ad98a56a0902b60414cfcdc2c7a8570351320c4bc425347c6.json
generated
Normal file
92
.sqlx/query-01a08873b7fa815ad98a56a0902b60414cfcdc2c7a8570351320c4bc425347c6.json
generated
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,\n r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at\n FROM reviews r\n INNER JOIN movies m ON m.id = r.movie_id\n WHERE r.movie_id = ?\n ORDER BY r.watched_at ASC\n LIMIT ? OFFSET ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "external_metadata_id",
|
||||||
|
"ordinal": 1,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "title",
|
||||||
|
"ordinal": 2,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "release_year",
|
||||||
|
"ordinal": 3,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "director",
|
||||||
|
"ordinal": 4,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "poster_path",
|
||||||
|
"ordinal": 5,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "review_id",
|
||||||
|
"ordinal": 6,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "movie_id",
|
||||||
|
"ordinal": 7,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "user_id",
|
||||||
|
"ordinal": 8,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "rating",
|
||||||
|
"ordinal": 9,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "comment",
|
||||||
|
"ordinal": 10,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "watched_at",
|
||||||
|
"ordinal": 11,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "created_at",
|
||||||
|
"ordinal": 12,
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 3
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "01a08873b7fa815ad98a56a0902b60414cfcdc2c7a8570351320c4bc425347c6"
|
||||||
|
}
|
||||||
92
.sqlx/query-026e2afeb573707cb360fcdab8f6137aabfaf603b5ed57b98ac2888b4a0389ff.json
generated
Normal file
92
.sqlx/query-026e2afeb573707cb360fcdab8f6137aabfaf603b5ed57b98ac2888b4a0389ff.json
generated
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,\n r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at\n FROM reviews r\n INNER JOIN movies m ON m.id = r.movie_id\n ORDER BY r.watched_at DESC\n LIMIT ? OFFSET ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "external_metadata_id",
|
||||||
|
"ordinal": 1,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "title",
|
||||||
|
"ordinal": 2,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "release_year",
|
||||||
|
"ordinal": 3,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "director",
|
||||||
|
"ordinal": 4,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "poster_path",
|
||||||
|
"ordinal": 5,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "review_id",
|
||||||
|
"ordinal": 6,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "movie_id",
|
||||||
|
"ordinal": 7,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "user_id",
|
||||||
|
"ordinal": 8,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "rating",
|
||||||
|
"ordinal": 9,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "comment",
|
||||||
|
"ordinal": 10,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "watched_at",
|
||||||
|
"ordinal": 11,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "created_at",
|
||||||
|
"ordinal": 12,
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 2
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "026e2afeb573707cb360fcdab8f6137aabfaf603b5ed57b98ac2888b4a0389ff"
|
||||||
|
}
|
||||||
20
.sqlx/query-0963b9661182e139cd760bbabb0d6ea3a301a2a3adbdfdda4a88f333a1144c77.json
generated
Normal file
20
.sqlx/query-0963b9661182e139cd760bbabb0d6ea3a301a2a3adbdfdda4a88f333a1144c77.json
generated
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT COUNT(*) FROM reviews",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "COUNT(*)",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Integer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 0
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "0963b9661182e139cd760bbabb0d6ea3a301a2a3adbdfdda4a88f333a1144c77"
|
||||||
|
}
|
||||||
32
.sqlx/query-167481bb1692cc81531d9a5cd85425e43d09a6df97c335ac347f7cfd61acd171.json
generated
Normal file
32
.sqlx/query-167481bb1692cc81531d9a5cd85425e43d09a6df97c335ac347f7cfd61acd171.json
generated
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT id, email, password_hash FROM users WHERE email = ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "email",
|
||||||
|
"ordinal": 1,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "password_hash",
|
||||||
|
"ordinal": 2,
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "167481bb1692cc81531d9a5cd85425e43d09a6df97c335ac347f7cfd61acd171"
|
||||||
|
}
|
||||||
12
.sqlx/query-18de90feb13b9f467f06d0ce25332d9ea7eabc99d9f1a44694e5d10762606f82.json
generated
Normal file
12
.sqlx/query-18de90feb13b9f467f06d0ce25332d9ea7eabc99d9f1a44694e5d10762606f82.json
generated
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "INSERT OR IGNORE INTO users (id, email, password_hash, created_at) VALUES (?, ?, ?, ?)",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 4
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "18de90feb13b9f467f06d0ce25332d9ea7eabc99d9f1a44694e5d10762606f82"
|
||||||
|
}
|
||||||
50
.sqlx/query-3047579c6ed13ce87aad9b9ce6300c02f0df3516979518976e13f9d9abc6a403.json
generated
Normal file
50
.sqlx/query-3047579c6ed13ce87aad9b9ce6300c02f0df3516979518976e13f9d9abc6a403.json
generated
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT id, external_metadata_id, title, release_year, director, poster_path\n FROM movies WHERE title = ? AND release_year = ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "external_metadata_id",
|
||||||
|
"ordinal": 1,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "title",
|
||||||
|
"ordinal": 2,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "release_year",
|
||||||
|
"ordinal": 3,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "director",
|
||||||
|
"ordinal": 4,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "poster_path",
|
||||||
|
"ordinal": 5,
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 2
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "3047579c6ed13ce87aad9b9ce6300c02f0df3516979518976e13f9d9abc6a403"
|
||||||
|
}
|
||||||
50
.sqlx/query-33d0dae7d16b0635c1c7eb5afd10824bb55af7cc7a854f590d326622863759d1.json
generated
Normal file
50
.sqlx/query-33d0dae7d16b0635c1c7eb5afd10824bb55af7cc7a854f590d326622863759d1.json
generated
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT id, external_metadata_id, title, release_year, director, poster_path\n FROM movies WHERE id = ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "external_metadata_id",
|
||||||
|
"ordinal": 1,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "title",
|
||||||
|
"ordinal": 2,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "release_year",
|
||||||
|
"ordinal": 3,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "director",
|
||||||
|
"ordinal": 4,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "poster_path",
|
||||||
|
"ordinal": 5,
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "33d0dae7d16b0635c1c7eb5afd10824bb55af7cc7a854f590d326622863759d1"
|
||||||
|
}
|
||||||
92
.sqlx/query-47f7cf95ce3450635b643ab710cadba96f40319140834d510bc5207b2552e055.json
generated
Normal file
92
.sqlx/query-47f7cf95ce3450635b643ab710cadba96f40319140834d510bc5207b2552e055.json
generated
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,\n r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at\n FROM reviews r\n INNER JOIN movies m ON m.id = r.movie_id\n WHERE r.movie_id = ?\n ORDER BY r.watched_at DESC\n LIMIT ? OFFSET ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "external_metadata_id",
|
||||||
|
"ordinal": 1,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "title",
|
||||||
|
"ordinal": 2,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "release_year",
|
||||||
|
"ordinal": 3,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "director",
|
||||||
|
"ordinal": 4,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "poster_path",
|
||||||
|
"ordinal": 5,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "review_id",
|
||||||
|
"ordinal": 6,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "movie_id",
|
||||||
|
"ordinal": 7,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "user_id",
|
||||||
|
"ordinal": 8,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "rating",
|
||||||
|
"ordinal": 9,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "comment",
|
||||||
|
"ordinal": 10,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "watched_at",
|
||||||
|
"ordinal": 11,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "created_at",
|
||||||
|
"ordinal": 12,
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 3
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "47f7cf95ce3450635b643ab710cadba96f40319140834d510bc5207b2552e055"
|
||||||
|
}
|
||||||
20
.sqlx/query-4b3074b532342c6356ee0e8e4d8c4a830f016234bb690e1f6240f02824d6d84f.json
generated
Normal file
20
.sqlx/query-4b3074b532342c6356ee0e8e4d8c4a830f016234bb690e1f6240f02824d6d84f.json
generated
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT COUNT(*) FROM reviews WHERE movie_id = ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "COUNT(*)",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Integer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "4b3074b532342c6356ee0e8e4d8c4a830f016234bb690e1f6240f02824d6d84f"
|
||||||
|
}
|
||||||
12
.sqlx/query-630e092fcd33bc312befef352a98225e6e18e6079644b949258a39bf4b0fe3e5.json
generated
Normal file
12
.sqlx/query-630e092fcd33bc312befef352a98225e6e18e6079644b949258a39bf4b0fe3e5.json
generated
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "INSERT INTO reviews (id, movie_id, user_id, rating, comment, watched_at, created_at)\n VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 7
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "630e092fcd33bc312befef352a98225e6e18e6079644b949258a39bf4b0fe3e5"
|
||||||
|
}
|
||||||
50
.sqlx/query-7bc4aebcb94547976d3d7e063e4e908fc22b977b3cbf063ee93ffe4648c42011.json
generated
Normal file
50
.sqlx/query-7bc4aebcb94547976d3d7e063e4e908fc22b977b3cbf063ee93ffe4648c42011.json
generated
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT id, external_metadata_id, title, release_year, director, poster_path\n FROM movies WHERE external_metadata_id = ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "external_metadata_id",
|
||||||
|
"ordinal": 1,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "title",
|
||||||
|
"ordinal": 2,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "release_year",
|
||||||
|
"ordinal": 3,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "director",
|
||||||
|
"ordinal": 4,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "poster_path",
|
||||||
|
"ordinal": 5,
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "7bc4aebcb94547976d3d7e063e4e908fc22b977b3cbf063ee93ffe4648c42011"
|
||||||
|
}
|
||||||
12
.sqlx/query-7d7e23355ee0e442f2aa27e898dcfa40bdc4b09391afe04325f076157d9d84aa.json
generated
Normal file
12
.sqlx/query-7d7e23355ee0e442f2aa27e898dcfa40bdc4b09391afe04325f076157d9d84aa.json
generated
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "INSERT INTO movies (id, external_metadata_id, title, release_year, director, poster_path)\n VALUES (?, ?, ?, ?, ?, ?)\n ON CONFLICT(id) DO UPDATE SET\n external_metadata_id = excluded.external_metadata_id,\n title = excluded.title,\n release_year = excluded.release_year,\n director = excluded.director,\n poster_path = excluded.poster_path",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 6
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "7d7e23355ee0e442f2aa27e898dcfa40bdc4b09391afe04325f076157d9d84aa"
|
||||||
|
}
|
||||||
56
.sqlx/query-af883f8b78f185077e2d3dcfaa0a6e62fbdfbf00c97c9b33b699dc631476181d.json
generated
Normal file
56
.sqlx/query-af883f8b78f185077e2d3dcfaa0a6e62fbdfbf00c97c9b33b699dc631476181d.json
generated
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT id, movie_id, user_id, rating, comment, watched_at, created_at\n FROM reviews WHERE movie_id = ? ORDER BY watched_at ASC",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "movie_id",
|
||||||
|
"ordinal": 1,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "user_id",
|
||||||
|
"ordinal": 2,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "rating",
|
||||||
|
"ordinal": 3,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "comment",
|
||||||
|
"ordinal": 4,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "watched_at",
|
||||||
|
"ordinal": 5,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "created_at",
|
||||||
|
"ordinal": 6,
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "af883f8b78f185077e2d3dcfaa0a6e62fbdfbf00c97c9b33b699dc631476181d"
|
||||||
|
}
|
||||||
92
.sqlx/query-affe1eb261283c09d4b1ce6e684681755f079a044ffec8ff2bd79cfd8efe16b8.json
generated
Normal file
92
.sqlx/query-affe1eb261283c09d4b1ce6e684681755f079a044ffec8ff2bd79cfd8efe16b8.json
generated
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,\n r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at\n FROM reviews r\n INNER JOIN movies m ON m.id = r.movie_id\n ORDER BY r.watched_at ASC\n LIMIT ? OFFSET ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "external_metadata_id",
|
||||||
|
"ordinal": 1,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "title",
|
||||||
|
"ordinal": 2,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "release_year",
|
||||||
|
"ordinal": 3,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "director",
|
||||||
|
"ordinal": 4,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "poster_path",
|
||||||
|
"ordinal": 5,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "review_id",
|
||||||
|
"ordinal": 6,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "movie_id",
|
||||||
|
"ordinal": 7,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "user_id",
|
||||||
|
"ordinal": 8,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "rating",
|
||||||
|
"ordinal": 9,
|
||||||
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "comment",
|
||||||
|
"ordinal": 10,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "watched_at",
|
||||||
|
"ordinal": 11,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "created_at",
|
||||||
|
"ordinal": 12,
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 2
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "affe1eb261283c09d4b1ce6e684681755f079a044ffec8ff2bd79cfd8efe16b8"
|
||||||
|
}
|
||||||
1300
Cargo.lock
generated
1300
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
10
Cargo.toml
10
Cargo.toml
@@ -4,6 +4,7 @@ members = [
|
|||||||
"crates/adapters/metadata",
|
"crates/adapters/metadata",
|
||||||
"crates/adapters/rss",
|
"crates/adapters/rss",
|
||||||
"crates/adapters/sqlite",
|
"crates/adapters/sqlite",
|
||||||
|
"crates/adapters/template-askama",
|
||||||
"crates/application",
|
"crates/application",
|
||||||
"crates/common",
|
"crates/common",
|
||||||
"crates/domain",
|
"crates/domain",
|
||||||
@@ -13,6 +14,7 @@ resolver = "2"
|
|||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
tokio = { version = "1.0", features = ["full"] }
|
tokio = { version = "1.0", features = ["full"] }
|
||||||
|
dotenvy = "0.15"
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
anyhow = "1.0"
|
anyhow = "1.0"
|
||||||
@@ -22,6 +24,13 @@ tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
|
|||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
uuid = { version = "1.23.0", features = ["v4", "serde"] }
|
uuid = { version = "1.23.0", features = ["v4", "serde"] }
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
sqlx = { version = "0.8.6", features = [
|
||||||
|
"runtime-tokio-rustls",
|
||||||
|
"sqlite",
|
||||||
|
"uuid",
|
||||||
|
"macros",
|
||||||
|
] }
|
||||||
|
reqwest = { version = "0.13", features = ["json", "query"] }
|
||||||
|
|
||||||
domain = { path = "crates/domain" }
|
domain = { path = "crates/domain" }
|
||||||
common = { path = "crates/common" }
|
common = { path = "crates/common" }
|
||||||
@@ -31,3 +40,4 @@ auth = { path = "crates/adapters/auth" }
|
|||||||
metadata = { path = "crates/adapters/metadata" }
|
metadata = { path = "crates/adapters/metadata" }
|
||||||
rss = { path = "crates/adapters/rss" }
|
rss = { path = "crates/adapters/rss" }
|
||||||
sqlite = { path = "crates/adapters/sqlite" }
|
sqlite = { path = "crates/adapters/sqlite" }
|
||||||
|
template-askama = { path = "crates/adapters/template-askama" }
|
||||||
|
|||||||
@@ -4,3 +4,12 @@ version = "0.1.0"
|
|||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
async-trait = { workspace = true }
|
||||||
|
domain = { workspace = true }
|
||||||
|
anyhow = { workspace = true }
|
||||||
|
chrono = { workspace = true }
|
||||||
|
uuid = { workspace = true }
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
jsonwebtoken = "9"
|
||||||
|
argon2 = { version = "0.5", features = ["std"] }
|
||||||
|
rand_core = { version = "0.6", features = ["getrandom"] }
|
||||||
|
|||||||
@@ -1,14 +1,104 @@
|
|||||||
pub fn add(left: u64, right: u64) -> u64 {
|
use async_trait::async_trait;
|
||||||
left + right
|
use argon2::{
|
||||||
|
Argon2,
|
||||||
|
password_hash::{PasswordHasher as _, PasswordVerifier, SaltString},
|
||||||
|
};
|
||||||
|
use chrono::{Duration, Utc};
|
||||||
|
use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation, decode, encode};
|
||||||
|
use rand_core::OsRng;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use domain::{
|
||||||
|
errors::DomainError,
|
||||||
|
ports::{AuthService, GeneratedToken, PasswordHasher},
|
||||||
|
value_objects::{PasswordHash, UserId},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub struct AuthConfig {
|
||||||
|
secret: String,
|
||||||
|
ttl_seconds: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
impl AuthConfig {
|
||||||
mod tests {
|
pub fn from_env() -> anyhow::Result<Self> {
|
||||||
use super::*;
|
let secret = std::env::var("JWT_SECRET")
|
||||||
|
.map_err(|_| anyhow::anyhow!("JWT_SECRET env var is required"))?;
|
||||||
#[test]
|
if secret.is_empty() {
|
||||||
fn it_works() {
|
anyhow::bail!("JWT_SECRET must not be empty");
|
||||||
let result = add(2, 2);
|
}
|
||||||
assert_eq!(result, 4);
|
let ttl_seconds = std::env::var("JWT_TTL_SECONDS")
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| v.parse().ok())
|
||||||
|
.unwrap_or(86400u64);
|
||||||
|
Ok(Self { secret, ttl_seconds })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
struct Claims {
|
||||||
|
sub: String,
|
||||||
|
exp: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct JwtAuthService {
|
||||||
|
config: AuthConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JwtAuthService {
|
||||||
|
pub fn new(config: AuthConfig) -> Self {
|
||||||
|
Self { config }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl AuthService for JwtAuthService {
|
||||||
|
async fn generate_token(&self, user_id: &UserId) -> Result<GeneratedToken, DomainError> {
|
||||||
|
let expires_at = Utc::now() + Duration::seconds(self.config.ttl_seconds as i64);
|
||||||
|
let claims = Claims {
|
||||||
|
sub: user_id.value().to_string(),
|
||||||
|
exp: expires_at.timestamp() as u64,
|
||||||
|
};
|
||||||
|
let token = encode(
|
||||||
|
&Header::default(),
|
||||||
|
&claims,
|
||||||
|
&EncodingKey::from_secret(self.config.secret.as_bytes()),
|
||||||
|
)
|
||||||
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
|
Ok(GeneratedToken { token, expires_at })
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn validate_token(&self, token: &str) -> Result<UserId, DomainError> {
|
||||||
|
let data = decode::<Claims>(
|
||||||
|
token,
|
||||||
|
&DecodingKey::from_secret(self.config.secret.as_bytes()),
|
||||||
|
&Validation::default(),
|
||||||
|
)
|
||||||
|
.map_err(|_| DomainError::Unauthorized("Invalid or expired token".into()))?;
|
||||||
|
let uuid = Uuid::parse_str(&data.claims.sub)
|
||||||
|
.map_err(|_| DomainError::Unauthorized("Invalid token subject".into()))?;
|
||||||
|
Ok(UserId::from_uuid(uuid))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Argon2PasswordHasher;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl PasswordHasher for Argon2PasswordHasher {
|
||||||
|
async fn hash(&self, plain_password: &str) -> Result<PasswordHash, DomainError> {
|
||||||
|
let salt = SaltString::generate(&mut OsRng);
|
||||||
|
let hash = Argon2::default()
|
||||||
|
.hash_password(plain_password.as_bytes(), &salt)
|
||||||
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?
|
||||||
|
.to_string();
|
||||||
|
PasswordHash::new(hash).map_err(|e| DomainError::InfrastructureError(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn verify(&self, plain_password: &str, hash: &PasswordHash) -> Result<bool, DomainError> {
|
||||||
|
let parsed = argon2::password_hash::PasswordHash::new(hash.value())
|
||||||
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
|
Ok(Argon2::default()
|
||||||
|
.verify_password(plain_password.as_bytes(), &parsed)
|
||||||
|
.is_ok())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,3 +4,7 @@ version = "0.1.0"
|
|||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
async-trait = { workspace = true }
|
||||||
|
reqwest = { workspace = true }
|
||||||
|
serde = { workspace = true }
|
||||||
|
domain = { workspace = true }
|
||||||
|
|||||||
@@ -1,14 +1,54 @@
|
|||||||
pub fn add(left: u64, right: u64) -> u64 {
|
use async_trait::async_trait;
|
||||||
left + right
|
use domain::{
|
||||||
|
errors::DomainError,
|
||||||
|
models::Movie,
|
||||||
|
ports::{MetadataClient, MetadataSearchCriteria},
|
||||||
|
value_objects::{ExternalMetadataId, MovieTitle, PosterUrl, ReleaseYear},
|
||||||
|
};
|
||||||
|
|
||||||
|
mod omdb;
|
||||||
|
|
||||||
|
pub(crate) struct ProviderMovie {
|
||||||
|
pub imdb_id: ExternalMetadataId,
|
||||||
|
pub title: MovieTitle,
|
||||||
|
pub release_year: ReleaseYear,
|
||||||
|
pub director: Option<String>,
|
||||||
|
pub poster_url: Option<PosterUrl>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[async_trait]
|
||||||
mod tests {
|
pub(crate) trait MetadataProvider: Send + Sync {
|
||||||
use super::*;
|
async fn fetch(&self, criteria: &MetadataSearchCriteria) -> Result<ProviderMovie, DomainError>;
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
pub struct MetadataClientImpl {
|
||||||
fn it_works() {
|
provider: Box<dyn MetadataProvider>,
|
||||||
let result = add(2, 2);
|
}
|
||||||
assert_eq!(result, 4);
|
|
||||||
|
impl MetadataClientImpl {
|
||||||
|
pub fn new_omdb(api_key: String) -> Self {
|
||||||
|
Self {
|
||||||
|
provider: Box::new(omdb::OmdbProvider::new(api_key)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl MetadataClient for MetadataClientImpl {
|
||||||
|
async fn fetch_movie_metadata(
|
||||||
|
&self,
|
||||||
|
criteria: &MetadataSearchCriteria,
|
||||||
|
) -> Result<Movie, DomainError> {
|
||||||
|
let pm = self.provider.fetch(criteria).await?;
|
||||||
|
Ok(Movie::new(Some(pm.imdb_id), pm.title, pm.release_year, pm.director, None))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_poster_url(
|
||||||
|
&self,
|
||||||
|
external_metadata_id: &ExternalMetadataId,
|
||||||
|
) -> Result<Option<PosterUrl>, DomainError> {
|
||||||
|
let criteria = MetadataSearchCriteria::ImdbId(external_metadata_id.clone());
|
||||||
|
let pm = self.provider.fetch(&criteria).await?;
|
||||||
|
Ok(pm.poster_url)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
119
crates/adapters/metadata/src/omdb.rs
Normal file
119
crates/adapters/metadata/src/omdb.rs
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use domain::{
|
||||||
|
errors::DomainError,
|
||||||
|
ports::MetadataSearchCriteria,
|
||||||
|
value_objects::{ExternalMetadataId, MovieTitle, PosterUrl, ReleaseYear},
|
||||||
|
};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::{MetadataProvider, ProviderMovie};
|
||||||
|
|
||||||
|
pub(crate) struct OmdbProvider {
|
||||||
|
client: reqwest::Client,
|
||||||
|
api_key: String,
|
||||||
|
base_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OmdbProvider {
|
||||||
|
pub(crate) fn new(api_key: String) -> Self {
|
||||||
|
Self {
|
||||||
|
client: reqwest::Client::new(),
|
||||||
|
api_key,
|
||||||
|
base_url: "http://www.omdbapi.com/".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct OmdbResponse {
|
||||||
|
#[serde(rename = "Title")]
|
||||||
|
title: String,
|
||||||
|
#[serde(rename = "Year")]
|
||||||
|
year: String,
|
||||||
|
#[serde(rename = "Director")]
|
||||||
|
director: String,
|
||||||
|
#[serde(rename = "Poster")]
|
||||||
|
poster: String,
|
||||||
|
#[serde(rename = "imdbID")]
|
||||||
|
imdb_id: String,
|
||||||
|
#[serde(rename = "Response")]
|
||||||
|
response: String,
|
||||||
|
#[serde(rename = "Error")]
|
||||||
|
error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl MetadataProvider for OmdbProvider {
|
||||||
|
async fn fetch(&self, criteria: &MetadataSearchCriteria) -> Result<ProviderMovie, DomainError> {
|
||||||
|
let mut url = reqwest::Url::parse(&self.base_url)
|
||||||
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut params = url.query_pairs_mut();
|
||||||
|
params.append_pair("apikey", &self.api_key);
|
||||||
|
match criteria {
|
||||||
|
MetadataSearchCriteria::ImdbId(id) => {
|
||||||
|
params.append_pair("i", id.value());
|
||||||
|
}
|
||||||
|
MetadataSearchCriteria::Title { title, year } => {
|
||||||
|
params.append_pair("t", title);
|
||||||
|
if let Some(y) = year {
|
||||||
|
params.append_pair("y", &y.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let http_resp = self
|
||||||
|
.client
|
||||||
|
.get(url)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e: reqwest::Error| DomainError::InfrastructureError(e.to_string()))?
|
||||||
|
.error_for_status()
|
||||||
|
.map_err(|e: reqwest::Error| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
|
|
||||||
|
let resp: OmdbResponse = http_resp
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e: reqwest::Error| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
|
|
||||||
|
if resp.response != "True" {
|
||||||
|
let msg = resp.error.unwrap_or_default();
|
||||||
|
return if msg.to_lowercase().contains("not found") {
|
||||||
|
Err(DomainError::NotFound(msg))
|
||||||
|
} else {
|
||||||
|
Err(DomainError::InfrastructureError(msg))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let year: u16 = resp
|
||||||
|
.year
|
||||||
|
.chars()
|
||||||
|
.take(4)
|
||||||
|
.collect::<String>()
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| {
|
||||||
|
DomainError::InfrastructureError(format!("Unparseable year: {}", resp.year))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let imdb_id = ExternalMetadataId::new(resp.imdb_id)
|
||||||
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
|
let title = MovieTitle::new(resp.title)
|
||||||
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
|
let release_year = ReleaseYear::new(year)
|
||||||
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
|
|
||||||
|
let director = match resp.director.as_str() {
|
||||||
|
"N/A" | "" => None,
|
||||||
|
d => Some(d.to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let poster_url = match resp.poster.as_str() {
|
||||||
|
"N/A" | "" => None,
|
||||||
|
url => PosterUrl::new(url.to_string()).ok(),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(ProviderMovie { imdb_id, title, release_year, director, poster_url })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,3 +10,10 @@ sqlx = { version = "0.8.6", features = [
|
|||||||
"uuid",
|
"uuid",
|
||||||
"macros",
|
"macros",
|
||||||
] }
|
] }
|
||||||
|
|
||||||
|
domain = { workspace = true }
|
||||||
|
uuid = { workspace = true }
|
||||||
|
chrono = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
|
async-trait = { workspace = true }
|
||||||
|
tokio = { workspace = true }
|
||||||
|
|||||||
24
crates/adapters/sqlite/migrations/0001_initial.sql
Normal file
24
crates/adapters/sqlite/migrations/0001_initial.sql
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS movies (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
external_metadata_id TEXT UNIQUE,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
release_year INTEGER NOT NULL,
|
||||||
|
director TEXT,
|
||||||
|
poster_path TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_movies_title_year
|
||||||
|
ON movies (title, release_year);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS reviews (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
movie_id TEXT NOT NULL REFERENCES movies(id),
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
rating INTEGER NOT NULL,
|
||||||
|
comment TEXT,
|
||||||
|
watched_at TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_reviews_movie_id ON reviews (movie_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_reviews_watched_at ON reviews (watched_at);
|
||||||
6
crates/adapters/sqlite/migrations/0002_users.sql
Normal file
6
crates/adapters/sqlite/migrations/0002_users.sql
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
email TEXT UNIQUE NOT NULL,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
@@ -1,14 +1,306 @@
|
|||||||
pub fn add(left: u64, right: u64) -> u64 {
|
use async_trait::async_trait;
|
||||||
left + right
|
use domain::{
|
||||||
|
errors::DomainError,
|
||||||
|
events::DomainEvent,
|
||||||
|
models::{
|
||||||
|
DiaryEntry, DiaryFilter, Movie, Review, ReviewHistory, SortDirection,
|
||||||
|
collections::Paginated,
|
||||||
|
},
|
||||||
|
ports::MovieRepository,
|
||||||
|
value_objects::{ExternalMetadataId, MovieId, MovieTitle, ReleaseYear},
|
||||||
|
};
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
mod migrations;
|
||||||
|
mod models;
|
||||||
|
mod users;
|
||||||
|
|
||||||
|
use models::{DiaryRow, MovieRow, ReviewRow, datetime_to_str};
|
||||||
|
|
||||||
|
pub use users::SqliteUserRepository;
|
||||||
|
|
||||||
|
pub struct SqliteMovieRepository {
|
||||||
|
pool: SqlitePool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
impl SqliteMovieRepository {
|
||||||
mod tests {
|
pub fn new(pool: SqlitePool) -> Self {
|
||||||
use super::*;
|
Self { pool }
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
pub async fn migrate(&self) -> Result<(), DomainError> {
|
||||||
fn it_works() {
|
migrations::run(&self.pool).await
|
||||||
let result = add(2, 2);
|
}
|
||||||
assert_eq!(result, 4);
|
|
||||||
|
fn map_err(e: sqlx::Error) -> DomainError {
|
||||||
|
tracing::error!("Database error: {:?}", e);
|
||||||
|
DomainError::InfrastructureError("Database operation failed".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn count_diary_entries(&self, movie_id: Option<&str>) -> Result<i64, DomainError> {
|
||||||
|
match movie_id {
|
||||||
|
None => sqlx::query_scalar!("SELECT COUNT(*) FROM reviews")
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(Self::map_err),
|
||||||
|
Some(id) => {
|
||||||
|
sqlx::query_scalar!("SELECT COUNT(*) FROM reviews WHERE movie_id = ?", id)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(Self::map_err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_diary_rows(
|
||||||
|
&self,
|
||||||
|
movie_id: Option<&str>,
|
||||||
|
sort: &SortDirection,
|
||||||
|
limit: i64,
|
||||||
|
offset: i64,
|
||||||
|
) -> Result<Vec<DiaryRow>, DomainError> {
|
||||||
|
// sqlx macros require literal ORDER BY values; separate branches also let the
|
||||||
|
// query planner use the movie_id index instead of falling back to a filtered scan.
|
||||||
|
match (movie_id, sort) {
|
||||||
|
(None, SortDirection::Descending) => sqlx::query_as!(
|
||||||
|
DiaryRow,
|
||||||
|
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
|
||||||
|
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at
|
||||||
|
FROM reviews r
|
||||||
|
INNER JOIN movies m ON m.id = r.movie_id
|
||||||
|
ORDER BY r.watched_at DESC
|
||||||
|
LIMIT ? OFFSET ?",
|
||||||
|
limit,
|
||||||
|
offset
|
||||||
|
)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(Self::map_err),
|
||||||
|
|
||||||
|
(None, SortDirection::Ascending) => sqlx::query_as!(
|
||||||
|
DiaryRow,
|
||||||
|
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
|
||||||
|
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at
|
||||||
|
FROM reviews r
|
||||||
|
INNER JOIN movies m ON m.id = r.movie_id
|
||||||
|
ORDER BY r.watched_at ASC
|
||||||
|
LIMIT ? OFFSET ?",
|
||||||
|
limit,
|
||||||
|
offset
|
||||||
|
)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(Self::map_err),
|
||||||
|
|
||||||
|
(Some(id), SortDirection::Descending) => sqlx::query_as!(
|
||||||
|
DiaryRow,
|
||||||
|
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
|
||||||
|
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at
|
||||||
|
FROM reviews r
|
||||||
|
INNER JOIN movies m ON m.id = r.movie_id
|
||||||
|
WHERE r.movie_id = ?
|
||||||
|
ORDER BY r.watched_at DESC
|
||||||
|
LIMIT ? OFFSET ?",
|
||||||
|
id,
|
||||||
|
limit,
|
||||||
|
offset
|
||||||
|
)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(Self::map_err),
|
||||||
|
|
||||||
|
(Some(id), SortDirection::Ascending) => sqlx::query_as!(
|
||||||
|
DiaryRow,
|
||||||
|
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
|
||||||
|
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at
|
||||||
|
FROM reviews r
|
||||||
|
INNER JOIN movies m ON m.id = r.movie_id
|
||||||
|
WHERE r.movie_id = ?
|
||||||
|
ORDER BY r.watched_at ASC
|
||||||
|
LIMIT ? OFFSET ?",
|
||||||
|
id,
|
||||||
|
limit,
|
||||||
|
offset
|
||||||
|
)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(Self::map_err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl MovieRepository for SqliteMovieRepository {
|
||||||
|
async fn get_movie_by_external_id(
|
||||||
|
&self,
|
||||||
|
external_metadata_id: &ExternalMetadataId,
|
||||||
|
) -> Result<Option<Movie>, DomainError> {
|
||||||
|
let id = external_metadata_id.value();
|
||||||
|
sqlx::query_as!(
|
||||||
|
MovieRow,
|
||||||
|
"SELECT id, external_metadata_id, title, release_year, director, poster_path
|
||||||
|
FROM movies WHERE external_metadata_id = ?",
|
||||||
|
id
|
||||||
|
)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(Self::map_err)?
|
||||||
|
.map(MovieRow::to_domain)
|
||||||
|
.transpose()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_movie_by_id(&self, movie_id: &MovieId) -> Result<Option<Movie>, DomainError> {
|
||||||
|
let id = movie_id.value().to_string();
|
||||||
|
sqlx::query_as!(
|
||||||
|
MovieRow,
|
||||||
|
"SELECT id, external_metadata_id, title, release_year, director, poster_path
|
||||||
|
FROM movies WHERE id = ?",
|
||||||
|
id
|
||||||
|
)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(Self::map_err)?
|
||||||
|
.map(MovieRow::to_domain)
|
||||||
|
.transpose()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_movies_by_title_and_year(
|
||||||
|
&self,
|
||||||
|
title: &MovieTitle,
|
||||||
|
year: &ReleaseYear,
|
||||||
|
) -> Result<Vec<Movie>, DomainError> {
|
||||||
|
let title = title.value();
|
||||||
|
let year = year.value() as i64;
|
||||||
|
sqlx::query_as!(
|
||||||
|
MovieRow,
|
||||||
|
"SELECT id, external_metadata_id, title, release_year, director, poster_path
|
||||||
|
FROM movies WHERE title = ? AND release_year = ?",
|
||||||
|
title,
|
||||||
|
year
|
||||||
|
)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(Self::map_err)?
|
||||||
|
.into_iter()
|
||||||
|
.map(MovieRow::to_domain)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn upsert_movie(&self, movie: &Movie) -> Result<(), DomainError> {
|
||||||
|
let id = movie.id().value().to_string();
|
||||||
|
let external_metadata_id = movie.external_metadata_id().map(|e| e.value().to_string());
|
||||||
|
let title = movie.title().value();
|
||||||
|
let release_year = movie.release_year().value() as i64;
|
||||||
|
let director = movie.director();
|
||||||
|
let poster_path = movie.poster_path().map(|p| p.value().to_string());
|
||||||
|
|
||||||
|
sqlx::query!(
|
||||||
|
"INSERT INTO movies (id, external_metadata_id, title, release_year, director, poster_path)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
|
external_metadata_id = excluded.external_metadata_id,
|
||||||
|
title = excluded.title,
|
||||||
|
release_year = excluded.release_year,
|
||||||
|
director = excluded.director,
|
||||||
|
poster_path = excluded.poster_path",
|
||||||
|
id,
|
||||||
|
external_metadata_id,
|
||||||
|
title,
|
||||||
|
release_year,
|
||||||
|
director,
|
||||||
|
poster_path
|
||||||
|
)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(Self::map_err)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn save_review(&self, review: &Review) -> Result<DomainEvent, DomainError> {
|
||||||
|
let id = review.id().value().to_string();
|
||||||
|
let movie_id = review.movie_id().value().to_string();
|
||||||
|
let user_id = review.user_id().value().to_string();
|
||||||
|
let rating = review.rating().value() as i64;
|
||||||
|
let comment = review.comment().map(|c| c.value().to_string());
|
||||||
|
let watched_at = datetime_to_str(review.watched_at());
|
||||||
|
let created_at = datetime_to_str(review.created_at());
|
||||||
|
|
||||||
|
sqlx::query!(
|
||||||
|
"INSERT INTO reviews (id, movie_id, user_id, rating, comment, watched_at, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
id,
|
||||||
|
movie_id,
|
||||||
|
user_id,
|
||||||
|
rating,
|
||||||
|
comment,
|
||||||
|
watched_at,
|
||||||
|
created_at
|
||||||
|
)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(Self::map_err)?;
|
||||||
|
|
||||||
|
Ok(DomainEvent::ReviewLogged {
|
||||||
|
review_id: review.id().clone(),
|
||||||
|
movie_id: review.movie_id().clone(),
|
||||||
|
user_id: review.user_id().clone(),
|
||||||
|
rating: review.rating().clone(),
|
||||||
|
watched_at: *review.watched_at(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn query_diary(&self, filter: &DiaryFilter) -> Result<Paginated<DiaryEntry>, DomainError> {
|
||||||
|
let movie_id: Option<String> = filter.movie_id.as_ref().map(|id| id.value().to_string());
|
||||||
|
let limit = filter.page.limit as i64;
|
||||||
|
let offset = filter.page.offset as i64;
|
||||||
|
|
||||||
|
let (total, rows) = tokio::try_join!(
|
||||||
|
self.count_diary_entries(movie_id.as_deref()),
|
||||||
|
self.fetch_diary_rows(movie_id.as_deref(), &filter.sort_by, limit, offset)
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let items = rows
|
||||||
|
.into_iter()
|
||||||
|
.map(DiaryRow::to_domain)
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
|
||||||
|
Ok(Paginated {
|
||||||
|
items,
|
||||||
|
total_count: total as u64,
|
||||||
|
limit: filter.page.limit,
|
||||||
|
offset: filter.page.offset,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_review_history(&self, movie_id: &MovieId) -> Result<ReviewHistory, DomainError> {
|
||||||
|
let id_str = movie_id.value().to_string();
|
||||||
|
|
||||||
|
let movie = sqlx::query_as!(
|
||||||
|
MovieRow,
|
||||||
|
"SELECT id, external_metadata_id, title, release_year, director, poster_path
|
||||||
|
FROM movies WHERE id = ?",
|
||||||
|
id_str
|
||||||
|
)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(Self::map_err)?
|
||||||
|
.ok_or_else(|| DomainError::NotFound(format!("Movie {}", id_str)))?
|
||||||
|
.to_domain()?;
|
||||||
|
|
||||||
|
let viewings = sqlx::query_as!(
|
||||||
|
ReviewRow,
|
||||||
|
"SELECT id, movie_id, user_id, rating, comment, watched_at, created_at
|
||||||
|
FROM reviews WHERE movie_id = ? ORDER BY watched_at ASC",
|
||||||
|
id_str
|
||||||
|
)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(Self::map_err)?
|
||||||
|
.into_iter()
|
||||||
|
.map(ReviewRow::to_domain)
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
|
||||||
|
Ok(ReviewHistory::new(movie, viewings))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
9
crates/adapters/sqlite/src/migrations.rs
Normal file
9
crates/adapters/sqlite/src/migrations.rs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
use domain::errors::DomainError;
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
pub(crate) async fn run(pool: &SqlitePool) -> Result<(), DomainError> {
|
||||||
|
sqlx::migrate!("./migrations")
|
||||||
|
.run(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
|
||||||
|
}
|
||||||
126
crates/adapters/sqlite/src/models.rs
Normal file
126
crates/adapters/sqlite/src/models.rs
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
use chrono::NaiveDateTime;
|
||||||
|
use domain::{
|
||||||
|
errors::DomainError,
|
||||||
|
models::{DiaryEntry, Movie, Review},
|
||||||
|
value_objects::{
|
||||||
|
Comment, ExternalMetadataId, MovieId, MovieTitle, PosterPath, Rating, ReleaseYear,
|
||||||
|
ReviewId, UserId,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
pub(crate) struct MovieRow {
|
||||||
|
pub id: String,
|
||||||
|
pub external_metadata_id: Option<String>,
|
||||||
|
pub title: String,
|
||||||
|
pub release_year: i64,
|
||||||
|
pub director: Option<String>,
|
||||||
|
pub poster_path: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MovieRow {
|
||||||
|
pub fn to_domain(self) -> Result<Movie, DomainError> {
|
||||||
|
let id = MovieId::from_uuid(parse_uuid(&self.id)?);
|
||||||
|
let external_metadata_id = self
|
||||||
|
.external_metadata_id
|
||||||
|
.map(ExternalMetadataId::new)
|
||||||
|
.transpose()?;
|
||||||
|
let title = MovieTitle::new(self.title)?;
|
||||||
|
let release_year = ReleaseYear::new(self.release_year as u16)?;
|
||||||
|
let poster_path = self.poster_path.map(PosterPath::new).transpose()?;
|
||||||
|
Ok(Movie::from_persistence(
|
||||||
|
id,
|
||||||
|
external_metadata_id,
|
||||||
|
title,
|
||||||
|
release_year,
|
||||||
|
self.director,
|
||||||
|
poster_path,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
pub(crate) struct ReviewRow {
|
||||||
|
pub id: String,
|
||||||
|
pub movie_id: String,
|
||||||
|
pub user_id: String,
|
||||||
|
pub rating: i64,
|
||||||
|
pub comment: Option<String>,
|
||||||
|
pub watched_at: String,
|
||||||
|
pub created_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ReviewRow {
|
||||||
|
pub fn to_domain(self) -> Result<Review, DomainError> {
|
||||||
|
let id = ReviewId::from_uuid(parse_uuid(&self.id)?);
|
||||||
|
let movie_id = MovieId::from_uuid(parse_uuid(&self.movie_id)?);
|
||||||
|
let user_id = UserId::from_uuid(parse_uuid(&self.user_id)?);
|
||||||
|
let rating = Rating::new(self.rating as u8)?;
|
||||||
|
let comment = self.comment.map(Comment::new).transpose()?;
|
||||||
|
let watched_at = parse_datetime(&self.watched_at)?;
|
||||||
|
let created_at = parse_datetime(&self.created_at)?;
|
||||||
|
Ok(Review::from_persistence(
|
||||||
|
id, movie_id, user_id, rating, comment, watched_at, created_at,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Used by query_diary JOIN — r.id aliased to review_id to avoid ambiguity with m.id
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
pub(crate) struct DiaryRow {
|
||||||
|
pub id: String,
|
||||||
|
pub external_metadata_id: Option<String>,
|
||||||
|
pub title: String,
|
||||||
|
pub release_year: i64,
|
||||||
|
pub director: Option<String>,
|
||||||
|
pub poster_path: Option<String>,
|
||||||
|
pub review_id: String,
|
||||||
|
pub movie_id: String,
|
||||||
|
pub user_id: String,
|
||||||
|
pub rating: i64,
|
||||||
|
pub comment: Option<String>,
|
||||||
|
pub watched_at: String,
|
||||||
|
pub created_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DiaryRow {
|
||||||
|
pub fn to_domain(self) -> Result<DiaryEntry, DomainError> {
|
||||||
|
let movie = MovieRow {
|
||||||
|
id: self.id,
|
||||||
|
external_metadata_id: self.external_metadata_id,
|
||||||
|
title: self.title,
|
||||||
|
release_year: self.release_year,
|
||||||
|
director: self.director,
|
||||||
|
poster_path: self.poster_path,
|
||||||
|
}
|
||||||
|
.to_domain()?;
|
||||||
|
|
||||||
|
let review = ReviewRow {
|
||||||
|
id: self.review_id,
|
||||||
|
movie_id: self.movie_id,
|
||||||
|
user_id: self.user_id,
|
||||||
|
rating: self.rating,
|
||||||
|
comment: self.comment,
|
||||||
|
watched_at: self.watched_at,
|
||||||
|
created_at: self.created_at,
|
||||||
|
}
|
||||||
|
.to_domain()?;
|
||||||
|
|
||||||
|
Ok(DiaryEntry::new(movie, review))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn parse_uuid(s: &str) -> Result<Uuid, DomainError> {
|
||||||
|
Uuid::parse_str(s)
|
||||||
|
.map_err(|e| DomainError::InfrastructureError(format!("Invalid UUID '{}': {}", s, e)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn datetime_to_str(dt: &NaiveDateTime) -> String {
|
||||||
|
dt.format("%Y-%m-%d %H:%M:%S").to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn parse_datetime(s: &str) -> Result<NaiveDateTime, DomainError> {
|
||||||
|
NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S")
|
||||||
|
.map_err(|e| DomainError::InfrastructureError(format!("Invalid datetime '{}': {}", s, e)))
|
||||||
|
}
|
||||||
76
crates/adapters/sqlite/src/users.rs
Normal file
76
crates/adapters/sqlite/src/users.rs
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use chrono::Utc;
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
use domain::{
|
||||||
|
errors::DomainError,
|
||||||
|
models::User,
|
||||||
|
ports::UserRepository,
|
||||||
|
value_objects::{Email, PasswordHash, UserId},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub struct SqliteUserRepository {
|
||||||
|
pool: SqlitePool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SqliteUserRepository {
|
||||||
|
pub fn new(pool: SqlitePool) -> Self {
|
||||||
|
Self { pool }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_err(e: sqlx::Error) -> DomainError {
|
||||||
|
tracing::error!("Database error: {:?}", e);
|
||||||
|
DomainError::InfrastructureError("Database operation failed".into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl UserRepository for SqliteUserRepository {
|
||||||
|
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError> {
|
||||||
|
let email_str = email.value();
|
||||||
|
let row = sqlx::query!(
|
||||||
|
"SELECT id, email, password_hash FROM users WHERE email = ?",
|
||||||
|
email_str
|
||||||
|
)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(Self::map_err)?;
|
||||||
|
|
||||||
|
match row {
|
||||||
|
None => Ok(None),
|
||||||
|
Some(r) => {
|
||||||
|
let id = uuid::Uuid::parse_str(&r.id)
|
||||||
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
|
let email = Email::new(r.email)
|
||||||
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
|
let hash = PasswordHash::new(r.password_hash)
|
||||||
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
|
Ok(Some(User::from_persistence(UserId::from_uuid(id), email, hash)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn save(&self, user: &User) -> Result<(), DomainError> {
|
||||||
|
let id = user.id().value().to_string();
|
||||||
|
let email = user.email().value();
|
||||||
|
let hash = user.password_hash().value();
|
||||||
|
let created_at = Utc::now().to_rfc3339();
|
||||||
|
|
||||||
|
let result = sqlx::query!(
|
||||||
|
"INSERT OR IGNORE INTO users (id, email, password_hash, created_at) VALUES (?, ?, ?, ?)",
|
||||||
|
id,
|
||||||
|
email,
|
||||||
|
hash,
|
||||||
|
created_at
|
||||||
|
)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(Self::map_err)?;
|
||||||
|
|
||||||
|
if result.rows_affected() == 0 {
|
||||||
|
return Err(DomainError::ValidationError("Email already registered".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
12
crates/adapters/template-askama/Cargo.toml
Normal file
12
crates/adapters/template-askama/Cargo.toml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
[package]
|
||||||
|
name = "template-askama"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
askama = { version = "0.16.0" }
|
||||||
|
|
||||||
|
serde = { workspace = true }
|
||||||
|
|
||||||
|
domain = { workspace = true }
|
||||||
|
application = { workspace = true }
|
||||||
35
crates/adapters/template-askama/src/lib.rs
Normal file
35
crates/adapters/template-askama/src/lib.rs
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
use askama::Template;
|
||||||
|
use application::ports::HtmlRenderer;
|
||||||
|
use domain::models::{DiaryEntry, collections::Paginated};
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "diary.html")]
|
||||||
|
struct DiaryTemplate<'a> {
|
||||||
|
entries: &'a [DiaryEntry],
|
||||||
|
current_offset: u32,
|
||||||
|
limit: u32,
|
||||||
|
has_more: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct AskamaHtmlRenderer;
|
||||||
|
|
||||||
|
impl AskamaHtmlRenderer {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HtmlRenderer for AskamaHtmlRenderer {
|
||||||
|
fn render_diary_page(&self, data: &Paginated<DiaryEntry>) -> Result<String, String> {
|
||||||
|
let has_more = (data.offset + data.limit) < data.total_count as u32;
|
||||||
|
|
||||||
|
let template = DiaryTemplate {
|
||||||
|
entries: &data.items,
|
||||||
|
current_offset: data.offset,
|
||||||
|
limit: data.limit,
|
||||||
|
has_more,
|
||||||
|
};
|
||||||
|
|
||||||
|
template.render().map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
76
crates/adapters/template-askama/templates/diary.html
Normal file
76
crates/adapters/template-askama/templates/diary.html
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
<!-- crates/presentation/templates/diary.html -->
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>My Movie Diary</title>
|
||||||
|
<style>
|
||||||
|
/* Minimalist old-school styling */
|
||||||
|
body { font-family: monospace; max-width: 800px; margin: 0 auto; padding: 20px; }
|
||||||
|
.entry { border-bottom: 1px solid #ccc; padding: 10px 0; }
|
||||||
|
.poster { max-width: 100px; float: left; margin-right: 15px; }
|
||||||
|
.clear { clear: both; }
|
||||||
|
.error { color: red; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Movie Diary</h1>
|
||||||
|
|
||||||
|
<!-- Zero-JS Form Submission -->
|
||||||
|
<form action="/reviews" method="POST">
|
||||||
|
<fieldset>
|
||||||
|
<legend>Log a Movie</legend>
|
||||||
|
|
||||||
|
<label for="tmdb_id">TMDB ID (Optional):</label>
|
||||||
|
<input type="text" name="external_metadata_id" id="tmdb_id"><br><br>
|
||||||
|
|
||||||
|
<label for="title">Title (Fallback):</label>
|
||||||
|
<input type="text" name="manual_title" id="title"><br><br>
|
||||||
|
|
||||||
|
<label for="year">Year (Fallback):</label>
|
||||||
|
<input type="number" name="manual_release_year" id="year" min="1888"><br><br>
|
||||||
|
|
||||||
|
<label for="rating">Rating (0-5):</label>
|
||||||
|
<input type="number" name="rating" id="rating" min="0" max="5" required><br><br>
|
||||||
|
|
||||||
|
<button type="submit">Log Movie</button>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<!-- Rendering the Domain Models -->
|
||||||
|
<div class="diary-entries">
|
||||||
|
{% for entry in entries %}
|
||||||
|
<div class="entry">
|
||||||
|
{% if let Some(poster) = entry.movie().poster_path() %}
|
||||||
|
<!-- Assuming you have a route to serve the raw images -->
|
||||||
|
<img src="/static/posters/{{ poster.value() }}" class="poster" alt="Poster">
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<h3>{{ entry.movie().title().value() }} ({{ entry.movie().release_year().value() }})</h3>
|
||||||
|
<p><strong>Rating:</strong> {{ entry.review().rating().value() }} / 5</p>
|
||||||
|
|
||||||
|
{% if let Some(comment) = entry.review().comment() %}
|
||||||
|
<p><em>"{{ comment.value() }}"</em></p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<p><small>Watched on: {{ entry.review().watched_at().format("%Y-%m-%d") }}</small></p>
|
||||||
|
<div class="clear"></div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p>No movies logged yet. Go watch something!</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Simple Pagination -->
|
||||||
|
<div>
|
||||||
|
{% if current_offset > 0 %}
|
||||||
|
<a href="/diary?offset={{ current_offset - limit }}">Previous Page</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if has_more %}
|
||||||
|
<a href="/diary?offset={{ current_offset + limit }}">Next Page</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -4,3 +4,7 @@ version = "0.1.0"
|
|||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
domain = { workspace = true }
|
||||||
|
uuid = { workspace = true }
|
||||||
|
chrono = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
|
|||||||
30
crates/application/src/commands.rs
Normal file
30
crates/application/src/commands.rs
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
use chrono::NaiveDateTime;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
pub struct LogReviewCommand {
|
||||||
|
pub external_metadata_id: Option<String>,
|
||||||
|
|
||||||
|
pub manual_title: Option<String>,
|
||||||
|
pub manual_release_year: Option<u16>,
|
||||||
|
pub manual_director: Option<String>,
|
||||||
|
|
||||||
|
pub user_id: Uuid,
|
||||||
|
pub rating: u8,
|
||||||
|
pub comment: Option<String>,
|
||||||
|
pub watched_at: NaiveDateTime,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct SyncPosterCommand {
|
||||||
|
pub movie_id: Uuid,
|
||||||
|
pub external_metadata_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct LoginCommand {
|
||||||
|
pub email: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct RegisterCommand {
|
||||||
|
pub email: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
13
crates/application/src/config.rs
Normal file
13
crates/application/src/config.rs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AppConfig {
|
||||||
|
pub allow_registration: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppConfig {
|
||||||
|
pub fn from_env() -> Self {
|
||||||
|
let allow_registration = std::env::var("ALLOW_REGISTRATION")
|
||||||
|
.map(|v| v == "true" || v == "1")
|
||||||
|
.unwrap_or(false);
|
||||||
|
Self { allow_registration }
|
||||||
|
}
|
||||||
|
}
|
||||||
21
crates/application/src/context.rs
Normal file
21
crates/application/src/context.rs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use domain::ports::{
|
||||||
|
AuthService, EventPublisher, MetadataClient, MovieRepository, PasswordHasher,
|
||||||
|
PosterFetcherClient, PosterStorage, UserRepository,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::config::AppConfig;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AppContext {
|
||||||
|
pub repository: Arc<dyn MovieRepository>,
|
||||||
|
pub metadata_client: Arc<dyn MetadataClient>,
|
||||||
|
pub poster_fetcher: Arc<dyn PosterFetcherClient>,
|
||||||
|
pub poster_storage: Arc<dyn PosterStorage>,
|
||||||
|
pub event_publisher: Arc<dyn EventPublisher>,
|
||||||
|
pub auth_service: Arc<dyn AuthService>,
|
||||||
|
pub password_hasher: Arc<dyn PasswordHasher>,
|
||||||
|
pub user_repository: Arc<dyn UserRepository>,
|
||||||
|
pub config: AppConfig,
|
||||||
|
}
|
||||||
@@ -1,14 +1,6 @@
|
|||||||
pub fn add(left: u64, right: u64) -> u64 {
|
pub mod commands;
|
||||||
left + right
|
pub mod config;
|
||||||
}
|
pub mod context;
|
||||||
|
pub mod ports;
|
||||||
#[cfg(test)]
|
pub mod queries;
|
||||||
mod tests {
|
pub mod use_cases;
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn it_works() {
|
|
||||||
let result = add(2, 2);
|
|
||||||
assert_eq!(result, 4);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
5
crates/application/src/ports.rs
Normal file
5
crates/application/src/ports.rs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
use domain::models::{DiaryEntry, collections::Paginated};
|
||||||
|
|
||||||
|
pub trait HtmlRenderer: Send + Sync {
|
||||||
|
fn render_diary_page(&self, data: &Paginated<DiaryEntry>) -> Result<String, String>;
|
||||||
|
}
|
||||||
13
crates/application/src/queries.rs
Normal file
13
crates/application/src/queries.rs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
use domain::models::SortDirection;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
pub struct GetDiaryQuery {
|
||||||
|
pub limit: Option<u32>,
|
||||||
|
pub offset: Option<u32>,
|
||||||
|
pub sort_by: Option<SortDirection>,
|
||||||
|
pub movie_id: Option<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct GetReviewHistoryQuery {
|
||||||
|
pub movie_id: Uuid,
|
||||||
|
}
|
||||||
29
crates/application/src/use_cases/get_diary.rs
Normal file
29
crates/application/src/use_cases/get_diary.rs
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
use domain::{
|
||||||
|
errors::DomainError,
|
||||||
|
models::{
|
||||||
|
DiaryEntry, DiaryFilter, SortDirection,
|
||||||
|
collections::{PageParams, Paginated},
|
||||||
|
},
|
||||||
|
value_objects::MovieId,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{context::AppContext, queries::GetDiaryQuery};
|
||||||
|
|
||||||
|
pub async fn execute(
|
||||||
|
ctx: &AppContext,
|
||||||
|
query: GetDiaryQuery,
|
||||||
|
) -> Result<Paginated<DiaryEntry>, DomainError> {
|
||||||
|
let page = PageParams::new(query.limit, query.offset)?;
|
||||||
|
|
||||||
|
let movie_id = query.movie_id.map(MovieId::from_uuid);
|
||||||
|
|
||||||
|
let filter = DiaryFilter {
|
||||||
|
sort_by: query.sort_by.unwrap_or(SortDirection::Descending),
|
||||||
|
page,
|
||||||
|
movie_id,
|
||||||
|
};
|
||||||
|
|
||||||
|
let paginated_results = ctx.repository.query_diary(&filter).await?;
|
||||||
|
|
||||||
|
Ok(paginated_results)
|
||||||
|
}
|
||||||
23
crates/application/src/use_cases/get_review_history.rs
Normal file
23
crates/application/src/use_cases/get_review_history.rs
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
use domain::{
|
||||||
|
errors::DomainError,
|
||||||
|
models::ReviewHistory,
|
||||||
|
services::review_history::{ReviewHistoryAnalyzer, Trend},
|
||||||
|
value_objects::MovieId,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{context::AppContext, queries::GetReviewHistoryQuery};
|
||||||
|
|
||||||
|
pub async fn execute(
|
||||||
|
ctx: &AppContext,
|
||||||
|
query: GetReviewHistoryQuery,
|
||||||
|
) -> Result<(ReviewHistory, Trend), DomainError> {
|
||||||
|
let movie_id = MovieId::from_uuid(query.movie_id);
|
||||||
|
|
||||||
|
let mut history = ctx.repository.get_review_history(&movie_id).await?;
|
||||||
|
|
||||||
|
let trend = ReviewHistoryAnalyzer::rating_trend(&history)?;
|
||||||
|
|
||||||
|
ReviewHistoryAnalyzer::sort_chronologically(&mut history);
|
||||||
|
|
||||||
|
Ok((history, trend))
|
||||||
|
}
|
||||||
120
crates/application/src/use_cases/log_review.rs
Normal file
120
crates/application/src/use_cases/log_review.rs
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
use domain::{
|
||||||
|
errors::DomainError,
|
||||||
|
events::DomainEvent,
|
||||||
|
models::{Movie, Review},
|
||||||
|
ports::MetadataSearchCriteria,
|
||||||
|
value_objects::{Comment, ExternalMetadataId, MovieTitle, Rating, ReleaseYear, UserId},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{commands::LogReviewCommand, context::AppContext};
|
||||||
|
|
||||||
|
pub async fn execute(ctx: &AppContext, cmd: LogReviewCommand) -> Result<(), DomainError> {
|
||||||
|
let rating = Rating::new(cmd.rating)?;
|
||||||
|
let user_id = UserId::from_uuid(cmd.user_id);
|
||||||
|
let comment = cmd.comment.clone().map(Comment::new).transpose()?;
|
||||||
|
|
||||||
|
let (movie, is_new_movie) = resolve_movie(ctx, &cmd).await?;
|
||||||
|
|
||||||
|
ctx.repository.upsert_movie(&movie).await?;
|
||||||
|
|
||||||
|
let review = Review::new(movie.id().clone(), user_id, rating, comment, cmd.watched_at)?;
|
||||||
|
let review_event = ctx.repository.save_review(&review).await?;
|
||||||
|
|
||||||
|
publish_events(ctx, &movie, is_new_movie, review_event).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn resolve_movie(
|
||||||
|
ctx: &AppContext,
|
||||||
|
cmd: &LogReviewCommand,
|
||||||
|
) -> Result<(Movie, bool), DomainError> {
|
||||||
|
if let Some(ext_id_str) = &cmd.external_metadata_id {
|
||||||
|
if let Some(resolved) = resolve_external_movie(ctx, ext_id_str).await? {
|
||||||
|
return Ok(resolved);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve_manual_movie(ctx, cmd).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn resolve_external_movie(
|
||||||
|
ctx: &AppContext,
|
||||||
|
ext_id_str: &str,
|
||||||
|
) -> Result<Option<(Movie, bool)>, DomainError> {
|
||||||
|
let tmdb_id = ExternalMetadataId::new(ext_id_str.to_string())?;
|
||||||
|
|
||||||
|
if let Some(m) = ctx.repository.get_movie_by_external_id(&tmdb_id).await? {
|
||||||
|
return Ok(Some((m, false)));
|
||||||
|
}
|
||||||
|
|
||||||
|
match ctx
|
||||||
|
.metadata_client
|
||||||
|
.fetch_movie_metadata(&MetadataSearchCriteria::ImdbId(tmdb_id))
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(m) => Ok(Some((m, true))),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(
|
||||||
|
"Failed to fetch from TMDB, falling back to manual entry: {:?}",
|
||||||
|
e
|
||||||
|
);
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn resolve_manual_movie(
|
||||||
|
ctx: &AppContext,
|
||||||
|
cmd: &LogReviewCommand,
|
||||||
|
) -> Result<(Movie, bool), DomainError> {
|
||||||
|
let title_str = cmd.manual_title.as_ref().ok_or_else(|| {
|
||||||
|
DomainError::ValidationError(
|
||||||
|
"Manual title required if TMDB fetch fails or is omitted".into(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let year_val = cmd.manual_release_year.ok_or_else(|| {
|
||||||
|
DomainError::ValidationError(
|
||||||
|
"Manual release year required if TMDB fetch fails or is omitted".into(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let title = MovieTitle::new(title_str.clone())?;
|
||||||
|
let release_year = ReleaseYear::new(year_val)?;
|
||||||
|
|
||||||
|
let candidates = ctx
|
||||||
|
.repository
|
||||||
|
.get_movies_by_title_and_year(&title, &release_year)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let matched_movie = candidates
|
||||||
|
.into_iter()
|
||||||
|
.find(|m| m.is_manual_match(&title, &release_year, cmd.manual_director.as_deref()));
|
||||||
|
|
||||||
|
if let Some(existing_movie) = matched_movie {
|
||||||
|
Ok((existing_movie, false))
|
||||||
|
} else {
|
||||||
|
let new_movie = Movie::new(None, title, release_year, cmd.manual_director.clone(), None);
|
||||||
|
Ok((new_movie, true))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn publish_events(
|
||||||
|
ctx: &AppContext,
|
||||||
|
movie: &Movie,
|
||||||
|
is_new_movie: bool,
|
||||||
|
review_event: DomainEvent,
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
|
if is_new_movie {
|
||||||
|
if let Some(ext_id) = movie.external_metadata_id() {
|
||||||
|
let discovery_event = DomainEvent::MovieDiscovered {
|
||||||
|
movie_id: movie.id().clone(),
|
||||||
|
external_metadata_id: ext_id.clone(),
|
||||||
|
};
|
||||||
|
ctx.event_publisher.publish(&discovery_event).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.event_publisher.publish(&review_event).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
39
crates/application/src/use_cases/login.rs
Normal file
39
crates/application/src/use_cases/login.rs
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use domain::{errors::DomainError, value_objects::Email};
|
||||||
|
|
||||||
|
use crate::{commands::LoginCommand, context::AppContext};
|
||||||
|
|
||||||
|
pub struct LoginResult {
|
||||||
|
pub token: String,
|
||||||
|
pub user_id: Uuid,
|
||||||
|
pub email: String,
|
||||||
|
pub expires_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn execute(ctx: &AppContext, cmd: LoginCommand) -> Result<LoginResult, DomainError> {
|
||||||
|
let email = Email::new(cmd.email)?;
|
||||||
|
let user = ctx
|
||||||
|
.user_repository
|
||||||
|
.find_by_email(&email)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| DomainError::Unauthorized("Invalid credentials".into()))?;
|
||||||
|
|
||||||
|
let valid = ctx
|
||||||
|
.password_hasher
|
||||||
|
.verify(&cmd.password, user.password_hash())
|
||||||
|
.await?;
|
||||||
|
if !valid {
|
||||||
|
return Err(DomainError::Unauthorized("Invalid credentials".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let generated = ctx.auth_service.generate_token(user.id()).await?;
|
||||||
|
|
||||||
|
Ok(LoginResult {
|
||||||
|
token: generated.token,
|
||||||
|
user_id: user.id().value(),
|
||||||
|
email: user.email().value().to_string(),
|
||||||
|
expires_at: generated.expires_at,
|
||||||
|
})
|
||||||
|
}
|
||||||
6
crates/application/src/use_cases/mod.rs
Normal file
6
crates/application/src/use_cases/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
pub mod get_diary;
|
||||||
|
pub mod get_review_history;
|
||||||
|
pub mod log_review;
|
||||||
|
pub mod login;
|
||||||
|
pub mod register;
|
||||||
|
pub mod sync_poster;
|
||||||
18
crates/application/src/use_cases/register.rs
Normal file
18
crates/application/src/use_cases/register.rs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
use domain::{errors::DomainError, models::User, value_objects::Email};
|
||||||
|
|
||||||
|
use crate::{commands::RegisterCommand, context::AppContext};
|
||||||
|
|
||||||
|
pub async fn execute(ctx: &AppContext, cmd: RegisterCommand) -> Result<(), DomainError> {
|
||||||
|
if !ctx.config.allow_registration {
|
||||||
|
return Err(DomainError::Unauthorized("Registration is disabled".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let email = Email::new(cmd.email)?;
|
||||||
|
|
||||||
|
if ctx.user_repository.find_by_email(&email).await?.is_some() {
|
||||||
|
return Err(DomainError::ValidationError("Email already registered".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let hash = ctx.password_hasher.hash(&cmd.password).await?;
|
||||||
|
ctx.user_repository.save(&User::new(email, hash)).await
|
||||||
|
}
|
||||||
47
crates/application/src/use_cases/sync_poster.rs
Normal file
47
crates/application/src/use_cases/sync_poster.rs
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
use domain::{
|
||||||
|
errors::DomainError,
|
||||||
|
value_objects::{ExternalMetadataId, MovieId},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{commands::SyncPosterCommand, context::AppContext};
|
||||||
|
|
||||||
|
pub async fn execute(ctx: &AppContext, cmd: SyncPosterCommand) -> Result<(), DomainError> {
|
||||||
|
let movie_id = MovieId::from_uuid(cmd.movie_id);
|
||||||
|
let external_metadata_id = ExternalMetadataId::new(cmd.external_metadata_id)?;
|
||||||
|
|
||||||
|
let mut movie = match ctx.repository.get_movie_by_id(&movie_id).await? {
|
||||||
|
Some(m) => m,
|
||||||
|
None => {
|
||||||
|
tracing::warn!(
|
||||||
|
"Sync cancelled: Movie {} not found in local DB",
|
||||||
|
movie_id.value()
|
||||||
|
);
|
||||||
|
return Err(DomainError::NotFound("Movie not found".into()));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let poster_url = match ctx
|
||||||
|
.metadata_client
|
||||||
|
.get_poster_url(&external_metadata_id)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(Some(url)) => url,
|
||||||
|
Ok(None) => return Ok(()),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("Warning: Failed to find poster URL metadata: {:?}", e);
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let image_bytes = ctx.poster_fetcher.fetch_poster_bytes(&poster_url).await?;
|
||||||
|
|
||||||
|
let stored_path = ctx
|
||||||
|
.poster_storage
|
||||||
|
.store_poster(&movie_id, &image_bytes)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
movie.update_poster(stored_path);
|
||||||
|
ctx.repository.upsert_movie(&movie).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -13,4 +13,7 @@ pub enum DomainError {
|
|||||||
|
|
||||||
#[error("Infrastructure failure: {0}")]
|
#[error("Infrastructure failure: {0}")]
|
||||||
InfrastructureError(String),
|
InfrastructureError(String),
|
||||||
|
|
||||||
|
#[error("Unauthorized: {0}")]
|
||||||
|
Unauthorized(String),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use chrono::NaiveDateTime;
|
use chrono::NaiveDateTime;
|
||||||
|
|
||||||
use crate::value_objects::{MovieId, Rating, ReviewId, UserId};
|
use crate::value_objects::{ExternalMetadataId, MovieId, Rating, ReviewId, UserId};
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub enum DomainEvent {
|
pub enum DomainEvent {
|
||||||
@@ -11,4 +11,8 @@ pub enum DomainEvent {
|
|||||||
rating: Rating,
|
rating: Rating,
|
||||||
watched_at: NaiveDateTime,
|
watched_at: NaiveDateTime,
|
||||||
},
|
},
|
||||||
|
MovieDiscovered {
|
||||||
|
movie_id: MovieId,
|
||||||
|
external_metadata_id: ExternalMetadataId,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ pub struct DiaryFilter {
|
|||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct Movie {
|
pub struct Movie {
|
||||||
id: MovieId,
|
id: MovieId,
|
||||||
external_metadata_id: ExternalMetadataId,
|
external_metadata_id: Option<ExternalMetadataId>,
|
||||||
title: MovieTitle,
|
title: MovieTitle,
|
||||||
release_year: ReleaseYear,
|
release_year: ReleaseYear,
|
||||||
director: Option<String>,
|
director: Option<String>,
|
||||||
@@ -36,7 +36,7 @@ pub struct Movie {
|
|||||||
|
|
||||||
impl Movie {
|
impl Movie {
|
||||||
pub fn new(
|
pub fn new(
|
||||||
external_metadata_id: ExternalMetadataId,
|
external_metadata_id: Option<ExternalMetadataId>,
|
||||||
title: MovieTitle,
|
title: MovieTitle,
|
||||||
release_year: ReleaseYear,
|
release_year: ReleaseYear,
|
||||||
director: Option<String>,
|
director: Option<String>,
|
||||||
@@ -52,11 +52,33 @@ impl Movie {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn from_persistence(
|
||||||
|
id: MovieId,
|
||||||
|
external_metadata_id: Option<ExternalMetadataId>,
|
||||||
|
title: MovieTitle,
|
||||||
|
release_year: ReleaseYear,
|
||||||
|
director: Option<String>,
|
||||||
|
poster_path: Option<PosterPath>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
id,
|
||||||
|
external_metadata_id,
|
||||||
|
title,
|
||||||
|
release_year,
|
||||||
|
director,
|
||||||
|
poster_path,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_poster(&mut self, poster_path: PosterPath) {
|
||||||
|
self.poster_path = Some(poster_path);
|
||||||
|
}
|
||||||
|
|
||||||
pub fn id(&self) -> &MovieId {
|
pub fn id(&self) -> &MovieId {
|
||||||
&self.id
|
&self.id
|
||||||
}
|
}
|
||||||
pub fn external_metadata_id(&self) -> &ExternalMetadataId {
|
pub fn external_metadata_id(&self) -> Option<&ExternalMetadataId> {
|
||||||
&self.external_metadata_id
|
self.external_metadata_id.as_ref()
|
||||||
}
|
}
|
||||||
pub fn title(&self) -> &MovieTitle {
|
pub fn title(&self) -> &MovieTitle {
|
||||||
&self.title
|
&self.title
|
||||||
@@ -72,6 +94,24 @@ impl Movie {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Movie {
|
||||||
|
pub fn is_manual_match(
|
||||||
|
&self,
|
||||||
|
title: &MovieTitle,
|
||||||
|
year: &ReleaseYear,
|
||||||
|
director: Option<&str>,
|
||||||
|
) -> bool {
|
||||||
|
if self.title != *title || self.release_year != *year {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
match (self.director(), director) {
|
||||||
|
(Some(existing_dir), Some(new_dir)) => existing_dir.eq_ignore_ascii_case(new_dir),
|
||||||
|
_ => true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct Review {
|
pub struct Review {
|
||||||
id: ReviewId,
|
id: ReviewId,
|
||||||
@@ -110,6 +150,26 @@ impl Review {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn from_persistence(
|
||||||
|
id: ReviewId,
|
||||||
|
movie_id: MovieId,
|
||||||
|
user_id: UserId,
|
||||||
|
rating: Rating,
|
||||||
|
comment: Option<Comment>,
|
||||||
|
watched_at: NaiveDateTime,
|
||||||
|
created_at: NaiveDateTime,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
id,
|
||||||
|
movie_id,
|
||||||
|
user_id,
|
||||||
|
rating,
|
||||||
|
comment,
|
||||||
|
watched_at,
|
||||||
|
created_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn id(&self) -> &ReviewId {
|
pub fn id(&self) -> &ReviewId {
|
||||||
&self.id
|
&self.id
|
||||||
}
|
}
|
||||||
@@ -135,14 +195,43 @@ impl Review {
|
|||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct DiaryEntry {
|
pub struct DiaryEntry {
|
||||||
pub movie: Movie,
|
movie: Movie,
|
||||||
pub review: Review,
|
review: Review,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DiaryEntry {
|
||||||
|
pub fn new(movie: Movie, review: Review) -> Self {
|
||||||
|
Self { movie, review }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn movie(&self) -> &Movie {
|
||||||
|
&self.movie
|
||||||
|
}
|
||||||
|
pub fn review(&self) -> &Review {
|
||||||
|
&self.review
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct ReviewHistory {
|
pub struct ReviewHistory {
|
||||||
pub movie: Movie,
|
movie: Movie,
|
||||||
pub viewings: Vec<Review>,
|
viewings: Vec<Review>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ReviewHistory {
|
||||||
|
pub fn new(movie: Movie, viewings: Vec<Review>) -> Self {
|
||||||
|
Self { movie, viewings }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn movie(&self) -> &Movie {
|
||||||
|
&self.movie
|
||||||
|
}
|
||||||
|
pub fn viewings(&self) -> &[Review] {
|
||||||
|
&self.viewings
|
||||||
|
}
|
||||||
|
pub fn viewings_mut(&mut self) -> &mut Vec<Review> {
|
||||||
|
&mut self.viewings
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
@@ -161,6 +250,10 @@ impl User {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn from_persistence(id: UserId, email: Email, password_hash: PasswordHash) -> Self {
|
||||||
|
Self { id, email, password_hash }
|
||||||
|
}
|
||||||
|
|
||||||
pub fn update_password(&mut self, new_hash: PasswordHash) {
|
pub fn update_password(&mut self, new_hash: PasswordHash) {
|
||||||
self.password_hash = new_hash;
|
self.password_hash = new_hash;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,29 @@
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
events::DomainEvent,
|
events::DomainEvent,
|
||||||
models::{DiaryEntry, DiaryFilter, Movie, Review, ReviewHistory, collections::Paginated},
|
models::{DiaryEntry, DiaryFilter, Movie, Review, ReviewHistory, User, collections::Paginated},
|
||||||
value_objects::{ExternalMetadataId, MovieId, PasswordHash, PosterPath, UserId},
|
value_objects::{
|
||||||
|
Email, ExternalMetadataId, MovieId, MovieTitle, PasswordHash, PosterPath, PosterUrl,
|
||||||
|
ReleaseYear, UserId,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait MovieRepository: Send + Sync {
|
pub trait MovieRepository: Send + Sync {
|
||||||
|
async fn get_movie_by_external_id(
|
||||||
|
&self,
|
||||||
|
external_metadata_id: &ExternalMetadataId,
|
||||||
|
) -> Result<Option<Movie>, DomainError>;
|
||||||
|
async fn get_movie_by_id(&self, movie_id: &MovieId) -> Result<Option<Movie>, DomainError>;
|
||||||
|
async fn get_movies_by_title_and_year(
|
||||||
|
&self,
|
||||||
|
title: &MovieTitle,
|
||||||
|
year: &ReleaseYear,
|
||||||
|
) -> Result<Vec<Movie>, DomainError>;
|
||||||
|
|
||||||
async fn upsert_movie(&self, movie: &Movie) -> Result<(), DomainError>;
|
async fn upsert_movie(&self, movie: &Movie) -> Result<(), DomainError>;
|
||||||
|
|
||||||
async fn save_review(&self, review: &Review) -> Result<DomainEvent, DomainError>;
|
async fn save_review(&self, review: &Review) -> Result<DomainEvent, DomainError>;
|
||||||
@@ -19,17 +34,26 @@ pub trait MovieRepository: Send + Sync {
|
|||||||
async fn get_review_history(&self, movie_id: &MovieId) -> Result<ReviewHistory, DomainError>;
|
async fn get_review_history(&self, movie_id: &MovieId) -> Result<ReviewHistory, DomainError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub enum MetadataSearchCriteria {
|
||||||
|
ImdbId(ExternalMetadataId),
|
||||||
|
Title { title: String, year: Option<u16> },
|
||||||
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait MetadataClient: Send + Sync {
|
pub trait MetadataClient: Send + Sync {
|
||||||
async fn fetch_movie_metadata(
|
async fn fetch_movie_metadata(
|
||||||
&self,
|
&self,
|
||||||
external_metadata_id: &ExternalMetadataId,
|
criteria: &MetadataSearchCriteria,
|
||||||
) -> Result<Movie, DomainError>;
|
) -> Result<Movie, DomainError>;
|
||||||
|
async fn get_poster_url(
|
||||||
|
&self,
|
||||||
|
external_metadata_id: &ExternalMetadataId,
|
||||||
|
) -> Result<Option<PosterUrl>, DomainError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait PosterFetcherClient: Send + Sync {
|
pub trait PosterFetcherClient: Send + Sync {
|
||||||
async fn fetch_poster_bytes(&self, poster_url: &str) -> Result<Vec<u8>, DomainError>;
|
async fn fetch_poster_bytes(&self, poster_url: &PosterUrl) -> Result<Vec<u8>, DomainError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -43,11 +67,23 @@ pub trait PosterStorage: Send + Sync {
|
|||||||
async fn get_poster(&self, poster_path: &PosterPath) -> Result<Vec<u8>, DomainError>;
|
async fn get_poster(&self, poster_path: &PosterPath) -> Result<Vec<u8>, DomainError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub struct GeneratedToken {
|
||||||
|
pub token: String,
|
||||||
|
pub expires_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait AuthService: Send + Sync {
|
pub trait AuthService: Send + Sync {
|
||||||
|
async fn generate_token(&self, user_id: &UserId) -> Result<GeneratedToken, DomainError>;
|
||||||
async fn validate_token(&self, token: &str) -> Result<UserId, DomainError>;
|
async fn validate_token(&self, token: &str) -> Result<UserId, DomainError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait UserRepository: Send + Sync {
|
||||||
|
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError>;
|
||||||
|
async fn save(&self, user: &User) -> Result<(), DomainError>;
|
||||||
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait EventPublisher: Send + Sync {
|
pub trait EventPublisher: Send + Sync {
|
||||||
async fn publish(&self, event: &DomainEvent) -> Result<(), DomainError>;
|
async fn publish(&self, event: &DomainEvent) -> Result<(), DomainError>;
|
||||||
|
|||||||
@@ -12,35 +12,35 @@ pub enum Trend {
|
|||||||
impl ReviewHistoryAnalyzer {
|
impl ReviewHistoryAnalyzer {
|
||||||
pub fn sort_chronologically(history: &mut ReviewHistory) {
|
pub fn sort_chronologically(history: &mut ReviewHistory) {
|
||||||
history
|
history
|
||||||
.viewings
|
.viewings_mut()
|
||||||
.sort_by(|a, b| a.watched_at().cmp(&b.watched_at()));
|
.sort_by(|a, b| a.watched_at().cmp(&b.watched_at()));
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_latest_rating(history: &ReviewHistory) -> Option<&Rating> {
|
pub fn get_latest_rating(history: &ReviewHistory) -> Option<&Rating> {
|
||||||
history
|
history
|
||||||
.viewings
|
.viewings()
|
||||||
.iter()
|
.iter()
|
||||||
.max_by_key(|r| r.watched_at())
|
.max_by_key(|r| r.watched_at())
|
||||||
.map(|r| r.rating())
|
.map(|r| r.rating())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn rating_trend(history: &ReviewHistory) -> Result<Trend, DomainError> {
|
pub fn rating_trend(history: &ReviewHistory) -> Result<Trend, DomainError> {
|
||||||
if history.viewings.len() < 2 {
|
if history.viewings().len() < 2 {
|
||||||
return Ok(Trend::Neutral);
|
return Ok(Trend::Neutral);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut sorted_history = history.clone();
|
let mut sorted_history = history.clone();
|
||||||
Self::sort_chronologically(&mut sorted_history);
|
Self::sort_chronologically(&mut sorted_history);
|
||||||
|
|
||||||
let latest_review = sorted_history.viewings.pop().unwrap();
|
let latest_review = sorted_history.viewings().last().unwrap();
|
||||||
let latest_rating = latest_review.rating().value() as f32;
|
let latest_rating = latest_review.rating().value() as f32;
|
||||||
|
|
||||||
let previous_sum: u32 = sorted_history
|
let previous_sum: u32 = sorted_history
|
||||||
.viewings
|
.viewings()
|
||||||
.iter()
|
.iter()
|
||||||
.map(|r| r.rating().value() as u32)
|
.map(|r| r.rating().value() as u32)
|
||||||
.sum();
|
.sum();
|
||||||
let historical_average = previous_sum as f32 / sorted_history.viewings.len() as f32;
|
let historical_average = previous_sum as f32 / sorted_history.viewings().len() as f32;
|
||||||
|
|
||||||
if latest_rating > historical_average {
|
if latest_rating > historical_average {
|
||||||
Ok(Trend::Improved)
|
Ok(Trend::Improved)
|
||||||
|
|||||||
@@ -206,3 +206,23 @@ impl PasswordHash {
|
|||||||
&self.0
|
&self.0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct PosterUrl(String);
|
||||||
|
|
||||||
|
impl PosterUrl {
|
||||||
|
pub fn new(url: String) -> Result<Self, DomainError> {
|
||||||
|
let trimmed = url.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
Err(DomainError::ValidationError(
|
||||||
|
"Poster URL cannot be empty".into(),
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
Ok(Self(trimmed.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn value(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,3 +4,29 @@ version = "0.1.0"
|
|||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
axum = { version = "0.8.8", features = ["macros"] }
|
||||||
|
tower-http = { version = "0.6.8", features = ["fs", "trace", "tracing"] }
|
||||||
|
|
||||||
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
anyhow = { workspace = true }
|
||||||
|
thiserror = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
|
tracing-subscriber = { workspace = true }
|
||||||
|
tokio = { workspace = true }
|
||||||
|
dotenvy = { workspace = true }
|
||||||
|
uuid = { workspace = true }
|
||||||
|
chrono = { workspace = true }
|
||||||
|
async-trait = { workspace = true }
|
||||||
|
|
||||||
|
domain = { workspace = true }
|
||||||
|
application = { workspace = true }
|
||||||
|
auth = { workspace = true }
|
||||||
|
metadata = { workspace = true }
|
||||||
|
sqlite = { workspace = true }
|
||||||
|
sqlx = { workspace = true }
|
||||||
|
template-askama = { workspace = true }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tower = { version = "0.5", features = ["util"] }
|
||||||
|
http-body-util = "0.1"
|
||||||
|
|||||||
127
crates/presentation/src/dtos.rs
Normal file
127
crates/presentation/src/dtos.rs
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct DiaryQueryParams {
|
||||||
|
pub limit: Option<u32>,
|
||||||
|
pub offset: Option<u32>,
|
||||||
|
pub sort_by: Option<String>,
|
||||||
|
pub movie_id: Option<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct LogReviewForm {
|
||||||
|
pub external_metadata_id: Option<String>,
|
||||||
|
pub manual_title: Option<String>,
|
||||||
|
pub manual_release_year: Option<u16>,
|
||||||
|
pub manual_director: Option<String>,
|
||||||
|
pub rating: u8,
|
||||||
|
pub comment: Option<String>,
|
||||||
|
pub watched_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct LogReviewRequest {
|
||||||
|
pub external_metadata_id: Option<String>,
|
||||||
|
pub manual_title: Option<String>,
|
||||||
|
pub manual_release_year: Option<u16>,
|
||||||
|
pub manual_director: Option<String>,
|
||||||
|
pub rating: u8,
|
||||||
|
pub comment: Option<String>,
|
||||||
|
pub watched_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct MovieDto {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub title: String,
|
||||||
|
pub release_year: u16,
|
||||||
|
pub director: Option<String>,
|
||||||
|
pub poster_path: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct ReviewDto {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub rating: u8,
|
||||||
|
pub comment: Option<String>,
|
||||||
|
pub watched_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct DiaryEntryDto {
|
||||||
|
pub movie: MovieDto,
|
||||||
|
pub review: ReviewDto,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct DiaryResponse {
|
||||||
|
pub items: Vec<DiaryEntryDto>,
|
||||||
|
pub total_count: u64,
|
||||||
|
pub limit: u32,
|
||||||
|
pub offset: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct ReviewHistoryResponse {
|
||||||
|
pub movie: MovieDto,
|
||||||
|
pub viewings: Vec<ReviewDto>,
|
||||||
|
pub trend: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct LoginRequest {
|
||||||
|
pub email: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct LoginResponse {
|
||||||
|
pub token: String,
|
||||||
|
pub user_id: Uuid,
|
||||||
|
pub email: String,
|
||||||
|
pub expires_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct RegisterRequest {
|
||||||
|
pub email: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn diary_response_serializes_correctly() {
|
||||||
|
let resp = DiaryResponse {
|
||||||
|
items: vec![],
|
||||||
|
total_count: 0,
|
||||||
|
limit: 20,
|
||||||
|
offset: 0,
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&resp).unwrap();
|
||||||
|
assert!(json.contains("\"total_count\":0"));
|
||||||
|
assert!(json.contains("\"items\":[]"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn diary_query_params_fields_are_optional() {
|
||||||
|
let params = DiaryQueryParams {
|
||||||
|
limit: None,
|
||||||
|
offset: None,
|
||||||
|
sort_by: None,
|
||||||
|
movie_id: None,
|
||||||
|
};
|
||||||
|
assert!(params.limit.is_none());
|
||||||
|
assert!(params.sort_by.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn login_request_deserializes() {
|
||||||
|
let json = r#"{"email":"a@b.com","password":"secret"}"#;
|
||||||
|
let req: LoginRequest = serde_json::from_str(json).unwrap();
|
||||||
|
assert_eq!(req.email, "a@b.com");
|
||||||
|
}
|
||||||
|
}
|
||||||
33
crates/presentation/src/errors.rs
Normal file
33
crates/presentation/src/errors.rs
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
use axum::{
|
||||||
|
http::StatusCode,
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
};
|
||||||
|
use domain::errors::DomainError;
|
||||||
|
|
||||||
|
pub struct ApiError(pub DomainError);
|
||||||
|
|
||||||
|
impl From<DomainError> for ApiError {
|
||||||
|
fn from(err: DomainError) -> Self {
|
||||||
|
Self(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoResponse for ApiError {
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
let (status, error_message) = match self.0 {
|
||||||
|
DomainError::InvalidRating { .. } => (StatusCode::BAD_REQUEST, self.0.to_string()),
|
||||||
|
DomainError::ValidationError(msg) => (StatusCode::BAD_REQUEST, msg),
|
||||||
|
DomainError::NotFound(msg) => (StatusCode::NOT_FOUND, msg),
|
||||||
|
DomainError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, msg),
|
||||||
|
DomainError::InfrastructureError(_) => {
|
||||||
|
tracing::error!("Internal Infrastructure Error: {:?}", self.0);
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"Internal server error".to_string(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
(status, error_message).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
119
crates/presentation/src/extractors.rs
Normal file
119
crates/presentation/src/extractors.rs
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
use axum::{
|
||||||
|
extract::{FromRef, FromRequestParts},
|
||||||
|
http::{header::AUTHORIZATION, request::Parts},
|
||||||
|
};
|
||||||
|
use domain::{errors::DomainError, value_objects::UserId};
|
||||||
|
|
||||||
|
use crate::{errors::ApiError, state::AppState};
|
||||||
|
|
||||||
|
pub struct AuthenticatedUser(pub UserId);
|
||||||
|
|
||||||
|
impl<S> FromRequestParts<S> for AuthenticatedUser
|
||||||
|
where
|
||||||
|
AppState: FromRef<S>,
|
||||||
|
S: Send + Sync,
|
||||||
|
{
|
||||||
|
type Rejection = ApiError;
|
||||||
|
|
||||||
|
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
|
||||||
|
let app_state = AppState::from_ref(state);
|
||||||
|
let token = parts
|
||||||
|
.headers
|
||||||
|
.get(AUTHORIZATION)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.and_then(|v| v.strip_prefix("Bearer "))
|
||||||
|
.ok_or_else(|| {
|
||||||
|
ApiError(DomainError::Unauthorized(
|
||||||
|
"Missing or invalid auth token".into(),
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
let user_id = app_state
|
||||||
|
.app_ctx
|
||||||
|
.auth_service
|
||||||
|
.validate_token(token)
|
||||||
|
.await?;
|
||||||
|
Ok(AuthenticatedUser(user_id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use axum::{
|
||||||
|
body::Body,
|
||||||
|
http::{Request, StatusCode},
|
||||||
|
routing::get,
|
||||||
|
Router,
|
||||||
|
};
|
||||||
|
use tower::ServiceExt;
|
||||||
|
|
||||||
|
async fn protected_handler(user: AuthenticatedUser) -> String {
|
||||||
|
user.0.value().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn test_router(state: crate::state::AppState) -> Router {
|
||||||
|
Router::new()
|
||||||
|
.route("/protected", get(protected_handler))
|
||||||
|
.with_state(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn missing_auth_header_returns_401() {
|
||||||
|
use std::sync::Arc;
|
||||||
|
use application::context::AppContext;
|
||||||
|
|
||||||
|
struct PanicRepo;
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl domain::ports::MovieRepository for PanicRepo {
|
||||||
|
async fn get_movie_by_external_id(&self, _: &domain::value_objects::ExternalMetadataId) -> Result<Option<domain::models::Movie>, domain::errors::DomainError> { panic!() }
|
||||||
|
async fn get_movie_by_id(&self, _: &domain::value_objects::MovieId) -> Result<Option<domain::models::Movie>, domain::errors::DomainError> { panic!() }
|
||||||
|
async fn get_movies_by_title_and_year(&self, _: &domain::value_objects::MovieTitle, _: &domain::value_objects::ReleaseYear) -> Result<Vec<domain::models::Movie>, domain::errors::DomainError> { panic!() }
|
||||||
|
async fn upsert_movie(&self, _: &domain::models::Movie) -> Result<(), domain::errors::DomainError> { panic!() }
|
||||||
|
async fn save_review(&self, _: &domain::models::Review) -> Result<domain::events::DomainEvent, domain::errors::DomainError> { panic!() }
|
||||||
|
async fn query_diary(&self, _: &domain::models::DiaryFilter) -> Result<domain::models::collections::Paginated<domain::models::DiaryEntry>, domain::errors::DomainError> { panic!() }
|
||||||
|
async fn get_review_history(&self, _: &domain::value_objects::MovieId) -> Result<domain::models::ReviewHistory, domain::errors::DomainError> { panic!() }
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PanicRenderer;
|
||||||
|
impl crate::ports::HtmlRenderer for PanicRenderer {
|
||||||
|
fn render_diary_page(&self, _: &domain::models::collections::Paginated<domain::models::DiaryEntry>) -> Result<String, String> { panic!() }
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PanicMeta; struct PanicFetcher; struct PanicStorage; struct PanicEvent; struct PanicHasher; struct PanicAuth; struct PanicUserRepo;
|
||||||
|
#[async_trait::async_trait] impl domain::ports::MetadataClient for PanicMeta { async fn fetch_movie_metadata(&self, _: &domain::ports::MetadataSearchCriteria) -> Result<domain::models::Movie, domain::errors::DomainError> { panic!() } async fn get_poster_url(&self, _: &domain::value_objects::ExternalMetadataId) -> Result<Option<domain::value_objects::PosterUrl>, domain::errors::DomainError> { panic!() } }
|
||||||
|
#[async_trait::async_trait] impl domain::ports::PosterFetcherClient for PanicFetcher { async fn fetch_poster_bytes(&self, _: &domain::value_objects::PosterUrl) -> Result<Vec<u8>, domain::errors::DomainError> { panic!() } }
|
||||||
|
#[async_trait::async_trait] impl domain::ports::PosterStorage for PanicStorage { async fn store_poster(&self, _: &domain::value_objects::MovieId, _: &[u8]) -> Result<domain::value_objects::PosterPath, domain::errors::DomainError> { panic!() } async fn get_poster(&self, _: &domain::value_objects::PosterPath) -> Result<Vec<u8>, domain::errors::DomainError> { panic!() } }
|
||||||
|
#[async_trait::async_trait] impl domain::ports::EventPublisher for PanicEvent { async fn publish(&self, _: &domain::events::DomainEvent) -> Result<(), domain::errors::DomainError> { panic!() } }
|
||||||
|
#[async_trait::async_trait] impl domain::ports::PasswordHasher for PanicHasher { async fn hash(&self, _: &str) -> Result<domain::value_objects::PasswordHash, domain::errors::DomainError> { panic!() } async fn verify(&self, _: &str, _: &domain::value_objects::PasswordHash) -> Result<bool, domain::errors::DomainError> { panic!() } }
|
||||||
|
#[async_trait::async_trait] impl domain::ports::AuthService for PanicAuth { async fn generate_token(&self, _: &domain::value_objects::UserId) -> Result<domain::ports::GeneratedToken, domain::errors::DomainError> { panic!() } async fn validate_token(&self, _: &str) -> Result<domain::value_objects::UserId, domain::errors::DomainError> { panic!() } }
|
||||||
|
#[async_trait::async_trait] impl domain::ports::UserRepository for PanicUserRepo { async fn find_by_email(&self, _: &domain::value_objects::Email) -> Result<Option<domain::models::User>, domain::errors::DomainError> { panic!() } async fn save(&self, _: &domain::models::User) -> Result<(), domain::errors::DomainError> { panic!() } }
|
||||||
|
|
||||||
|
let state = crate::state::AppState {
|
||||||
|
app_ctx: AppContext {
|
||||||
|
repository: Arc::new(PanicRepo),
|
||||||
|
metadata_client: Arc::new(PanicMeta),
|
||||||
|
poster_fetcher: Arc::new(PanicFetcher),
|
||||||
|
poster_storage: Arc::new(PanicStorage),
|
||||||
|
event_publisher: Arc::new(PanicEvent),
|
||||||
|
auth_service: Arc::new(PanicAuth),
|
||||||
|
password_hasher: Arc::new(PanicHasher),
|
||||||
|
user_repository: Arc::new(PanicUserRepo),
|
||||||
|
config: application::config::AppConfig { allow_registration: false },
|
||||||
|
},
|
||||||
|
html_renderer: Arc::new(PanicRenderer),
|
||||||
|
};
|
||||||
|
|
||||||
|
let app = test_router(state);
|
||||||
|
let response = app
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri("/protected")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
}
|
||||||
275
crates/presentation/src/handlers.rs
Normal file
275
crates/presentation/src/handlers.rs
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
pub mod html {
|
||||||
|
use axum::{
|
||||||
|
extract::{Query, State},
|
||||||
|
response::{Html, IntoResponse, Redirect},
|
||||||
|
Form,
|
||||||
|
};
|
||||||
|
use chrono::NaiveDateTime;
|
||||||
|
|
||||||
|
use application::{
|
||||||
|
commands::LogReviewCommand,
|
||||||
|
queries::GetDiaryQuery,
|
||||||
|
use_cases::{get_diary, log_review},
|
||||||
|
};
|
||||||
|
use domain::{errors::DomainError, models::SortDirection};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
dtos::{DiaryQueryParams, LogReviewForm},
|
||||||
|
errors::ApiError,
|
||||||
|
extractors::AuthenticatedUser,
|
||||||
|
state::AppState,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub async fn get_diary_page(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Query(params): Query<DiaryQueryParams>,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
let query = GetDiaryQuery {
|
||||||
|
limit: params.limit,
|
||||||
|
offset: params.offset,
|
||||||
|
sort_by: params.sort_by.as_deref().map(|s| {
|
||||||
|
if s == "asc" {
|
||||||
|
SortDirection::Ascending
|
||||||
|
} else {
|
||||||
|
SortDirection::Descending
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
movie_id: params.movie_id,
|
||||||
|
};
|
||||||
|
|
||||||
|
let page = get_diary::execute(&state.app_ctx, query).await?;
|
||||||
|
let html = state
|
||||||
|
.html_renderer
|
||||||
|
.render_diary_page(&page)
|
||||||
|
.map_err(|e| ApiError(DomainError::InfrastructureError(e)))?;
|
||||||
|
|
||||||
|
Ok(Html(html))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn post_review(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
user: AuthenticatedUser,
|
||||||
|
Form(form): Form<LogReviewForm>,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
let watched_at = NaiveDateTime::parse_from_str(&form.watched_at, "%Y-%m-%dT%H:%M:%S")
|
||||||
|
.map_err(|_| {
|
||||||
|
ApiError(DomainError::ValidationError(
|
||||||
|
"Invalid watched_at format, expected YYYY-MM-DDTHH:MM:SS".into(),
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let cmd = LogReviewCommand {
|
||||||
|
external_metadata_id: form.external_metadata_id,
|
||||||
|
manual_title: form.manual_title,
|
||||||
|
manual_release_year: form.manual_release_year,
|
||||||
|
manual_director: form.manual_director,
|
||||||
|
user_id: user.0.value(),
|
||||||
|
rating: form.rating,
|
||||||
|
comment: form.comment,
|
||||||
|
watched_at,
|
||||||
|
};
|
||||||
|
|
||||||
|
log_review::execute(&state.app_ctx, cmd).await?;
|
||||||
|
|
||||||
|
Ok(Redirect::to("/diary"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod api {
|
||||||
|
use axum::{
|
||||||
|
Json,
|
||||||
|
extract::{Path, Query, State},
|
||||||
|
http::StatusCode,
|
||||||
|
response::IntoResponse,
|
||||||
|
};
|
||||||
|
use chrono::NaiveDateTime;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use application::{
|
||||||
|
commands::{LoginCommand, LogReviewCommand, RegisterCommand, SyncPosterCommand},
|
||||||
|
queries::{GetDiaryQuery, GetReviewHistoryQuery},
|
||||||
|
use_cases::{get_diary, get_review_history, log_review, login as login_uc, register as register_uc, sync_poster},
|
||||||
|
};
|
||||||
|
use domain::{
|
||||||
|
errors::DomainError,
|
||||||
|
models::{DiaryEntry, Movie, Review, SortDirection},
|
||||||
|
services::review_history::Trend,
|
||||||
|
value_objects::MovieId,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
dtos::{
|
||||||
|
DiaryEntryDto, DiaryQueryParams, DiaryResponse, LoginRequest, LoginResponse,
|
||||||
|
LogReviewRequest, MovieDto, RegisterRequest, ReviewDto, ReviewHistoryResponse,
|
||||||
|
},
|
||||||
|
errors::ApiError,
|
||||||
|
extractors::AuthenticatedUser,
|
||||||
|
state::AppState,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub async fn get_diary(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Query(params): Query<DiaryQueryParams>,
|
||||||
|
) -> Result<Json<DiaryResponse>, ApiError> {
|
||||||
|
let query = GetDiaryQuery {
|
||||||
|
limit: params.limit,
|
||||||
|
offset: params.offset,
|
||||||
|
sort_by: params.sort_by.as_deref().map(|s| {
|
||||||
|
if s == "asc" {
|
||||||
|
SortDirection::Ascending
|
||||||
|
} else {
|
||||||
|
SortDirection::Descending
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
movie_id: params.movie_id,
|
||||||
|
};
|
||||||
|
|
||||||
|
let page = get_diary::execute(&state.app_ctx, query).await?;
|
||||||
|
|
||||||
|
Ok(Json(DiaryResponse {
|
||||||
|
items: page.items.iter().map(entry_to_dto).collect(),
|
||||||
|
total_count: page.total_count,
|
||||||
|
limit: page.limit,
|
||||||
|
offset: page.offset,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_review_history(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(movie_id): Path<Uuid>,
|
||||||
|
) -> Result<Json<ReviewHistoryResponse>, ApiError> {
|
||||||
|
let (history, trend) = get_review_history::execute(
|
||||||
|
&state.app_ctx,
|
||||||
|
GetReviewHistoryQuery { movie_id },
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(ReviewHistoryResponse {
|
||||||
|
movie: movie_to_dto(history.movie()),
|
||||||
|
viewings: history.viewings().iter().map(review_to_dto).collect(),
|
||||||
|
trend: match trend {
|
||||||
|
Trend::Improved => "improved",
|
||||||
|
Trend::Declined => "declined",
|
||||||
|
Trend::Neutral => "neutral",
|
||||||
|
}
|
||||||
|
.to_string(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn post_review(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
user: AuthenticatedUser,
|
||||||
|
Json(req): Json<LogReviewRequest>,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
let watched_at = NaiveDateTime::parse_from_str(&req.watched_at, "%Y-%m-%dT%H:%M:%S")
|
||||||
|
.map_err(|_| {
|
||||||
|
ApiError(DomainError::ValidationError(
|
||||||
|
"Invalid watched_at format, expected YYYY-MM-DDTHH:MM:SS".into(),
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let cmd = LogReviewCommand {
|
||||||
|
external_metadata_id: req.external_metadata_id,
|
||||||
|
manual_title: req.manual_title,
|
||||||
|
manual_release_year: req.manual_release_year,
|
||||||
|
manual_director: req.manual_director,
|
||||||
|
user_id: user.0.value(),
|
||||||
|
rating: req.rating,
|
||||||
|
comment: req.comment,
|
||||||
|
watched_at,
|
||||||
|
};
|
||||||
|
|
||||||
|
log_review::execute(&state.app_ctx, cmd).await?;
|
||||||
|
|
||||||
|
Ok(StatusCode::CREATED)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn sync_poster(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
_user: AuthenticatedUser,
|
||||||
|
Path(movie_id): Path<Uuid>,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
let movie = state
|
||||||
|
.app_ctx
|
||||||
|
.repository
|
||||||
|
.get_movie_by_id(&MovieId::from_uuid(movie_id))
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| ApiError(DomainError::NotFound(format!("Movie {movie_id}"))))?;
|
||||||
|
|
||||||
|
let external_id = movie
|
||||||
|
.external_metadata_id()
|
||||||
|
.ok_or_else(|| {
|
||||||
|
ApiError(DomainError::ValidationError(
|
||||||
|
"Movie has no external metadata ID, cannot sync poster".into(),
|
||||||
|
))
|
||||||
|
})?
|
||||||
|
.value()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
sync_poster::execute(
|
||||||
|
&state.app_ctx,
|
||||||
|
SyncPosterCommand {
|
||||||
|
movie_id,
|
||||||
|
external_metadata_id: external_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn login(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(req): Json<LoginRequest>,
|
||||||
|
) -> Result<Json<LoginResponse>, ApiError> {
|
||||||
|
let result = login_uc::execute(&state.app_ctx, LoginCommand {
|
||||||
|
email: req.email,
|
||||||
|
password: req.password,
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
Ok(Json(LoginResponse {
|
||||||
|
token: result.token,
|
||||||
|
user_id: result.user_id,
|
||||||
|
email: result.email,
|
||||||
|
expires_at: result.expires_at.to_rfc3339(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn register(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(req): Json<RegisterRequest>,
|
||||||
|
) -> Result<StatusCode, ApiError> {
|
||||||
|
register_uc::execute(&state.app_ctx, RegisterCommand {
|
||||||
|
email: req.email,
|
||||||
|
password: req.password,
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
Ok(StatusCode::CREATED)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn movie_to_dto(movie: &Movie) -> MovieDto {
|
||||||
|
MovieDto {
|
||||||
|
id: movie.id().value(),
|
||||||
|
title: movie.title().value().to_string(),
|
||||||
|
release_year: movie.release_year().value(),
|
||||||
|
director: movie.director().map(|d| d.to_string()),
|
||||||
|
poster_path: movie.poster_path().map(|p| p.value().to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn review_to_dto(review: &Review) -> ReviewDto {
|
||||||
|
ReviewDto {
|
||||||
|
id: review.id().value(),
|
||||||
|
rating: review.rating().value(),
|
||||||
|
comment: review.comment().map(|c| c.value().to_string()),
|
||||||
|
watched_at: review.watched_at().to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn entry_to_dto(entry: &DiaryEntry) -> DiaryEntryDto {
|
||||||
|
DiaryEntryDto {
|
||||||
|
movie: movie_to_dto(entry.movie()),
|
||||||
|
review: review_to_dto(entry.review()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
7
crates/presentation/src/lib.rs
Normal file
7
crates/presentation/src/lib.rs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
pub mod dtos;
|
||||||
|
pub mod errors;
|
||||||
|
pub mod extractors;
|
||||||
|
pub mod handlers;
|
||||||
|
pub mod ports;
|
||||||
|
pub mod routes;
|
||||||
|
pub mod state;
|
||||||
@@ -1,3 +1,126 @@
|
|||||||
fn main() {
|
use std::sync::Arc;
|
||||||
println!("Hello, world!");
|
|
||||||
|
use anyhow::Context;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use domain::{
|
||||||
|
errors::DomainError,
|
||||||
|
events::DomainEvent,
|
||||||
|
ports::{EventPublisher, PosterFetcherClient, PosterStorage},
|
||||||
|
value_objects::{MovieId, PosterPath, PosterUrl},
|
||||||
|
};
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
use tokio::net::TcpListener;
|
||||||
|
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||||
|
|
||||||
|
use application::{config::AppConfig, context::AppContext};
|
||||||
|
use auth::{AuthConfig, Argon2PasswordHasher, JwtAuthService};
|
||||||
|
use metadata::MetadataClientImpl;
|
||||||
|
use sqlite::{SqliteMovieRepository, SqliteUserRepository};
|
||||||
|
use template_askama::AskamaHtmlRenderer;
|
||||||
|
|
||||||
|
use presentation::{routes, state::AppState};
|
||||||
|
|
||||||
|
struct StubPosterFetcher;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl PosterFetcherClient for StubPosterFetcher {
|
||||||
|
async fn fetch_poster_bytes(&self, _url: &PosterUrl) -> Result<Vec<u8>, DomainError> {
|
||||||
|
Err(DomainError::InfrastructureError(
|
||||||
|
"poster fetcher not implemented".into(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StubPosterStorage;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl PosterStorage for StubPosterStorage {
|
||||||
|
async fn store_poster(
|
||||||
|
&self,
|
||||||
|
_movie_id: &MovieId,
|
||||||
|
_bytes: &[u8],
|
||||||
|
) -> Result<PosterPath, DomainError> {
|
||||||
|
Err(DomainError::InfrastructureError(
|
||||||
|
"poster storage not implemented".into(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_poster(&self, _path: &PosterPath) -> Result<Vec<u8>, DomainError> {
|
||||||
|
Err(DomainError::InfrastructureError(
|
||||||
|
"poster storage not implemented".into(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StubEventPublisher;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl EventPublisher for StubEventPublisher {
|
||||||
|
async fn publish(&self, _event: &DomainEvent) -> Result<(), DomainError> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> anyhow::Result<()> {
|
||||||
|
dotenvy::dotenv().ok();
|
||||||
|
init_tracing();
|
||||||
|
|
||||||
|
let state = wire_dependencies()
|
||||||
|
.await
|
||||||
|
.context("Failed to wire dependencies")?;
|
||||||
|
|
||||||
|
let app = routes::build_router(state);
|
||||||
|
|
||||||
|
let listener = TcpListener::bind("0.0.0.0:3000").await?;
|
||||||
|
tracing::info!("Listening on 0.0.0.0:3000");
|
||||||
|
axum::serve(listener, app).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn wire_dependencies() -> anyhow::Result<AppState> {
|
||||||
|
let auth_config = AuthConfig::from_env()?;
|
||||||
|
let app_config = AppConfig::from_env();
|
||||||
|
let omdb_api_key = std::env::var("OMDB_API_KEY").context("OMDB_API_KEY must be set")?;
|
||||||
|
|
||||||
|
let pool = SqlitePool::connect("sqlite://reviews.db")
|
||||||
|
.await
|
||||||
|
.context("Failed to connect to SQLite database")?;
|
||||||
|
|
||||||
|
let movie_repo = SqliteMovieRepository::new(pool.clone());
|
||||||
|
movie_repo
|
||||||
|
.migrate()
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("{}", e))
|
||||||
|
.context("Database migration failed")?;
|
||||||
|
|
||||||
|
let user_repo = SqliteUserRepository::new(pool);
|
||||||
|
|
||||||
|
let app_ctx = AppContext {
|
||||||
|
repository: Arc::new(movie_repo),
|
||||||
|
metadata_client: Arc::new(MetadataClientImpl::new_omdb(omdb_api_key)),
|
||||||
|
poster_fetcher: Arc::new(StubPosterFetcher),
|
||||||
|
poster_storage: Arc::new(StubPosterStorage),
|
||||||
|
event_publisher: Arc::new(StubEventPublisher),
|
||||||
|
auth_service: Arc::new(JwtAuthService::new(auth_config)),
|
||||||
|
password_hasher: Arc::new(Argon2PasswordHasher),
|
||||||
|
user_repository: Arc::new(user_repo),
|
||||||
|
config: app_config,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(AppState {
|
||||||
|
app_ctx,
|
||||||
|
html_renderer: Arc::new(AskamaHtmlRenderer::new()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn init_tracing() {
|
||||||
|
tracing_subscriber::registry()
|
||||||
|
.with(tracing_subscriber::EnvFilter::new(
|
||||||
|
std::env::var("RUST_LOG")
|
||||||
|
.unwrap_or_else(|_| "presentation=debug,tower_http=debug".into()),
|
||||||
|
))
|
||||||
|
.with(tracing_subscriber::fmt::layer())
|
||||||
|
.init();
|
||||||
}
|
}
|
||||||
|
|||||||
1
crates/presentation/src/ports.rs
Normal file
1
crates/presentation/src/ports.rs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
pub use application::ports::HtmlRenderer;
|
||||||
38
crates/presentation/src/routes.rs
Normal file
38
crates/presentation/src/routes.rs
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
use axum::{Router, routing};
|
||||||
|
use tower_http::{services::ServeDir, trace::TraceLayer};
|
||||||
|
|
||||||
|
use crate::{handlers, state::AppState};
|
||||||
|
|
||||||
|
pub fn build_router(state: AppState) -> Router {
|
||||||
|
Router::new()
|
||||||
|
.merge(html_routes())
|
||||||
|
.merge(api_routes())
|
||||||
|
.nest_service("/static", ServeDir::new("static"))
|
||||||
|
.layer(TraceLayer::new_for_http())
|
||||||
|
.with_state(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn html_routes() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route("/diary", routing::get(handlers::html::get_diary_page))
|
||||||
|
.route("/reviews", routing::post(handlers::html::post_review))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn api_routes() -> Router<AppState> {
|
||||||
|
Router::new().nest(
|
||||||
|
"/api",
|
||||||
|
Router::new()
|
||||||
|
.route("/diary", routing::get(handlers::api::get_diary))
|
||||||
|
.route(
|
||||||
|
"/movies/{id}/history",
|
||||||
|
routing::get(handlers::api::get_review_history),
|
||||||
|
)
|
||||||
|
.route("/reviews", routing::post(handlers::api::post_review))
|
||||||
|
.route(
|
||||||
|
"/movies/{id}/sync-poster",
|
||||||
|
routing::post(handlers::api::sync_poster),
|
||||||
|
)
|
||||||
|
.route("/auth/login", routing::post(handlers::api::login))
|
||||||
|
.route("/auth/register", routing::post(handlers::api::register)),
|
||||||
|
)
|
||||||
|
}
|
||||||
11
crates/presentation/src/state.rs
Normal file
11
crates/presentation/src/state.rs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use application::context::AppContext;
|
||||||
|
|
||||||
|
use crate::ports::HtmlRenderer;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AppState {
|
||||||
|
pub app_ctx: AppContext,
|
||||||
|
pub html_renderer: Arc<dyn HtmlRenderer>,
|
||||||
|
}
|
||||||
168
crates/presentation/tests/api_test.rs
Normal file
168
crates/presentation/tests/api_test.rs
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use application::{config::AppConfig, context::AppContext};
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use axum::{
|
||||||
|
Router,
|
||||||
|
body::Body,
|
||||||
|
http::{Request, StatusCode},
|
||||||
|
};
|
||||||
|
use domain::{
|
||||||
|
errors::DomainError,
|
||||||
|
events::DomainEvent,
|
||||||
|
models::{Movie, User},
|
||||||
|
ports::{
|
||||||
|
AuthService, EventPublisher, GeneratedToken, MetadataClient, MetadataSearchCriteria,
|
||||||
|
PasswordHasher, PosterFetcherClient, PosterStorage, UserRepository,
|
||||||
|
},
|
||||||
|
value_objects::{
|
||||||
|
Email, ExternalMetadataId, MovieId, PasswordHash, PosterPath, PosterUrl, UserId,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
use http_body_util::BodyExt;
|
||||||
|
use presentation::{routes, state::AppState};
|
||||||
|
use sqlite::SqliteMovieRepository;
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
use template_askama::AskamaHtmlRenderer;
|
||||||
|
use tower::ServiceExt;
|
||||||
|
|
||||||
|
struct NoopEventPublisher;
|
||||||
|
#[async_trait]
|
||||||
|
impl EventPublisher for NoopEventPublisher {
|
||||||
|
async fn publish(&self, _: &DomainEvent) -> Result<(), DomainError> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PanicMeta;
|
||||||
|
#[async_trait]
|
||||||
|
impl MetadataClient for PanicMeta {
|
||||||
|
async fn fetch_movie_metadata(&self, _: &MetadataSearchCriteria) -> Result<Movie, DomainError> {
|
||||||
|
panic!("metadata not wired in tests")
|
||||||
|
}
|
||||||
|
async fn get_poster_url(&self, _: &ExternalMetadataId) -> Result<Option<PosterUrl>, DomainError> {
|
||||||
|
panic!()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PanicFetcher;
|
||||||
|
#[async_trait]
|
||||||
|
impl PosterFetcherClient for PanicFetcher {
|
||||||
|
async fn fetch_poster_bytes(&self, _: &PosterUrl) -> Result<Vec<u8>, DomainError> {
|
||||||
|
panic!()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PanicStorage;
|
||||||
|
#[async_trait]
|
||||||
|
impl PosterStorage for PanicStorage {
|
||||||
|
async fn store_poster(&self, _: &MovieId, _: &[u8]) -> Result<PosterPath, DomainError> {
|
||||||
|
panic!()
|
||||||
|
}
|
||||||
|
async fn get_poster(&self, _: &PosterPath) -> Result<Vec<u8>, DomainError> {
|
||||||
|
panic!()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PanicHasher;
|
||||||
|
#[async_trait]
|
||||||
|
impl PasswordHasher for PanicHasher {
|
||||||
|
async fn hash(&self, _: &str) -> Result<PasswordHash, DomainError> { panic!() }
|
||||||
|
async fn verify(&self, _: &str, _: &PasswordHash) -> Result<bool, DomainError> { panic!() }
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PanicAuth;
|
||||||
|
#[async_trait]
|
||||||
|
impl AuthService for PanicAuth {
|
||||||
|
async fn generate_token(&self, _: &UserId) -> Result<GeneratedToken, DomainError> { panic!() }
|
||||||
|
async fn validate_token(&self, _: &str) -> Result<UserId, DomainError> { panic!() }
|
||||||
|
}
|
||||||
|
|
||||||
|
struct NobodyUserRepo;
|
||||||
|
#[async_trait]
|
||||||
|
impl UserRepository for NobodyUserRepo {
|
||||||
|
async fn find_by_email(&self, _: &Email) -> Result<Option<User>, DomainError> { Ok(None) }
|
||||||
|
async fn save(&self, _: &User) -> Result<(), DomainError> { panic!() }
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn test_app() -> Router {
|
||||||
|
let pool = SqlitePool::connect("sqlite::memory:")
|
||||||
|
.await
|
||||||
|
.expect("in-memory SQLite failed");
|
||||||
|
let repo = SqliteMovieRepository::new(pool);
|
||||||
|
repo.migrate().await.expect("migration failed");
|
||||||
|
|
||||||
|
let state = AppState {
|
||||||
|
app_ctx: AppContext {
|
||||||
|
repository: Arc::new(repo),
|
||||||
|
metadata_client: Arc::new(PanicMeta),
|
||||||
|
poster_fetcher: Arc::new(PanicFetcher),
|
||||||
|
poster_storage: Arc::new(PanicStorage),
|
||||||
|
event_publisher: Arc::new(NoopEventPublisher),
|
||||||
|
auth_service: Arc::new(PanicAuth),
|
||||||
|
password_hasher: Arc::new(PanicHasher),
|
||||||
|
user_repository: Arc::new(NobodyUserRepo),
|
||||||
|
config: AppConfig { allow_registration: false },
|
||||||
|
},
|
||||||
|
html_renderer: Arc::new(AskamaHtmlRenderer::new()),
|
||||||
|
};
|
||||||
|
|
||||||
|
routes::build_router(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn get_api_diary_returns_empty_list() {
|
||||||
|
let app = test_app().await;
|
||||||
|
let response = app
|
||||||
|
.oneshot(Request::builder().uri("/api/diary").body(Body::empty()).unwrap())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
let bytes = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(json["total_count"], 0);
|
||||||
|
assert_eq!(json["items"], serde_json::json!([]));
|
||||||
|
assert_eq!(json["limit"], 20);
|
||||||
|
assert_eq!(json["offset"], 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn post_api_reviews_without_auth_returns_401() {
|
||||||
|
let app = test_app().await;
|
||||||
|
let response = app
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/api/reviews")
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.body(Body::from(
|
||||||
|
r#"{"rating":4,"watched_at":"2026-01-01T20:00:00","manual_title":"Dune","manual_release_year":2021}"#,
|
||||||
|
))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn post_api_auth_login_unknown_user_returns_401() {
|
||||||
|
let app = test_app().await;
|
||||||
|
let response = app
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/api/auth/login")
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.body(Body::from(r#"{"email":"a@b.com","password":"x"}"#))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user