feat: move rss to it's own module and add atom support

This commit is contained in:
Jet 2026-03-23 15:43:02 -07:00
parent 452b8b49c3
commit 3991d25293
No known key found for this signature in database
11 changed files with 875 additions and 143 deletions

View file

@ -0,0 +1,15 @@
[package]
name = "noisebell-rss"
version = "0.1.0"
edition = "2021"
[dependencies]
anyhow = "1.0"
axum = "0.8"
chrono = "0.4"
noisebell-common = { path = "../noisebell-common" }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "signal", "time"] }
tower-http = { version = "0.6", features = ["trace"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }

View file

@ -0,0 +1,74 @@
# RSS Service
Serves public feed endpoints for Noisebell.
This service does not store history. On each request it fetches the current state from `cache-service` and renders that one current state as RSS or Atom.
## States
- `open`: Noisebridge is open right now.
- `closed`: Noisebridge is closed right now.
- `offline`: the cache cannot currently confirm the Pi state.
## Feed behavior
- Feeds are current-state only, not historical archives.
- Each feed emits either one item or zero items.
- The item `guid` changes when the current status event changes, so feed readers can treat it as new.
- Filtered feeds omit the item when the current state does not match that feed.
## Endpoints
| Format | Purpose | Path |
|--------|---------|------|
| JSON | Current status passthrough | `/status` |
| RSS | All states | `/all/rss.xml` |
| Atom | All states | `/all/atom.xml` |
| RSS | Open + closed only | `/door/rss.xml` |
| Atom | Open + closed only | `/door/atom.xml` |
| RSS | Open only | `/open/rss.xml` |
| Atom | Open only | `/open/atom.xml` |
| HTTP | Health check | `/health` |
| HTTP | Redirect to docs | `/` |
Current aliases:
- `/rss.xml` currently serves the same feed as `/all/rss.xml`.
- `/atom.xml` currently serves the same feed as `/all/atom.xml`.
- `/` redirects to the repo README for this module.
## Feed text
Items use custom titles:
- `Noisebridge opened`
- `Noisebridge closed`
- `Noisebridge sensor went offline`
Descriptions include:
- the current state
- when that state began, if known
- when the cache last confirmed the state
## Caching and polling hints
- RSS includes `<ttl>1`, which suggests a 1-minute polling interval.
- HTTP responses include `Cache-Control: public, max-age=60`.
- HTTP responses also include `ETag` and `Last-Modified` so clients can revalidate cheaply.
## Atom vs RSS
RSS and Atom are both XML feed formats.
- RSS is older and very widely supported.
- Atom is newer and has a more regular structure.
- For this service they contain the same information in two different standard formats.
## Configuration
| Variable | Default | Description |
|----------|---------|-------------|
| `NOISEBELL_RSS_CACHE_URL` | required | Base URL of `cache-service` |
| `NOISEBELL_RSS_PORT` | `3002` | Listen port |
| `NOISEBELL_RSS_HTTP_TIMEOUT_SECS` | `10` | Timeout when requesting cache status |

View file

@ -0,0 +1,75 @@
pkg:
{ config, lib, ... }:
let
cfg = config.services.noisebell-rss;
bin = "${pkg}/bin/noisebell-rss";
in
{
options.services.noisebell-rss = {
enable = lib.mkEnableOption "noisebell RSS service";
domain = lib.mkOption {
type = lib.types.str;
description = "Domain for the Caddy virtual host.";
};
port = lib.mkOption {
type = lib.types.port;
default = 3002;
};
cacheUrl = lib.mkOption {
type = lib.types.str;
description = "URL of the cache service (e.g. http://localhost:3000).";
};
httpTimeoutSecs = lib.mkOption {
type = lib.types.ints.positive;
default = 10;
};
};
config = lib.mkIf cfg.enable {
users.users.noisebell-rss = {
isSystemUser = true;
group = "noisebell-rss";
};
users.groups.noisebell-rss = { };
services.caddy.virtualHosts.${cfg.domain}.extraConfig = ''
reverse_proxy localhost:${toString cfg.port}
'';
systemd.services.noisebell-rss = {
description = "Noisebell RSS service";
wantedBy = [ "multi-user.target" ];
after = [ "network-online.target" ];
wants = [ "network-online.target" ];
environment = {
NOISEBELL_RSS_PORT = toString cfg.port;
NOISEBELL_RSS_CACHE_URL = cfg.cacheUrl;
NOISEBELL_RSS_HTTP_TIMEOUT_SECS = toString cfg.httpTimeoutSecs;
RUST_LOG = "info";
};
script = ''
exec ${bin}
'';
serviceConfig = {
Type = "simple";
Restart = "on-failure";
RestartSec = 5;
User = "noisebell-rss";
Group = "noisebell-rss";
NoNewPrivileges = true;
ProtectSystem = "strict";
ProtectHome = true;
PrivateTmp = true;
ProtectKernelTunables = true;
ProtectKernelModules = true;
ProtectControlGroups = true;
RestrictSUIDSGID = true;
};
};
};
}

View file

@ -0,0 +1,677 @@
use std::sync::Arc;
use std::time::Duration;
use anyhow::{Context, Result};
use axum::extract::State;
use axum::http::{header, HeaderMap, HeaderValue, StatusCode};
use axum::response::{IntoResponse, Redirect, Response};
use axum::routing::get;
use axum::{Json, Router};
use chrono::{DateTime, Utc};
use noisebell_common::{CacheStatusResponse, DoorStatus};
use tower_http::trace::TraceLayer;
use tracing::{error, info, Level};
const FEED_TTL_MINUTES: u32 = 1;
const README_URL: &str =
"https://git.extremist.software/jet/noisebell/src/branch/main/remote/rss-service/README.md";
struct AppState {
cache_url: String,
client: reqwest::Client,
}
#[derive(Clone, Copy)]
enum FeedFormat {
Rss,
Atom,
}
#[derive(Clone, Copy)]
enum FeedKind {
All,
Door,
Open,
}
#[derive(Clone, Copy)]
struct FeedSpec {
kind: FeedKind,
format: FeedFormat,
path: &'static str,
}
struct FeedDocument {
body: String,
etag: String,
last_modified: String,
content_type: &'static str,
}
fn unix_now() -> u64 {
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs()
}
fn format_full_timestamp(ts: u64) -> String {
DateTime::from_timestamp(ts as i64, 0)
.map(|dt: DateTime<Utc>| dt.format("%A, %B %-d, %Y at %-I:%M:%S %p UTC").to_string())
.unwrap_or_else(|| format!("unix timestamp {ts}"))
}
fn format_rfc2822_timestamp(ts: u64) -> String {
DateTime::from_timestamp(ts as i64, 0)
.map(|dt: DateTime<Utc>| dt.to_rfc2822())
.unwrap_or_else(|| "Thu, 01 Jan 1970 00:00:00 +0000".to_string())
}
fn format_rfc3339_timestamp(ts: u64) -> String {
DateTime::from_timestamp(ts as i64, 0)
.map(|dt: DateTime<Utc>| dt.to_rfc3339())
.unwrap_or_else(|| "1970-01-01T00:00:00+00:00".to_string())
}
fn format_duration(seconds: u64) -> String {
let units = [(86_400, "day"), (3_600, "hour"), (60, "minute"), (1, "second")];
let mut remaining = seconds;
let mut parts = Vec::new();
for (unit_seconds, name) in units {
if remaining >= unit_seconds {
let count = remaining / unit_seconds;
remaining %= unit_seconds;
let suffix = if count == 1 { "" } else { "s" };
parts.push(format!("{count} {name}{suffix}"));
}
if parts.len() == 2 {
break;
}
}
if parts.is_empty() {
"0 seconds".to_string()
} else if parts.len() == 1 {
parts.remove(0)
} else {
format!("{} and {}", parts[0], parts[1])
}
}
fn xml_escape(text: &str) -> String {
let mut escaped = String::with_capacity(text.len());
for ch in text.chars() {
match ch {
'&' => escaped.push_str("&amp;"),
'<' => escaped.push_str("&lt;"),
'>' => escaped.push_str("&gt;"),
'"' => escaped.push_str("&quot;"),
'\'' => escaped.push_str("&apos;"),
_ => escaped.push(ch),
}
}
escaped
}
fn header_value(headers: &HeaderMap, name: &'static str) -> Option<String> {
headers
.get(name)
.and_then(|value| value.to_str().ok())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn public_base_url(headers: &HeaderMap) -> String {
let host = header_value(headers, "x-forwarded-host")
.or_else(|| header_value(headers, "host"))
.unwrap_or_else(|| "localhost:3002".to_string());
let scheme = header_value(headers, "x-forwarded-proto").unwrap_or_else(|| "http".to_string());
format!("{scheme}://{host}")
}
fn item_timestamp(status: &CacheStatusResponse) -> u64 {
status.since.or(status.last_checked).unwrap_or(0)
}
fn item_guid(status: &CacheStatusResponse) -> String {
let ts = item_timestamp(status);
format!("urn:noisebell:status:{}:{ts}", status.status.as_str())
}
fn feed_kind_slug(kind: FeedKind) -> &'static str {
match kind {
FeedKind::All => "all",
FeedKind::Door => "door",
FeedKind::Open => "open",
}
}
fn feed_title(kind: FeedKind) -> &'static str {
match kind {
FeedKind::All => "Noisebell all status feed",
FeedKind::Door => "Noisebell door state feed",
FeedKind::Open => "Noisebell open-only feed",
}
}
fn feed_description(kind: FeedKind) -> &'static str {
match kind {
FeedKind::All => "Current Noisebell state including open, closed, and offline.",
FeedKind::Door => "Current Noisebell door state, limited to open and closed.",
FeedKind::Open => "Current Noisebell state when the space is open.",
}
}
fn feed_self_path(spec: FeedSpec) -> &'static str {
spec.path
}
fn feed_alt_path(kind: FeedKind, format: FeedFormat) -> &'static str {
match (kind, format) {
(FeedKind::All, FeedFormat::Rss) => "/all/rss.xml",
(FeedKind::All, FeedFormat::Atom) => "/all/atom.xml",
(FeedKind::Door, FeedFormat::Rss) => "/door/",
(FeedKind::Door, FeedFormat::Atom) => "/door/atom.xml",
(FeedKind::Open, FeedFormat::Rss) => "/open/",
(FeedKind::Open, FeedFormat::Atom) => "/open/atom.xml",
}
}
fn matches_feed(kind: FeedKind, status: DoorStatus) -> bool {
match kind {
FeedKind::All => true,
FeedKind::Door => matches!(status, DoorStatus::Open | DoorStatus::Closed),
FeedKind::Open => status == DoorStatus::Open,
}
}
fn custom_item_title(status: DoorStatus) -> &'static str {
match status {
DoorStatus::Open => "Noisebridge opened",
DoorStatus::Closed => "Noisebridge closed",
DoorStatus::Offline => "Noisebridge sensor went offline",
}
}
fn richer_item_text(status: &CacheStatusResponse, now: u64) -> String {
let since_sentence = status
.since
.map(|ts| {
format!(
"The current state became {} at {}, about {} ago.",
status.status,
format_full_timestamp(ts),
format_duration(now.saturating_sub(ts)),
)
})
.unwrap_or_else(|| {
format!("The current state is {}, but the start time is unknown.", status.status)
});
let checked_sentence = status
.last_checked
.map(|ts| {
format!(
"The cache last confirmed this at {}, about {} ago.",
format_full_timestamp(ts),
format_duration(now.saturating_sub(ts)),
)
})
.unwrap_or_else(|| "The cache has no recorded last-checked time yet.".to_string());
let lead = match status.status {
DoorStatus::Open => "Noisebridge is open right now.",
DoorStatus::Closed => "Noisebridge is closed right now.",
DoorStatus::Offline => "Noisebridge is currently offline right now.",
};
format!("{lead} {since_sentence} {checked_sentence}")
}
fn effective_human_readable(status: &CacheStatusResponse, now: u64) -> String {
if status.human_readable.is_empty() {
richer_item_text(status, now)
} else {
status.human_readable.clone()
}
}
fn build_rss_item(base_url: &str, status: &CacheStatusResponse, now: u64) -> String {
let ts = item_timestamp(status);
let pub_date = format_rfc2822_timestamp(ts);
let link = format!("{base_url}/status");
let guid = item_guid(status);
let title = custom_item_title(status.status);
let description = richer_item_text(status, now);
format!(
concat!(
" <item>\n",
" <title>{title}</title>\n",
" <link>{link}</link>\n",
" <guid isPermaLink=\"false\">{guid}</guid>\n",
" <pubDate>{pub_date}</pubDate>\n",
" <description>{description}</description>\n",
" </item>\n"
),
title = xml_escape(title),
link = xml_escape(&link),
guid = xml_escape(&guid),
pub_date = xml_escape(&pub_date),
description = xml_escape(&description),
)
}
fn build_rss_feed(
base_url: &str,
spec: FeedSpec,
status: &CacheStatusResponse,
now: u64,
) -> FeedDocument {
let ts = item_timestamp(status);
let pub_date = format_rfc2822_timestamp(ts);
let self_url = format!("{base_url}{}", feed_self_path(spec));
let atom_alt_url = format!("{base_url}{}", feed_alt_path(spec.kind, FeedFormat::Atom));
let item = if matches_feed(spec.kind, status.status) {
build_rss_item(base_url, status, now)
} else {
String::new()
};
let etag = format!("\"rss:{}:{}:{}\"", feed_kind_slug(spec.kind), status.status.as_str(), ts);
let body = format!(
concat!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n",
"<rss version=\"2.0\" xmlns:atom=\"http://www.w3.org/2005/Atom\">\n",
" <channel>\n",
" <title>{title}</title>\n",
" <link>{self_url}</link>\n",
" <description>{description}</description>\n",
" <lastBuildDate>{pub_date}</lastBuildDate>\n",
" <ttl>{ttl}</ttl>\n",
" <atom:link href=\"{self_url_attr}\" rel=\"self\" type=\"application/rss+xml\" />\n",
" <atom:link href=\"{atom_url_attr}\" rel=\"alternate\" type=\"application/atom+xml\" />\n",
"{item}",
" </channel>\n",
"</rss>\n"
),
title = xml_escape(feed_title(spec.kind)),
self_url = xml_escape(&self_url),
description = xml_escape(feed_description(spec.kind)),
pub_date = xml_escape(&pub_date),
ttl = FEED_TTL_MINUTES,
self_url_attr = xml_escape(&self_url),
atom_url_attr = xml_escape(&atom_alt_url),
item = item,
);
FeedDocument {
body,
etag,
last_modified: pub_date,
content_type: "application/rss+xml; charset=utf-8",
}
}
fn build_atom_entry(base_url: &str, status: &CacheStatusResponse, now: u64) -> String {
let ts = item_timestamp(status);
let updated = format_rfc3339_timestamp(ts);
let link = format!("{base_url}/status");
let guid = item_guid(status);
let title = custom_item_title(status.status);
let description = richer_item_text(status, now);
format!(
concat!(
" <entry>\n",
" <title>{title}</title>\n",
" <id>{guid}</id>\n",
" <updated>{updated}</updated>\n",
" <link href=\"{link_attr}\" />\n",
" <summary>{description}</summary>\n",
" </entry>\n"
),
title = xml_escape(title),
guid = xml_escape(&guid),
updated = xml_escape(&updated),
link_attr = xml_escape(&link),
description = xml_escape(&description),
)
}
fn build_atom_feed(
base_url: &str,
spec: FeedSpec,
status: &CacheStatusResponse,
now: u64,
) -> FeedDocument {
let ts = item_timestamp(status);
let updated = format_rfc3339_timestamp(ts);
let self_url = format!("{base_url}{}", feed_self_path(spec));
let rss_alt_url = format!("{base_url}{}", feed_alt_path(spec.kind, FeedFormat::Rss));
let id = format!("urn:noisebell:feed:{}", feed_kind_slug(spec.kind));
let entry = if matches_feed(spec.kind, status.status) {
build_atom_entry(base_url, status, now)
} else {
String::new()
};
let etag = format!("\"atom:{}:{}:{}\"", feed_kind_slug(spec.kind), status.status.as_str(), ts);
let body = format!(
concat!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n",
"<feed xmlns=\"http://www.w3.org/2005/Atom\">\n",
" <title>{title}</title>\n",
" <id>{id}</id>\n",
" <updated>{updated}</updated>\n",
" <link href=\"{self_url_attr}\" rel=\"self\" />\n",
" <link href=\"{rss_url_attr}\" rel=\"alternate\" type=\"application/rss+xml\" />\n",
" <subtitle>{description}</subtitle>\n",
"{entry}",
"</feed>\n"
),
title = xml_escape(feed_title(spec.kind)),
id = xml_escape(&id),
updated = xml_escape(&updated),
self_url_attr = xml_escape(&self_url),
rss_url_attr = xml_escape(&rss_alt_url),
description = xml_escape(feed_description(spec.kind)),
entry = entry,
);
FeedDocument {
body,
etag,
last_modified: format_rfc2822_timestamp(ts),
content_type: "application/atom+xml; charset=utf-8",
}
}
fn build_feed_document(
base_url: &str,
spec: FeedSpec,
status: &CacheStatusResponse,
now: u64,
) -> FeedDocument {
match spec.format {
FeedFormat::Rss => build_rss_feed(base_url, spec, status, now),
FeedFormat::Atom => build_atom_feed(base_url, spec, status, now),
}
}
fn header_matches(headers: &HeaderMap, name: &'static str, expected: &str) -> bool {
headers
.get(name)
.and_then(|value| value.to_str().ok())
.map(str::trim)
.map(|value| value == expected)
.unwrap_or(false)
}
fn response_with_cache(headers: &HeaderMap, document: FeedDocument) -> Response {
if header_matches(headers, "if-none-match", &document.etag) {
return (
StatusCode::NOT_MODIFIED,
[
(header::ETAG, document.etag),
(header::LAST_MODIFIED, document.last_modified),
(header::CACHE_CONTROL, "public, max-age=60".to_string()),
],
)
.into_response();
}
let mut response = document.body.into_response();
response
.headers_mut()
.insert(header::CONTENT_TYPE, HeaderValue::from_static(document.content_type));
response
.headers_mut()
.insert(header::CACHE_CONTROL, HeaderValue::from_static("public, max-age=60"));
response
.headers_mut()
.insert(header::ETAG, HeaderValue::from_str(&document.etag).expect("etag is always valid"));
response.headers_mut().insert(
header::LAST_MODIFIED,
HeaderValue::from_str(&document.last_modified).expect("last-modified is always valid"),
);
response
}
async fn fetch_status(state: &AppState) -> Result<CacheStatusResponse, StatusCode> {
let url = format!("{}/status", state.cache_url);
let response = state.client.get(&url).send().await.map_err(|e| {
error!(error = %e, %url, "failed to reach cache service");
StatusCode::BAD_GATEWAY
})?;
if !response.status().is_success() {
error!(status = %response.status(), %url, "cache service returned non-success status");
return Err(StatusCode::BAD_GATEWAY);
}
response.json::<CacheStatusResponse>().await.map_err(|e| {
error!(error = %e, %url, "failed to parse cache status response");
StatusCode::BAD_GATEWAY
})
}
async fn health() -> StatusCode {
StatusCode::OK
}
async fn root_redirect() -> Redirect {
Redirect::temporary(README_URL)
}
async fn get_status(
State(state): State<Arc<AppState>>,
) -> Result<Json<CacheStatusResponse>, StatusCode> {
let mut status = fetch_status(&state).await?;
status.human_readable = effective_human_readable(&status, unix_now());
Ok(Json(status))
}
async fn serve_feed(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
spec: FeedSpec,
) -> Result<Response, StatusCode> {
let mut status = fetch_status(&state).await?;
let now = unix_now();
status.human_readable = effective_human_readable(&status, now);
let base_url = public_base_url(&headers);
let document = build_feed_document(&base_url, spec, &status, now);
Ok(response_with_cache(&headers, document))
}
async fn get_all_rss(
state: State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<Response, StatusCode> {
serve_feed(state, headers, FeedSpec { kind: FeedKind::All, format: FeedFormat::Rss, path: "/" })
.await
}
async fn get_all_atom(
state: State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<Response, StatusCode> {
serve_feed(
state,
headers,
FeedSpec { kind: FeedKind::All, format: FeedFormat::Atom, path: "/atom.xml" },
)
.await
}
async fn get_door_rss(
state: State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<Response, StatusCode> {
serve_feed(
state,
headers,
FeedSpec { kind: FeedKind::Door, format: FeedFormat::Rss, path: "/door/" },
)
.await
}
async fn get_door_atom(
state: State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<Response, StatusCode> {
serve_feed(
state,
headers,
FeedSpec { kind: FeedKind::Door, format: FeedFormat::Atom, path: "/door/atom.xml" },
)
.await
}
async fn get_open_rss(
state: State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<Response, StatusCode> {
serve_feed(
state,
headers,
FeedSpec { kind: FeedKind::Open, format: FeedFormat::Rss, path: "/open/" },
)
.await
}
async fn get_open_atom(
state: State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<Response, StatusCode> {
serve_feed(
state,
headers,
FeedSpec { kind: FeedKind::Open, format: FeedFormat::Atom, path: "/open/atom.xml" },
)
.await
}
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.init();
let port: u16 = std::env::var("NOISEBELL_RSS_PORT")
.unwrap_or_else(|_| "3002".into())
.parse()
.context("NOISEBELL_RSS_PORT must be a valid u16")?;
let cache_url = std::env::var("NOISEBELL_RSS_CACHE_URL")
.context("NOISEBELL_RSS_CACHE_URL is required")?
.trim_end_matches('/')
.to_string();
let http_timeout_secs: u64 = std::env::var("NOISEBELL_RSS_HTTP_TIMEOUT_SECS")
.unwrap_or_else(|_| "10".into())
.parse()
.context("NOISEBELL_RSS_HTTP_TIMEOUT_SECS must be a valid u64")?;
info!(port, %cache_url, ttl_minutes = FEED_TTL_MINUTES, "starting noisebell-rss");
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(http_timeout_secs))
.build()
.context("failed to build HTTP client")?;
let app_state = Arc::new(AppState { cache_url, client });
let app = Router::new()
.route("/health", get(health))
.route("/status", get(get_status))
.route("/", get(root_redirect))
.route("/rss.xml", get(get_all_rss))
.route("/atom.xml", get(get_all_atom))
.route("/all/rss.xml", get(get_all_rss))
.route("/all/atom.xml", get(get_all_atom))
.route("/door/", get(get_door_rss))
.route("/door/rss.xml", get(get_door_rss))
.route("/door/atom.xml", get(get_door_atom))
.route("/open/", get(get_open_rss))
.route("/open/rss.xml", get(get_open_rss))
.route("/open/atom.xml", get(get_open_atom))
.layer(
TraceLayer::new_for_http()
.make_span_with(tower_http::trace::DefaultMakeSpan::new().level(Level::INFO))
.on_response(tower_http::trace::DefaultOnResponse::new().level(Level::INFO)),
)
.with_state(app_state);
let listener = tokio::net::TcpListener::bind(("0.0.0.0", port))
.await
.context(format!("failed to bind to 0.0.0.0:{port}"))?;
info!(port, "listening");
let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.context("failed to register SIGTERM handler")?;
axum::serve(listener, app)
.with_graceful_shutdown(async move {
sigterm.recv().await;
})
.await
.context("server error")?;
info!("shutdown complete");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_status(status: DoorStatus) -> CacheStatusResponse {
CacheStatusResponse {
status,
since: Some(1_700_000_000),
last_checked: Some(1_700_000_120),
human_readable: String::new(),
}
}
#[test]
fn open_feed_omits_closed_item() {
let feed = build_rss_feed(
"https://rss.example.com",
FeedSpec { kind: FeedKind::Open, format: FeedFormat::Rss, path: "/open/" },
&sample_status(DoorStatus::Closed),
1_700_000_200,
);
assert_eq!(feed.body.matches("<item>").count(), 0);
}
#[test]
fn all_rss_feed_contains_custom_title() {
let feed = build_rss_feed(
"https://rss.example.com",
FeedSpec { kind: FeedKind::All, format: FeedFormat::Rss, path: "/" },
&sample_status(DoorStatus::Open),
1_700_000_200,
);
assert!(feed.body.contains("<title>Noisebridge opened</title>"));
assert!(feed.body.contains("<ttl>1</ttl>"));
assert!(feed.body.contains("/atom.xml"));
}
#[test]
fn atom_feed_contains_entry() {
let feed = build_atom_feed(
"https://rss.example.com",
FeedSpec { kind: FeedKind::Door, format: FeedFormat::Atom, path: "/door/atom.xml" },
&sample_status(DoorStatus::Closed),
1_700_000_200,
);
assert!(feed.body.contains("<feed xmlns=\"http://www.w3.org/2005/Atom\">"));
assert!(feed.body.contains("<entry>"));
assert!(feed.body.contains("Noisebridge closed"));
}
}