1use crate::signing::LabelSigner;
4use crate::store::LabelStore;
5use crate::types::{Label, LabelValue, SignedLabel};
6use sqlx::SqlitePool;
7
8pub struct LabelService {
10 store: LabelStore,
12 signer: LabelSigner,
14 labeler_did: String,
16}
17
18impl LabelService {
19 pub fn new(db: SqlitePool, signer: LabelSigner, labeler_did: String) -> Self {
21 Self {
22 store: LabelStore::new(db),
23 signer,
24 labeler_did,
25 }
26 }
27
28 pub async fn migrate(&self) -> anyhow::Result<()> {
30 self.store.migrate().await
31 }
32
33 pub async fn create_label(
37 &self,
38 subject_uri: &str,
39 value: LabelValue,
40 subject_cid: Option<&str>,
41 ) -> anyhow::Result<SignedLabel> {
42 let label = Label {
43 ver: 1,
44 src: self.labeler_did.clone(),
45 uri: subject_uri.to_string(),
46 cid: subject_cid.map(|s| s.to_string()),
47 val: value.to_string(),
48 neg: false,
49 cts: now_iso8601(),
50 exp: None,
51 };
52
53 let sig = self.signer.sign(&label)?;
54 let signed = SignedLabel { label, sig };
55
56 self.store.insert(&signed).await?;
57 Ok(signed)
58 }
59
60 pub async fn negate_label(
65 &self,
66 subject_uri: &str,
67 value: LabelValue,
68 subject_cid: Option<&str>,
69 ) -> anyhow::Result<SignedLabel> {
70 let label = Label {
71 ver: 1,
72 src: self.labeler_did.clone(),
73 uri: subject_uri.to_string(),
74 cid: subject_cid.map(|s| s.to_string()),
75 val: value.to_string(),
76 neg: true,
77 cts: now_iso8601(),
78 exp: None,
79 };
80
81 let sig = self.signer.sign(&label)?;
82 let signed = SignedLabel { label, sig };
83
84 self.store.insert(&signed).await?;
85 Ok(signed)
86 }
87
88 pub async fn query_labels(&self, uri: &str) -> anyhow::Result<Vec<SignedLabel>> {
90 self.store.query_by_uri(uri).await
91 }
92
93 pub async fn query_since(
98 &self,
99 cursor: i64,
100 limit: i64,
101 ) -> anyhow::Result<Vec<(i64, SignedLabel)>> {
102 self.store.query_since(cursor, limit).await
103 }
104}
105
106fn now_iso8601() -> String {
113 let now = std::time::SystemTime::now()
114 .duration_since(std::time::UNIX_EPOCH)
115 .unwrap_or_default();
116 format!("{}Z", now.as_secs())
117}
118
119#[cfg(test)]
120mod tests {
121 use super::*;
122 use crate::signing::LabelSigner;
123 use sqlx::SqlitePool;
124
125 fn test_signer() -> LabelSigner {
126 LabelSigner::new(b"test-key".to_vec())
127 }
128
129 async fn setup_service() -> LabelService {
130 let db = SqlitePool::connect("sqlite::memory:").await.unwrap();
131 let svc = LabelService::new(db, test_signer(), "did:plc:test-labeler".to_string());
132 svc.migrate().await.unwrap();
133 svc
134 }
135
136 #[test]
137 fn now_iso8601_produces_nonempty_string() {
138 let ts = now_iso8601();
139 assert!(!ts.is_empty());
140 assert!(ts.ends_with('Z'));
141 }
142
143 #[tokio::test]
144 async fn test_new_and_migrate() {
145 let db = SqlitePool::connect("sqlite::memory:").await.unwrap();
146 let svc = LabelService::new(db, test_signer(), "did:plc:labeler".to_string());
147 let result = svc.migrate().await;
148 assert!(result.is_ok());
149 }
150
151 #[tokio::test]
152 async fn test_create_label() {
153 let svc = setup_service().await;
154 let uri = "at://did:plc:user/app.bsky.feed.post/abc";
155
156 let signed = svc.create_label(uri, LabelValue::Spam, None).await.unwrap();
157
158 assert_eq!(signed.label.src, "did:plc:test-labeler");
159 assert_eq!(signed.label.uri, uri);
160 assert_eq!(signed.label.val, "spam");
161 assert!(!signed.label.neg);
162 assert!(!signed.sig.is_empty());
163
164 let labels = svc.query_labels(uri).await.unwrap();
166 assert_eq!(labels.len(), 1);
167 assert_eq!(labels[0].label.val, "spam");
168 assert_eq!(labels[0].label.src, "did:plc:test-labeler");
169 }
170
171 #[tokio::test]
172 async fn test_negate_label() {
173 let svc = setup_service().await;
174 let uri = "at://did:plc:user/app.bsky.feed.post/abc";
175
176 let negated = svc.negate_label(uri, LabelValue::Porn, None).await.unwrap();
177
178 assert!(negated.label.neg);
179 assert_eq!(negated.label.val, "porn");
180 assert!(!negated.sig.is_empty());
181
182 let labels = svc.query_labels(uri).await.unwrap();
183 assert_eq!(labels.len(), 1);
184 assert!(labels[0].label.neg);
185 }
186
187 #[tokio::test]
188 async fn test_create_and_query_multiple() {
189 let svc = setup_service().await;
190 let uri_a = "at://did:plc:user/post/a";
191 let uri_b = "at://did:plc:user/post/b";
192
193 svc.create_label(uri_a, LabelValue::Spam, None)
194 .await
195 .unwrap();
196 svc.create_label(uri_a, LabelValue::Nudity, None)
197 .await
198 .unwrap();
199 svc.create_label(uri_b, LabelValue::Impersonation, None)
200 .await
201 .unwrap();
202
203 let labels_a = svc.query_labels(uri_a).await.unwrap();
204 assert_eq!(labels_a.len(), 2);
205 assert_eq!(labels_a[0].label.val, "spam");
206 assert_eq!(labels_a[1].label.val, "nudity");
207
208 let labels_b = svc.query_labels(uri_b).await.unwrap();
209 assert_eq!(labels_b.len(), 1);
210 assert_eq!(labels_b[0].label.val, "impersonation");
211 }
212
213 #[tokio::test]
214 async fn test_query_since_ordering() {
215 let svc = setup_service().await;
216 let uri = "at://did:plc:user/post/1";
217
218 svc.create_label(uri, LabelValue::Spam, None).await.unwrap();
219 svc.create_label(uri, LabelValue::Porn, None).await.unwrap();
220 svc.create_label(uri, LabelValue::Custom("custom".into()), None)
221 .await
222 .unwrap();
223
224 let results = svc.query_since(0, 10).await.unwrap();
225 assert_eq!(results.len(), 3);
226
227 assert!(results[0].0 < results[1].0);
229 assert!(results[1].0 < results[2].0);
230
231 assert_eq!(results[0].1.label.val, "spam");
233 assert_eq!(results[1].1.label.val, "porn");
234 assert_eq!(results[2].1.label.val, "custom");
235 }
236}