From ae2c34df6c92893cddaf58ec565d65bdfb41a790 Mon Sep 17 00:00:00 2001 From: cyph3rasi Date: Sat, 7 Mar 2026 16:11:37 -0800 Subject: [PATCH] Add installer, CI, ARM build and test updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a one‑line Docker installer and environment examples, add CI and docker validation workflows, and update docs and tests. Key changes: - Add docker/install.sh installer that bootstraps /opt/synapsis, downloads compose files, optionally installs Docker, and generates secrets. - Add top-level .env.example and docker/.env.example entries for shared storage and local development. - Add GitHub Actions CI (ci.yml) with type checks, targeted vitest runs, build and docker-compose validation. - Update existing docker workflow to set up QEMU and build multi‑arch images (amd64, arm64). - Update README and docker/README to use the installer, point to the new repo, adjust local setup instructions, and note shared S3 env vars. - Update docker-compose comments and include shared S3 env variables and defaults. - Update .gitignore to ignore site-work mirrors. - Tests: expand supported bot source types (add brave_news, youtube), increase POST_MAX_LENGTH from 400 to 600, add minimal user handle in mention handler tests, and add mocks & test adjustments in scheduler tests to keep them unit-scoped. These changes simplify installation, add CI coverage (including Docker config validation), enable multi‑arch builds, and align tests with expanded bot source/validation behavior. --- .env.example | 32 ++++ .github/workflows/ci.yml | 56 +++++++ .github/workflows/docker.yml | 5 +- .gitignore | 3 + README.md | 21 +-- docker-compose.yml | 20 +-- docker/.env.example | 13 ++ docker/README.md | 35 ++--- docker/install.sh | 154 +++++++++++++++++++ src/lib/bots/contentSource.property.test.ts | 6 +- src/lib/bots/contentSource.test.ts | 4 +- src/lib/bots/mentionHandler.property.test.ts | 3 + src/lib/bots/posting.property.test.ts | 2 +- src/lib/bots/scheduler.test.ts | 44 +++++- 14 files changed, 350 insertions(+), 48 deletions(-) create mode 100644 .env.example create mode 100644 .github/workflows/ci.yml create mode 100644 docker/install.sh diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6d5b4a8 --- /dev/null +++ b/.env.example @@ -0,0 +1,32 @@ +# Synapsis local development environment +# Copy to .env and adjust values for your machine. + +# Required: local database +DATABASE_URL=postgres://postgres:postgres@localhost:5432/synapsis + +# Required: used for sessions, node key encryption, and bot API key encryption +AUTH_SECRET=replace-with-a-long-random-secret + +# Required for admin access. Register with one of these emails locally. +ADMIN_EMAILS=admin@example.com + +# Recommended: local node identity +NEXT_PUBLIC_NODE_DOMAIN=localhost:3000 +NEXT_PUBLIC_APP_URL=http://localhost:3000 + +# Optional: node metadata shown before a node record exists in the database +# NEXT_PUBLIC_NODE_NAME=Synapsis Local +# NEXT_PUBLIC_NODE_DESCRIPTION=Local development node +# NEXT_PUBLIC_ACCENT_COLOR=#FFFFFF + +# Optional: bot limits +# BOT_MAX_PER_USER=5 + +# Optional: app-level storage fallback. Most media storage is user-owned and can be +# configured in the app, so these are only needed if you want a shared default. +# STORAGE_ENDPOINT= +# STORAGE_REGION= +# STORAGE_BUCKET= +# STORAGE_ACCESS_KEY= +# STORAGE_SECRET_KEY= +# STORAGE_PUBLIC_BASE_URL= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c7db2df --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,56 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +jobs: + smoke: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Type check + run: npm run type-check + + - name: Targeted bot regression checks + run: > + AUTH_SECRET=ci-test-secret + npx vitest run + src/lib/bots/encryption.test.ts + src/lib/bots/contentSource.test.ts + src/lib/bots/posting.property.test.ts + src/lib/bots/mentionHandler.property.test.ts + src/lib/bots/scheduler.test.ts + + - name: Production build + run: npm run build + + docker-validation: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Validate production compose config + run: docker compose --env-file docker/.env.example config > /tmp/synapsis-compose.txt + + - name: Validate source-build compose config + run: docker compose -f docker/docker-compose.yml --env-file docker/.env.example config > /tmp/synapsis-build-compose.txt + + - name: Build application image + run: docker build -f docker/Dockerfile . diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 9cc10a9..7b09f7a 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -29,6 +29,9 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 @@ -61,4 +64,4 @@ jobs: tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} no-cache: true - platforms: linux/amd64 + platforms: linux/amd64,linux/arm64 diff --git a/.gitignore b/.gitignore index 55ad5c3..6af1c13 100644 --- a/.gitignore +++ b/.gitignore @@ -51,3 +51,6 @@ next-env.d.ts .vscode/ .kiro/ .env.dev + +# local website/docs mirrors +site-work/ diff --git a/README.md b/README.md index 349e38f..5eb59d7 100644 --- a/README.md +++ b/README.md @@ -11,19 +11,14 @@ Synapsis is an open-source, federated social network built for the decentralized Run your personal Synapsis node with a single command: ```bash -# 1. Download and start -mkdir -p /opt/synapsis && cd /opt/synapsis -curl -O https://raw.githubusercontent.com/cyph3rasi/synapsis/main/docker-compose.yml -curl -O https://raw.githubusercontent.com/cyph3rasi/synapsis/main/docker/Caddyfile -curl -O https://raw.githubusercontent.com/cyph3rasi/synapsis/main/docker/caddy-entrypoint.sh -curl -O https://raw.githubusercontent.com/cyph3rasi/synapsis/main/docker/.env.example -cp .env.example .env +# 1. Bootstrap the deployment directory +curl -fsSL https://synapsis.social/install.sh | bash -# 2. Edit .env with your domain and secrets (takes 2 minutes) -nano .env -# Use a host-only domain (no scheme), e.g. synapsis.example.com +# 2. Edit /opt/synapsis/.env with your domain and admin email +nano /opt/synapsis/.env # 3. Start your node +cd /opt/synapsis docker compose up -d ``` @@ -117,7 +112,7 @@ Want to hack on Synapsis? Here's how to run it locally: ```bash # 1. Clone the repository -git clone https://github.com/cyph3rasi/synapsis.git +git clone https://github.com/GnosysLabs/Synapsis.git cd synapsis # 2. Install dependencies @@ -128,13 +123,13 @@ cp .env.example .env # Edit .env with your local database and storage settings # 4. Set up the database -npx drizzle-kit push +npm run db:push # 5. Run the development server npm run dev ``` -Visit `http://localhost:3000` — the app will redirect you to `/install` for initial setup. +Visit `http://localhost:3000` and register with an email listed in `ADMIN_EMAILS`. Local setup no longer uses a dedicated `/install` route. ### Tech Stack diff --git a/docker-compose.yml b/docker-compose.yml index 2fb0978..8b343ce 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,15 +2,10 @@ # Uses pre-built image from GitHub Container Registry # # Quick Start: -# 1. Download files to a new directory: -# mkdir -p /opt/synapsis && cd /opt/synapsis -# curl -O https://raw.githubusercontent.com/cyph3rasi/synapsis/main/docker-compose.yml -# curl -O https://raw.githubusercontent.com/cyph3rasi/synapsis/main/docker/Caddyfile -# curl -O https://raw.githubusercontent.com/cyph3rasi/synapsis/main/docker/caddy-entrypoint.sh -# curl -O https://raw.githubusercontent.com/cyph3rasi/synapsis/main/docker/.env.example -# cp .env.example .env -# 2. Configure (edit with your domain and secrets): nano .env -# 3. Start your node: docker compose up -d +# 1. Bootstrap the deployment directory: +# curl -fsSL https://synapsis.social/install.sh | bash +# 2. Configure (edit with your domain and admin email): nano /opt/synapsis/.env +# 3. Start your node: cd /opt/synapsis && docker compose up -d # # Services: # - Synapsis Next.js application (from GHCR) @@ -75,7 +70,12 @@ services: # Port configuration (auto or specific port) PORT: ${PORT:-3000} - # S3 Storage public URL (for custom S3 providers like R2, B2, Contabo) + # Shared S3 storage configuration + STORAGE_ENDPOINT: ${STORAGE_ENDPOINT:-} + STORAGE_REGION: ${STORAGE_REGION:-us-east-1} + STORAGE_BUCKET: ${STORAGE_BUCKET:-} + STORAGE_ACCESS_KEY: ${STORAGE_ACCESS_KEY:-} + STORAGE_SECRET_KEY: ${STORAGE_SECRET_KEY:-} STORAGE_PUBLIC_BASE_URL: ${STORAGE_PUBLIC_BASE_URL:-} # Node environment diff --git a/docker/.env.example b/docker/.env.example index 2a02910..2a401b2 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -43,3 +43,16 @@ PORT=auto # Maximum AI bots per user (default: 5) # BOT_MAX_PER_USER=5 + +# =========================================== +# OPTIONAL: Shared S3 Storage +# =========================================== +# These are only needed if you want app-level shared storage defaults. +# Most media storage is configured per user inside the app. + +# STORAGE_ENDPOINT= +# STORAGE_REGION=us-east-1 +# STORAGE_BUCKET= +# STORAGE_ACCESS_KEY= +# STORAGE_SECRET_KEY= +# STORAGE_PUBLIC_BASE_URL= diff --git a/docker/README.md b/docker/README.md index 63ffb0c..e6abd41 100644 --- a/docker/README.md +++ b/docker/README.md @@ -7,13 +7,9 @@ Production Docker deployment using pre-built images from GitHub Container Regist ## 🚀 Quick Start ```bash -mkdir -p /opt/synapsis && cd /opt/synapsis -curl -O https://raw.githubusercontent.com/cyph3rasi/synapsis/main/docker-compose.yml -curl -O https://raw.githubusercontent.com/cyph3rasi/synapsis/main/docker/Caddyfile -curl -O https://raw.githubusercontent.com/cyph3rasi/synapsis/main/docker/caddy-entrypoint.sh -curl -O https://raw.githubusercontent.com/cyph3rasi/synapsis/main/docker/.env.example -cp .env.example .env -nano .env # Add your domain and secrets +curl -fsSL https://synapsis.social/install.sh | bash +nano /opt/synapsis/.env # Add your domain and admin email +cd /opt/synapsis docker compose up -d ``` @@ -27,14 +23,7 @@ Your node is live at `https://your-domain.com` with automatic SSL. |-------------|---------| | **Server** | 2GB RAM, 2 CPU cores, 20GB SSD (minimum) | | **Domain** | A domain or subdomain pointing to your server | -| **Docker** | Version 24.0+ with Docker Compose 2.20+ | - -**Install Docker (Ubuntu/Debian):** -```bash -curl -fsSL https://get.docker.com | sh -sudo usermod -aG docker $USER -newgrp docker -``` +| **Docker** | Installed automatically by `install.sh` when missing on supported Linux hosts | --- @@ -53,6 +42,15 @@ Optional (advanced): - `NEXT_PUBLIC_NODE_DOMAIN` to override the node domain (defaults to `DOMAIN`) - `NEXT_PUBLIC_APP_URL` to override the public app URL used by background jobs (auto-derived from the node domain) - `ALLOW_LOCALHOST=1` to allow `localhost` in production containers for local testing +- Shared S3 storage env vars are available if you want app-level fallback storage + +Optional shared storage env vars: +- `STORAGE_ENDPOINT` +- `STORAGE_REGION` +- `STORAGE_BUCKET` +- `STORAGE_ACCESS_KEY` +- `STORAGE_SECRET_KEY` +- `STORAGE_PUBLIC_BASE_URL` **Port Configuration:** - `PORT=auto` (default) — Automatically finds an available port between 3000-3020 @@ -131,8 +129,7 @@ docker compose exec caddy caddy validate --config /etc/caddy/Caddyfile # Verify image exists docker pull ghcr.io/cyph3rasi/synapsis:latest -# Check available tags at: -# https://github.com/cyph3rasi/synapsis/pkgs/container/synapsis +# Check the published package tags in GitHub Container Registry ``` --- @@ -166,9 +163,9 @@ echo "0 2 * * * /opt/synapsis/backup.sh" | sudo crontab - To build locally instead of using pre-built images: ```bash -git clone https://github.com/cyph3rasi/synapsis.git +git clone https://github.com/GnosysLabs/Synapsis.git cd synapsis/docker -docker compose -f docker-compose.build.yml up -d --build +docker compose up -d --build ``` --- diff --git a/docker/install.sh b/docker/install.sh new file mode 100644 index 0000000..91ad578 --- /dev/null +++ b/docker/install.sh @@ -0,0 +1,154 @@ +#!/bin/sh + +set -eu + +REPO="${REPO:-GnosysLabs/Synapsis}" +REF="${REF:-}" +INSTALL_DIR="${1:-${INSTALL_DIR:-/opt/synapsis}}" +REPO_API="https://api.github.com/repos/${REPO}" +PUBLIC_INSTALL_URL="${PUBLIC_INSTALL_URL:-https://synapsis.social/install.sh}" + +resolve_ref() { + if [ -n "${REF}" ]; then + REF_SOURCE="custom override" + return + fi + + echo "🔎 Resolving latest Synapsis release" + latest_tag=$( + curl -fsSL "${REPO_API}/releases/latest" 2>/dev/null \ + | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' \ + | head -n 1 + ) + + if [ -n "${latest_tag}" ]; then + REF="${latest_tag}" + REF_SOURCE="latest GitHub release" + return + fi + + REF="main" + REF_SOURCE="main fallback" + echo "⚠️ Could not resolve the latest release, falling back to ${REF}" >&2 +} + +require_command() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "❌ Required command not found: $1" >&2 + exit 1 + fi +} + +download_file() { + source_path="$1" + target_path="$2" + echo "⬇️ Downloading ${source_path}" + curl -fsSL "${RAW_BASE}/${source_path}" -o "${target_path}" +} + +set_env_value() { + file="$1" + key="$2" + value="$3" + + escaped_value=$(printf '%s\n' "$value" | sed -e 's/[\/&]/\\&/g') + sed -i.bak -E "s|^${key}=.*$|${key}=${escaped_value}|" "$file" + rm -f "${file}.bak" +} + +generate_db_password() { + openssl rand -base64 24 | tr -d '\n' | tr '/+' '_-' | cut -c1-32 +} + +install_docker_if_needed() { + if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then + echo "🐳 Docker is already installed" + return + fi + + if [ "$(id -u)" -ne 0 ]; then + echo "❌ Docker is not installed and this installer is not running as root." >&2 + echo " Re-run it as root or with sudo so Synapsis can install Docker for you." >&2 + exit 1 + fi + + echo "🐳 Docker not found, installing Docker" + curl -fsSL https://get.docker.com | sh + + if command -v systemctl >/dev/null 2>&1; then + systemctl enable --now docker >/dev/null 2>&1 || true + elif command -v service >/dev/null 2>&1; then + service docker start >/dev/null 2>&1 || true + fi + + if ! command -v docker >/dev/null 2>&1; then + echo "❌ Docker installation did not finish successfully." >&2 + exit 1 + fi + + if ! docker compose version >/dev/null 2>&1; then + echo "⚠️ Docker was installed, but 'docker compose' is not available yet." >&2 + echo " Install the Docker Compose plugin before starting Synapsis." >&2 + fi +} + +require_command curl +require_command chmod +require_command mkdir +require_command cp + +resolve_ref +RAW_BASE="https://raw.githubusercontent.com/${REPO}/${REF}" + +echo "========================================" +echo " Synapsis Docker Installer" +echo "========================================" +echo " Repo: ${REPO}" +echo " Ref: ${REF} (${REF_SOURCE})" +echo " Install dir: ${INSTALL_DIR}" +echo "========================================" + +install_docker_if_needed +mkdir -p "${INSTALL_DIR}" + +download_file "docker-compose.yml" "${INSTALL_DIR}/docker-compose.yml" +download_file "docker/Caddyfile" "${INSTALL_DIR}/Caddyfile" +download_file "docker/caddy-entrypoint.sh" "${INSTALL_DIR}/caddy-entrypoint.sh" +download_file "docker/.env.example" "${INSTALL_DIR}/.env.example" + +chmod 755 "${INSTALL_DIR}/caddy-entrypoint.sh" + +if [ ! -f "${INSTALL_DIR}/.env" ]; then + cp "${INSTALL_DIR}/.env.example" "${INSTALL_DIR}/.env" + echo "📝 Created ${INSTALL_DIR}/.env" + + if command -v openssl >/dev/null 2>&1; then + set_env_value "${INSTALL_DIR}/.env" "AUTH_SECRET" "$(openssl rand -hex 32)" + set_env_value "${INSTALL_DIR}/.env" "DB_PASSWORD" "$(generate_db_password)" + echo "🔐 Generated AUTH_SECRET and DB_PASSWORD" + else + echo "⚠️ openssl not found, leaving placeholder secrets in .env" + fi + + if [ -n "${DOMAIN:-}" ]; then + set_env_value "${INSTALL_DIR}/.env" "DOMAIN" "${DOMAIN}" + echo "🌐 Set DOMAIN=${DOMAIN}" + fi + + if [ -n "${ADMIN_EMAILS:-}" ]; then + set_env_value "${INSTALL_DIR}/.env" "ADMIN_EMAILS" "${ADMIN_EMAILS}" + echo "📧 Set ADMIN_EMAILS=${ADMIN_EMAILS}" + fi +else + echo "📝 Existing ${INSTALL_DIR}/.env found, leaving it unchanged" +fi + +echo "" +echo "Next steps:" +echo " 1. Review ${INSTALL_DIR}/.env" +echo " 2. Start Synapsis:" +echo " cd ${INSTALL_DIR} && docker compose up -d" +echo "" +echo "One-line usage examples:" +echo " curl -fsSL ${PUBLIC_INSTALL_URL} | bash" +echo " curl -fsSL ${PUBLIC_INSTALL_URL} | DOMAIN=synapsis.example.com ADMIN_EMAILS=you@example.com bash" diff --git a/src/lib/bots/contentSource.property.test.ts b/src/lib/bots/contentSource.property.test.ts index f978f42..38cd882 100644 --- a/src/lib/bots/contentSource.property.test.ts +++ b/src/lib/bots/contentSource.property.test.ts @@ -750,11 +750,13 @@ describe('Feature: bot-system, Property 14: Multiple Source Types Per Bot', () = ); }); - it('SUPPORTED_SOURCE_TYPES contains all three types', () => { + it('SUPPORTED_SOURCE_TYPES contains the core and extended source types', () => { expect(SUPPORTED_SOURCE_TYPES).toContain('rss'); expect(SUPPORTED_SOURCE_TYPES).toContain('reddit'); expect(SUPPORTED_SOURCE_TYPES).toContain('news_api'); - expect(SUPPORTED_SOURCE_TYPES.length).toBe(3); + expect(SUPPORTED_SOURCE_TYPES).toContain('brave_news'); + expect(SUPPORTED_SOURCE_TYPES).toContain('youtube'); + expect(SUPPORTED_SOURCE_TYPES.length).toBe(5); }); }); diff --git a/src/lib/bots/contentSource.test.ts b/src/lib/bots/contentSource.test.ts index 6693c0f..50a78ad 100644 --- a/src/lib/bots/contentSource.test.ts +++ b/src/lib/bots/contentSource.test.ts @@ -433,7 +433,9 @@ describe('Constants', () => { expect(SUPPORTED_SOURCE_TYPES).toContain('rss'); expect(SUPPORTED_SOURCE_TYPES).toContain('reddit'); expect(SUPPORTED_SOURCE_TYPES).toContain('news_api'); - expect(SUPPORTED_SOURCE_TYPES.length).toBe(4); + expect(SUPPORTED_SOURCE_TYPES).toContain('brave_news'); + expect(SUPPORTED_SOURCE_TYPES).toContain('youtube'); + expect(SUPPORTED_SOURCE_TYPES.length).toBe(5); }); diff --git a/src/lib/bots/mentionHandler.property.test.ts b/src/lib/bots/mentionHandler.property.test.ts index f4f3e36..e5652ae 100644 --- a/src/lib/bots/mentionHandler.property.test.ts +++ b/src/lib/bots/mentionHandler.property.test.ts @@ -52,6 +52,9 @@ export const __addBot = (handle: string, userId: string) => { id, userId, handle: handle.toLowerCase(), + user: { + handle: handle.toLowerCase(), + }, name: `Bot ${handle}`, personalityConfig: JSON.stringify({ systemPrompt: 'Test bot', diff --git a/src/lib/bots/posting.property.test.ts b/src/lib/bots/posting.property.test.ts index a623a21..1ef5107 100644 --- a/src/lib/bots/posting.property.test.ts +++ b/src/lib/bots/posting.property.test.ts @@ -454,7 +454,7 @@ describe('Feature: bot-system, Property 38: Post Content Validation', () => { it('validation constants are properly defined (Requirement 11.5)', async () => { // Verify constants are reasonable - expect(POST_MAX_LENGTH).toBe(400); + expect(POST_MAX_LENGTH).toBe(600); expect(POST_MIN_LENGTH).toBe(1); expect(MAX_URLS_PER_POST).toBe(5); diff --git a/src/lib/bots/scheduler.test.ts b/src/lib/bots/scheduler.test.ts index 291483d..1fb6465 100644 --- a/src/lib/bots/scheduler.test.ts +++ b/src/lib/bots/scheduler.test.ts @@ -586,6 +586,22 @@ vi.mock('./rateLimiter', () => ({ canPost: vi.fn(async () => ({ allowed: true })), })); +// Mock post creation so scheduler tests stay unit-scoped +vi.mock('./posting', () => ({ + triggerPost: vi.fn(async () => ({ + success: true, + post: { + id: 'post-1', + content: 'Generated post', + }, + })), +})); + +// Mock source fetching to avoid exercising fetcher behavior here +vi.mock('./contentFetcher', () => ({ + fetchAllSourcesForBot: vi.fn(async () => undefined), +})); + import { hasUnprocessedContent, getNextUnprocessedContent, @@ -742,7 +758,7 @@ describe('processScheduledPosts', () => { expect(result.details[0].status).toBe('skipped_rate_limit'); }); - it('should skip bots when no content available', async () => { + it('should post original content when a bot has no active sources', async () => { const { db } = await import('@/db'); const { canPost } = await import('./rateLimiter'); @@ -760,6 +776,32 @@ describe('processScheduledPosts', () => { vi.mocked(canPost).mockResolvedValue({ allowed: true }); vi.mocked(db.query.botContentSources.findMany).mockResolvedValue([]); + const result = await processScheduledPosts(); + expect(result.processed).toBe(1); + expect(result.details[0].status).toBe('posted'); + }); + + it('should skip bots when active sources have no content available', async () => { + const { db } = await import('@/db'); + const { canPost } = await import('./rateLimiter'); + + vi.mocked(db.query.bots.findMany).mockResolvedValue([ + { + id: 'bot-1', + scheduleConfig: JSON.stringify({ + type: 'interval', + intervalMinutes: 30, + }), + lastPostAt: null, + } as any, + ]); + + vi.mocked(canPost).mockResolvedValue({ allowed: true }); + vi.mocked(db.query.botContentSources.findMany).mockResolvedValue([ + { id: 'source-1' } as any, + ]); + vi.mocked(db.query.botContentItems.findFirst).mockResolvedValue(undefined); + const result = await processScheduledPosts(); expect(result.skipped).toBe(1); expect(result.details[0].status).toBe('skipped_no_content');