1
use crate::db::DB_POOL;
2
use crate::user::User;
3
use sqlx::PgPool;
4
use sqlx::types::Uuid;
5
use supp_macro::local_db_sqlx_test;
6
use tokio::sync::OnceCell;
7

            
8
static USER: OnceCell<User> = OnceCell::const_new();
9

            
10
21
async fn setup() {
11
21
    USER.get_or_init(|| async { User { id: Uuid::new_v4() } })
12
21
        .await;
13
21
}
14

            
15
21
async fn user() -> &'static User {
16
21
    let user = USER.get().unwrap();
17
21
    user.commit()
18
21
        .await
19
21
        .expect("Failed to commit user to database");
20
21
    user
21
21
}
22

            
23
#[local_db_sqlx_test]
24
async fn test_create_and_list_scripts(pool: PgPool) -> Result<(), anyhow::Error> {
25
    let user = user().await;
26
    let bytecode = vec![0x00, 0x61, 0x73, 0x6d];
27

            
28
    let id = user.create_script(bytecode.clone(), None).await?;
29
    assert!(!id.is_nil());
30

            
31
    let scripts = user.list_scripts().await?;
32
    assert_eq!(scripts.len(), 1);
33
    assert_eq!(scripts[0].id, id);
34
    assert_eq!(scripts[0].size, bytecode.len() as i32);
35
    assert!(scripts[0].name.is_none());
36
}
37

            
38
#[local_db_sqlx_test]
39
async fn test_create_script_with_name(pool: PgPool) -> Result<(), anyhow::Error> {
40
    let user = user().await;
41
    let bytecode = vec![0x00, 0x61, 0x73, 0x6d];
42
    let name = "test_script".to_string();
43

            
44
    user.create_script(bytecode, Some(name.clone())).await?;
45

            
46
    let scripts = user.list_scripts().await?;
47
    assert_eq!(scripts.len(), 1);
48
    assert_eq!(scripts[0].name, Some(name));
49
}
50

            
51
#[local_db_sqlx_test]
52
async fn test_get_script(pool: PgPool) -> Result<(), anyhow::Error> {
53
    let user = user().await;
54
    let bytecode = vec![0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00];
55
    let name = "fetch_test".to_string();
56

            
57
    let id = user
58
        .create_script(bytecode.clone(), Some(name.clone()))
59
        .await?;
60

            
61
    let script = user.get_script(id).await?;
62
    assert_eq!(script.id, id);
63
    assert_eq!(script.bytecode, bytecode);
64
    assert_eq!(script.name, Some(name));
65
}
66

            
67
#[local_db_sqlx_test]
68
async fn test_update_script_bytecode(pool: PgPool) -> Result<(), anyhow::Error> {
69
    let user = user().await;
70
    let original = vec![0x00, 0x61, 0x73, 0x6d];
71
    let updated = vec![0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00];
72

            
73
    let id = user.create_script(original, None).await?;
74
    user.update_script_bytecode(id, updated.clone()).await?;
75

            
76
    let script = user.get_script(id).await?;
77
    assert_eq!(script.bytecode, updated);
78
}
79

            
80
#[local_db_sqlx_test]
81
async fn test_delete_script(pool: PgPool) -> Result<(), anyhow::Error> {
82
    let user = user().await;
83
    let bytecode = vec![0x00, 0x61, 0x73, 0x6d];
84

            
85
    let id = user
86
        .create_script(bytecode, Some("to_delete".to_string()))
87
        .await?;
88
    assert_eq!(user.list_scripts().await?.len(), 1);
89

            
90
    user.delete_script(id).await?;
91
    assert!(user.list_scripts().await?.is_empty());
92
}
93

            
94
#[local_db_sqlx_test]
95
async fn test_set_script_enabled(pool: PgPool) -> Result<(), anyhow::Error> {
96
    let user = user().await;
97
    let bytecode = vec![0x00, 0x61, 0x73, 0x6d];
98

            
99
    let id = user.create_script(bytecode, None).await?;
100
    assert!(user.get_script(id).await?.enabled.is_none());
101

            
102
    user.set_script_enabled(id, false).await?;
103
    assert_eq!(
104
        user.get_script(id).await?.enabled,
105
        Some("false".to_string())
106
    );
107

            
108
    user.set_script_enabled(id, true).await?;
109
    assert!(user.get_script(id).await?.enabled.is_none());
110
}
111

            
112
#[local_db_sqlx_test]
113
async fn test_update_script_name(pool: PgPool) -> Result<(), anyhow::Error> {
114
    let user = user().await;
115
    let bytecode = vec![0x00, 0x61, 0x73, 0x6d];
116

            
117
    let id = user
118
        .create_script(bytecode, Some("original_name".to_string()))
119
        .await?;
120
    assert_eq!(
121
        user.get_script(id).await?.name,
122
        Some("original_name".to_string())
123
    );
124

            
125
    user.update_script_name(id, Some("new_name".to_string()))
126
        .await?;
127
    assert_eq!(
128
        user.get_script(id).await?.name,
129
        Some("new_name".to_string())
130
    );
131

            
132
    user.update_script_name(id, None).await?;
133
    assert!(user.get_script(id).await?.name.is_none());
134
}
135

            
136
#[local_db_sqlx_test]
137
async fn test_multiple_scripts(pool: PgPool) -> Result<(), anyhow::Error> {
138
    let user = user().await;
139

            
140
    let id1 = user
141
        .create_script(vec![0x01], Some("script1".to_string()))
142
        .await?;
143
    let id2 = user
144
        .create_script(vec![0x02, 0x03], Some("script2".to_string()))
145
        .await?;
146
    let id3 = user.create_script(vec![0x04, 0x05, 0x06], None).await?;
147

            
148
    let ids: Vec<Uuid> = user.list_scripts().await?.iter().map(|s| s.id).collect();
149
    assert_eq!(ids.len(), 3);
150
    assert!(ids.contains(&id1) && ids.contains(&id2) && ids.contains(&id3));
151

            
152
    user.delete_script(id2).await?;
153
    let ids: Vec<Uuid> = user.list_scripts().await?.iter().map(|s| s.id).collect();
154
    assert_eq!(ids.len(), 2);
155
    assert!(ids.contains(&id1) && !ids.contains(&id2) && ids.contains(&id3));
156
}
157

            
158
#[local_db_sqlx_test]
159
async fn test_template_crud_round_trip(pool: PgPool) -> Result<(), anyhow::Error> {
160
    let user = user().await;
161
    let source = "(set-draft-note \"Groceries\")".to_string();
162

            
163
    let id = user
164
        .create_template(source.clone(), Some("groceries".to_string()))
165
        .await?;
166

            
167
    let detail = user.get_template(id).await?;
168
    assert_eq!(detail.source, source);
169
    assert_eq!(detail.name, Some("groceries".to_string()));
170

            
171
    let templates = user.list_templates().await?;
172
    assert_eq!(templates.len(), 1);
173
    assert_eq!(templates[0].id, id);
174

            
175
    user.update_template_source(id, "(set-draft-note \"Rent\")".to_string())
176
        .await?;
177
    assert_eq!(
178
        user.get_template(id).await?.source,
179
        "(set-draft-note \"Rent\")"
180
    );
181

            
182
    user.update_template_name(id, Some("rent".to_string()))
183
        .await?;
184
    assert_eq!(user.get_template(id).await?.name, Some("rent".to_string()));
185

            
186
    user.delete_template(id).await?;
187
    assert!(user.list_templates().await?.is_empty());
188
}
189

            
190
/// Scripts and templates are isolated by kind: a template never shows up in the
191
/// script listing and vice versa.
192
#[local_db_sqlx_test]
193
async fn test_kind_isolation(pool: PgPool) -> Result<(), anyhow::Error> {
194
    let user = user().await;
195

            
196
    let script_id = user.create_script(vec![0x00, 0x61], None).await?;
197
    let template_id = user
198
        .create_template("(set-draft-note \"x\")".to_string(), None)
199
        .await?;
200

            
201
    let script_ids: Vec<Uuid> = user.list_scripts().await?.iter().map(|s| s.id).collect();
202
    assert_eq!(script_ids, vec![script_id]);
203

            
204
    let template_ids: Vec<Uuid> = user.list_templates().await?.iter().map(|t| t.id).collect();
205
    assert_eq!(template_ids, vec![template_id]);
206

            
207
    assert!(user.get_script(template_id).await.is_err());
208
    assert!(user.get_template(script_id).await.is_err());
209
}
210

            
211
/// Two artifacts sharing the same canonical `name`/`enabled` tag values both
212
/// link correctly — the canonical-id-propagation fix. Before the fix, the
213
/// second create linked a dangling fresh uuid and the FK insert failed.
214
#[local_db_sqlx_test]
215
async fn test_shared_canonical_tag_values(pool: PgPool) -> Result<(), anyhow::Error> {
216
    let user = user().await;
217

            
218
    let id1 = user
219
        .create_script(vec![0x01], Some("shared".to_string()))
220
        .await?;
221
    let id2 = user
222
        .create_script(vec![0x02], Some("shared".to_string()))
223
        .await?;
224

            
225
    user.set_script_enabled(id1, false).await?;
226
    user.set_script_enabled(id2, false).await?;
227

            
228
    let scripts = user.list_scripts().await?;
229
    assert_eq!(scripts.len(), 2);
230
    for s in &scripts {
231
        assert_eq!(s.name, Some("shared".to_string()));
232
        assert_eq!(s.enabled, Some("false".to_string()));
233
    }
234
1
    assert!(scripts.iter().any(|s| s.id == id1));
235
2
    assert!(scripts.iter().any(|s| s.id == id2));
236
}
237

            
238
/// Deleting one of two artifacts sharing the canonical `kind` tag succeeds
239
/// (artifact-lifecycle delete — parent gone at commit, deferred guard passes).
240
#[local_db_sqlx_test]
241
async fn test_lifecycle_delete_with_shared_kind(pool: PgPool) -> Result<(), anyhow::Error> {
242
    let user = user().await;
243

            
244
    let id1 = user.create_script(vec![0x01], None).await?;
245
    let id2 = user.create_script(vec![0x02], None).await?;
246

            
247
    user.delete_script(id1).await?;
248

            
249
    let remaining: Vec<Uuid> = user.list_scripts().await?.iter().map(|s| s.id).collect();
250
    assert_eq!(remaining, vec![id2]);
251
}
252

            
253
/// Resolve the canonical id of the `kind` tag linked to an artifact.
254
6
async fn kind_tag_id(pool: &PgPool, artifact_id: Uuid) -> Uuid {
255
6
    sqlx::query_scalar::<_, Uuid>(
256
6
        "SELECT at.tag_id FROM artifact_tags at \
257
6
         INNER JOIN tags t ON t.id = at.tag_id \
258
6
         WHERE at.artifact_id = $1 AND t.tag_name = 'kind'",
259
6
    )
260
6
    .bind(artifact_id)
261
6
    .fetch_one(pool)
262
6
    .await
263
6
    .expect("kind tag must be linked")
264
6
}
265

            
266
/// The auto-run selector is fail-closed: a source-only template is never
267
/// returned, while an automation is. This is the load-bearing execution guard.
268
#[local_db_sqlx_test]
269
async fn test_template_never_in_autorun(pool: PgPool) -> Result<(), anyhow::Error> {
270
    let user = user().await;
271

            
272
    let script_id = user
273
        .create_script(vec![0x00, 0x61, 0x73, 0x6d], None)
274
        .await?;
275
    user.create_template("(set-draft-note \"x\")".to_string(), None)
276
        .await?;
277

            
278
    let enabled: Vec<Uuid> = sqlx::query_file!("sql/select/artifacts/enabled.sql")
279
        .fetch_all(&pool)
280
        .await?
281
        .into_iter()
282
        .map(|r| r.id)
283
        .collect();
284

            
285
    assert_eq!(enabled, vec![script_id]);
286
}
287

            
288
/// The DB rejects a second `kind` tag on one artifact (one-kind invariant).
289
#[local_db_sqlx_test]
290
async fn test_db_rejects_second_kind_tag(pool: PgPool) -> Result<(), anyhow::Error> {
291
    let user = user().await;
292
    let id = user.create_script(vec![0x01], None).await?;
293

            
294
    // Upsert a kind=template tag row, then try to also link it.
295
    let template_tag: Uuid = sqlx::query_scalar(
296
        "INSERT INTO tags (id, tag_name, tag_value) VALUES ($1, 'kind', 'template') \
297
         ON CONFLICT (tag_name, tag_value) DO UPDATE SET tag_value = excluded.tag_value \
298
         RETURNING id",
299
    )
300
    .bind(Uuid::new_v4())
301
    .fetch_one(&pool)
302
    .await?;
303

            
304
    let result = sqlx::query("INSERT INTO artifact_tags (artifact_id, tag_id) VALUES ($1, $2)")
305
        .bind(id)
306
        .bind(template_tag)
307
        .execute(&pool)
308
        .await;
309

            
310
    assert!(result.is_err(), "second kind tag must be rejected");
311
}
312

            
313
/// The DB rejects tagging a source-only artifact as automation (kind<->shape):
314
/// an automation must carry bytecode.
315
#[local_db_sqlx_test]
316
async fn test_db_rejects_kind_shape_mismatch(pool: PgPool) -> Result<(), anyhow::Error> {
317
    let user = user().await;
318
    let id = user
319
        .create_template("(set-draft-note \"x\")".to_string(), None)
320
        .await?;
321

            
322
    let template_kind = kind_tag_id(&pool, id).await;
323

            
324
    // Detach the correct kind=template, then attempt kind=automation.
325
    sqlx::query("DELETE FROM artifact_tags WHERE artifact_id = $1 AND tag_id = $2")
326
        .bind(id)
327
        .bind(template_kind)
328
        .execute(&pool)
329
        .await
330
        .ok();
331

            
332
    let automation_tag: Uuid = sqlx::query_scalar(
333
        "INSERT INTO tags (id, tag_name, tag_value) VALUES ($1, 'kind', 'automation') \
334
         ON CONFLICT (tag_name, tag_value) DO UPDATE SET tag_value = excluded.tag_value \
335
         RETURNING id",
336
    )
337
    .bind(Uuid::new_v4())
338
    .fetch_one(&pool)
339
    .await?;
340

            
341
    let result = sqlx::query("INSERT INTO artifact_tags (artifact_id, tag_id) VALUES ($1, $2)")
342
        .bind(id)
343
        .bind(automation_tag)
344
        .execute(&pool)
345
        .await;
346

            
347
    assert!(
348
        result.is_err(),
349
        "tagging a source-only artifact as automation must be rejected"
350
    );
351
}
352

            
353
/// The DB rejects a generic unlink of a `kind` tag while the artifact survives
354
/// (the deferred DELETE guard distinguishes lifecycle delete from unlink).
355
#[local_db_sqlx_test]
356
async fn test_db_rejects_generic_kind_unlink(pool: PgPool) -> Result<(), anyhow::Error> {
357
    let user = user().await;
358
    let id = user.create_script(vec![0x01], None).await?;
359
    let kind_tag = kind_tag_id(&pool, id).await;
360

            
361
    let result = sqlx::query("DELETE FROM artifact_tags WHERE artifact_id = $1 AND tag_id = $2")
362
        .bind(id)
363
        .bind(kind_tag)
364
        .execute(&pool)
365
        .await;
366

            
367
    assert!(
368
        result.is_err(),
369
        "unlinking a kind tag from a surviving artifact must be rejected"
370
    );
371
}
372

            
373
/// The DB rejects deleting a `kind` tag row that is still referenced
374
/// (the shared-canonical DELETE vector via `delete_tag`).
375
#[local_db_sqlx_test]
376
async fn test_db_rejects_referenced_kind_tag_delete(pool: PgPool) -> Result<(), anyhow::Error> {
377
    let user = user().await;
378
    let id = user.create_script(vec![0x01], None).await?;
379
    let kind_tag = kind_tag_id(&pool, id).await;
380

            
381
    let result = sqlx::query("DELETE FROM tags WHERE id = $1")
382
        .bind(kind_tag)
383
        .execute(&pool)
384
        .await;
385

            
386
    assert!(
387
        result.is_err(),
388
        "deleting a referenced kind tag must be rejected"
389
    );
390
}
391

            
392
/// The DB rejects renaming a linked `kind` tag into a value inconsistent with
393
/// the artifact's shape (the canonical-rename vector via `update_tag`).
394
#[local_db_sqlx_test]
395
async fn test_db_rejects_kind_tag_rename(pool: PgPool) -> Result<(), anyhow::Error> {
396
    let user = user().await;
397
    let id = user.create_script(vec![0x01], None).await?;
398
    let kind_tag = kind_tag_id(&pool, id).await;
399

            
400
    // Flip automation -> template on an artifact that carries bytecode.
401
    let result = sqlx::query("UPDATE tags SET tag_value = 'template' WHERE id = $1")
402
        .bind(kind_tag)
403
        .execute(&pool)
404
        .await;
405

            
406
    assert!(
407
        result.is_err(),
408
        "flipping a linked kind tag to an inconsistent value must be rejected"
409
    );
410
}
411

            
412
/// Renaming a linked `kind` tag to a NON-kind name (so the artifact would end
413
/// up with zero kind tags) is rejected by the exactly-one-kind invariant. This
414
/// is the `update_tag`-orphan vector: a generic tag rename must not strip the
415
/// discriminator off a live artifact.
416
#[local_db_sqlx_test]
417
async fn test_db_rejects_kind_tag_rename_to_non_kind(pool: PgPool) -> Result<(), anyhow::Error> {
418
    let user = user().await;
419
    let id = user.create_script(vec![0x01], None).await?;
420

            
421
    let result = user
422
        .update_tag(
423
            kind_tag_id(&pool, id).await,
424
            "category".to_string(),
425
            "misc".to_string(),
426
            None,
427
        )
428
        .await;
429

            
430
    assert!(
431
        result.is_err(),
432
        "renaming a linked kind tag to a non-kind must be rejected (would orphan)"
433
    );
434
}
435

            
436
/// A raw `INSERT INTO artifacts` with no kind tag cannot commit — the deferred
437
/// artifacts trigger requires exactly one kind tag at commit.
438
#[local_db_sqlx_test]
439
async fn test_db_rejects_artifact_without_kind(pool: PgPool) -> Result<(), anyhow::Error> {
440
    user().await;
441

            
442
    let result = sqlx::query("INSERT INTO artifacts (id, bytecode, source) VALUES ($1, $2, NULL)")
443
        .bind(Uuid::new_v4())
444
        .bind(vec![0x01u8])
445
        .execute(&pool)
446
        .await;
447

            
448
    assert!(
449
        result.is_err(),
450
        "an artifact with no kind tag must not commit"
451
    );
452
}
453

            
454
/// Moving a `kind` link off one artifact onto another (raw UPDATE of
455
/// `artifact_tags.artifact_id`) is rejected: the old owner would be left
456
/// kindless. Validates the old-owner re-check on UPDATE.
457
#[local_db_sqlx_test]
458
async fn test_db_rejects_kind_link_move(pool: PgPool) -> Result<(), anyhow::Error> {
459
    let user = user().await;
460
    let donor = user.create_script(vec![0x01], None).await?;
461
    let recipient = user.create_script(vec![0x02], None).await?;
462
    let donor_kind_link = kind_tag_id(&pool, donor).await;
463

            
464
    // Repoint the donor's kind link to the recipient — donor is left kindless,
465
    // recipient ends up with two kind tags. Either way: rejected.
466
    let result = sqlx::query(
467
        "UPDATE artifact_tags SET artifact_id = $1 \
468
         WHERE artifact_id = $2 AND tag_id = $3",
469
    )
470
    .bind(recipient)
471
    .bind(donor)
472
    .bind(donor_kind_link)
473
    .execute(&pool)
474
    .await;
475

            
476
    assert!(
477
        result.is_err(),
478
        "moving a kind link off its artifact must be rejected"
479
    );
480
}