commit
a47c4a0398
96 files changed
+25721
No files matched your search
@@ -0,0 +1,8 @@
|
||||
exports.openSetup = async (page, name) => {
|
||||
await page.waitForFunction(()=>window.Alpine && Alpine.$data(document.body).ready && Alpine.$data(document.body).workspace);
|
||||
const combined=['Schedules','Escalations & routing','Variables & rules'].includes(name);
|
||||
const target=page.getByRole('link',{name:combined?'Schedule setup':name,exact:true});
|
||||
if(!await target.isVisible())await page.getByRole('button',{name:'Setup',exact:true}).click();
|
||||
await target.click();
|
||||
if(combined)await page.locator(name==='Schedules'?'#schedules':name==='Escalations & routing'?'#schedules':'#generation').scrollIntoViewIfNeeded();
|
||||
};
|
||||
@@ -0,0 +1,170 @@
|
||||
//! Stateful HTTP fixture. It verifies wire methods and bodies, not live credentials.
|
||||
use serde_json::{Value, json};
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
use tokio::{
|
||||
io::{AsyncReadExt, AsyncWriteExt},
|
||||
net::TcpListener,
|
||||
};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct State {
|
||||
pub objects: BTreeMap<String, Value>,
|
||||
pub writes: usize,
|
||||
pub fail_once: Option<String>,
|
||||
pub external_override: bool,
|
||||
pub users: Vec<Value>,
|
||||
pub lookups: usize,
|
||||
}
|
||||
pub struct Server {
|
||||
pub base: String,
|
||||
pub state: Arc<Mutex<State>>,
|
||||
task: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
impl Drop for Server {
|
||||
fn drop(&mut self) {
|
||||
self.task.abort();
|
||||
}
|
||||
}
|
||||
impl Server {
|
||||
pub async fn start() -> Self {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let base = format!("http://{}", listener.local_addr().unwrap());
|
||||
let state = Arc::new(Mutex::new(State::default()));
|
||||
let copy = state.clone();
|
||||
let task = tokio::spawn(async move {
|
||||
loop {
|
||||
let (mut socket, _) = listener.accept().await.unwrap();
|
||||
let shared = copy.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut buffer = vec![];
|
||||
let (head_end, length) = loop {
|
||||
let mut chunk = [0; 4096];
|
||||
let n = socket.read(&mut chunk).await.unwrap();
|
||||
if n == 0 {
|
||||
return;
|
||||
}
|
||||
buffer.extend_from_slice(&chunk[..n]);
|
||||
if let Some(end) = buffer.windows(4).position(|w| w == b"\r\n\r\n") {
|
||||
let head = String::from_utf8_lossy(&buffer[..end]);
|
||||
let length = head
|
||||
.lines()
|
||||
.find_map(|l| {
|
||||
l.to_lowercase()
|
||||
.strip_prefix("content-length: ")
|
||||
.and_then(|v| v.parse::<usize>().ok())
|
||||
})
|
||||
.unwrap_or(0);
|
||||
break (end + 4, length);
|
||||
}
|
||||
};
|
||||
while buffer.len() < head_end + length {
|
||||
let mut chunk = [0; 4096];
|
||||
let n = socket.read(&mut chunk).await.unwrap();
|
||||
if n == 0 {
|
||||
return;
|
||||
}
|
||||
buffer.extend_from_slice(&chunk[..n]);
|
||||
}
|
||||
let head = String::from_utf8_lossy(&buffer[..head_end]);
|
||||
assert!(head.to_lowercase().contains("authorization: basic "));
|
||||
let mut first = head.lines().next().unwrap().split_whitespace();
|
||||
let method = first.next().unwrap();
|
||||
let path = first.next().unwrap().split('?').next().unwrap();
|
||||
let body = if length > 0 {
|
||||
serde_json::from_slice(&buffer[head_end..head_end + length]).unwrap()
|
||||
} else {
|
||||
Value::Null
|
||||
};
|
||||
let (code, result) = handle(&mut shared.lock().unwrap(), method, path, body);
|
||||
let body = result.to_string();
|
||||
let response = format!(
|
||||
"HTTP/1.1 {code} Result\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
||||
body.len()
|
||||
);
|
||||
socket.write_all(response.as_bytes()).await.unwrap();
|
||||
});
|
||||
}
|
||||
});
|
||||
Self { base, state, task }
|
||||
}
|
||||
}
|
||||
fn handle(s: &mut State, method: &str, path: &str, mut body: Value) -> (u16, Value) {
|
||||
if method == "POST" && s.fail_once.as_ref().is_some_and(|p| path.ends_with(p)) {
|
||||
s.fail_once = None;
|
||||
return (429, json!({"error":"Try later"}));
|
||||
}
|
||||
if method == "GET" {
|
||||
if path.ends_with("/user/search") {
|
||||
s.lookups += 1;
|
||||
return (200, json!(s.users));
|
||||
}
|
||||
if path.ends_with("/overrides") {
|
||||
return (
|
||||
200,
|
||||
json!({"values":if s.external_override{vec![json!({"alias":"external"})]}else{vec![]}}),
|
||||
);
|
||||
}
|
||||
if let Some(parent) = path.strip_suffix("/rotations") {
|
||||
return (
|
||||
200,
|
||||
json!({"values":s.objects.get(parent).unwrap()["rotations"]}),
|
||||
);
|
||||
}
|
||||
if let Some(value) = s.objects.get(path) {
|
||||
return (200, value.clone());
|
||||
}
|
||||
if path.ends_with("/schedules")
|
||||
|| path.ends_with("/escalations")
|
||||
|| path.ends_with("/routing-rules")
|
||||
{
|
||||
return (
|
||||
200,
|
||||
json!({"values":s.objects.iter().filter(|(k,_)|k.rsplit_once('/').is_some_and(|(p,_)|p==path)).map(|(_,v)|v.clone()).collect::<Vec<_>>()}),
|
||||
);
|
||||
}
|
||||
return (404, json!({}));
|
||||
}
|
||||
if method == "DELETE" {
|
||||
s.writes += 1;
|
||||
if path.ends_with("/overrides/external") {
|
||||
s.external_override = false;
|
||||
} else {
|
||||
s.objects.remove(path);
|
||||
}
|
||||
return (200, json!({"result":"deleted"}));
|
||||
}
|
||||
if path.contains("routing-rules") {
|
||||
assert!(body["order"].as_u64().unwrap() <= 100);
|
||||
assert!(body["notify"].is_object());
|
||||
}
|
||||
if path.contains("escalations") {
|
||||
assert!((1..=20).contains(&body["repeat"]["count"].as_u64().unwrap()));
|
||||
assert!(body["rules"].is_array());
|
||||
}
|
||||
if path.contains("schedules") {
|
||||
for rotation in body["rotations"].as_array().unwrap() {
|
||||
assert_eq!(rotation["participants"].as_array().unwrap().len(), 1);
|
||||
assert!(
|
||||
rotation["endDate"].as_str().unwrap() > rotation["startDate"].as_str().unwrap()
|
||||
);
|
||||
}
|
||||
}
|
||||
s.writes += 1;
|
||||
if method == "POST" {
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
body["id"] = json!(id);
|
||||
s.objects.insert(format!("{path}/{id}"), body.clone());
|
||||
return (201, body);
|
||||
}
|
||||
assert_eq!(method, "PATCH");
|
||||
if let Some(previous) = s.objects.get(path) {
|
||||
body["id"] = previous["id"].clone();
|
||||
s.objects.insert(path.into(), body.clone());
|
||||
(200, body)
|
||||
} else {
|
||||
(404, json!({}))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
use chrono::{Duration, Utc};
|
||||
use cot::db::Model;
|
||||
use meguri::{
|
||||
domain::*,
|
||||
models::UserRow,
|
||||
service,
|
||||
store::{Store, WorkspaceRecord},
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
pub struct Fixture {
|
||||
pub db: Store,
|
||||
pub people: [User; 3],
|
||||
pub team: WorkspaceRecord,
|
||||
pub schedule: Uuid,
|
||||
pub start: chrono::DateTime<Utc>,
|
||||
}
|
||||
impl Fixture {
|
||||
pub async fn new() -> Self {
|
||||
let url = std::env::var("MEGURI_TEST_DATABASE_URL")
|
||||
.expect("Set MEGURI_TEST_DATABASE_URL to a disposable PostgreSQL database");
|
||||
let db = Store::connect(&url).await.unwrap();
|
||||
let mut people = vec![];
|
||||
for name in ["Alice", "Bob", "Carol"] {
|
||||
let user = User {
|
||||
language: None,
|
||||
id: Uuid::new_v4(),
|
||||
username: format!("test-{}", Uuid::new_v4().simple()),
|
||||
display_name: name.into(),
|
||||
country: "CY".into(),
|
||||
timezone: None,
|
||||
};
|
||||
UserRow {
|
||||
language: None,
|
||||
demo_deleted_at: None,
|
||||
id: user.id.to_string(),
|
||||
username: user.username.clone(),
|
||||
display_name: user.display_name.clone(),
|
||||
country: user.country.clone(),
|
||||
timezone: None,
|
||||
password_hash: "test-only-unusable-password".into(),
|
||||
created_at: Utc::now(),
|
||||
}
|
||||
.insert(&db.db)
|
||||
.await
|
||||
.unwrap();
|
||||
people.push(user);
|
||||
}
|
||||
let people: [User; 3] = people.try_into().unwrap();
|
||||
let mut team = service::create_workspace(&db, people[0].id, "Test team".into())
|
||||
.await
|
||||
.unwrap();
|
||||
for person in &people[1..] {
|
||||
team = service::set_member(
|
||||
&db,
|
||||
people[0].id,
|
||||
team.id,
|
||||
team.revision,
|
||||
person.username.clone(),
|
||||
Permission::member(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
let schedule = Uuid::new_v4();
|
||||
let day = Utc::now().date_naive() + Duration::days(2);
|
||||
let mut doc = team.document.clone();
|
||||
doc.timezone = "UTC".into();
|
||||
doc.schedules.push(Schedule {
|
||||
handover: default_handover(),
|
||||
id: schedule,
|
||||
name: "General".into(),
|
||||
kind: ScheduleKind::Rotation {
|
||||
participants: people.iter().map(|p| p.id).collect(),
|
||||
anchor: day,
|
||||
shift_days: 7,
|
||||
},
|
||||
});
|
||||
team = service::update_document(&db, people[0].id, team.id, team.revision, doc)
|
||||
.await
|
||||
.unwrap();
|
||||
let start = meguri::engine::handover(&team.document, day).unwrap();
|
||||
Self {
|
||||
db,
|
||||
people,
|
||||
team,
|
||||
schedule,
|
||||
start,
|
||||
}
|
||||
}
|
||||
pub fn leg(&self) -> ReplacementLeg {
|
||||
ReplacementLeg {
|
||||
schedule_id: self.schedule,
|
||||
start: self.start,
|
||||
end: self.start + Duration::days(1),
|
||||
from_user: self.people[0].id,
|
||||
to_user: self.people[1].id,
|
||||
}
|
||||
}
|
||||
pub async fn propose(&mut self, legs: Vec<ReplacementLeg>, force: bool) {
|
||||
self.team = service::request_replacement(
|
||||
&self.db,
|
||||
self.people[0].id,
|
||||
self.team.id,
|
||||
service::NewRequest {
|
||||
remaining: false,
|
||||
revision: self.team.revision,
|
||||
reason: "Test replacement".into(),
|
||||
legs,
|
||||
force,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
pub fn decision(&self, action: &str) -> service::RequestDecision {
|
||||
service::RequestDecision {
|
||||
revision: self.team.revision,
|
||||
action: action.into(),
|
||||
reason: "Test decision".into(),
|
||||
}
|
||||
}
|
||||
pub async fn prioritize_publication(&self) {
|
||||
// The worker takes a bounded batch. Repeated runs leave other test tenants
|
||||
// in this disposable database, so explicitly put this test's job first.
|
||||
self.db
|
||||
.db
|
||||
.raw_with(
|
||||
"UPDATE meguri__sync_jobs SET next_attempt='1970-01-01T00:00:00Z' WHERE workspace_id=$1",
|
||||
&[&self.team.id.to_string()],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user