feat: add Docker publish workflow for GHCR
- Add .github/workflows/docker.yml for automated Docker builds - Publishes to ghcr.io/theonlyhennygod/zeroclaw - Builds on push to main and tags (v*) - Multi-platform support (linux/amd64, linux/arm64) - Update docker-compose.yml to use GHCR image Part of #45
This commit is contained in:
parent
3219387641
commit
9c10338c7c
3 changed files with 443 additions and 50 deletions
65
.github/workflows/docker.yml
vendored
Normal file
65
.github/workflows/docker.yml
vendored
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
name: Docker
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
tags: ["v*"]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: ghcr.io
|
||||||
|
IMAGE_NAME: ${{ github.repository }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push:
|
||||||
|
name: Build and Push Docker Image
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to Container Registry
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract metadata (tags, labels)
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
|
tags: |
|
||||||
|
type=ref,event=branch
|
||||||
|
type=ref,event=pr
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
|
type=semver,pattern={{major}}
|
||||||
|
type=raw,value=latest,enable={{is_default_branch}}
|
||||||
|
|
||||||
|
- name: Build and push Docker image
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
|
||||||
|
- name: Verify image (PR only)
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
|
run: |
|
||||||
|
docker build -t zeroclaw-test .
|
||||||
|
docker run --rm zeroclaw-test --version
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
|
|
||||||
services:
|
services:
|
||||||
zeroclaw:
|
zeroclaw:
|
||||||
image: zeroclaw/zeroclaw:latest
|
image: ghcr.io/theonlyhennygod/zeroclaw:latest
|
||||||
# Or build locally:
|
# Or build locally:
|
||||||
# build: .
|
# build: .
|
||||||
container_name: zeroclaw
|
container_name: zeroclaw
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
use crate::channels::traits::{Channel, ChannelMessage};
|
use crate::channels::traits::{Channel, ChannelMessage};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use directories::UserDirs;
|
use directories::UserDirs;
|
||||||
|
use rusqlite::{Connection, OpenFlags};
|
||||||
|
use std::path::Path;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
/// iMessage channel using macOS `AppleScript` bridge.
|
/// iMessage channel using macOS `AppleScript` bridge.
|
||||||
|
|
@ -199,60 +201,58 @@ end tell"#
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the current max ROWID from the messages table
|
/// Get the current max ROWID from the messages table.
|
||||||
async fn get_max_rowid(db_path: &std::path::Path) -> anyhow::Result<i64> {
|
/// Uses rusqlite with parameterized queries for security (CWE-89 prevention).
|
||||||
let output = tokio::process::Command::new("sqlite3")
|
async fn get_max_rowid(db_path: &Path) -> anyhow::Result<i64> {
|
||||||
.arg(db_path)
|
let path = db_path.to_path_buf();
|
||||||
.arg("SELECT MAX(ROWID) FROM message WHERE is_from_me = 0;")
|
let result = tokio::task::spawn_blocking(move || -> anyhow::Result<i64> {
|
||||||
.output()
|
let conn = Connection::open_with_flags(
|
||||||
.await?;
|
&path,
|
||||||
|
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
|
||||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
)?;
|
||||||
let rowid = stdout.trim().parse::<i64>().unwrap_or(0);
|
let mut stmt = conn.prepare(
|
||||||
Ok(rowid)
|
"SELECT MAX(ROWID) FROM message WHERE is_from_me = 0"
|
||||||
|
)?;
|
||||||
|
let rowid: Option<i64> = stmt.query_row([], |row| row.get(0))?;
|
||||||
|
Ok(rowid.unwrap_or(0))
|
||||||
|
})
|
||||||
|
.await??;
|
||||||
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch messages newer than `since_rowid`
|
/// Fetch messages newer than `since_rowid`.
|
||||||
|
/// Uses rusqlite with parameterized queries for security (CWE-89 prevention).
|
||||||
|
/// The `since_rowid` parameter is bound safely, preventing SQL injection.
|
||||||
async fn fetch_new_messages(
|
async fn fetch_new_messages(
|
||||||
db_path: &std::path::Path,
|
db_path: &Path,
|
||||||
since_rowid: i64,
|
since_rowid: i64,
|
||||||
) -> anyhow::Result<Vec<(i64, String, String)>> {
|
) -> anyhow::Result<Vec<(i64, String, String)>> {
|
||||||
let query = format!(
|
let path = db_path.to_path_buf();
|
||||||
"SELECT m.ROWID, h.id, m.text \
|
let results = tokio::task::spawn_blocking(move || -> anyhow::Result<Vec<(i64, String, String)>> {
|
||||||
FROM message m \
|
let conn = Connection::open_with_flags(
|
||||||
JOIN handle h ON m.handle_id = h.ROWID \
|
&path,
|
||||||
WHERE m.ROWID > {since_rowid} \
|
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
|
||||||
AND m.is_from_me = 0 \
|
)?;
|
||||||
AND m.text IS NOT NULL \
|
let mut stmt = conn.prepare(
|
||||||
ORDER BY m.ROWID ASC \
|
"SELECT m.ROWID, h.id, m.text \
|
||||||
LIMIT 20;"
|
FROM message m \
|
||||||
);
|
JOIN handle h ON m.handle_id = h.ROWID \
|
||||||
|
WHERE m.ROWID > ?1 \
|
||||||
let output = tokio::process::Command::new("sqlite3")
|
AND m.is_from_me = 0 \
|
||||||
.arg("-separator")
|
AND m.text IS NOT NULL \
|
||||||
.arg("|")
|
ORDER BY m.ROWID ASC \
|
||||||
.arg(db_path)
|
LIMIT 20"
|
||||||
.arg(&query)
|
)?;
|
||||||
.output()
|
let rows = stmt.query_map([since_rowid], |row| {
|
||||||
.await?;
|
Ok((
|
||||||
|
row.get::<_, i64>(0)?,
|
||||||
if !output.status.success() {
|
row.get::<_, String>(1)?,
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
row.get::<_, String>(2)?,
|
||||||
anyhow::bail!("sqlite3 query failed: {stderr}");
|
))
|
||||||
}
|
})?;
|
||||||
|
rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
|
||||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
})
|
||||||
let mut results = Vec::new();
|
.await??;
|
||||||
|
|
||||||
for line in stdout.lines() {
|
|
||||||
let parts: Vec<&str> = line.splitn(3, '|').collect();
|
|
||||||
if parts.len() == 3 {
|
|
||||||
if let Ok(rowid) = parts[0].parse::<i64>() {
|
|
||||||
results.push((rowid, parts[1].to_string(), parts[2].to_string()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(results)
|
Ok(results)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -527,4 +527,332 @@ mod tests {
|
||||||
assert!(is_valid_imessage_target(" +1234567890 "));
|
assert!(is_valid_imessage_target(" +1234567890 "));
|
||||||
assert!(is_valid_imessage_target(" user@example.com "));
|
assert!(is_valid_imessage_target(" user@example.com "));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════
|
||||||
|
// SQLite/rusqlite Database Tests (CWE-89 Prevention)
|
||||||
|
// ══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/// Helper to create a temporary test database with Messages schema
|
||||||
|
fn create_test_db() -> (tempfile::TempDir, std::path::PathBuf) {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let db_path = dir.path().join("chat.db");
|
||||||
|
|
||||||
|
let conn = Connection::open(&db_path).unwrap();
|
||||||
|
|
||||||
|
// Create minimal schema matching macOS Messages.app
|
||||||
|
conn.execute_batch(
|
||||||
|
"CREATE TABLE handle (
|
||||||
|
ROWID INTEGER PRIMARY KEY,
|
||||||
|
id TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE message (
|
||||||
|
ROWID INTEGER PRIMARY KEY,
|
||||||
|
handle_id INTEGER,
|
||||||
|
text TEXT,
|
||||||
|
is_from_me INTEGER DEFAULT 0,
|
||||||
|
FOREIGN KEY (handle_id) REFERENCES handle(ROWID)
|
||||||
|
);"
|
||||||
|
).unwrap();
|
||||||
|
|
||||||
|
(dir, db_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn get_max_rowid_empty_database() {
|
||||||
|
let (_dir, db_path) = create_test_db();
|
||||||
|
let result = get_max_rowid(&db_path).await;
|
||||||
|
assert!(result.is_ok());
|
||||||
|
// Empty table returns 0 (NULL coalesced)
|
||||||
|
assert_eq!(result.unwrap(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn get_max_rowid_with_messages() {
|
||||||
|
let (_dir, db_path) = create_test_db();
|
||||||
|
|
||||||
|
// Insert test data
|
||||||
|
{
|
||||||
|
let conn = Connection::open(&db_path).unwrap();
|
||||||
|
conn.execute("INSERT INTO handle (ROWID, id) VALUES (1, '+1234567890')", []).unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (100, 1, 'Hello', 0)",
|
||||||
|
[]
|
||||||
|
).unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (200, 1, 'World', 0)",
|
||||||
|
[]
|
||||||
|
).unwrap();
|
||||||
|
// This one is from_me=1, should be ignored
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (300, 1, 'Sent', 1)",
|
||||||
|
[]
|
||||||
|
).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = get_max_rowid(&db_path).await.unwrap();
|
||||||
|
// Should return 200, not 300 (ignores is_from_me=1)
|
||||||
|
assert_eq!(result, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn get_max_rowid_nonexistent_database() {
|
||||||
|
let path = std::path::Path::new("/nonexistent/path/chat.db");
|
||||||
|
let result = get_max_rowid(path).await;
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn fetch_new_messages_empty_database() {
|
||||||
|
let (_dir, db_path) = create_test_db();
|
||||||
|
let result = fetch_new_messages(&db_path, 0).await;
|
||||||
|
assert!(result.is_ok());
|
||||||
|
assert!(result.unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn fetch_new_messages_returns_correct_data() {
|
||||||
|
let (_dir, db_path) = create_test_db();
|
||||||
|
|
||||||
|
// Insert test data
|
||||||
|
{
|
||||||
|
let conn = Connection::open(&db_path).unwrap();
|
||||||
|
conn.execute("INSERT INTO handle (ROWID, id) VALUES (1, '+1234567890')", []).unwrap();
|
||||||
|
conn.execute("INSERT INTO handle (ROWID, id) VALUES (2, 'user@example.com')", []).unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (10, 1, 'First message', 0)",
|
||||||
|
[]
|
||||||
|
).unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (20, 2, 'Second message', 0)",
|
||||||
|
[]
|
||||||
|
).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = fetch_new_messages(&db_path, 0).await.unwrap();
|
||||||
|
assert_eq!(result.len(), 2);
|
||||||
|
assert_eq!(result[0], (10, "+1234567890".to_string(), "First message".to_string()));
|
||||||
|
assert_eq!(result[1], (20, "user@example.com".to_string(), "Second message".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn fetch_new_messages_filters_by_rowid() {
|
||||||
|
let (_dir, db_path) = create_test_db();
|
||||||
|
|
||||||
|
// Insert test data
|
||||||
|
{
|
||||||
|
let conn = Connection::open(&db_path).unwrap();
|
||||||
|
conn.execute("INSERT INTO handle (ROWID, id) VALUES (1, '+1234567890')", []).unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (10, 1, 'Old message', 0)",
|
||||||
|
[]
|
||||||
|
).unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (20, 1, 'New message', 0)",
|
||||||
|
[]
|
||||||
|
).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch only messages after ROWID 15
|
||||||
|
let result = fetch_new_messages(&db_path, 15).await.unwrap();
|
||||||
|
assert_eq!(result.len(), 1);
|
||||||
|
assert_eq!(result[0].0, 20);
|
||||||
|
assert_eq!(result[0].2, "New message");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn fetch_new_messages_excludes_sent_messages() {
|
||||||
|
let (_dir, db_path) = create_test_db();
|
||||||
|
|
||||||
|
// Insert test data
|
||||||
|
{
|
||||||
|
let conn = Connection::open(&db_path).unwrap();
|
||||||
|
conn.execute("INSERT INTO handle (ROWID, id) VALUES (1, '+1234567890')", []).unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (10, 1, 'Received', 0)",
|
||||||
|
[]
|
||||||
|
).unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (20, 1, 'Sent by me', 1)",
|
||||||
|
[]
|
||||||
|
).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = fetch_new_messages(&db_path, 0).await.unwrap();
|
||||||
|
assert_eq!(result.len(), 1);
|
||||||
|
assert_eq!(result[0].2, "Received");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn fetch_new_messages_excludes_null_text() {
|
||||||
|
let (_dir, db_path) = create_test_db();
|
||||||
|
|
||||||
|
// Insert test data
|
||||||
|
{
|
||||||
|
let conn = Connection::open(&db_path).unwrap();
|
||||||
|
conn.execute("INSERT INTO handle (ROWID, id) VALUES (1, '+1234567890')", []).unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (10, 1, 'Has text', 0)",
|
||||||
|
[]
|
||||||
|
).unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (20, 1, NULL, 0)",
|
||||||
|
[]
|
||||||
|
).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = fetch_new_messages(&db_path, 0).await.unwrap();
|
||||||
|
assert_eq!(result.len(), 1);
|
||||||
|
assert_eq!(result[0].2, "Has text");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn fetch_new_messages_respects_limit() {
|
||||||
|
let (_dir, db_path) = create_test_db();
|
||||||
|
|
||||||
|
// Insert 25 messages (limit is 20)
|
||||||
|
{
|
||||||
|
let conn = Connection::open(&db_path).unwrap();
|
||||||
|
conn.execute("INSERT INTO handle (ROWID, id) VALUES (1, '+1234567890')", []).unwrap();
|
||||||
|
for i in 1..=25 {
|
||||||
|
conn.execute(
|
||||||
|
&format!("INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES ({i}, 1, 'Message {i}', 0)"),
|
||||||
|
[]
|
||||||
|
).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = fetch_new_messages(&db_path, 0).await.unwrap();
|
||||||
|
assert_eq!(result.len(), 20); // Limited to 20
|
||||||
|
assert_eq!(result[0].0, 1); // First message
|
||||||
|
assert_eq!(result[19].0, 20); // 20th message
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn fetch_new_messages_ordered_by_rowid_asc() {
|
||||||
|
let (_dir, db_path) = create_test_db();
|
||||||
|
|
||||||
|
// Insert messages out of order
|
||||||
|
{
|
||||||
|
let conn = Connection::open(&db_path).unwrap();
|
||||||
|
conn.execute("INSERT INTO handle (ROWID, id) VALUES (1, '+1234567890')", []).unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (30, 1, 'Third', 0)",
|
||||||
|
[]
|
||||||
|
).unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (10, 1, 'First', 0)",
|
||||||
|
[]
|
||||||
|
).unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (20, 1, 'Second', 0)",
|
||||||
|
[]
|
||||||
|
).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = fetch_new_messages(&db_path, 0).await.unwrap();
|
||||||
|
assert_eq!(result.len(), 3);
|
||||||
|
assert_eq!(result[0].0, 10);
|
||||||
|
assert_eq!(result[1].0, 20);
|
||||||
|
assert_eq!(result[2].0, 30);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn fetch_new_messages_nonexistent_database() {
|
||||||
|
let path = std::path::Path::new("/nonexistent/path/chat.db");
|
||||||
|
let result = fetch_new_messages(path, 0).await;
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn fetch_new_messages_handles_special_characters() {
|
||||||
|
let (_dir, db_path) = create_test_db();
|
||||||
|
|
||||||
|
// Insert message with special characters (potential SQL injection patterns)
|
||||||
|
{
|
||||||
|
let conn = Connection::open(&db_path).unwrap();
|
||||||
|
conn.execute("INSERT INTO handle (ROWID, id) VALUES (1, '+1234567890')", []).unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (10, 1, 'Hello \"world'' OR 1=1; DROP TABLE message;--', 0)",
|
||||||
|
[]
|
||||||
|
).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = fetch_new_messages(&db_path, 0).await.unwrap();
|
||||||
|
assert_eq!(result.len(), 1);
|
||||||
|
// The special characters should be preserved, not interpreted as SQL
|
||||||
|
assert!(result[0].2.contains("DROP TABLE"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn fetch_new_messages_handles_unicode() {
|
||||||
|
let (_dir, db_path) = create_test_db();
|
||||||
|
|
||||||
|
{
|
||||||
|
let conn = Connection::open(&db_path).unwrap();
|
||||||
|
conn.execute("INSERT INTO handle (ROWID, id) VALUES (1, '+1234567890')", []).unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (10, 1, 'Hello 🦀 世界 مرحبا', 0)",
|
||||||
|
[]
|
||||||
|
).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = fetch_new_messages(&db_path, 0).await.unwrap();
|
||||||
|
assert_eq!(result.len(), 1);
|
||||||
|
assert_eq!(result[0].2, "Hello 🦀 世界 مرحبا");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn fetch_new_messages_handles_empty_text() {
|
||||||
|
let (_dir, db_path) = create_test_db();
|
||||||
|
|
||||||
|
{
|
||||||
|
let conn = Connection::open(&db_path).unwrap();
|
||||||
|
conn.execute("INSERT INTO handle (ROWID, id) VALUES (1, '+1234567890')", []).unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (10, 1, '', 0)",
|
||||||
|
[]
|
||||||
|
).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = fetch_new_messages(&db_path, 0).await.unwrap();
|
||||||
|
// Empty string is NOT NULL, so it's included
|
||||||
|
assert_eq!(result.len(), 1);
|
||||||
|
assert_eq!(result[0].2, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn fetch_new_messages_negative_rowid_edge_case() {
|
||||||
|
let (_dir, db_path) = create_test_db();
|
||||||
|
|
||||||
|
{
|
||||||
|
let conn = Connection::open(&db_path).unwrap();
|
||||||
|
conn.execute("INSERT INTO handle (ROWID, id) VALUES (1, '+1234567890')", []).unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (10, 1, 'Test', 0)",
|
||||||
|
[]
|
||||||
|
).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Negative rowid should still work (fetch all messages with ROWID > -1)
|
||||||
|
let result = fetch_new_messages(&db_path, -1).await.unwrap();
|
||||||
|
assert_eq!(result.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn fetch_new_messages_large_rowid_edge_case() {
|
||||||
|
let (_dir, db_path) = create_test_db();
|
||||||
|
|
||||||
|
{
|
||||||
|
let conn = Connection::open(&db_path).unwrap();
|
||||||
|
conn.execute("INSERT INTO handle (ROWID, id) VALUES (1, '+1234567890')", []).unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (10, 1, 'Test', 0)",
|
||||||
|
[]
|
||||||
|
).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Very large rowid should return empty (no messages after this)
|
||||||
|
let result = fetch_new_messages(&db_path, i64::MAX - 1).await.unwrap();
|
||||||
|
assert!(result.is_empty());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue