Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitleaks.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ regex = '''signal-cli.*password["\s:=]+[^\s"]{8,}'''
tags = ["password", "signal"]

[allowlist]
regexes = [
# WHY: canonical exousia HS256 unit-test secret (fixture, no live credential); shared verbatim across
# exousia lib.rs/middleware.rs/service.rs test modules. Value-scoped so it needs no per-file maintenance.
'''test-secret-that-is-long-enough-for-hs256''',
]
paths = [
'''vendor/''',
'''\.gitleaks\.toml''',
Expand Down
40 changes: 33 additions & 7 deletions crates/apotheke/src/repo/music.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use snafu::ResultExt;
use sqlx::SqlitePool;

use crate::error::{DbError, QuerySnafu};
use crate::error::{DbError, QuerySnafu, TransactionSnafu};

// WHY: wire DTO — SQLx row from the music_release_groups table.
#[derive(Debug, Clone, sqlx::FromRow)]
Expand Down Expand Up @@ -90,17 +90,22 @@ pub async fn insert_release_group(
Ok(())
}

pub async fn get_release_group(
pool: &SqlitePool,
// NOTE: executor-generic so hierarchy reads can run inside a transaction
// snapshot (`&mut *tx`) or directly on a pool (`&pool`).
pub async fn get_release_group<'e, E>(
executor: E,
id: &[u8],
) -> Result<Option<MusicReleaseGroup>, DbError> {
) -> Result<Option<MusicReleaseGroup>, DbError>
where
E: sqlx::Executor<'e, Database = sqlx::Sqlite>,
{
sqlx::query_as::<_, MusicReleaseGroup>(
"SELECT id, registry_id, title, rg_type, mb_release_group_id, year,
quality_profile_id, added_at
FROM music_release_groups WHERE id = ?",
)
.bind(id)
.fetch_optional(pool)
.fetch_optional(executor)
.await
.context(QuerySnafu {
table: "music_release_groups",
Expand Down Expand Up @@ -517,22 +522,30 @@ pub async fn get_track_scrobble_metadata(

// --- hierarchy queries ---

// INVARIANT: both SELECTs run inside one transaction snapshot — a concurrent
// delete of the group between the two reads cannot yield the inconsistent
// (None, non-empty) shape; a missing group short-circuits to (None, []).
pub async fn get_release_group_with_releases(
pool: &SqlitePool,
group_id: &[u8],
) -> Result<(Option<MusicReleaseGroup>, Vec<MusicRelease>), DbError> {
let group = get_release_group(pool, group_id).await?;
let mut tx = pool.begin().await.context(TransactionSnafu)?;
let group = get_release_group(&mut *tx, group_id).await?;
if group.is_none() {
return Ok((None, Vec::new()));
}
let releases = sqlx::query_as::<_, MusicRelease>(
"SELECT id, release_group_id, title, release_date, country, label,
catalog_number, mb_release_id, added_at
FROM music_releases WHERE release_group_id = ? ORDER BY release_date",
)
.bind(group_id)
.fetch_all(pool)
.fetch_all(&mut *tx)
.await
.context(QuerySnafu {
table: "music_releases",
})?;
tx.commit().await.context(TransactionSnafu)?;
Ok((group, releases))
}

Expand Down Expand Up @@ -718,6 +731,19 @@ mod tests {
assert_eq!(flat.len(), 1);
}

#[tokio::test]
async fn get_release_group_with_releases_missing_group_returns_none_and_empty() {
let pool = setup().await;
let (group, releases) = get_release_group_with_releases(&pool, &make_id())
.await
.unwrap();
assert!(group.is_none());
assert!(
releases.is_empty(),
"a missing group must never pair with releases"
);
}

#[tokio::test]
async fn list_empty_returns_empty() {
let pool = setup().await;
Expand Down
130 changes: 130 additions & 0 deletions crates/apotheke/src/repo/news.rs
Original file line number Diff line number Diff line change
Expand Up @@ -407,4 +407,134 @@ mod tests {
let results = list_feeds(&pool, 10, 0).await.unwrap();
assert!(results.is_empty());
}

async fn seed_feed(pool: &SqlitePool, url: &str) -> Vec<u8> {
let id = make_id();
let feed = NewsFeed {
id: id.clone(),
title: "Seed Feed".to_string(),
url: url.to_string(),
site_url: None,
description: None,
category: None,
icon_url: None,
last_fetched_at: None,
fetch_interval_minutes: 60,
is_active: 1,
added_at: now(),
updated_at: now(),
};
insert_feed(pool, &feed).await.unwrap();
id
}

async fn seed_article(pool: &SqlitePool, feed_id: &[u8], guid: &str, published_at: &str) {
let article = NewsArticle {
id: make_id(),
feed_id: feed_id.to_vec(),
guid: guid.to_string(),
title: format!("Article {guid}"),
url: format!("https://example.com/{guid}"),
author: None,
content_html: None,
summary: None,
published_at: Some(published_at.to_string()),
is_read: 0,
is_starred: 0,
source_type: "rss".to_string(),
added_at: now(),
};
insert_article(pool, &article).await.unwrap();
}

// -- article_guid_exists dedup guard --

#[tokio::test]
async fn article_guid_exists_true_after_insert() {
let pool = setup().await;
let feed_id = seed_feed(&pool, "https://example.com/a.xml").await;
seed_article(&pool, &feed_id, "guid-001", "2026-01-01T00:00:00Z").await;

assert!(
article_guid_exists(&pool, &feed_id, "guid-001")
.await
.unwrap()
);
}

#[tokio::test]
async fn article_guid_exists_false_for_unseen_guid() {
let pool = setup().await;
let feed_id = seed_feed(&pool, "https://example.com/b.xml").await;
seed_article(&pool, &feed_id, "guid-001", "2026-01-01T00:00:00Z").await;

assert!(
!article_guid_exists(&pool, &feed_id, "guid-999")
.await
.unwrap()
);
}

#[tokio::test]
async fn article_guid_exists_false_for_same_guid_different_feed() {
let pool = setup().await;
let feed_a = seed_feed(&pool, "https://example.com/c.xml").await;
let feed_b = seed_feed(&pool, "https://example.com/d.xml").await;
seed_article(&pool, &feed_a, "shared-guid", "2026-01-01T00:00:00Z").await;

assert!(
!article_guid_exists(&pool, &feed_b, "shared-guid")
.await
.unwrap(),
"guid dedup must be scoped per feed"
);
}

// -- delete_articles_exceeding_count retention --

#[tokio::test]
async fn delete_articles_exceeding_count_removes_oldest() {
let pool = setup().await;
let feed_id = seed_feed(&pool, "https://example.com/e.xml").await;
for (guid, published) in [
("old-1", "2026-01-01T00:00:00Z"),
("old-2", "2026-01-02T00:00:00Z"),
("new-1", "2026-01-03T00:00:00Z"),
("new-2", "2026-01-04T00:00:00Z"),
] {
seed_article(&pool, &feed_id, guid, published).await;
}

let deleted = delete_articles_exceeding_count(&pool, &feed_id, 2)
.await
.unwrap();
assert_eq!(deleted, 2);

for (guid, expected) in [
("old-1", false),
("old-2", false),
("new-1", true),
("new-2", true),
] {
assert_eq!(
article_guid_exists(&pool, &feed_id, guid).await.unwrap(),
expected,
"retention must keep the newest keep_count articles ({guid})"
);
}
}

#[tokio::test]
async fn delete_articles_exceeding_count_noop_when_under_limit() {
let pool = setup().await;
let feed_id = seed_feed(&pool, "https://example.com/f.xml").await;
seed_article(&pool, &feed_id, "only-1", "2026-01-01T00:00:00Z").await;
seed_article(&pool, &feed_id, "only-2", "2026-01-02T00:00:00Z").await;

let deleted = delete_articles_exceeding_count(&pool, &feed_id, 5)
.await
.unwrap();
assert_eq!(deleted, 0);
assert_eq!(count_articles_for_feed(&pool, &feed_id).await.unwrap(), 2);
}
}
69 changes: 47 additions & 22 deletions crates/apotheke/src/repo/play_history/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -156,17 +156,20 @@ fn bytes_to_media_id(bytes: Vec<u8>) -> Option<MediaId> {
Some(MediaId::from_uuid(uuid::Uuid::from_bytes(arr)))
}

fn parse_media_type(s: &str) -> MediaType {
// WHY: `None` for an unrecognized string — a silent default would alias
// corrupt or future-variant rows onto Music; callers skip-and-log instead,
// mirroring the bytes_to_media_id filter_map pattern.
fn parse_media_type(s: &str) -> Option<MediaType> {
match s {
"music" => MediaType::Music,
"audiobook" => MediaType::Audiobook,
"book" => MediaType::Book,
"comic" => MediaType::Comic,
"podcast" => MediaType::Podcast,
"news" => MediaType::News,
"movie" => MediaType::Movie,
"tv" => MediaType::Tv,
_ => MediaType::Music,
"music" => Some(MediaType::Music),
"audiobook" => Some(MediaType::Audiobook),
"book" => Some(MediaType::Book),
"comic" => Some(MediaType::Comic),
"podcast" => Some(MediaType::Podcast),
"news" => Some(MediaType::News),
"movie" => Some(MediaType::Movie),
"tv" => Some(MediaType::Tv),
_ => None,
}
}

Expand Down Expand Up @@ -231,6 +234,7 @@ pub async fn end_session(
pub async fn get_active_sessions(
pool: &SqlitePool,
user_id: UserId,
limit: u32,
) -> Result<Vec<PlaySession>, DbError> {
sqlx::query_as::<_, PlaySession>(
"SELECT id, media_id, user_id, media_type, started_at, ended_at,
Expand All @@ -239,9 +243,11 @@ pub async fn get_active_sessions(
device_name, quality_score, dsp_active
FROM play_sessions
WHERE user_id = ? AND ended_at IS NULL
ORDER BY started_at DESC",
ORDER BY started_at DESC
LIMIT ?",
)
.bind(user_id.as_bytes().as_ref())
.bind(i64::from(limit))
.fetch_all(pool)
.await
.context(QuerySnafu {
Expand Down Expand Up @@ -346,14 +352,18 @@ pub async fn update_item_stats(
Ok(())
}

// INVARIANT: the upsert and the unique_items recompute run inside one
// `BEGIN IMMEDIATE` transaction — a failure between them rolls back the
// upsert instead of leaving unique_items permanently stale for the bucket.
pub async fn update_daily_stats(
pool: &SqlitePool,
user_id: UserId,
date: &str,
media_type: MediaType,
media_id: MediaId,
duration_ms: i64,
) -> Result<(), DbError> {
let mut tx = crate::pools::begin_immediate(pool).await?;

sqlx::query(
"INSERT INTO play_stats_daily
(user_id, date, media_type, sessions, total_ms, unique_items)
Expand All @@ -366,7 +376,7 @@ pub async fn update_daily_stats(
.bind(date)
.bind(media_type.to_string())
.bind(duration_ms)
.execute(pool)
.execute(&mut *tx)
.await
.context(QuerySnafu {
table: "play_stats_daily",
Expand All @@ -390,13 +400,13 @@ pub async fn update_daily_stats(
.bind(user_id.as_bytes().as_ref())
.bind(date)
.bind(media_type.to_string())
.execute(pool)
.execute(&mut *tx)
.await
.context(QuerySnafu {
table: "play_stats_daily",
})?;

let _ = media_id;
crate::pools::commit_tx(tx).await?;
Ok(())
}

Expand Down Expand Up @@ -633,9 +643,16 @@ pub async fn listening_time(
let mut by_media_type = Vec::with_capacity(rows.len());

for row in rows {
// WHY: handled here — an unrecognized media_type row is dropped from
// the per-type breakdown (and from the totals, keeping them consistent)
// rather than silently aggregated under Music.
let Some(media_type) = parse_media_type(&row.media_type) else {
tracing::warn!(media_type = %row.media_type, "skipping unknown media_type row in listening_time");
continue;
};
total_ms += row.total_ms;
session_count += row.session_count;
by_media_type.push((parse_media_type(&row.media_type), row.total_ms));
by_media_type.push((media_type, row.total_ms));
}

Ok(ListeningTimeSummary {
Expand Down Expand Up @@ -667,12 +684,20 @@ pub async fn daily_activity(

Ok(rows
.into_iter()
.map(|r| DailyStats {
date: r.date,
media_type: parse_media_type(&r.media_type),
sessions: r.sessions,
total_ms: r.total_ms,
unique_items: r.unique_items,
.filter_map(|r| {
// WHY: handled here — an unrecognized media_type row is skipped
// rather than silently aliased onto Music.
let Some(media_type) = parse_media_type(&r.media_type) else {
tracing::warn!(media_type = %r.media_type, "skipping unknown media_type row in daily_activity");
return None;
};
Some(DailyStats {
date: r.date,
media_type,
sessions: r.sessions,
total_ms: r.total_ms,
unique_items: r.unique_items,
})
})
.collect())
}
Expand Down
Loading