Skip to main content

max / makenotwork

25.9 KB · 748 lines History Blame Raw
1 //! DB-layer contract tests for `db::synckit::blobs`, the SyncKit blob rows and
2 //! the storage accounting hung off them.
3 //!
4 //! `db_synckit_layer` touches two blob cases in passing (owner scoping and the
5 //! internal re-confirm). What had no test anywhere is the arithmetic those rows
6 //! feed, which is where being wrong costs a developer money or hands a user
7 //! free storage:
8 //!
9 //! - `storage_used_bytes` sums the authoritative rows rather than a counter,
10 //! - `confirm_internal_blob` compares `used + size` against the subscription
11 //! cap with `>`, so a blob landing exactly on the cap is allowed and one
12 //! byte more is refused, and a NULL cap fails closed at zero,
13 //! - `confirm_developer_blob` enforces the bulk app-wide cap, the per-key cap,
14 //! and the defensive app aggregate that catches a key which is itself empty,
15 //! - both confirm paths are idempotent under redelivery: a repeated confirm
16 //! of the same `(app, user, hash)` returns `AlreadyStored` and moves no
17 //! counter, because an upload retry must not eat the cap twice,
18 //! - `delete_sync_blob` is the only shrink path: it refunds the app-wide and
19 //! per-key counters by exactly the blob's size, dead-letters the S3 object
20 //! in the same transaction, and a second delete is a `NotFound` no-op that
21 //! refunds nothing a second time,
22 //! - `count_sync_devices` counts one `(app, user)` pair and no other.
23 //!
24 //! Delete this file and the caps stop being tested at their boundaries: an
25 //! off-by-one in either direction, a double refund on a repeated delete, and a
26 //! redelivered confirm charging twice all pass silently.
27
28 use crate::harness::db::TestDb;
29 use crate::harness::seed_user;
30
31 use makenotwork::db::synckit;
32 use makenotwork::db::synckit::{BlobConfirm, BlobDelete};
33 use makenotwork::db::{SyncAppId, SyncEnforcementMode, UserId};
34
35 /// One gibibyte. Written out rather than read back from
36 /// `synckit_billing::storage_cap_bytes`, so the boundary cases below compare
37 /// against an independent number instead of re-deriving the one under test.
38 const GIB: i64 = 1024 * 1024 * 1024;
39
40 /// Seed a sync app owned by `user`, with the `sync_app_usage_current` row the
41 /// developer-billing path locks `FOR UPDATE`.
42 async fn seed_app(pool: &sqlx::PgPool, user: UserId, name: &str) -> SyncAppId {
43 synckit::create_sync_app(pool, user, name, &format!("key_{name}_padding"), None, None)
44 .await
45 .expect("seed sync app")
46 .id
47 }
48
49 /// Mark an app first-party, so the end-user subscription model applies.
50 async fn make_internal(pool: &sqlx::PgPool, app: SyncAppId) {
51 sqlx::query("UPDATE sync_apps SET is_internal = true WHERE id = $1")
52 .bind(app)
53 .execute(pool)
54 .await
55 .expect("mark internal");
56 }
57
58 /// Give `user` an active end-user subscription on `app` capped at `limit_bytes`.
59 async fn seed_subscription(
60 pool: &sqlx::PgPool,
61 user: UserId,
62 app: SyncAppId,
63 sub_id: &str,
64 limit_bytes: Option<i64>,
65 ) {
66 sqlx::query(
67 "INSERT INTO app_sync_subscriptions
68 (user_id, app_id, stripe_subscription_id, stripe_customer_id, tier,
69 status, storage_limit_bytes)
70 VALUES ($1, $2, $3, 'cus_test', 'monthly', 'active', $4)",
71 )
72 .bind(user)
73 .bind(app)
74 .bind(sub_id)
75 .bind(limit_bytes)
76 .execute(pool)
77 .await
78 .expect("seed subscription");
79 }
80
81 /// The app-wide running counter the developer-billing path maintains.
82 async fn app_bytes(pool: &sqlx::PgPool, app: SyncAppId) -> i64 {
83 sqlx::query_scalar::<_, i64>(
84 "SELECT bytes_stored FROM sync_app_usage_current WHERE app_id = $1",
85 )
86 .bind(app)
87 .fetch_one(pool)
88 .await
89 .expect("read app bytes_stored")
90 }
91
92 /// The per-key running counter, or `None` when the key has no row yet.
93 async fn key_bytes(pool: &sqlx::PgPool, app: SyncAppId, key: &str) -> Option<i64> {
94 sqlx::query_scalar::<_, i64>(
95 "SELECT bytes_stored FROM sync_key_usage_current WHERE app_id = $1 AND key = $2",
96 )
97 .bind(app)
98 .bind(key)
99 .fetch_optional(pool)
100 .await
101 .expect("read key bytes_stored")
102 }
103
104 /// Count of blob rows for one `(app, user)`.
105 async fn blob_rows(pool: &sqlx::PgPool, app: SyncAppId, user: UserId) -> i64 {
106 sqlx::query_scalar::<_, i64>(
107 "SELECT COUNT(*) FROM sync_blobs WHERE app_id = $1 AND user_id = $2",
108 )
109 .bind(app)
110 .bind(user)
111 .fetch_one(pool)
112 .await
113 .expect("count blob rows")
114 }
115
116 /// Every dead-lettered S3 key queued so far, with its bucket and source.
117 async fn pending_deletions(pool: &sqlx::PgPool) -> Vec<(String, String, String)> {
118 sqlx::query_as::<_, (String, String, String)>(
119 "SELECT s3_key, bucket, source FROM pending_s3_deletions ORDER BY s3_key",
120 )
121 .fetch_all(pool)
122 .await
123 .expect("read pending deletions")
124 }
125
126 /// Confirm a blob on a bulk-billed developer app with the given GB cap.
127 #[allow(clippy::too_many_arguments)]
128 async fn confirm_bulk(
129 pool: &sqlx::PgPool,
130 app: SyncAppId,
131 user: UserId,
132 hash: &str,
133 size: i64,
134 key: &str,
135 gb_cap: i32,
136 ) -> BlobConfirm {
137 synckit::confirm_developer_blob(
138 pool,
139 app,
140 user,
141 hash,
142 size,
143 &format!("s3/{hash}"),
144 key,
145 SyncEnforcementMode::Bulk,
146 Some(gb_cap),
147 None,
148 None,
149 )
150 .await
151 .expect("bulk confirm")
152 }
153
154 /// Confirm a blob on a per-key-billed developer app: `key_cap` keys, `gb_per_key`
155 /// GB each.
156 #[allow(clippy::too_many_arguments)]
157 async fn confirm_per_key(
158 pool: &sqlx::PgPool,
159 app: SyncAppId,
160 user: UserId,
161 hash: &str,
162 size: i64,
163 key: &str,
164 key_cap: i32,
165 gb_per_key: i32,
166 ) -> BlobConfirm {
167 synckit::confirm_developer_blob(
168 pool,
169 app,
170 user,
171 hash,
172 size,
173 &format!("s3/{hash}"),
174 key,
175 SyncEnforcementMode::PerKey,
176 None,
177 Some(key_cap),
178 Some(gb_per_key),
179 )
180 .await
181 .expect("per-key confirm")
182 }
183
184 // ── storage_used_bytes ──────────────────────────────────────────────────────
185
186 /// Three distinct, non-round sizes, so a sum is distinguishable from a count, a
187 /// max, or the size of the first row.
188 #[tokio::test]
189 async fn storage_used_bytes_sums_every_blob_of_one_user_and_no_one_elses() {
190 let db = TestDb::new().await;
191 let alice = seed_user(&db.pool, "skblobs_sum_alice").await;
192 let bob = seed_user(&db.pool, "skblobs_sum_bob").await;
193 let app = seed_app(&db.pool, alice, "blobsum").await;
194 make_internal(&db.pool, app).await;
195 seed_subscription(&db.pool, alice, app, "sub_sum_alice", Some(100_000)).await;
196 seed_subscription(&db.pool, bob, app, "sub_sum_bob", Some(100_000)).await;
197
198 for (hash, size) in [("h-a", 3000), ("h-b", 700), ("h-c", 41)] {
199 let out =
200 synckit::confirm_internal_blob(&db.pool, app, alice, hash, size, "s3/a", "default")
201 .await
202 .expect("confirm");
203 assert_eq!(out, BlobConfirm::Stored, "seeding {hash} must store");
204 }
205 let out = synckit::confirm_internal_blob(&db.pool, app, bob, "h-d", 500, "s3/d", "default")
206 .await
207 .expect("confirm");
208 assert_eq!(out, BlobConfirm::Stored, "bob's own blob stores");
209
210 assert_eq!(
211 synckit::storage_used_bytes(&db.pool, app, alice)
212 .await
213 .expect("alice usage"),
214 3741,
215 "3000 + 700 + 41: the sum of the rows, not their count or their max"
216 );
217 assert_eq!(
218 synckit::storage_used_bytes(&db.pool, app, bob)
219 .await
220 .expect("bob usage"),
221 500,
222 "bob is charged for his row alone"
223 );
224 }
225
226 // ── confirm_internal_blob: the end-user cap ─────────────────────────────────
227
228 /// The gate is `used + size > limit`, so exactly-on-the-cap is allowed and one
229 /// byte past it is refused. Both sides are asserted: a test that only checked
230 /// the refusal could not tell `>` from `>=`.
231 #[tokio::test]
232 async fn an_internal_blob_landing_exactly_on_the_cap_is_stored_and_one_byte_more_is_refused() {
233 let db = TestDb::new().await;
234 let user = seed_user(&db.pool, "skblobs_cap").await;
235 let app = seed_app(&db.pool, user, "blobcap").await;
236 make_internal(&db.pool, app).await;
237 seed_subscription(&db.pool, user, app, "sub_cap", Some(5000)).await;
238
239 let first = synckit::confirm_internal_blob(&db.pool, app, user, "h-1", 3000, "s3/1", "default")
240 .await
241 .expect("first confirm");
242 assert_eq!(first, BlobConfirm::Stored, "3000 of a 5000 cap fits");
243
244 // 3000 + 2000 == 5000: the boundary itself, which must be allowed.
245 let exact = synckit::confirm_internal_blob(&db.pool, app, user, "h-2", 2000, "s3/2", "default")
246 .await
247 .expect("boundary confirm");
248 assert_eq!(
249 exact,
250 BlobConfirm::Stored,
251 "a blob that fills the cap exactly is within it"
252 );
253
254 // One byte past it is not.
255 let over = synckit::confirm_internal_blob(&db.pool, app, user, "h-3", 1, "s3/3", "default")
256 .await
257 .expect("over-cap confirm");
258 assert_eq!(
259 over,
260 BlobConfirm::QuotaExceeded {
261 dimension: "storage",
262 used: 5000,
263 limit: 5000,
264 key: None,
265 },
266 "one byte past a full cap is refused, and the reason names the real numbers"
267 );
268
269 assert!(
270 synckit::get_sync_blob_by_hash(&db.pool, app, user, "h-3")
271 .await
272 .expect("lookup")
273 .is_none(),
274 "a refused confirm writes no row"
275 );
276 assert_eq!(
277 synckit::storage_used_bytes(&db.pool, app, user)
278 .await
279 .expect("usage"),
280 5000,
281 "and charges nothing"
282 );
283 }
284
285 /// A NULL `storage_limit_bytes` becomes a zero cap (`unwrap_or(0)`), so the path
286 /// fails closed rather than treating "no cap recorded" as unlimited.
287 #[tokio::test]
288 async fn an_internal_blob_is_refused_when_the_subscription_records_no_cap() {
289 let db = TestDb::new().await;
290 let user = seed_user(&db.pool, "skblobs_nullcap").await;
291 let app = seed_app(&db.pool, user, "blobnullcap").await;
292 make_internal(&db.pool, app).await;
293 seed_subscription(&db.pool, user, app, "sub_nullcap", None).await;
294
295 let out = synckit::confirm_internal_blob(&db.pool, app, user, "h-n", 4096, "s3/n", "default")
296 .await
297 .expect("confirm");
298 assert_eq!(
299 out,
300 BlobConfirm::QuotaExceeded {
301 dimension: "storage",
302 used: 0,
303 limit: 0,
304 key: None,
305 },
306 "a missing cap is zero, not infinity"
307 );
308 assert_eq!(
309 blob_rows(&db.pool, app, user).await,
310 0,
311 "nothing was written"
312 );
313 }
314
315 /// Paid-only: no subscription row at all, and a row that is not `active`, are
316 /// both `NoSubscription`, and neither writes.
317 #[tokio::test]
318 async fn an_internal_blob_needs_an_active_subscription_to_be_stored() {
319 let db = TestDb::new().await;
320 let user = seed_user(&db.pool, "skblobs_nosub").await;
321 let app = seed_app(&db.pool, user, "blobnosub").await;
322 make_internal(&db.pool, app).await;
323
324 let missing =
325 synckit::confirm_internal_blob(&db.pool, app, user, "h-m", 2048, "s3/m", "default")
326 .await
327 .expect("confirm without a subscription");
328 assert_eq!(
329 missing,
330 BlobConfirm::NoSubscription,
331 "no subscription row means no write"
332 );
333
334 seed_subscription(&db.pool, user, app, "sub_nosub", Some(100_000)).await;
335 synckit::update_app_sync_subscription_status(&db.pool, "sub_nosub", "canceled", None)
336 .await
337 .expect("cancel");
338
339 let canceled =
340 synckit::confirm_internal_blob(&db.pool, app, user, "h-m", 2048, "s3/m", "default")
341 .await
342 .expect("confirm on a canceled subscription");
343 assert_eq!(
344 canceled,
345 BlobConfirm::NoSubscription,
346 "a canceled subscription closes writes even though the row exists"
347 );
348 assert_eq!(
349 blob_rows(&db.pool, app, user).await,
350 0,
351 "neither refusal wrote a row"
352 );
353 }
354
355 // ── confirm_developer_blob: bulk mode ───────────────────────────────────────
356
357 /// Bulk mode caps the app as a whole. Same boundary pair as the internal path,
358 /// against a cap expressed in GB, plus the two counters the path maintains.
359 #[tokio::test]
360 async fn bulk_mode_fills_the_app_cap_exactly_then_refuses_the_next_byte() {
361 let db = TestDb::new().await;
362 let dev = seed_user(&db.pool, "skblobs_bulk_dev").await;
363 let user = seed_user(&db.pool, "skblobs_bulk_user").await;
364 let app = seed_app(&db.pool, dev, "blobbulk").await;
365 let cap = 2 * GIB;
366
367 // Two blobs on two different keys, so the app-wide counter is visibly the
368 // sum of the keys rather than either one of them.
369 let first = confirm_bulk(&db.pool, app, user, "b-1", cap - 500, "alpha", 2).await;
370 assert_eq!(first, BlobConfirm::Stored, "the first blob fits under 2 GB");
371
372 let exact = confirm_bulk(&db.pool, app, user, "b-2", 500, "beta", 2).await;
373 assert_eq!(
374 exact,
375 BlobConfirm::Stored,
376 "the blob that fills the cap exactly is within it"
377 );
378
379 let over = confirm_bulk(&db.pool, app, user, "b-3", 1, "beta", 2).await;
380 assert_eq!(
381 over,
382 BlobConfirm::QuotaExceeded {
383 dimension: "storage",
384 used: cap,
385 limit: cap,
386 key: None,
387 },
388 "bulk mode reports the app dimension with no key attached"
389 );
390
391 assert_eq!(
392 app_bytes(&db.pool, app).await,
393 cap,
394 "the app counter is the sum of both blobs"
395 );
396 assert_eq!(
397 key_bytes(&db.pool, app, "alpha").await,
398 Some(cap - 500),
399 "each key is charged its own blob"
400 );
401 assert_eq!(key_bytes(&db.pool, app, "beta").await, Some(500));
402 assert_eq!(
403 blob_rows(&db.pool, app, user).await,
404 2,
405 "the refused confirm wrote no third row"
406 );
407 }
408
409 /// Stripe-style redelivery: the same confirm arriving twice must be a no-op the
410 /// second time, not a second charge against the cap.
411 #[tokio::test]
412 async fn a_redelivered_developer_confirm_is_a_no_op_and_charges_nothing_twice() {
413 let db = TestDb::new().await;
414 let dev = seed_user(&db.pool, "skblobs_replay_dev").await;
415 let user = seed_user(&db.pool, "skblobs_replay_user").await;
416 let app = seed_app(&db.pool, dev, "blobreplay").await;
417
418 let first = confirm_bulk(&db.pool, app, user, "r-1", 7000, "alpha", 10).await;
419 assert_eq!(first, BlobConfirm::Stored);
420
421 // The identical delivery again, and a third that claims a different size and
422 // key: the hash is the identity, so both are refused a second charge.
423 let second = confirm_bulk(&db.pool, app, user, "r-1", 7000, "alpha", 10).await;
424 assert_eq!(
425 second,
426 BlobConfirm::AlreadyStored,
427 "a redelivery of the same (app, user, hash) is idempotent"
428 );
429 let third = confirm_bulk(&db.pool, app, user, "r-1", 3300, "beta", 10).await;
430 assert_eq!(
431 third,
432 BlobConfirm::AlreadyStored,
433 "the hash decides, so a redelivery carrying different metadata is still a no-op"
434 );
435
436 assert_eq!(
437 app_bytes(&db.pool, app).await,
438 7000,
439 "the app counter moved once, not three times"
440 );
441 assert_eq!(
442 key_bytes(&db.pool, app, "alpha").await,
443 Some(7000),
444 "and the key counter with it"
445 );
446 assert_eq!(
447 key_bytes(&db.pool, app, "beta").await,
448 None,
449 "the redelivery's other key was never charged"
450 );
451 assert_eq!(blob_rows(&db.pool, app, user).await, 1);
452 }
453
454 // ── confirm_developer_blob: per-key mode ────────────────────────────────────
455
456 /// Per-key mode caps each key at `gb_per_key`. A key that fills its own cap is
457 /// refused while a different key keeps working, which is the whole point of the
458 /// mode: one full key must not degrade the app.
459 #[tokio::test]
460 async fn per_key_mode_refuses_the_key_that_is_full_and_leaves_the_others_writable() {
461 let db = TestDb::new().await;
462 let dev = seed_user(&db.pool, "skblobs_pk_dev").await;
463 let user = seed_user(&db.pool, "skblobs_pk_user").await;
464 let app = seed_app(&db.pool, dev, "blobperkey").await;
465 // 3 keys x 1 GB: per-key limit 1 GiB, app aggregate 3 GiB.
466 let (key_cap, gb) = (3, 1);
467
468 let first = confirm_per_key(&db.pool, app, user, "p-1", GIB - 700, "alpha", key_cap, gb).await;
469 assert_eq!(first, BlobConfirm::Stored, "under alpha's 1 GB");
470
471 let exact = confirm_per_key(&db.pool, app, user, "p-2", 700, "alpha", key_cap, gb).await;
472 assert_eq!(
473 exact,
474 BlobConfirm::Stored,
475 "a blob filling the key cap exactly is within it"
476 );
477
478 let over = confirm_per_key(&db.pool, app, user, "p-3", 1, "alpha", key_cap, gb).await;
479 assert_eq!(
480 over,
481 BlobConfirm::QuotaExceeded {
482 dimension: "storage_per_key",
483 used: GIB,
484 limit: GIB,
485 key: Some("alpha".to_string()),
486 },
487 "the refusal names the key it applies to, so the caller can tell the developer which one"
488 );
489
490 // The app aggregate still has 2 GiB free, so another key writes fine.
491 let other = confirm_per_key(&db.pool, app, user, "p-4", 4096, "beta", key_cap, gb).await;
492 assert_eq!(
493 other,
494 BlobConfirm::Stored,
495 "a full key must not close the whole app"
496 );
497
498 assert_eq!(key_bytes(&db.pool, app, "alpha").await, Some(GIB));
499 assert_eq!(key_bytes(&db.pool, app, "beta").await, Some(4096));
500 assert_eq!(
501 app_bytes(&db.pool, app).await,
502 GIB + 4096,
503 "the app counter is the sum across keys"
504 );
505 }
506
507 /// The defensive app-aggregate ceiling: a key with nothing stored still cannot
508 /// write once the app total is at `key_cap * gb_per_key`. Asserted on an empty
509 /// key so it cannot be confused with the per-key check, which would pass here.
510 #[tokio::test]
511 async fn per_key_mode_stops_an_empty_key_once_the_app_aggregate_is_full() {
512 let db = TestDb::new().await;
513 let dev = seed_user(&db.pool, "skblobs_agg_dev").await;
514 let user = seed_user(&db.pool, "skblobs_agg_user").await;
515 let app = seed_app(&db.pool, dev, "blobaggregate").await;
516 // 2 keys x 1 GB: app aggregate 2 GiB.
517 let (key_cap, gb) = (2, 1);
518
519 for (hash, key) in [("a-1", "alpha"), ("a-2", "beta")] {
520 let out = confirm_per_key(&db.pool, app, user, hash, GIB, key, key_cap, gb).await;
521 assert_eq!(out, BlobConfirm::Stored, "{key} fills its own 1 GB exactly");
522 }
523 assert_eq!(app_bytes(&db.pool, app).await, 2 * GIB);
524
525 // gamma has stored nothing, so the per-key check (0 + 1 <= 1 GiB) passes and
526 // only the aggregate ceiling can refuse this.
527 let refused = confirm_per_key(&db.pool, app, user, "a-3", 1, "gamma", key_cap, gb).await;
528 assert_eq!(
529 refused,
530 BlobConfirm::QuotaExceeded {
531 dimension: "storage",
532 used: 2 * GIB,
533 limit: 2 * GIB,
534 key: None,
535 },
536 "the aggregate ceiling refuses an empty key, reporting the app dimension"
537 );
538 assert_eq!(
539 key_bytes(&db.pool, app, "gamma").await,
540 None,
541 "and left no counter row behind for it"
542 );
543 assert_eq!(blob_rows(&db.pool, app, user).await, 2);
544 }
545
546 // ── delete_sync_blob ────────────────────────────────────────────────────────
547
548 /// Delete refunds both counters by exactly the deleted blob's size, dead-letters
549 /// its object, and a repeat delete refunds nothing further. The two blobs carry
550 /// different sizes so a refund of the wrong one is visible.
551 #[tokio::test]
552 async fn deleting_a_blob_refunds_its_own_size_once_and_dead_letters_its_object() {
553 let db = TestDb::new().await;
554 let dev = seed_user(&db.pool, "skblobs_del_dev").await;
555 let user = seed_user(&db.pool, "skblobs_del_user").await;
556 let app = seed_app(&db.pool, dev, "blobdelete").await;
557
558 confirm_bulk(&db.pool, app, user, "d-1", 7000, "alpha", 10).await;
559 confirm_bulk(&db.pool, app, user, "d-2", 3300, "alpha", 10).await;
560 assert_eq!(app_bytes(&db.pool, app).await, 10_300);
561
562 let deleted = synckit::delete_sync_blob(&db.pool, app, user, "d-1")
563 .await
564 .expect("delete");
565 assert_eq!(
566 deleted,
567 BlobDelete::Deleted { size_bytes: 7000 },
568 "the delete reports the size it freed"
569 );
570 assert_eq!(
571 app_bytes(&db.pool, app).await,
572 3300,
573 "the app counter is reduced by 7000, not zeroed and not reduced by the other blob"
574 );
575 assert_eq!(
576 key_bytes(&db.pool, app, "alpha").await,
577 Some(3300),
578 "the key counter tracks it"
579 );
580
581 let queued = pending_deletions(&db.pool).await;
582 assert_eq!(
583 queued,
584 vec![(
585 "s3/d-1".to_string(),
586 "synckit".to_string(),
587 "synckit_blob_delete".to_string()
588 )],
589 "the object is dead-lettered into the synckit bucket by the same transaction"
590 );
591
592 // Deleting again must not refund a second time.
593 let again = synckit::delete_sync_blob(&db.pool, app, user, "d-1")
594 .await
595 .expect("second delete");
596 assert_eq!(
597 again,
598 BlobDelete::NotFound,
599 "a repeat delete finds nothing to free"
600 );
601 assert_eq!(
602 app_bytes(&db.pool, app).await,
603 3300,
604 "and refunds nothing a second time"
605 );
606 assert_eq!(key_bytes(&db.pool, app, "alpha").await, Some(3300));
607 assert_eq!(
608 pending_deletions(&db.pool).await.len(),
609 1,
610 "and enqueues no second deletion"
611 );
612 }
613
614 /// For an internal app the blob rows are the accounting, so a delete is what
615 /// frees the quota. The app counter must stay at zero rather than going
616 /// negative on the refund UPDATE that matches an untouched row.
617 #[tokio::test]
618 async fn deleting_an_internal_blob_frees_the_cap_it_was_holding() {
619 let db = TestDb::new().await;
620 let user = seed_user(&db.pool, "skblobs_intdel").await;
621 let app = seed_app(&db.pool, user, "blobintdel").await;
622 make_internal(&db.pool, app).await;
623 seed_subscription(&db.pool, user, app, "sub_intdel", Some(5000)).await;
624
625 synckit::confirm_internal_blob(&db.pool, app, user, "i-1", 3000, "s3/i-1", "default")
626 .await
627 .expect("store the first blob");
628
629 // 3000 + 2500 = 5500 over a 5000 cap.
630 let blocked =
631 synckit::confirm_internal_blob(&db.pool, app, user, "i-2", 2500, "s3/i-2", "default")
632 .await
633 .expect("confirm over cap");
634 assert_eq!(
635 blocked,
636 BlobConfirm::QuotaExceeded {
637 dimension: "storage",
638 used: 3000,
639 limit: 5000,
640 key: None,
641 },
642 "the second blob does not fit alongside the first"
643 );
644
645 let freed = synckit::delete_sync_blob(&db.pool, app, user, "i-1")
646 .await
647 .expect("delete");
648 assert_eq!(freed, BlobDelete::Deleted { size_bytes: 3000 });
649 assert_eq!(
650 synckit::storage_used_bytes(&db.pool, app, user)
651 .await
652 .expect("usage"),
653 0,
654 "usage is summed from the rows, so removing the row is the whole refund"
655 );
656 assert_eq!(
657 app_bytes(&db.pool, app).await,
658 0,
659 "an internal app keeps no counter, and the refund floors at zero rather than going negative"
660 );
661
662 let retried =
663 synckit::confirm_internal_blob(&db.pool, app, user, "i-2", 2500, "s3/i-2", "default")
664 .await
665 .expect("retry after the delete");
666 assert_eq!(
667 retried,
668 BlobConfirm::Stored,
669 "the freed cap is immediately usable, without waiting on the drift job"
670 );
671 }
672
673 // ── count_sync_devices ──────────────────────────────────────────────────────
674
675 /// Counts one `(app, user)` pair. Three devices for one user against two for
676 /// another, in two apps, so a query missing either scope gives a different
677 /// number than the right one.
678 #[tokio::test]
679 async fn count_sync_devices_counts_one_user_in_one_app() {
680 let db = TestDb::new().await;
681 let alice = seed_user(&db.pool, "skblobs_dev_alice").await;
682 let bob = seed_user(&db.pool, "skblobs_dev_bob").await;
683 let app_one = seed_app(&db.pool, alice, "devcountone").await;
684 let app_two = seed_app(&db.pool, alice, "devcounttwo").await;
685
686 for name in ["laptop", "desktop", "phone"] {
687 synckit::upsert_sync_device(
688 &db.pool,
689 app_one,
690 alice,
691 name,
692 makenotwork::db::SyncPlatform::Macos,
693 None,
694 )
695 .await
696 .expect("seed alice device");
697 }
698 for name in ["tablet", "watch"] {
699 synckit::upsert_sync_device(
700 &db.pool,
701 app_one,
702 bob,
703 name,
704 makenotwork::db::SyncPlatform::Macos,
705 None,
706 )
707 .await
708 .expect("seed bob device");
709 }
710 synckit::upsert_sync_device(
711 &db.pool,
712 app_two,
713 alice,
714 "laptop",
715 makenotwork::db::SyncPlatform::Macos,
716 None,
717 )
718 .await
719 .expect("seed alice device in the second app");
720
721 assert_eq!(
722 synckit::count_sync_devices(&db.pool, app_one, alice)
723 .await
724 .expect("count"),
725 3,
726 "alice's three devices in the first app, not bob's and not her other app's"
727 );
728 assert_eq!(
729 synckit::count_sync_devices(&db.pool, app_one, bob)
730 .await
731 .expect("count"),
732 2
733 );
734 assert_eq!(
735 synckit::count_sync_devices(&db.pool, app_two, alice)
736 .await
737 .expect("count"),
738 1
739 );
740 assert_eq!(
741 synckit::count_sync_devices(&db.pool, app_two, bob)
742 .await
743 .expect("count"),
744 0,
745 "a user with no device in an app counts zero"
746 );
747 }
748