All checks were successful
Test / rust-fmt-check (pull_request) Successful in 1m35s
Test / frontend-tests (pull_request) Successful in 1m41s
Test / frontend-typecheck (pull_request) Successful in 1m43s
Test / rust-clippy (pull_request) Successful in 3m10s
Test / rust-tests (pull_request) Successful in 4m39s
PR Review Automation / review (pull_request) Successful in 4m58s
Store compressed log content and raw image bytes in SQLite so attachments are self-contained regardless of source file availability on disk. DB (migrations 020-022): - log_files.content_compressed BLOB — gzip-compressed extracted text - image_attachments.image_data BLOB — raw image bytes - Views v_log_files_with_issue and v_image_attachments_with_issue for cross-incident queries with joined issue title Rust backend: - compress_text / decompress_text helpers (flate2 rust_backend / miniz_oxide) with 100 MB decompression-bomb guard - upload_log_file*, upload_log_file_by_content store content_compressed - upload_image_attachment*, upload_paste_image store image_data - New commands: get_log_file_content, list_all_log_files (analysis.rs) - New commands: get_image_attachment_data, list_all_image_attachments (image.rs) - All commands fall back to file_path for pre-migration records Frontend: - LogFileSummary, ImageAttachmentSummary types in tauriCommands.ts - attachmentStore (Zustand) — loadAttachments, searchAttachments - History page: Issues tab (existing) + Attachments tab (new) with log/image tables, search bar, View modals, lazy thumbnails Tests: 227 Rust (+16 new), 103 frontend (+9 new), tsc clean, clippy clean Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
1237 lines
44 KiB
Rust
1237 lines
44 KiB
Rust
use rusqlite::Connection;
|
|
|
|
/// Run all database migrations in order, tracking which have been applied.
|
|
pub fn run_migrations(conn: &Connection) -> anyhow::Result<()> {
|
|
conn.execute_batch(
|
|
"CREATE TABLE IF NOT EXISTS _migrations (
|
|
id INTEGER PRIMARY KEY,
|
|
name TEXT NOT NULL UNIQUE,
|
|
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
);",
|
|
)?;
|
|
|
|
let migrations: &[(&str, &str)] = &[
|
|
(
|
|
"001_create_issues",
|
|
"CREATE TABLE IF NOT EXISTS issues (
|
|
id TEXT PRIMARY KEY,
|
|
title TEXT NOT NULL,
|
|
description TEXT NOT NULL DEFAULT '',
|
|
severity TEXT NOT NULL DEFAULT 'medium',
|
|
status TEXT NOT NULL DEFAULT 'open',
|
|
category TEXT NOT NULL DEFAULT 'general',
|
|
source TEXT NOT NULL DEFAULT 'manual',
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
resolved_at TEXT,
|
|
assigned_to TEXT NOT NULL DEFAULT '',
|
|
tags TEXT NOT NULL DEFAULT '[]'
|
|
);",
|
|
),
|
|
(
|
|
"002_create_log_files",
|
|
"CREATE TABLE IF NOT EXISTS log_files (
|
|
id TEXT PRIMARY KEY,
|
|
issue_id TEXT NOT NULL REFERENCES issues(id) ON DELETE CASCADE,
|
|
file_name TEXT NOT NULL,
|
|
file_path TEXT NOT NULL DEFAULT '',
|
|
file_size INTEGER NOT NULL DEFAULT 0,
|
|
mime_type TEXT NOT NULL DEFAULT 'text/plain',
|
|
content_hash TEXT NOT NULL DEFAULT '',
|
|
uploaded_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
redacted INTEGER NOT NULL DEFAULT 0
|
|
);",
|
|
),
|
|
(
|
|
"003_create_pii_spans",
|
|
"CREATE TABLE IF NOT EXISTS pii_spans (
|
|
id TEXT PRIMARY KEY,
|
|
log_file_id TEXT NOT NULL REFERENCES log_files(id) ON DELETE CASCADE,
|
|
pii_type TEXT NOT NULL,
|
|
start_offset INTEGER NOT NULL,
|
|
end_offset INTEGER NOT NULL,
|
|
original_value TEXT NOT NULL,
|
|
replacement TEXT NOT NULL
|
|
);",
|
|
),
|
|
(
|
|
"004_create_ai_conversations",
|
|
"CREATE TABLE IF NOT EXISTS ai_conversations (
|
|
id TEXT PRIMARY KEY,
|
|
issue_id TEXT NOT NULL REFERENCES issues(id) ON DELETE CASCADE,
|
|
provider TEXT NOT NULL,
|
|
model TEXT NOT NULL,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
title TEXT NOT NULL DEFAULT 'Untitled'
|
|
);",
|
|
),
|
|
(
|
|
"005_create_ai_messages",
|
|
"CREATE TABLE IF NOT EXISTS ai_messages (
|
|
id TEXT PRIMARY KEY,
|
|
conversation_id TEXT NOT NULL REFERENCES ai_conversations(id) ON DELETE CASCADE,
|
|
role TEXT NOT NULL CHECK(role IN ('system','user','assistant')),
|
|
content TEXT NOT NULL,
|
|
token_count INTEGER NOT NULL DEFAULT 0,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
);",
|
|
),
|
|
(
|
|
"006_create_resolution_steps",
|
|
"CREATE TABLE IF NOT EXISTS resolution_steps (
|
|
id TEXT PRIMARY KEY,
|
|
issue_id TEXT NOT NULL REFERENCES issues(id) ON DELETE CASCADE,
|
|
step_order INTEGER NOT NULL DEFAULT 0,
|
|
why_question TEXT NOT NULL DEFAULT '',
|
|
answer TEXT NOT NULL DEFAULT '',
|
|
evidence TEXT NOT NULL DEFAULT '',
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
);",
|
|
),
|
|
(
|
|
"007_create_documents",
|
|
"CREATE TABLE IF NOT EXISTS documents (
|
|
id TEXT PRIMARY KEY,
|
|
issue_id TEXT NOT NULL REFERENCES issues(id) ON DELETE CASCADE,
|
|
doc_type TEXT NOT NULL,
|
|
title TEXT NOT NULL,
|
|
content_md TEXT NOT NULL,
|
|
created_at INTEGER NOT NULL,
|
|
updated_at INTEGER NOT NULL
|
|
);",
|
|
),
|
|
(
|
|
"008_create_audit_log",
|
|
"CREATE TABLE IF NOT EXISTS audit_log (
|
|
id TEXT PRIMARY KEY,
|
|
timestamp TEXT NOT NULL DEFAULT (datetime('now')),
|
|
action TEXT NOT NULL,
|
|
entity_type TEXT NOT NULL DEFAULT '',
|
|
entity_id TEXT NOT NULL DEFAULT '',
|
|
user_id TEXT NOT NULL DEFAULT 'local',
|
|
details TEXT NOT NULL DEFAULT '{}'
|
|
);",
|
|
),
|
|
(
|
|
"009_create_settings",
|
|
"CREATE TABLE IF NOT EXISTS settings (
|
|
key TEXT PRIMARY KEY,
|
|
value TEXT NOT NULL DEFAULT '',
|
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
);",
|
|
),
|
|
(
|
|
"010_issues_fts",
|
|
"CREATE VIRTUAL TABLE IF NOT EXISTS issues_fts USING fts5(
|
|
id UNINDEXED, title, description,
|
|
content='issues', content_rowid='rowid'
|
|
);",
|
|
),
|
|
(
|
|
"011_create_integrations",
|
|
"CREATE TABLE IF NOT EXISTS credentials (
|
|
id TEXT PRIMARY KEY,
|
|
service TEXT NOT NULL CHECK(service IN ('confluence','servicenow','azuredevops')),
|
|
token_hash TEXT NOT NULL,
|
|
encrypted_token TEXT NOT NULL,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
expires_at TEXT,
|
|
UNIQUE(service)
|
|
);
|
|
CREATE TABLE IF NOT EXISTS integration_config (
|
|
id TEXT PRIMARY KEY,
|
|
service TEXT NOT NULL CHECK(service IN ('confluence','servicenow','azuredevops')),
|
|
base_url TEXT NOT NULL,
|
|
username TEXT,
|
|
project_name TEXT,
|
|
space_key TEXT,
|
|
auto_create_enabled INTEGER NOT NULL DEFAULT 0,
|
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
UNIQUE(service)
|
|
);",
|
|
),
|
|
(
|
|
"012_audit_hash_chain",
|
|
"ALTER TABLE audit_log ADD COLUMN prev_hash TEXT NOT NULL DEFAULT '';
|
|
ALTER TABLE audit_log ADD COLUMN entry_hash TEXT NOT NULL DEFAULT '';",
|
|
),
|
|
(
|
|
"013_image_attachments",
|
|
"CREATE TABLE IF NOT EXISTS image_attachments (
|
|
id TEXT PRIMARY KEY,
|
|
issue_id TEXT NOT NULL REFERENCES issues(id) ON DELETE CASCADE,
|
|
file_name TEXT NOT NULL,
|
|
file_path TEXT NOT NULL DEFAULT '',
|
|
file_size INTEGER NOT NULL DEFAULT 0,
|
|
mime_type TEXT NOT NULL DEFAULT 'image/png',
|
|
upload_hash TEXT NOT NULL DEFAULT '',
|
|
uploaded_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
pii_warning_acknowledged INTEGER NOT NULL DEFAULT 1,
|
|
is_paste INTEGER NOT NULL DEFAULT 0
|
|
);",
|
|
),
|
|
(
|
|
"014_create_ai_providers",
|
|
"CREATE TABLE IF NOT EXISTS ai_providers (
|
|
id TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL UNIQUE,
|
|
provider_type TEXT NOT NULL,
|
|
api_url TEXT NOT NULL,
|
|
encrypted_api_key TEXT NOT NULL,
|
|
model TEXT NOT NULL,
|
|
max_tokens INTEGER,
|
|
temperature REAL,
|
|
custom_endpoint_path TEXT,
|
|
custom_auth_header TEXT,
|
|
custom_auth_prefix TEXT,
|
|
api_format TEXT,
|
|
user_id TEXT,
|
|
use_datastore_upload INTEGER,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
);",
|
|
),
|
|
(
|
|
"015_add_use_datastore_upload",
|
|
"ALTER TABLE ai_providers ADD COLUMN use_datastore_upload INTEGER DEFAULT 0",
|
|
),
|
|
(
|
|
"016_add_created_at",
|
|
"ALTER TABLE ai_providers ADD COLUMN created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%S', 'now'))",
|
|
),
|
|
(
|
|
"017_create_timeline_events",
|
|
"CREATE TABLE IF NOT EXISTS timeline_events (
|
|
id TEXT PRIMARY KEY,
|
|
issue_id TEXT NOT NULL,
|
|
event_type TEXT NOT NULL,
|
|
description TEXT NOT NULL DEFAULT '',
|
|
metadata TEXT NOT NULL DEFAULT '{}',
|
|
created_at TEXT NOT NULL,
|
|
FOREIGN KEY (issue_id) REFERENCES issues(id) ON DELETE CASCADE
|
|
);
|
|
CREATE INDEX idx_timeline_events_issue ON timeline_events(issue_id);
|
|
CREATE INDEX idx_timeline_events_time ON timeline_events(created_at);",
|
|
),
|
|
(
|
|
"018_mcp_servers",
|
|
"CREATE TABLE IF NOT EXISTS mcp_servers (
|
|
id TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
url TEXT NOT NULL,
|
|
transport_type TEXT NOT NULL CHECK(transport_type IN ('stdio', 'http')),
|
|
transport_config TEXT NOT NULL DEFAULT '{}',
|
|
auth_type TEXT NOT NULL CHECK(auth_type IN ('none', 'api_key', 'bearer', 'oauth2')),
|
|
auth_value TEXT,
|
|
enabled INTEGER NOT NULL DEFAULT 1,
|
|
last_discovered_at TEXT,
|
|
discovery_status TEXT NOT NULL DEFAULT 'pending'
|
|
CHECK(discovery_status IN ('pending','connected','unreachable','error')),
|
|
discovery_error TEXT,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
);
|
|
CREATE TABLE IF NOT EXISTS mcp_tools (
|
|
id TEXT PRIMARY KEY,
|
|
server_id TEXT NOT NULL,
|
|
name TEXT NOT NULL,
|
|
tool_key TEXT NOT NULL,
|
|
description TEXT,
|
|
parameters TEXT NOT NULL DEFAULT '{}',
|
|
FOREIGN KEY(server_id) REFERENCES mcp_servers(id) ON DELETE CASCADE
|
|
);
|
|
CREATE TABLE IF NOT EXISTS mcp_resources (
|
|
id TEXT PRIMARY KEY,
|
|
server_id TEXT NOT NULL,
|
|
uri TEXT NOT NULL,
|
|
name TEXT,
|
|
description TEXT,
|
|
FOREIGN KEY(server_id) REFERENCES mcp_servers(id) ON DELETE CASCADE
|
|
);",
|
|
),
|
|
(
|
|
"019_create_sudo_config",
|
|
"CREATE TABLE IF NOT EXISTS sudo_config (
|
|
id TEXT PRIMARY KEY,
|
|
username TEXT NOT NULL DEFAULT '',
|
|
encrypted_password TEXT NOT NULL,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
);",
|
|
),
|
|
(
|
|
"020_add_log_content_compressed",
|
|
"ALTER TABLE log_files ADD COLUMN content_compressed BLOB",
|
|
),
|
|
(
|
|
"021_add_image_data",
|
|
"ALTER TABLE image_attachments ADD COLUMN image_data BLOB",
|
|
),
|
|
(
|
|
"022_attachment_views",
|
|
"CREATE VIEW IF NOT EXISTS v_log_files_with_issue AS
|
|
SELECT lf.id, lf.issue_id, lf.file_name, lf.file_path, lf.file_size,
|
|
lf.mime_type, lf.content_hash, lf.uploaded_at, lf.redacted,
|
|
i.title AS issue_title
|
|
FROM log_files lf
|
|
JOIN issues i ON i.id = lf.issue_id;
|
|
CREATE VIEW IF NOT EXISTS v_image_attachments_with_issue AS
|
|
SELECT ia.id, ia.issue_id, ia.file_name, ia.file_path, ia.file_size,
|
|
ia.mime_type, ia.upload_hash, ia.uploaded_at,
|
|
ia.pii_warning_acknowledged, ia.is_paste,
|
|
i.title AS issue_title
|
|
FROM image_attachments ia
|
|
JOIN issues i ON i.id = ia.issue_id;",
|
|
),
|
|
];
|
|
|
|
for (name, sql) in migrations {
|
|
let already_applied: bool = conn
|
|
.prepare("SELECT COUNT(*) FROM _migrations WHERE name = ?1")?
|
|
.query_row([name], |row| row.get::<_, i64>(0))
|
|
.map(|count| count > 0)?;
|
|
|
|
if !already_applied {
|
|
// FTS5 virtual table creation can be skipped if FTS5 is not compiled in
|
|
// Also handle column-already-exists errors for migrations 015-016
|
|
if name.contains("fts") {
|
|
if let Err(e) = conn.execute_batch(sql) {
|
|
tracing::warn!("FTS5 not available, skipping: {e}");
|
|
}
|
|
} else if name.ends_with("_add_use_datastore_upload")
|
|
|| name.ends_with("_add_created_at")
|
|
|| name.ends_with("_add_log_content_compressed")
|
|
|| name.ends_with("_add_image_data")
|
|
{
|
|
// Use execute for ALTER TABLE (SQLite only allows one statement per command)
|
|
// Skip error if column already exists (SQLITE_ERROR with "duplicate column name")
|
|
if let Err(e) = conn.execute(sql, []) {
|
|
let err_str = e.to_string();
|
|
if err_str.contains("duplicate column name") {
|
|
tracing::info!("Column may already exist, skipping migration {name}: {e}");
|
|
} else {
|
|
return Err(e.into());
|
|
}
|
|
}
|
|
} else {
|
|
// Use execute_batch for other migrations (FTS5, CREATE TABLE, etc.)
|
|
if let Err(e) = conn.execute_batch(sql) {
|
|
return Err(e.into());
|
|
}
|
|
}
|
|
conn.execute("INSERT INTO _migrations (name) VALUES (?1)", [name])?;
|
|
tracing::info!("Applied migration: {name}");
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use rusqlite::Connection;
|
|
|
|
fn setup_test_db() -> Connection {
|
|
let conn = Connection::open_in_memory().unwrap();
|
|
run_migrations(&conn).unwrap();
|
|
conn
|
|
}
|
|
|
|
#[test]
|
|
fn test_create_image_attachments_table() {
|
|
let conn = setup_test_db();
|
|
|
|
let count: i64 = conn
|
|
.query_row(
|
|
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='image_attachments'",
|
|
[],
|
|
|r| r.get(0),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(count, 1);
|
|
|
|
let mut stmt = conn
|
|
.prepare("PRAGMA table_info(image_attachments)")
|
|
.unwrap();
|
|
let columns: Vec<String> = stmt
|
|
.query_map([], |row| row.get::<_, String>(1))
|
|
.unwrap()
|
|
.collect::<Result<Vec<_>, _>>()
|
|
.unwrap();
|
|
|
|
assert!(columns.contains(&"id".to_string()));
|
|
assert!(columns.contains(&"issue_id".to_string()));
|
|
assert!(columns.contains(&"file_name".to_string()));
|
|
assert!(columns.contains(&"file_path".to_string()));
|
|
assert!(columns.contains(&"file_size".to_string()));
|
|
assert!(columns.contains(&"mime_type".to_string()));
|
|
assert!(columns.contains(&"upload_hash".to_string()));
|
|
assert!(columns.contains(&"uploaded_at".to_string()));
|
|
assert!(columns.contains(&"pii_warning_acknowledged".to_string()));
|
|
assert!(columns.contains(&"is_paste".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_create_integration_config_table() {
|
|
let conn = setup_test_db();
|
|
|
|
// Verify table exists
|
|
let count: i64 = conn
|
|
.query_row(
|
|
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='integration_config'",
|
|
[],
|
|
|r| r.get(0),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(count, 1);
|
|
|
|
// Verify columns
|
|
let mut stmt = conn
|
|
.prepare("PRAGMA table_info(integration_config)")
|
|
.unwrap();
|
|
let columns: Vec<String> = stmt
|
|
.query_map([], |row| row.get::<_, String>(1))
|
|
.unwrap()
|
|
.collect::<Result<Vec<_>, _>>()
|
|
.unwrap();
|
|
|
|
assert!(columns.contains(&"id".to_string()));
|
|
assert!(columns.contains(&"service".to_string()));
|
|
assert!(columns.contains(&"base_url".to_string()));
|
|
assert!(columns.contains(&"username".to_string()));
|
|
assert!(columns.contains(&"project_name".to_string()));
|
|
assert!(columns.contains(&"space_key".to_string()));
|
|
assert!(columns.contains(&"auto_create_enabled".to_string()));
|
|
assert!(columns.contains(&"updated_at".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_store_and_retrieve_credential() {
|
|
let conn = setup_test_db();
|
|
|
|
// Insert credential
|
|
conn.execute(
|
|
"INSERT INTO credentials (id, service, token_hash, encrypted_token, created_at)
|
|
VALUES (?1, ?2, ?3, ?4, ?5)",
|
|
rusqlite::params![
|
|
"test-id",
|
|
"confluence",
|
|
"test_hash",
|
|
"encrypted_test",
|
|
chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string()
|
|
],
|
|
)
|
|
.unwrap();
|
|
|
|
// Retrieve
|
|
let (service, token_hash): (String, String) = conn
|
|
.query_row(
|
|
"SELECT service, token_hash FROM credentials WHERE service = ?1",
|
|
["confluence"],
|
|
|r| Ok((r.get(0)?, r.get(1)?)),
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(service, "confluence");
|
|
assert_eq!(token_hash, "test_hash");
|
|
}
|
|
|
|
#[test]
|
|
fn test_store_and_retrieve_integration_config() {
|
|
let conn = setup_test_db();
|
|
|
|
// Insert config
|
|
conn.execute(
|
|
"INSERT INTO integration_config (id, service, base_url, space_key, auto_create_enabled, updated_at)
|
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
|
rusqlite::params![
|
|
"test-config-id",
|
|
"confluence",
|
|
"https://example.atlassian.net",
|
|
"DEV",
|
|
1,
|
|
chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string()
|
|
],
|
|
)
|
|
.unwrap();
|
|
|
|
// Retrieve
|
|
let (service, base_url, space_key, auto_create): (String, String, String, i32) = conn
|
|
.query_row(
|
|
"SELECT service, base_url, space_key, auto_create_enabled FROM integration_config WHERE service = ?1",
|
|
["confluence"],
|
|
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(service, "confluence");
|
|
assert_eq!(base_url, "https://example.atlassian.net");
|
|
assert_eq!(space_key, "DEV");
|
|
assert_eq!(auto_create, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn test_service_uniqueness_constraint() {
|
|
let conn = setup_test_db();
|
|
|
|
// Insert first credential
|
|
conn.execute(
|
|
"INSERT INTO credentials (id, service, token_hash, encrypted_token, created_at)
|
|
VALUES (?1, ?2, ?3, ?4, ?5)",
|
|
rusqlite::params![
|
|
"test-id-1",
|
|
"confluence",
|
|
"hash1",
|
|
"token1",
|
|
chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string()
|
|
],
|
|
)
|
|
.unwrap();
|
|
|
|
// Try to insert duplicate service - should fail
|
|
let result = conn.execute(
|
|
"INSERT INTO credentials (id, service, token_hash, encrypted_token, created_at)
|
|
VALUES (?1, ?2, ?3, ?4, ?5)",
|
|
rusqlite::params![
|
|
"test-id-2",
|
|
"confluence",
|
|
"hash2",
|
|
"token2",
|
|
chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string()
|
|
],
|
|
);
|
|
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_migration_tracking() {
|
|
let conn = setup_test_db();
|
|
|
|
// Verify migration 011 was applied
|
|
let applied: i64 = conn
|
|
.query_row(
|
|
"SELECT COUNT(*) FROM _migrations WHERE name = ?1",
|
|
["011_create_integrations"],
|
|
|r| r.get(0),
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(applied, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn test_migrations_idempotent() {
|
|
let conn = Connection::open_in_memory().unwrap();
|
|
|
|
// Run migrations twice
|
|
run_migrations(&conn).unwrap();
|
|
run_migrations(&conn).unwrap();
|
|
|
|
// Verify migration was only recorded once
|
|
let count: i64 = conn
|
|
.query_row(
|
|
"SELECT COUNT(*) FROM _migrations WHERE name = ?1",
|
|
["011_create_integrations"],
|
|
|r| r.get(0),
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(count, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn test_store_and_retrieve_image_attachment() {
|
|
let conn = setup_test_db();
|
|
|
|
// Create an issue first (required for foreign key)
|
|
let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
|
|
conn.execute(
|
|
"INSERT INTO issues (id, title, description, severity, status, category, source, created_at, updated_at, resolved_at, assigned_to, tags)
|
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
|
|
rusqlite::params![
|
|
"test-issue-1",
|
|
"Test Issue",
|
|
"Test description",
|
|
"medium",
|
|
"open",
|
|
"test",
|
|
"manual",
|
|
now.clone(),
|
|
now.clone(),
|
|
None::<Option<String>>,
|
|
"",
|
|
"[]",
|
|
],
|
|
)
|
|
.unwrap();
|
|
|
|
// Now insert the image attachment
|
|
conn.execute(
|
|
"INSERT INTO image_attachments (id, issue_id, file_name, file_path, file_size, mime_type, upload_hash, uploaded_at, pii_warning_acknowledged, is_paste)
|
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
|
|
rusqlite::params![
|
|
"test-img-1",
|
|
"test-issue-1",
|
|
"screenshot.png",
|
|
"/path/to/screenshot.png",
|
|
102400,
|
|
"image/png",
|
|
"abc123hash",
|
|
now,
|
|
1,
|
|
0,
|
|
],
|
|
)
|
|
.unwrap();
|
|
|
|
let (id, issue_id, file_name, mime_type, is_paste): (String, String, String, String, i32) = conn
|
|
.query_row(
|
|
"SELECT id, issue_id, file_name, mime_type, is_paste FROM image_attachments WHERE id = ?1",
|
|
["test-img-1"],
|
|
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?)),
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(id, "test-img-1");
|
|
assert_eq!(issue_id, "test-issue-1");
|
|
assert_eq!(file_name, "screenshot.png");
|
|
assert_eq!(mime_type, "image/png");
|
|
assert_eq!(is_paste, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_create_ai_providers_table() {
|
|
let conn = setup_test_db();
|
|
|
|
let count: i64 = conn
|
|
.query_row(
|
|
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='ai_providers'",
|
|
[],
|
|
|r| r.get(0),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(count, 1);
|
|
|
|
let mut stmt = conn.prepare("PRAGMA table_info(ai_providers)").unwrap();
|
|
let columns: Vec<String> = stmt
|
|
.query_map([], |row| row.get::<_, String>(1))
|
|
.unwrap()
|
|
.collect::<Result<Vec<_>, _>>()
|
|
.unwrap();
|
|
|
|
assert!(columns.contains(&"id".to_string()));
|
|
assert!(columns.contains(&"name".to_string()));
|
|
assert!(columns.contains(&"provider_type".to_string()));
|
|
assert!(columns.contains(&"api_url".to_string()));
|
|
assert!(columns.contains(&"encrypted_api_key".to_string()));
|
|
assert!(columns.contains(&"model".to_string()));
|
|
assert!(columns.contains(&"max_tokens".to_string()));
|
|
assert!(columns.contains(&"temperature".to_string()));
|
|
assert!(columns.contains(&"custom_endpoint_path".to_string()));
|
|
assert!(columns.contains(&"custom_auth_header".to_string()));
|
|
assert!(columns.contains(&"custom_auth_prefix".to_string()));
|
|
assert!(columns.contains(&"api_format".to_string()));
|
|
assert!(columns.contains(&"user_id".to_string()));
|
|
assert!(columns.contains(&"use_datastore_upload".to_string()));
|
|
assert!(columns.contains(&"created_at".to_string()));
|
|
assert!(columns.contains(&"updated_at".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_store_and_retrieve_ai_provider() {
|
|
let conn = setup_test_db();
|
|
|
|
conn.execute(
|
|
"INSERT INTO ai_providers (id, name, provider_type, api_url, encrypted_api_key, model)
|
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
|
rusqlite::params![
|
|
"test-provider-1",
|
|
"My OpenAI",
|
|
"openai",
|
|
"https://api.openai.com/v1",
|
|
"encrypted_key_123",
|
|
"gpt-4o"
|
|
],
|
|
)
|
|
.unwrap();
|
|
|
|
let (name, provider_type, api_url, encrypted_key, model): (String, String, String, String, String) = conn
|
|
.query_row(
|
|
"SELECT name, provider_type, api_url, encrypted_api_key, model FROM ai_providers WHERE name = ?1",
|
|
["My OpenAI"],
|
|
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?)),
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(name, "My OpenAI");
|
|
assert_eq!(provider_type, "openai");
|
|
assert_eq!(api_url, "https://api.openai.com/v1");
|
|
assert_eq!(encrypted_key, "encrypted_key_123");
|
|
assert_eq!(model, "gpt-4o");
|
|
}
|
|
|
|
#[test]
|
|
fn test_add_missing_columns_to_existing_table() {
|
|
let conn = Connection::open_in_memory().unwrap();
|
|
|
|
// Simulate existing table without use_datastore_upload and created_at
|
|
conn.execute_batch(
|
|
"CREATE TABLE IF NOT EXISTS ai_providers (
|
|
id TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL UNIQUE,
|
|
provider_type TEXT NOT NULL,
|
|
api_url TEXT NOT NULL,
|
|
encrypted_api_key TEXT NOT NULL,
|
|
model TEXT NOT NULL,
|
|
max_tokens INTEGER,
|
|
temperature REAL,
|
|
custom_endpoint_path TEXT,
|
|
custom_auth_header TEXT,
|
|
custom_auth_prefix TEXT,
|
|
api_format TEXT,
|
|
user_id TEXT,
|
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
);",
|
|
)
|
|
.unwrap();
|
|
|
|
// Verify columns BEFORE migration
|
|
let mut stmt = conn.prepare("PRAGMA table_info(ai_providers)").unwrap();
|
|
let columns: Vec<String> = stmt
|
|
.query_map([], |row| row.get::<_, String>(1))
|
|
.unwrap()
|
|
.collect::<Result<Vec<_>, _>>()
|
|
.unwrap();
|
|
|
|
assert!(columns.contains(&"name".to_string()));
|
|
assert!(columns.contains(&"model".to_string()));
|
|
assert!(!columns.contains(&"use_datastore_upload".to_string()));
|
|
assert!(!columns.contains(&"created_at".to_string()));
|
|
|
|
// Run migrations (should apply 015 to add missing columns)
|
|
run_migrations(&conn).unwrap();
|
|
|
|
// Verify columns AFTER migration
|
|
let mut stmt = conn.prepare("PRAGMA table_info(ai_providers)").unwrap();
|
|
let columns: Vec<String> = stmt
|
|
.query_map([], |row| row.get::<_, String>(1))
|
|
.unwrap()
|
|
.collect::<Result<Vec<_>, _>>()
|
|
.unwrap();
|
|
|
|
assert!(columns.contains(&"name".to_string()));
|
|
assert!(columns.contains(&"model".to_string()));
|
|
assert!(columns.contains(&"use_datastore_upload".to_string()));
|
|
assert!(columns.contains(&"created_at".to_string()));
|
|
|
|
// Verify data integrity - existing rows should have default values
|
|
conn.execute(
|
|
"INSERT INTO ai_providers (id, name, provider_type, api_url, encrypted_api_key, model)
|
|
VALUES (?, ?, ?, ?, ?, ?)",
|
|
rusqlite::params![
|
|
"test-provider-2",
|
|
"Test Provider",
|
|
"openai",
|
|
"https://api.example.com",
|
|
"encrypted_key_456",
|
|
"gpt-3.5-turbo"
|
|
],
|
|
)
|
|
.unwrap();
|
|
|
|
let (name, use_datastore_upload, created_at): (String, bool, String) = conn
|
|
.query_row(
|
|
"SELECT name, use_datastore_upload, created_at FROM ai_providers WHERE name = ?1",
|
|
["Test Provider"],
|
|
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(name, "Test Provider");
|
|
assert!(!use_datastore_upload);
|
|
assert!(created_at.len() > 0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_idempotent_add_missing_columns() {
|
|
let conn = Connection::open_in_memory().unwrap();
|
|
|
|
// Create table with both columns already present (simulating prior migration run)
|
|
conn.execute_batch(
|
|
"CREATE TABLE IF NOT EXISTS ai_providers (
|
|
id TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL UNIQUE,
|
|
provider_type TEXT NOT NULL,
|
|
api_url TEXT NOT NULL,
|
|
encrypted_api_key TEXT NOT NULL,
|
|
model TEXT NOT NULL,
|
|
max_tokens INTEGER,
|
|
temperature REAL,
|
|
custom_endpoint_path TEXT,
|
|
custom_auth_header TEXT,
|
|
custom_auth_prefix TEXT,
|
|
api_format TEXT,
|
|
user_id TEXT,
|
|
use_datastore_upload INTEGER DEFAULT 0,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
);",
|
|
)
|
|
.unwrap();
|
|
|
|
// Should not fail even though columns already exist
|
|
run_migrations(&conn).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn test_timeline_events_table_exists() {
|
|
let conn = setup_test_db();
|
|
let count: i64 = conn
|
|
.query_row(
|
|
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='timeline_events'",
|
|
[],
|
|
|r| r.get(0),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(count, 1);
|
|
|
|
let mut stmt = conn.prepare("PRAGMA table_info(timeline_events)").unwrap();
|
|
let columns: Vec<String> = stmt
|
|
.query_map([], |row| row.get::<_, String>(1))
|
|
.unwrap()
|
|
.collect::<Result<Vec<_>, _>>()
|
|
.unwrap();
|
|
|
|
assert!(columns.contains(&"id".to_string()));
|
|
assert!(columns.contains(&"issue_id".to_string()));
|
|
assert!(columns.contains(&"event_type".to_string()));
|
|
assert!(columns.contains(&"description".to_string()));
|
|
assert!(columns.contains(&"metadata".to_string()));
|
|
assert!(columns.contains(&"created_at".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_timeline_events_cascade_delete() {
|
|
let conn = setup_test_db();
|
|
conn.execute("PRAGMA foreign_keys = ON", []).unwrap();
|
|
|
|
let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
|
|
conn.execute(
|
|
"INSERT INTO issues (id, title, created_at, updated_at) VALUES (?1, ?2, ?3, ?4)",
|
|
rusqlite::params!["issue-1", "Test Issue", now, now],
|
|
)
|
|
.unwrap();
|
|
|
|
conn.execute(
|
|
"INSERT INTO timeline_events (id, issue_id, event_type, description, metadata, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
|
rusqlite::params!["te-1", "issue-1", "triage_started", "Started triage", "{}", "2025-01-15 10:00:00 UTC"],
|
|
)
|
|
.unwrap();
|
|
|
|
// Verify event exists
|
|
let count: i64 = conn
|
|
.query_row("SELECT COUNT(*) FROM timeline_events", [], |r| r.get(0))
|
|
.unwrap();
|
|
assert_eq!(count, 1);
|
|
|
|
// Delete issue — cascade should remove timeline event
|
|
conn.execute("DELETE FROM issues WHERE id = 'issue-1'", [])
|
|
.unwrap();
|
|
|
|
let count: i64 = conn
|
|
.query_row("SELECT COUNT(*) FROM timeline_events", [], |r| r.get(0))
|
|
.unwrap();
|
|
assert_eq!(count, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_timeline_events_indexes() {
|
|
let conn = setup_test_db();
|
|
let mut stmt = conn
|
|
.prepare(
|
|
"SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='timeline_events'",
|
|
)
|
|
.unwrap();
|
|
let indexes: Vec<String> = stmt
|
|
.query_map([], |row| row.get(0))
|
|
.unwrap()
|
|
.filter_map(|r| r.ok())
|
|
.collect();
|
|
assert!(indexes.contains(&"idx_timeline_events_issue".to_string()));
|
|
assert!(indexes.contains(&"idx_timeline_events_time".to_string()));
|
|
}
|
|
|
|
// ─── Migration 018: mcp_servers / mcp_tools / mcp_resources ─────────────
|
|
|
|
#[test]
|
|
fn test_018_migration_mcp_tables() {
|
|
let conn = setup_test_db();
|
|
|
|
for table in &["mcp_servers", "mcp_tools", "mcp_resources"] {
|
|
let count: i64 = conn
|
|
.query_row(
|
|
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
|
|
[table],
|
|
|r| r.get(0),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(count, 1, "table {table} should exist");
|
|
}
|
|
|
|
let mut stmt = conn.prepare("PRAGMA table_info(mcp_servers)").unwrap();
|
|
let cols: Vec<String> = stmt
|
|
.query_map([], |row| row.get::<_, String>(1))
|
|
.unwrap()
|
|
.collect::<Result<Vec<_>, _>>()
|
|
.unwrap();
|
|
for col in &[
|
|
"id",
|
|
"name",
|
|
"url",
|
|
"transport_type",
|
|
"transport_config",
|
|
"auth_type",
|
|
"auth_value",
|
|
"enabled",
|
|
"last_discovered_at",
|
|
"discovery_status",
|
|
"discovery_error",
|
|
"created_at",
|
|
"updated_at",
|
|
] {
|
|
assert!(
|
|
cols.contains(&col.to_string()),
|
|
"mcp_servers missing column {col}"
|
|
);
|
|
}
|
|
|
|
let mut stmt = conn.prepare("PRAGMA table_info(mcp_tools)").unwrap();
|
|
let cols: Vec<String> = stmt
|
|
.query_map([], |row| row.get::<_, String>(1))
|
|
.unwrap()
|
|
.collect::<Result<Vec<_>, _>>()
|
|
.unwrap();
|
|
for col in &[
|
|
"id",
|
|
"server_id",
|
|
"name",
|
|
"tool_key",
|
|
"description",
|
|
"parameters",
|
|
] {
|
|
assert!(
|
|
cols.contains(&col.to_string()),
|
|
"mcp_tools missing column {col}"
|
|
);
|
|
}
|
|
|
|
let mut stmt = conn.prepare("PRAGMA table_info(mcp_resources)").unwrap();
|
|
let cols: Vec<String> = stmt
|
|
.query_map([], |row| row.get::<_, String>(1))
|
|
.unwrap()
|
|
.collect::<Result<Vec<_>, _>>()
|
|
.unwrap();
|
|
for col in &["id", "server_id", "uri", "name", "description"] {
|
|
assert!(
|
|
cols.contains(&col.to_string()),
|
|
"mcp_resources missing column {col}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_018_mcp_servers_check_constraints() {
|
|
let conn = setup_test_db();
|
|
|
|
// Valid insert should succeed
|
|
conn.execute(
|
|
"INSERT INTO mcp_servers (id, name, url, transport_type, auth_type)
|
|
VALUES ('s1', 'My Server', 'http://localhost:8080/mcp', 'http', 'none')",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
|
|
// Invalid transport_type must fail
|
|
let err = conn.execute(
|
|
"INSERT INTO mcp_servers (id, name, url, transport_type, auth_type)
|
|
VALUES ('s2', 'Bad Transport', '', 'websocket', 'none')",
|
|
[],
|
|
);
|
|
assert!(err.is_err(), "invalid transport_type should be rejected");
|
|
|
|
// Invalid auth_type must fail
|
|
let err = conn.execute(
|
|
"INSERT INTO mcp_servers (id, name, url, transport_type, auth_type)
|
|
VALUES ('s3', 'Bad Auth', '', 'stdio', 'password')",
|
|
[],
|
|
);
|
|
assert!(err.is_err(), "invalid auth_type should be rejected");
|
|
|
|
// Invalid discovery_status must fail
|
|
let err = conn.execute(
|
|
"INSERT INTO mcp_servers (id, name, url, transport_type, auth_type, discovery_status)
|
|
VALUES ('s4', 'Bad Status', '', 'stdio', 'none', 'unknown')",
|
|
[],
|
|
);
|
|
assert!(err.is_err(), "invalid discovery_status should be rejected");
|
|
}
|
|
|
|
#[test]
|
|
fn test_018_mcp_tools_cascade_delete() {
|
|
let conn = setup_test_db();
|
|
conn.execute("PRAGMA foreign_keys = ON", []).unwrap();
|
|
|
|
conn.execute(
|
|
"INSERT INTO mcp_servers (id, name, url, transport_type, auth_type)
|
|
VALUES ('srv-1', 'Test', 'http://localhost/mcp', 'http', 'none')",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
|
|
conn.execute(
|
|
"INSERT INTO mcp_tools (id, server_id, name, tool_key)
|
|
VALUES ('tool-1', 'srv-1', 'echo', 'mcp_test_echo')",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
|
|
let count: i64 = conn
|
|
.query_row("SELECT COUNT(*) FROM mcp_tools", [], |r| r.get(0))
|
|
.unwrap();
|
|
assert_eq!(count, 1);
|
|
|
|
conn.execute("DELETE FROM mcp_servers WHERE id = 'srv-1'", [])
|
|
.unwrap();
|
|
|
|
let count: i64 = conn
|
|
.query_row("SELECT COUNT(*) FROM mcp_tools", [], |r| r.get(0))
|
|
.unwrap();
|
|
assert_eq!(count, 0, "cascade delete should remove mcp_tools");
|
|
}
|
|
|
|
#[test]
|
|
fn test_018_mcp_resources_cascade_delete() {
|
|
let conn = setup_test_db();
|
|
conn.execute("PRAGMA foreign_keys = ON", []).unwrap();
|
|
|
|
conn.execute(
|
|
"INSERT INTO mcp_servers (id, name, url, transport_type, auth_type)
|
|
VALUES ('srv-2', 'Test', 'http://localhost/mcp', 'http', 'none')",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
|
|
conn.execute(
|
|
"INSERT INTO mcp_resources (id, server_id, uri)
|
|
VALUES ('res-1', 'srv-2', 'file:///tmp/data.txt')",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
|
|
let count: i64 = conn
|
|
.query_row("SELECT COUNT(*) FROM mcp_resources", [], |r| r.get(0))
|
|
.unwrap();
|
|
assert_eq!(count, 1);
|
|
|
|
conn.execute("DELETE FROM mcp_servers WHERE id = 'srv-2'", [])
|
|
.unwrap();
|
|
|
|
let count: i64 = conn
|
|
.query_row("SELECT COUNT(*) FROM mcp_resources", [], |r| r.get(0))
|
|
.unwrap();
|
|
assert_eq!(count, 0, "cascade delete should remove mcp_resources");
|
|
}
|
|
|
|
#[test]
|
|
fn test_018_idempotent() {
|
|
let conn = Connection::open_in_memory().unwrap();
|
|
run_migrations(&conn).unwrap();
|
|
run_migrations(&conn).unwrap();
|
|
|
|
for table in &["mcp_servers", "mcp_tools", "mcp_resources"] {
|
|
let count: i64 = conn
|
|
.query_row(
|
|
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
|
|
[table],
|
|
|r| r.get(0),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(count, 1, "table {table} should exist after double-run");
|
|
}
|
|
|
|
let applied: i64 = conn
|
|
.query_row(
|
|
"SELECT COUNT(*) FROM _migrations WHERE name = '018_mcp_servers'",
|
|
[],
|
|
|r| r.get(0),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(applied, 1, "018 should only be recorded once");
|
|
}
|
|
|
|
// ─── Migration 019: sudo_config ─────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn test_019_sudo_config_table_exists() {
|
|
let conn = setup_test_db();
|
|
let count: i64 = conn
|
|
.query_row(
|
|
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='sudo_config'",
|
|
[],
|
|
|r| r.get(0),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(count, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn test_019_sudo_config_columns() {
|
|
let conn = setup_test_db();
|
|
let mut stmt = conn.prepare("PRAGMA table_info(sudo_config)").unwrap();
|
|
let columns: Vec<String> = stmt
|
|
.query_map([], |row| row.get::<_, String>(1))
|
|
.unwrap()
|
|
.collect::<Result<Vec<_>, _>>()
|
|
.unwrap();
|
|
|
|
assert!(columns.contains(&"id".to_string()));
|
|
assert!(columns.contains(&"username".to_string()));
|
|
assert!(columns.contains(&"encrypted_password".to_string()));
|
|
assert!(columns.contains(&"created_at".to_string()));
|
|
assert!(columns.contains(&"updated_at".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_019_idempotent() {
|
|
let conn = Connection::open_in_memory().unwrap();
|
|
run_migrations(&conn).unwrap();
|
|
run_migrations(&conn).unwrap();
|
|
|
|
let count: i64 = conn
|
|
.query_row(
|
|
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='sudo_config'",
|
|
[],
|
|
|r| r.get(0),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(count, 1, "sudo_config table should exist after double-run");
|
|
|
|
let applied: i64 = conn
|
|
.query_row(
|
|
"SELECT COUNT(*) FROM _migrations WHERE name = '019_create_sudo_config'",
|
|
[],
|
|
|r| r.get(0),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(applied, 1, "019 should only be recorded once");
|
|
}
|
|
|
|
// ─── Migration 020-022: attachment content storage ──────────────────────────
|
|
|
|
#[test]
|
|
fn test_020_log_content_compressed_column() {
|
|
let conn = setup_test_db();
|
|
let mut stmt = conn.prepare("PRAGMA table_info(log_files)").unwrap();
|
|
let columns: Vec<String> = stmt
|
|
.query_map([], |row| row.get::<_, String>(1))
|
|
.unwrap()
|
|
.collect::<Result<Vec<_>, _>>()
|
|
.unwrap();
|
|
assert!(
|
|
columns.contains(&"content_compressed".to_string()),
|
|
"log_files should have content_compressed column"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_021_image_data_column() {
|
|
let conn = setup_test_db();
|
|
let mut stmt = conn
|
|
.prepare("PRAGMA table_info(image_attachments)")
|
|
.unwrap();
|
|
let columns: Vec<String> = stmt
|
|
.query_map([], |row| row.get::<_, String>(1))
|
|
.unwrap()
|
|
.collect::<Result<Vec<_>, _>>()
|
|
.unwrap();
|
|
assert!(
|
|
columns.contains(&"image_data".to_string()),
|
|
"image_attachments should have image_data column"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_022_attachment_views_exist() {
|
|
let conn = setup_test_db();
|
|
for view in &["v_log_files_with_issue", "v_image_attachments_with_issue"] {
|
|
let count: i64 = conn
|
|
.query_row(
|
|
"SELECT COUNT(*) FROM sqlite_master WHERE type='view' AND name=?1",
|
|
[view],
|
|
|r| r.get(0),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(count, 1, "view {view} should exist");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_022_views_join_issue_title() {
|
|
let conn = setup_test_db();
|
|
conn.execute("PRAGMA foreign_keys = ON", []).unwrap();
|
|
let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
|
|
|
|
conn.execute(
|
|
"INSERT INTO issues (id, title, created_at, updated_at) VALUES (?1, ?2, ?3, ?4)",
|
|
rusqlite::params!["issue-view-1", "Disk Full Alert", now.clone(), now.clone()],
|
|
)
|
|
.unwrap();
|
|
|
|
conn.execute(
|
|
"INSERT INTO log_files (id, issue_id, file_name, file_path, uploaded_at) \
|
|
VALUES (?1, ?2, ?3, ?4, ?5)",
|
|
rusqlite::params![
|
|
"lf-1",
|
|
"issue-view-1",
|
|
"syslog.log",
|
|
"/var/log/syslog",
|
|
now.clone()
|
|
],
|
|
)
|
|
.unwrap();
|
|
|
|
let issue_title: String = conn
|
|
.query_row(
|
|
"SELECT issue_title FROM v_log_files_with_issue WHERE id = 'lf-1'",
|
|
[],
|
|
|r| r.get(0),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(issue_title, "Disk Full Alert");
|
|
}
|
|
|
|
#[test]
|
|
fn test_020_021_idempotent() {
|
|
let conn = Connection::open_in_memory().unwrap();
|
|
run_migrations(&conn).unwrap();
|
|
run_migrations(&conn).unwrap();
|
|
|
|
for migration in &[
|
|
"020_add_log_content_compressed",
|
|
"021_add_image_data",
|
|
"022_attachment_views",
|
|
] {
|
|
let count: i64 = conn
|
|
.query_row(
|
|
"SELECT COUNT(*) FROM _migrations WHERE name = ?1",
|
|
[migration],
|
|
|r| r.get(0),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(count, 1, "{migration} should be recorded exactly once");
|
|
}
|
|
}
|
|
}
|