chore: added initial testing suite c6bc132e
Steve Simkins · 2026-04-13 14:14 16 file(s) · +1624 −0
.github/workflows/ci.yml +25 −0
30 30
31 31
      - name: cargo check
32 32
        run: cargo check --workspace
33 +
34 +
  test:
35 +
    name: Test
36 +
    runs-on: ubuntu-latest
37 +
    steps:
38 +
      - uses: actions/checkout@v6
39 +
        with:
40 +
          submodules: recursive
41 +
42 +
      - name: Install Rust toolchain
43 +
        uses: dtolnay/rust-toolchain@stable
44 +
45 +
      - name: Cache cargo registry & build
46 +
        uses: actions/cache@v4
47 +
        with:
48 +
          path: |
49 +
            ~/.cargo/registry
50 +
            ~/.cargo/git
51 +
            target
52 +
          key: ${{ runner.os }}-cargo-test-${{ hashFiles('**/Cargo.lock') }}
53 +
          restore-keys: |
54 +
            ${{ runner.os }}-cargo-test-
55 +
56 +
      - name: cargo test
57 +
        run: cargo test --workspace
Cargo.lock +1 −0
4181 4181
 "serde_json",
4182 4182
 "subtle",
4183 4183
 "syntect",
4184 +
 "tempfile",
4184 4185
 "tokio",
4185 4186
 "toml",
4186 4187
 "tower-http",
Cargo.toml +3 −0
45 45
# Archive
46 46
zip = "2"
47 47
48 +
# Testing
49 +
tempfile = "3"
50 +
48 51
# Workspace crates
49 52
andromeda-auth = { path = "crates/auth" }
apps/cellar/src/db.rs +221 −0
361 361
    )?;
362 362
    Ok(())
363 363
}
364 +
365 +
#[cfg(test)]
366 +
mod tests {
367 +
    use super::*;
368 +
369 +
    fn test_db() -> Db {
370 +
        let conn = Connection::open_in_memory().unwrap();
371 +
        conn.execute_batch(
372 +
            "CREATE TABLE IF NOT EXISTS wines (
373 +
                id              INTEGER PRIMARY KEY AUTOINCREMENT,
374 +
                short_id        TEXT NOT NULL UNIQUE,
375 +
                name            TEXT NOT NULL,
376 +
                origin          TEXT NOT NULL,
377 +
                grape           TEXT NOT NULL,
378 +
                notes           TEXT NOT NULL,
379 +
                image           BLOB,
380 +
                image_mime      TEXT,
381 +
                sweetness       INTEGER NOT NULL CHECK(sweetness BETWEEN 1 AND 5),
382 +
                acidity         INTEGER NOT NULL CHECK(acidity BETWEEN 1 AND 5),
383 +
                tannin          INTEGER NOT NULL CHECK(tannin BETWEEN 1 AND 5),
384 +
                alcohol         INTEGER NOT NULL CHECK(alcohol BETWEEN 1 AND 5),
385 +
                body            INTEGER NOT NULL CHECK(body BETWEEN 1 AND 5),
386 +
                clarity         INTEGER NOT NULL DEFAULT 3,
387 +
                color_intensity INTEGER NOT NULL DEFAULT 3,
388 +
                aroma_intensity INTEGER NOT NULL DEFAULT 3,
389 +
                nose_complexity INTEGER NOT NULL DEFAULT 3,
390 +
                background      TEXT NOT NULL DEFAULT '',
391 +
                created_at      TEXT NOT NULL DEFAULT (datetime('now')),
392 +
                wishlist        INTEGER NOT NULL DEFAULT 0
393 +
            );
394 +
            CREATE TABLE IF NOT EXISTS sessions (
395 +
                id         INTEGER PRIMARY KEY AUTOINCREMENT,
396 +
                token      TEXT NOT NULL UNIQUE,
397 +
                expires_at TEXT NOT NULL
398 +
            );",
399 +
        )
400 +
        .unwrap();
401 +
        Arc::new(Mutex::new(conn))
402 +
    }
403 +
404 +
    fn create_test_wine(db: &Db, name: &str, wishlist: bool) -> Wine {
405 +
        create_wine(
406 +
            db, name, "France", "Merlot", "Smooth", None, None,
407 +
            3, 3, 3, 3, 3, 3, 3, 3, 3, "", wishlist,
408 +
        )
409 +
        .unwrap()
410 +
    }
411 +
412 +
    // ── Wine CRUD ──────────────────────────────────────────────────────
413 +
414 +
    #[test]
415 +
    fn create_and_get_wine() {
416 +
        let db = test_db();
417 +
        let wine = create_test_wine(&db, "Chateau Test", false);
418 +
        assert_eq!(wine.name, "Chateau Test");
419 +
        assert_eq!(wine.origin, "France");
420 +
        assert!(!wine.wishlist);
421 +
422 +
        let fetched = get_wine_by_short_id(&db, &wine.short_id).unwrap().unwrap();
423 +
        assert_eq!(fetched.name, "Chateau Test");
424 +
    }
425 +
426 +
    #[test]
427 +
    fn create_wine_invalid_sweetness_fails() {
428 +
        let db = test_db();
429 +
        let result = create_wine(
430 +
            &db, "Bad", "X", "X", "X", None, None,
431 +
            6, 3, 3, 3, 3, 3, 3, 3, 3, "", false, // sweetness=6 > 5
432 +
        );
433 +
        assert!(result.is_err());
434 +
    }
435 +
436 +
    #[test]
437 +
    fn create_wine_zero_rating_fails() {
438 +
        let db = test_db();
439 +
        let result = create_wine(
440 +
            &db, "Bad", "X", "X", "X", None, None,
441 +
            0, 3, 3, 3, 3, 3, 3, 3, 3, "", false, // sweetness=0 < 1
442 +
        );
443 +
        assert!(result.is_err());
444 +
    }
445 +
446 +
    #[test]
447 +
    fn get_cellar_wines_excludes_wishlist() {
448 +
        let db = test_db();
449 +
        create_test_wine(&db, "Cellar Wine", false);
450 +
        create_test_wine(&db, "Wishlist Wine", true);
451 +
452 +
        let cellar = get_cellar_wines(&db).unwrap();
453 +
        assert_eq!(cellar.len(), 1);
454 +
        assert_eq!(cellar[0].name, "Cellar Wine");
455 +
    }
456 +
457 +
    #[test]
458 +
    fn get_wishlist_wines_only_wishlist() {
459 +
        let db = test_db();
460 +
        create_test_wine(&db, "Cellar Wine", false);
461 +
        create_test_wine(&db, "Wishlist Wine", true);
462 +
463 +
        let wishlist = get_wishlist_wines(&db).unwrap();
464 +
        assert_eq!(wishlist.len(), 1);
465 +
        assert_eq!(wishlist[0].name, "Wishlist Wine");
466 +
    }
467 +
468 +
    #[test]
469 +
    fn promote_wine_moves_to_cellar() {
470 +
        let db = test_db();
471 +
        let wine = create_test_wine(&db, "To Promote", true);
472 +
473 +
        assert!(promote_wine(&db, &wine.short_id).unwrap());
474 +
475 +
        let promoted = get_wine_by_short_id(&db, &wine.short_id).unwrap().unwrap();
476 +
        assert!(!promoted.wishlist);
477 +
478 +
        assert_eq!(get_wishlist_wines(&db).unwrap().len(), 0);
479 +
        assert_eq!(get_cellar_wines(&db).unwrap().len(), 1);
480 +
    }
481 +
482 +
    #[test]
483 +
    fn promote_cellar_wine_returns_false() {
484 +
        let db = test_db();
485 +
        let wine = create_test_wine(&db, "Already Cellar", false);
486 +
        assert!(!promote_wine(&db, &wine.short_id).unwrap());
487 +
    }
488 +
489 +
    #[test]
490 +
    fn update_wine_works() {
491 +
        let db = test_db();
492 +
        let wine = create_test_wine(&db, "Old Name", false);
493 +
494 +
        let updated = update_wine(
495 +
            &db, &wine.short_id, "New Name", "Italy", "Sangiovese", "Bold",
496 +
            4, 4, 4, 4, 4, 4, 4, 4, 4, "deep red",
497 +
        )
498 +
        .unwrap()
499 +
        .unwrap();
500 +
501 +
        assert_eq!(updated.name, "New Name");
502 +
        assert_eq!(updated.origin, "Italy");
503 +
        assert_eq!(updated.sweetness, 4);
504 +
        assert_eq!(updated.background, "deep red");
505 +
    }
506 +
507 +
    #[test]
508 +
    fn update_wishlist_wine_works() {
509 +
        let db = test_db();
510 +
        let wine = create_test_wine(&db, "Wish", true);
511 +
512 +
        let updated = update_wishlist_wine(
513 +
            &db, &wine.short_id, "Updated Wish", "Spain", "Tempranillo", "Try soon", "amber",
514 +
        )
515 +
        .unwrap()
516 +
        .unwrap();
517 +
        assert_eq!(updated.name, "Updated Wish");
518 +
        assert!(updated.wishlist);
519 +
    }
520 +
521 +
    #[test]
522 +
    fn update_wishlist_wine_on_cellar_wine_returns_none() {
523 +
        let db = test_db();
524 +
        let wine = create_test_wine(&db, "Cellar", false);
525 +
        let result = update_wishlist_wine(&db, &wine.short_id, "X", "X", "X", "X", "").unwrap();
526 +
        assert!(result.is_none());
527 +
    }
528 +
529 +
    #[test]
530 +
    fn update_wine_image_and_get() {
531 +
        let db = test_db();
532 +
        let wine = create_test_wine(&db, "Photo Wine", false);
533 +
        assert!(!wine.has_image);
534 +
535 +
        let img_data = vec![0xFF, 0xD8, 0xFF]; // fake JPEG header
536 +
        assert!(update_wine_image(&db, &wine.short_id, &img_data, "image/jpeg").unwrap());
537 +
538 +
        let (data, mime) = get_wine_image(&db, &wine.short_id).unwrap().unwrap();
539 +
        assert_eq!(data, img_data);
540 +
        assert_eq!(mime, "image/jpeg");
541 +
    }
542 +
543 +
    #[test]
544 +
    fn get_wine_image_no_image() {
545 +
        let db = test_db();
546 +
        let wine = create_test_wine(&db, "No Photo", false);
547 +
        assert!(get_wine_image(&db, &wine.short_id).unwrap().is_none());
548 +
    }
549 +
550 +
    #[test]
551 +
    fn delete_wine_works() {
552 +
        let db = test_db();
553 +
        let wine = create_test_wine(&db, "Delete Me", false);
554 +
        assert!(delete_wine(&db, &wine.short_id).unwrap());
555 +
        assert!(get_wine_by_short_id(&db, &wine.short_id).unwrap().is_none());
556 +
    }
557 +
558 +
    #[test]
559 +
    fn delete_nonexistent_wine() {
560 +
        let db = test_db();
561 +
        assert!(!delete_wine(&db, "nope").unwrap());
562 +
    }
563 +
564 +
    // ── Sessions ───────────────────────────────────────────────────────
565 +
566 +
    #[test]
567 +
    fn session_lifecycle() {
568 +
        let db = test_db();
569 +
        insert_session(&db, "tok", "2099-01-01 00:00:00").unwrap();
570 +
        assert!(get_session_expiry(&db, "tok").unwrap().is_some());
571 +
        delete_session(&db, "tok").unwrap();
572 +
        assert!(get_session_expiry(&db, "tok").unwrap().is_none());
573 +
    }
574 +
575 +
    #[test]
576 +
    fn prune_expired_sessions_works() {
577 +
        let db = test_db();
578 +
        insert_session(&db, "old", "2000-01-01 00:00:00").unwrap();
579 +
        insert_session(&db, "new", "2099-01-01 00:00:00").unwrap();
580 +
        prune_expired_sessions(&db).unwrap();
581 +
        assert!(get_session_expiry(&db, "old").unwrap().is_none());
582 +
        assert!(get_session_expiry(&db, "new").unwrap().is_some());
583 +
    }
584 +
}
apps/feeds/src/feeds.rs +56 −0
315 315
    Ok(format!("Successfully added feed: {feed_url}"))
316 316
}
317 317
318 +
#[cfg(test)]
319 +
mod tests {
320 +
    use super::*;
321 +
322 +
    #[test]
323 +
    fn parse_opml_extracts_xml_urls() {
324 +
        let opml = r#"<?xml version="1.0" encoding="UTF-8"?>
325 +
<opml version="2.0">
326 +
  <body>
327 +
    <outline type="rss" text="Blog A" xmlUrl="https://a.com/feed" />
328 +
    <outline type="rss" text="Blog B" xmlUrl="https://b.com/rss" />
329 +
  </body>
330 +
</opml>"#;
331 +
        let urls = parse_opml(opml);
332 +
        assert_eq!(urls, vec!["https://a.com/feed", "https://b.com/rss"]);
333 +
    }
334 +
335 +
    #[test]
336 +
    fn parse_opml_empty_document() {
337 +
        let opml = r#"<?xml version="1.0"?><opml><body></body></opml>"#;
338 +
        assert!(parse_opml(opml).is_empty());
339 +
    }
340 +
341 +
    #[test]
342 +
    fn parse_opml_no_xml_url_attribute() {
343 +
        let opml = r#"<?xml version="1.0"?>
344 +
<opml><body>
345 +
  <outline type="rss" text="No URL" htmlUrl="https://example.com" />
346 +
</body></opml>"#;
347 +
        assert!(parse_opml(opml).is_empty());
348 +
    }
349 +
350 +
    #[test]
351 +
    fn parse_opml_nested_outlines() {
352 +
        let opml = r#"<?xml version="1.0"?>
353 +
<opml><body>
354 +
  <outline text="Category">
355 +
    <outline type="rss" text="Nested" xmlUrl="https://nested.com/feed" />
356 +
  </outline>
357 +
</body></opml>"#;
358 +
        let urls = parse_opml(opml);
359 +
        assert_eq!(urls, vec!["https://nested.com/feed"]);
360 +
    }
361 +
362 +
    #[test]
363 +
    fn parse_opml_skips_empty_url() {
364 +
        let opml = r#"<?xml version="1.0"?>
365 +
<opml><body>
366 +
  <outline type="rss" text="Empty" xmlUrl="" />
367 +
  <outline type="rss" text="Valid" xmlUrl="https://valid.com/feed" />
368 +
</body></opml>"#;
369 +
        let urls = parse_opml(opml);
370 +
        assert_eq!(urls, vec!["https://valid.com/feed"]);
371 +
    }
372 +
}
373 +
318 374
pub async fn get_feed_items(
319 375
    url_query: Option<&str>,
320 376
) -> Result<(Vec<FeedItem>, Option<Vec<String>>), String> {
apps/feeds/src/main.rs +59 −0
194 194
        .replace('\'', "&apos;")
195 195
}
196 196
197 +
#[cfg(test)]
198 +
mod tests {
199 +
    use super::*;
200 +
201 +
    #[test]
202 +
    fn escape_xml_ampersand() {
203 +
        assert_eq!(escape_xml("A&B"), "A&amp;B");
204 +
    }
205 +
206 +
    #[test]
207 +
    fn escape_xml_less_than() {
208 +
        assert_eq!(escape_xml("a<b"), "a&lt;b");
209 +
    }
210 +
211 +
    #[test]
212 +
    fn escape_xml_greater_than() {
213 +
        assert_eq!(escape_xml("a>b"), "a&gt;b");
214 +
    }
215 +
216 +
    #[test]
217 +
    fn escape_xml_quote() {
218 +
        assert_eq!(escape_xml(r#"a"b"#), "a&quot;b");
219 +
    }
220 +
221 +
    #[test]
222 +
    fn escape_xml_apostrophe() {
223 +
        assert_eq!(escape_xml("a'b"), "a&apos;b");
224 +
    }
225 +
226 +
    #[test]
227 +
    fn escape_xml_all_special() {
228 +
        assert_eq!(
229 +
            escape_xml(r#"<a href="x">&'test'</a>"#),
230 +
            "&lt;a href=&quot;x&quot;&gt;&amp;&apos;test&apos;&lt;/a&gt;"
231 +
        );
232 +
    }
233 +
234 +
    #[test]
235 +
    fn escape_xml_no_special_chars() {
236 +
        assert_eq!(escape_xml("hello world"), "hello world");
237 +
    }
238 +
239 +
    #[test]
240 +
    fn escape_xml_empty() {
241 +
        assert_eq!(escape_xml(""), "");
242 +
    }
243 +
244 +
    #[test]
245 +
    fn format_date_valid_timestamp() {
246 +
        // 2024-01-15 00:00:00 UTC
247 +
        assert_eq!(format_date(1705276800), "Jan 15, 2024");
248 +
    }
249 +
250 +
    #[test]
251 +
    fn format_date_zero() {
252 +
        assert_eq!(format_date(0), "Jan 1, 1970");
253 +
    }
254 +
}
255 +
197 256
async fn static_handler(axum::extract::Path(path): axum::extract::Path<String>) -> Response {
198 257
    match Static::get(&path) {
199 258
        Some(file) => {
apps/jotts/src/db.rs +112 −0
212 212
    )?;
213 213
    Ok(())
214 214
}
215 +
216 +
#[cfg(test)]
217 +
mod tests {
218 +
    use super::*;
219 +
220 +
    fn test_db() -> Db {
221 +
        let conn = Connection::open_in_memory().unwrap();
222 +
        conn.execute_batch(
223 +
            "CREATE TABLE IF NOT EXISTS notes (
224 +
                id         INTEGER PRIMARY KEY AUTOINCREMENT,
225 +
                short_id   TEXT NOT NULL UNIQUE,
226 +
                title      TEXT NOT NULL,
227 +
                content    TEXT NOT NULL,
228 +
                created_at TEXT NOT NULL DEFAULT (datetime('now')),
229 +
                updated_at TEXT NOT NULL DEFAULT (datetime('now'))
230 +
            );
231 +
            CREATE TABLE IF NOT EXISTS sessions (
232 +
                id         INTEGER PRIMARY KEY AUTOINCREMENT,
233 +
                token      TEXT NOT NULL UNIQUE,
234 +
                expires_at TEXT NOT NULL
235 +
            );",
236 +
        )
237 +
        .unwrap();
238 +
        Arc::new(Mutex::new(conn))
239 +
    }
240 +
241 +
    // ── Note CRUD ──────────────────────────────────────────────────────
242 +
243 +
    #[test]
244 +
    fn create_and_get_note() {
245 +
        let db = test_db();
246 +
        let note = create_note(&db, "My Note", "Some content").unwrap();
247 +
        assert_eq!(note.title, "My Note");
248 +
        assert_eq!(note.content, "Some content");
249 +
250 +
        let fetched = get_note_by_short_id(&db, &note.short_id).unwrap().unwrap();
251 +
        assert_eq!(fetched.title, "My Note");
252 +
    }
253 +
254 +
    #[test]
255 +
    fn get_note_not_found() {
256 +
        let db = test_db();
257 +
        assert!(get_note_by_short_id(&db, "nope").unwrap().is_none());
258 +
    }
259 +
260 +
    #[test]
261 +
    fn get_all_notes_ordered_desc() {
262 +
        let db = test_db();
263 +
        create_note(&db, "First", "a").unwrap();
264 +
        create_note(&db, "Second", "b").unwrap();
265 +
266 +
        let all = get_all_notes(&db).unwrap();
267 +
        assert_eq!(all.len(), 2);
268 +
        assert_eq!(all[0].title, "Second");
269 +
        assert_eq!(all[1].title, "First");
270 +
    }
271 +
272 +
    #[test]
273 +
    fn update_note() {
274 +
        let db = test_db();
275 +
        let note = create_note(&db, "Old", "old").unwrap();
276 +
        let updated = update_note_by_short_id(&db, &note.short_id, "New", "new")
277 +
            .unwrap()
278 +
            .unwrap();
279 +
        assert_eq!(updated.title, "New");
280 +
        assert_eq!(updated.content, "new");
281 +
    }
282 +
283 +
    #[test]
284 +
    fn update_nonexistent_note() {
285 +
        let db = test_db();
286 +
        assert!(update_note_by_short_id(&db, "nope", "x", "x").unwrap().is_none());
287 +
    }
288 +
289 +
    #[test]
290 +
    fn delete_note() {
291 +
        let db = test_db();
292 +
        let note = create_note(&db, "Del", "x").unwrap();
293 +
        assert!(delete_note_by_short_id(&db, &note.short_id).unwrap());
294 +
        assert!(get_note_by_short_id(&db, &note.short_id).unwrap().is_none());
295 +
    }
296 +
297 +
    #[test]
298 +
    fn delete_nonexistent_note() {
299 +
        let db = test_db();
300 +
        assert!(!delete_note_by_short_id(&db, "nope").unwrap());
301 +
    }
302 +
303 +
    // ── Sessions ───────────────────────────────────────────────────────
304 +
305 +
    #[test]
306 +
    fn session_lifecycle() {
307 +
        let db = test_db();
308 +
        insert_session(&db, "tok", "2099-01-01 00:00:00").unwrap();
309 +
        assert_eq!(
310 +
            get_session_expiry(&db, "tok").unwrap(),
311 +
            Some("2099-01-01 00:00:00".to_string())
312 +
        );
313 +
        delete_session(&db, "tok").unwrap();
314 +
        assert!(get_session_expiry(&db, "tok").unwrap().is_none());
315 +
    }
316 +
317 +
    #[test]
318 +
    fn prune_expired_sessions_works() {
319 +
        let db = test_db();
320 +
        insert_session(&db, "old", "2000-01-01 00:00:00").unwrap();
321 +
        insert_session(&db, "new", "2099-01-01 00:00:00").unwrap();
322 +
        prune_expired_sessions(&db).unwrap();
323 +
        assert!(get_session_expiry(&db, "old").unwrap().is_none());
324 +
        assert!(get_session_expiry(&db, "new").unwrap().is_some());
325 +
    }
326 +
}
apps/og/src/og.rs +110 −0
127 127
    }
128 128
    link_tags
129 129
}
130 +
131 +
#[cfg(test)]
132 +
mod tests {
133 +
    use super::*;
134 +
135 +
    fn base() -> Url {
136 +
        Url::parse("https://example.com/page").unwrap()
137 +
    }
138 +
139 +
    // ── extract_favicon ────────────────────────────────────────────────
140 +
141 +
    #[test]
142 +
    fn favicon_from_rel_icon() {
143 +
        let doc = Html::parse_document(
144 +
            r#"<html><head><link rel="icon" href="/favicon.png"></head></html>"#,
145 +
        );
146 +
        let result = extract_favicon(&doc, &base());
147 +
        assert_eq!(result, Some("https://example.com/favicon.png".to_string()));
148 +
    }
149 +
150 +
    #[test]
151 +
    fn favicon_from_shortcut_icon() {
152 +
        let doc = Html::parse_document(
153 +
            r#"<html><head><link rel="shortcut icon" href="/icon.ico"></head></html>"#,
154 +
        );
155 +
        let result = extract_favicon(&doc, &base());
156 +
        assert_eq!(result, Some("https://example.com/icon.ico".to_string()));
157 +
    }
158 +
159 +
    #[test]
160 +
    fn favicon_from_apple_touch_icon() {
161 +
        let doc = Html::parse_document(
162 +
            r#"<html><head><link rel="apple-touch-icon" href="/apple.png"></head></html>"#,
163 +
        );
164 +
        let result = extract_favicon(&doc, &base());
165 +
        assert_eq!(result, Some("https://example.com/apple.png".to_string()));
166 +
    }
167 +
168 +
    #[test]
169 +
    fn favicon_priority_icon_over_shortcut() {
170 +
        let doc = Html::parse_document(
171 +
            r#"<html><head>
172 +
                <link rel="icon" href="/first.png">
173 +
                <link rel="shortcut icon" href="/second.ico">
174 +
            </head></html>"#,
175 +
        );
176 +
        let result = extract_favicon(&doc, &base());
177 +
        assert_eq!(result, Some("https://example.com/first.png".to_string()));
178 +
    }
179 +
180 +
    #[test]
181 +
    fn favicon_fallback_to_favicon_ico() {
182 +
        let doc = Html::parse_document("<html><head></head></html>");
183 +
        let result = extract_favicon(&doc, &base());
184 +
        assert_eq!(result, Some("https://example.com/favicon.ico".to_string()));
185 +
    }
186 +
187 +
    #[test]
188 +
    fn favicon_resolves_relative_url() {
189 +
        let doc = Html::parse_document(
190 +
            r#"<html><head><link rel="icon" href="assets/icon.png"></head></html>"#,
191 +
        );
192 +
        let result = extract_favicon(&doc, &base());
193 +
        assert_eq!(
194 +
            result,
195 +
            Some("https://example.com/assets/icon.png".to_string())
196 +
        );
197 +
    }
198 +
199 +
    // ── extract_link_tags ──────────────────────────────────────────────
200 +
201 +
    #[test]
202 +
    fn link_tags_extracts_multiple() {
203 +
        let doc = Html::parse_document(
204 +
            r#"<html><head>
205 +
                <link rel="stylesheet" href="/style.css">
206 +
                <link rel="canonical" href="https://example.com/">
207 +
            </head></html>"#,
208 +
        );
209 +
        let tags = extract_link_tags(&doc, &base());
210 +
        assert_eq!(tags.len(), 2);
211 +
        assert_eq!(tags[0].rel, "stylesheet");
212 +
        assert_eq!(tags[1].rel, "canonical");
213 +
    }
214 +
215 +
    #[test]
216 +
    fn link_tags_resolves_relative_href() {
217 +
        let doc = Html::parse_document(
218 +
            r#"<html><head><link rel="stylesheet" href="css/main.css"></head></html>"#,
219 +
        );
220 +
        let tags = extract_link_tags(&doc, &base());
221 +
        assert_eq!(tags[0].href, "https://example.com/css/main.css");
222 +
    }
223 +
224 +
    #[test]
225 +
    fn link_tags_preserves_extra_attrs() {
226 +
        let doc = Html::parse_document(
227 +
            r#"<html><head><link rel="stylesheet" href="/s.css" type="text/css"></head></html>"#,
228 +
        );
229 +
        let tags = extract_link_tags(&doc, &base());
230 +
        assert!(tags[0].extra.contains("type=\"text/css\""));
231 +
    }
232 +
233 +
    #[test]
234 +
    fn link_tags_empty_head() {
235 +
        let doc = Html::parse_document("<html><head></head></html>");
236 +
        let tags = extract_link_tags(&doc, &base());
237 +
        assert!(tags.is_empty());
238 +
    }
239 +
}
apps/parcels/src/auth.rs +58 −0
99 99
        _ => false,
100 100
    }
101 101
}
102 +
103 +
#[cfg(test)]
104 +
mod tests {
105 +
    use super::*;
106 +
107 +
    #[test]
108 +
    fn format_unix_epoch() {
109 +
        assert_eq!(format_unix_to_datetime(0, 0, 0, 0), "1970-01-01 00:00:00");
110 +
    }
111 +
112 +
    #[test]
113 +
    fn format_unix_known_date() {
114 +
        // 2024-01-15 = day 19737 since epoch
115 +
        assert_eq!(
116 +
            format_unix_to_datetime(19737, 12, 30, 45),
117 +
            "2024-01-15 12:30:45"
118 +
        );
119 +
    }
120 +
121 +
    #[test]
122 +
    fn format_unix_y2k() {
123 +
        // 2000-01-01 = day 10957
124 +
        assert_eq!(
125 +
            format_unix_to_datetime(10957, 0, 0, 0),
126 +
            "2000-01-01 00:00:00"
127 +
        );
128 +
    }
129 +
130 +
    #[test]
131 +
    fn format_unix_leap_day() {
132 +
        // 2024-02-29 = day 19782
133 +
        assert_eq!(
134 +
            format_unix_to_datetime(19782, 23, 59, 59),
135 +
            "2024-02-29 23:59:59"
136 +
        );
137 +
    }
138 +
139 +
    #[test]
140 +
    fn format_unix_end_of_year() {
141 +
        // 2023-12-31 = day 19722
142 +
        assert_eq!(
143 +
            format_unix_to_datetime(19722, 23, 59, 59),
144 +
            "2023-12-31 23:59:59"
145 +
        );
146 +
    }
147 +
148 +
    #[test]
149 +
    fn session_expiry_at_is_valid_format() {
150 +
        let expiry = session_expiry_at();
151 +
        // Should match YYYY-MM-DD HH:MM:SS
152 +
        assert_eq!(expiry.len(), 19);
153 +
        assert_eq!(&expiry[4..5], "-");
154 +
        assert_eq!(&expiry[7..8], "-");
155 +
        assert_eq!(&expiry[10..11], " ");
156 +
        assert_eq!(&expiry[13..14], ":");
157 +
        assert_eq!(&expiry[16..17], ":");
158 +
    }
159 +
}
apps/parcels/src/db.rs +204 −0
277 277
    conn.execute("DELETE FROM sessions WHERE expires_at < datetime('now')", [])?;
278 278
    Ok(())
279 279
}
280 +
281 +
#[cfg(test)]
282 +
mod tests {
283 +
    use super::*;
284 +
285 +
    fn test_db() -> Db {
286 +
        let conn = Connection::open_in_memory().unwrap();
287 +
        conn.execute_batch("PRAGMA foreign_keys=ON;").unwrap();
288 +
        conn.execute_batch(
289 +
            "CREATE TABLE IF NOT EXISTS packages (
290 +
                id                INTEGER PRIMARY KEY AUTOINCREMENT,
291 +
                tracking_number   TEXT NOT NULL UNIQUE,
292 +
                label             TEXT,
293 +
                status            TEXT,
294 +
                status_category   TEXT,
295 +
                status_summary    TEXT,
296 +
                mail_class        TEXT,
297 +
                expected_delivery TEXT,
298 +
                last_refreshed_at TEXT,
299 +
                created_at        TEXT NOT NULL DEFAULT (datetime('now'))
300 +
            );
301 +
            CREATE TABLE IF NOT EXISTS tracking_events (
302 +
                id              INTEGER PRIMARY KEY AUTOINCREMENT,
303 +
                package_id      INTEGER NOT NULL REFERENCES packages(id) ON DELETE CASCADE,
304 +
                event_timestamp TEXT,
305 +
                event_type      TEXT,
306 +
                event_city      TEXT,
307 +
                event_state     TEXT,
308 +
                event_zip       TEXT,
309 +
                event_code      TEXT
310 +
            );
311 +
            CREATE TABLE IF NOT EXISTS sessions (
312 +
                id         INTEGER PRIMARY KEY AUTOINCREMENT,
313 +
                token      TEXT NOT NULL UNIQUE,
314 +
                expires_at TEXT NOT NULL
315 +
            );",
316 +
        )
317 +
        .unwrap();
318 +
        Arc::new(Mutex::new(conn))
319 +
    }
320 +
321 +
    // ── Package CRUD ───────────────────────────────────────────────────
322 +
323 +
    #[test]
324 +
    fn insert_and_list_packages() {
325 +
        let db = test_db();
326 +
        let id = insert_package(&db, "TRACK001", Some("My Package")).unwrap();
327 +
        assert!(id > 0);
328 +
329 +
        let packages = list_packages(&db).unwrap();
330 +
        assert_eq!(packages.len(), 1);
331 +
        assert_eq!(packages[0].tracking_number, "TRACK001");
332 +
        assert_eq!(packages[0].label.as_deref(), Some("My Package"));
333 +
    }
334 +
335 +
    #[test]
336 +
    fn insert_duplicate_tracking_number_fails() {
337 +
        let db = test_db();
338 +
        insert_package(&db, "TRACK001", None).unwrap();
339 +
        let result = insert_package(&db, "TRACK001", None);
340 +
        assert!(result.is_err());
341 +
    }
342 +
343 +
    #[test]
344 +
    fn get_package_found() {
345 +
        let db = test_db();
346 +
        let id = insert_package(&db, "TRACK001", None).unwrap();
347 +
        let pkg = get_package(&db, id).unwrap();
348 +
        assert!(pkg.is_some());
349 +
        assert_eq!(pkg.unwrap().tracking_number, "TRACK001");
350 +
    }
351 +
352 +
    #[test]
353 +
    fn get_package_not_found() {
354 +
        let db = test_db();
355 +
        let pkg = get_package(&db, 999).unwrap();
356 +
        assert!(pkg.is_none());
357 +
    }
358 +
359 +
    #[test]
360 +
    fn update_package_status_works() {
361 +
        let db = test_db();
362 +
        let id = insert_package(&db, "TRACK001", None).unwrap();
363 +
        update_package_status(
364 +
            &db,
365 +
            id,
366 +
            "Delivered",
367 +
            Some("Delivered"),
368 +
            Some("Package delivered"),
369 +
            Some("Priority"),
370 +
            Some("2024-01-20"),
371 +
            "2024-01-18 12:00:00",
372 +
        )
373 +
        .unwrap();
374 +
375 +
        let pkg = get_package(&db, id).unwrap().unwrap();
376 +
        assert_eq!(pkg.status.as_deref(), Some("Delivered"));
377 +
        assert_eq!(pkg.mail_class.as_deref(), Some("Priority"));
378 +
    }
379 +
380 +
    #[test]
381 +
    fn delete_package_removes_it() {
382 +
        let db = test_db();
383 +
        let id = insert_package(&db, "TRACK001", None).unwrap();
384 +
        delete_package(&db, id).unwrap();
385 +
        assert!(get_package(&db, id).unwrap().is_none());
386 +
    }
387 +
388 +
    // ── Tracking Events ────────────────────────────────────────────────
389 +
390 +
    #[test]
391 +
    fn insert_and_get_events() {
392 +
        let db = test_db();
393 +
        let pkg_id = insert_package(&db, "TRACK001", None).unwrap();
394 +
395 +
        insert_event(
396 +
            &db,
397 +
            pkg_id,
398 +
            Some("2024-01-15 10:00:00"),
399 +
            Some("Delivered"),
400 +
            Some("New York"),
401 +
            Some("NY"),
402 +
            Some("10001"),
403 +
            Some("01"),
404 +
        )
405 +
        .unwrap();
406 +
407 +
        insert_event(
408 +
            &db,
409 +
            pkg_id,
410 +
            Some("2024-01-14 08:00:00"),
411 +
            Some("In Transit"),
412 +
            Some("Chicago"),
413 +
            Some("IL"),
414 +
            None,
415 +
            None,
416 +
        )
417 +
        .unwrap();
418 +
419 +
        let events = get_events_for_package(&db, pkg_id).unwrap();
420 +
        assert_eq!(events.len(), 2);
421 +
        // Ordered by timestamp DESC
422 +
        assert_eq!(events[0].event_city.as_deref(), Some("New York"));
423 +
        assert_eq!(events[1].event_city.as_deref(), Some("Chicago"));
424 +
    }
425 +
426 +
    #[test]
427 +
    fn delete_events_for_package_clears_them() {
428 +
        let db = test_db();
429 +
        let pkg_id = insert_package(&db, "TRACK001", None).unwrap();
430 +
        insert_event(&db, pkg_id, None, Some("Shipped"), None, None, None, None).unwrap();
431 +
432 +
        delete_events_for_package(&db, pkg_id).unwrap();
433 +
        let events = get_events_for_package(&db, pkg_id).unwrap();
434 +
        assert!(events.is_empty());
435 +
    }
436 +
437 +
    #[test]
438 +
    fn delete_package_cascades_to_events() {
439 +
        let db = test_db();
440 +
        let pkg_id = insert_package(&db, "TRACK001", None).unwrap();
441 +
        insert_event(&db, pkg_id, None, Some("Shipped"), None, None, None, None).unwrap();
442 +
443 +
        delete_package(&db, pkg_id).unwrap();
444 +
        let events = get_events_for_package(&db, pkg_id).unwrap();
445 +
        assert!(events.is_empty());
446 +
    }
447 +
448 +
    // ── Sessions ───────────────────────────────────────────────────────
449 +
450 +
    #[test]
451 +
    fn session_crud_lifecycle() {
452 +
        let db = test_db();
453 +
        let token = "abc123";
454 +
455 +
        insert_session(&db, token, "2099-01-01 00:00:00").unwrap();
456 +
457 +
        let expiry = get_session_expiry(&db, token).unwrap();
458 +
        assert_eq!(expiry, Some("2099-01-01 00:00:00".to_string()));
459 +
460 +
        delete_session(&db, token).unwrap();
461 +
        let expiry = get_session_expiry(&db, token).unwrap();
462 +
        assert!(expiry.is_none());
463 +
    }
464 +
465 +
    #[test]
466 +
    fn get_session_expiry_missing_token() {
467 +
        let db = test_db();
468 +
        let expiry = get_session_expiry(&db, "nonexistent").unwrap();
469 +
        assert!(expiry.is_none());
470 +
    }
471 +
472 +
    #[test]
473 +
    fn prune_expired_sessions_removes_old() {
474 +
        let db = test_db();
475 +
        insert_session(&db, "expired", "2000-01-01 00:00:00").unwrap();
476 +
        insert_session(&db, "valid", "2099-01-01 00:00:00").unwrap();
477 +
478 +
        prune_expired_sessions(&db).unwrap();
479 +
480 +
        assert!(get_session_expiry(&db, "expired").unwrap().is_none());
481 +
        assert!(get_session_expiry(&db, "valid").unwrap().is_some());
482 +
    }
483 +
}
apps/posts/src/db.rs +312 −0
629 629
    conn.execute("DELETE FROM files WHERE short_id = ?1", params![short_id])?;
630 630
    Ok(Some(file))
631 631
}
632 +
633 +
#[cfg(test)]
634 +
mod tests {
635 +
    use super::*;
636 +
637 +
    fn test_db() -> Db {
638 +
        let conn = Connection::open_in_memory().unwrap();
639 +
        conn.execute_batch(
640 +
            "CREATE TABLE IF NOT EXISTS posts (
641 +
                id              INTEGER PRIMARY KEY AUTOINCREMENT,
642 +
                short_id        TEXT NOT NULL UNIQUE,
643 +
                title           TEXT NOT NULL,
644 +
                slug            TEXT NOT NULL UNIQUE,
645 +
                alias           TEXT,
646 +
                canonical_url   TEXT,
647 +
                published_date  TEXT,
648 +
                meta_description TEXT,
649 +
                meta_image      TEXT,
650 +
                lang            TEXT NOT NULL DEFAULT 'en',
651 +
                tags            TEXT,
652 +
                content         TEXT NOT NULL,
653 +
                status          TEXT NOT NULL DEFAULT 'draft',
654 +
                created_at      TEXT NOT NULL DEFAULT (datetime('now')),
655 +
                updated_at      TEXT NOT NULL DEFAULT (datetime('now'))
656 +
            );
657 +
            CREATE TABLE IF NOT EXISTS pages (
658 +
                id              INTEGER PRIMARY KEY AUTOINCREMENT,
659 +
                short_id        TEXT NOT NULL UNIQUE,
660 +
                title           TEXT NOT NULL,
661 +
                slug            TEXT NOT NULL UNIQUE,
662 +
                content         TEXT NOT NULL,
663 +
                is_published    INTEGER NOT NULL DEFAULT 0,
664 +
                nav_order       INTEGER NOT NULL DEFAULT 0,
665 +
                created_at      TEXT NOT NULL DEFAULT (datetime('now')),
666 +
                updated_at      TEXT NOT NULL DEFAULT (datetime('now'))
667 +
            );
668 +
            CREATE TABLE IF NOT EXISTS sessions (
669 +
                id              INTEGER PRIMARY KEY AUTOINCREMENT,
670 +
                token           TEXT NOT NULL UNIQUE,
671 +
                expires_at      TEXT NOT NULL
672 +
            );
673 +
            CREATE TABLE IF NOT EXISTS settings (
674 +
                key   TEXT PRIMARY KEY,
675 +
                value TEXT NOT NULL
676 +
            );
677 +
            CREATE TABLE IF NOT EXISTS files (
678 +
                id            INTEGER PRIMARY KEY AUTOINCREMENT,
679 +
                short_id      TEXT NOT NULL UNIQUE,
680 +
                filename      TEXT NOT NULL UNIQUE,
681 +
                original_name TEXT NOT NULL,
682 +
                content_type  TEXT NOT NULL DEFAULT 'application/octet-stream',
683 +
                size          INTEGER NOT NULL,
684 +
                created_at    TEXT NOT NULL DEFAULT (datetime('now'))
685 +
            );",
686 +
        )
687 +
        .unwrap();
688 +
        Arc::new(Mutex::new(conn))
689 +
    }
690 +
691 +
    // ── Post CRUD ──────────────────────────────────────────────────────
692 +
693 +
    #[test]
694 +
    fn create_and_get_post() {
695 +
        let db = test_db();
696 +
        let post = create_post(
697 +
            &db, "Hello World", "hello-world", "# Hello", "draft",
698 +
            None, None, None, None, None, "en", None,
699 +
        )
700 +
        .unwrap();
701 +
        assert_eq!(post.title, "Hello World");
702 +
        assert_eq!(post.slug, "hello-world");
703 +
        assert_eq!(post.status, "draft");
704 +
705 +
        let fetched = get_post_by_short_id(&db, &post.short_id).unwrap().unwrap();
706 +
        assert_eq!(fetched.title, "Hello World");
707 +
    }
708 +
709 +
    #[test]
710 +
    fn get_post_by_slug_works() {
711 +
        let db = test_db();
712 +
        create_post(
713 +
            &db, "Test", "test-slug", "content", "published",
714 +
            None, None, Some("2024-01-01"), None, None, "en", None,
715 +
        )
716 +
        .unwrap();
717 +
718 +
        let post = get_post_by_slug(&db, "test-slug").unwrap().unwrap();
719 +
        assert_eq!(post.title, "Test");
720 +
    }
721 +
722 +
    #[test]
723 +
    fn duplicate_slug_fails() {
724 +
        let db = test_db();
725 +
        create_post(&db, "A", "same-slug", "a", "draft", None, None, None, None, None, "en", None).unwrap();
726 +
        let result = create_post(&db, "B", "same-slug", "b", "draft", None, None, None, None, None, "en", None);
727 +
        assert!(result.is_err());
728 +
    }
729 +
730 +
    #[test]
731 +
    fn get_all_posts_ordered_desc() {
732 +
        let db = test_db();
733 +
        create_post(&db, "First", "first", "a", "draft", None, None, None, None, None, "en", None).unwrap();
734 +
        create_post(&db, "Second", "second", "b", "draft", None, None, None, None, None, "en", None).unwrap();
735 +
736 +
        let all = get_all_posts(&db).unwrap();
737 +
        assert_eq!(all.len(), 2);
738 +
        assert_eq!(all[0].title, "Second");
739 +
        assert_eq!(all[1].title, "First");
740 +
    }
741 +
742 +
    #[test]
743 +
    fn get_published_posts_filters() {
744 +
        let db = test_db();
745 +
        create_post(&db, "Draft", "draft", "a", "draft", None, None, None, None, None, "en", None).unwrap();
746 +
        create_post(&db, "Published", "pub", "b", "published", None, None, Some("2024-01-01"), None, None, "en", None).unwrap();
747 +
748 +
        let published = get_published_posts(&db).unwrap();
749 +
        assert_eq!(published.len(), 1);
750 +
        assert_eq!(published[0].title, "Published");
751 +
    }
752 +
753 +
    #[test]
754 +
    fn delete_post_works() {
755 +
        let db = test_db();
756 +
        let post = create_post(&db, "Del", "del", "x", "draft", None, None, None, None, None, "en", None).unwrap();
757 +
        assert!(delete_post(&db, &post.short_id).unwrap());
758 +
        assert!(get_post_by_short_id(&db, &post.short_id).unwrap().is_none());
759 +
    }
760 +
761 +
    #[test]
762 +
    fn toggle_post_status_draft_to_published() {
763 +
        let db = test_db();
764 +
        let post = create_post(&db, "Toggle", "toggle", "x", "draft", None, None, None, None, None, "en", None).unwrap();
765 +
        let new_status = toggle_post_status(&db, &post.short_id).unwrap().unwrap();
766 +
        assert_eq!(new_status, "published");
767 +
768 +
        let updated = get_post_by_short_id(&db, &post.short_id).unwrap().unwrap();
769 +
        assert_eq!(updated.status, "published");
770 +
        assert!(updated.published_date.is_some());
771 +
    }
772 +
773 +
    #[test]
774 +
    fn toggle_post_status_published_to_draft() {
775 +
        let db = test_db();
776 +
        let post = create_post(&db, "Toggle", "toggle", "x", "published", None, None, Some("2024-01-01"), None, None, "en", None).unwrap();
777 +
        let new_status = toggle_post_status(&db, &post.short_id).unwrap().unwrap();
778 +
        assert_eq!(new_status, "draft");
779 +
    }
780 +
781 +
    #[test]
782 +
    fn find_alias_redirect_found() {
783 +
        let db = test_db();
784 +
        create_post(&db, "Aliased", "aliased-post", "x", "published", Some("old-url"), None, Some("2024-01-01"), None, None, "en", None).unwrap();
785 +
        let redirect = find_alias_redirect(&db, "old-url").unwrap();
786 +
        assert_eq!(redirect, Some("/posts/aliased-post".to_string()));
787 +
    }
788 +
789 +
    #[test]
790 +
    fn find_alias_redirect_not_found() {
791 +
        let db = test_db();
792 +
        assert!(find_alias_redirect(&db, "nonexistent").unwrap().is_none());
793 +
    }
794 +
795 +
    #[test]
796 +
    fn find_alias_redirect_only_published() {
797 +
        let db = test_db();
798 +
        create_post(&db, "Draft Alias", "draft-alias", "x", "draft", Some("my-alias"), None, None, None, None, "en", None).unwrap();
799 +
        assert!(find_alias_redirect(&db, "my-alias").unwrap().is_none());
800 +
    }
801 +
802 +
    // ── Page CRUD ──────────────────────────────────────────────────────
803 +
804 +
    #[test]
805 +
    fn create_and_get_page() {
806 +
        let db = test_db();
807 +
        let page = create_page(&db, "About", "about", "About content", true, 1).unwrap();
808 +
        assert_eq!(page.title, "About");
809 +
        assert!(page.is_published);
810 +
811 +
        let fetched = get_page_by_short_id(&db, &page.short_id).unwrap().unwrap();
812 +
        assert_eq!(fetched.slug, "about");
813 +
    }
814 +
815 +
    #[test]
816 +
    fn get_page_by_slug_works() {
817 +
        let db = test_db();
818 +
        create_page(&db, "Contact", "contact", "Email us", false, 2).unwrap();
819 +
        let page = get_page_by_slug(&db, "contact").unwrap().unwrap();
820 +
        assert_eq!(page.title, "Contact");
821 +
    }
822 +
823 +
    #[test]
824 +
    fn get_published_pages_filters() {
825 +
        let db = test_db();
826 +
        create_page(&db, "Pub", "pub", "x", true, 1).unwrap();
827 +
        create_page(&db, "Draft", "draft", "x", false, 2).unwrap();
828 +
829 +
        let published = get_published_pages(&db).unwrap();
830 +
        assert_eq!(published.len(), 1);
831 +
        assert_eq!(published[0].title, "Pub");
832 +
    }
833 +
834 +
    #[test]
835 +
    fn update_page_works() {
836 +
        let db = test_db();
837 +
        let page = create_page(&db, "Old", "old", "old content", false, 0).unwrap();
838 +
        let updated = update_page(&db, &page.short_id, "New", "new", "new content", true, 5)
839 +
            .unwrap()
840 +
            .unwrap();
841 +
        assert_eq!(updated.title, "New");
842 +
        assert!(updated.is_published);
843 +
        assert_eq!(updated.nav_order, 5);
844 +
    }
845 +
846 +
    #[test]
847 +
    fn delete_page_works() {
848 +
        let db = test_db();
849 +
        let page = create_page(&db, "Del", "del", "x", false, 0).unwrap();
850 +
        assert!(delete_page(&db, &page.short_id).unwrap());
851 +
        assert!(get_page_by_short_id(&db, &page.short_id).unwrap().is_none());
852 +
    }
853 +
854 +
    // ── Settings ───────────────────────────────────────────────────────
855 +
856 +
    #[test]
857 +
    fn settings_get_set() {
858 +
        let db = test_db();
859 +
        set_setting(&db, "blog_title", "My Blog").unwrap();
860 +
        let val = get_setting(&db, "blog_title").unwrap();
861 +
        assert_eq!(val, Some("My Blog".to_string()));
862 +
    }
863 +
864 +
    #[test]
865 +
    fn settings_upsert() {
866 +
        let db = test_db();
867 +
        set_setting(&db, "key", "first").unwrap();
868 +
        set_setting(&db, "key", "second").unwrap();
869 +
        assert_eq!(get_setting(&db, "key").unwrap(), Some("second".to_string()));
870 +
    }
871 +
872 +
    #[test]
873 +
    fn settings_missing_key() {
874 +
        let db = test_db();
875 +
        assert!(get_setting(&db, "nonexistent").unwrap().is_none());
876 +
    }
877 +
878 +
    #[test]
879 +
    fn get_all_settings_works() {
880 +
        let db = test_db();
881 +
        set_setting(&db, "a", "1").unwrap();
882 +
        set_setting(&db, "b", "2").unwrap();
883 +
884 +
        let all = get_all_settings(&db).unwrap();
885 +
        assert_eq!(all.len(), 2);
886 +
        assert_eq!(all[0], ("a".to_string(), "1".to_string()));
887 +
    }
888 +
889 +
    // ── File CRUD ──────────────────────────────────────────────────────
890 +
891 +
    #[test]
892 +
    fn create_and_get_files() {
893 +
        let db = test_db();
894 +
        let file = create_file(&db, "abc123.jpg", "photo.jpg", "image/jpeg", 1024).unwrap();
895 +
        assert_eq!(file.filename, "abc123.jpg");
896 +
        assert_eq!(file.original_name, "photo.jpg");
897 +
        assert_eq!(file.size, 1024);
898 +
899 +
        let all = get_all_files(&db).unwrap();
900 +
        assert_eq!(all.len(), 1);
901 +
    }
902 +
903 +
    #[test]
904 +
    fn delete_file_returns_deleted() {
905 +
        let db = test_db();
906 +
        let file = create_file(&db, "f.txt", "f.txt", "text/plain", 10).unwrap();
907 +
        let deleted = delete_file(&db, &file.short_id).unwrap();
908 +
        assert!(deleted.is_some());
909 +
        assert_eq!(deleted.unwrap().filename, "f.txt");
910 +
911 +
        assert!(get_all_files(&db).unwrap().is_empty());
912 +
    }
913 +
914 +
    #[test]
915 +
    fn delete_file_not_found() {
916 +
        let db = test_db();
917 +
        assert!(delete_file(&db, "nonexistent").unwrap().is_none());
918 +
    }
919 +
920 +
    // ── Sessions ───────────────────────────────────────────────────────
921 +
922 +
    #[test]
923 +
    fn session_lifecycle() {
924 +
        let db = test_db();
925 +
        insert_session(&db, "tok", "2099-12-31 23:59:59").unwrap();
926 +
        assert_eq!(
927 +
            get_session_expiry(&db, "tok").unwrap(),
928 +
            Some("2099-12-31 23:59:59".to_string())
929 +
        );
930 +
        delete_session(&db, "tok").unwrap();
931 +
        assert!(get_session_expiry(&db, "tok").unwrap().is_none());
932 +
    }
933 +
934 +
    #[test]
935 +
    fn prune_expired_sessions_works() {
936 +
        let db = test_db();
937 +
        insert_session(&db, "old", "2000-01-01 00:00:00").unwrap();
938 +
        insert_session(&db, "new", "2099-01-01 00:00:00").unwrap();
939 +
        prune_expired_sessions(&db).unwrap();
940 +
        assert!(get_session_expiry(&db, "old").unwrap().is_none());
941 +
        assert!(get_session_expiry(&db, "new").unwrap().is_some());
942 +
    }
943 +
}
apps/shrink/src/server.rs +162 −0
212 212
        .unwrap_or("compressed");
213 213
    format!("{}_compressed.{}", stem, new_ext)
214 214
}
215 +
216 +
#[cfg(test)]
217 +
mod tests {
218 +
    use super::*;
219 +
220 +
    // ── build_download_filename ────────────────────────────────────────
221 +
222 +
    #[test]
223 +
    fn filename_with_extension() {
224 +
        assert_eq!(build_download_filename("photo.png", "jpg"), "photo_compressed.jpg");
225 +
    }
226 +
227 +
    #[test]
228 +
    fn filename_without_extension() {
229 +
        assert_eq!(build_download_filename("photo", "jpg"), "photo_compressed.jpg");
230 +
    }
231 +
232 +
    #[test]
233 +
    fn filename_empty_string() {
234 +
        assert_eq!(build_download_filename("", "jpg"), "compressed_compressed.jpg");
235 +
    }
236 +
237 +
    #[test]
238 +
    fn filename_multiple_dots() {
239 +
        assert_eq!(
240 +
            build_download_filename("my.cool.photo.png", "jpg"),
241 +
            "my.cool.photo_compressed.jpg"
242 +
        );
243 +
    }
244 +
245 +
    // ── strip_gps_from_exif ────────────────────────────────────────────
246 +
247 +
    #[test]
248 +
    fn strip_gps_too_short_returns_unchanged() {
249 +
        let data = vec![0u8; 4];
250 +
        assert_eq!(strip_gps_from_exif(&data), data);
251 +
    }
252 +
253 +
    #[test]
254 +
    fn strip_gps_no_tiff_header_returns_unchanged() {
255 +
        let data = vec![0u8; 32];
256 +
        assert_eq!(strip_gps_from_exif(&data), data);
257 +
    }
258 +
259 +
    #[test]
260 +
    fn strip_gps_little_endian_zeroes_gps_ifd() {
261 +
        // Build minimal TIFF (little-endian) with one IFD entry: GPS tag 0x8825
262 +
        let mut data = Vec::new();
263 +
        // TIFF header: "II" + magic 42 + offset to IFD0 (8)
264 +
        data.extend_from_slice(b"II");
265 +
        data.extend_from_slice(&42u16.to_le_bytes());
266 +
        data.extend_from_slice(&8u32.to_le_bytes()); // IFD0 at offset 8
267 +
268 +
        // IFD0 at offset 8: 1 entry
269 +
        data.extend_from_slice(&1u16.to_le_bytes());
270 +
271 +
        // IFD entry: tag=0x8825 (GPS), type=LONG(4), count=1, value=offset to GPS IFD
272 +
        let gps_ifd_offset: u32 = 22; // right after this IFD entry + next IFD pointer
273 +
        data.extend_from_slice(&0x8825u16.to_le_bytes());
274 +
        data.extend_from_slice(&4u16.to_le_bytes()); // type LONG
275 +
        data.extend_from_slice(&1u32.to_le_bytes()); // count
276 +
        data.extend_from_slice(&gps_ifd_offset.to_le_bytes()); // GPS IFD offset
277 +
278 +
        // Next IFD pointer (none)
279 +
        // We need padding to get to offset 22
280 +
        // Current size = 8 + 2 + 12 = 22, perfect
281 +
282 +
        // GPS IFD at offset 22: entry count = 5 (nonzero, should be zeroed)
283 +
        data.extend_from_slice(&5u16.to_le_bytes());
284 +
        // Some dummy GPS entries
285 +
        data.extend_from_slice(&[0u8; 24]);
286 +
287 +
        let result = strip_gps_from_exif(&data);
288 +
        // GPS IFD entry count at offset 22 should now be 0
289 +
        let gps_count = u16::from_le_bytes([result[22], result[23]]);
290 +
        assert_eq!(gps_count, 0);
291 +
    }
292 +
293 +
    #[test]
294 +
    fn strip_gps_big_endian_zeroes_gps_ifd() {
295 +
        let mut data = Vec::new();
296 +
        // TIFF header: "MM" + magic 42 + offset to IFD0 (8)
297 +
        data.extend_from_slice(b"MM");
298 +
        data.extend_from_slice(&42u16.to_be_bytes());
299 +
        data.extend_from_slice(&8u32.to_be_bytes());
300 +
301 +
        // IFD0: 1 entry
302 +
        data.extend_from_slice(&1u16.to_be_bytes());
303 +
304 +
        // GPS tag entry pointing to GPS IFD at offset 22
305 +
        data.extend_from_slice(&0x8825u16.to_be_bytes());
306 +
        data.extend_from_slice(&4u16.to_be_bytes());
307 +
        data.extend_from_slice(&1u32.to_be_bytes());
308 +
        data.extend_from_slice(&22u32.to_be_bytes());
309 +
310 +
        // GPS IFD at offset 22
311 +
        data.extend_from_slice(&3u16.to_be_bytes()); // 3 entries
312 +
        data.extend_from_slice(&[0u8; 24]);
313 +
314 +
        let result = strip_gps_from_exif(&data);
315 +
        let gps_count = u16::from_be_bytes([result[22], result[23]]);
316 +
        assert_eq!(gps_count, 0);
317 +
    }
318 +
319 +
    #[test]
320 +
    fn strip_gps_no_gps_tag_unchanged() {
321 +
        let mut data = Vec::new();
322 +
        data.extend_from_slice(b"II");
323 +
        data.extend_from_slice(&42u16.to_le_bytes());
324 +
        data.extend_from_slice(&8u32.to_le_bytes());
325 +
326 +
        // IFD0: 1 entry, but NOT a GPS tag (use 0x010F = Make)
327 +
        data.extend_from_slice(&1u16.to_le_bytes());
328 +
        data.extend_from_slice(&0x010Fu16.to_le_bytes());
329 +
        data.extend_from_slice(&2u16.to_le_bytes());
330 +
        data.extend_from_slice(&1u32.to_le_bytes());
331 +
        data.extend_from_slice(&0u32.to_le_bytes());
332 +
333 +
        let original = data.clone();
334 +
        let result = strip_gps_from_exif(&data);
335 +
        assert_eq!(result, original);
336 +
    }
337 +
338 +
    // ── compress_image ─────────────────────────────────────────────────
339 +
340 +
    #[test]
341 +
    fn compress_image_invalid_data_returns_error() {
342 +
        let result = compress_image(&[0, 1, 2, 3], 80, 0);
343 +
        assert!(result.is_err());
344 +
    }
345 +
346 +
    #[test]
347 +
    fn compress_image_valid_jpeg() {
348 +
        // Create a minimal 2x2 RGB image and encode as JPEG
349 +
        let img = image::RgbImage::from_fn(2, 2, |_, _| image::Rgb([255u8, 0, 0]));
350 +
        let mut buf = Vec::new();
351 +
        let encoder = image::codecs::jpeg::JpegEncoder::new(&mut buf);
352 +
        image::DynamicImage::ImageRgb8(img)
353 +
            .write_with_encoder(encoder)
354 +
            .unwrap();
355 +
356 +
        let result = compress_image(&buf, 80, 0);
357 +
        assert!(result.is_ok());
358 +
        assert!(!result.unwrap().is_empty());
359 +
    }
360 +
361 +
    #[test]
362 +
    fn compress_image_with_resize() {
363 +
        let img = image::RgbImage::from_fn(100, 50, |_, _| image::Rgb([0u8, 128, 255]));
364 +
        let mut buf = Vec::new();
365 +
        let encoder = image::codecs::jpeg::JpegEncoder::new(&mut buf);
366 +
        image::DynamicImage::ImageRgb8(img)
367 +
            .write_with_encoder(encoder)
368 +
            .unwrap();
369 +
370 +
        let result = compress_image(&buf, 80, 50).unwrap();
371 +
        // Verify output is valid JPEG (starts with FFD8)
372 +
        assert!(result.len() >= 2);
373 +
        assert_eq!(result[0], 0xFF);
374 +
        assert_eq!(result[1], 0xD8);
375 +
    }
376 +
}
apps/sipp/Cargo.toml +3 −0
39 39
toml = "1.0"
40 40
rpassword = "7"
41 41
open = "5.3.3"
42 +
43 +
[dev-dependencies]
44 +
tempfile = { workspace = true }
apps/sipp/src/config.rs +63 −0
29 29
    std::fs::write(&path, contents)?;
30 30
    Ok(())
31 31
}
32 +
33 +
#[cfg(test)]
34 +
mod tests {
35 +
    use super::*;
36 +
37 +
    #[test]
38 +
    fn config_default_is_none_fields() {
39 +
        let config = Config::default();
40 +
        assert!(config.remote_url.is_none());
41 +
        assert!(config.api_key.is_none());
42 +
    }
43 +
44 +
    #[test]
45 +
    fn config_toml_roundtrip() {
46 +
        let config = Config {
47 +
            remote_url: Some("http://localhost:3000".to_string()),
48 +
            api_key: Some("secret-key-123".to_string()),
49 +
        };
50 +
        let serialized = toml::to_string_pretty(&config).unwrap();
51 +
        let deserialized: Config = toml::from_str(&serialized).unwrap();
52 +
        assert_eq!(deserialized.remote_url, config.remote_url);
53 +
        assert_eq!(deserialized.api_key, config.api_key);
54 +
    }
55 +
56 +
    #[test]
57 +
    fn config_toml_roundtrip_with_nones() {
58 +
        let config = Config {
59 +
            remote_url: None,
60 +
            api_key: None,
61 +
        };
62 +
        let serialized = toml::to_string_pretty(&config).unwrap();
63 +
        let deserialized: Config = toml::from_str(&serialized).unwrap();
64 +
        assert!(deserialized.remote_url.is_none());
65 +
        assert!(deserialized.api_key.is_none());
66 +
    }
67 +
68 +
    #[test]
69 +
    fn load_config_missing_file_returns_default() {
70 +
        let tmp = tempfile::tempdir().unwrap();
71 +
        // SAFETY: test-only, single-threaded test runner for this test
72 +
        unsafe { std::env::set_var("HOME", tmp.path()); }
73 +
        let config = load_config();
74 +
        assert!(config.remote_url.is_none());
75 +
        assert!(config.api_key.is_none());
76 +
    }
77 +
78 +
    #[test]
79 +
    fn save_and_load_config_roundtrip() {
80 +
        let tmp = tempfile::tempdir().unwrap();
81 +
        // SAFETY: test-only, single-threaded test runner for this test
82 +
        unsafe { std::env::set_var("HOME", tmp.path()); }
83 +
84 +
        let config = Config {
85 +
            remote_url: Some("https://sipp.example.com".to_string()),
86 +
            api_key: Some("key123".to_string()),
87 +
        };
88 +
        save_config(&config).unwrap();
89 +
90 +
        let loaded = load_config();
91 +
        assert_eq!(loaded.remote_url, config.remote_url);
92 +
        assert_eq!(loaded.api_key, config.api_key);
93 +
    }
94 +
}
apps/sipp/src/db.rs +92 −0
152 152
        Err(e) => Err(DbError::Sqlite(e)),
153 153
    }
154 154
}
155 +
156 +
#[cfg(test)]
157 +
mod tests {
158 +
    use super::*;
159 +
160 +
    fn test_db() -> Db {
161 +
        let conn = Connection::open_in_memory().unwrap();
162 +
        conn.execute(
163 +
            "CREATE TABLE IF NOT EXISTS snippets (
164 +
                id INTEGER PRIMARY KEY AUTOINCREMENT,
165 +
                short_id TEXT NOT NULL UNIQUE,
166 +
                content TEXT NOT NULL,
167 +
                name TEXT NOT NULL
168 +
            )",
169 +
            [],
170 +
        )
171 +
        .unwrap();
172 +
        Arc::new(Mutex::new(conn))
173 +
    }
174 +
175 +
    #[test]
176 +
    fn create_and_get_snippet() {
177 +
        let db = test_db();
178 +
        let snippet = create_snippet(&db, "hello.rs", "fn main() {}").unwrap();
179 +
        assert_eq!(snippet.name, "hello.rs");
180 +
        assert_eq!(snippet.content, "fn main() {}");
181 +
        assert!(!snippet.short_id.is_empty());
182 +
183 +
        let fetched = get_snippet_by_short_id(&db, &snippet.short_id)
184 +
            .unwrap()
185 +
            .unwrap();
186 +
        assert_eq!(fetched.name, "hello.rs");
187 +
        assert_eq!(fetched.content, "fn main() {}");
188 +
    }
189 +
190 +
    #[test]
191 +
    fn get_snippet_not_found() {
192 +
        let db = test_db();
193 +
        let result = get_snippet_by_short_id(&db, "nonexistent").unwrap();
194 +
        assert!(result.is_none());
195 +
    }
196 +
197 +
    #[test]
198 +
    fn get_all_snippets_ordered_desc() {
199 +
        let db = test_db();
200 +
        create_snippet(&db, "first", "aaa").unwrap();
201 +
        create_snippet(&db, "second", "bbb").unwrap();
202 +
203 +
        let all = get_all_snippets(&db).unwrap();
204 +
        assert_eq!(all.len(), 2);
205 +
        assert_eq!(all[0].name, "second"); // DESC order
206 +
        assert_eq!(all[1].name, "first");
207 +
    }
208 +
209 +
    #[test]
210 +
    fn update_snippet() {
211 +
        let db = test_db();
212 +
        let snippet = create_snippet(&db, "old.rs", "old content").unwrap();
213 +
214 +
        let updated = update_snippet_by_short_id(&db, &snippet.short_id, "new.rs", "new content")
215 +
            .unwrap()
216 +
            .unwrap();
217 +
        assert_eq!(updated.name, "new.rs");
218 +
        assert_eq!(updated.content, "new content");
219 +
    }
220 +
221 +
    #[test]
222 +
    fn update_nonexistent_snippet() {
223 +
        let db = test_db();
224 +
        let result = update_snippet_by_short_id(&db, "nope", "name", "content").unwrap();
225 +
        assert!(result.is_none());
226 +
    }
227 +
228 +
    #[test]
229 +
    fn delete_snippet() {
230 +
        let db = test_db();
231 +
        let snippet = create_snippet(&db, "test", "content").unwrap();
232 +
233 +
        let deleted = delete_snippet_by_short_id(&db, &snippet.short_id).unwrap();
234 +
        assert!(deleted);
235 +
236 +
        let fetched = get_snippet_by_short_id(&db, &snippet.short_id).unwrap();
237 +
        assert!(fetched.is_none());
238 +
    }
239 +
240 +
    #[test]
241 +
    fn delete_nonexistent_returns_false() {
242 +
        let db = test_db();
243 +
        let deleted = delete_snippet_by_short_id(&db, "nonexistent").unwrap();
244 +
        assert!(!deleted);
245 +
    }
246 +
}
crates/auth/src/lib.rs +143 −0
54 54
    }
55 55
    None
56 56
}
57 +
58 +
#[cfg(test)]
59 +
mod tests {
60 +
    use super::*;
61 +
    use axum::http::HeaderMap;
62 +
63 +
    // ── verify_password ────────────────────────────────────────────────
64 +
65 +
    #[test]
66 +
    fn verify_password_correct() {
67 +
        assert!(verify_password("hunter2", "hunter2"));
68 +
    }
69 +
70 +
    #[test]
71 +
    fn verify_password_wrong() {
72 +
        assert!(!verify_password("hunter2", "hunter3"));
73 +
    }
74 +
75 +
    #[test]
76 +
    fn verify_password_empty_both() {
77 +
        assert!(verify_password("", ""));
78 +
    }
79 +
80 +
    #[test]
81 +
    fn verify_password_empty_vs_nonempty() {
82 +
        assert!(!verify_password("", "something"));
83 +
        assert!(!verify_password("something", ""));
84 +
    }
85 +
86 +
    #[test]
87 +
    fn verify_password_length_mismatch() {
88 +
        assert!(!verify_password("short", "longer_password"));
89 +
    }
90 +
91 +
    #[test]
92 +
    fn verify_password_over_256_bytes_truncated_to_same() {
93 +
        // Both 300 chars, identical first 256 → truncation makes them equal
94 +
        let long_a = "a".repeat(300);
95 +
        let mut long_b = "a".repeat(256);
96 +
        long_b.push_str(&"b".repeat(44));
97 +
        // Same length, same first 256 bytes → passes
98 +
        assert!(verify_password(&long_a, &long_b));
99 +
    }
100 +
101 +
    #[test]
102 +
    fn verify_password_over_256_bytes_different_prefix() {
103 +
        let long_a = "a".repeat(300);
104 +
        let mut long_b = "a".repeat(300);
105 +
        // Differ within first 256 bytes
106 +
        unsafe { long_b.as_bytes_mut()[0] = b'z'; }
107 +
        assert!(!verify_password(&long_a, &long_b));
108 +
    }
109 +
110 +
    #[test]
111 +
    fn verify_password_exactly_256_bytes() {
112 +
        let pw = "x".repeat(256);
113 +
        assert!(verify_password(&pw, &pw));
114 +
    }
115 +
116 +
    // ── generate_session_token ─────────────────────────────────────────
117 +
118 +
    #[test]
119 +
    fn session_token_is_64_hex_chars() {
120 +
        let token = generate_session_token();
121 +
        assert_eq!(token.len(), 64);
122 +
        assert!(token.chars().all(|c| c.is_ascii_hexdigit()));
123 +
    }
124 +
125 +
    #[test]
126 +
    fn session_token_unique_across_calls() {
127 +
        let a = generate_session_token();
128 +
        let b = generate_session_token();
129 +
        assert_ne!(a, b);
130 +
    }
131 +
132 +
    // ── build_session_cookie ───────────────────────────────────────────
133 +
134 +
    #[test]
135 +
    fn build_session_cookie_not_secure() {
136 +
        let cookie = build_session_cookie("abc123", false);
137 +
        assert!(cookie.contains("session=abc123"));
138 +
        assert!(cookie.contains("HttpOnly"));
139 +
        assert!(cookie.contains("SameSite=Strict"));
140 +
        assert!(cookie.contains("Path=/"));
141 +
        assert!(cookie.contains("Max-Age=604800"));
142 +
        assert!(!cookie.contains("Secure"));
143 +
    }
144 +
145 +
    #[test]
146 +
    fn build_session_cookie_secure() {
147 +
        let cookie = build_session_cookie("abc123", true);
148 +
        assert!(cookie.contains("Secure"));
149 +
    }
150 +
151 +
    // ── clear_session_cookie ───────────────────────────────────────────
152 +
153 +
    #[test]
154 +
    fn clear_session_cookie_has_zero_max_age() {
155 +
        let cookie = clear_session_cookie();
156 +
        assert!(cookie.contains("session="));
157 +
        assert!(cookie.contains("Max-Age=0"));
158 +
        assert!(cookie.contains("HttpOnly"));
159 +
    }
160 +
161 +
    // ── extract_session_cookie ─────────────────────────────────────────
162 +
163 +
    #[test]
164 +
    fn extract_session_cookie_present() {
165 +
        let mut headers = HeaderMap::new();
166 +
        headers.insert("cookie", "session=tok123".parse().unwrap());
167 +
        assert_eq!(extract_session_cookie(&headers), Some("tok123".to_string()));
168 +
    }
169 +
170 +
    #[test]
171 +
    fn extract_session_cookie_absent() {
172 +
        let headers = HeaderMap::new();
173 +
        assert_eq!(extract_session_cookie(&headers), None);
174 +
    }
175 +
176 +
    #[test]
177 +
    fn extract_session_cookie_empty_value() {
178 +
        let mut headers = HeaderMap::new();
179 +
        headers.insert("cookie", "session=".parse().unwrap());
180 +
        assert_eq!(extract_session_cookie(&headers), None);
181 +
    }
182 +
183 +
    #[test]
184 +
    fn extract_session_cookie_among_multiple() {
185 +
        let mut headers = HeaderMap::new();
186 +
        headers.insert(
187 +
            "cookie",
188 +
            "theme=dark; session=abc; lang=en".parse().unwrap(),
189 +
        );
190 +
        assert_eq!(extract_session_cookie(&headers), Some("abc".to_string()));
191 +
    }
192 +
193 +
    #[test]
194 +
    fn extract_session_cookie_no_session_key() {
195 +
        let mut headers = HeaderMap::new();
196 +
        headers.insert("cookie", "theme=dark; lang=en".parse().unwrap());
197 +
        assert_eq!(extract_session_cookie(&headers), None);
198 +
    }
199 +
}