feat(rhema-code): add the colors setting with four palettes
The stored `colors` key names a palette — rhema (the default, Jadeite), aquamarine, black-and-white, or dracula — and the Appearance page of /settings cycles it live, like the theme and icon toggles beside it. Each palette carries a dark and a light appearance under the existing `theme` key; dracula's light side is derived, the official palette ships only dark. The appearance decision moves off token comparison: Theme now answers is_dark() from the ground/text luminance, so palettes beyond the original two get the right per-profile fallback tones.
This commit is contained in:
commit
81da26b955
159 files changed
+50628
No files matched your search
@@ -0,0 +1,144 @@
|
||||
//! Offline integration tests: the CalDAV backend against an in-process
|
||||
//! wiremock server. Zero network.
|
||||
|
||||
use rhema_calendar_integration::{CaldavCalendar, Calendar, CalendarError, Window};
|
||||
use time::OffsetDateTime;
|
||||
use time::format_description::well_known::Rfc3339;
|
||||
use wiremock::matchers::{body_string_contains, header, method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
fn window() -> Window {
|
||||
// Oct 1 00:00 to Oct 3 00:00: wide enough that the recurring
|
||||
// master's second day lands inside, and the `time-range` bound in
|
||||
// the request reads `end=20261003T000000Z`.
|
||||
let from = OffsetDateTime::parse("2026-10-01T00:00:00Z", &Rfc3339).expect("parses");
|
||||
Window::new(from, from + time::Duration::hours(48))
|
||||
}
|
||||
|
||||
/// The 207 answer: one single event, one recurring master with a moved
|
||||
/// override. The iCalendar objects carry ` ` where CRLF
|
||||
/// belongs, because that is how servers spell an ICS inside XML, and
|
||||
/// an unexpanded reference would fold the object into one unreadable
|
||||
/// line.
|
||||
const MULTISTATUS: &str = r#"<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
||||
<D:response>
|
||||
<D:href>/calendars/me/home/kickoff.ics</D:href>
|
||||
<D:propstat>
|
||||
<D:prop>
|
||||
<D:getetag>"1"</D:getetag>
|
||||
<C:calendar-data>BEGIN:VCALENDAR VERSION:2.0 BEGIN:VEVENT UID:kickoff@x DTSTART:20261001T140000Z DTEND:20261001T150000Z SUMMARY:kickoff END:VEVENT END:VCALENDAR</C:calendar-data>
|
||||
</D:prop>
|
||||
<D:status>HTTP/1.1 200 OK</D:status>
|
||||
</D:propstat>
|
||||
</D:response>
|
||||
<D:response>
|
||||
<D:href>/calendars/me/home/standup.ics</D:href>
|
||||
<D:propstat>
|
||||
<D:prop>
|
||||
<D:getetag>"2"</D:getetag>
|
||||
<C:calendar-data>BEGIN:VCALENDAR BEGIN:VEVENT UID:standup@x DTSTART:20261001T090000Z RRULE:FREQ=DAILY;COUNT=3 SUMMARY:standup END:VEVENT BEGIN:VEVENT UID:standup@x RECURRENCE-ID:20261001T090000Z DTSTART:20261001T160000Z SUMMARY:standup moved END:VEVENT END:VCALENDAR</C:calendar-data>
|
||||
</D:prop>
|
||||
<D:status>HTTP/1.1 200 OK</D:status>
|
||||
</D:propstat>
|
||||
</D:response>
|
||||
</D:multistatus>"#;
|
||||
|
||||
fn calendar(server: &MockServer) -> CaldavCalendar {
|
||||
CaldavCalendar::new(format!("{}/calendars/me/home/", server.uri())).with_basic("me", "app-pass")
|
||||
}
|
||||
|
||||
fn starts(events: &[rhema_calendar_integration::CalendarEvent]) -> Vec<String> {
|
||||
events
|
||||
.iter()
|
||||
.map(|event| event.start.format(&Rfc3339).expect("formats"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_poll_is_one_report_and_expands_the_answer() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("REPORT"))
|
||||
.and(path("/calendars/me/home"))
|
||||
.and(header("Depth", "1"))
|
||||
.and(body_string_contains(
|
||||
r#"<C:time-range start="20261001T000000Z" end="20261003T000000Z"/>"#,
|
||||
))
|
||||
.respond_with(ResponseTemplate::new(207).set_body_string(MULTISTATUS))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let events = calendar(&server).events(&window()).await.expect("polls");
|
||||
// The master's Oct 1 occurrence is suppressed by the override that
|
||||
// moved it to 16:00; the Oct 2 occurrence is in the window, and
|
||||
// the Oct 3 one (COUNT=3) is past its end.
|
||||
assert_eq!(events.len(), 3, "{:?}", starts(&events));
|
||||
assert_eq!(events[0].title, "kickoff");
|
||||
assert_eq!(events[1].title, "standup moved");
|
||||
assert_eq!(events[2].title, "standup");
|
||||
for event in &events {
|
||||
assert!(
|
||||
event.id.starts_with("caldav:"),
|
||||
"occurrence ids spell the adapter: {}",
|
||||
event.id
|
||||
);
|
||||
}
|
||||
assert!(starts(&events)[0].contains("14:00"));
|
||||
assert!(starts(&events)[1].contains("16:00"));
|
||||
assert!(starts(&events)[2].contains("2026-10-02T09:00"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn basic_credentials_ride_the_authorization_header_only() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("REPORT"))
|
||||
.and(header("Authorization", "Basic bWU6YXBwLXBhc3M="))
|
||||
.respond_with(ResponseTemplate::new(207).set_body_string(MULTISTATUS))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
calendar(&server).events(&window()).await.expect("polls");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_bearer_token_is_the_other_auth_spelling() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("REPORT"))
|
||||
.and(header("Authorization", "Bearer sekrit"))
|
||||
.respond_with(ResponseTemplate::new(207).set_body_string(MULTISTATUS))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let calendar = CaldavCalendar::new(server.uri()).with_token("sekrit");
|
||||
calendar.events(&window()).await.expect("polls");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_server_error_is_named() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("REPORT"))
|
||||
.respond_with(ResponseTemplate::new(503).set_body_string("maintenance"))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let err = calendar(&server).events(&window()).await.expect_err("loud");
|
||||
assert!(
|
||||
matches!(err, CalendarError::Api { status: 503, .. }),
|
||||
"got: {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_non_xml_answer_is_a_loud_parse_error() {
|
||||
let server = MockServer::start().await;
|
||||
// An HTML login page can arrive with a 200; almost none of it is
|
||||
// well-formed XML, and a shape break must surface, not read as a
|
||||
// quietly empty calendar.
|
||||
Mock::given(method("REPORT"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string("<D:multistatus></D:response>"))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let err = calendar(&server).events(&window()).await.expect_err("loud");
|
||||
assert!(matches!(err, CalendarError::Parse(_)), "got: {err:?}");
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
//! Offline integration tests: the Google Calendar backend against an
|
||||
//! in-process wiremock server. Zero network.
|
||||
|
||||
use rhema_calendar_integration::{Calendar, CalendarError, GoogleCalendar, Window};
|
||||
use serde_json::{Value, json};
|
||||
use time::OffsetDateTime;
|
||||
use time::format_description::well_known::Rfc3339;
|
||||
use wiremock::matchers::{method, path, query_param};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
fn window() -> Window {
|
||||
let from = OffsetDateTime::parse("2026-10-20T00:00:00Z", &Rfc3339).expect("parses");
|
||||
Window::new(from, from + time::Duration::hours(24))
|
||||
}
|
||||
|
||||
/// A timed row; `patch` overrides any field.
|
||||
fn row(id: &str, patch: Value) -> Value {
|
||||
let mut base = json!({
|
||||
"id": id,
|
||||
"status": "confirmed",
|
||||
"summary": format!("event {id}"),
|
||||
"start": { "dateTime": "2026-10-20T14:00:00+02:00" },
|
||||
"end": { "dateTime": "2026-10-20T15:00:00+02:00" },
|
||||
"htmlLink": format!("https://calendar.google.com/x/{id}")
|
||||
});
|
||||
if let (Value::Object(base), Value::Object(patch)) = (&mut base, patch) {
|
||||
for (key, value) in patch {
|
||||
base.insert(key, value);
|
||||
}
|
||||
}
|
||||
base
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_poll_asks_for_expanded_instances_ordered_by_start() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/calendar/v3/calendars/primary/events"))
|
||||
.and(query_param("singleEvents", "true"))
|
||||
.and(query_param("orderBy", "startTime"))
|
||||
.and(query_param("timeMin", "2026-10-20T00:00:00Z"))
|
||||
.and(query_param("timeMax", "2026-10-21T00:00:00Z"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "items": [
|
||||
row("a", json!({})),
|
||||
]})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let calendar = GoogleCalendar::new("tok", "primary").with_base_url(server.uri());
|
||||
let events = calendar.events(&window()).await.expect("polls");
|
||||
assert_eq!(events.len(), 1);
|
||||
// An offset start is an instant: the id pins the UTC second.
|
||||
let at = OffsetDateTime::parse("2026-10-20T12:00:00Z", &Rfc3339).expect("parses");
|
||||
assert_eq!(events[0].id, format!("google:a@{}", at.unix_timestamp()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pages_are_followed_until_the_cursor_ends() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(query_param("pageToken", "cursor-2"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "items": [
|
||||
row("last", json!({}))
|
||||
]})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"items": [row("first", json!({}))],
|
||||
"nextPageToken": "cursor-2"
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let calendar = GoogleCalendar::new("tok", "primary").with_base_url(server.uri());
|
||||
let events = calendar.events(&window()).await.expect("polls");
|
||||
let ids: Vec<&str> = events
|
||||
.iter()
|
||||
.map(|event| event.id.split('@').next().unwrap_or(""))
|
||||
.collect();
|
||||
assert_eq!(ids.len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_api_key_rides_the_key_parameter_and_a_token_the_header() {
|
||||
let server = MockServer::start().await;
|
||||
// The key mock is mounted first and wins the requests it matches;
|
||||
// everything else (the keyless bearer client below) falls through
|
||||
// to the refusal.
|
||||
Mock::given(method("GET"))
|
||||
.and(query_param("key", "pub-key"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "items": [
|
||||
row("public", json!({}))
|
||||
]})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.respond_with(ResponseTemplate::new(400))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let calendar = GoogleCalendar::with_api_key("pub-key", "primary").with_base_url(server.uri());
|
||||
assert_eq!(calendar.events(&window()).await.expect("polls").len(), 1);
|
||||
|
||||
// A client built with a token never sends `key`, so it lands on
|
||||
// the refusal: the credential kind decides the wire shape.
|
||||
let calendar = GoogleCalendar::new("tok", "primary").with_base_url(server.uri());
|
||||
let err = calendar
|
||||
.events(&window())
|
||||
.await
|
||||
.expect_err("the keyless request is refused");
|
||||
assert!(
|
||||
matches!(err, CalendarError::Api { status: 400, .. }),
|
||||
"got: {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancelled_rows_do_not_become_reminders() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "items": [
|
||||
row("kept", json!({})),
|
||||
row("gone", json!({ "status": "cancelled" }))
|
||||
]})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let calendar = GoogleCalendar::new("tok", "primary").with_base_url(server.uri());
|
||||
let events = calendar.events(&window()).await.expect("polls");
|
||||
assert_eq!(events.len(), 1);
|
||||
assert!(events[0].id.contains("kept"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_server_error_is_named() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.respond_with(ResponseTemplate::new(403).set_body_string("quota"))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let calendar = GoogleCalendar::new("tok", "primary").with_base_url(server.uri());
|
||||
let err = calendar.events(&window()).await.expect_err("loud");
|
||||
assert!(
|
||||
matches!(err, CalendarError::Api { status: 403, .. }),
|
||||
"got: {err:?}"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
//! Offline integration tests: the ICS backend against an in-process
|
||||
//! wiremock server. Zero network.
|
||||
|
||||
use rhema_calendar_integration::{Calendar, CalendarError, IcsCalendar, Window};
|
||||
use time::OffsetDateTime;
|
||||
use time::format_description::well_known::Rfc3339;
|
||||
use wiremock::matchers::{header, method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
const FEED_BODY: &str = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\n\
|
||||
BEGIN:VEVENT\r\nUID:meet-1@x\r\nDTSTART:20261020T140000Z\r\nDTEND:20261020T150000Z\r\n\
|
||||
SUMMARY:kickoff\\, phase one\r\nLOCATION:paris\r\n\
|
||||
ORGANIZER:mailto:boss@example.net\r\nATTENDEE:mailto:alice@example.net\r\n\
|
||||
URL:https://meet.example.net/1\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
|
||||
|
||||
fn window() -> Window {
|
||||
let from = OffsetDateTime::parse("2026-10-20T00:00:00Z", &Rfc3339).expect("parses");
|
||||
Window::new(from, from + time::Duration::hours(24))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_poll_is_one_get_and_normalizes_the_feed() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/remote.php/dav/calendars/personal.ics"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(FEED_BODY))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let feed = IcsCalendar::new(format!(
|
||||
"{}/remote.php/dav/calendars/personal.ics",
|
||||
server.uri()
|
||||
));
|
||||
let events = feed.events(&window()).await.expect("polls");
|
||||
assert_eq!(events.len(), 1);
|
||||
let event = &events[0];
|
||||
assert_eq!(event.title, "kickoff, phase one");
|
||||
assert_eq!(event.location.as_deref(), Some("paris"));
|
||||
assert_eq!(event.organizer.as_deref(), Some("boss@example.net"));
|
||||
assert_eq!(event.attendees, vec!["alice@example.net".to_owned()]);
|
||||
assert_eq!(event.url.as_deref(), Some("https://meet.example.net/1"));
|
||||
assert_eq!(
|
||||
event.start,
|
||||
OffsetDateTime::parse("2026-10-20T14:00:00Z", &Rfc3339).expect("parses")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_token_rides_the_authorization_header_only() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(header("Authorization", "Bearer sekrit"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(FEED_BODY))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let feed = IcsCalendar::new(server.uri()).with_token("sekrit");
|
||||
feed.events(&window()).await.expect("polls");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_server_error_is_named_with_a_bounded_body() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.respond_with(ResponseTemplate::new(503).set_body_string("maintenance"))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let feed = IcsCalendar::new(server.uri());
|
||||
let err = feed.events(&window()).await.expect_err("loud");
|
||||
assert!(
|
||||
matches!(err, CalendarError::Api { status: 503, .. }),
|
||||
"got: {err:?}"
|
||||
);
|
||||
assert!(err.to_string().contains("maintenance"), "got: {err}");
|
||||
}
|
||||
Reference in new issue
Block a user