Something went wrong. Try again.
Something went wrong. Try again.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977297829792980298129822983298429852986298729882989299029912992299329942995299629972998299930003001300230033004300530063007300830093010301130123013301430153016301730183019302030213022302330243025302630273028302930303031303230333034303530363037303830393040304130423043304430453046304730483049305030513052305330543055305630573058305930603061306230633064306530663067306830693070307130723073307430753076307730783079308030813082308330843085308630873088308930903091309230933094309530963097309830993100310131023103310431053106310731083109311031113112311331143115311631173118311931203121312231233124312531263127312831293130313131323133313431353136313731383139314031413142314331443145314631473148314931503151315231533154315531563157315831593160316131623163316431653166316731683169317031713172317331743175317631773178317931803181318231833184318531863187318831893190319131923193319431953196319731983199320032013202320332043205320632073208320932103211321232133214321532163217321832193220322132223223322432253226322732283229323032313232323332343235323632373238323932403241324232433244324532463247324832493250325132523253325432553256325732583259326032613262326332643265326632673268326932703271327232733274327532763277327832793280328132823283328432853286328732883289329032913292329332943295329632973298329933003301330233033304330533063307330833093310331133123313331433153316331733183319332033213322332333243325332633273328332933303331333233333334333533363337333833393340334133423343334433453346334733483349335033513352335333543355335633573358335933603361336233633364336533663367336833693370337133723373337433753376337733783379338033813382338333843385338633873388338933903391339233933394339533963397339833993400340134023403340434053406340734083409341034113412341334143415341634173418341934203421342234233424342534263427342834293430343134323433343434353436343734383439344034413442344334443445344634473448344934503451345234533454345534563457345834593460346134623463346434653466346734683469347034713472347334743475347634773478347934803481348234833484348534863487348834893490349134923493349434953496349734983499350035013502350335043505350635073508350935103511351235133514351535163517351835193520352135223523352435253526352735283529353035313532353335343535353635373538353935403541354235433544354535463547354835493550355135523553355435553556355735583559356035613562356335643565356635673568356935703571357235733574357535763577357835793580358135823583358435853586358735883589359035913592359335943595359635973598359936003601360236033604360536063607360836093610361136123613361436153616361736183619362036213622362336243625362636273628362936303631363236333634363536363637363836393640364136423643364436453646364736483649365036513652365336543655365636573658365936603661366236633664366536663667366836693670367136723673367436753676367736783679368036813682368336843685368636873688368936903691369236933694369536963697369836993700370137023703370437053706370737083709371037113712371337143715371637173718371937203721372237233724372537263727372837293730373137323733373437353736373737383739374037413742374337443745374637473748374937503751375237533754375537563757375837593760376137623763376437653766376737683769377037713772377337743775377637773778377937803781378237833784378537863787378837893790379137923793379437953796379737983799380038013802380338043805380638073808380938103811381238133814381538163817381838193820382138223823382438253826382738283829383038313832383338343835383638373838383938403841384238433844384538463847384838493850385138523853385438553856385738583859386038613862386338643865386638673868386938703871387238733874387538763877387838793880388138823883use std::collections::{HashMap, HashSet};
use chrono::{DateTime, Utc};
use crate::{ clients::{self, esi, esi::models::market::RegionOrder, http}, config::FeatureFlags, features::market::{outbid, watch_eval}, services::location_search::LocationTier, store::{ Database, model::{ MarketAlertKind, MarketOrder, MarketWatch, NewNotification, Notification, NotificationDestination, NotificationKind, NotificationOwner, NotificationTarget, WatchDirection, }, repo::{ calendar, character, customs_office, finance, industry, mail, market_alert_state, market_watchlist, notifications, org, sde, skill_completion, structure_state, }, }, sync::JobKind,};
const FUEL_EXPIRING_DAYS: f64 = 5.0;
const FUEL_LOW_DAYS: f64 = 2.0;
const LIST_LIMIT: i64 = 200;
#[derive(Clone, Debug, Default)]pub struct Snapshot { pub list: Vec<Notification>, pub outbid: i64, pub surfaced: Vec<Notification>, pub unread: i64, pub who: HashMap<NotificationOwner, String>,}
pub async fn refresh( db: &Database, now: DateTime<Utc>, characters: &[i64], corporations: &[i64], features: &FeatureFlags, run_detectors: bool,) -> Snapshot { let surfaced = if run_detectors { detect(db, now, characters, corporations, features).await } else { Vec::new() };
let list = notifications::list(db, LIST_LIMIT).await.unwrap_or_default(); let unread = notifications::unread_count(db).await.unwrap_or(0); let outbid = market_alert_state::count_alerted_outbid_open(db).await.unwrap_or(0); let who = resolve_owner_names(db, &list, &surfaced).await;
Snapshot { list, outbid, surfaced, unread, who, }}
pub async fn detect( db: &Database, now: DateTime<Utc>, characters: &[i64], corporations: &[i64], features: &FeatureFlags,) -> Vec<Notification> { let mut surfaced = Vec::new(); for &character_id in characters { run(mail_detector(db, character_id, features), "mail", &mut surfaced).await; run(calendar_detector(db, character_id, features), "calendar", &mut surfaced).await; run( killmail_detector(db, NotificationOwner::Character(character_id), features), "killmail", &mut surfaced, ) .await; run(skill_detector(db, character_id, now, features), "skill", &mut surfaced).await; run( industry_detector(db, NotificationOwner::Character(character_id), features), "industry", &mut surfaced, ) .await; run(outbid_detector(db, character_id, features), "outbid", &mut surfaced).await; run( structure_notification_detector(db, character_id, features), "structure notifications", &mut surfaced, ) .await; } // Watchlist is app-global (not per-character); run its detector once, anchored to the first // owned character since notification/alert-state rows require a character FK. if let Some(&anchor_character_id) = characters.first() { run( watchlist_target_detector(db, anchor_character_id, features), "watchlist target", &mut surfaced, ) .await; } for &corporation_id in corporations { run( killmail_detector(db, NotificationOwner::Corporation(corporation_id), features), "corp killmail", &mut surfaced, ) .await; run( industry_detector(db, NotificationOwner::Corporation(corporation_id), features), "corp industry", &mut surfaced, ) .await; run( extraction_scheduled_detector(db, corporation_id, features), "extraction scheduled", &mut surfaced, ) .await; run( extraction_cracked_detector(db, corporation_id, now, features), "extraction cracked", &mut surfaced, ) .await; run( structure_state_detector(db, corporation_id, now, features), "structure state", &mut surfaced, ) .await; run( customs_office_detector(db, corporation_id, features), "customs office", &mut surfaced, ) .await; } surfaced}
async fn run( detector: impl std::future::Future<Output = Result<Vec<Notification>, crate::store::Error>>, label: &str, surfaced: &mut Vec<Notification>,) { match detector.await { Ok(mut emitted) => surfaced.append(&mut emitted), Err(error) => { tracing::warn!(target: "pod::notifications", %error, detector = label, "notification detector failed"); } }}
async fn resolve_owner_names( db: &Database, list: &[Notification], surfaced: &[Notification],) -> HashMap<NotificationOwner, String> { let mut names = HashMap::new(); resolve_into(db, list, &mut names).await; resolve_into(db, surfaced, &mut names).await; names}
pub async fn resolve_names(db: &Database, notifications: &[Notification]) -> HashMap<NotificationOwner, String> { let mut names = HashMap::new(); resolve_into(db, notifications, &mut names).await; names}
async fn resolve_into(db: &Database, notifications: &[Notification], names: &mut HashMap<NotificationOwner, String>) { for owner in notifications.iter().map(Notification::owner) { if names.contains_key(&owner) { continue; } let name = match owner { NotificationOwner::Character(id) => character_name(db, id).await, NotificationOwner::Corporation(id) => corporation_name(db, id).await, }; names.insert(owner, name); }}
async fn character_name(db: &Database, character_id: i64) -> String { character::get(db, character_id) .await .ok() .flatten() .map(|character| character.name().clone()) .unwrap_or_default()}
/// Whether this is the first scan of `(owner, kind)`: the notifications table holds no row — surfaced or/// suppressed watermark — for it yet. Deliberately independent of the sync ledger: the sync engine writes/// the ledger's `last_success_at` BEFORE the detector pulse, so a ledger-based check reads false on the/// very first sync and would flood the whole pre-existing history. On a true first scan the caller/// watermarks the current items (and a sentinel) so later passes only surface genuinely new items.async fn is_first_scan( db: &Database, owner: NotificationOwner, kind: NotificationKind,) -> Result<bool, crate::store::Error> { Ok(!notifications::has_any(db, &owner, kind).await?)}
async fn corporation_name(db: &Database, corporation_id: i64) -> String { org::get_corporation(db, corporation_id) .await .ok() .flatten() .map(|corporation| corporation.name().clone()) .unwrap_or_default()}
async fn type_name(db: &Database, type_id: i64) -> Option<String> { sde::get_item_type(db, type_id) .await .ok() .flatten() .map(|item| item.name().clone())}
async fn mail_detector( db: &Database, character_id: i64, features: &FeatureFlags,) -> Result<Vec<Notification>, crate::store::Error> { if !JobKind::CharacterMail.is_feature_enabled(features) { return Ok(Vec::new()); }
let owner = NotificationOwner::Character(character_id); let first = is_first_scan(db, owner, NotificationKind::Mail).await?; let headers = mail::headers(db, character_id).await?; // Default: received mail not authored by the owner (drop self-sent copies in the Sent box). let received: Vec<_> = headers.into_iter().filter(|m| m.from_id() != character_id).collect(); let watermarks: Vec<String> = received.iter().map(|m| mail_key(character_id, m.mail_id())).collect();
if first { notifications::watermark(db, &owner, NotificationKind::Mail, &watermarks).await?; return Ok(Vec::new()); }
let mut surfaced = Vec::new(); for header in received { let title = header .subject() .clone() .filter(|s| !s.is_empty()) .unwrap_or_else(|| t!("shell.notification.mail_title_fallback").into_owned()); let emitted = notifications::emit( db, &NewNotification { body: t!("shell.notification.mail_body", name => header.from_name()).into_owned(), dedup_key: mail_key(character_id, header.mail_id()), kind: NotificationKind::Mail, owner: NotificationOwner::Character(character_id), target: NotificationTarget { character: Some(character_id), destination: NotificationDestination::Mail, sub: None, }, title, }, ) .await?; surfaced.extend(emitted); } Ok(surfaced)}
async fn calendar_detector( db: &Database, character_id: i64, features: &FeatureFlags,) -> Result<Vec<Notification>, crate::store::Error> { if !JobKind::CharacterCalendar.is_feature_enabled(features) { return Ok(Vec::new()); }
let owner = NotificationOwner::Character(character_id); let first = is_first_scan(db, owner, NotificationKind::Calendar).await?; // The table holds only real ESI events; Pod-derived overlays live in memory, so reading the table // already excludes synthetic ones. let events = calendar::events(db, character_id).await?; let watermarks: Vec<String> = events .iter() .map(|e| calendar_key(character_id, e.event_id())) .collect();
if first { notifications::watermark(db, &owner, NotificationKind::Calendar, &watermarks).await?; return Ok(Vec::new()); }
let mut surfaced = Vec::new(); for event in events { let emitted = notifications::emit( db, &NewNotification { body: event.owner_name().clone(), dedup_key: calendar_key(character_id, event.event_id()), kind: NotificationKind::Calendar, owner: NotificationOwner::Character(character_id), target: NotificationTarget { character: Some(character_id), destination: NotificationDestination::Calendar, sub: None, }, title: event.title().clone(), }, ) .await?; surfaced.extend(emitted); } Ok(surfaced)}
async fn killmail_detector( db: &Database, owner: NotificationOwner, features: &FeatureFlags,) -> Result<Vec<Notification>, crate::store::Error> { let job = match owner { NotificationOwner::Character(_) => JobKind::CharacterKillmails, NotificationOwner::Corporation(_) => JobKind::CorporationKillmails, }; if !job.is_feature_enabled(features) { return Ok(Vec::new()); }
let first = is_first_scan(db, owner, NotificationKind::Killmail).await?; let rows: Vec<(i64, i64)> = match owner { NotificationOwner::Character(id) => character::killmails(db, id) .await? .into_iter() .map(|k| (k.killmail_id(), k.ship_type_id())) .collect(), NotificationOwner::Corporation(id) => org::corporation_killmails(db, id) .await? .into_iter() .map(|k| (k.killmail_id(), k.ship_type_id())) .collect(), }; let watermarks: Vec<String> = rows.iter().map(|(killmail_id, _)| killmail_key(*killmail_id)).collect();
if first { notifications::watermark(db, &owner, NotificationKind::Killmail, &watermarks).await?; return Ok(Vec::new()); }
let mut surfaced = Vec::new(); for (killmail_id, ship_type_id) in rows { let ship = type_name(db, ship_type_id) .await .unwrap_or_else(|| t!("shell.notification.killmail_ship_fallback").into_owned()); let emitted = notifications::emit( db, &NewNotification { body: t!("shell.notification.killmail_destroyed", ship => ship).into_owned(), dedup_key: killmail_key(killmail_id), kind: NotificationKind::Killmail, owner, target: NotificationTarget::killmail(owner, killmail_id), title: t!("shell.notification.killmail_title").into_owned(), }, ) .await?; surfaced.extend(emitted); } Ok(surfaced)}
async fn skill_detector( db: &Database, character_id: i64, now: DateTime<Utc>, features: &FeatureFlags,) -> Result<Vec<Notification>, crate::store::Error> { if !JobKind::CharacterSkills.is_feature_enabled(features) { return Ok(Vec::new()); }
let owner = NotificationOwner::Character(character_id); let first = is_first_scan(db, owner, NotificationKind::Skill).await?; // A finished skill leaves no completion row; "new" is a finish_date crossing wall-clock. Scan the // whole queue so a multi-skill burst that matured while idle each fires once. let now_rfc = now.to_rfc3339(); let matured: Vec<(i64, i64, String)> = character::skillqueue(db, character_id) .await? .into_iter() .filter_map(|entry| { entry .finish_date() .clone() .map(|finish| (entry.skill_id(), entry.finished_level(), finish)) }) .filter(|(_, _, finish)| crossed(finish, &now_rfc)) .collect(); let watermarks: Vec<String> = matured .iter() .map(|(skill_id, _, finish)| skill_key(character_id, *skill_id, finish)) .collect();
if first { notifications::watermark(db, &owner, NotificationKind::Skill, &watermarks).await?; return Ok(Vec::new()); }
let mut surfaced = Vec::new(); for (skill_id, level, finish) in matured { capture_completion(db, character_id, skill_id, level, &finish).await?; let skill = type_name(db, skill_id) .await .unwrap_or_else(|| t!("shell.notification.skill_fallback").into_owned()); let emitted = notifications::emit( db, &NewNotification { body: t!("shell.notification.skill_body").into_owned(), dedup_key: skill_key(character_id, skill_id, &finish), kind: NotificationKind::Skill, owner: NotificationOwner::Character(character_id), target: NotificationTarget { character: Some(character_id), destination: NotificationDestination::Skills, sub: None, }, title: t!("shell.notification.skill_title", skill => skill).into_owned(), }, ) .await?; surfaced.extend(emitted); } Ok(surfaced)}
/// Called once per matured entry, independent of whether the paired notification is deduped.////// `insert_if_absent` keeps repeated calls idempotent across reruns; entries watermarked on the/// first scan never reach this function, so pre-existing completion history is deliberately not/// backfilled.async fn capture_completion( db: &Database, character_id: i64, skill_id: i64, level: i64, completed_at: &str,) -> Result<(), crate::store::Error> { skill_completion::insert_if_absent(db, character_id, skill_id, level, completed_at).await?; Ok(())}
async fn industry_detector( db: &Database, owner: NotificationOwner, features: &FeatureFlags,) -> Result<Vec<Notification>, crate::store::Error> { let (job, subject_id) = match owner { NotificationOwner::Character(id) => (JobKind::CharacterIndustryJobs, id), NotificationOwner::Corporation(id) => (JobKind::CorporationIndustryJobs, id), }; if !job.is_feature_enabled(features) { return Ok(Vec::new()); }
let first = is_first_scan(db, owner, NotificationKind::Industry).await?; let jobs: Vec<(i64, Option<i64>)> = match owner { NotificationOwner::Character(id) => industry::list_for_character(db, id) .await? .into_iter() .filter(|j| is_industry_done(j.status())) .map(|j| (j.job_id(), j.product_type_id())) .collect(), NotificationOwner::Corporation(id) => industry::list_for_corporation(db, id) .await? .into_iter() .filter(|j| is_industry_done(j.status())) .map(|j| (j.job_id(), j.product_type_id())) .collect(), }; let watermarks: Vec<String> = jobs.iter().map(|(job_id, _)| industry_key(*job_id)).collect();
if first { notifications::watermark(db, &owner, NotificationKind::Industry, &watermarks).await?; return Ok(Vec::new()); }
let mut surfaced = Vec::new(); for (job_id, product_type_id) in jobs { let product = match product_type_id { Some(id) => type_name(db, id) .await .unwrap_or_else(|| t!("shell.notification.industry_fallback").into_owned()), None => t!("shell.notification.industry_fallback").into_owned(), }; let emitted = notifications::emit( db, &NewNotification { body: t!("shell.notification.industry_ready", product => product).into_owned(), dedup_key: industry_key(job_id), kind: NotificationKind::Industry, owner, target: NotificationTarget { character: matches!(owner, NotificationOwner::Character(_)).then_some(subject_id), destination: NotificationDestination::Industry, sub: None, }, title: t!("shell.notification.industry_title").into_owned(), }, ) .await?; surfaced.extend(emitted); } Ok(surfaced)}
async fn extraction_scheduled_detector( db: &Database, corporation_id: i64, features: &FeatureFlags,) -> Result<Vec<Notification>, crate::store::Error> { if !JobKind::CorporationMiningExtractions.is_feature_enabled(features) { return Ok(Vec::new()); }
let owner = NotificationOwner::Corporation(corporation_id); let first = is_first_scan(db, owner, NotificationKind::ExtractionScheduled).await?; let scheduled: Vec<_> = org::corporation_mining_extractions(db, corporation_id) .await? .into_iter() .filter_map(|e| { e.chunk_arrival_time() .clone() .map(|arrival| (e.structure_id(), e.moon_id(), arrival, e.moon_name().clone())) }) .collect(); let watermarks: Vec<String> = scheduled .iter() .map(|(structure_id, moon_id, arrival, _)| { extraction_scheduled_key(corporation_id, *structure_id, *moon_id, arrival) }) .collect();
if first { notifications::watermark(db, &owner, NotificationKind::ExtractionScheduled, &watermarks).await?; return Ok(Vec::new()); }
let who = corporation_name(db, corporation_id).await; let mut surfaced = Vec::new(); for (structure_id, moon_id, arrival, moon_name) in scheduled { let emitted = notifications::emit( db, &NewNotification { body: moon_name.unwrap_or_else(|| who.clone()), dedup_key: extraction_scheduled_key(corporation_id, structure_id, moon_id, &arrival), kind: NotificationKind::ExtractionScheduled, owner: NotificationOwner::Corporation(corporation_id), target: NotificationTarget { character: None, destination: NotificationDestination::Industry, sub: None, }, title: t!("shell.notification.extraction_scheduled_title").into_owned(), }, ) .await?; surfaced.extend(emitted); } Ok(surfaced)}
async fn extraction_cracked_detector( db: &Database, corporation_id: i64, now: DateTime<Utc>, features: &FeatureFlags,) -> Result<Vec<Notification>, crate::store::Error> { if !JobKind::CorporationMiningExtractions.is_feature_enabled(features) { return Ok(Vec::new()); }
let owner = NotificationOwner::Corporation(corporation_id); let first = is_first_scan(db, owner, NotificationKind::ExtractionCracked).await?; let now_rfc = now.to_rfc3339(); let cracked: Vec<_> = org::corporation_mining_extractions(db, corporation_id) .await? .into_iter() .filter_map(|e| { e.chunk_arrival_time() .clone() .map(|arrival| (e.structure_id(), e.moon_id(), arrival, e.moon_name().clone())) }) .filter(|(_, _, arrival, _)| crossed(arrival, &now_rfc)) .collect(); let watermarks: Vec<String> = cracked .iter() .map(|(structure_id, moon_id, arrival, _)| extraction_cracked_key(corporation_id, *structure_id, *moon_id, arrival)) .collect();
if first { notifications::watermark(db, &owner, NotificationKind::ExtractionCracked, &watermarks).await?; return Ok(Vec::new()); }
let mut surfaced = Vec::new(); for (structure_id, moon_id, arrival, moon_name) in cracked { let emitted = notifications::emit( db, &NewNotification { body: moon_name.unwrap_or_else(|| t!("shell.notification.extraction_cracked_fallback").into_owned()), dedup_key: extraction_cracked_key(corporation_id, structure_id, moon_id, &arrival), kind: NotificationKind::ExtractionCracked, owner: NotificationOwner::Corporation(corporation_id), target: NotificationTarget { character: None, destination: NotificationDestination::Industry, sub: None, }, title: t!("shell.notification.extraction_cracked_title").into_owned(), }, ) .await?; surfaced.extend(emitted); } Ok(surfaced)}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]enum AlertSeverity { Critical, Info, Warning,}
impl AlertSeverity { fn toasts(self) -> bool { matches!(self, AlertSeverity::Critical | AlertSeverity::Warning) }}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]enum FeedAlert { Anchoring, Destroyed, FuelLow, Reinforced, ServiceOffline, Unanchoring, UnderAttack,}
struct StructureAlert { body: String, dedup_key: String, severity: AlertSeverity, structure_id: i64, title: String,}
async fn structure_state_detector( db: &Database, corporation_id: i64, now: DateTime<Utc>, features: &FeatureFlags,) -> Result<Vec<Notification>, crate::store::Error> { if !features.is_sub_enabled(crate::config::SubFeature::StructureAlerts) { return Ok(Vec::new()); }
let owner = NotificationOwner::Corporation(corporation_id); let first = is_first_scan(db, owner, NotificationKind::StructureAlert).await?; let structures: Vec<(i64, String)> = industry::accessible_facilities(db) .await? .into_iter() .filter(|facility| facility.owner_id() == Some(corporation_id)) .map(|facility| (facility.id(), facility.name().clone())) .collect();
let mut alerts: Vec<StructureAlert> = Vec::new(); for (structure_id, name) in structures { if let Some(state) = structure_state::read(db, structure_id).await? { collect_structure_alerts(corporation_id, structure_id, &name, &state, now, &mut alerts); } }
if first { let keys: Vec<String> = alerts.iter().map(|alert| alert.dedup_key.clone()).collect(); notifications::watermark(db, &owner, NotificationKind::StructureAlert, &keys).await?; return Ok(Vec::new()); }
let mut surfaced = Vec::new(); for alert in alerts { emit_structure_alert( db, owner, NotificationKind::StructureAlert, corporation_id, &alert, &mut surfaced, ) .await?; } Ok(surfaced)}
async fn customs_office_detector( db: &Database, corporation_id: i64, features: &FeatureFlags,) -> Result<Vec<Notification>, crate::store::Error> { if !features.is_sub_enabled(crate::config::SubFeature::StructureAlerts) { return Ok(Vec::new()); }
let owner = NotificationOwner::Corporation(corporation_id); let first = is_first_scan(db, owner, NotificationKind::CustomsOfficeVulnerable).await?; let offices = customs_office::list_for_corporation(db, corporation_id).await?; let keys: Vec<String> = offices .iter() .map(|office| customs_office_key(corporation_id, office.office_id, office.reinforce_exit_start)) .collect();
if first { notifications::watermark(db, &owner, NotificationKind::CustomsOfficeVulnerable, &keys).await?; return Ok(Vec::new()); }
let mut surfaced = Vec::new(); for office in offices { let alert = StructureAlert { body: t!( "shell.notification.customs_office_vulnerable_body", hour => format!("{:02}:00", office.reinforce_exit_start) ) .into_owned(), dedup_key: customs_office_key(corporation_id, office.office_id, office.reinforce_exit_start), severity: AlertSeverity::Info, structure_id: office.office_id, title: t!("shell.notification.customs_office_vulnerable_title").into_owned(), }; emit_structure_alert( db, owner, NotificationKind::CustomsOfficeVulnerable, corporation_id, &alert, &mut surfaced, ) .await?; } Ok(surfaced)}
async fn structure_notification_detector( db: &Database, character_id: i64, features: &FeatureFlags,) -> Result<Vec<Notification>, crate::store::Error> { if !features.is_sub_enabled(crate::config::SubFeature::StructureAlerts) { return Ok(Vec::new()); }
let owner = NotificationOwner::Character(character_id); let first = is_first_scan(db, owner, NotificationKind::StructureAttack).await?; let events: Vec<(i64, FeedAlert, Option<i64>)> = character::notifications(db, character_id) .await? .into_iter() .filter_map(|notification| { feed_alert(notification.notif_type()).map(|alert| { ( notification.notification_id(), alert, parse_structure_id(notification.text().as_deref()), ) }) }) .collect(); let keys: Vec<String> = events .iter() .map(|(notification_id, _, _)| structure_notif_key(*notification_id)) .collect();
if first { notifications::watermark(db, &owner, NotificationKind::StructureAttack, &keys).await?; return Ok(Vec::new()); }
let facilities: HashMap<i64, (i64, String)> = industry::accessible_facilities(db) .await? .into_iter() .filter_map(|facility| { facility .owner_id() .map(|corp| (facility.id(), (corp, facility.name().clone()))) }) .collect();
let mut surfaced = Vec::new(); for (notification_id, alert, structure_id) in events { let known = structure_id.and_then(|id| facilities.get(&id).map(|(corp, name)| (id, *corp, name.clone()))); let (display, target) = match known { Some((id, corp, name)) => (name, NotificationTarget::structure(corp, id)), None => ( t!("shell.notification.structure_fallback").into_owned(), NotificationTarget { character: Some(character_id), destination: NotificationDestination::Structures, sub: None, }, ), }; let severity = feed_severity(alert); let emitted = notifications::emit( db, &NewNotification { body: display, dedup_key: structure_notif_key(notification_id), kind: NotificationKind::StructureAttack, owner, target, title: feed_title(alert), }, ) .await?; if let Some(notification) = emitted && severity.toasts() { surfaced.push(notification); } } Ok(surfaced)}
fn collect_structure_alerts( corporation_id: i64, structure_id: i64, name: &str, state: &crate::store::model::StructureState, now: DateTime<Utc>, out: &mut Vec<StructureAlert>,) { let display = if name.is_empty() { t!("shell.notification.structure_fallback").into_owned() } else { name.to_owned() };
if state.state.as_deref().is_some_and(is_reinforced_state) { let marker = state.state_timer_end.clone().unwrap_or_default(); out.push(StructureAlert { body: display.clone(), dedup_key: structure_reinforced_key(corporation_id, structure_id, &marker), severity: AlertSeverity::Critical, structure_id, title: t!("shell.notification.structure_reinforced_title").into_owned(), }); }
if let Some(days) = fuel_days_remaining(state.fuel_expires.as_deref(), now) { let expires = state.fuel_expires.clone().unwrap_or_default(); if days <= 0.0 { out.push(StructureAlert { body: display.clone(), dedup_key: structure_fuel_key(corporation_id, structure_id, "empty", &expires), severity: AlertSeverity::Critical, structure_id, title: t!("shell.notification.structure_fuel_empty_title").into_owned(), }); } else if days < FUEL_LOW_DAYS { out.push(StructureAlert { body: display.clone(), dedup_key: structure_fuel_key(corporation_id, structure_id, "low", &expires), severity: AlertSeverity::Warning, structure_id, title: t!("shell.notification.structure_fuel_low_title").into_owned(), }); } else if days < FUEL_EXPIRING_DAYS { out.push(StructureAlert { body: display.clone(), dedup_key: structure_fuel_key(corporation_id, structure_id, "expiring", &expires), severity: AlertSeverity::Info, structure_id, title: t!("shell.notification.structure_fuel_expiring_title").into_owned(), }); } }
for service in state.service_list() { if !service.state.eq_ignore_ascii_case("online") { out.push(StructureAlert { body: display.clone(), dedup_key: structure_service_key(corporation_id, structure_id, &service.name), severity: AlertSeverity::Warning, structure_id, title: t!("shell.notification.structure_service_title").into_owned(), }); } }
if let Some(raw) = state.state.as_deref() { let lower = raw.to_lowercase(); if lower.contains("anchoring") { out.push(StructureAlert { body: display.clone(), dedup_key: structure_anchor_key(corporation_id, structure_id, "anchoring", &lower), severity: AlertSeverity::Info, structure_id, title: t!("shell.notification.structure_anchoring_title").into_owned(), }); } }
if let Some(unanchors_at) = state.unanchors_at.as_deref() { out.push(StructureAlert { body: display, dedup_key: structure_anchor_key(corporation_id, structure_id, "unanchoring", unanchors_at), severity: AlertSeverity::Info, structure_id, title: t!("shell.notification.structure_unanchoring_title").into_owned(), }); }}
async fn emit_structure_alert( db: &Database, owner: NotificationOwner, kind: NotificationKind, corporation_id: i64, alert: &StructureAlert, surfaced: &mut Vec<Notification>,) -> Result<(), crate::store::Error> { let emitted = notifications::emit( db, &NewNotification { body: alert.body.clone(), dedup_key: alert.dedup_key.clone(), kind, owner, target: NotificationTarget::structure(corporation_id, alert.structure_id), title: alert.title.clone(), }, ) .await?; if let Some(notification) = emitted && alert.severity.toasts() { surfaced.push(notification); } Ok(())}
fn feed_alert(notif_type: &str) -> Option<FeedAlert> { match notif_type { "CustomsOfficeAttacked" | "OrbitalAttacked" | "StructureUnderAttack" | "TowerAlertMsg" => { Some(FeedAlert::UnderAttack) } "StructureLostArmor" | "StructureLostShields" | "TowerResourceAlertMsg" => Some(FeedAlert::Reinforced), "StructureDestroyed" => Some(FeedAlert::Destroyed), "StructureAnchoring" => Some(FeedAlert::Anchoring), "StructureUnanchoring" => Some(FeedAlert::Unanchoring), "StructureServicesOffline" => Some(FeedAlert::ServiceOffline), "StructureFuelAlert" | "StructureLowReagentsAlert" => Some(FeedAlert::FuelLow), _ => None, }}
fn feed_severity(alert: FeedAlert) -> AlertSeverity { match alert { FeedAlert::Destroyed | FeedAlert::Reinforced => AlertSeverity::Critical, FeedAlert::FuelLow | FeedAlert::ServiceOffline | FeedAlert::UnderAttack => AlertSeverity::Warning, FeedAlert::Anchoring | FeedAlert::Unanchoring => AlertSeverity::Info, }}
fn feed_title(alert: FeedAlert) -> String { match alert { FeedAlert::Anchoring => t!("shell.notification.structure_anchoring_title").into_owned(), FeedAlert::Destroyed => t!("shell.notification.structure_destroyed_title").into_owned(), FeedAlert::FuelLow => t!("shell.notification.structure_fuel_low_title").into_owned(), FeedAlert::Reinforced => t!("shell.notification.structure_reinforced_title").into_owned(), FeedAlert::ServiceOffline => t!("shell.notification.structure_service_title").into_owned(), FeedAlert::Unanchoring => t!("shell.notification.structure_unanchoring_title").into_owned(), FeedAlert::UnderAttack => t!("shell.notification.structure_under_attack_title").into_owned(), }}
fn parse_structure_id(text: Option<&str>) -> Option<i64> { let map: HashMap<String, serde_yaml::Value> = serde_yaml::from_str(text?).ok()?; ["structureID", "planetID"] .iter() .find_map(|key| map.get(*key).and_then(yaml_i64))}
fn yaml_i64(value: &serde_yaml::Value) -> Option<i64> { match value { serde_yaml::Value::Number(number) => number.as_i64(), serde_yaml::Value::String(text) => text.parse().ok(), _ => None, }}
fn fuel_days_remaining(fuel_expires: Option<&str>, now: DateTime<Utc>) -> Option<f64> { let expires = DateTime::parse_from_rfc3339(fuel_expires?).ok()?.with_timezone(&Utc); Some((expires - now).num_seconds() as f64 / 86_400.0)}
fn is_reinforced_state(state: &str) -> bool { state.to_lowercase().contains("reinforce")}
// Edge-triggered outbid alerts. Freshly-synced open orders are annotated against the live public// region book (S3-T4); the transition not-outbid -> outbid fires exactly once. The per-order// alert-state (S6-T1) freezes a price-derived marker while the order stays outbid, so its dedup_key is// stable and the emit dedup swallows every rerun; regaining best price clears the state so the next// undercut mints a fresh marker and a fresh notification. Wired into `detect()` separately (S6-T5).async fn outbid_detector( db: &Database, character_id: i64, features: &FeatureFlags,) -> Result<Vec<Notification>, crate::store::Error> { if !JobKind::CharacterMarketOrders.is_feature_enabled(features) { return Ok(Vec::new()); }
let orders = finance::open_for_character(db, character_id).await?; let book = outbid_book(db, &orders).await; reconcile_outbid(db, character_id, &orders, &book).await}
async fn reconcile_outbid<Q: outbid::BookQuote>( db: &Database, character_id: i64, orders: &[MarketOrder], book: &[Q],) -> Result<Vec<Notification>, crate::store::Error> { let owner = NotificationOwner::Character(character_id); let first = is_first_scan(db, owner, NotificationKind::Outbid).await?; let annotations = outbid::annotate_all(orders, book);
if first { return watermark_outbid(db, character_id, orders, &annotations).await; }
let mut surfaced = Vec::new(); for (order, annotation) in orders.iter().zip(&annotations) { let emitted = step_outbid(db, character_id, order, annotation).await?; surfaced.extend(emitted); } Ok(surfaced)}
// First scan: watermark every already-outbid order (mark its alert-state and suppress the matching// dedup_key) so a character deep in an undercut war at first sync is not flooded.async fn watermark_outbid( db: &Database, character_id: i64, orders: &[MarketOrder], annotations: &[outbid::Annotation],) -> Result<Vec<Notification>, crate::store::Error> { let owner = NotificationOwner::Character(character_id); let mut keys = Vec::new(); for (order, annotation) in orders.iter().zip(annotations) { if let Some(best) = outbid_best(annotation) { let state = market_alert_state::mark( db, MarketAlertKind::Outbid, character_id, order.order_id(), &outbid_marker(best), ) .await?; keys.push(state.dedup_key()); } } notifications::watermark(db, &owner, NotificationKind::Outbid, &keys).await?; Ok(Vec::new())}
async fn step_outbid( db: &Database, character_id: i64, order: &MarketOrder, annotation: &outbid::Annotation,) -> Result<Vec<Notification>, crate::store::Error> { let Some(best) = outbid_best(annotation) else { market_alert_state::clear(db, MarketAlertKind::Outbid, character_id, order.order_id()).await?; return Ok(Vec::new()); }; let state = market_alert_state::mark( db, MarketAlertKind::Outbid, character_id, order.order_id(), &outbid_marker(best), ) .await?; emit_outbid(db, character_id, order, &state.dedup_key()).await}
async fn emit_outbid( db: &Database, character_id: i64, order: &MarketOrder, dedup_key: &str,) -> Result<Vec<Notification>, crate::store::Error> { let item = type_name(db, order.type_id()) .await .unwrap_or_else(|| t!("shell.notification.outbid_fallback").into_owned()); let emitted = notifications::emit( db, &NewNotification { body: t!("shell.notification.outbid_body", item => item).into_owned(), dedup_key: dedup_key.to_owned(), kind: NotificationKind::Outbid, owner: NotificationOwner::Character(character_id), target: NotificationTarget::market_outbid(Some(character_id), order.order_id()), title: t!("shell.notification.outbid_title").into_owned(), }, ) .await?; Ok(emitted.into_iter().collect())}
async fn outbid_book(db: &Database, orders: &[MarketOrder]) -> Vec<outbid::Quote> { let Ok(esi) = public_market_client(db) else { return Vec::new(); }; let mut seen: HashSet<(i64, i64)> = HashSet::new(); let mut book = Vec::new(); for order in orders { let key = (order.region_id(), order.type_id()); if !seen.insert(key) { continue; } let sells = esi.market().sell_orders(key.0, key.1).await.unwrap_or_default(); let buys = esi.market().buy_orders(key.0, key.1).await.unwrap_or_default(); push_outbid_quotes(&mut book, key.1, false, sells); push_outbid_quotes(&mut book, key.1, true, buys); } book}
fn push_outbid_quotes(book: &mut Vec<outbid::Quote>, type_id: i64, is_buy_order: bool, orders: Vec<RegionOrder>) { for order in orders { book.push(outbid::Quote { is_buy_order, location_id: order.location_id, price: order.price, type_id, }); }}
fn public_market_client(db: &Database) -> Result<esi::Client, clients::Error> { let http = http::Client::builder(http::Cache::new(db.clone())).build(); esi::Client::builder(http).user_agent(clients::user_agent()).build()}
fn outbid_best(annotation: &outbid::Annotation) -> Option<f64> { annotation.outbid.then_some(annotation.best).flatten()}
fn outbid_marker(best: f64) -> String { format!("{best:.2}")}
// Edge-triggered watchlist target alerts. The app-global watchlist (S4-T6) is evaluated over the// live public region book, anchored to one owned character for alert-state and notification// ownership; the transition not-met -> met fires exactly once. The per-watch alert-state// (S6-T1) freezes a crossing-derived marker while the target stays met, so its dedup_key is stable and// the emit dedup swallows every rerun; a retreat back across the target clears the state so the next// crossing mints a fresh marker and a fresh notification. Wired into `detect()` separately (S6-T5).async fn watchlist_target_detector( db: &Database, character_id: i64, features: &FeatureFlags,) -> Result<Vec<Notification>, crate::store::Error> { if !JobKind::CharacterMarketOrders.is_feature_enabled(features) { return Ok(Vec::new()); }
let watches = market_watchlist::list(db).await?; let prices = target_book(db, &watches).await; reconcile_target(db, character_id, &watches, &prices).await}
async fn reconcile_target( db: &Database, character_id: i64, watches: &[MarketWatch], prices: &watch_eval::PriceMap,) -> Result<Vec<Notification>, crate::store::Error> { let owner = NotificationOwner::Character(character_id); let first = is_first_scan(db, owner, NotificationKind::WatchlistTarget).await?;
if first { return watermark_target(db, character_id, watches, prices).await; }
let mut surfaced = Vec::new(); for watch in watches { let emitted = step_target(db, character_id, watch, prices).await?; surfaced.extend(emitted); } Ok(surfaced)}
// First scan: mark every already-met watch (freeze its alert-state and suppress the matching dedup_key)// so a character whose targets are already met at first sync is not flooded.async fn watermark_target( db: &Database, character_id: i64, watches: &[MarketWatch], prices: &watch_eval::PriceMap,) -> Result<Vec<Notification>, crate::store::Error> { let owner = NotificationOwner::Character(character_id); let mut keys = Vec::new(); for watch in watches { if let Some(marker) = target_met_marker(watch, prices) { let state = market_alert_state::mark(db, MarketAlertKind::Target, character_id, watch.id, &marker).await?; keys.push(state.dedup_key()); } } notifications::watermark(db, &owner, NotificationKind::WatchlistTarget, &keys).await?; Ok(Vec::new())}
async fn step_target( db: &Database, character_id: i64, watch: &MarketWatch, prices: &watch_eval::PriceMap,) -> Result<Vec<Notification>, crate::store::Error> { let Some(marker) = target_met_marker(watch, prices) else { market_alert_state::clear(db, MarketAlertKind::Target, character_id, watch.id).await?; return Ok(Vec::new()); }; let state = market_alert_state::mark(db, MarketAlertKind::Target, character_id, watch.id, &marker).await?; emit_target(db, character_id, watch, &state.dedup_key()).await}
async fn emit_target( db: &Database, character_id: i64, watch: &MarketWatch, dedup_key: &str,) -> Result<Vec<Notification>, crate::store::Error> { let item = type_name(db, watch.type_id) .await .unwrap_or_else(|| t!("shell.notification.watchlist_target_fallback").into_owned()); let location = target_location_name(db, watch.region_id).await; let direction = target_direction_label(watch); let emitted = notifications::emit( db, &NewNotification { body: t!( "shell.notification.watchlist_target_body", item => item, direction => direction, location => location ) .into_owned(), dedup_key: dedup_key.to_owned(), kind: NotificationKind::WatchlistTarget, owner: NotificationOwner::Character(character_id), target: NotificationTarget::market_watchlist_target(None, watch.type_id), title: t!("shell.notification.watchlist_target_title").into_owned(), }, ) .await?; Ok(emitted.into_iter().collect())}
async fn target_book(db: &Database, watches: &[MarketWatch]) -> watch_eval::PriceMap { let mut prices = watch_eval::PriceMap::new(); let Ok(esi) = public_market_client(db) else { return prices; }; let mut seen: HashSet<(i64, i64)> = HashSet::new(); for watch in watches { let Some((scope_id, tier)) = target_scope(watch) else { continue; }; let Some(region_id) = watch.region_id else { continue; }; // A structure book needs an authed grant the background detector cannot build; leaving it unpriced // avoids alerting against the wrong (region) market. if tier == LocationTier::Structure { continue; } let key = (watch.type_id, scope_id); if !seen.insert(key) { continue; } let best = target_best_prices(&esi, db, region_id, watch.type_id, tier, scope_id).await; prices.insert(key, best); } prices}
fn target_scope(watch: &MarketWatch) -> Option<(i64, LocationTier)> { let scope_id = watch.location_id.or(watch.region_id)?; let tier = watch .location_tier .as_deref() .and_then(LocationTier::parse) .or_else(|| LocationTier::from_id(scope_id)) .unwrap_or(LocationTier::Region); Some((scope_id, tier))}
async fn target_best_prices( esi: &esi::Client, db: &Database, region_id: i64, type_id: i64, tier: LocationTier, scope_id: i64,) -> watch_eval::BestPrices { let sells = filter_target_scope( db, tier, scope_id, esi.market().sell_orders(region_id, type_id).await.unwrap_or_default(), ) .await; let buys = filter_target_scope( db, tier, scope_id, esi.market().buy_orders(region_id, type_id).await.unwrap_or_default(), ) .await; watch_eval::BestPrices::available( buys.iter().map(|order| order.price).max_by(f64::total_cmp), sells.iter().map(|order| order.price).min_by(f64::total_cmp), )}
async fn filter_target_scope( db: &Database, tier: LocationTier, scope_id: i64, orders: Vec<RegionOrder>,) -> Vec<RegionOrder> { match tier { LocationTier::Station => orders .into_iter() .filter(|order| order.location_id == scope_id) .collect(), LocationTier::System => orders.into_iter().filter(|order| order.system_id == scope_id).collect(), LocationTier::Constellation => { let systems = target_constellation_systems(db, scope_id).await; orders .into_iter() .filter(|order| systems.contains(&order.system_id)) .collect() } _ => orders, }}
async fn target_constellation_systems(db: &Database, constellation_id: i64) -> HashSet<i64> { sde::all_solar_systems(db) .await .unwrap_or_default() .into_iter() .filter(|system| system.constellation_id() == constellation_id) .map(|system| system.id()) .collect()}
// Reuses S4-T6's met rule (Buy watches best_sell, Sell watches best_buy; met = current crosses target):// returns Some(marker) only when the target is met. The marker folds the crossing side and price so a// fresh crossing after a retreat mints a distinct dedup_key.fn target_met_marker(watch: &MarketWatch, prices: &watch_eval::PriceMap) -> Option<String> { let direction = WatchDirection::parse(&watch.direction)?; let (scope_id, _) = target_scope(watch)?; let best = prices.get(&(watch.type_id, scope_id))?; let outcome = watch_eval::evaluate(direction, watch.target_price, best); let current = outcome.current?; outcome.met.then(|| target_marker(direction, current))}
fn target_marker(direction: WatchDirection, current: f64) -> String { format!("{}:{current:.2}", direction.as_str())}
fn target_direction_label(watch: &MarketWatch) -> String { match WatchDirection::parse(&watch.direction) { Some(WatchDirection::Sell) => t!("shell.notification.watchlist_target_sell").into_owned(), _ => t!("shell.notification.watchlist_target_buy").into_owned(), }}
async fn target_location_name(db: &Database, region_id: Option<i64>) -> String { let fallback = || t!("shell.notification.watchlist_target_location_fallback").into_owned(); let Some(region_id) = region_id else { return fallback(); }; sde::get_region(db, region_id) .await .ok() .flatten() .map(|region| region.name().clone()) .unwrap_or_else(fallback)}
fn crossed(timestamp: &str, now_rfc: &str) -> bool { match ( DateTime::parse_from_rfc3339(timestamp), DateTime::parse_from_rfc3339(now_rfc), ) { (Ok(when), Ok(now)) => when <= now, _ => false, }}
fn is_industry_done(status: &str) -> bool { matches!(status, "ready" | "delivered")}
fn calendar_key(character_id: i64, event_id: i64) -> String { format!("calendar:{character_id}:{event_id}")}
fn extraction_cracked_key(corporation_id: i64, structure_id: i64, moon_id: i64, arrival: &str) -> String { format!("extraction_cracked:{corporation_id}:{structure_id}:{moon_id}:{arrival}")}
fn extraction_scheduled_key(corporation_id: i64, structure_id: i64, moon_id: i64, arrival: &str) -> String { format!("extraction_scheduled:{corporation_id}:{structure_id}:{moon_id}:{arrival}")}
fn industry_key(job_id: i64) -> String { format!("industry:{job_id}")}
// Keyed on the killmail id ALONE (no owner): a kill attributable to both an owned character and its// owned corporation must notify exactly once, so whichever owner's detector reaches it first wins and// the other's emit is an INSERT OR IGNORE no-op.fn killmail_key(killmail_id: i64) -> String { format!("killmail:{killmail_id}")}
fn mail_key(character_id: i64, mail_id: i64) -> String { format!("mail:{character_id}:{mail_id}")}
fn customs_office_key(corporation_id: i64, office_id: i64, reinforce_hour: i64) -> String { format!("customs_office_vulnerable:{corporation_id}:{office_id}:{reinforce_hour}")}
fn structure_anchor_key(corporation_id: i64, structure_id: i64, phase: &str, marker: &str) -> String { format!("structure_anchor:{corporation_id}:{structure_id}:{phase}:{marker}")}
fn structure_fuel_key(corporation_id: i64, structure_id: i64, band: &str, expires: &str) -> String { format!("structure_fuel:{corporation_id}:{structure_id}:{band}:{expires}")}
// Keyed on the notification id ALONE (no owner): a single corp event delivered to several director// characters must notify exactly once, so whichever character's detector reaches it first wins and the// others' emit is an INSERT OR IGNORE no-op (mirrors killmail_key).fn structure_notif_key(notification_id: i64) -> String { format!("structure_notif:{notification_id}")}
fn structure_reinforced_key(corporation_id: i64, structure_id: i64, marker: &str) -> String { format!("structure_reinforced:{corporation_id}:{structure_id}:{marker}")}
fn structure_service_key(corporation_id: i64, structure_id: i64, service: &str) -> String { format!("structure_service:{corporation_id}:{structure_id}:{service}")}
fn skill_key(character_id: i64, skill_id: i64, finish_date: &str) -> String { format!("skill:{character_id}:{skill_id}:{finish_date}")}
#[cfg(test)]mod tests { use super::*;
mod crossed { use pretty_assertions::assert_eq;
use super::*;
#[test] fn it_flags_a_past_timestamp_as_matured() { assert!(crossed("2026-01-01T00:00:00+00:00", "2026-06-01T00:00:00+00:00")); }
#[test] fn it_does_not_flag_a_future_timestamp() { assert!(!crossed("2026-12-01T00:00:00+00:00", "2026-06-01T00:00:00+00:00")); }
#[test] fn it_treats_an_unparseable_timestamp_as_not_matured() { assert!(!crossed("not-a-date", "2026-06-01T00:00:00+00:00")); }
#[test] fn it_treats_the_exact_boundary_as_matured() { assert_eq!(crossed("2026-06-01T00:00:00+00:00", "2026-06-01T00:00:00+00:00"), true); } }
mod filter_target_scope { use pretty_assertions::assert_eq;
use super::*; use crate::store::{ self, model::{Constellation, Region, SolarSystem}, };
const REGION: i64 = 10_000_002; const CONSTELLATION: i64 = 20_000_020; const IN_SYSTEM: i64 = 30_000_142; const OUT_SYSTEM: i64 = 30_000_005; const STATION: i64 = 60_003_760;
fn order(location_id: i64, system_id: i64) -> RegionOrder { RegionOrder { location_id, system_id, ..RegionOrder::default() } }
async fn seed_constellation(db: &Database) { sde::upsert_region( db, &Region { description: None, id: REGION, name: "The Forge".to_owned(), }, ) .await .unwrap(); sde::upsert_constellation( db, &Constellation { id: CONSTELLATION, name: "Kimotoro".to_owned(), position_x: 0.0, position_y: 0.0, position_z: 0.0, region_id: REGION, }, ) .await .unwrap(); sde::upsert_solar_system( db, &SolarSystem { constellation_id: CONSTELLATION, id: IN_SYSTEM, name: "Jita".to_owned(), position_x: 0.0, position_y: 0.0, position_z: 0.0, security_class: None, security_status: 0.9, star_id: None, }, ) .await .unwrap(); }
#[tokio::test] async fn it_keeps_only_orders_at_the_station() { let db = store::open_test().await.unwrap(); let orders = vec![order(STATION, IN_SYSTEM), order(60_003_761, IN_SYSTEM)];
let filtered = filter_target_scope(&db, LocationTier::Station, STATION, orders).await;
assert_eq!(filtered.len(), 1); assert_eq!(filtered[0].location_id, STATION); }
#[tokio::test] async fn it_keeps_only_orders_in_the_system() { let db = store::open_test().await.unwrap(); let orders = vec![order(STATION, IN_SYSTEM), order(60_003_761, OUT_SYSTEM)];
let filtered = filter_target_scope(&db, LocationTier::System, IN_SYSTEM, orders).await;
assert_eq!(filtered.len(), 1); assert_eq!(filtered[0].system_id, IN_SYSTEM); }
#[tokio::test] async fn it_keeps_only_orders_within_the_constellation() { let db = store::open_test().await.unwrap(); seed_constellation(&db).await; let orders = vec![order(STATION, IN_SYSTEM), order(60_003_761, OUT_SYSTEM)];
let filtered = filter_target_scope(&db, LocationTier::Constellation, CONSTELLATION, orders).await;
assert_eq!(filtered.len(), 1); assert_eq!(filtered[0].system_id, IN_SYSTEM); }
#[tokio::test] async fn it_leaves_a_region_scope_unfiltered() { let db = store::open_test().await.unwrap(); let orders = vec![order(STATION, IN_SYSTEM), order(60_003_761, OUT_SYSTEM)];
let filtered = filter_target_scope(&db, LocationTier::Region, REGION, orders).await;
assert_eq!(filtered.len(), 2); } }
mod is_industry_done { use super::*;
#[test] fn it_only_fires_on_ready_or_delivered() { assert!(is_industry_done("ready")); assert!(is_industry_done("delivered")); assert!(!is_industry_done("active")); assert!(!is_industry_done("paused")); } }
mod keys { use pretty_assertions::assert_eq;
use super::*;
#[test] fn it_builds_stable_kind_prefixed_keys() { assert_eq!(mail_key(1, 2), "mail:1:2"); assert_eq!(calendar_key(1, 2), "calendar:1:2"); assert_eq!(killmail_key(7), "killmail:7"); assert_eq!( skill_key(1, 3300, "2026-06-22T00:00:00+00:00"), "skill:1:3300:2026-06-22T00:00:00+00:00" ); assert_eq!(industry_key(55), "industry:55"); assert_eq!( extraction_scheduled_key(98, 1001, 40_000, "2026-06-22T00:00:00+00:00"), "extraction_scheduled:98:1001:40000:2026-06-22T00:00:00+00:00" ); assert_eq!( extraction_cracked_key(98, 1001, 40_000, "2026-06-22T00:00:00+00:00"), "extraction_cracked:98:1001:40000:2026-06-22T00:00:00+00:00" ); } }
mod killmail_detector { use pretty_assertions::assert_eq;
use super::*; use crate::{ config::Feature, store::{ self, model::{CharacterKillEntry, CorporationKillEntry}, }, };
const CHARACTER: i64 = 95_465_499;
fn flags() -> FeatureFlags { FeatureFlags::default() }
fn killmails_disabled() -> FeatureFlags { let mut flags = FeatureFlags::default(); flags.set_enabled(Feature::CombatLog, false); flags }
async fn seed_character(db: &Database) { sqlx::query("INSERT INTO races (id, alliance_id, description, name) VALUES (1, 1, '', 'Caldari')") .execute(db.writer()) .await .unwrap(); let bloodline = "INSERT INTO bloodlines \ (id, corporation_id, race_id, charisma, description, intelligence, memory, name, perception, willpower) \ VALUES (1, 1, 1, 1, '', 1, 1, 'Civire', 1, 1)"; sqlx::query(bloodline).execute(db.writer()).await.unwrap(); let corporation = "INSERT INTO corporations (id, ceo_id, creator_id, member_count, name, tax_rate, ticker) \ VALUES (1, 1, 1, 1, 'Test Corp', 0.0, 'TEST')"; sqlx::query(corporation).execute(db.writer()).await.unwrap(); let character = "INSERT INTO characters (id, bloodline_id, corporation_id, race_id, birthday, gender, name) \ VALUES (?, 1, 1, 1, '2020-01-01T00:00:00Z', 'male', 'Test Pilot')"; sqlx::query(character) .bind(CHARACTER) .execute(db.writer()) .await .unwrap(); }
fn kill(killmail_id: i64) -> CharacterKillEntry { CharacterKillEntry { attacker_count: 1, character_id: CHARACTER, final_blow: true, is_kill: true, kill_hash: format!("hash-{killmail_id}"), kill_time: "2026-06-20T00:00:00+00:00".to_owned(), killmail_id, ship_type_id: 587, synced_at: "2026-06-22T00:00:00+00:00".to_owned(), system_id: 30_000_142, value_destroyed_isk: 0.0, value_final: true, value_isk: 0.0, value_recheck_count: 0, value_source: "zkill".to_owned(), victim_alliance_id: None, victim_corp_id: None, victim_damage_taken: 100, victim_id: None, } }
const CORPORATION: i64 = 1;
fn corp_kill(killmail_id: i64) -> CorporationKillEntry { CorporationKillEntry { attacker_count: 1, corporation_id: CORPORATION, final_blow: true, is_kill: true, kill_hash: format!("corp-hash-{killmail_id}"), kill_time: "2026-06-20T00:00:00+00:00".to_owned(), killmail_id, ship_type_id: 587, synced_at: "2026-06-22T00:00:00+00:00".to_owned(), system_id: 30_000_142, value_destroyed_isk: 0.0, value_final: true, value_isk: 0.0, value_recheck_count: 0, value_source: "zkill".to_owned(), victim_alliance_id: None, victim_corp_id: None, victim_damage_taken: 100, victim_id: None, } }
#[tokio::test] async fn it_watermarks_existing_history_on_the_first_scan() { let db = store::open_test().await.unwrap(); seed_character(&db).await; character::upsert_killmail(&db, &kill(1)).await.unwrap();
let surfaced = killmail_detector(&db, NotificationOwner::Character(CHARACTER), &flags()) .await .unwrap();
assert!(surfaced.is_empty(), "pre-existing history is watermarked, not surfaced"); assert!(notifications::list(&db, 50).await.unwrap().is_empty()); }
#[tokio::test] async fn it_emits_exactly_one_after_the_first_scan() { let db = store::open_test().await.unwrap(); seed_character(&db).await; character::upsert_killmail(&db, &kill(1)).await.unwrap(); killmail_detector(&db, NotificationOwner::Character(CHARACTER), &flags()) .await .unwrap(); character::upsert_killmail(&db, &kill(2)).await.unwrap();
let surfaced = killmail_detector(&db, NotificationOwner::Character(CHARACTER), &flags()) .await .unwrap();
assert_eq!(surfaced.len(), 1); assert_eq!(surfaced[0].dedup_key(), "killmail:2"); assert_eq!(notifications::unread_count(&db).await.unwrap(), 1); }
#[tokio::test] async fn it_is_a_no_op_on_a_rerun_over_unchanged_data() { let db = store::open_test().await.unwrap(); seed_character(&db).await; character::upsert_killmail(&db, &kill(1)).await.unwrap(); killmail_detector(&db, NotificationOwner::Character(CHARACTER), &flags()) .await .unwrap(); character::upsert_killmail(&db, &kill(2)).await.unwrap(); killmail_detector(&db, NotificationOwner::Character(CHARACTER), &flags()) .await .unwrap();
let rerun = killmail_detector(&db, NotificationOwner::Character(CHARACTER), &flags()) .await .unwrap();
assert!(rerun.is_empty(), "re-running over unchanged data surfaces nothing new"); assert_eq!(notifications::list(&db, 50).await.unwrap().len(), 1); }
#[tokio::test] async fn it_notifies_once_when_owned_by_both_a_character_and_its_corporation() { let db = store::open_test().await.unwrap(); seed_character(&db).await; killmail_detector(&db, NotificationOwner::Character(CHARACTER), &flags()) .await .unwrap(); killmail_detector(&db, NotificationOwner::Corporation(CORPORATION), &flags()) .await .unwrap(); character::upsert_killmail(&db, &kill(42)).await.unwrap(); org::upsert_corporation_killmail(&db, &corp_kill(42)).await.unwrap();
let from_char = killmail_detector(&db, NotificationOwner::Character(CHARACTER), &flags()) .await .unwrap(); let from_corp = killmail_detector(&db, NotificationOwner::Corporation(CORPORATION), &flags()) .await .unwrap();
assert_eq!(from_char.len(), 1, "the character detector surfaces the kill once"); assert!( from_corp.is_empty(), "the corp detector sees the same key already taken" ); assert_eq!(notifications::list(&db, 50).await.unwrap().len(), 1); }
#[tokio::test] async fn it_does_not_run_for_a_disabled_feature() { let db = store::open_test().await.unwrap(); seed_character(&db).await; character::upsert_killmail(&db, &kill(1)).await.unwrap();
let surfaced = killmail_detector(&db, NotificationOwner::Character(CHARACTER), &killmails_disabled()) .await .unwrap();
assert!(surfaced.is_empty()); assert!(notifications::list(&db, 50).await.unwrap().is_empty()); } }
async fn seed_character(db: &Database, character_id: i64) { sqlx::query( "INSERT INTO races (id, alliance_id, description, name) VALUES (1, 1, '', 'Caldari') ON CONFLICT DO NOTHING", ) .execute(db.writer()) .await .unwrap(); let bloodline = "INSERT INTO bloodlines \ (id, corporation_id, race_id, charisma, description, intelligence, memory, name, perception, willpower) \ VALUES (1, 1, 1, 1, '', 1, 1, 'Civire', 1, 1) ON CONFLICT DO NOTHING"; sqlx::query(bloodline).execute(db.writer()).await.unwrap(); let corporation = "INSERT INTO corporations (id, ceo_id, creator_id, member_count, name, tax_rate, ticker) \ VALUES (1, 1, 1, 1, 'Test Corp', 0.0, 'TEST') ON CONFLICT DO NOTHING"; sqlx::query(corporation).execute(db.writer()).await.unwrap(); let character = "INSERT INTO characters (id, bloodline_id, corporation_id, race_id, birthday, gender, name) \ VALUES (?, 1, 1, 1, '2020-01-01T00:00:00Z', 'male', 'Test Pilot')"; sqlx::query(character) .bind(character_id) .execute(db.writer()) .await .unwrap(); }
async fn seed_corporation(db: &Database, corporation_id: i64) { sqlx::query( "INSERT INTO corporations (id, ceo_id, creator_id, member_count, name, tax_rate, ticker) \ VALUES (?, 1, 1, 1, 'Test Corp', 0.0, 'TEST') ON CONFLICT DO NOTHING", ) .bind(corporation_id) .execute(db.writer()) .await .unwrap(); }
mod calendar_detector { use pretty_assertions::assert_eq;
use super::*; use crate::{ config::Feature, store::{self, model::CharacterCalendarEvent}, };
const CHARACTER: i64 = 7001;
fn event(event_id: i64) -> CharacterCalendarEvent { CharacterCalendarEvent { character_id: CHARACTER, event_id, owner_name: "Fleet Command".to_owned(), timestamp: "2026-06-20T00:00:00+00:00".to_owned(), title: "Op briefing".to_owned(), ..Default::default() } }
async fn seed_event(db: &Database, event_id: i64) { calendar::upsert_complete(db, &event(event_id), &[]).await.unwrap(); }
#[tokio::test] async fn it_watermarks_existing_history_on_the_first_scan() { let db = store::open_test().await.unwrap(); seed_character(&db, CHARACTER).await; seed_event(&db, 1).await;
let surfaced = calendar_detector(&db, CHARACTER, &FeatureFlags::default()) .await .unwrap();
assert!(surfaced.is_empty(), "pre-existing events are watermarked, not surfaced"); assert!(notifications::list(&db, 50).await.unwrap().is_empty()); }
#[tokio::test] async fn it_emits_a_new_event_after_the_first_scan() { let db = store::open_test().await.unwrap(); seed_character(&db, CHARACTER).await; seed_event(&db, 1).await; calendar_detector(&db, CHARACTER, &FeatureFlags::default()) .await .unwrap(); seed_event(&db, 2).await;
let surfaced = calendar_detector(&db, CHARACTER, &FeatureFlags::default()) .await .unwrap();
assert_eq!(surfaced.len(), 1); assert_eq!(surfaced[0].dedup_key(), "calendar:7001:2"); assert_eq!(surfaced[0].owner(), NotificationOwner::Character(CHARACTER)); assert_eq!(surfaced[0].title(), "Op briefing"); assert_eq!(surfaced[0].body(), "Fleet Command"); assert_eq!( surfaced[0].target().destination, crate::store::model::NotificationDestination::Calendar ); }
#[tokio::test] async fn it_is_a_no_op_on_a_rerun_over_unchanged_data() { let db = store::open_test().await.unwrap(); seed_character(&db, CHARACTER).await; seed_event(&db, 1).await; calendar_detector(&db, CHARACTER, &FeatureFlags::default()) .await .unwrap(); seed_event(&db, 2).await; calendar_detector(&db, CHARACTER, &FeatureFlags::default()) .await .unwrap();
let rerun = calendar_detector(&db, CHARACTER, &FeatureFlags::default()) .await .unwrap();
assert!(rerun.is_empty()); assert_eq!(notifications::list(&db, 50).await.unwrap().len(), 1); }
#[tokio::test] async fn it_does_not_run_for_a_disabled_feature() { let db = store::open_test().await.unwrap(); seed_character(&db, CHARACTER).await; seed_event(&db, 1).await; let mut flags = FeatureFlags::default(); flags.set_enabled(Feature::Calendar, false);
let surfaced = calendar_detector(&db, CHARACTER, &flags).await.unwrap();
assert!(surfaced.is_empty()); assert!(notifications::list(&db, 50).await.unwrap().is_empty()); } }
mod extraction_cracked_detector { use pretty_assertions::assert_eq;
use super::*; use crate::{ config::Feature, store::{self, model::CorporationMiningExtraction}, };
const CORPORATION: i64 = 98_000_001;
const NOW: &str = "2026-06-21T00:00:00+00:00";
fn now() -> DateTime<Utc> { DateTime::parse_from_rfc3339(NOW).unwrap().with_timezone(&Utc) }
fn extraction(structure_id: i64, moon_id: i64, arrival: &str) -> CorporationMiningExtraction { CorporationMiningExtraction { chunk_arrival_time: Some(arrival.to_owned()), corporation_id: CORPORATION, extraction_start_time: None, moon_id, moon_name: None, natural_decay_time: None, security_status: None, solar_system_id: None, structure_id, } }
async fn seed(db: &Database, extractions: &[CorporationMiningExtraction]) { seed_corporation(db, CORPORATION).await; org::replace_extractions_for_corporation(db, CORPORATION, extractions) .await .unwrap(); }
#[tokio::test] async fn it_watermarks_already_cracked_chunks_on_the_first_scan() { let db = store::open_test().await.unwrap(); seed(&db, &[extraction(1001, 40_000, "2026-06-20T00:00:00+00:00")]).await;
let surfaced = extraction_cracked_detector(&db, CORPORATION, now(), &FeatureFlags::default()) .await .unwrap();
assert!(surfaced.is_empty(), "a past arrival is watermarked, not surfaced"); assert!(notifications::list(&db, 50).await.unwrap().is_empty()); }
#[tokio::test] async fn it_ignores_a_future_arrival() { let db = store::open_test().await.unwrap(); seed(&db, &[extraction(1001, 40_000, "2026-12-01T00:00:00+00:00")]).await; extraction_cracked_detector(&db, CORPORATION, now(), &FeatureFlags::default()) .await .unwrap();
let surfaced = extraction_cracked_detector(&db, CORPORATION, now(), &FeatureFlags::default()) .await .unwrap();
assert!( surfaced.is_empty(), "a chunk whose arrival is still in the future never cracks" ); assert!(notifications::list(&db, 50).await.unwrap().is_empty()); }
#[tokio::test] async fn it_emits_when_a_chunk_matures_after_the_first_scan() { let db = store::open_test().await.unwrap(); seed(&db, &[extraction(1001, 40_000, "2026-12-01T00:00:00+00:00")]).await; extraction_cracked_detector(&db, CORPORATION, now(), &FeatureFlags::default()) .await .unwrap(); let later = DateTime::parse_from_rfc3339("2026-12-02T00:00:00+00:00") .unwrap() .with_timezone(&Utc);
let surfaced = extraction_cracked_detector(&db, CORPORATION, later, &FeatureFlags::default()) .await .unwrap();
assert_eq!(surfaced.len(), 1); assert_eq!( surfaced[0].dedup_key(), "extraction_cracked:98000001:1001:40000:2026-12-01T00:00:00+00:00" ); assert_eq!(surfaced[0].owner(), NotificationOwner::Corporation(CORPORATION)); assert_eq!(surfaced[0].title(), "Moon chunk fractured"); assert_eq!(surfaced[0].body(), "Ready to mine"); assert_eq!(surfaced[0].target().character, None); }
#[tokio::test] async fn it_does_not_run_for_a_disabled_feature() { let db = store::open_test().await.unwrap(); seed(&db, &[extraction(1001, 40_000, "2026-06-20T00:00:00+00:00")]).await; let mut flags = FeatureFlags::default(); flags.set_enabled(Feature::Industry, false);
let surfaced = extraction_cracked_detector(&db, CORPORATION, now(), &flags) .await .unwrap();
assert!(surfaced.is_empty()); assert!(notifications::list(&db, 50).await.unwrap().is_empty()); } }
mod extraction_scheduled_detector { use pretty_assertions::assert_eq;
use super::*; use crate::{ config::Feature, store::{self, model::CorporationMiningExtraction}, };
const CORPORATION: i64 = 98_000_002;
fn extraction(structure_id: i64, moon_id: i64, arrival: &str) -> CorporationMiningExtraction { CorporationMiningExtraction { chunk_arrival_time: Some(arrival.to_owned()), corporation_id: CORPORATION, extraction_start_time: None, moon_id, moon_name: None, natural_decay_time: None, security_status: None, solar_system_id: None, structure_id, } }
fn no_arrival(structure_id: i64, moon_id: i64) -> CorporationMiningExtraction { CorporationMiningExtraction { chunk_arrival_time: None, corporation_id: CORPORATION, extraction_start_time: None, moon_id, moon_name: None, natural_decay_time: None, security_status: None, solar_system_id: None, structure_id, } }
async fn seed(db: &Database, extractions: &[CorporationMiningExtraction]) { seed_corporation(db, CORPORATION).await; org::replace_extractions_for_corporation(db, CORPORATION, extractions) .await .unwrap(); }
#[tokio::test] async fn it_watermarks_existing_schedules_on_the_first_scan() { let db = store::open_test().await.unwrap(); seed(&db, &[extraction(1001, 40_000, "2026-12-01T00:00:00+00:00")]).await;
let surfaced = extraction_scheduled_detector(&db, CORPORATION, &FeatureFlags::default()) .await .unwrap();
assert!( surfaced.is_empty(), "pre-existing schedules are watermarked, not surfaced" ); assert!(notifications::list(&db, 50).await.unwrap().is_empty()); }
#[tokio::test] async fn it_skips_an_extraction_without_an_arrival_time() { let db = store::open_test().await.unwrap(); seed(&db, &[no_arrival(1001, 40_000)]).await; extraction_scheduled_detector(&db, CORPORATION, &FeatureFlags::default()) .await .unwrap(); seed(&db, &[no_arrival(1001, 40_000), no_arrival(1002, 40_001)]).await;
let surfaced = extraction_scheduled_detector(&db, CORPORATION, &FeatureFlags::default()) .await .unwrap();
assert!( surfaced.is_empty(), "an extraction with no chunk arrival has nothing to schedule" ); }
#[tokio::test] async fn it_emits_a_new_schedule_after_the_first_scan() { let db = store::open_test().await.unwrap(); seed(&db, &[extraction(1001, 40_000, "2026-12-01T00:00:00+00:00")]).await; extraction_scheduled_detector(&db, CORPORATION, &FeatureFlags::default()) .await .unwrap(); seed( &db, &[ extraction(1001, 40_000, "2026-12-01T00:00:00+00:00"), extraction(1002, 40_001, "2026-12-05T00:00:00+00:00"), ], ) .await;
let surfaced = extraction_scheduled_detector(&db, CORPORATION, &FeatureFlags::default()) .await .unwrap();
assert_eq!(surfaced.len(), 1); assert_eq!( surfaced[0].dedup_key(), "extraction_scheduled:98000002:1002:40001:2026-12-05T00:00:00+00:00" ); assert_eq!(surfaced[0].owner(), NotificationOwner::Corporation(CORPORATION)); assert_eq!(surfaced[0].title(), "Extraction scheduled"); assert_eq!( surfaced[0].target().destination, crate::store::model::NotificationDestination::Industry ); }
#[tokio::test] async fn it_does_not_run_for_a_disabled_feature() { let db = store::open_test().await.unwrap(); seed(&db, &[extraction(1001, 40_000, "2026-12-01T00:00:00+00:00")]).await; let mut flags = FeatureFlags::default(); flags.set_enabled(Feature::Industry, false);
let surfaced = extraction_scheduled_detector(&db, CORPORATION, &flags).await.unwrap();
assert!(surfaced.is_empty()); assert!(notifications::list(&db, 50).await.unwrap().is_empty()); } }
mod industry_detector { use pretty_assertions::assert_eq;
use super::*; use crate::{ config::Feature, store::{ self, model::{CharacterIndustryJob, CorporationIndustryJob, CorporationMemberRole, OwnerType}, repo::infra, }, };
const CHARACTER: i64 = 7100;
const CORPORATION: i64 = 1;
const DIRECTOR: i64 = 7100;
fn job(job_id: i64, status: &str) -> CharacterIndustryJob { CharacterIndustryJob { activity_id: 1, blueprint_id: 100, blueprint_location_id: 60_003_760, blueprint_type_id: 12_345, character_id: CHARACTER, completed_character_id: None, completed_date: None, cost: None, duration: 3600, end_date: "2026-06-20T00:00:00+00:00".to_owned(), facility_id: 60_003_760, installer_id: CHARACTER, job_id, licensed_runs: None, output_location_id: 60_003_760, pause_date: None, probability: None, product_type_id: Some(587), runs: 1, start_date: "2026-06-19T00:00:00+00:00".to_owned(), station_id: None, status: status.to_owned(), successful_runs: None, } }
fn corp_job(job_id: i64, status: &str) -> CorporationIndustryJob { CorporationIndustryJob { activity_id: 1, blueprint_id: 100, blueprint_location_id: 60_003_760, blueprint_type_id: 12_345, completed_character_id: None, completed_date: None, corporation_id: CORPORATION, cost: None, duration: 3600, end_date: "2026-06-20T00:00:00+00:00".to_owned(), facility_id: 60_003_760, installer_id: DIRECTOR, job_id, licensed_runs: None, output_location_id: 60_003_760, pause_date: None, probability: None, product_type_id: Some(587), runs: 1, start_date: "2026-06-19T00:00:00+00:00".to_owned(), station_id: None, status: status.to_owned(), successful_runs: None, } }
// The corp industry reader gates on `corp_is_authorized`: an owned corp whose Director-roled // credential authorizer is a member holding the Director role. Seed that full chain. async fn authorize_corp(db: &Database) { seed_character(db, DIRECTOR).await; infra::upsert( db, CORPORATION, OwnerType::Corporation, "tok", "rt", 9999, Some(DIRECTOR), None, ) .await .unwrap(); org::replace_for_corporation( db, CORPORATION, &[CorporationMemberRole::from(( CORPORATION, DIRECTOR, "Director".to_owned(), ))], ) .await .unwrap(); }
#[tokio::test] async fn it_watermarks_finished_jobs_on_the_first_scan() { let db = store::open_test().await.unwrap(); seed_character(&db, CHARACTER).await; industry::replace_for_character(&db, CHARACTER, &[job(1, "ready")]) .await .unwrap();
let surfaced = industry_detector(&db, NotificationOwner::Character(CHARACTER), &FeatureFlags::default()) .await .unwrap();
assert!( surfaced.is_empty(), "pre-existing finished jobs are watermarked, not surfaced" ); assert!(notifications::list(&db, 50).await.unwrap().is_empty()); }
#[tokio::test] async fn it_only_surfaces_done_jobs() { let db = store::open_test().await.unwrap(); seed_character(&db, CHARACTER).await; industry::replace_for_character(&db, CHARACTER, &[job(1, "ready")]) .await .unwrap(); industry_detector(&db, NotificationOwner::Character(CHARACTER), &FeatureFlags::default()) .await .unwrap(); industry::replace_for_character( &db, CHARACTER, &[job(1, "ready"), job(2, "active"), job(3, "delivered")], ) .await .unwrap();
let surfaced = industry_detector(&db, NotificationOwner::Character(CHARACTER), &FeatureFlags::default()) .await .unwrap();
assert_eq!(surfaced.len(), 1, "only the newly-delivered job surfaces"); assert_eq!(surfaced[0].dedup_key(), "industry:3"); assert_eq!(surfaced[0].owner(), NotificationOwner::Character(CHARACTER)); assert_eq!(surfaced[0].title(), "Industry job complete"); assert_eq!(surfaced[0].target().character, Some(CHARACTER)); }
#[tokio::test] async fn it_emits_for_a_corporation_owner() { let db = store::open_test().await.unwrap(); authorize_corp(&db).await; industry::replace_for_corporation(&db, CORPORATION, &[corp_job(10, "ready")]) .await .unwrap(); industry_detector( &db, NotificationOwner::Corporation(CORPORATION), &FeatureFlags::default(), ) .await .unwrap(); industry::replace_for_corporation(&db, CORPORATION, &[corp_job(10, "ready"), corp_job(11, "delivered")]) .await .unwrap();
let surfaced = industry_detector( &db, NotificationOwner::Corporation(CORPORATION), &FeatureFlags::default(), ) .await .unwrap();
assert_eq!(surfaced.len(), 1); assert_eq!(surfaced[0].dedup_key(), "industry:11"); assert_eq!(surfaced[0].owner(), NotificationOwner::Corporation(CORPORATION)); assert_eq!( surfaced[0].target().character, None, "a corp job carries no character target" ); }
#[tokio::test] async fn it_does_not_run_for_a_disabled_feature() { let db = store::open_test().await.unwrap(); seed_character(&db, CHARACTER).await; industry::replace_for_character(&db, CHARACTER, &[job(1, "ready")]) .await .unwrap(); let mut flags = FeatureFlags::default(); flags.set_enabled(Feature::Industry, false);
let surfaced = industry_detector(&db, NotificationOwner::Character(CHARACTER), &flags) .await .unwrap();
assert!(surfaced.is_empty()); assert!(notifications::list(&db, 50).await.unwrap().is_empty()); } }
mod outbid_detector { use pretty_assertions::assert_eq;
use super::*; use crate::{ clients::esi::models::character::MarketOrder as EsiMarketOrder, config::SubFeature, store::{self, repo::market_alert_state}, };
const CHARACTER: i64 = 7300;
const STATION: i64 = 60_003_760;
const REGION: i64 = 10_000_002;
const TYPE: i64 = 34;
fn order(order_id: i64, price: f64) -> MarketOrder { MarketOrder::from(( CHARACTER, EsiMarketOrder { duration: 90, escrow: 0.0, is_buy_order: false, is_corporation: false, issued: "2026-07-13T00:00:00Z".to_owned(), location_id: STATION, min_volume: Some(1), order_id, price, range: "station".to_owned(), region_id: REGION, type_id: TYPE, volume_remain: 100, volume_total: 100, }, )) }
fn competitor(price: f64) -> outbid::Quote { outbid::Quote { is_buy_order: false, location_id: STATION, price, type_id: TYPE, } }
async fn seed_order(db: &Database, order_id: i64, price: f64) { finance::replace(db, CHARACTER, &[order(order_id, price)]) .await .unwrap(); }
async fn orders(db: &Database) -> Vec<MarketOrder> { finance::open_for_character(db, CHARACTER).await.unwrap() }
#[tokio::test] async fn it_watermarks_an_already_outbid_order_on_the_first_scan() { let db = store::open_test().await.unwrap(); seed_character(&db, CHARACTER).await; seed_order(&db, 5001, 100.0).await;
let surfaced = reconcile_outbid(&db, CHARACTER, &orders(&db).await, &[competitor(90.0)]) .await .unwrap();
assert!( surfaced.is_empty(), "a first-scan undercut is watermarked, not surfaced" ); assert!(notifications::list(&db, 50).await.unwrap().is_empty()); let state = market_alert_state::read(&db, MarketAlertKind::Outbid, CHARACTER, 5001) .await .unwrap() .unwrap(); assert!(state.alerted); assert_eq!(state.marker, "90.00"); }
#[tokio::test] async fn it_does_not_re_emit_a_watermarked_order_on_a_later_scan() { let db = store::open_test().await.unwrap(); seed_character(&db, CHARACTER).await; seed_order(&db, 5001, 100.0).await; reconcile_outbid(&db, CHARACTER, &orders(&db).await, &[competitor(90.0)]) .await .unwrap();
let again = reconcile_outbid(&db, CHARACTER, &orders(&db).await, &[competitor(90.0)]) .await .unwrap();
assert!(again.is_empty(), "an order already outbid at first scan never surfaces"); assert!(notifications::list(&db, 50).await.unwrap().is_empty()); }
#[tokio::test] async fn it_emits_exactly_one_on_the_not_outbid_to_outbid_transition() { let db = store::open_test().await.unwrap(); seed_character(&db, CHARACTER).await; seed_order(&db, 5001, 100.0).await; // First scan while holding best price: nothing outbid, so only the first-scan sentinel lands. reconcile_outbid(&db, CHARACTER, &orders(&db).await, &[] as &[outbid::Quote]) .await .unwrap();
let surfaced = reconcile_outbid(&db, CHARACTER, &orders(&db).await, &[competitor(90.0)]) .await .unwrap();
assert_eq!(surfaced.len(), 1); assert_eq!(surfaced[0].dedup_key(), &format!("outbid:{CHARACTER}:5001:90.00")); assert_eq!(surfaced[0].owner(), NotificationOwner::Character(CHARACTER)); assert_eq!(surfaced[0].title(), "You have been outbid"); assert_eq!(surfaced[0].target().destination, NotificationDestination::Market); assert_eq!(surfaced[0].target().character, Some(CHARACTER)); }
#[tokio::test] async fn it_never_re_fires_while_the_order_stays_outbid() { let db = store::open_test().await.unwrap(); seed_character(&db, CHARACTER).await; seed_order(&db, 5001, 100.0).await; reconcile_outbid(&db, CHARACTER, &orders(&db).await, &[] as &[outbid::Quote]) .await .unwrap(); reconcile_outbid(&db, CHARACTER, &orders(&db).await, &[competitor(90.0)]) .await .unwrap();
// Still outbid, competitor drops further: the marker is frozen so the dedup_key holds. let rerun = reconcile_outbid(&db, CHARACTER, &orders(&db).await, &[competitor(80.0)]) .await .unwrap();
assert!(rerun.is_empty(), "re-running while still outbid surfaces nothing"); assert_eq!(notifications::list(&db, 50).await.unwrap().len(), 1); }
#[tokio::test] async fn it_re_fires_with_a_fresh_dedup_key_after_regaining_best_then_being_undercut() { let db = store::open_test().await.unwrap(); seed_character(&db, CHARACTER).await; seed_order(&db, 5001, 100.0).await; reconcile_outbid(&db, CHARACTER, &orders(&db).await, &[] as &[outbid::Quote]) .await .unwrap(); let first = reconcile_outbid(&db, CHARACTER, &orders(&db).await, &[competitor(90.0)]) .await .unwrap(); // Order regains best price (no competing undercut): alert-state clears. reconcile_outbid(&db, CHARACTER, &orders(&db).await, &[] as &[outbid::Quote]) .await .unwrap();
let refire = reconcile_outbid(&db, CHARACTER, &orders(&db).await, &[competitor(80.0)]) .await .unwrap();
assert_eq!(refire.len(), 1, "a fresh undercut after regaining best fires again"); assert_eq!(refire[0].dedup_key(), &format!("outbid:{CHARACTER}:5001:80.00")); assert_ne!(refire[0].dedup_key(), first[0].dedup_key()); assert_eq!(notifications::list(&db, 50).await.unwrap().len(), 2); }
#[tokio::test] async fn it_does_not_run_for_a_disabled_feature() { let db = store::open_test().await.unwrap(); seed_character(&db, CHARACTER).await; seed_order(&db, 5001, 100.0).await; let mut flags = FeatureFlags::default(); flags.set_sub_enabled(SubFeature::MarketOrders, false);
let surfaced = outbid_detector(&db, CHARACTER, &flags).await.unwrap();
assert!(surfaced.is_empty()); assert!(notifications::list(&db, 50).await.unwrap().is_empty()); }
mod helpers { use pretty_assertions::assert_eq;
use super::*;
#[test] fn it_derives_a_two_decimal_marker_from_the_best_price() { assert_eq!(outbid_marker(90.0), "90.00"); assert_eq!(outbid_marker(90.055), "90.06"); assert_eq!(outbid_marker(1_234.5), "1234.50"); }
#[test] fn it_reads_the_best_price_only_when_outbid() { let hit = outbid::Annotation { best: Some(90.0), gap: Some(10.0), gap_pct: Some(10.0), outbid: true, }; assert_eq!(outbid_best(&hit), Some(90.0)); assert_eq!(outbid_best(&outbid::Annotation::default()), None); }
#[test] fn it_maps_region_orders_into_side_tagged_quotes() { let mut book = Vec::new(); push_outbid_quotes( &mut book, TYPE, false, vec![RegionOrder { duration: 90, is_buy_order: true, issued: String::new(), location_id: STATION, min_volume: 1, order_id: 1, price: 42.0, range: "station".to_owned(), system_id: 30_000_142, type_id: 999, volume_remain: 5, }], );
assert_eq!(book.len(), 1); assert_eq!( book[0].is_buy_order, false, "the side is stamped by the caller, not the row" ); assert_eq!(book[0].type_id, TYPE); assert_eq!(book[0].location_id, STATION); assert_eq!(book[0].price, 42.0); } } }
mod mail_detector { use pretty_assertions::assert_eq;
use super::*; use crate::{ config::Feature, store::{ self, model::{CharacterMail, CharacterMailBody, CharacterMailRecipient}, }, };
const CHARACTER: i64 = 7200;
fn header(mail_id: i64, from_id: i64, subject: Option<&str>) -> CharacterMail { CharacterMail { character_id: CHARACTER, from_id, from_name: "Sender".to_owned(), mail_id, subject: subject.map(str::to_owned), timestamp: "2026-06-20T00:00:00+00:00".to_owned(), ..Default::default() } }
async fn seed_mail(db: &Database, mail_id: i64, from_id: i64, subject: Option<&str>) { let body = CharacterMailBody { body: "<p>hi</p>".to_owned(), character_id: CHARACTER, mail_id, }; mail::upsert_complete( db, &header(mail_id, from_id, subject), &body, &[] as &[CharacterMailRecipient], ) .await .unwrap(); }
#[tokio::test] async fn it_watermarks_existing_mail_on_the_first_scan() { let db = store::open_test().await.unwrap(); seed_character(&db, CHARACTER).await; seed_mail(&db, 1, 999, Some("Hello")).await;
let surfaced = mail_detector(&db, CHARACTER, &FeatureFlags::default()).await.unwrap();
assert!(surfaced.is_empty(), "pre-existing mail is watermarked, not surfaced"); assert!(notifications::list(&db, 50).await.unwrap().is_empty()); }
#[tokio::test] async fn it_emits_a_new_received_mail_after_the_first_scan() { let db = store::open_test().await.unwrap(); seed_character(&db, CHARACTER).await; seed_mail(&db, 1, 999, Some("Hello")).await; mail_detector(&db, CHARACTER, &FeatureFlags::default()).await.unwrap(); seed_mail(&db, 2, 888, Some("Re: contract")).await;
let surfaced = mail_detector(&db, CHARACTER, &FeatureFlags::default()).await.unwrap();
assert_eq!(surfaced.len(), 1); assert_eq!(surfaced[0].dedup_key(), "mail:7200:2"); assert_eq!(surfaced[0].owner(), NotificationOwner::Character(CHARACTER)); assert_eq!(surfaced[0].title(), "Re: contract"); assert_eq!(surfaced[0].body(), "From Sender"); }
#[tokio::test] async fn it_titles_an_empty_subject_mail_with_a_fallback() { let db = store::open_test().await.unwrap(); seed_character(&db, CHARACTER).await; seed_mail(&db, 1, 999, Some("seed")).await; mail_detector(&db, CHARACTER, &FeatureFlags::default()).await.unwrap(); seed_mail(&db, 2, 888, Some("")).await;
let surfaced = mail_detector(&db, CHARACTER, &FeatureFlags::default()).await.unwrap();
assert_eq!(surfaced.len(), 1); assert_eq!(surfaced[0].title(), "New EVE mail"); }
#[tokio::test] async fn it_drops_self_sent_mail() { let db = store::open_test().await.unwrap(); seed_character(&db, CHARACTER).await; seed_mail(&db, 1, 999, Some("seed")).await; mail_detector(&db, CHARACTER, &FeatureFlags::default()).await.unwrap(); seed_mail(&db, 2, CHARACTER, Some("My own message")).await;
let surfaced = mail_detector(&db, CHARACTER, &FeatureFlags::default()).await.unwrap();
assert!(surfaced.is_empty(), "mail authored by the owner is filtered out"); assert!(notifications::list(&db, 50).await.unwrap().is_empty()); }
#[tokio::test] async fn it_does_not_run_for_a_disabled_feature() { let db = store::open_test().await.unwrap(); seed_character(&db, CHARACTER).await; seed_mail(&db, 1, 999, Some("Hello")).await; let mut flags = FeatureFlags::default(); flags.set_enabled(Feature::Mail, false);
let surfaced = mail_detector(&db, CHARACTER, &flags).await.unwrap();
assert!(surfaced.is_empty()); assert!(notifications::list(&db, 50).await.unwrap().is_empty()); } }
mod skill_detector { use pretty_assertions::assert_eq;
use super::*; use crate::{ config::Feature, store::{self, model::CharacterSkillqueue}, };
const CHARACTER: i64 = 7300;
const NOW: &str = "2026-06-21T00:00:00+00:00";
fn now() -> DateTime<Utc> { DateTime::parse_from_rfc3339(NOW).unwrap().with_timezone(&Utc) }
fn entry(skill_id: i64, queue_position: i64, finish: Option<&str>) -> CharacterSkillqueue { CharacterSkillqueue { character_id: CHARACTER, finish_date: finish.map(str::to_owned), finished_level: 5, level_end_sp: None, level_start_sp: None, queue_position, skill_id, start_date: None, training_start_sp: None, } }
async fn seed_queue(db: &Database, entries: &[CharacterSkillqueue]) { character::replace_skillqueue(db, CHARACTER, entries).await.unwrap(); }
#[tokio::test] async fn it_watermarks_already_matured_skills_on_the_first_scan() { let db = store::open_test().await.unwrap(); seed_character(&db, CHARACTER).await; seed_queue(&db, &[entry(3300, 0, Some("2026-06-20T00:00:00+00:00"))]).await;
let surfaced = skill_detector(&db, CHARACTER, now(), &FeatureFlags::default()) .await .unwrap();
assert!( surfaced.is_empty(), "a skill already finished is watermarked, not surfaced" ); assert!(notifications::list(&db, 50).await.unwrap().is_empty()); }
#[tokio::test] async fn it_ignores_a_skill_still_training_and_one_without_a_finish_date() { let db = store::open_test().await.unwrap(); seed_character(&db, CHARACTER).await; seed_queue( &db, &[entry(3300, 0, Some("2026-12-01T00:00:00+00:00")), entry(3301, 1, None)], ) .await; skill_detector(&db, CHARACTER, now(), &FeatureFlags::default()) .await .unwrap();
let surfaced = skill_detector(&db, CHARACTER, now(), &FeatureFlags::default()) .await .unwrap();
assert!(surfaced.is_empty(), "future and not-yet-started skills never mature"); assert!(notifications::list(&db, 50).await.unwrap().is_empty()); }
#[tokio::test] async fn it_emits_when_a_skill_matures_after_the_first_scan() { let db = store::open_test().await.unwrap(); seed_character(&db, CHARACTER).await; seed_queue(&db, &[entry(3300, 0, Some("2026-12-01T00:00:00+00:00"))]).await; skill_detector(&db, CHARACTER, now(), &FeatureFlags::default()) .await .unwrap(); let later = DateTime::parse_from_rfc3339("2026-12-02T00:00:00+00:00") .unwrap() .with_timezone(&Utc);
let surfaced = skill_detector(&db, CHARACTER, later, &FeatureFlags::default()) .await .unwrap();
assert_eq!(surfaced.len(), 1); assert_eq!(surfaced[0].dedup_key(), "skill:7300:3300:2026-12-01T00:00:00+00:00"); assert_eq!(surfaced[0].owner(), NotificationOwner::Character(CHARACTER)); assert_eq!(surfaced[0].body(), "Training complete"); assert_eq!(surfaced[0].target().character, Some(CHARACTER)); assert_eq!( surfaced[0].target().destination, crate::store::model::NotificationDestination::Skills ); }
#[tokio::test] async fn it_does_not_run_for_a_disabled_feature() { let db = store::open_test().await.unwrap(); seed_character(&db, CHARACTER).await; seed_queue(&db, &[entry(3300, 0, Some("2026-06-20T00:00:00+00:00"))]).await; let mut flags = FeatureFlags::default(); flags.set_enabled(Feature::SkillMonitoring, false);
let surfaced = skill_detector(&db, CHARACTER, now(), &flags).await.unwrap();
assert!(surfaced.is_empty()); assert!(notifications::list(&db, 50).await.unwrap().is_empty()); }
#[tokio::test] async fn it_captures_a_detected_completion_in_the_table_exactly_once() { let db = store::open_test().await.unwrap(); seed_character(&db, CHARACTER).await; seed_queue(&db, &[entry(3300, 0, Some("2026-12-01T00:00:00+00:00"))]).await; skill_detector(&db, CHARACTER, now(), &FeatureFlags::default()) .await .unwrap(); let later = DateTime::parse_from_rfc3339("2026-12-02T00:00:00+00:00") .unwrap() .with_timezone(&Utc);
skill_detector(&db, CHARACTER, later, &FeatureFlags::default()) .await .unwrap(); skill_detector(&db, CHARACTER, later, &FeatureFlags::default()) .await .unwrap();
let rows = skill_completion::unverified(&db, CHARACTER).await.unwrap(); assert_eq!( rows.len(), 1, "a repeated detection captures the completion exactly once" ); assert_eq!(rows[0].skill_id, 3300); assert_eq!(rows[0].level, 5); assert_eq!(rows[0].completed_at, "2026-12-01T00:00:00+00:00"); }
#[tokio::test] async fn it_captures_even_when_the_notification_is_deduped() { let db = store::open_test().await.unwrap(); seed_character(&db, CHARACTER).await; seed_queue(&db, &[entry(3300, 0, Some("2026-12-01T00:00:00+00:00"))]).await; skill_detector(&db, CHARACTER, now(), &FeatureFlags::default()) .await .unwrap(); let later = DateTime::parse_from_rfc3339("2026-12-02T00:00:00+00:00") .unwrap() .with_timezone(&Utc); skill_detector(&db, CHARACTER, later, &FeatureFlags::default()) .await .unwrap();
let rerun = skill_detector(&db, CHARACTER, later, &FeatureFlags::default()) .await .unwrap();
assert!(rerun.is_empty(), "the notification is deduped on the rerun"); assert_eq!(skill_completion::unverified(&db, CHARACTER).await.unwrap().len(), 1); }
#[tokio::test] async fn it_does_not_capture_watermarked_history_on_the_first_scan() { let db = store::open_test().await.unwrap(); seed_character(&db, CHARACTER).await; seed_queue(&db, &[entry(3300, 0, Some("2026-06-20T00:00:00+00:00"))]).await;
skill_detector(&db, CHARACTER, now(), &FeatureFlags::default()) .await .unwrap();
let rows = skill_completion::unverified(&db, CHARACTER).await.unwrap(); assert!(rows.is_empty(), "pre-existing history is watermarked, not captured"); } }
mod target_marker { use pretty_assertions::assert_eq;
use super::*;
#[test] fn it_folds_the_crossing_side_and_price() { assert_eq!(target_marker(WatchDirection::Buy, 9.0), "buy:9.00"); assert_eq!(target_marker(WatchDirection::Sell, 12.5), "sell:12.50"); } }
mod target_met_marker { use pretty_assertions::assert_eq;
use super::*; use crate::store::model::WatchDirection;
const TYPE_ID: i64 = 34;
const REGION: i64 = 10_000_002;
fn watch(direction: WatchDirection, target: Option<f64>) -> MarketWatch { MarketWatch { created_at: String::new(), direction: direction.as_str().to_owned(), id: 7, location_id: None, location_tier: None, region_id: Some(REGION), target_price: target, type_id: TYPE_ID, updated_at: String::new(), } }
fn price_map(best_buy: Option<f64>, best_sell: Option<f64>) -> watch_eval::PriceMap { let mut map = watch_eval::PriceMap::new(); map.insert( (TYPE_ID, REGION), watch_eval::BestPrices::available(best_buy, best_sell), ); map }
#[test] fn it_marks_a_met_buy_watch_from_the_best_sell() { let marker = target_met_marker( &watch(WatchDirection::Buy, Some(10.0)), &price_map(Some(8.0), Some(9.0)), ); assert_eq!(marker, Some("buy:9.00".to_owned())); }
#[test] fn it_marks_a_met_sell_watch_from_the_best_buy() { let marker = target_met_marker( &watch(WatchDirection::Sell, Some(10.0)), &price_map(Some(12.0), Some(13.0)), ); assert_eq!(marker, Some("sell:12.00".to_owned())); }
#[test] fn it_yields_nothing_when_the_target_is_unmet() { let marker = target_met_marker( &watch(WatchDirection::Buy, Some(10.0)), &price_map(Some(8.0), Some(11.0)), ); assert_eq!(marker, None); }
#[test] fn it_yields_nothing_when_the_region_book_is_absent() { let marker = target_met_marker(&watch(WatchDirection::Buy, Some(10.0)), &watch_eval::PriceMap::new()); assert_eq!(marker, None); } }
mod watchlist_target_detector { use pretty_assertions::assert_eq;
use super::*; use crate::{ config::SubFeature, store::{ self, model::{NewWatch, WatchDirection}, }, };
const CHARACTER: i64 = 91_000_001;
const TYPE_ID: i64 = 34;
const REGION: i64 = 10_000_002;
async fn seed_region(db: &Database) { sqlx::query("INSERT INTO regions (id, description, name) VALUES (?, '', 'The Forge') ON CONFLICT DO NOTHING") .bind(REGION) .execute(db.writer()) .await .unwrap(); }
async fn seed_watch(db: &Database, direction: WatchDirection, target: f64) -> i64 { let created = market_watchlist::create( db, &NewWatch { direction, location_id: None, location_tier: None, region_id: Some(REGION), target_price: Some(target), type_id: TYPE_ID, }, ) .await .unwrap(); created.id }
fn price_map(best_buy: Option<f64>, best_sell: Option<f64>) -> watch_eval::PriceMap { let mut map = watch_eval::PriceMap::new(); map.insert( (TYPE_ID, REGION), watch_eval::BestPrices::available(best_buy, best_sell), ); map }
async fn watches(db: &Database) -> Vec<MarketWatch> { market_watchlist::list(db).await.unwrap() }
#[tokio::test] async fn it_watermarks_already_met_watches_on_the_first_scan() { let db = store::open_test().await.unwrap(); seed_character(&db, CHARACTER).await; seed_watch(&db, WatchDirection::Buy, 10.0).await;
let surfaced = reconcile_target(&db, CHARACTER, &watches(&db).await, &price_map(None, Some(9.0))) .await .unwrap();
assert!( surfaced.is_empty(), "an already-met target is watermarked, not surfaced" ); assert!(notifications::list(&db, 50).await.unwrap().is_empty()); }
#[tokio::test] async fn it_emits_exactly_one_on_the_not_met_to_met_transition() { let db = store::open_test().await.unwrap(); seed_character(&db, CHARACTER).await; seed_region(&db).await; let watch_id = seed_watch(&db, WatchDirection::Buy, 10.0).await; // First scan while unmet: watermark nothing, so a later crossing is a genuine transition. reconcile_target(&db, CHARACTER, &watches(&db).await, &price_map(None, Some(11.0))) .await .unwrap();
let surfaced = reconcile_target(&db, CHARACTER, &watches(&db).await, &price_map(None, Some(9.0))) .await .unwrap();
assert_eq!(surfaced.len(), 1); assert_eq!( surfaced[0].dedup_key(), &format!("target:{CHARACTER}:{watch_id}:buy:9.00") ); assert_eq!(surfaced[0].owner(), NotificationOwner::Character(CHARACTER)); assert_eq!( surfaced[0].target().destination, crate::store::model::NotificationDestination::Market ); assert_eq!(notifications::unread_count(&db).await.unwrap(), 1); }
#[tokio::test] async fn it_is_a_no_op_while_the_target_stays_met() { let db = store::open_test().await.unwrap(); seed_character(&db, CHARACTER).await; seed_region(&db).await; seed_watch(&db, WatchDirection::Buy, 10.0).await; reconcile_target(&db, CHARACTER, &watches(&db).await, &price_map(None, Some(11.0))) .await .unwrap(); reconcile_target(&db, CHARACTER, &watches(&db).await, &price_map(None, Some(9.0))) .await .unwrap();
let rerun = reconcile_target(&db, CHARACTER, &watches(&db).await, &price_map(None, Some(8.0))) .await .unwrap();
assert!( rerun.is_empty(), "the frozen marker keeps the dedup_key stable while met" ); assert_eq!(notifications::list(&db, 50).await.unwrap().len(), 1); }
#[tokio::test] async fn it_clears_and_re_fires_a_fresh_key_when_price_retreats_then_recrosses() { let db = store::open_test().await.unwrap(); seed_character(&db, CHARACTER).await; seed_region(&db).await; let watch_id = seed_watch(&db, WatchDirection::Buy, 10.0).await; reconcile_target(&db, CHARACTER, &watches(&db).await, &price_map(None, Some(11.0))) .await .unwrap(); let first = reconcile_target(&db, CHARACTER, &watches(&db).await, &price_map(None, Some(9.0))) .await .unwrap(); // Retreat above the target clears the alert-state. reconcile_target(&db, CHARACTER, &watches(&db).await, &price_map(None, Some(12.0))) .await .unwrap();
let second = reconcile_target(&db, CHARACTER, &watches(&db).await, &price_map(None, Some(7.0))) .await .unwrap();
assert_eq!(first.len(), 1); assert_eq!(second.len(), 1); assert_eq!(first[0].dedup_key(), &format!("target:{CHARACTER}:{watch_id}:buy:9.00")); assert_eq!( second[0].dedup_key(), &format!("target:{CHARACTER}:{watch_id}:buy:7.00") ); assert_ne!(first[0].dedup_key(), second[0].dedup_key()); assert_eq!(notifications::list(&db, 50).await.unwrap().len(), 2); }
#[tokio::test] async fn it_does_not_run_for_a_disabled_feature() { let db = store::open_test().await.unwrap(); seed_character(&db, CHARACTER).await; seed_watch(&db, WatchDirection::Buy, 10.0).await; let mut flags = FeatureFlags::default(); flags.set_sub_enabled(SubFeature::MarketOrders, false);
let surfaced = watchlist_target_detector(&db, CHARACTER, &flags).await.unwrap();
assert!(surfaced.is_empty()); assert!(notifications::list(&db, 50).await.unwrap().is_empty()); } }
mod parse_structure_id { use pretty_assertions::assert_eq;
use super::*;
#[test] fn it_reads_a_numeric_structure_id() { let text = "solarsystemID: 30002537\nstructureID: 1035408540807\nstructureTypeID: 35832\n";
assert_eq!(parse_structure_id(Some(text)), Some(1_035_408_540_807)); }
#[test] fn it_falls_back_to_the_planet_id_for_orbital_events() { let text = "planetID: 40255432\nsolarSystemID: 30002537\ntypeID: 2233\n";
assert_eq!(parse_structure_id(Some(text)), Some(40_255_432)); }
#[test] fn it_is_none_for_missing_or_unparseable_text() { assert_eq!(parse_structure_id(None), None); assert_eq!(parse_structure_id(Some("charID: 12345\n")), None); assert_eq!(parse_structure_id(Some(": : not yaml : :")), None); } }
mod feed_alert { use pretty_assertions::assert_eq;
use super::*;
#[test] fn it_maps_the_structure_and_orbital_event_types() { assert_eq!(feed_alert("StructureUnderAttack"), Some(FeedAlert::UnderAttack)); assert_eq!(feed_alert("OrbitalAttacked"), Some(FeedAlert::UnderAttack)); assert_eq!(feed_alert("CustomsOfficeAttacked"), Some(FeedAlert::UnderAttack)); assert_eq!(feed_alert("StructureLostShields"), Some(FeedAlert::Reinforced)); assert_eq!(feed_alert("StructureLostArmor"), Some(FeedAlert::Reinforced)); assert_eq!(feed_alert("StructureDestroyed"), Some(FeedAlert::Destroyed)); assert_eq!(feed_alert("StructureAnchoring"), Some(FeedAlert::Anchoring)); assert_eq!(feed_alert("StructureUnanchoring"), Some(FeedAlert::Unanchoring)); assert_eq!(feed_alert("StructureServicesOffline"), Some(FeedAlert::ServiceOffline)); assert_eq!(feed_alert("StructureFuelAlert"), Some(FeedAlert::FuelLow)); }
#[test] fn it_ignores_unrelated_notification_types() { assert_eq!(feed_alert("KillReportFinalBlow"), None); assert_eq!(feed_alert("WarDeclared"), None); }
#[test] fn it_ranks_severity_by_alert() { assert_eq!(feed_severity(FeedAlert::Destroyed), AlertSeverity::Critical); assert_eq!(feed_severity(FeedAlert::Reinforced), AlertSeverity::Critical); assert_eq!(feed_severity(FeedAlert::UnderAttack), AlertSeverity::Warning); assert_eq!(feed_severity(FeedAlert::ServiceOffline), AlertSeverity::Warning); assert_eq!(feed_severity(FeedAlert::Anchoring), AlertSeverity::Info);
assert!(AlertSeverity::Critical.toasts()); assert!(AlertSeverity::Warning.toasts()); assert!(!AlertSeverity::Info.toasts()); } }
mod structure_keys { use pretty_assertions::assert_eq;
use super::*;
#[test] fn it_builds_stable_kind_prefixed_keys() { assert_eq!( structure_reinforced_key(98, 1001, "2026-07-19T18:00:00+00:00"), "structure_reinforced:98:1001:2026-07-19T18:00:00+00:00" ); assert_eq!( structure_fuel_key(98, 1001, "low", "2026-07-20T00:00:00+00:00"), "structure_fuel:98:1001:low:2026-07-20T00:00:00+00:00" ); assert_eq!( structure_service_key(98, 1001, "Clone Bay"), "structure_service:98:1001:Clone Bay" ); assert_eq!( structure_anchor_key(98, 1001, "unanchoring", "2026-08-01T00:00:00+00:00"), "structure_anchor:98:1001:unanchoring:2026-08-01T00:00:00+00:00" ); assert_eq!(customs_office_key(98, 5001, 18), "customs_office_vulnerable:98:5001:18"); assert_eq!(structure_notif_key(42), "structure_notif:42"); } }
mod fuel_bands { use pretty_assertions::assert_eq;
use super::*;
fn now() -> DateTime<Utc> { DateTime::parse_from_rfc3339("2026-07-15T00:00:00+00:00") .unwrap() .with_timezone(&Utc) }
#[test] fn it_reports_remaining_fuel_days() { let days = fuel_days_remaining(Some("2026-07-17T00:00:00+00:00"), now()).unwrap();
assert_eq!(days.round() as i64, 2); }
#[test] fn it_is_none_for_missing_or_unparseable_expiry() { assert_eq!(fuel_days_remaining(None, now()), None); assert_eq!(fuel_days_remaining(Some("nope"), now()), None); }
#[test] fn it_flags_reinforced_state_strings() { assert!(is_reinforced_state("armor_reinforce")); assert!(is_reinforced_state("hull_reinforce")); assert!(!is_reinforced_state("shield_vulnerable")); } }
mod collect_structure_alerts { use pretty_assertions::assert_eq;
use super::*; use crate::store::model::{StructureService, StructureState};
const CORP: i64 = 98_000_001;
const STRUCTURE: i64 = 1_035_000_000_001;
fn now() -> DateTime<Utc> { DateTime::parse_from_rfc3339("2026-07-15T00:00:00+00:00") .unwrap() .with_timezone(&Utc) }
fn run(state: &StructureState) -> Vec<StructureAlert> { let mut out = Vec::new(); collect_structure_alerts(CORP, STRUCTURE, "Test Citadel", state, now(), &mut out); out }
#[test] fn it_raises_a_critical_reinforced_alert() { let mut state = StructureState::new(STRUCTURE, "2026-07-15T00:00:00Z"); state.state = Some("armor_reinforce".to_owned()); state.state_timer_end = Some("2026-07-19T18:00:00+00:00".to_owned());
let alerts = run(&state);
assert_eq!(alerts.len(), 1); assert_eq!(alerts[0].severity, AlertSeverity::Critical); assert_eq!( alerts[0].dedup_key, "structure_reinforced:98000001:1035000000001:2026-07-19T18:00:00+00:00" ); assert_eq!(alerts[0].body, "Test Citadel"); }
#[test] fn it_bands_fuel_by_days_remaining() { let mut empty = StructureState::new(STRUCTURE, "2026-07-15T00:00:00Z"); empty.fuel_expires = Some("2026-07-14T00:00:00+00:00".to_owned()); let mut low = StructureState::new(STRUCTURE, "2026-07-15T00:00:00Z"); low.fuel_expires = Some("2026-07-16T00:00:00+00:00".to_owned()); let mut expiring = StructureState::new(STRUCTURE, "2026-07-15T00:00:00Z"); expiring.fuel_expires = Some("2026-07-18T00:00:00+00:00".to_owned()); let mut healthy = StructureState::new(STRUCTURE, "2026-07-15T00:00:00Z"); healthy.fuel_expires = Some("2026-08-15T00:00:00+00:00".to_owned());
assert_eq!(run(&empty)[0].severity, AlertSeverity::Critical); assert_eq!(run(&low)[0].severity, AlertSeverity::Warning); assert_eq!(run(&expiring)[0].severity, AlertSeverity::Info); assert!(run(&healthy).is_empty()); }
#[test] fn it_raises_a_warning_for_each_offline_service() { let mut state = StructureState::new(STRUCTURE, "2026-07-15T00:00:00Z"); state.set_service_list(&[ StructureService { name: "Clone Bay".to_owned(), state: "online".to_owned(), }, StructureService { name: "Manufacturing".to_owned(), state: "offline".to_owned(), }, ]);
let alerts = run(&state);
assert_eq!(alerts.len(), 1); assert_eq!(alerts[0].severity, AlertSeverity::Warning); assert_eq!( alerts[0].dedup_key, "structure_service:98000001:1035000000001:Manufacturing" ); }
#[test] fn it_raises_info_for_anchoring_and_unanchoring() { let mut anchoring = StructureState::new(STRUCTURE, "2026-07-15T00:00:00Z"); anchoring.state = Some("anchoring".to_owned()); let mut unanchoring = StructureState::new(STRUCTURE, "2026-07-15T00:00:00Z"); unanchoring.unanchors_at = Some("2026-08-01T00:00:00+00:00".to_owned());
let anchor_alerts = run(&anchoring); let unanchor_alerts = run(&unanchoring);
assert_eq!(anchor_alerts.len(), 1); assert_eq!(anchor_alerts[0].severity, AlertSeverity::Info); assert_eq!(unanchor_alerts.len(), 1); assert_eq!(unanchor_alerts[0].severity, AlertSeverity::Info); assert_eq!( unanchor_alerts[0].dedup_key, "structure_anchor:98000001:1035000000001:unanchoring:2026-08-01T00:00:00+00:00" ); }
#[test] fn it_stays_quiet_for_a_healthy_structure() { let mut state = StructureState::new(STRUCTURE, "2026-07-15T00:00:00Z"); state.state = Some("shield_vulnerable".to_owned()); state.fuel_expires = Some("2026-08-15T00:00:00+00:00".to_owned()); state.set_service_list(&[StructureService { name: "Clone Bay".to_owned(), state: "online".to_owned(), }]);
assert!(run(&state).is_empty()); } }
mod emit_structure_alert { use pretty_assertions::assert_eq;
use super::*; use crate::store;
const CORP: i64 = 98_000_001;
fn alert(severity: AlertSeverity, dedup_key: &str) -> StructureAlert { StructureAlert { body: "Test Citadel".to_owned(), dedup_key: dedup_key.to_owned(), severity, structure_id: 1001, title: "Structure".to_owned(), } }
#[tokio::test] async fn it_toasts_warning_and_critical_but_not_info() { let db = store::open_test().await.unwrap(); let owner = NotificationOwner::Corporation(CORP); let mut surfaced = Vec::new();
emit_structure_alert( &db, owner, NotificationKind::StructureAlert, CORP, &alert(AlertSeverity::Warning, "structure_fuel:1"), &mut surfaced, ) .await .unwrap(); emit_structure_alert( &db, owner, NotificationKind::StructureAlert, CORP, &alert(AlertSeverity::Info, "structure_anchor:1"), &mut surfaced, ) .await .unwrap();
assert_eq!(surfaced.len(), 1, "only the warning is returned for toasting"); assert_eq!(surfaced[0].dedup_key(), "structure_fuel:1"); assert_eq!( notifications::list(&db, 50).await.unwrap().len(), 2, "both the warning and the info land in the bell" ); } }
mod customs_office_detector { use pretty_assertions::assert_eq;
use super::*; use crate::store::{self, model::CustomsOffice};
const CORP: i64 = 98_000_001;
const OFFICE: i64 = 1_026_000_000_001;
const SYSTEM: i64 = 30_000_001;
async fn seed_refs(db: &Database) { sqlx::query("INSERT INTO regions (id, name) VALUES (10000001, 'Region')") .execute(db.writer()) .await .unwrap(); sqlx::query( "INSERT INTO constellations (id, region_id, name, position_x, position_y, position_z) \ VALUES (20000001, 10000001, 'Constellation', 0, 0, 0)", ) .execute(db.writer()) .await .unwrap(); sqlx::query( "INSERT INTO solar_systems (id, constellation_id, name, position_x, position_y, position_z, security_status) \ VALUES (?, 20000001, 'System', 0, 0, 0, 0.5)", ) .bind(SYSTEM) .execute(db.writer()) .await .unwrap(); seed_corporation(db, CORP).await; }
fn office(office_id: i64) -> CustomsOffice { CustomsOffice { alliance_tax_rate: None, allow_access_with_standings: true, allow_alliance_access: false, bad_standing_tax_rate: None, corporation_id: CORP, corporation_tax_rate: Some(0.05), excellent_standing_tax_rate: None, good_standing_tax_rate: None, neutral_standing_tax_rate: None, office_id, planet_id: Some(40_000_001), reinforce_exit_end: 22, reinforce_exit_start: 18, standing_level: "neutral".to_owned(), synced_at: "2026-07-15T00:00:00Z".to_owned(), system_id: SYSTEM, terrible_standing_tax_rate: None, } }
#[tokio::test] async fn it_watermarks_existing_offices_on_the_first_scan() { let db = store::open_test().await.unwrap(); seed_refs(&db).await; customs_office::upsert(&db, &office(OFFICE)).await.unwrap();
let surfaced = customs_office_detector(&db, CORP, &FeatureFlags::default()) .await .unwrap();
assert!(surfaced.is_empty()); assert!(notifications::list(&db, 50).await.unwrap().is_empty()); }
#[tokio::test] async fn it_lands_a_new_office_in_the_bell_without_toasting() { let db = store::open_test().await.unwrap(); seed_refs(&db).await; customs_office::upsert(&db, &office(OFFICE)).await.unwrap(); customs_office_detector(&db, CORP, &FeatureFlags::default()) .await .unwrap(); customs_office::upsert(&db, &office(OFFICE + 1)).await.unwrap();
let surfaced = customs_office_detector(&db, CORP, &FeatureFlags::default()) .await .unwrap();
assert!( surfaced.is_empty(), "a vulnerability-window heads-up is info, so it never toasts" ); let listed = notifications::list(&db, 50).await.unwrap(); assert_eq!(listed.len(), 1); assert_eq!(listed[0].kind(), NotificationKind::CustomsOfficeVulnerable); assert_eq!( listed[0].dedup_key(), &format!("customs_office_vulnerable:{CORP}:{}:18", OFFICE + 1) ); }
#[tokio::test] async fn it_does_not_run_when_structure_alerts_are_disabled() { let db = store::open_test().await.unwrap(); seed_refs(&db).await; customs_office::upsert(&db, &office(OFFICE)).await.unwrap(); let mut flags = FeatureFlags::default(); flags.set_sub_enabled(crate::config::SubFeature::StructureAlerts, false);
let surfaced = customs_office_detector(&db, CORP, &flags).await.unwrap();
assert!(surfaced.is_empty()); assert!(notifications::list(&db, 50).await.unwrap().is_empty()); } }
mod structure_notification_detector { use pretty_assertions::assert_eq;
use super::*; use crate::store::{self, model::CharacterNotification};
const CHARACTER: i64 = 95_465_499;
fn notification(notification_id: i64, notif_type: &str) -> CharacterNotification { CharacterNotification { character_id: CHARACTER, is_read: false, notif_type: notif_type.to_owned(), notification_id, sender_id: Some(1001), sender_type: Some("corporation".to_owned()), synced_at: "2026-07-15T00:00:00Z".to_owned(), text: Some("solarsystemID: 30002537\nstructureID: 1035408540807\n".to_owned()), timestamp: "2026-07-14T00:00:00Z".to_owned(), } }
#[tokio::test] async fn it_watermarks_existing_feed_events_on_the_first_scan() { let db = store::open_test().await.unwrap(); seed_character(&db, CHARACTER).await; character::upsert_notification(&db, ¬ification(1, "StructureUnderAttack")) .await .unwrap();
let surfaced = structure_notification_detector(&db, CHARACTER, &FeatureFlags::default()) .await .unwrap();
assert!( surfaced.is_empty(), "pre-existing feed events are watermarked, not surfaced" ); assert!(notifications::list(&db, 50).await.unwrap().is_empty()); }
#[tokio::test] async fn it_surfaces_a_new_attack_and_ignores_unrelated_types() { let db = store::open_test().await.unwrap(); seed_character(&db, CHARACTER).await; character::upsert_notification(&db, ¬ification(1, "StructureUnderAttack")) .await .unwrap(); structure_notification_detector(&db, CHARACTER, &FeatureFlags::default()) .await .unwrap(); character::upsert_notification(&db, ¬ification(2, "StructureDestroyed")) .await .unwrap(); character::upsert_notification(&db, ¬ification(3, "WarDeclared")) .await .unwrap();
let surfaced = structure_notification_detector(&db, CHARACTER, &FeatureFlags::default()) .await .unwrap();
assert_eq!(surfaced.len(), 1, "only the structure event surfaces"); assert_eq!(surfaced[0].dedup_key(), "structure_notif:2"); assert_eq!(surfaced[0].kind(), NotificationKind::StructureAttack); assert_eq!(notifications::unread_count(&db).await.unwrap(), 1); }
#[tokio::test] async fn it_is_a_no_op_on_a_rerun_over_unchanged_data() { let db = store::open_test().await.unwrap(); seed_character(&db, CHARACTER).await; character::upsert_notification(&db, ¬ification(1, "StructureUnderAttack")) .await .unwrap(); structure_notification_detector(&db, CHARACTER, &FeatureFlags::default()) .await .unwrap(); character::upsert_notification(&db, ¬ification(2, "StructureLostShields")) .await .unwrap(); structure_notification_detector(&db, CHARACTER, &FeatureFlags::default()) .await .unwrap();
let rerun = structure_notification_detector(&db, CHARACTER, &FeatureFlags::default()) .await .unwrap();
assert!(rerun.is_empty()); assert_eq!(notifications::list(&db, 50).await.unwrap().len(), 1); } }
mod structure_state_detector { use pretty_assertions::assert_eq;
use super::*; use crate::store::{ self, model::{CorporationMemberRole, OwnerType, StructureService, StructureState}, repo::{infra, structure_state}, };
const CORP: i64 = 1;
const DIRECTOR: i64 = 7100;
const STRUCTURE: i64 = 1_035_000_000_001;
const SYSTEM: i64 = 30_000_001;
fn now() -> DateTime<Utc> { DateTime::parse_from_rfc3339("2026-07-15T00:00:00+00:00") .unwrap() .with_timezone(&Utc) }
async fn seed_accessible_structure(db: &Database) { seed_character(db, DIRECTOR).await; infra::upsert(db, DIRECTOR, OwnerType::Character, "tok", "rt", 9999, None, None) .await .unwrap(); infra::upsert( db, CORP, OwnerType::Corporation, "tok", "rt", 9999, Some(DIRECTOR), None, ) .await .unwrap(); org::replace_for_corporation( db, CORP, &[CorporationMemberRole::from((CORP, DIRECTOR, "Director".to_owned()))], ) .await .unwrap(); sqlx::query("INSERT INTO regions (id, name) VALUES (10000001, 'Region')") .execute(db.writer()) .await .unwrap(); sqlx::query( "INSERT INTO constellations (id, region_id, name, position_x, position_y, position_z) \ VALUES (20000001, 10000001, 'Constellation', 0, 0, 0)", ) .execute(db.writer()) .await .unwrap(); sqlx::query( "INSERT INTO solar_systems (id, constellation_id, name, position_x, position_y, position_z, security_status) \ VALUES (?, 20000001, 'System', 0, 0, 0, 0.5)", ) .bind(SYSTEM) .execute(db.writer()) .await .unwrap(); sqlx::query("INSERT INTO structures (id, solar_system_id, owner_id, name) VALUES (?, ?, ?, 'Cobalt Keep')") .bind(STRUCTURE) .bind(SYSTEM) .bind(CORP) .execute(db.writer()) .await .unwrap(); }
fn reinforced(timer_end: &str) -> StructureState { let mut state = StructureState::new(STRUCTURE, "2026-07-15T00:00:00Z"); state.state = Some("armor_reinforce".to_owned()); state.state_timer_end = Some(timer_end.to_owned()); state.set_service_list(&[StructureService { name: "Clone Bay".to_owned(), state: "online".to_owned(), }]); state }
#[tokio::test] async fn it_watermarks_existing_alerts_on_the_first_scan() { let db = store::open_test().await.unwrap(); seed_accessible_structure(&db).await; structure_state::upsert(&db, &reinforced("2026-07-19T18:00:00+00:00")) .await .unwrap();
let surfaced = structure_state_detector(&db, CORP, now(), &FeatureFlags::default()) .await .unwrap();
assert!( surfaced.is_empty(), "pre-existing structure alerts are watermarked, not surfaced" ); assert!(notifications::list(&db, 50).await.unwrap().is_empty()); }
#[tokio::test] async fn it_surfaces_a_new_reinforced_alert_after_the_first_scan() { let db = store::open_test().await.unwrap(); seed_accessible_structure(&db).await; structure_state::upsert(&db, &reinforced("2026-07-19T18:00:00+00:00")) .await .unwrap(); structure_state_detector(&db, CORP, now(), &FeatureFlags::default()) .await .unwrap(); structure_state::upsert(&db, &reinforced("2026-07-21T18:00:00+00:00")) .await .unwrap();
let surfaced = structure_state_detector(&db, CORP, now(), &FeatureFlags::default()) .await .unwrap();
assert_eq!(surfaced.len(), 1, "the fresh reinforcement timer surfaces once"); assert_eq!(surfaced[0].kind(), NotificationKind::StructureAlert); assert_eq!( surfaced[0].dedup_key(), "structure_reinforced:1:1035000000001:2026-07-21T18:00:00+00:00" ); }
#[tokio::test] async fn it_does_not_run_when_structure_alerts_are_disabled() { let db = store::open_test().await.unwrap(); seed_accessible_structure(&db).await; structure_state::upsert(&db, &reinforced("2026-07-19T18:00:00+00:00")) .await .unwrap(); let mut flags = FeatureFlags::default(); flags.set_sub_enabled(crate::config::SubFeature::StructureAlerts, false);
let surfaced = structure_state_detector(&db, CORP, now(), &flags).await.unwrap();
assert!(surfaced.is_empty()); assert!(notifications::list(&db, 50).await.unwrap().is_empty()); } }}