Skip to main content

max / makenotwork

9.8 KB · 307 lines History Blame Raw
1 //! Content lifecycle: create item -> add text content -> update -> delete -> soft-deleted
2
3 use crate::harness::TestHarness;
4 use serde_json::Value;
5
6 #[tokio::test]
7 async fn item_lifecycle() {
8 let mut h = TestHarness::new().await;
9
10 // Setup: creator with project
11 let user_id = h.signup("author", "author@example.com", "password123").await;
12 h.grant_creator(user_id).await;
13 h.client.post_form("/logout", "").await;
14 h.login("author", "password123").await;
15
16 let resp = h
17 .client
18 .post_form("/api/projects", "slug=my-project&title=My+Project")
19 .await;
20 let project: Value = resp.json();
21 let project_id = project["id"].as_str().unwrap();
22
23 // Create item
24 let resp = h
25 .client
26 .post_form(
27 &format!("/api/projects/{}/items", project_id),
28 "title=My+Article&item_type=text",
29 )
30 .await;
31 assert!(resp.status.is_success(), "Create item failed: {}", resp.text);
32 let item: Value = resp.json();
33 let item_id = item["id"].as_str().unwrap();
34
35 // Add text content
36 let resp = h
37 .client
38 .put_json(
39 &format!("/api/items/{}/text", item_id),
40 "{\"body\": \"# Hello\\n\\nThis is my article content.\"}",
41 )
42 .await;
43 assert!(
44 resp.status.is_success(),
45 "Update text failed: {} {}",
46 resp.status,
47 resp.text
48 );
49 let text_resp: Value = resp.json();
50 assert!(text_resp["word_count"].as_u64().unwrap() > 0);
51
52 // Update text content
53 let resp = h
54 .client
55 .put_json(
56 &format!("/api/items/{}/text", item_id),
57 "{\"body\": \"# Updated\\n\\nRevised article content with more words.\"}",
58 )
59 .await;
60 assert!(resp.status.is_success(), "Update text failed: {}", resp.text);
61
62 // Delete item
63 let resp = h
64 .client
65 .delete(&format!("/api/items/{}", item_id))
66 .await;
67 assert!(resp.status.is_success(), "Delete item failed: {}", resp.text);
68
69 // Verify item is soft-deleted (deleted_at set, not visible to normal queries)
70 let deleted_at: Option<chrono::DateTime<chrono::Utc>> = sqlx::query_scalar(
71 "SELECT deleted_at FROM items WHERE id = $1",
72 )
73 .bind(item_id.parse::<uuid::Uuid>().unwrap())
74 .fetch_one(&h.db)
75 .await
76 .unwrap();
77 assert!(deleted_at.is_some(), "Item should be soft-deleted (deleted_at set)");
78
79 // Verify item is not visible to normal listing queries
80 let visible_count = sqlx::query_scalar::<_, i64>(
81 "SELECT COUNT(*) FROM items WHERE id = $1 AND deleted_at IS NULL",
82 )
83 .bind(item_id.parse::<uuid::Uuid>().unwrap())
84 .fetch_one(&h.db)
85 .await
86 .unwrap();
87 assert_eq!(visible_count, 0, "Soft-deleted item should not be visible");
88 }
89
90 #[tokio::test]
91 async fn item_text_update() {
92 let mut h = TestHarness::new().await;
93
94 let user_id = h.signup("textwriter", "textwriter@example.com", "password123").await;
95 h.grant_creator(user_id).await;
96 h.client.post_form("/logout", "").await;
97 h.login("textwriter", "password123").await;
98
99 let resp = h
100 .client
101 .post_form("/api/projects", "slug=text-proj&title=Text+Project")
102 .await;
103 let project: Value = resp.json();
104 let project_id = project["id"].as_str().unwrap();
105
106 // Create a text item
107 let resp = h
108 .client
109 .post_form(
110 &format!("/api/projects/{}/items", project_id),
111 "title=Text+Article&item_type=text",
112 )
113 .await;
114 assert!(resp.status.is_success(), "Create item failed: {}", resp.text);
115 let item: Value = resp.json();
116 let item_id = item["id"].as_str().unwrap();
117
118 // Set text body
119 let resp = h
120 .client
121 .put_json(
122 &format!("/api/items/{}/text", item_id),
123 r##"{"body": "# First Draft\n\nSome initial content here."}"##,
124 )
125 .await;
126 assert!(resp.status.is_success(), "Set text failed: {} {}", resp.status, resp.text);
127 let text: Value = resp.json();
128 assert!(text["word_count"].as_i64().unwrap() > 0, "Word count should be positive");
129
130 // Update text body
131 let resp = h
132 .client
133 .put_json(
134 &format!("/api/items/{}/text", item_id),
135 r##"{"body": "# Revised Draft\n\nCompletely rewritten with new material and extra words."}"##,
136 )
137 .await;
138 assert!(resp.status.is_success(), "Update text failed: {} {}", resp.status, resp.text);
139 let text: Value = resp.json();
140 assert_eq!(
141 text["body"].as_str(),
142 Some("# Revised Draft\n\nCompletely rewritten with new material and extra words."),
143 "Body should reflect the update"
144 );
145 }
146
147 #[tokio::test]
148 async fn item_duplicate() {
149 let mut h = TestHarness::new().await;
150
151 let user_id = h.signup("dupuser", "dupuser@example.com", "password123").await;
152 h.grant_creator(user_id).await;
153 h.client.post_form("/logout", "").await;
154 h.login("dupuser", "password123").await;
155
156 let resp = h
157 .client
158 .post_form("/api/projects", "slug=dup-proj&title=Dup+Project")
159 .await;
160 let project: Value = resp.json();
161 let project_id = project["id"].as_str().unwrap();
162
163 // Create item with title, description, price
164 let resp = h
165 .client
166 .post_form(
167 &format!("/api/projects/{}/items", project_id),
168 "title=Original+Item&item_type=text&price_cents=500",
169 )
170 .await;
171 assert!(resp.status.is_success(), "Create item failed: {}", resp.text);
172 let item: Value = resp.json();
173 let item_id = item["id"].as_str().unwrap();
174
175 // Add a description
176 let resp = h
177 .client
178 .put_form(
179 &format!("/api/items/{}", item_id),
180 "description=A+great+item",
181 )
182 .await;
183 assert!(resp.status.is_success(), "Update item failed: {}", resp.text);
184
185 // Duplicate
186 let resp = h
187 .client
188 .post_json(
189 &format!("/api/items/{}/duplicate", item_id),
190 "{}",
191 )
192 .await;
193 assert!(resp.status.is_success(), "Duplicate failed: {} {}", resp.status, resp.text);
194 let dup: Value = resp.json();
195
196 // Verify the duplicate has "Copy of" prefix and is a draft
197 let dup_title = dup["title"].as_str().unwrap();
198 assert!(
199 dup_title.starts_with("Copy of"),
200 "Duplicate title should start with 'Copy of', got: {}",
201 dup_title
202 );
203 assert_eq!(dup["is_public"].as_bool(), Some(false), "Duplicate should be a draft");
204 assert_eq!(dup["price_cents"].as_i64(), Some(500), "Duplicate should preserve price");
205 assert_ne!(dup["id"].as_str(), Some(item_id), "Duplicate should have a new ID");
206 }
207
208 #[tokio::test]
209 async fn non_owner_cannot_edit_item() {
210 let mut h = TestHarness::new().await;
211
212 // User A creates project + item
213 let user_a = h.signup("itemowner", "itemowner@example.com", "password123").await;
214 h.grant_creator(user_a).await;
215 h.client.post_form("/logout", "").await;
216 h.login("itemowner", "password123").await;
217
218 let resp = h
219 .client
220 .post_form("/api/projects", "slug=owner-proj&title=Owner+Project")
221 .await;
222 let project: Value = resp.json();
223 let project_id = project["id"].as_str().unwrap();
224
225 let resp = h
226 .client
227 .post_form(
228 &format!("/api/projects/{}/items", project_id),
229 "title=Private+Item&item_type=text",
230 )
231 .await;
232 assert!(resp.status.is_success(), "Create item failed: {}", resp.text);
233 let item: Value = resp.json();
234 let item_id = item["id"].as_str().unwrap();
235
236 // Log out, sign up user B
237 h.client.post_form("/logout", "").await;
238 let user_b = h.signup("itemintruder", "itemintruder@example.com", "password123").await;
239 h.grant_creator(user_b).await;
240 h.client.post_form("/logout", "").await;
241 h.login("itemintruder", "password123").await;
242
243 // User B tries to update user A's item
244 let resp = h
245 .client
246 .put_form(
247 &format!("/api/items/{}", item_id),
248 "title=Hacked+Item",
249 )
250 .await;
251 assert_eq!(resp.status, 403, "Non-owner update should be 403, got {}", resp.status);
252 }
253
254 #[tokio::test]
255 async fn publish_unpublish_item() {
256 let mut h = TestHarness::new().await;
257
258 let user_id = h.signup("pubuser", "pubuser@example.com", "password123").await;
259 h.grant_creator(user_id).await;
260 h.client.post_form("/logout", "").await;
261 h.login("pubuser", "password123").await;
262
263 let resp = h
264 .client
265 .post_form("/api/projects", "slug=pub-proj&title=Pub+Project")
266 .await;
267 let project: Value = resp.json();
268 let project_id = project["id"].as_str().unwrap();
269
270 // Create item (public by default per DB schema)
271 let resp = h
272 .client
273 .post_form(
274 &format!("/api/projects/{}/items", project_id),
275 "title=Toggle+Item&item_type=text",
276 )
277 .await;
278 assert!(resp.status.is_success(), "Create item failed: {}", resp.text);
279 let item: Value = resp.json();
280 let item_id = item["id"].as_str().unwrap();
281 assert_eq!(item["is_public"].as_bool(), Some(true), "New item should be public by default");
282
283 // Unpublish (make draft)
284 let resp = h
285 .client
286 .put_form(
287 &format!("/api/items/{}", item_id),
288 "is_public=false",
289 )
290 .await;
291 assert!(resp.status.is_success(), "Unpublish failed: {} {}", resp.status, resp.text);
292 let updated: Value = resp.json();
293 assert_eq!(updated["is_public"].as_bool(), Some(false), "Item should be draft after unpublish");
294
295 // Republish
296 let resp = h
297 .client
298 .put_form(
299 &format!("/api/items/{}", item_id),
300 "is_public=true",
301 )
302 .await;
303 assert!(resp.status.is_success(), "Publish failed: {} {}", resp.status, resp.text);
304 let updated: Value = resp.json();
305 assert_eq!(updated["is_public"].as_bool(), Some(true), "Item should be public after republish");
306 }
307