diff --git a/.gitignore b/.gitignore index 990fa12..e801e16 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,10 @@ coverage.out # Private demo / ops notes (hostnames, keys, personal runbooks) docs/demo-hiveshare.md +# Python +__pycache__/ +*.pyc + # IDE .idea/ .vscode/ diff --git a/API.md b/API.md new file mode 100644 index 0000000..3ace3a9 --- /dev/null +++ b/API.md @@ -0,0 +1,1212 @@ +# HiveShare API Reference + +> **Verification:** All curl examples in this document are tested by `scripts/test-api-examples.sh`. If you add or modify an endpoint or example, update the script to match and run it to verify: +> ```bash +> ./scripts/test-api-examples.sh +> ``` + +## Table of Contents + +- [Overview](#overview) +- [Health](#health) + - [GET /health](#get-health) +- [Auth](#auth) + - [POST /api/v1/auth/register](#post-register) + - [GET /api/v1/auth/whoami](#get-whoami) +- [Hiveshares](#hiveshares) + - [POST /api/v1/hiveshares](#post-create-hiveshare) + - [GET /api/v1/hiveshares](#get-list-hiveshares) + - [GET /api/v1/hiveshares/{id}](#get-hiveshare) + - [PUT /api/v1/hiveshares/{id}](#put-update-hiveshare) + - [DELETE /api/v1/hiveshares/{id}](#delete-hiveshare) + - [POST /api/v1/hiveshares/{id}/invite](#post-invite) + - [POST /api/v1/invitations/{token}/accept](#post-accept-invite) + - [GET /api/v1/hiveshares/{id}/members](#get-members) + - [DELETE /api/v1/hiveshares/{id}/members/{userId}](#delete-member) +- [Hives](#hives) + - [POST /api/v1/hiveshares/{id}/hives](#post-create-entry) + - [GET /api/v1/hiveshares/{id}/hives](#get-list-entries) + - [GET /api/v1/hiveshares/{id}/hives/{entryId}](#get-entry) + - [PUT /api/v1/hiveshares/{id}/hives/{entryId}](#put-update-entry) + - [DELETE /api/v1/hiveshares/{id}/hives/{entryId}](#delete-entry) + - [POST /api/v1/hiveshares/{id}/hives/search](#post-search) + - [POST /api/v1/hiveshares/{id}/hives/copy](#post-copy-entries) +- [History](#history) + - [GET /api/v1/hiveshares/{id}/hives/{entryId}/history](#get-history) + - [POST /api/v1/hiveshares/{id}/hives/{entryId}/rollback](#post-rollback) + - [POST /api/v1/hiveshares/{id}/hives/undelete](#post-undelete) +- [Snapshots](#snapshots) + - [POST /api/v1/hiveshares/{id}/snapshots](#post-create-snapshot) + - [GET /api/v1/hiveshares/{id}/snapshots](#get-list-snapshots) + - [GET /api/v1/hiveshares/{id}/snapshots/{snapshotId}](#get-snapshot) + - [POST /api/v1/hiveshares/{id}/snapshots/{snapshotId}/restore](#post-restore-snapshot) + - [DELETE /api/v1/hiveshares/{id}/snapshots/{snapshotId}](#delete-snapshot) +- [Metrics](#metrics) + - [GET /api/v1/hiveshares/{id}/metrics](#get-hiveshare-metrics) + - [GET /api/v1/metrics/me](#get-user-metrics) +- [SSE Stream](#sse-stream) + - [GET /api/v1/hiveshares/{id}/stream](#get-stream) + +--- + +## Overview + +Base URL: `http://localhost:8080` (configurable via `BASE_URL` env var) + +**Authentication:** All endpoints except `/health`, `/api/v1/auth/register`, and `/api/v1/invitations/{token}/accept` require a Bearer token in the `Authorization` header: + +``` +Authorization: Bearer hvs_ +``` + +**Rate limiting:** 60 requests per minute per API key (or per IP if unauthenticated). Returns `429` when exceeded. + +**Body size limit:** 1 MB max request body. + +**Error format:** All errors return JSON: +```json +{"error": "description of the problem"} +``` + +--- + +## Health + +### GET /health + +`GET /health` + +**Auth:** None + +**Response:** `200 OK` +```json +{ + "status": "ok", + "db": "ok", + "redis": "ok", + "commit": "d61ca5d", + "build_time": "2026-07-22T20:13:28Z" +} +``` + +Returns `503` with `"status": "degraded"` if Postgres or Redis is unreachable. + +**Example:** +```bash +curl http://localhost:8080/health +``` + +--- + +## Auth + +### POST Register + +`POST /api/v1/auth/register` + +**Auth:** None + +**Request body:** +```json +{ + "email": "user@example.com", + "name": "Display Name" +} +``` + +**Response:** `201 Created` +```json +{ + "id": "uuid", + "email": "user@example.com", + "name": "Display Name", + "api_key": "hvs_<48 hex chars>", + "created_at": "2026-07-22T10:00:00Z" +} +``` + +The `api_key` is returned only once. It is stored as a SHA-256 hash and cannot be retrieved again. + +**Error responses:** +- `400` — email or name missing +- `409` — email already registered + +**Example:** +```bash +curl -X POST http://localhost:8080/api/v1/auth/register \ + -H "Content-Type: application/json" \ + -d '{"email":"alice@example.com","name":"Alice"}' +``` + +--- + +### GET Whoami + +`GET /api/v1/auth/whoami` + +**Auth:** Required + +**Response:** `200 OK` +```json +{ + "id": "uuid", + "email": "user@example.com", + "name": "Display Name", + "created_at": "2026-07-22T10:00:00Z" +} +``` + +**Error responses:** +- `401` — missing or invalid API key + +**Example:** +```bash +curl http://localhost:8080/api/v1/auth/whoami \ + -H "Authorization: Bearer hvs_YOUR_API_KEY" +``` + +--- + +## Hiveshares + +### POST Create Hiveshare + +`POST /api/v1/hiveshares` + +**Auth:** Required + +**Request body:** +```json +{ + "name": "Sprint 42", + "description": "Shared context for sprint 42" +} +``` + +**Response:** `201 Created` +```json +{ + "id": "uuid", + "name": "Sprint 42", + "description": "Shared context for sprint 42", + "owner_id": "uuid", + "settings": {}, + "created_at": "2026-07-22T10:00:00Z", + "updated_at": "2026-07-22T10:00:00Z", + "role": "all", + "member_count": 1 +} +``` + +**Error responses:** +- `400` — name missing + +**Example:** +```bash +curl -X POST http://localhost:8080/api/v1/hiveshares \ + -H "Authorization: Bearer hvs_YOUR_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"name":"Sprint 42","description":"Shared context"}' +``` + +--- + +### GET List Hiveshares + +`GET /api/v1/hiveshares` + +**Auth:** Required + +Returns all hiveshares the authenticated user is a member of. + +**Response:** `200 OK` +```json +[ + { + "id": "uuid", + "name": "Sprint 42", + "description": "...", + "owner_id": "uuid", + "role": "all", + "member_count": 3, + "created_at": "2026-07-22T10:00:00Z", + "updated_at": "2026-07-22T10:00:00Z" + } +] +``` + +**Example:** +```bash +curl http://localhost:8080/api/v1/hiveshares \ + -H "Authorization: Bearer hvs_YOUR_API_KEY" +``` + +--- + +### GET Hiveshare + +`GET /api/v1/hiveshares/{id}` + +**Auth:** Required +**Access:** Must be a member + +**Response:** `200 OK` +```json +{ + "id": "uuid", + "name": "Sprint 42", + "description": "...", + "owner_id": "uuid", + "settings": {}, + "role": "all", + "member_count": 3, + "created_at": "2026-07-22T10:00:00Z", + "updated_at": "2026-07-22T10:00:00Z" +} +``` + +**Error responses:** +- `404` — hiveshare not found or user is not a member + +**Example:** +```bash +curl http://localhost:8080/api/v1/hiveshares/HIVESHARE_ID \ + -H "Authorization: Bearer hvs_YOUR_API_KEY" +``` + +--- + +### PUT Update Hiveshare + +`PUT /api/v1/hiveshares/{id}` + +**Auth:** Required +**Access:** CanWrite (role `all`) + +**Request body:** +```json +{ + "name": "New Name", + "description": "Updated description" +} +``` + +**Response:** `200 OK` (returns the updated hiveshare) + +**Error responses:** +- `403` — view-only access + +**Example:** +```bash +curl -X PUT http://localhost:8080/api/v1/hiveshares/HIVESHARE_ID \ + -H "Authorization: Bearer hvs_YOUR_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"name":"Renamed","description":"Updated"}' +``` + +--- + +### DELETE Hiveshare + +`DELETE /api/v1/hiveshares/{id}` + +**Auth:** Required +**Access:** Owner only (`owner_id` must match) + +**Response:** `204 No Content` + +**Error responses:** +- `403` — not the owner +- `404` — hiveshare not found + +**Example:** +```bash +curl -X DELETE http://localhost:8080/api/v1/hiveshares/HIVESHARE_ID \ + -H "Authorization: Bearer hvs_YOUR_API_KEY" +``` + +--- + +### POST Invite + +`POST /api/v1/hiveshares/{id}/invite` + +**Auth:** Required +**Access:** CanWrite (role `all`) + +**Request body:** +```json +{ + "email": "bob@example.com", + "role": "view" +} +``` + +`role` is `all` (read/write/invite) or `view` (read-only). Defaults to `all`. + +**Response:** `201 Created` +```json +{ + "id": "uuid", + "hiveshare_id": "uuid", + "email": "bob@example.com", + "invited_by": "uuid", + "token": "48-hex-chars", + "role": "view", + "status": "pending", + "created_at": "2026-07-22T10:00:00Z", + "expires_at": "2026-07-29T10:00:00Z", + "invite_url": "http://localhost:8080/api/v1/invitations/TOKEN/accept" +} +``` + +Invitations expire after 7 days. + +**Error responses:** +- `400` — email missing +- `403` — view-only access + +**Example:** +```bash +curl -X POST http://localhost:8080/api/v1/hiveshares/HIVESHARE_ID/invite \ + -H "Authorization: Bearer hvs_YOUR_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"email":"bob@example.com","role":"view"}' +``` + +--- + +### POST Accept Invite + +`POST /api/v1/invitations/{token}/accept` + +**Auth:** None + +**Request body (optional):** +```json +{ + "name": "Bob" +} +``` + +If `name` is omitted, the invited email is used as the display name. If the user does not exist, one is created. + +**Response:** `200 OK` +```json +{ + "message": "Welcome to Sprint 42", + "hiveshare_id": "uuid", + "user": { + "id": "uuid", + "email": "bob@example.com", + "name": "Bob", + "api_key": "hvs_...", + "created_at": "2026-07-22T10:00:00Z" + } +} +``` + +**Error responses:** +- `404` — invitation not found +- `410` — invitation expired or already accepted + +**Example:** +```bash +curl -X POST http://localhost:8080/api/v1/invitations/TOKEN/accept \ + -H "Content-Type: application/json" \ + -d '{"name":"Bob"}' +``` + +--- + +### GET Members + +`GET /api/v1/hiveshares/{id}/members` + +**Auth:** Required +**Access:** CanView + +**Response:** `200 OK` +```json +[ + { + "hiveshare_id": "uuid", + "user_id": "uuid", + "name": "Alice", + "email": "alice@example.com", + "role": "all", + "joined_at": "2026-07-22T10:00:00Z" + } +] +``` + +**Error responses:** +- `403` — not a member + +**Example:** +```bash +curl http://localhost:8080/api/v1/hiveshares/HIVESHARE_ID/members \ + -H "Authorization: Bearer hvs_YOUR_API_KEY" +``` + +--- + +### DELETE Member + +`DELETE /api/v1/hiveshares/{id}/members/{userId}` + +**Auth:** Required +**Access:** CanWrite to remove others; any member can remove themselves + +Cannot remove the owner (`owner_id`). + +**Response:** `204 No Content` + +**Error responses:** +- `403` — view-only trying to remove someone else + +**Example:** +```bash +curl -X DELETE http://localhost:8080/api/v1/hiveshares/HIVESHARE_ID/members/USER_ID \ + -H "Authorization: Bearer hvs_YOUR_API_KEY" +``` + +--- + +## Hives + +### POST Create Entry + +`POST /api/v1/hiveshares/{id}/hives` + +**Auth:** Required +**Access:** CanWrite + +**Request body:** +```json +{ + "source_type": "jira", + "source_ref": "PROJ-123", + "source_url": "https://issues.example.com/PROJ-123", + "tool": "claude", + "content": "Analysis of the auth refactor...", + "summary": "Auth refactor analysis", + "tags": ["auth", "refactor"], + "metadata": {"sprint": 42} +} +``` + +| Field | Required | Values | +|-------|----------|--------| +| `source_type` | Yes | `jira`, `github_issue`, `github_pr`, `file`, `url`, `manual` | +| `source_ref` | Yes | Free text (e.g. ticket ID, file path) | +| `content` | Yes | The memory content | +| `tool` | No | `claude`, `cursor`, `manual` (default: `manual`) | +| `source_url` | No | URL to the source | +| `summary` | No | Short summary | +| `tags` | No | Array of strings | +| `metadata` | No | Arbitrary JSON object | + +Embedding is generated asynchronously after creation. + +**Response:** `201 Created` +```json +{ + "id": "uuid", + "hiveshare_id": "uuid", + "user_id": "uuid", + "user_name": "Alice", + "source_type": "jira", + "source_ref": "PROJ-123", + "source_url": "https://issues.example.com/PROJ-123", + "tool": "claude", + "content": "Analysis of the auth refactor...", + "summary": "Auth refactor analysis", + "tags": ["auth", "refactor"], + "metadata": {"sprint": 42}, + "views": 0, + "reuses": 0, + "created_at": "2026-07-22T10:00:00Z", + "updated_at": "2026-07-22T10:00:00Z" +} +``` + +**Error responses:** +- `400` — content, source_type, or source_ref missing +- `403` — view-only access + +**Example:** +```bash +curl -X POST http://localhost:8080/api/v1/hiveshares/HIVESHARE_ID/hives \ + -H "Authorization: Bearer hvs_YOUR_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"source_type":"jira","source_ref":"PROJ-123","content":"Analysis...","tool":"claude","tags":["auth"]}' +``` + +--- + +### GET List Entries + +`GET /api/v1/hiveshares/{id}/hives` + +**Auth:** Required +**Access:** CanView + +**Query parameters:** + +| Param | Default | Description | +|-------|---------|-------------| +| `limit` | 50 | Max entries to return | +| `offset` | 0 | Pagination offset | +| `source_type` | | Filter by source type | +| `source_ref` | | Filter by source reference | +| `tag` | | Filter by tag | +| `tool` | | Filter by tool | + +List responses omit `content` to keep payloads small. Use the GET single entry endpoint for full content. + +**Response:** `200 OK` +```json +[ + { + "id": "uuid", + "hiveshare_id": "uuid", + "user_id": "uuid", + "user_name": "Alice", + "source_type": "jira", + "source_ref": "PROJ-123", + "summary": "Auth refactor analysis", + "tags": ["auth"], + "views": 5, + "reuses": 2, + "created_at": "2026-07-22T10:00:00Z" + } +] +``` + +**Example:** +```bash +curl "http://localhost:8080/api/v1/hiveshares/HIVESHARE_ID/hives?source_type=jira&limit=10" \ + -H "Authorization: Bearer hvs_YOUR_API_KEY" +``` + +--- + +### GET Entry + +`GET /api/v1/hiveshares/{id}/hives/{entryId}` + +**Auth:** Required +**Access:** CanView + +Returns the full entry including content. Increments view counter. + +**Response:** `200 OK` +```json +{ + "id": "uuid", + "hiveshare_id": "uuid", + "user_id": "uuid", + "user_name": "Alice", + "source_type": "jira", + "source_ref": "PROJ-123", + "source_url": "https://...", + "tool": "claude", + "content": "Full content text...", + "summary": "Auth refactor analysis", + "tags": ["auth"], + "metadata": {"sprint": 42}, + "views": 6, + "reuses": 2, + "created_at": "2026-07-22T10:00:00Z", + "updated_at": "2026-07-22T10:00:00Z" +} +``` + +**Error responses:** +- `404` — entry not found + +**Example:** +```bash +curl http://localhost:8080/api/v1/hiveshares/HIVESHARE_ID/hives/ENTRY_ID \ + -H "Authorization: Bearer hvs_YOUR_API_KEY" +``` + +--- + +### PUT Update Entry + +`PUT /api/v1/hiveshares/{id}/hives/{entryId}` + +**Auth:** Required +**Access:** CanWrite + +**Request body:** +```json +{ + "content": "Updated analysis...", + "summary": "Updated summary", + "tags": ["auth", "updated"] +} +``` + +All fields are optional. Updating content triggers re-embedding. + +**Response:** `200 OK` (returns the updated entry) + +**Error responses:** +- `403` — view-only access + +**Example:** +```bash +curl -X PUT http://localhost:8080/api/v1/hiveshares/HIVESHARE_ID/hives/ENTRY_ID \ + -H "Authorization: Bearer hvs_YOUR_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"content":"Updated analysis...","tags":["auth","updated"]}' +``` + +--- + +### DELETE Entry + +`DELETE /api/v1/hiveshares/{id}/hives/{entryId}` + +**Auth:** Required +**Access:** CanWrite + +**Response:** `204 No Content` + +**Example:** +```bash +curl -X DELETE http://localhost:8080/api/v1/hiveshares/HIVESHARE_ID/hives/ENTRY_ID \ + -H "Authorization: Bearer hvs_YOUR_API_KEY" +``` + +--- + +### POST Search + +`POST /api/v1/hiveshares/{id}/hives/search` + +**Auth:** Required +**Access:** CanView + +Searches by semantic similarity (vector search) if embeddings are enabled, falls back to PostgreSQL full-text search otherwise. + +**Request body:** +```json +{ + "query": "auth refactor approach", + "source_type": "jira", + "limit": 10 +} +``` + +| Field | Required | Default | Description | +|-------|----------|---------|-------------| +| `query` | Yes | | Search query text | +| `source_type` | No | | Filter results by source type | +| `limit` | No | 10 | Max results | + +**Response:** `200 OK` +```json +{ + "results": [ + { + "id": "uuid", + "hiveshare_id": "uuid", + "user_id": "uuid", + "user_name": "Alice", + "source_type": "jira", + "source_ref": "PROJ-123", + "content": "Full content...", + "summary": "...", + "tags": ["auth"], + "views": 5, + "reuses": 2, + "score": 0.87, + "created_at": "2026-07-22T10:00:00Z" + } + ], + "count": 1, + "query": "auth refactor approach" +} +``` + +**Error responses:** +- `400` — query missing + +**Example:** +```bash +curl -X POST http://localhost:8080/api/v1/hiveshares/HIVESHARE_ID/hives/search \ + -H "Authorization: Bearer hvs_YOUR_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"query":"auth refactor","limit":5}' +``` + +--- + +### POST Copy Entries + +`POST /api/v1/hiveshares/{id}/hives/copy` + +**Auth:** Required +**Access:** CanWrite on target hiveshare; CanView on source hiveshare(s) + +Copies hives (including embeddings) from any accessible hiveshare into the target. Used for rollforward merges after a snapshot restore. + +**Request body:** +```json +{ + "entry_ids": ["uuid-1", "uuid-2"] +} +``` + +**Response:** `201 Created` +```json +[ + { + "id": "new-uuid", + "hiveshare_id": "target-hiveshare-uuid", + "content": "Copied content...", + "source_type": "jira", + "source_ref": "PROJ-123", + ... + } +] +``` + +Entries with NULL embeddings are queued for re-embedding. + +**Error responses:** +- `400` — entry_ids missing or empty + +**Example:** +```bash +curl -X POST http://localhost:8080/api/v1/hiveshares/TARGET_ID/hives/copy \ + -H "Authorization: Bearer hvs_YOUR_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"entry_ids":["ENTRY_UUID_1","ENTRY_UUID_2"]}' +``` + +--- + +## History + +### GET History + +`GET /api/v1/hiveshares/{id}/hives/{entryId}/history` + +**Auth:** Required +**Access:** CanView + +Returns version history for a hive, including deleted entries. + +**Query parameters:** + +| Param | Default | Description | +|-------|---------|-------------| +| `limit` | 20 | Max versions | +| `offset` | 0 | Pagination offset | + +**Response:** `200 OK` +```json +[ + { + "history_id": 42, + "entry_id": "uuid", + "hiveshare_id": "uuid", + "user_id": "uuid", + "action": "update", + "content": "Updated content...", + "summary": "Updated", + "has_embedding": true, + "tags": ["auth"], + "source_type": "jira", + "source_ref": "PROJ-123", + "tool": "claude", + "recorded_at": "2026-07-22T10:05:00Z" + }, + { + "history_id": 41, + "entry_id": "uuid", + "action": "insert", + "content": "Original content...", + "has_embedding": true, + "recorded_at": "2026-07-22T10:00:00Z" + } +] +``` + +`action` is one of: `insert`, `update`, `delete`. + +**Example:** +```bash +curl "http://localhost:8080/api/v1/hiveshares/HIVESHARE_ID/hives/ENTRY_ID/history?limit=10" \ + -H "Authorization: Bearer hvs_YOUR_API_KEY" +``` + +--- + +### POST Rollback + +`POST /api/v1/hiveshares/{id}/hives/{entryId}/rollback` + +**Auth:** Required +**Access:** CanWrite + +Restores a hive to a prior version. If the history version has an embedding, it is restored directly. If not, a re-embed job is enqueued. + +**Request body:** +```json +{ + "history_id": 41 +} +``` + +**Response:** `200 OK` (returns the restored entry) + +**Error responses:** +- `400` — history_id missing +- `404` — entry or history version not found + +**Example:** +```bash +curl -X POST http://localhost:8080/api/v1/hiveshares/HIVESHARE_ID/hives/ENTRY_ID/rollback \ + -H "Authorization: Bearer hvs_YOUR_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"history_id":41}' +``` + +--- + +### POST Undelete + +`POST /api/v1/hiveshares/{id}/hives/undelete` + +**Auth:** Required +**Access:** CanWrite + +Restores a deleted hive from its history record. The history version must have `action: "delete"`. + +**Request body:** +```json +{ + "history_id": 43 +} +``` + +**Response:** `201 Created` (returns the restored entry with its original ID) + +**Error responses:** +- `400` — history_id missing +- `404` — history version not found or not a delete action + +**Example:** +```bash +curl -X POST http://localhost:8080/api/v1/hiveshares/HIVESHARE_ID/hives/undelete \ + -H "Authorization: Bearer hvs_YOUR_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"history_id":43}' +``` + +--- + +## Snapshots + +### POST Create Snapshot + +`POST /api/v1/hiveshares/{id}/snapshots` + +**Auth:** Required +**Access:** CanWrite + +Creates a point-in-time snapshot of all hives in the hiveshare, including their embeddings. + +**Request body:** +```json +{ + "name": "before-cleanup", + "description": "Snapshot before removing stale entries" +} +``` + +| Field | Required | Description | +|-------|----------|-------------| +| `name` | Yes | Snapshot name | +| `description` | No | Description | + +**Response:** `201 Created` +```json +{ + "snapshot_id": 1, + "hiveshare_id": "uuid", + "created_by": "uuid", + "name": "before-cleanup", + "description": "Snapshot before removing stale entries", + "entry_count": 15, + "created_at": "2026-07-22T10:00:00Z" +} +``` + +**Error responses:** +- `400` — name missing + +**Example:** +```bash +curl -X POST http://localhost:8080/api/v1/hiveshares/HIVESHARE_ID/snapshots \ + -H "Authorization: Bearer hvs_YOUR_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"name":"before-cleanup","description":"Snapshot before removing stale entries"}' +``` + +--- + +### GET List Snapshots + +`GET /api/v1/hiveshares/{id}/snapshots` + +**Auth:** Required +**Access:** CanView + +**Response:** `200 OK` +```json +[ + { + "snapshot_id": 1, + "hiveshare_id": "uuid", + "created_by": "uuid", + "name": "before-cleanup", + "entry_count": 15, + "created_at": "2026-07-22T10:00:00Z" + } +] +``` + +**Example:** +```bash +curl http://localhost:8080/api/v1/hiveshares/HIVESHARE_ID/snapshots \ + -H "Authorization: Bearer hvs_YOUR_API_KEY" +``` + +--- + +### GET Snapshot + +`GET /api/v1/hiveshares/{id}/snapshots/{snapshotId}` + +**Auth:** Required +**Access:** CanView + +Returns snapshot metadata and the list of frozen entries. + +**Response:** `200 OK` +```json +{ + "snapshot": { + "snapshot_id": 1, + "hiveshare_id": "uuid", + "created_by": "uuid", + "name": "before-cleanup", + "entry_count": 15, + "created_at": "2026-07-22T10:00:00Z" + }, + "entries": [ + { + "entry_id": "uuid", + "content": "...", + "summary": "...", + "has_embedding": true, + "tags": ["auth"], + "source_type": "jira", + "source_ref": "PROJ-123", + "tool": "claude" + } + ] +} +``` + +**Error responses:** +- `404` — snapshot not found + +**Example:** +```bash +curl http://localhost:8080/api/v1/hiveshares/HIVESHARE_ID/snapshots/1 \ + -H "Authorization: Bearer hvs_YOUR_API_KEY" +``` + +--- + +### POST Restore Snapshot + +`POST /api/v1/hiveshares/{id}/snapshots/{snapshotId}/restore` + +**Auth:** Required +**Access:** CanWrite + +Creates a **new hiveshare** from the snapshot. The original hiveshare is not modified. Entries with embeddings are copied as-is; entries without embeddings are queued for re-embedding. + +**Request body (optional):** +```json +{ + "name": "Sprint 42 (restored)" +} +``` + +If `name` is omitted, defaults to `"(restored)"`. + +**Response:** `201 Created` +```json +{ + "hiveshare": { + "id": "new-uuid", + "name": "Sprint 42 (restored)", + "owner_id": "uuid", + "role": "all", + "member_count": 1, + ... + }, + "entries_restored": 15 +} +``` + +**Example:** +```bash +curl -X POST http://localhost:8080/api/v1/hiveshares/HIVESHARE_ID/snapshots/1/restore \ + -H "Authorization: Bearer hvs_YOUR_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"name":"Sprint 42 (restored)"}' +``` + +--- + +### DELETE Snapshot + +`DELETE /api/v1/hiveshares/{id}/snapshots/{snapshotId}` + +**Auth:** Required +**Access:** CanWrite + +Deletes the snapshot and all its frozen entries. + +**Response:** `204 No Content` + +**Example:** +```bash +curl -X DELETE http://localhost:8080/api/v1/hiveshares/HIVESHARE_ID/snapshots/1 \ + -H "Authorization: Bearer hvs_YOUR_API_KEY" +``` + +--- + +## Metrics + +### GET Hiveshare Metrics + +`GET /api/v1/hiveshares/{id}/metrics` + +**Auth:** Required +**Access:** CanView + +**Response:** `200 OK` +```json +{ + "hiveshare": { + "name": "Sprint 42", + "description": "...", + "member_count": 3 + }, + "memory": { + "total_entries": 25, + "by_source_type": {"jira": 15, "github_pr": 8, "manual": 2}, + "by_tool": {"claude": 20, "cursor": 3, "manual": 2}, + "unique_sources": 12 + }, + "collaboration": { + "total_views": 150, + "total_reuses": 45, + "reuse_rate": 0.3, + "top_contributors": [ + {"user_id": "uuid", "name": "Alice", "entries": 15, "reuses_received": 30} + ] + }, + "coverage": { + "jira_refs_with_memory": 10, + "github_refs_with_memory": 5 + }, + "activity": { + "last_7d_adds": 8, + "last_7d_searches": 25, + "active_users_7d": 3 + } +} +``` + +**Example:** +```bash +curl http://localhost:8080/api/v1/hiveshares/HIVESHARE_ID/metrics \ + -H "Authorization: Bearer hvs_YOUR_API_KEY" +``` + +--- + +### GET User Metrics + +`GET /api/v1/metrics/me` + +**Auth:** Required + +**Response:** `200 OK` +```json +{ + "total_entries": 42, + "total_searches": 120, + "hiveshares_owned": 3, + "hiveshares_joined": 5, + "total_reuses_given": 30 +} +``` + +**Example:** +```bash +curl http://localhost:8080/api/v1/metrics/me \ + -H "Authorization: Bearer hvs_YOUR_API_KEY" +``` + +--- + +## SSE Stream + +### GET Stream + +`GET /api/v1/hiveshares/{id}/stream` + +**Auth:** Required +**Access:** CanView + +Opens a long-lived Server-Sent Events connection. Events are published via Redis pub/sub and fanned out to all connected clients. + +**Headers:** +``` +Accept: text/event-stream +Cache-Control: no-cache +``` + +**Event types:** + +| Event | Payload | When | +|-------|---------|------| +| `connected` | `{"hiveshare_id": "uuid"}` | Initial connection | +| `hive_added` | Full hive | Entry created | +| `hive_updated` | Full hive | Entry updated | +| `hive_rolled_back` | Full hive | Entry rolled back | +| `hive_undeleted` | Full hive | Entry restored from deletion | + +Keepalive comments (`: keepalive`) are sent every 25 seconds. + +**Example:** +```bash +curl -N http://localhost:8080/api/v1/hiveshares/HIVESHARE_ID/stream \ + -H "Authorization: Bearer hvs_YOUR_API_KEY" \ + -H "Accept: text/event-stream" +``` diff --git a/Makefile b/Makefile index 23c789f..7650dbb 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,5 @@ -.PHONY: all build server mcp cli deps migrate dev clean docker-up docker-down release server-linux +.PHONY: all build server mcp cli deps migrate dev dev-clean clean docker-up docker-down release server-linux \ + smoke-test smoke-test-full integration-test psql # ── Config ─────────────────────────────────────────────────────────────────── @@ -8,6 +9,7 @@ BUILDTIME := $(shell date -u +%Y-%m-%dT%H:%M:%SZ) VERSION_LDFLAGS := -X github.com/KB-perByte/hiveshare/internal/version.Commit=$(COMMIT) \ -X github.com/KB-perByte/hiveshare/internal/version.BuildTime=$(BUILDTIME) GOFLAGS := -ldflags="-s -w $(VERSION_LDFLAGS)" +CONTAINER_RUNTIME ?= docker # ── Build ───────────────────────────────────────────────────────────────────── @@ -36,13 +38,26 @@ deps: # ── Database ───────────────────────────────────────────────────────────────── POSTGRES_URL ?= postgres://hiveshare:hiveshare@localhost:5432/hiveshare?sslmode=disable +# Compose service name (stable across project prefixes / renamed containers). +POSTGRES_SERVICE ?= postgres migrate: @echo "Applying migrations..." - @for f in migrations/*.sql; do \ - echo " Running $$f..."; \ - psql "$(POSTGRES_URL)" -f "$$f"; \ - done + @if command -v psql >/dev/null 2>&1; then \ + for f in migrations/*.sql; do \ + echo " Running $$f..."; \ + psql "$(POSTGRES_URL)" -f "$$f"; \ + done; \ + elif $(CONTAINER_RUNTIME) compose ps --status running $(POSTGRES_SERVICE) >/dev/null 2>&1; then \ + for f in migrations/*.sql; do \ + echo " Running $$f (via compose $(POSTGRES_SERVICE))..."; \ + $(CONTAINER_RUNTIME) compose exec -T $(POSTGRES_SERVICE) \ + psql -U hiveshare -d hiveshare -f - < "$$f"; \ + done; \ + else \ + echo "Error: psql not found and compose service '$(POSTGRES_SERVICE)' is not running"; \ + exit 1; \ + fi @echo "Migrations done." # ── Dev ─────────────────────────────────────────────────────────────────────── @@ -55,11 +70,18 @@ dev: docker-up EMBED_PROVIDER= go run ./cmd/server docker-up: - docker compose up -d + $(CONTAINER_RUNTIME) compose up -d docker-down: - docker compose down + $(CONTAINER_RUNTIME) compose down + +dev-clean: + $(CONTAINER_RUNTIME) compose down -v +psql: + @echo "**INFO**: Opening psql via '$(CONTAINER_RUNTIME) compose exec $(POSTGRES_SERVICE)'" + $(CONTAINER_RUNTIME) compose exec -it $(POSTGRES_SERVICE) psql -U hiveshare -d hiveshare + # ── Install CLI ─────────────────────────────────────────────────────────────── install: cli @@ -93,6 +115,19 @@ release: done @echo "Done. Tarballs in dist/" +# ── Test ────────────────────────────────────────────────────────────────────── + +HIVESHARE_TEST_URL ?= $(or $(BASE_URL),http://localhost:8080) + +smoke-test: + @./scripts/smoke-test.sh $(HIVESHARE_TEST_URL) + +smoke-test-full: + @./scripts/smoke-test-full.sh $(HIVESHARE_TEST_URL) + +integration-test: + HIVESHARE_TEST_URL=$(HIVESHARE_TEST_URL) pytest tests/ -v + # ── Clean ───────────────────────────────────────────────────────────────────── clean: @@ -114,5 +149,10 @@ help: @echo " make install-mcp Install hiveshare-mcp to /usr/local/bin" @echo " make docker-up Start postgres + redis" @echo " make docker-down Stop postgres + redis" + @echo " make dev-clean Stop containers and wipe database volume" + @echo " make psql Open psql shell in the running postgres container" + @echo " make smoke-test Basic connectivity check (no user needed)" + @echo " make smoke-test-full Full endpoint smoke test (curl + jq)" + @echo " make integration-test Pytest integration tests" @echo " make release Cross-compile all platforms → dist/" @echo " make clean Remove ./bin/ and dist/" diff --git a/cmd/hshare/main.go b/cmd/hshare/main.go index b2bb311..89a4de4 100644 --- a/cmd/hshare/main.go +++ b/cmd/hshare/main.go @@ -182,7 +182,194 @@ func hiveshareCmd() *cobra.Command { }, } - cmd.AddCommand(create, list, use) + cmd.AddCommand(create, list, use, snapshotCmd()) + return cmd +} + +func snapshotCmd() *cobra.Command { + cmd := &cobra.Command{Use: "snapshot", Short: "Manage hiveshare snapshots", Aliases: []string{"snap"}} + + create := &cobra.Command{ + Use: "create", + Short: "Create a snapshot of the current hiveshare", + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newClient() + if err != nil { + return err + } + cfg := loadConfig() + hsID, _ := cmd.Flags().GetString("hiveshare") + if hsID == "" { + hsID = cfg.DefaultHiveshare + } + if hsID == "" { + return fmt.Errorf("no hiveshare set") + } + name, _ := cmd.Flags().GetString("name") + desc, _ := cmd.Flags().GetString("description") + if name == "" { + name = "snapshot-" + time.Now().Format("2006-01-02-150405") + } + var result map[string]interface{} + if err := c.post(fmt.Sprintf("/api/v1/hiveshares/%s/snapshots", hsID), + map[string]string{"name": name, "description": desc}, &result); err != nil { + return err + } + fmt.Printf("Snapshot created: %s (%.0f entries)\n", result["name"], result["entry_count"]) + return nil + }, + } + create.Flags().String("hiveshare", "", "Hiveshare ID") + create.Flags().String("name", "", "Snapshot name (auto-generated if empty)") + create.Flags().String("description", "", "Description") + + list := &cobra.Command{ + Use: "list", + Short: "List snapshots for the current hiveshare", + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newClient() + if err != nil { + return err + } + cfg := loadConfig() + hsID, _ := cmd.Flags().GetString("hiveshare") + if hsID == "" { + hsID = cfg.DefaultHiveshare + } + if hsID == "" { + return fmt.Errorf("no hiveshare set") + } + var snaps []map[string]interface{} + if err := c.get(fmt.Sprintf("/api/v1/hiveshares/%s/snapshots", hsID), &snaps); err != nil { + return err + } + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(w, "ID\tNAME\tENTRIES\tCREATED_AT") + for _, s := range snaps { + t, _ := time.Parse(time.RFC3339Nano, s["created_at"].(string)) + fmt.Fprintf(w, "%.0f\t%s\t%.0f\t%s\n", + s["snapshot_id"], s["name"], s["entry_count"], t.Format("2006-01-02 15:04")) + } + w.Flush() + return nil + }, + } + list.Flags().String("hiveshare", "", "Hiveshare ID") + + show := &cobra.Command{ + Use: "show ", + Short: "Show snapshot details and entry list", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newClient() + if err != nil { + return err + } + cfg := loadConfig() + hsID, _ := cmd.Flags().GetString("hiveshare") + if hsID == "" { + hsID = cfg.DefaultHiveshare + } + if hsID == "" { + return fmt.Errorf("no hiveshare set") + } + var result map[string]interface{} + if err := c.get(fmt.Sprintf("/api/v1/hiveshares/%s/snapshots/%s", hsID, args[0]), &result); err != nil { + return err + } + snap, _ := result["snapshot"].(map[string]interface{}) + fmt.Printf("Snapshot: %s\n", snap["name"]) + if d, ok := snap["description"].(string); ok && d != "" { + fmt.Printf("Description: %s\n", d) + } + t, _ := time.Parse(time.RFC3339Nano, snap["created_at"].(string)) + fmt.Printf("Created: %s\n", t.Format("2006-01-02 15:04")) + fmt.Printf("Entries: %.0f\n\n", snap["entry_count"]) + + entries, _ := result["entries"].([]interface{}) + if len(entries) > 0 { + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(w, "ENTRY_ID\tSOURCE\tREF\tEMBEDDING") + for _, e := range entries { + entry, _ := e.(map[string]interface{}) + hasEmb := "no" + if b, ok := entry["has_embedding"].(bool); ok && b { + hasEmb = "yes" + } + fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", + entry["entry_id"], entry["source_type"], entry["source_ref"], hasEmb) + } + w.Flush() + } + return nil + }, + } + show.Flags().String("hiveshare", "", "Hiveshare ID") + + restore := &cobra.Command{ + Use: "restore ", + Short: "Create a new hiveshare from a snapshot", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newClient() + if err != nil { + return err + } + cfg := loadConfig() + hsID, _ := cmd.Flags().GetString("hiveshare") + if hsID == "" { + hsID = cfg.DefaultHiveshare + } + if hsID == "" { + return fmt.Errorf("no hiveshare set") + } + name, _ := cmd.Flags().GetString("name") + var result map[string]interface{} + body := map[string]string{} + if name != "" { + body["name"] = name + } + if err := c.post(fmt.Sprintf("/api/v1/hiveshares/%s/snapshots/%s/restore", hsID, args[0]), + body, &result); err != nil { + return err + } + hs, _ := result["hiveshare"].(map[string]interface{}) + fmt.Printf("Restored to new hiveshare: %s (%s)\n", hs["name"], hs["id"]) + fmt.Printf("Entries restored: %.0f\n", result["entries_restored"]) + fmt.Printf("Run 'hshare hiveshare use %s' to switch to it\n", hs["id"]) + return nil + }, + } + restore.Flags().String("hiveshare", "", "Hiveshare ID") + restore.Flags().String("name", "", "Name for the restored hiveshare") + + del := &cobra.Command{ + Use: "delete ", + Short: "Delete a snapshot", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newClient() + if err != nil { + return err + } + cfg := loadConfig() + hsID, _ := cmd.Flags().GetString("hiveshare") + if hsID == "" { + hsID = cfg.DefaultHiveshare + } + if hsID == "" { + return fmt.Errorf("no hiveshare set") + } + if err := c.delete(fmt.Sprintf("/api/v1/hiveshares/%s/snapshots/%s", hsID, args[0])); err != nil { + return err + } + fmt.Printf("Snapshot %s deleted\n", args[0]) + return nil + }, + } + del.Flags().String("hiveshare", "", "Hiveshare ID") + + cmd.AddCommand(create, list, show, restore, del) return cmd } @@ -356,7 +543,151 @@ func hiveCmd() *cobra.Command { list.Flags().IntP("limit", "l", 20, "Max results") list.Flags().StringP("source-type", "t", "", "Filter by source type") - cmd.AddCommand(add, search, list) + history := &cobra.Command{ + Use: "history ", + Short: "Show version history for a hive", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newClient() + if err != nil { + return err + } + cfg := loadConfig() + hsID, _ := cmd.Flags().GetString("hiveshare") + if hsID == "" { + hsID = cfg.DefaultHiveshare + } + if hsID == "" { + return fmt.Errorf("no hiveshare set") + } + limit, _ := cmd.Flags().GetInt("limit") + path := fmt.Sprintf("/api/v1/hiveshares/%s/hives/%s/history?limit=%d", hsID, args[0], limit) + var versions []map[string]interface{} + if err := c.get(path, &versions); err != nil { + return err + } + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(w, "VERSION\tACTION\tSUMMARY\tEMBEDDING\tRECORDED_AT") + for _, v := range versions { + t, _ := time.Parse(time.RFC3339Nano, v["recorded_at"].(string)) + hasEmb := "no" + if b, ok := v["has_embedding"].(bool); ok && b { + hasEmb = "yes" + } + summary, _ := v["summary"].(string) + if len(summary) > 40 { + summary = summary[:40] + "..." + } + fmt.Fprintf(w, "%.0f\t%s\t%s\t%s\t%s\n", + v["history_id"], v["action"], summary, hasEmb, t.Format("2006-01-02 15:04")) + } + w.Flush() + return nil + }, + } + history.Flags().String("hiveshare", "", "Hiveshare ID") + history.Flags().IntP("limit", "l", 20, "Max versions") + + rollback := &cobra.Command{ + Use: "rollback ", + Short: "Rollback a hive to a prior version", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newClient() + if err != nil { + return err + } + cfg := loadConfig() + hsID, _ := cmd.Flags().GetString("hiveshare") + if hsID == "" { + hsID = cfg.DefaultHiveshare + } + if hsID == "" { + return fmt.Errorf("no hiveshare set") + } + version, _ := cmd.Flags().GetInt64("version") + if version == 0 { + return fmt.Errorf("--version is required") + } + var result map[string]interface{} + if err := c.post(fmt.Sprintf("/api/v1/hiveshares/%s/hives/%s/rollback", hsID, args[0]), + map[string]interface{}{"history_id": version}, &result); err != nil { + return err + } + fmt.Printf("Rolled back entry %s to version %d\n", args[0], version) + return nil + }, + } + rollback.Flags().String("hiveshare", "", "Hiveshare ID") + rollback.Flags().Int64("version", 0, "History version ID to restore") + rollback.MarkFlagRequired("version") + + undelete := &cobra.Command{ + Use: "undelete", + Short: "Restore a deleted hive", + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newClient() + if err != nil { + return err + } + cfg := loadConfig() + hsID, _ := cmd.Flags().GetString("hiveshare") + if hsID == "" { + hsID = cfg.DefaultHiveshare + } + if hsID == "" { + return fmt.Errorf("no hiveshare set") + } + version, _ := cmd.Flags().GetInt64("version") + if version == 0 { + return fmt.Errorf("--version is required") + } + var result map[string]interface{} + if err := c.post(fmt.Sprintf("/api/v1/hiveshares/%s/hives/undelete", hsID), + map[string]interface{}{"history_id": version}, &result); err != nil { + return err + } + fmt.Printf("Restored deleted entry: %s\n", result["id"]) + return nil + }, + } + undelete.Flags().String("hiveshare", "", "Hiveshare ID") + undelete.Flags().Int64("version", 0, "History version ID of the delete action") + undelete.MarkFlagRequired("version") + + copyCmd := &cobra.Command{ + Use: "copy", + Short: "Copy memory entries to another hiveshare (rollforward merge)", + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newClient() + if err != nil { + return err + } + toHS, _ := cmd.Flags().GetString("to") + if toHS == "" { + return fmt.Errorf("--to is required") + } + entriesStr, _ := cmd.Flags().GetString("entries") + if entriesStr == "" { + return fmt.Errorf("--entries is required") + } + var entryIDs []string + for _, e := range strings.Split(entriesStr, ",") { + entryIDs = append(entryIDs, strings.TrimSpace(e)) + } + var result []map[string]interface{} + if err := c.post(fmt.Sprintf("/api/v1/hiveshares/%s/hives/copy", toHS), + map[string]interface{}{"entry_ids": entryIDs}, &result); err != nil { + return err + } + fmt.Printf("Copied %d entries to hiveshare %s\n", len(result), toHS) + return nil + }, + } + copyCmd.Flags().String("to", "", "Target hiveshare ID") + copyCmd.Flags().String("entries", "", "Comma-separated entry IDs to copy") + + cmd.AddCommand(add, search, list, history, rollback, undelete, copyCmd) return cmd } diff --git a/cmd/server/main.go b/cmd/server/main.go index fd47b34..88998d2 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -6,6 +6,7 @@ import ( "net/http" "os" "os/signal" + "strconv" "syscall" "time" @@ -58,13 +59,22 @@ func main() { views := store.NewViewCounter(rdb, pool) views.StartFlusher(ctx, 60*time.Second) + historyStore := store.NewHistoryStore(pool) + worker := embed.NewWorker(embedder, hiveStore, 2, 64) worker.Start(ctx, 2) // rolling TTL for usage_events (retain 90 days) go purgeUsageEvents(ctx, metricsStore) - router := api.NewRouter(userStore, hsStore, hiveStore, metricsStore, embedder, hub, worker, views, pool, rdb) + // optional history purge + historyTTLDays := envInt("HISTORY_TTL_DAYS", 0) + historyMaxVersions := envInt("HISTORY_MAX_VERSIONS", 0) + if historyTTLDays > 0 || historyMaxVersions > 0 { + go purgeHistory(ctx, historyStore, historyTTLDays, historyMaxVersions) + } + + router := api.NewRouter(userStore, hsStore, hiveStore, metricsStore, historyStore, embedder, hub, worker, views, pool, rdb) addr := os.Getenv("LISTEN_ADDR") if addr == "" { @@ -98,6 +108,47 @@ func main() { _ = srv.Shutdown(shutCtx) } +func envInt(key string, fallback int) int { + if v := os.Getenv(key); v != "" { + if n, err := strconv.Atoi(v); err == nil { + return n + } + } + return fallback +} + +func purgeHistory(ctx context.Context, hs *store.HistoryStore, ttlDays, maxVersions int) { + run := func() { + if ttlDays > 0 { + n, err := hs.PurgeByAge(ctx, time.Duration(ttlDays)*24*time.Hour) + if err != nil { + slog.Warn("history age purge failed", "err", err) + } else if n > 0 { + slog.Info("history purged by age", "rows", n) + } + } + if maxVersions > 0 { + n, err := hs.PurgeByCount(ctx, maxVersions) + if err != nil { + slog.Warn("history count purge failed", "err", err) + } else if n > 0 { + slog.Info("history purged by count", "rows", n) + } + } + } + run() + ticker := time.NewTicker(24 * time.Hour) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + run() + } + } +} + func purgeUsageEvents(ctx context.Context, metrics *store.MetricsStore) { run := func() { n, err := metrics.PurgeOldUsageEvents(ctx, 90*24*time.Hour) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 20ae955..f4f7c0f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -106,10 +106,10 @@ All items below were found in the initial review and are **fixed in tree**. ```mermaid graph LR - A["Add memory\nPOST /memory"] -->|"enqueue"| B["Embed Worker"] + A["Add memory\nPOST /hives"] -->|"enqueue"| B["Embed Worker"] A -->|"fast INSERT\nembedding=NULL"| C["PostgreSQL"] B -->|"async UPDATE"| C - D["Search\nPOST /memory/search"] -->|"embed query"| E["Embedding API"] + D["Search\nPOST /hives/search"] -->|"embed query"| E["Embedding API"] D -->|"HNSW scan"| C F["SSE Stream"] -->|"1 sub/hiveshare"| G["Redis"] H["Get memory"] -->|"INCR views"| G diff --git a/internal/api/memory.go b/internal/api/memory.go index 6c33fdd..7e9a632 100644 --- a/internal/api/memory.go +++ b/internal/api/memory.go @@ -6,23 +6,20 @@ import ( "net/http" "strconv" + "github.com/go-chi/chi/v5" "github.com/google/uuid" - "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5" "github.com/KB-perByte/hiveshare/internal/embed" "github.com/KB-perByte/hiveshare/internal/models" "github.com/KB-perByte/hiveshare/internal/realtime" "github.com/KB-perByte/hiveshare/internal/store" ) -func isUniqueViolation(err error) bool { - var pgErr *pgconn.PgError - return errors.As(err, &pgErr) && pgErr.Code == "23505" -} - type HiveHandler struct { mem *store.HiveStore hs *store.HiveshareStore metrics *store.MetricsStore + history *store.HistoryStore embedder embed.Embedder hub *realtime.Hub worker *embed.Worker @@ -33,13 +30,14 @@ func NewHiveHandler( mem *store.HiveStore, hs *store.HiveshareStore, metrics *store.MetricsStore, + history *store.HistoryStore, embedder embed.Embedder, hub *realtime.Hub, worker *embed.Worker, views *store.ViewCounter, ) *HiveHandler { return &HiveHandler{ - mem: mem, hs: hs, metrics: metrics, embedder: embedder, + mem: mem, hs: hs, metrics: metrics, history: history, embedder: embedder, hub: hub, worker: worker, views: views, } } @@ -152,7 +150,7 @@ func (h *HiveHandler) Create(w http.ResponseWriter, r *http.Request) { if err == nil { break } - if n >= 9 || !isUniqueViolation(err) { + if n >= 9 || !store.IsUniqueViolation(err) { writeError(w, http.StatusInternalServerError, err.Error()) return } @@ -342,3 +340,285 @@ func (h *HiveHandler) Stream(w http.ResponseWriter, r *http.Request) { } h.hub.ServeSSE(w, r, hsID) } + +// ── Per-entry history ──────────────────────────────────────────────────────── + +func (h *HiveHandler) ListHistory(w http.ResponseWriter, r *http.Request) { + hsID, ok := h.requireAccess(r, w, false) + if !ok { + return + } + entryID, err := parseUUID(r, "entryId") + if err != nil { + writeError(w, http.StatusBadRequest, "invalid entry id") + return + } + q := r.URL.Query() + limit, _ := strconv.Atoi(q.Get("limit")) + offset, _ := strconv.Atoi(q.Get("offset")) + versions, err := h.history.ListVersions(r.Context(), entryID, hsID, limit, offset) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + if versions == nil { + versions = []*models.HistoryEntry{} + } + writeJSON(w, http.StatusOK, versions) +} + +func (h *HiveHandler) Rollback(w http.ResponseWriter, r *http.Request) { + u := currentUser(r) + hsID, ok := h.requireAccess(r, w, true) + if !ok { + return + } + entryID, err := parseUUID(r, "entryId") + if err != nil { + writeError(w, http.StatusBadRequest, "invalid entry id") + return + } + var req struct { + HistoryID int64 `json:"history_id"` + } + if err := decodeJSON(r, &req); err != nil || req.HistoryID == 0 { + writeError(w, http.StatusBadRequest, "history_id is required") + return + } + entry, hasEmb, err := h.history.Rollback(r.Context(), entryID, hsID, req.HistoryID) + if err != nil { + writeHistoryErr(w, "rollback failed", err) + return + } + if !hasEmb { + h.worker.Enqueue(embed.Job{EntryID: entry.ID, Content: entry.Content}) + } + _ = h.metrics.RecordEvent(r.Context(), &models.UsageEvent{ + UserID: u.ID, + HiveshareID: &hsID, + EntryID: &entry.ID, + EventType: "rollback", + }) + _ = h.hub.Publish(r.Context(), models.StreamEvent{ + Type: "hive_rolled_back", + HiveshareID: hsID, + Payload: entry, + }) + writeJSON(w, http.StatusOK, entry) +} + +func (h *HiveHandler) Undelete(w http.ResponseWriter, r *http.Request) { + u := currentUser(r) + hsID, ok := h.requireAccess(r, w, true) + if !ok { + return + } + var req struct { + HistoryID int64 `json:"history_id"` + } + if err := decodeJSON(r, &req); err != nil || req.HistoryID == 0 { + writeError(w, http.StatusBadRequest, "history_id is required") + return + } + entry, hasEmb, err := h.history.Undelete(r.Context(), req.HistoryID, hsID) + if err != nil { + writeHistoryErr(w, "undelete failed", err) + return + } + if !hasEmb { + h.worker.Enqueue(embed.Job{EntryID: entry.ID, Content: entry.Content}) + } + _ = h.metrics.RecordEvent(r.Context(), &models.UsageEvent{ + UserID: u.ID, + HiveshareID: &hsID, + EntryID: &entry.ID, + EventType: "undelete", + }) + _ = h.hub.Publish(r.Context(), models.StreamEvent{ + Type: "hive_undeleted", + HiveshareID: hsID, + Payload: entry, + }) + writeJSON(w, http.StatusCreated, entry) +} + +// ── Snapshots ──────────────────────────────────────────────────────────────── + +func (h *HiveHandler) CreateSnapshot(w http.ResponseWriter, r *http.Request) { + u := currentUser(r) + hsID, ok := h.requireAccess(r, w, true) + if !ok { + return + } + var req struct { + Name string `json:"name"` + Description string `json:"description"` + } + if err := decodeJSON(r, &req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + if req.Name == "" { + writeError(w, http.StatusBadRequest, "name is required") + return + } + snap, err := h.history.CreateSnapshot(r.Context(), hsID, u.ID, req.Name, req.Description) + if err != nil { + if errors.Is(err, store.ErrSnapshotTooLarge) { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusCreated, snap) +} + +func (h *HiveHandler) ListSnapshots(w http.ResponseWriter, r *http.Request) { + hsID, ok := h.requireAccess(r, w, false) + if !ok { + return + } + snaps, err := h.history.ListSnapshots(r.Context(), hsID) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + if snaps == nil { + snaps = []*models.Snapshot{} + } + writeJSON(w, http.StatusOK, snaps) +} + +func (h *HiveHandler) GetSnapshot(w http.ResponseWriter, r *http.Request) { + hsID, ok := h.requireAccess(r, w, false) + if !ok { + return + } + snapshotID, err := strconv.ParseInt(chi.URLParam(r, "snapshotId"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid snapshot id") + return + } + snap, entries, err := h.history.GetSnapshot(r.Context(), snapshotID, hsID) + if err != nil { + writeError(w, http.StatusNotFound, "snapshot not found") + return + } + writeJSON(w, http.StatusOK, map[string]interface{}{ + "snapshot": snap, + "entries": entries, + }) +} + +func (h *HiveHandler) RestoreSnapshot(w http.ResponseWriter, r *http.Request) { + u := currentUser(r) + hsID, ok := h.requireAccess(r, w, true) + if !ok { + return + } + snapshotID, err := strconv.ParseInt(chi.URLParam(r, "snapshotId"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid snapshot id") + return + } + var req struct { + Name string `json:"name"` + } + if err := decodeJSON(r, &req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + if req.Name == "" { + req.Name = "(restored)" + } + result, err := h.history.RestoreSnapshot(r.Context(), snapshotID, hsID, u.ID, req.Name) + if err != nil { + writeHistoryErr(w, "restore failed", err) + return + } + for _, id := range result.NullEmbeddings { + entry, getErr := h.mem.Get(r.Context(), id, result.Hiveshare.ID) + if getErr == nil { + h.worker.Enqueue(embed.Job{EntryID: id, Content: entry.Content}) + } + } + _ = h.metrics.RecordEvent(r.Context(), &models.UsageEvent{ + UserID: u.ID, + EventType: "snapshot_restore", + }) + writeJSON(w, http.StatusCreated, map[string]interface{}{ + "hiveshare": result.Hiveshare, + "entries_restored": result.EntriesCreated, + }) +} + +func (h *HiveHandler) DeleteSnapshot(w http.ResponseWriter, r *http.Request) { + hsID, ok := h.requireAccess(r, w, true) + if !ok { + return + } + snapshotID, err := strconv.ParseInt(chi.URLParam(r, "snapshotId"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid snapshot id") + return + } + deleted, err := h.history.DeleteSnapshot(r.Context(), snapshotID, hsID) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + if !deleted { + writeError(w, http.StatusNotFound, "snapshot not found") + return + } + w.WriteHeader(http.StatusNoContent) +} + +// ── Copy entries ───────────────────────────────────────────────────────────── + +func (h *HiveHandler) CopyEntries(w http.ResponseWriter, r *http.Request) { + u := currentUser(r) + hsID, ok := h.requireAccess(r, w, true) + if !ok { + return + } + var req struct { + EntryIDs []uuid.UUID `json:"entry_ids"` + } + if err := decodeJSON(r, &req); err != nil || len(req.EntryIDs) == 0 { + writeError(w, http.StatusBadRequest, "entry_ids is required") + return + } + results, err := h.history.CopyEntries(r.Context(), hsID, u.ID, req.EntryIDs) + if err != nil { + if errors.Is(err, store.ErrForbidden) { + writeError(w, http.StatusForbidden, "not a member of source hiveshare") + return + } + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + var entries []*models.Hive + for _, cr := range results { + entries = append(entries, cr.Entry) + if !cr.HasEmbedding { + h.worker.Enqueue(embed.Job{EntryID: cr.Entry.ID, Content: cr.Entry.Content}) + } + } + _ = h.metrics.RecordEvent(r.Context(), &models.UsageEvent{ + UserID: u.ID, + HiveshareID: &hsID, + EventType: "copy", + }) + writeJSON(w, http.StatusCreated, entries) +} + +// writeHistoryErr maps pgx.ErrNoRows → 404 and everything else → 500. +func writeHistoryErr(w http.ResponseWriter, prefix string, err error) { + if errors.Is(err, pgx.ErrNoRows) { + writeError(w, http.StatusNotFound, prefix+": not found") + return + } + writeError(w, http.StatusInternalServerError, prefix+": "+err.Error()) +} diff --git a/internal/api/router.go b/internal/api/router.go index a032930..ce6242e 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -29,6 +29,7 @@ func NewRouter( hsStore *store.HiveshareStore, hiveStore *store.HiveStore, metricsStore *store.MetricsStore, + historyStore *store.HistoryStore, embedder embed.Embedder, hub *realtime.Hub, worker *embed.Worker, @@ -59,7 +60,7 @@ func NewRouter( auth := NewAuthHandler(userStore) hs := NewHiveshareHandler(hsStore, metricsStore) - hive := NewHiveHandler(hiveStore, hsStore, metricsStore, embedder, hub, worker, views) + hive := NewHiveHandler(hiveStore, hsStore, metricsStore, historyStore, embedder, hub, worker, views) met := NewMetricsHandler(metricsStore) r.Get("/health", healthHandler(pool, rdb)) @@ -94,6 +95,16 @@ func NewRouter( r.Put("/hiveshares/{id}/hives/{entryId}", hive.Update) r.Delete("/hiveshares/{id}/hives/{entryId}", hive.Delete) r.Post("/hiveshares/{id}/hives/search", hive.Search) + r.Get("/hiveshares/{id}/hives/{entryId}/history", hive.ListHistory) + r.Post("/hiveshares/{id}/hives/{entryId}/rollback", hive.Rollback) + r.Post("/hiveshares/{id}/hives/undelete", hive.Undelete) + r.Post("/hiveshares/{id}/hives/copy", hive.CopyEntries) + + r.Post("/hiveshares/{id}/snapshots", hive.CreateSnapshot) + r.Get("/hiveshares/{id}/snapshots", hive.ListSnapshots) + r.Get("/hiveshares/{id}/snapshots/{snapshotId}", hive.GetSnapshot) + r.Post("/hiveshares/{id}/snapshots/{snapshotId}/restore", hive.RestoreSnapshot) + r.Delete("/hiveshares/{id}/snapshots/{snapshotId}", hive.DeleteSnapshot) r.Get("/hiveshares/{id}/metrics", hs.Metrics) r.Get("/metrics/me", met.UserMetrics) diff --git a/internal/models/models.go b/internal/models/models.go index 89b5110..30c74b6 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -161,6 +161,51 @@ type UserMetrics struct { TotalReusesGiven int `json:"total_reuses_given"` } +// HistoryEntry is one version row from hives_history (insert/update/delete). +type HistoryEntry struct { + HistoryID int64 `json:"history_id"` + EntryID uuid.UUID `json:"entry_id"` + HiveshareID uuid.UUID `json:"hiveshare_id"` + UserID uuid.UUID `json:"user_id"` + Action string `json:"action"` + Content string `json:"content,omitempty"` + Summary string `json:"summary,omitempty"` + HasEmbedding bool `json:"has_embedding"` + Tags []string `json:"tags"` + Metadata map[string]interface{} `json:"metadata,omitempty"` + SourceType string `json:"source_type"` + SourceRef string `json:"source_ref"` + SourceURL string `json:"source_url,omitempty"` + Tool string `json:"tool,omitempty"` + RecordedAt time.Time `json:"recorded_at"` +} + +// Snapshot is a point-in-time capture of all hives in a hiveshare. +type Snapshot struct { + SnapshotID int64 `json:"snapshot_id"` + HiveshareID uuid.UUID `json:"hiveshare_id"` + CreatedBy uuid.UUID `json:"created_by"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + EntryCount int `json:"entry_count"` + CreatedAt time.Time `json:"created_at"` +} + +// SnapshotEntry is one hive captured inside a snapshot (no history metadata). +type SnapshotEntry struct { + EntryID uuid.UUID `json:"entry_id"` + HiveshareID uuid.UUID `json:"hiveshare_id"` + Content string `json:"content,omitempty"` + Summary string `json:"summary,omitempty"` + HasEmbedding bool `json:"has_embedding"` + Tags []string `json:"tags"` + Metadata map[string]interface{} `json:"metadata,omitempty"` + SourceType string `json:"source_type"` + SourceRef string `json:"source_ref"` + SourceURL string `json:"source_url,omitempty"` + Tool string `json:"tool,omitempty"` +} + // StreamEvent is the payload pushed over SSE to connected clients whenever a // hive is added or updated in a hiveshare. type StreamEvent struct { diff --git a/internal/store/errors.go b/internal/store/errors.go new file mode 100644 index 0000000..dd441e4 --- /dev/null +++ b/internal/store/errors.go @@ -0,0 +1,19 @@ +package store + +import ( + "errors" + + "github.com/jackc/pgx/v5/pgconn" +) + +// ErrForbidden is returned when the caller lacks membership on a source hiveshare. +var ErrForbidden = errors.New("forbidden") + +// ErrSnapshotTooLarge is returned when a hiveshare has too many entries to snapshot. +var ErrSnapshotTooLarge = errors.New("snapshot too large") + +// IsUniqueViolation reports whether err is a Postgres unique_violation (23505). +func IsUniqueViolation(err error) bool { + var pgErr *pgconn.PgError + return errors.As(err, &pgErr) && pgErr.Code == "23505" +} diff --git a/internal/store/history.go b/internal/store/history.go new file mode 100644 index 0000000..cd2f9a1 --- /dev/null +++ b/internal/store/history.go @@ -0,0 +1,462 @@ +package store + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/pgvector/pgvector-go" + "github.com/KB-perByte/hiveshare/internal/models" +) + +type HistoryStore struct { + db *pgxpool.Pool +} + +func NewHistoryStore(db *pgxpool.Pool) *HistoryStore { + return &HistoryStore{db: db} +} + +// ── Per-entry history ──────────────────────────────────────────────────────── + +func (s *HistoryStore) ListVersions(ctx context.Context, entryID, hiveshareID uuid.UUID, limit, offset int) ([]*models.HistoryEntry, error) { + if limit == 0 { + limit = 20 + } + rows, err := s.db.Query(ctx, + `SELECT history_id, entry_id, hiveshare_id, user_id, action, + content, summary, (embedding IS NOT NULL) AS has_embedding, + tags, metadata, source_type, source_ref, source_url, tool, recorded_at + FROM hives_history + WHERE entry_id = $1 AND hiveshare_id = $2 + ORDER BY recorded_at DESC + LIMIT $3 OFFSET $4`, + entryID, hiveshareID, limit, offset, + ) + if err != nil { + return nil, fmt.Errorf("list versions: %w", err) + } + defer rows.Close() + + var result []*models.HistoryEntry + for rows.Next() { + var h models.HistoryEntry + if err := rows.Scan(&h.HistoryID, &h.EntryID, &h.HiveshareID, &h.UserID, &h.Action, + &h.Content, &h.Summary, &h.HasEmbedding, + &h.Tags, &h.Metadata, &h.SourceType, &h.SourceRef, &h.SourceURL, &h.Tool, &h.RecordedAt); err != nil { + return nil, err + } + result = append(result, &h) + } + return result, rows.Err() +} + +func (s *HistoryStore) Rollback(ctx context.Context, entryID, hiveshareID uuid.UUID, historyID int64) (*models.Hive, bool, error) { + var e models.Hive + var hasEmbedding bool + err := s.db.QueryRow(ctx, + `UPDATE hives me + SET content = h.content, summary = h.summary, tags = h.tags, metadata = h.metadata, + embedding = h.embedding, updated_at = NOW() + FROM hives_history h + WHERE me.id = h.entry_id + AND me.id = $1 AND me.hiveshare_id = $2 AND h.history_id = $3 + RETURNING me.id, me.hiveshare_id, me.user_id, me.source_type, me.source_ref, me.source_url, + me.tool, me.content, me.summary, me.tags, me.metadata, me.views, me.reuses, + me.created_at, me.updated_at, (me.embedding IS NOT NULL)`, + entryID, hiveshareID, historyID, + ).Scan(&e.ID, &e.HiveshareID, &e.UserID, &e.SourceType, &e.SourceRef, &e.SourceURL, + &e.Tool, &e.Content, &e.Summary, &e.Tags, &e.Metadata, &e.Views, &e.Reuses, + &e.CreatedAt, &e.UpdatedAt, &hasEmbedding) + if err != nil { + return nil, false, fmt.Errorf("rollback entry: %w", err) + } + return &e, hasEmbedding, nil +} + +func (s *HistoryStore) Undelete(ctx context.Context, historyID int64, hiveshareID uuid.UUID) (*models.Hive, bool, error) { + var src models.HistoryEntry + err := s.db.QueryRow(ctx, + `SELECT entry_id, hiveshare_id, user_id, content, summary, embedding IS NOT NULL, + tags, metadata, source_type, source_ref, source_url, tool + FROM hives_history + WHERE history_id = $1 AND action = 'delete' AND hiveshare_id = $2`, + historyID, hiveshareID, + ).Scan(&src.EntryID, &src.HiveshareID, &src.UserID, &src.Content, &src.Summary, &src.HasEmbedding, + &src.Tags, &src.Metadata, &src.SourceType, &src.SourceRef, &src.SourceURL, &src.Tool) + if err != nil { + return nil, false, fmt.Errorf("undelete entry: %w", err) + } + + baseRef := src.SourceRef + var e models.Hive + var hasEmbedding bool + for n := 0; ; n++ { + ref := baseRef + if n > 0 { + ref = fmt.Sprintf("%s-%d", baseRef, n+1) + } + err = s.db.QueryRow(ctx, + `INSERT INTO hives + (id, hiveshare_id, user_id, source_type, source_ref, source_url, + tool, content, summary, embedding, tags, metadata) + SELECT entry_id, hiveshare_id, user_id, source_type, $3, source_url, + tool, content, summary, embedding, tags, metadata + FROM hives_history + WHERE history_id = $1 AND action = 'delete' AND hiveshare_id = $2 + RETURNING id, hiveshare_id, user_id, source_type, source_ref, source_url, + tool, content, summary, tags, metadata, views, reuses, + created_at, updated_at, (embedding IS NOT NULL)`, + historyID, hiveshareID, ref, + ).Scan(&e.ID, &e.HiveshareID, &e.UserID, &e.SourceType, &e.SourceRef, &e.SourceURL, + &e.Tool, &e.Content, &e.Summary, &e.Tags, &e.Metadata, &e.Views, &e.Reuses, + &e.CreatedAt, &e.UpdatedAt, &hasEmbedding) + if err == nil { + return &e, hasEmbedding, nil + } + if n >= 9 || !IsUniqueViolation(err) { + return nil, false, fmt.Errorf("undelete entry: %w", err) + } + } +} + +// ── Purge ──────────────────────────────────────────────────────────────────── + +func (s *HistoryStore) PurgeByAge(ctx context.Context, olderThan time.Duration) (int64, error) { + cutoff := time.Now().Add(-olderThan) + tag, err := s.db.Exec(ctx, + `DELETE FROM hives_history WHERE recorded_at < $1`, + cutoff, + ) + if err != nil { + return 0, fmt.Errorf("purge history by age: %w", err) + } + return tag.RowsAffected(), nil +} + +func (s *HistoryStore) PurgeByCount(ctx context.Context, maxVersions int) (int64, error) { + tag, err := s.db.Exec(ctx, + `DELETE FROM hives_history + WHERE history_id IN ( + SELECT history_id FROM ( + SELECT history_id, + ROW_NUMBER() OVER (PARTITION BY entry_id ORDER BY recorded_at DESC) AS rn + FROM hives_history + ) ranked + WHERE rn > $1 + )`, + maxVersions, + ) + if err != nil { + return 0, fmt.Errorf("purge history by count: %w", err) + } + return tag.RowsAffected(), nil +} + +// ── Snapshots ──────────────────────────────────────────────────────────────── + +// maxSnapshotEntries caps snapshot size so embeddings for huge hiveshares +// cannot balloon disk/memory without an explicit opt-in later. +const maxSnapshotEntries = 10000 + +func (s *HistoryStore) CreateSnapshot(ctx context.Context, hiveshareID, userID uuid.UUID, name, description string) (*models.Snapshot, error) { + var count int + if err := s.db.QueryRow(ctx, + `SELECT COUNT(*) FROM hives WHERE hiveshare_id = $1`, hiveshareID, + ).Scan(&count); err != nil { + return nil, fmt.Errorf("count hives for snapshot: %w", err) + } + if count > maxSnapshotEntries { + return nil, fmt.Errorf("%w: %d entries (max %d)", ErrSnapshotTooLarge, count, maxSnapshotEntries) + } + + tx, err := s.db.Begin(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) + + var snap models.Snapshot + err = tx.QueryRow(ctx, + `INSERT INTO hiveshare_snapshots (hiveshare_id, created_by, name, description) + VALUES ($1, $2, $3, $4) + RETURNING snapshot_id, hiveshare_id, created_by, name, description, created_at`, + hiveshareID, userID, name, description, + ).Scan(&snap.SnapshotID, &snap.HiveshareID, &snap.CreatedBy, &snap.Name, &snap.Description, &snap.CreatedAt) + if err != nil { + return nil, fmt.Errorf("insert snapshot: %w", err) + } + + tag, err := tx.Exec(ctx, + `INSERT INTO hiveshare_snapshot_entries + (snapshot_id, entry_id, content, summary, embedding, tags, metadata, + source_type, source_ref, source_url, tool) + SELECT $1, id, content, summary, embedding, tags, metadata, + source_type, source_ref, source_url, tool + FROM hives + WHERE hiveshare_id = $2`, + snap.SnapshotID, hiveshareID, + ) + if err != nil { + return nil, fmt.Errorf("snapshot entries: %w", err) + } + snap.EntryCount = int(tag.RowsAffected()) + + if err := tx.Commit(ctx); err != nil { + return nil, err + } + return &snap, nil +} + +func (s *HistoryStore) ListSnapshots(ctx context.Context, hiveshareID uuid.UUID) ([]*models.Snapshot, error) { + rows, err := s.db.Query(ctx, + `SELECT s.snapshot_id, s.hiveshare_id, s.created_by, s.name, s.description, s.created_at, + (SELECT COUNT(*) FROM hiveshare_snapshot_entries se WHERE se.snapshot_id = s.snapshot_id) AS entry_count + FROM hiveshare_snapshots s + WHERE s.hiveshare_id = $1 + ORDER BY s.created_at DESC`, + hiveshareID, + ) + if err != nil { + return nil, fmt.Errorf("list snapshots: %w", err) + } + defer rows.Close() + + var result []*models.Snapshot + for rows.Next() { + var snap models.Snapshot + if err := rows.Scan(&snap.SnapshotID, &snap.HiveshareID, &snap.CreatedBy, &snap.Name, + &snap.Description, &snap.CreatedAt, &snap.EntryCount); err != nil { + return nil, err + } + result = append(result, &snap) + } + return result, rows.Err() +} + +func (s *HistoryStore) GetSnapshot(ctx context.Context, snapshotID int64, hiveshareID uuid.UUID) (*models.Snapshot, []*models.SnapshotEntry, error) { + var snap models.Snapshot + err := s.db.QueryRow(ctx, + `SELECT s.snapshot_id, s.hiveshare_id, s.created_by, s.name, s.description, s.created_at, + (SELECT COUNT(*) FROM hiveshare_snapshot_entries se WHERE se.snapshot_id = s.snapshot_id) + FROM hiveshare_snapshots s + WHERE s.snapshot_id = $1 AND s.hiveshare_id = $2`, + snapshotID, hiveshareID, + ).Scan(&snap.SnapshotID, &snap.HiveshareID, &snap.CreatedBy, &snap.Name, + &snap.Description, &snap.CreatedAt, &snap.EntryCount) + if err != nil { + return nil, nil, fmt.Errorf("get snapshot: %w", err) + } + + rows, err := s.db.Query(ctx, + `SELECT entry_id, content, summary, (embedding IS NOT NULL) AS has_embedding, + tags, metadata, source_type, source_ref, source_url, tool + FROM hiveshare_snapshot_entries + WHERE snapshot_id = $1`, + snapshotID, + ) + if err != nil { + return nil, nil, fmt.Errorf("get snapshot entries: %w", err) + } + defer rows.Close() + + var entries []*models.SnapshotEntry + for rows.Next() { + e := &models.SnapshotEntry{HiveshareID: snap.HiveshareID} + if err := rows.Scan(&e.EntryID, &e.Content, &e.Summary, &e.HasEmbedding, + &e.Tags, &e.Metadata, &e.SourceType, &e.SourceRef, &e.SourceURL, &e.Tool); err != nil { + return nil, nil, err + } + entries = append(entries, e) + } + return &snap, entries, rows.Err() +} + +type RestoreResult struct { + Hiveshare *models.Hiveshare + EntriesCreated int + NullEmbeddings []uuid.UUID +} + +func (s *HistoryStore) RestoreSnapshot(ctx context.Context, snapshotID int64, sourceHiveshareID, userID uuid.UUID, name string) (*RestoreResult, error) { + tx, err := s.db.Begin(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) + + var hs models.Hiveshare + err = tx.QueryRow(ctx, + `INSERT INTO hiveshares (name, description, owner_id) + SELECT $1, s.description, $2 + FROM hiveshare_snapshots s + WHERE s.snapshot_id = $3 AND s.hiveshare_id = $4 + RETURNING id, name, description, owner_id, settings, created_at, updated_at`, + name, userID, snapshotID, sourceHiveshareID, + ).Scan(&hs.ID, &hs.Name, &hs.Description, &hs.OwnerID, &hs.Settings, &hs.CreatedAt, &hs.UpdatedAt) + if err != nil { + return nil, fmt.Errorf("create hiveshare from snapshot: %w", err) + } + + _, err = tx.Exec(ctx, + `INSERT INTO hiveshare_members (hiveshare_id, user_id, role) VALUES ($1, $2, 'all')`, + hs.ID, userID, + ) + if err != nil { + return nil, fmt.Errorf("insert owner member: %w", err) + } + + tag, err := tx.Exec(ctx, + `INSERT INTO hives + (hiveshare_id, user_id, source_type, source_ref, source_url, + tool, content, summary, embedding, tags, metadata) + SELECT $1, $2, source_type, source_ref, source_url, + tool, content, summary, embedding, tags, metadata + FROM hiveshare_snapshot_entries + WHERE snapshot_id = $3`, + hs.ID, userID, snapshotID, + ) + if err != nil { + return nil, fmt.Errorf("restore snapshot entries: %w", err) + } + + rows, err := tx.Query(ctx, + `SELECT id FROM hives WHERE hiveshare_id = $1 AND embedding IS NULL`, + hs.ID, + ) + if err != nil { + return nil, fmt.Errorf("find null embeddings: %w", err) + } + defer rows.Close() + var nullEmbeddings []uuid.UUID + for rows.Next() { + var id uuid.UUID + if err := rows.Scan(&id); err != nil { + return nil, err + } + nullEmbeddings = append(nullEmbeddings, id) + } + if err := rows.Err(); err != nil { + return nil, err + } + + if err := tx.Commit(ctx); err != nil { + return nil, err + } + + hs.Role = models.RoleAll + hs.MemberCount = 1 + return &RestoreResult{ + Hiveshare: &hs, + EntriesCreated: int(tag.RowsAffected()), + NullEmbeddings: nullEmbeddings, + }, nil +} + +func (s *HistoryStore) DeleteSnapshot(ctx context.Context, snapshotID int64, hiveshareID uuid.UUID) (bool, error) { + tag, err := s.db.Exec(ctx, + `DELETE FROM hiveshare_snapshots WHERE snapshot_id = $1 AND hiveshare_id = $2`, + snapshotID, hiveshareID, + ) + if err != nil { + return false, err + } + return tag.RowsAffected() > 0, nil +} + +// ── Copy entries ───────────────────────────────────────────────────────────── + +type CopyResult struct { + Entry *models.Hive + HasEmbedding bool +} + +// CopyEntries copies hives into targetHiveshareID. +// ponytail: O(n) round-trips per entry_id (fine for small lists); bulk +// INSERT…SELECT FROM unnest($1::uuid[]) if copy batches grow past ~100. +func (s *HistoryStore) CopyEntries(ctx context.Context, targetHiveshareID, userID uuid.UUID, entryIDs []uuid.UUID) ([]*CopyResult, error) { + if len(entryIDs) == 0 { + return nil, nil + } + + tx, err := s.db.Begin(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) + + var results []*CopyResult + for _, eid := range entryIDs { + var e models.Hive + var hasEmb bool + var emb *pgvector.Vector + var sourceHS uuid.UUID + err := tx.QueryRow(ctx, + `SELECT h.id, h.hiveshare_id, h.source_type, h.source_ref, h.source_url, h.tool, + h.content, h.summary, h.embedding, h.tags, h.metadata + FROM hives h + JOIN hiveshare_members m ON m.hiveshare_id = h.hiveshare_id AND m.user_id = $2 + WHERE h.id = $1`, + eid, userID, + ).Scan(&e.ID, &sourceHS, &e.SourceType, &e.SourceRef, &e.SourceURL, &e.Tool, + &e.Content, &e.Summary, &emb, &e.Tags, &e.Metadata) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + // Distinguish missing vs forbidden without leaking existence across tenants. + var exists bool + _ = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM hives WHERE id = $1)`, eid).Scan(&exists) + if exists { + return nil, ErrForbidden + } + return nil, fmt.Errorf("read source entry %s: %w", eid, err) + } + return nil, fmt.Errorf("read source entry %s: %w", eid, err) + } + _ = sourceHS + + var embVal interface{} + if emb != nil { + embVal = emb + hasEmb = true + } + + baseRef := e.SourceRef + var newEntry models.Hive + for n := 0; ; n++ { + ref := baseRef + if n > 0 { + ref = fmt.Sprintf("%s-%d", baseRef, n+1) + } + err = tx.QueryRow(ctx, + `INSERT INTO hives + (hiveshare_id, user_id, source_type, source_ref, source_url, + tool, content, summary, embedding, tags, metadata) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + RETURNING id, hiveshare_id, user_id, source_type, source_ref, source_url, + tool, content, summary, tags, metadata, views, reuses, created_at, updated_at`, + targetHiveshareID, userID, e.SourceType, ref, e.SourceURL, + e.Tool, e.Content, e.Summary, embVal, e.Tags, e.Metadata, + ).Scan(&newEntry.ID, &newEntry.HiveshareID, &newEntry.UserID, &newEntry.SourceType, + &newEntry.SourceRef, &newEntry.SourceURL, &newEntry.Tool, &newEntry.Content, + &newEntry.Summary, &newEntry.Tags, &newEntry.Metadata, &newEntry.Views, + &newEntry.Reuses, &newEntry.CreatedAt, &newEntry.UpdatedAt) + if err == nil { + break + } + if n >= 9 || !IsUniqueViolation(err) { + return nil, fmt.Errorf("copy entry %s: %w", eid, err) + } + } + + results = append(results, &CopyResult{Entry: &newEntry, HasEmbedding: hasEmb}) + } + + if err := tx.Commit(ctx); err != nil { + return nil, err + } + return results, nil +} diff --git a/migrations/006_hive_history.sql b/migrations/006_hive_history.sql new file mode 100644 index 0000000..1870a82 --- /dev/null +++ b/migrations/006_hive_history.sql @@ -0,0 +1,105 @@ +-- Hive history: trigger-based audit trail for all content mutations. +-- Enables per-hive rollback/undelete and hiveshare-level snapshots. +-- Runs after 005_rename_memory_to_hive.sql (table is already named hives). + +-- ── History table ──────────────────────────────────────────────────────────── + +CREATE TABLE IF NOT EXISTS hives_history ( + history_id BIGSERIAL PRIMARY KEY, + entry_id UUID NOT NULL, + hiveshare_id UUID NOT NULL, + user_id UUID NOT NULL, + action TEXT NOT NULL, + content TEXT, + summary TEXT, + embedding vector(1536), + tags TEXT[] NOT NULL DEFAULT '{}', + metadata JSONB NOT NULL DEFAULT '{}', + source_type TEXT, + source_ref TEXT, + source_url TEXT, + tool TEXT, + recorded_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT valid_history_action CHECK (action IN ('insert', 'update', 'delete')) +); + +CREATE INDEX IF NOT EXISTS hives_history_entry_idx + ON hives_history (entry_id, recorded_at DESC); + +CREATE INDEX IF NOT EXISTS hives_history_hiveshare_idx + ON hives_history (hiveshare_id, recorded_at DESC); + +-- ── Trigger function ───────────────────────────────────────────────────────── + +CREATE OR REPLACE FUNCTION record_hive_history() RETURNS TRIGGER AS $$ +BEGIN + IF TG_OP = 'DELETE' THEN + INSERT INTO hives_history + (entry_id, hiveshare_id, user_id, action, content, summary, embedding, + tags, metadata, source_type, source_ref, source_url, tool) + VALUES + (OLD.id, OLD.hiveshare_id, OLD.user_id, 'delete', OLD.content, OLD.summary, + OLD.embedding, OLD.tags, OLD.metadata, OLD.source_type, OLD.source_ref, + OLD.source_url, OLD.tool); + RETURN OLD; + ELSE + INSERT INTO hives_history + (entry_id, hiveshare_id, user_id, action, content, summary, embedding, + tags, metadata, source_type, source_ref, source_url, tool) + VALUES + (NEW.id, NEW.hiveshare_id, NEW.user_id, lower(TG_OP), NEW.content, NEW.summary, + NEW.embedding, NEW.tags, NEW.metadata, NEW.source_type, NEW.source_ref, + NEW.source_url, NEW.tool); + RETURN NEW; + END IF; +END; +$$ LANGUAGE plpgsql; + +-- embedding is intentionally omitted from UPDATE OF: the async embed worker +-- fills it after insert and must not create a spurious "update" history row. +DROP TRIGGER IF EXISTS hive_history_trigger ON hives; +CREATE TRIGGER hive_history_trigger + AFTER INSERT OR UPDATE OF content, summary, tags, metadata OR DELETE + ON hives + FOR EACH ROW EXECUTE FUNCTION record_hive_history(); + +-- ── Snapshot tables ────────────────────────────────────────────────────────── + +CREATE TABLE IF NOT EXISTS hiveshare_snapshots ( + snapshot_id BIGSERIAL PRIMARY KEY, + hiveshare_id UUID NOT NULL REFERENCES hiveshares(id) ON DELETE CASCADE, + created_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + description TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS hiveshare_snapshot_entries ( + snapshot_id BIGINT NOT NULL REFERENCES hiveshare_snapshots(snapshot_id) ON DELETE CASCADE, + entry_id UUID NOT NULL, + content TEXT, + summary TEXT, + embedding vector(1536), + tags TEXT[] NOT NULL DEFAULT '{}', + metadata JSONB NOT NULL DEFAULT '{}', + source_type TEXT, + source_ref TEXT, + source_url TEXT, + tool TEXT, + PRIMARY KEY (snapshot_id, entry_id) +); + +CREATE INDEX IF NOT EXISTS hiveshare_snapshots_hs_idx + ON hiveshare_snapshots (hiveshare_id, created_at DESC); + +-- ── Backfill existing entries ──────────────────────────────────────────────── + +INSERT INTO hives_history + (entry_id, hiveshare_id, user_id, action, content, summary, embedding, + tags, metadata, source_type, source_ref, source_url, tool) +SELECT id, hiveshare_id, user_id, 'insert', content, summary, embedding, + tags, metadata, source_type, source_ref, source_url, tool +FROM hives +WHERE NOT EXISTS ( + SELECT 1 FROM hives_history WHERE entry_id = hives.id +); diff --git a/scripts/smoke-helpers.sh b/scripts/smoke-helpers.sh new file mode 100755 index 0000000..1aab297 --- /dev/null +++ b/scripts/smoke-helpers.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# Shared helpers for smoke test scripts. Source this, don't run it. + +SMOKE_BASE="${1:-${HIVESHARE_TEST_URL:-http://localhost:8080}}/api/v1" +SMOKE_PASS=0 +SMOKE_FAIL=0 +SMOKE_TMPDIR=$(mktemp -d "${TMPDIR:-/tmp}/hiveshare-smoke.XXXXXX") +trap 'rm -rf "$SMOKE_TMPDIR"' EXIT + +smoke_ok() { SMOKE_PASS=$((SMOKE_PASS+1)); echo " PASS: $1"; } +smoke_fail() { SMOKE_FAIL=$((SMOKE_FAIL+1)); echo " FAIL: $1"; } +smoke_check() { + if [ "$1" = "$2" ]; then smoke_ok "$3"; else smoke_fail "$3 (expected '$2', got '$1')"; fi +} +smoke_section() { echo ""; echo "── $1 ──"; } +smoke_summary() { + echo "" + echo "=== $1: $SMOKE_PASS passed, $SMOKE_FAIL failed ===" + [ "$SMOKE_FAIL" -eq 0 ] && return 0 || return 1 +} + +smoke_register() { + local suffix="$1" + local name="$2" + local ts + ts=$(date +%s%N) + curl -sf -X POST "$SMOKE_BASE/auth/register" \ + -H "Content-Type: application/json" \ + -d "{\"email\":\"${suffix}-${ts}@test.local\",\"name\":\"${name}\"}" +} diff --git a/scripts/smoke-test-auth.sh b/scripts/smoke-test-auth.sh new file mode 100755 index 0000000..0ff062a --- /dev/null +++ b/scripts/smoke-test-auth.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/smoke-helpers.sh" "$@" + +echo "=== Auth Smoke Test ===" + +smoke_section "Register" +TS=$(date +%s%N) +REG_CODE=$(curl -s -o "$SMOKE_TMPDIR/auth_reg.json" -w "%{http_code}" -X POST "$SMOKE_BASE/auth/register" \ + -H "Content-Type: application/json" \ + -d "{\"email\":\"auth-${TS}@test.local\",\"name\":\"Auth Test\"}") +REG=$(cat "$SMOKE_TMPDIR/auth_reg.json") +smoke_check "$REG_CODE" "201" "register returns 201" +KEY=$(echo "$REG" | jq -r '.api_key') +[ -n "$KEY" ] && [ "$KEY" != "null" ] && smoke_ok "registered" || smoke_fail "register" +echo "$KEY" | grep -q "^hvs_" && smoke_ok "api_key has hvs_ prefix" || smoke_fail "api_key missing hvs_ prefix" +ID=$(echo "$REG" | jq -r '.id') +[ -n "$ID" ] && [ "$ID" != "null" ] && smoke_ok "response has id" || smoke_fail "response missing id" +AUTH="Authorization: Bearer $KEY" + +smoke_section "Whoami" +WHOAMI_CODE=$(curl -s -o "$SMOKE_TMPDIR/auth_whoami.json" -w "%{http_code}" "$SMOKE_BASE/auth/whoami" -H "$AUTH") +WHOAMI=$(cat "$SMOKE_TMPDIR/auth_whoami.json") +smoke_check "$WHOAMI_CODE" "200" "whoami returns 200" +WHOAMI_EMAIL=$(echo "$WHOAMI" | jq -r '.email') +REG_EMAIL=$(echo "$REG" | jq -r '.email') +smoke_check "$WHOAMI_EMAIL" "$REG_EMAIL" "whoami returns correct email" +smoke_check "$(echo "$WHOAMI" | jq -r '.name')" "Auth Test" "whoami returns correct name" + +smoke_section "Duplicate email" +EMAIL=$(echo "$REG" | jq -r '.email') +DUP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$SMOKE_BASE/auth/register" \ + -H "Content-Type: application/json" \ + -d "{\"email\":\"$EMAIL\",\"name\":\"Dup\"}") +smoke_check "$DUP_CODE" "409" "duplicate email rejected" + +smoke_section "Missing fields" +BAD_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$SMOKE_BASE/auth/register" \ + -H "Content-Type: application/json" -d '{"email":"only-email@test.local"}') +smoke_check "$BAD_CODE" "400" "missing name returns 400" + +smoke_section "Auth enforcement" +NO_AUTH=$(curl -s -o /dev/null -w "%{http_code}" "$SMOKE_BASE/auth/whoami") +smoke_check "$NO_AUTH" "401" "no auth returns 401" + +BAD_KEY=$(curl -s -o /dev/null -w "%{http_code}" "$SMOKE_BASE/auth/whoami" \ + -H "Authorization: Bearer hvs_invalid") +smoke_check "$BAD_KEY" "401" "bad key returns 401" + +smoke_summary "Auth" diff --git a/scripts/smoke-test-full.sh b/scripts/smoke-test-full.sh new file mode 100755 index 0000000..5de385c --- /dev/null +++ b/scripts/smoke-test-full.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Smoke test harness — runs all smoke-test-*.sh scripts in order. +# Usage: ./scripts/smoke-test-full.sh [base_url] + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +URL="${1:-${HIVESHARE_TEST_URL:-http://localhost:8080}}" +TOTAL_PASS=0 +TOTAL_FAIL=0 +FAILED_SUITES="" + +run_suite() { + local script="$1" + local name + name=$(basename "$script" .sh | sed 's/smoke-test-//') + echo "" + echo "================================================================" + echo " Running: $name" + echo "================================================================" + if "$script" "$URL"; then + TOTAL_PASS=$((TOTAL_PASS+1)) + else + TOTAL_FAIL=$((TOTAL_FAIL+1)) + FAILED_SUITES="$FAILED_SUITES $name" + fi +} + +# Connectivity first — bail if server is unreachable +run_suite "$SCRIPT_DIR/smoke-test.sh" +if [ "$TOTAL_FAIL" -gt 0 ]; then + echo "" + echo "=== Connectivity failed — skipping remaining suites ===" + exit 1 +fi + +# Run all smoke-test-*.sh scripts except the harness itself and the base connectivity test +for script in "$SCRIPT_DIR"/smoke-test-*.sh; do + base=$(basename "$script") + case "$base" in + smoke-test-full.sh|smoke-test.sh) continue ;; + esac + run_suite "$script" +done + +echo "" +echo "================================================================" +echo " Final: $TOTAL_PASS suites passed, $TOTAL_FAIL failed" +if [ -n "$FAILED_SUITES" ]; then + echo " Failed:$FAILED_SUITES" +fi +echo "================================================================" +[ "$TOTAL_FAIL" -eq 0 ] && exit 0 || exit 1 diff --git a/scripts/smoke-test-history.sh b/scripts/smoke-test-history.sh new file mode 100755 index 0000000..7c186c2 --- /dev/null +++ b/scripts/smoke-test-history.sh @@ -0,0 +1,149 @@ +#!/usr/bin/env bash +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/smoke-helpers.sh" "$@" + +echo "=== History Smoke Test ===" + +REG=$(smoke_register "hist" "History User") +KEY=$(echo "$REG" | jq -r '.api_key') +AUTH="Authorization: Bearer $KEY" + +HS=$(curl -sf -X POST "$SMOKE_BASE/hiveshares" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d '{"name":"hist-test"}') +HS_ID=$(echo "$HS" | jq -r '.id') + +# ── Create + verify history ─────────────────────────────────────────────────── +smoke_section "Entry history" + +ENTRY=$(curl -sf -X POST "$SMOKE_BASE/hiveshares/$HS_ID/hives" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d '{"source_type":"manual","source_ref":"hist-1","content":"Original content","tool":"manual","tags":["test"]}') +ENTRY_ID=$(echo "$ENTRY" | jq -r '.id') +smoke_ok "created entry" + +HIST=$(curl -sf "$SMOKE_BASE/hiveshares/$HS_ID/hives/$ENTRY_ID/history" -H "$AUTH") +HIST_LEN=$(echo "$HIST" | jq 'length') +[ "$HIST_LEN" -ge 1 ] && smoke_ok "history has $HIST_LEN versions" || smoke_fail "no history" +INSERT_COUNT=$(echo "$HIST" | jq '[.[] | select(.action=="insert")] | length') +smoke_check "$INSERT_COUNT" "1" "insert history row exists" +LAST_ACTION=$(echo "$HIST" | jq -r '.[-1].action') +smoke_check "$LAST_ACTION" "insert" "last history action is insert" +HIST_ID=$(echo "$HIST" | jq '.[0].history_id') + +# ── Update + verify history ─────────────────────────────────────────────────── +curl -sf -X PUT "$SMOKE_BASE/hiveshares/$HS_ID/hives/$ENTRY_ID" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d '{"content":"Updated content","summary":"updated","tags":["updated"]}' > /dev/null + +HIST2=$(curl -sf "$SMOKE_BASE/hiveshares/$HS_ID/hives/$ENTRY_ID/history" -H "$AUTH") +UPDATE_COUNT=$(echo "$HIST2" | jq '[.[] | select(.action=="update")] | length') +[ "$UPDATE_COUNT" -ge 1 ] && smoke_ok "update history row exists" || smoke_fail "no update history" + +# ── Rollback ────────────────────────────────────────────────────────────────── +smoke_section "Rollback" + +ROLLED=$(curl -sf -X POST "$SMOKE_BASE/hiveshares/$HS_ID/hives/$ENTRY_ID/rollback" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d "{\"history_id\":$HIST_ID}") +ROLL_CONTENT=$(echo "$ROLLED" | jq -r '.content') +smoke_check "$ROLL_CONTENT" "Original content" "rollback restored original" + +# ── Delete + Undelete ───────────────────────────────────────────────────────── +smoke_section "Delete + Undelete" + +ENTRY2=$(curl -sf -X POST "$SMOKE_BASE/hiveshares/$HS_ID/hives" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d '{"source_type":"manual","source_ref":"del-test","content":"Delete me","tool":"manual","tags":[]}') +ENTRY2_ID=$(echo "$ENTRY2" | jq -r '.id') + +curl -sf -X DELETE "$SMOKE_BASE/hiveshares/$HS_ID/hives/$ENTRY2_ID" -H "$AUTH" > /dev/null +smoke_ok "deleted entry" + +GET_AFTER_DEL=$(curl -s -o /dev/null -w "%{http_code}" \ + "$SMOKE_BASE/hiveshares/$HS_ID/hives/$ENTRY2_ID" -H "$AUTH") +smoke_check "$GET_AFTER_DEL" "404" "deleted entry returns 404" + +DEL_HIST=$(curl -sf "$SMOKE_BASE/hiveshares/$HS_ID/hives/$ENTRY2_ID/history" -H "$AUTH") +DEL_HIST_ID=$(echo "$DEL_HIST" | jq '[.[] | select(.action=="delete")][0].history_id') +[ "$DEL_HIST_ID" != "null" ] && smoke_ok "delete history row found" || smoke_fail "no delete history" + +UNDEL_CODE=$(curl -s -o $SMOKE_TMPDIR/hist_undel.json -w "%{http_code}" -X POST "$SMOKE_BASE/hiveshares/$HS_ID/hives/undelete" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d "{\"history_id\":$DEL_HIST_ID}") +UNDEL=$(cat $SMOKE_TMPDIR/hist_undel.json) +smoke_check "$UNDEL_CODE" "201" "undelete returns 201" +UNDEL_ID=$(echo "$UNDEL" | jq -r '.id') +smoke_check "$UNDEL_ID" "$ENTRY2_ID" "undeleted with same ID" +UNDEL_CONTENT=$(echo "$UNDEL" | jq -r '.content') +smoke_check "$UNDEL_CONTENT" "Delete me" "undeleted content matches original" + +# ── Snapshots ───────────────────────────────────────────────────────────────── +smoke_section "Snapshots" + +SNAP_CODE=$(curl -s -o $SMOKE_TMPDIR/hist_snap.json -w "%{http_code}" -X POST "$SMOKE_BASE/hiveshares/$HS_ID/snapshots" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d '{"name":"test-snap","description":"smoke test"}') +SNAP=$(cat $SMOKE_TMPDIR/hist_snap.json) +smoke_check "$SNAP_CODE" "201" "snapshot create returns 201" +SNAP_ID=$(echo "$SNAP" | jq '.snapshot_id') +smoke_check "$(echo "$SNAP" | jq -r '.name')" "test-snap" "snapshot has correct name" +SNAP_EC=$(echo "$SNAP" | jq '.entry_count') +[ "$SNAP_EC" -ge 1 ] && smoke_ok "snapshot has $SNAP_EC entries" || smoke_fail "snapshot empty" + +SNAP_LIST=$(curl -sf "$SMOKE_BASE/hiveshares/$HS_ID/snapshots" -H "$AUTH") +SL=$(echo "$SNAP_LIST" | jq 'length') +[ "$SL" -ge 1 ] && smoke_ok "listed $SL snapshots" || smoke_fail "no snapshots" + +SNAP_DETAIL=$(curl -sf "$SMOKE_BASE/hiveshares/$HS_ID/snapshots/$SNAP_ID" -H "$AUTH") +smoke_check "$(echo "$SNAP_DETAIL" | jq 'has("snapshot")')" "true" "detail has snapshot key" +smoke_check "$(echo "$SNAP_DETAIL" | jq 'has("entries")')" "true" "detail has entries key" +SE=$(echo "$SNAP_DETAIL" | jq '.entries | length') +[ "$SE" -ge 1 ] && smoke_ok "snapshot detail has $SE entries" || smoke_fail "detail empty" + +RESTORE_CODE=$(curl -s -o "$SMOKE_TMPDIR/hist_restore.json" -w "%{http_code}" -X POST \ + "$SMOKE_BASE/hiveshares/$HS_ID/snapshots/$SNAP_ID/restore" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d '{"name":"restored-hs"}') +RESTORED=$(cat "$SMOKE_TMPDIR/hist_restore.json") +smoke_check "$RESTORE_CODE" "201" "restore returns 201" +NEW_HS_ID=$(echo "$RESTORED" | jq -r '.hiveshare.id') +smoke_check "$(echo "$RESTORED" | jq -r '.hiveshare.name')" "restored-hs" "restored hiveshare has correct name" +[ "$NEW_HS_ID" != "$HS_ID" ] && smoke_ok "restored to new hiveshare" || smoke_fail "same ID" +RESTORED_EC=$(echo "$RESTORED" | jq '.entries_restored') +[ "$RESTORED_EC" -ge 1 ] && smoke_ok "$RESTORED_EC entries restored" || smoke_fail "no entries" + +DEL_SNAP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X DELETE \ + "$SMOKE_BASE/hiveshares/$HS_ID/snapshots/$SNAP_ID" -H "$AUTH") +smoke_check "$DEL_SNAP_CODE" "204" "snapshot deleted" + +# ── Copy ────────────────────────────────────────────────────────────────────── +smoke_section "Copy" + +COPY_HS=$(curl -sf -X POST "$SMOKE_BASE/hiveshares" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d '{"name":"copy-target"}') +COPY_HS_ID=$(echo "$COPY_HS" | jq -r '.id') + +COPY_CODE=$(curl -s -o "$SMOKE_TMPDIR/hist_copy.json" -w "%{http_code}" -X POST \ + "$SMOKE_BASE/hiveshares/$COPY_HS_ID/hives/copy" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d "{\"entry_ids\":[\"$ENTRY_ID\"]}") +COPIED=$(cat "$SMOKE_TMPDIR/hist_copy.json") +smoke_check "$COPY_CODE" "201" "copy returns 201" +COPY_LEN=$(echo "$COPIED" | jq 'length') +smoke_check "$COPY_LEN" "1" "copied 1 entry" +COPY_HS_CHECK=$(echo "$COPIED" | jq -r '.[0].hiveshare_id') +smoke_check "$COPY_HS_CHECK" "$COPY_HS_ID" "entry in target hiveshare" +COPY_CONTENT=$(echo "$COPIED" | jq -r '.[0].content') +smoke_check "$COPY_CONTENT" "Original content" "copied content matches source" + +# ── Cleanup ─────────────────────────────────────────────────────────────────── +smoke_section "Cleanup" +curl -sf -X DELETE "$SMOKE_BASE/hiveshares/$COPY_HS_ID" -H "$AUTH" > /dev/null +curl -sf -X DELETE "$SMOKE_BASE/hiveshares/$NEW_HS_ID" -H "$AUTH" > /dev/null +curl -sf -X DELETE "$SMOKE_BASE/hiveshares/$HS_ID" -H "$AUTH" > /dev/null +smoke_ok "cleaned up" + +smoke_summary "History" diff --git a/scripts/smoke-test-hives.sh b/scripts/smoke-test-hives.sh new file mode 100755 index 0000000..adae06e --- /dev/null +++ b/scripts/smoke-test-hives.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/smoke-helpers.sh" "$@" + +echo "=== Memory Smoke Test ===" + +REG_A=$(smoke_register "mem-a" "Writer") +KEY_A=$(echo "$REG_A" | jq -r '.api_key') +AUTH_A="Authorization: Bearer $KEY_A" + +REG_B=$(smoke_register "mem-b" "Viewer") +KEY_B=$(echo "$REG_B" | jq -r '.api_key') +AUTH_B="Authorization: Bearer $KEY_B" + +HS=$(curl -sf -X POST "$SMOKE_BASE/hiveshares" \ + -H "$AUTH_A" -H "Content-Type: application/json" \ + -d '{"name":"mem-test"}') +HS_ID=$(echo "$HS" | jq -r '.id') + +EMAIL_B=$(echo "$REG_B" | jq -r '.email') +TOKEN=$(curl -sf -X POST "$SMOKE_BASE/hiveshares/$HS_ID/invite" \ + -H "$AUTH_A" -H "Content-Type: application/json" \ + -d "{\"email\":\"$EMAIL_B\",\"role\":\"view\"}" | jq -r '.token') +curl -sf -X POST "$SMOKE_BASE/invitations/$TOKEN/accept" \ + -H "Content-Type: application/json" -d '{}' > /dev/null + +smoke_section "Create" +CREATE_CODE=$(curl -s -o $SMOKE_TMPDIR/mem_create.json -w "%{http_code}" -X POST "$SMOKE_BASE/hiveshares/$HS_ID/hives" \ + -H "$AUTH_A" -H "Content-Type: application/json" \ + -d '{"source_type":"jira","source_ref":"MEM-1","content":"Test content","tool":"claude","tags":["test"]}') +ENTRY=$(cat $SMOKE_TMPDIR/mem_create.json) +ENTRY_ID=$(echo "$ENTRY" | jq -r '.id') +smoke_check "$CREATE_CODE" "201" "create returns 201" +[ -n "$ENTRY_ID" ] && smoke_ok "created entry has id" || smoke_fail "create missing id" +smoke_check "$(echo "$ENTRY" | jq -r '.source_type')" "jira" "create returns source_type" +smoke_check "$(echo "$ENTRY" | jq -r '.source_ref')" "MEM-1" "create returns source_ref" +smoke_check "$(echo "$ENTRY" | jq -r '.tool')" "claude" "create returns tool" + +BAD_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$SMOKE_BASE/hiveshares/$HS_ID/hives" \ + -H "$AUTH_A" -H "Content-Type: application/json" -d '{"content":"no source"}') +smoke_check "$BAD_CODE" "400" "missing fields returns 400" + +VIEW_WRITE=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$SMOKE_BASE/hiveshares/$HS_ID/hives" \ + -H "$AUTH_B" -H "Content-Type: application/json" \ + -d '{"source_type":"manual","source_ref":"x","content":"x","tool":"manual"}') +smoke_check "$VIEW_WRITE" "403" "view-only cannot write" + +smoke_section "List" +LIST_CODE=$(curl -s -o $SMOKE_TMPDIR/mem_list.json -w "%{http_code}" "$SMOKE_BASE/hiveshares/$HS_ID/hives" -H "$AUTH_A") +MEM_LIST=$(cat $SMOKE_TMPDIR/mem_list.json) +smoke_check "$LIST_CODE" "200" "list returns 200" +MC=$(echo "$MEM_LIST" | jq 'length') +[ "$MC" -ge 1 ] && smoke_ok "listed $MC entries" || smoke_fail "list empty" + +smoke_section "List filter by source_type" +FILTERED=$(curl -sf "$SMOKE_BASE/hiveshares/$HS_ID/hives?source_type=jira" -H "$AUTH_A") +FILTERED_LEN=$(echo "$FILTERED" | jq 'length') +[ "$FILTERED_LEN" -ge 1 ] && smoke_ok "filtered list has $FILTERED_LEN entries" || smoke_fail "filter returned 0" +FILTERED_TYPES=$(echo "$FILTERED" | jq -r '.[].source_type' | sort -u) +smoke_check "$FILTERED_TYPES" "jira" "all filtered entries are jira" + +smoke_section "Get" +GET_CODE=$(curl -s -o $SMOKE_TMPDIR/mem_get.json -w "%{http_code}" "$SMOKE_BASE/hiveshares/$HS_ID/hives/$ENTRY_ID" -H "$AUTH_A") +GET_ENTRY=$(cat $SMOKE_TMPDIR/mem_get.json) +smoke_check "$GET_CODE" "200" "get returns 200" +smoke_check "$(echo "$GET_ENTRY" | jq -r '.id')" "$ENTRY_ID" "get returns correct id" +smoke_check "$(echo "$GET_ENTRY" | jq -r '.content')" "Test content" "get returns content" +smoke_check "$(echo "$GET_ENTRY" | jq 'has("content")')" "true" "get has content key" + +smoke_section "Search" +SEARCH_CODE=$(curl -s -o $SMOKE_TMPDIR/mem_search.json -w "%{http_code}" -X POST "$SMOKE_BASE/hiveshares/$HS_ID/hives/search" \ + -H "$AUTH_A" -H "Content-Type: application/json" \ + -d '{"query":"test content","limit":5}') +SEARCH=$(cat $SMOKE_TMPDIR/mem_search.json) +smoke_check "$SEARCH_CODE" "200" "search returns 200" +SC=$(echo "$SEARCH" | jq '.count') +[ "$SC" -ge 1 ] && smoke_ok "found $SC results" || smoke_fail "search returned 0" +smoke_check "$(echo "$SEARCH" | jq 'has("results")')" "true" "search has results key" +smoke_check "$(echo "$SEARCH" | jq 'has("count")')" "true" "search has count key" +smoke_check "$(echo "$SEARCH" | jq 'has("query")')" "true" "search has query key" + +SEARCH_400=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$SMOKE_BASE/hiveshares/$HS_ID/hives/search" \ + -H "$AUTH_A" -H "Content-Type: application/json" -d '{"limit":5}') +smoke_check "$SEARCH_400" "400" "search without query returns 400" + +smoke_section "Update" +UPD_CODE=$(curl -s -o $SMOKE_TMPDIR/mem_upd.json -w "%{http_code}" -X PUT "$SMOKE_BASE/hiveshares/$HS_ID/hives/$ENTRY_ID" \ + -H "$AUTH_A" -H "Content-Type: application/json" \ + -d '{"content":"Updated","summary":"upd","tags":["updated"]}') +smoke_check "$UPD_CODE" "200" "update returns 200" +smoke_check "$(cat $SMOKE_TMPDIR/mem_upd.json | jq -r '.content')" "Updated" "update changes content" + +smoke_section "Delete" +DEL_ENTRY=$(curl -sf -X POST "$SMOKE_BASE/hiveshares/$HS_ID/hives" \ + -H "$AUTH_A" -H "Content-Type: application/json" \ + -d '{"source_type":"manual","source_ref":"del","content":"delete me","tool":"manual","tags":[]}') +DEL_ID=$(echo "$DEL_ENTRY" | jq -r '.id') +DEL_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X DELETE \ + "$SMOKE_BASE/hiveshares/$HS_ID/hives/$DEL_ID" -H "$AUTH_A") +smoke_check "$DEL_CODE" "204" "delete returns 204" + +smoke_section "Cleanup" +curl -sf -X DELETE "$SMOKE_BASE/hiveshares/$HS_ID" -H "$AUTH_A" > /dev/null +smoke_ok "cleaned up" + +smoke_summary "Memory" diff --git a/scripts/smoke-test-hiveshare.sh b/scripts/smoke-test-hiveshare.sh new file mode 100755 index 0000000..af2704d --- /dev/null +++ b/scripts/smoke-test-hiveshare.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/smoke-helpers.sh" "$@" + +echo "=== Hiveshare Smoke Test ===" + +REG_A=$(smoke_register "hs-a" "User A") +KEY_A=$(echo "$REG_A" | jq -r '.api_key') +AUTH_A="Authorization: Bearer $KEY_A" + +REG_B=$(smoke_register "hs-b" "User B") +KEY_B=$(echo "$REG_B" | jq -r '.api_key') +AUTH_B="Authorization: Bearer $KEY_B" + +smoke_section "Create" +HS_RESP=$(curl -s -o $SMOKE_TMPDIR/hs_create.json -w "%{http_code}" -X POST "$SMOKE_BASE/hiveshares" \ + -H "$AUTH_A" -H "Content-Type: application/json" \ + -d '{"name":"hs-test","description":"smoke"}') +HS=$(cat $SMOKE_TMPDIR/hs_create.json) +HS_ID=$(echo "$HS" | jq -r '.id') +smoke_check "$HS_RESP" "201" "create returns 201" +smoke_check "$(echo "$HS" | jq -r '.name')" "hs-test" "create returns correct name" +smoke_check "$(echo "$HS" | jq -r '.role')" "all" "creator gets all role" +smoke_check "$(echo "$HS" | jq '.member_count')" "1" "starts with 1 member" + +smoke_section "List" +LIST_CODE=$(curl -s -o $SMOKE_TMPDIR/hs_list.json -w "%{http_code}" "$SMOKE_BASE/hiveshares" -H "$AUTH_A") +LIST=$(cat $SMOKE_TMPDIR/hs_list.json) +smoke_check "$LIST_CODE" "200" "list returns 200" +echo "$LIST" | jq -r '.[].id' | grep -q "$HS_ID" && smoke_ok "in list" || smoke_fail "not in list" + +smoke_section "Get" +GET_CODE=$(curl -s -o $SMOKE_TMPDIR/hs_get.json -w "%{http_code}" "$SMOKE_BASE/hiveshares/$HS_ID" -H "$AUTH_A") +GET=$(cat $SMOKE_TMPDIR/hs_get.json) +smoke_check "$GET_CODE" "200" "get returns 200" +smoke_check "$(echo "$GET" | jq -r '.name')" "hs-test" "get returns correct name" +smoke_check "$(echo "$GET" | jq -r '.id')" "$HS_ID" "get returns correct id" + +FORBID=$(curl -s -o /dev/null -w "%{http_code}" "$SMOKE_BASE/hiveshares/$HS_ID" -H "$AUTH_B") +smoke_check "$FORBID" "404" "non-member gets 404" + +smoke_section "Update" +UPD_CODE=$(curl -s -o $SMOKE_TMPDIR/hs_upd.json -w "%{http_code}" -X PUT "$SMOKE_BASE/hiveshares/$HS_ID" \ + -H "$AUTH_A" -H "Content-Type: application/json" \ + -d '{"name":"updated","description":"updated"}') +smoke_check "$UPD_CODE" "200" "update returns 200" +smoke_check "$(cat $SMOKE_TMPDIR/hs_upd.json | jq -r '.name')" "updated" "update changes name" + +smoke_section "Invite & Members" +EMAIL_B=$(echo "$REG_B" | jq -r '.email') +INV_CODE=$(curl -s -o $SMOKE_TMPDIR/hs_inv.json -w "%{http_code}" -X POST "$SMOKE_BASE/hiveshares/$HS_ID/invite" \ + -H "$AUTH_A" -H "Content-Type: application/json" \ + -d "{\"email\":\"$EMAIL_B\",\"role\":\"view\"}") +INV=$(cat $SMOKE_TMPDIR/hs_inv.json) +smoke_check "$INV_CODE" "201" "invite returns 201" +TOKEN=$(echo "$INV" | jq -r '.token') +[ -n "$TOKEN" ] && smoke_ok "invitation has token" || smoke_fail "invite missing token" + +ACCEPT_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$SMOKE_BASE/invitations/$TOKEN/accept" \ + -H "Content-Type: application/json" -d '{}') +smoke_check "$ACCEPT_CODE" "200" "accept returns 200" + +MEM_CODE=$(curl -s -o $SMOKE_TMPDIR/hs_mem.json -w "%{http_code}" "$SMOKE_BASE/hiveshares/$HS_ID/members" -H "$AUTH_A") +MEMBERS=$(cat $SMOKE_TMPDIR/hs_mem.json) +smoke_check "$MEM_CODE" "200" "members returns 200" +MC=$(echo "$MEMBERS" | jq 'length') +[ "$MC" -ge 2 ] && smoke_ok "$MC members after invite" || smoke_fail "expected >= 2 members" + +B_READ=$(curl -s -o /dev/null -w "%{http_code}" "$SMOKE_BASE/hiveshares/$HS_ID" -H "$AUTH_B") +smoke_check "$B_READ" "200" "invited user can read" + +smoke_section "Delete" +DEL_HS=$(curl -sf -X POST "$SMOKE_BASE/hiveshares" \ + -H "$AUTH_A" -H "Content-Type: application/json" -d '{"name":"del-me"}') +DEL_ID=$(echo "$DEL_HS" | jq -r '.id') +DEL_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X DELETE "$SMOKE_BASE/hiveshares/$DEL_ID" -H "$AUTH_A") +smoke_check "$DEL_CODE" "204" "delete returns 204" + +smoke_section "Cleanup" +curl -sf -X DELETE "$SMOKE_BASE/hiveshares/$HS_ID" -H "$AUTH_A" > /dev/null +smoke_ok "cleaned up" + +smoke_summary "Hiveshare" diff --git a/scripts/smoke-test-metrics.sh b/scripts/smoke-test-metrics.sh new file mode 100755 index 0000000..f40b606 --- /dev/null +++ b/scripts/smoke-test-metrics.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/smoke-helpers.sh" "$@" + +echo "=== Metrics Smoke Test ===" + +REG=$(smoke_register "met" "Metrics User") +KEY=$(echo "$REG" | jq -r '.api_key') +AUTH="Authorization: Bearer $KEY" + +HS=$(curl -sf -X POST "$SMOKE_BASE/hiveshares" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d '{"name":"metrics-test"}') +HS_ID=$(echo "$HS" | jq -r '.id') + +curl -sf -X POST "$SMOKE_BASE/hiveshares/$HS_ID/hives" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d '{"source_type":"jira","source_ref":"MET-1","content":"For metrics","tool":"claude","tags":[]}' > /dev/null + +smoke_section "Hiveshare metrics" +HS_MET_CODE=$(curl -s -o $SMOKE_TMPDIR/met_hs.json -w "%{http_code}" "$SMOKE_BASE/hiveshares/$HS_ID/metrics" -H "$AUTH") +HS_MET=$(cat $SMOKE_TMPDIR/met_hs.json) +smoke_check "$HS_MET_CODE" "200" "hiveshare metrics returns 200" +smoke_check "$(echo "$HS_MET" | jq 'has("hiveshare")')" "true" "has hiveshare summary" +smoke_check "$(echo "$HS_MET" | jq 'has("memory")')" "true" "has memory stats" +smoke_check "$(echo "$HS_MET" | jq 'has("collaboration")')" "true" "has collaboration stats" +smoke_check "$(echo "$HS_MET" | jq 'has("coverage")')" "true" "has coverage stats" +smoke_check "$(echo "$HS_MET" | jq 'has("activity")')" "true" "has activity stats" +TOTAL=$(echo "$HS_MET" | jq '.memory.total_entries') +[ "$TOTAL" -ge 1 ] && smoke_ok "total_entries >= 1" || smoke_fail "total_entries is $TOTAL" + +smoke_section "User metrics" +USER_MET_CODE=$(curl -s -o $SMOKE_TMPDIR/met_user.json -w "%{http_code}" "$SMOKE_BASE/metrics/me" -H "$AUTH") +USER_MET=$(cat $SMOKE_TMPDIR/met_user.json) +smoke_check "$USER_MET_CODE" "200" "user metrics returns 200" +smoke_check "$(echo "$USER_MET" | jq 'has("total_entries")')" "true" "has total_entries" +smoke_check "$(echo "$USER_MET" | jq 'has("total_searches")')" "true" "has total_searches" +smoke_check "$(echo "$USER_MET" | jq 'has("hiveshares_owned")')" "true" "has hiveshares_owned" + +smoke_section "Cleanup" +curl -sf -X DELETE "$SMOKE_BASE/hiveshares/$HS_ID" -H "$AUTH" > /dev/null +smoke_ok "cleaned up" + +smoke_summary "Metrics" diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh new file mode 100755 index 0000000..a50b0a4 --- /dev/null +++ b/scripts/smoke-test.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Basic connectivity smoke test — no user required. +# Tests: health endpoint, response format, DB/Redis status. +# Usage: ./scripts/smoke-test.sh [base_url] + +BASE="${1:-${HIVESHARE_TEST_URL:-http://localhost:8080}}" +PASS=0 +FAIL=0 + +ok() { PASS=$((PASS+1)); echo " PASS: $1"; } +fail() { FAIL=$((FAIL+1)); echo " FAIL: $1"; } + +echo "=== HiveShare Basic Smoke Test ===" +echo "Target: $BASE" +echo "" + +# ── Health endpoint ─────────────────────────────────────────────────────────── +echo "1. Health endpoint reachable" +HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "$BASE/health" 2>/dev/null || echo "000") +if [ "$HTTP_CODE" = "200" ]; then + ok "GET /health returned 200" +else + fail "GET /health returned $HTTP_CODE (is the server running?)" + echo "" + echo "=== Results: $PASS passed, $FAIL failed ===" + exit 1 +fi + +echo "2. Health response format" +HEALTH=$(curl -sf "$BASE/health") +STATUS=$(echo "$HEALTH" | jq -r '.status' 2>/dev/null || echo "parse_error") +DB=$(echo "$HEALTH" | jq -r '.db' 2>/dev/null || echo "parse_error") +REDIS=$(echo "$HEALTH" | jq -r '.redis' 2>/dev/null || echo "parse_error") +COMMIT=$(echo "$HEALTH" | jq -r '.commit' 2>/dev/null || echo "parse_error") + +[ "$STATUS" = "ok" ] && ok "status: ok" || fail "status: $STATUS" +[ "$DB" = "ok" ] && ok "db: ok" || fail "db: $DB" +[ "$REDIS" = "ok" ] && ok "redis: ok" || fail "redis: $REDIS" +[ "$COMMIT" != "parse_error" ] && ok "commit: $COMMIT" || fail "commit field missing" +BUILD_TIME=$(echo "$HEALTH" | jq -r '.build_time' 2>/dev/null || echo "parse_error") +[ "$BUILD_TIME" != "parse_error" ] && ok "build_time: $BUILD_TIME" || fail "build_time field missing" + +# ── Auth required ───────────────────────────────────────────────────────────── +echo "3. Auth enforcement" +AUTH_CODE=$(curl -s -o /dev/null -w "%{http_code}" "$BASE/api/v1/hiveshares" 2>/dev/null) +[ "$AUTH_CODE" = "401" ] && ok "GET /hiveshares without auth returns 401" || fail "expected 401, got $AUTH_CODE" + +AUTH_CODE2=$(curl -s -o /dev/null -w "%{http_code}" "$BASE/api/v1/auth/whoami" \ + -H "Authorization: Bearer hvs_invalid" 2>/dev/null) +[ "$AUTH_CODE2" = "401" ] && ok "invalid API key returns 401" || fail "expected 401, got $AUTH_CODE2" + +# ── 404 for unknown routes ─────────────────────────────────────────────────── +echo "4. Unknown routes" +NOT_FOUND=$(curl -s -o /dev/null -w "%{http_code}" "$BASE/api/v1/nonexistent" 2>/dev/null) +[ "$NOT_FOUND" = "404" ] || [ "$NOT_FOUND" = "405" ] && ok "unknown route returns $NOT_FOUND" || fail "expected 404/405, got $NOT_FOUND" + +# ── Summary ─────────────────────────────────────────────────────────────────── +echo "" +echo "=== Results: $PASS passed, $FAIL failed ===" +[ "$FAIL" -eq 0 ] && exit 0 || exit 1 diff --git a/scripts/test-api-examples.sh b/scripts/test-api-examples.sh new file mode 100755 index 0000000..cff4cd6 --- /dev/null +++ b/scripts/test-api-examples.sh @@ -0,0 +1,381 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Tests every curl example from API.md against the running server. +# Usage: ./scripts/test-api-examples.sh [base_url] + +BASE="${1:-${HIVESHARE_TEST_URL:-http://localhost:8080}}" +API="$BASE/api/v1" +PASS=0 +FAIL=0 +TMPDIR=$(mktemp -d "${TMPDIR:-/tmp}/hiveshare-api-examples.XXXXXX") +trap 'rm -rf "$TMPDIR"' EXIT + +ok() { PASS=$((PASS+1)); echo " PASS: $1"; } +fail() { FAIL=$((FAIL+1)); echo " FAIL: $1"; } +check_code() { + if [ "$1" = "$2" ]; then ok "$3"; else fail "$3 (expected $2, got $1)"; fi +} +section() { echo ""; echo "── $1 ──"; } + +TS=$(date +%s%N) + +echo "=== API.md Curl Example Verification ===" +echo "Target: $BASE" + +# ── Health ──────────────────────────────────────────────────────────────────── +section "Health" + +CODE=$(curl -s -o "$TMPDIR/health.json" -w "%{http_code}" "$BASE/health") +check_code "$CODE" "200" "GET /health" +jq -e '.status' "$TMPDIR/health.json" > /dev/null && ok "has status field" || fail "missing status" +jq -e '.db' "$TMPDIR/health.json" > /dev/null && ok "has db field" || fail "missing db" +jq -e '.redis' "$TMPDIR/health.json" > /dev/null && ok "has redis field" || fail "missing redis" +jq -e '.commit' "$TMPDIR/health.json" > /dev/null && ok "has commit field" || fail "missing commit" +jq -e '.build_time' "$TMPDIR/health.json" > /dev/null && ok "has build_time field" || fail "missing build_time" + +# ── Auth: Register ──────────────────────────────────────────────────────────── +section "Auth: Register" + +CODE=$(curl -s -o "$TMPDIR/reg.json" -w "%{http_code}" -X POST "$API/auth/register" \ + -H "Content-Type: application/json" \ + -d "{\"email\":\"apidoc-a-${TS}@test.local\",\"name\":\"Alice\"}") +check_code "$CODE" "201" "POST /auth/register" +KEY_A=$(jq -r '.api_key' "$TMPDIR/reg.json") +[ -n "$KEY_A" ] && [ "$KEY_A" != "null" ] && ok "api_key present" || fail "api_key missing" +echo "$KEY_A" | grep -q "^hvs_" && ok "api_key has hvs_ prefix" || fail "bad prefix" +jq -e '.id' "$TMPDIR/reg.json" > /dev/null && ok "has id" || fail "missing id" +AUTH_A="Authorization: Bearer $KEY_A" + +CODE=$(curl -s -o "$TMPDIR/reg_b.json" -w "%{http_code}" -X POST "$API/auth/register" \ + -H "Content-Type: application/json" \ + -d "{\"email\":\"apidoc-b-${TS}@test.local\",\"name\":\"Bob\"}") +check_code "$CODE" "201" "register user B" +KEY_B=$(jq -r '.api_key' "$TMPDIR/reg_b.json") +AUTH_B="Authorization: Bearer $KEY_B" +EMAIL_B=$(jq -r '.email' "$TMPDIR/reg_b.json") + +# ── Auth: Duplicate ─────────────────────────────────────────────────────────── +section "Auth: Duplicate" + +CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$API/auth/register" \ + -H "Content-Type: application/json" \ + -d "{\"email\":\"apidoc-a-${TS}@test.local\",\"name\":\"Dup\"}") +check_code "$CODE" "409" "duplicate email rejected" + +# ── Auth: Missing fields ───────────────────────────────────────────────────── +CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$API/auth/register" \ + -H "Content-Type: application/json" \ + -d '{"email":"only-email@test.local"}') +check_code "$CODE" "400" "missing name returns 400" + +# ── Auth: Whoami ────────────────────────────────────────────────────────────── +section "Auth: Whoami" + +CODE=$(curl -s -o "$TMPDIR/whoami.json" -w "%{http_code}" "$API/auth/whoami" -H "$AUTH_A") +check_code "$CODE" "200" "GET /auth/whoami" +NAME=$(jq -r '.name' "$TMPDIR/whoami.json") +[ "$NAME" = "Alice" ] && ok "name matches" || fail "name: $NAME" + +CODE=$(curl -s -o /dev/null -w "%{http_code}" "$API/auth/whoami") +check_code "$CODE" "401" "no auth returns 401" + +CODE=$(curl -s -o /dev/null -w "%{http_code}" "$API/auth/whoami" \ + -H "Authorization: Bearer hvs_bogus") +check_code "$CODE" "401" "bad key returns 401" + +# ── Hiveshares: Create ──────────────────────────────────────────────────────── +section "Hiveshares: Create" + +CODE=$(curl -s -o "$TMPDIR/hs_create.json" -w "%{http_code}" -X POST "$API/hiveshares" \ + -H "$AUTH_A" -H "Content-Type: application/json" \ + -d '{"name":"Sprint 42","description":"Shared context"}') +check_code "$CODE" "201" "POST /hiveshares" +HS_ID=$(jq -r '.id' "$TMPDIR/hs_create.json") +[ "$(jq -r '.name' "$TMPDIR/hs_create.json")" = "Sprint 42" ] && ok "name matches" || fail "name mismatch" +[ "$(jq -r '.role' "$TMPDIR/hs_create.json")" = "all" ] && ok "role is all" || fail "wrong role" +[ "$(jq '.member_count' "$TMPDIR/hs_create.json")" = "1" ] && ok "member_count is 1" || fail "wrong member_count" + +# ── Hiveshares: List ────────────────────────────────────────────────────────── +section "Hiveshares: List" + +CODE=$(curl -s -o "$TMPDIR/hs_list.json" -w "%{http_code}" "$API/hiveshares" -H "$AUTH_A") +check_code "$CODE" "200" "GET /hiveshares" +jq -r '.[].id' "$TMPDIR/hs_list.json" | grep -q "$HS_ID" && ok "hiveshare in list" || fail "not in list" + +# ── Hiveshares: Get ─────────────────────────────────────────────────────────── +section "Hiveshares: Get" + +CODE=$(curl -s -o "$TMPDIR/hs_get.json" -w "%{http_code}" "$API/hiveshares/$HS_ID" -H "$AUTH_A") +check_code "$CODE" "200" "GET /hiveshares/{id}" +[ "$(jq -r '.id' "$TMPDIR/hs_get.json")" = "$HS_ID" ] && ok "id matches" || fail "id mismatch" + +CODE=$(curl -s -o /dev/null -w "%{http_code}" "$API/hiveshares/$HS_ID" -H "$AUTH_B") +check_code "$CODE" "404" "non-member gets 404" + +# ── Hiveshares: Update ──────────────────────────────────────────────────────── +section "Hiveshares: Update" + +CODE=$(curl -s -o "$TMPDIR/hs_upd.json" -w "%{http_code}" -X PUT "$API/hiveshares/$HS_ID" \ + -H "$AUTH_A" -H "Content-Type: application/json" \ + -d '{"name":"Renamed","description":"Updated"}') +check_code "$CODE" "200" "PUT /hiveshares/{id}" +[ "$(jq -r '.name' "$TMPDIR/hs_upd.json")" = "Renamed" ] && ok "name updated" || fail "name not updated" + +# ── Hiveshares: Invite ──────────────────────────────────────────────────────── +section "Hiveshares: Invite" + +CODE=$(curl -s -o "$TMPDIR/invite.json" -w "%{http_code}" -X POST "$API/hiveshares/$HS_ID/invite" \ + -H "$AUTH_A" -H "Content-Type: application/json" \ + -d "{\"email\":\"$EMAIL_B\",\"role\":\"view\"}") +check_code "$CODE" "201" "POST /hiveshares/{id}/invite" +TOKEN=$(jq -r '.token' "$TMPDIR/invite.json") +[ -n "$TOKEN" ] && ok "has token" || fail "missing token" +jq -e '.invite_url' "$TMPDIR/invite.json" > /dev/null && ok "has invite_url" || fail "missing invite_url" + +# ── Hiveshares: Accept Invite ───────────────────────────────────────────────── +section "Hiveshares: Accept Invite" + +CODE=$(curl -s -o "$TMPDIR/accept.json" -w "%{http_code}" -X POST "$API/invitations/$TOKEN/accept" \ + -H "Content-Type: application/json" -d '{"name":"Bob"}') +check_code "$CODE" "200" "POST /invitations/{token}/accept" +jq -e '.hiveshare_id' "$TMPDIR/accept.json" > /dev/null && ok "has hiveshare_id" || fail "missing hiveshare_id" + +# ── Hiveshares: Members ─────────────────────────────────────────────────────── +section "Hiveshares: Members" + +CODE=$(curl -s -o "$TMPDIR/members.json" -w "%{http_code}" "$API/hiveshares/$HS_ID/members" -H "$AUTH_A") +check_code "$CODE" "200" "GET /hiveshares/{id}/members" +MC=$(jq 'length' "$TMPDIR/members.json") +[ "$MC" -ge 2 ] && ok "$MC members" || fail "expected >= 2" + +# ── Memory: Create ──────────────────────────────────────────────────────────── +section "Memory: Create" + +CODE=$(curl -s -o "$TMPDIR/mem_create.json" -w "%{http_code}" -X POST "$API/hiveshares/$HS_ID/hives" \ + -H "$AUTH_A" -H "Content-Type: application/json" \ + -d '{"source_type":"jira","source_ref":"PROJ-123","content":"Analysis of auth refactor","tool":"claude","tags":["auth"]}') +check_code "$CODE" "201" "POST /hiveshares/{id}/hives" +ENTRY_ID=$(jq -r '.id' "$TMPDIR/mem_create.json") +[ "$(jq -r '.source_type' "$TMPDIR/mem_create.json")" = "jira" ] && ok "source_type" || fail "wrong source_type" +[ "$(jq -r '.source_ref' "$TMPDIR/mem_create.json")" = "PROJ-123" ] && ok "source_ref" || fail "wrong source_ref" +[ "$(jq -r '.tool' "$TMPDIR/mem_create.json")" = "claude" ] && ok "tool" || fail "wrong tool" + +CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$API/hiveshares/$HS_ID/hives" \ + -H "$AUTH_A" -H "Content-Type: application/json" \ + -d '{"content":"no source"}') +check_code "$CODE" "400" "missing fields returns 400" + +CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$API/hiveshares/$HS_ID/hives" \ + -H "$AUTH_B" -H "Content-Type: application/json" \ + -d '{"source_type":"manual","source_ref":"x","content":"x","tool":"manual"}') +check_code "$CODE" "403" "view-only cannot write" + +# ── Memory: List ────────────────────────────────────────────────────────────── +section "Memory: List" + +CODE=$(curl -s -o "$TMPDIR/mem_list.json" -w "%{http_code}" \ + "$API/hiveshares/$HS_ID/hives?source_type=jira&limit=10" -H "$AUTH_A") +check_code "$CODE" "200" "GET /hiveshares/{id}/hives" +[ "$(jq 'length' "$TMPDIR/mem_list.json")" -ge 1 ] && ok "has entries" || fail "empty list" + +# ── Memory: Get ─────────────────────────────────────────────────────────────── +section "Memory: Get" + +CODE=$(curl -s -o "$TMPDIR/mem_get.json" -w "%{http_code}" \ + "$API/hiveshares/$HS_ID/hives/$ENTRY_ID" -H "$AUTH_A") +check_code "$CODE" "200" "GET /hiveshares/{id}/hives/{entryId}" +[ "$(jq -r '.id' "$TMPDIR/mem_get.json")" = "$ENTRY_ID" ] && ok "id matches" || fail "id mismatch" +jq -e '.content' "$TMPDIR/mem_get.json" > /dev/null && ok "has content" || fail "missing content" + +# ── Memory: Update ──────────────────────────────────────────────────────────── +section "Memory: Update" + +CODE=$(curl -s -o "$TMPDIR/mem_upd.json" -w "%{http_code}" -X PUT \ + "$API/hiveshares/$HS_ID/hives/$ENTRY_ID" \ + -H "$AUTH_A" -H "Content-Type: application/json" \ + -d '{"content":"Updated analysis...","tags":["auth","updated"]}') +check_code "$CODE" "200" "PUT /hiveshares/{id}/hives/{entryId}" +[ "$(jq -r '.content' "$TMPDIR/mem_upd.json")" = "Updated analysis..." ] && ok "content updated" || fail "content not updated" + +# ── Memory: Search ──────────────────────────────────────────────────────────── +section "Memory: Search" + +CODE=$(curl -s -o "$TMPDIR/search.json" -w "%{http_code}" -X POST \ + "$API/hiveshares/$HS_ID/hives/search" \ + -H "$AUTH_A" -H "Content-Type: application/json" \ + -d '{"query":"auth refactor","limit":5}') +check_code "$CODE" "200" "POST /hiveshares/{id}/hives/search" +jq -e '.results' "$TMPDIR/search.json" > /dev/null && ok "has results" || fail "missing results" +jq -e '.count' "$TMPDIR/search.json" > /dev/null && ok "has count" || fail "missing count" +jq -e '.query' "$TMPDIR/search.json" > /dev/null && ok "has query" || fail "missing query" + +CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST \ + "$API/hiveshares/$HS_ID/hives/search" \ + -H "$AUTH_A" -H "Content-Type: application/json" -d '{"limit":5}') +check_code "$CODE" "400" "search missing query returns 400" + +# ── History: List ───────────────────────────────────────────────────────────── +section "History: List" + +CODE=$(curl -s -o "$TMPDIR/hist.json" -w "%{http_code}" \ + "$API/hiveshares/$HS_ID/hives/$ENTRY_ID/history?limit=10" -H "$AUTH_A") +check_code "$CODE" "200" "GET /hives/{entryId}/history" +HIST_LEN=$(jq 'length' "$TMPDIR/hist.json") +[ "$HIST_LEN" -ge 1 ] && ok "$HIST_LEN versions" || fail "no history" +HIST_ID=$(jq '.[- 1].history_id' "$TMPDIR/hist.json") +jq -e '.[0].action' "$TMPDIR/hist.json" > /dev/null && ok "has action field" || fail "missing action" +jq -e '.[0] | has("has_embedding")' "$TMPDIR/hist.json" > /dev/null && ok "has has_embedding field" || fail "missing has_embedding" + +# ── History: Rollback ───────────────────────────────────────────────────────── +section "History: Rollback" + +CODE=$(curl -s -o "$TMPDIR/rollback.json" -w "%{http_code}" -X POST \ + "$API/hiveshares/$HS_ID/hives/$ENTRY_ID/rollback" \ + -H "$AUTH_A" -H "Content-Type: application/json" \ + -d "{\"history_id\":$HIST_ID}") +check_code "$CODE" "200" "POST /hives/{entryId}/rollback" +[ "$(jq -r '.content' "$TMPDIR/rollback.json")" = "Analysis of auth refactor" ] && ok "content restored" || fail "content not restored" + +# ── History: Delete + Undelete ──────────────────────────────────────────────── +section "History: Undelete" + +ENTRY2=$(curl -s -X POST "$API/hiveshares/$HS_ID/hives" \ + -H "$AUTH_A" -H "Content-Type: application/json" \ + -d '{"source_type":"manual","source_ref":"del-test","content":"Delete me","tool":"manual","tags":[]}') +ENTRY2_ID=$(echo "$ENTRY2" | jq -r '.id') + +curl -s -X DELETE "$API/hiveshares/$HS_ID/hives/$ENTRY2_ID" -H "$AUTH_A" > /dev/null + +CODE=$(curl -s -o /dev/null -w "%{http_code}" "$API/hiveshares/$HS_ID/hives/$ENTRY2_ID" -H "$AUTH_A") +check_code "$CODE" "404" "deleted entry returns 404" + +DEL_HIST=$(curl -s "$API/hiveshares/$HS_ID/hives/$ENTRY2_ID/history" -H "$AUTH_A") +DEL_HIST_ID=$(echo "$DEL_HIST" | jq '[.[] | select(.action=="delete")][0].history_id') + +CODE=$(curl -s -o "$TMPDIR/undel.json" -w "%{http_code}" -X POST \ + "$API/hiveshares/$HS_ID/hives/undelete" \ + -H "$AUTH_A" -H "Content-Type: application/json" \ + -d "{\"history_id\":$DEL_HIST_ID}") +check_code "$CODE" "201" "POST /hives/undelete" +[ "$(jq -r '.id' "$TMPDIR/undel.json")" = "$ENTRY2_ID" ] && ok "same id" || fail "id mismatch" +[ "$(jq -r '.content' "$TMPDIR/undel.json")" = "Delete me" ] && ok "content restored" || fail "content mismatch" + +# ── Snapshots: Create ───────────────────────────────────────────────────────── +section "Snapshots: Create" + +CODE=$(curl -s -o "$TMPDIR/snap.json" -w "%{http_code}" -X POST \ + "$API/hiveshares/$HS_ID/snapshots" \ + -H "$AUTH_A" -H "Content-Type: application/json" \ + -d '{"name":"before-cleanup","description":"Snapshot before removing stale entries"}') +check_code "$CODE" "201" "POST /hiveshares/{id}/snapshots" +SNAP_ID=$(jq '.snapshot_id' "$TMPDIR/snap.json") +[ "$(jq -r '.name' "$TMPDIR/snap.json")" = "before-cleanup" ] && ok "name matches" || fail "name mismatch" +[ "$(jq '.entry_count' "$TMPDIR/snap.json")" -ge 1 ] && ok "entry_count >= 1" || fail "no entries" + +# ── Snapshots: List ─────────────────────────────────────────────────────────── +section "Snapshots: List" + +CODE=$(curl -s -o "$TMPDIR/snap_list.json" -w "%{http_code}" \ + "$API/hiveshares/$HS_ID/snapshots" -H "$AUTH_A") +check_code "$CODE" "200" "GET /hiveshares/{id}/snapshots" +[ "$(jq 'length' "$TMPDIR/snap_list.json")" -ge 1 ] && ok "has snapshots" || fail "empty list" + +# ── Snapshots: Get ──────────────────────────────────────────────────────────── +section "Snapshots: Get" + +CODE=$(curl -s -o "$TMPDIR/snap_get.json" -w "%{http_code}" \ + "$API/hiveshares/$HS_ID/snapshots/$SNAP_ID" -H "$AUTH_A") +check_code "$CODE" "200" "GET /hiveshares/{id}/snapshots/{snapshotId}" +jq -e '.snapshot' "$TMPDIR/snap_get.json" > /dev/null && ok "has snapshot key" || fail "missing snapshot" +jq -e '.entries' "$TMPDIR/snap_get.json" > /dev/null && ok "has entries key" || fail "missing entries" +[ "$(jq '.entries | length' "$TMPDIR/snap_get.json")" -ge 1 ] && ok "has entries" || fail "no entries" + +# ── Snapshots: Restore ──────────────────────────────────────────────────────── +section "Snapshots: Restore" + +CODE=$(curl -s -o "$TMPDIR/restore.json" -w "%{http_code}" -X POST \ + "$API/hiveshares/$HS_ID/snapshots/$SNAP_ID/restore" \ + -H "$AUTH_A" -H "Content-Type: application/json" \ + -d '{"name":"Sprint 42 (restored)"}') +check_code "$CODE" "201" "POST /snapshots/{snapshotId}/restore" +NEW_HS_ID=$(jq -r '.hiveshare.id' "$TMPDIR/restore.json") +[ "$NEW_HS_ID" != "$HS_ID" ] && ok "new hiveshare id" || fail "same id" +[ "$(jq -r '.hiveshare.name' "$TMPDIR/restore.json")" = "Sprint 42 (restored)" ] && ok "name matches" || fail "name mismatch" +[ "$(jq '.entries_restored' "$TMPDIR/restore.json")" -ge 1 ] && ok "entries restored" || fail "no entries" + +# ── Snapshots: Delete ───────────────────────────────────────────────────────── +section "Snapshots: Delete" + +CODE=$(curl -s -o /dev/null -w "%{http_code}" -X DELETE \ + "$API/hiveshares/$HS_ID/snapshots/$SNAP_ID" -H "$AUTH_A") +check_code "$CODE" "204" "DELETE /snapshots/{snapshotId}" + +# ── Memory: Copy ────────────────────────────────────────────────────────────── +section "Memory: Copy" + +CODE=$(curl -s -o "$TMPDIR/copy.json" -w "%{http_code}" -X POST \ + "$API/hiveshares/$NEW_HS_ID/hives/copy" \ + -H "$AUTH_A" -H "Content-Type: application/json" \ + -d "{\"entry_ids\":[\"$ENTRY_ID\"]}") +check_code "$CODE" "201" "POST /hiveshares/{id}/hives/copy" +[ "$(jq 'length' "$TMPDIR/copy.json")" = "1" ] && ok "copied 1 entry" || fail "wrong count" +[ "$(jq -r '.[0].hiveshare_id' "$TMPDIR/copy.json")" = "$NEW_HS_ID" ] && ok "in target hiveshare" || fail "wrong hiveshare" + +# ── Memory: Delete ──────────────────────────────────────────────────────────── +section "Memory: Delete" + +CODE=$(curl -s -o /dev/null -w "%{http_code}" -X DELETE \ + "$API/hiveshares/$HS_ID/hives/$ENTRY2_ID" -H "$AUTH_A") +check_code "$CODE" "204" "DELETE /hiveshares/{id}/hives/{entryId}" + +# ── Metrics ─────────────────────────────────────────────────────────────────── +section "Metrics" + +CODE=$(curl -s -o "$TMPDIR/hs_met.json" -w "%{http_code}" \ + "$API/hiveshares/$HS_ID/metrics" -H "$AUTH_A") +check_code "$CODE" "200" "GET /hiveshares/{id}/metrics" +jq -e '.hiveshare' "$TMPDIR/hs_met.json" > /dev/null && ok "has hiveshare" || fail "missing" +jq -e '.memory' "$TMPDIR/hs_met.json" > /dev/null && ok "has memory" || fail "missing" +jq -e '.collaboration' "$TMPDIR/hs_met.json" > /dev/null && ok "has collaboration" || fail "missing" +jq -e '.coverage' "$TMPDIR/hs_met.json" > /dev/null && ok "has coverage" || fail "missing" +jq -e '.activity' "$TMPDIR/hs_met.json" > /dev/null && ok "has activity" || fail "missing" + +CODE=$(curl -s -o "$TMPDIR/user_met.json" -w "%{http_code}" "$API/metrics/me" -H "$AUTH_A") +check_code "$CODE" "200" "GET /metrics/me" +jq -e '.total_entries' "$TMPDIR/user_met.json" > /dev/null && ok "has total_entries" || fail "missing" + +# ── Hiveshares: Delete (owner only) ────────────────────────────────────────── +section "Hiveshares: Delete" + +CODE=$(curl -s -o /dev/null -w "%{http_code}" -X DELETE "$API/hiveshares/$HS_ID" -H "$AUTH_B") +check_code "$CODE" "403" "non-owner cannot delete" + +CODE=$(curl -s -o /dev/null -w "%{http_code}" -X DELETE "$API/hiveshares/$HS_ID" -H "$AUTH_A") +check_code "$CODE" "204" "DELETE /hiveshares/{id}" + +# ── Remove member (tested via self-leave before delete) ─────────────────────── +section "Members: Remove" + +HS3=$(curl -s -X POST "$API/hiveshares" \ + -H "$AUTH_A" -H "Content-Type: application/json" -d '{"name":"remove-test"}') +HS3_ID=$(echo "$HS3" | jq -r '.id') +INV3=$(curl -s -X POST "$API/hiveshares/$HS3_ID/invite" \ + -H "$AUTH_A" -H "Content-Type: application/json" \ + -d "{\"email\":\"$EMAIL_B\",\"role\":\"view\"}") +TOK3=$(echo "$INV3" | jq -r '.token') +curl -s -X POST "$API/invitations/$TOK3/accept" -H "Content-Type: application/json" -d '{}' > /dev/null +USER_B_ID=$(jq -r '.id' "$TMPDIR/reg_b.json") + +CODE=$(curl -s -o /dev/null -w "%{http_code}" -X DELETE \ + "$API/hiveshares/$HS3_ID/members/$USER_B_ID" -H "$AUTH_A") +check_code "$CODE" "204" "DELETE /hiveshares/{id}/members/{userId}" + +# cleanup +curl -s -X DELETE "$API/hiveshares/$HS3_ID" -H "$AUTH_A" > /dev/null +curl -s -X DELETE "$API/hiveshares/$NEW_HS_ID" -H "$AUTH_A" > /dev/null + +# ── Summary ─────────────────────────────────────────────────────────────────── +echo "" +echo "=== Results: $PASS passed, $FAIL failed ===" +[ "$FAIL" -eq 0 ] && exit 0 || exit 1 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..8884c45 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,75 @@ +"""Shared fixtures for HiveShare integration tests. + +Requires a running server (make dev) at BASE_URL. +""" + +import os +import time +import requests + + +BASE_URL = os.environ.get("HIVESHARE_TEST_URL", "http://localhost:8080") +API = f"{BASE_URL}/api/v1" + +_SESSION_TS = str(int(time.time() * 1000)) + + +def _register(suffix, name): + email = f"pytest-{suffix}-{_SESSION_TS}@test.local" + resp = requests.post(f"{API}/auth/register", timeout=10, json={ + "email": email, + "name": name, + }) + resp.raise_for_status() + data = resp.json() + return {"api_key": data["api_key"], "id": data["id"], "email": email} + + +import pytest + + +@pytest.fixture(scope="session") +def api_url(): + return API + + +@pytest.fixture(scope="session") +def user_a(): + """Register user A with a unique email.""" + return _register("a", "Test User A") + + +@pytest.fixture(scope="session") +def user_b(): + """Register user B with a unique email.""" + return _register("b", "Test User B") + + +def auth_header(user): + return {"Authorization": f"Bearer {user['api_key']}"} + + +@pytest.fixture(scope="session") +def hiveshare_id(api_url, user_a): + """Create a hiveshare owned by user A.""" + resp = requests.post(f"{api_url}/hiveshares", timeout=10, json={ + "name": "Test Hiveshare", + "description": "Integration test hiveshare", + }, headers=auth_header(user_a)) + resp.raise_for_status() + return resp.json()["id"] + + +@pytest.fixture(scope="session") +def hive_entry(api_url, user_a, hiveshare_id): + """Create a hive in the test hiveshare.""" + resp = requests.post(f"{api_url}/hiveshares/{hiveshare_id}/hives", timeout=10, json={ + "source_type": "manual", + "source_ref": "test-entry-1", + "content": "Original content for testing history", + "summary": "Test entry", + "tool": "manual", + "tags": ["test"], + }, headers=auth_header(user_a)) + resp.raise_for_status() + return resp.json() diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..1a02023 --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,59 @@ +"""Integration tests for auth endpoints.""" + +import requests +import time + +from conftest import auth_header, API + +TIMEOUT = 10 + + +class TestRegister: + def test_register_returns_api_key(self): + resp = requests.post(f"{API}/auth/register", timeout=TIMEOUT, json={ + "email": f"reg-{int(time.time())}@test.local", + "name": "Register Test", + }) + assert resp.status_code == 201 + data = resp.json() + assert data["api_key"].startswith("hvs_") + assert "id" in data + + def test_register_duplicate_email_409(self): + email = f"dup-{int(time.time())}@test.local" + requests.post(f"{API}/auth/register", timeout=TIMEOUT, json={ + "email": email, "name": "First", + }).raise_for_status() + + resp = requests.post(f"{API}/auth/register", timeout=TIMEOUT, json={ + "email": email, "name": "Second", + }) + assert resp.status_code == 409 + + def test_register_missing_fields_400(self): + resp = requests.post(f"{API}/auth/register", timeout=TIMEOUT, json={ + "email": "no-name@test.local", + }) + assert resp.status_code == 400 + + +class TestWhoami: + def test_whoami_returns_user(self, api_url, user_a): + resp = requests.get( + f"{api_url}/auth/whoami", + headers=auth_header(user_a), timeout=TIMEOUT, + ) + assert resp.status_code == 200 + assert resp.json()["email"] == user_a["email"] + assert resp.json()["name"] == "Test User A" + + def test_whoami_no_auth_401(self, api_url): + resp = requests.get(f"{api_url}/auth/whoami", timeout=TIMEOUT) + assert resp.status_code == 401 + + def test_whoami_bad_key_401(self, api_url): + resp = requests.get( + f"{api_url}/auth/whoami", + headers={"Authorization": "Bearer hvs_bogus"}, timeout=TIMEOUT, + ) + assert resp.status_code == 401 diff --git a/tests/test_history.py b/tests/test_history.py new file mode 100644 index 0000000..914c451 --- /dev/null +++ b/tests/test_history.py @@ -0,0 +1,245 @@ +"""Integration tests for hive history, snapshots, rollback, and copy. + +Run: pytest tests/ -v +Requires: make dev (server + postgres + redis running) +""" + +import time +import requests +import pytest + +from conftest import auth_header + +TIMEOUT = 10 + + +def _create_hive(api_url, user, hiveshare_id, source_ref, content, **extra): + body = { + "source_type": "manual", + "source_ref": source_ref, + "content": content, + "tool": "manual", + "tags": [], + **extra, + } + resp = requests.post( + f"{api_url}/hiveshares/{hiveshare_id}/hives", + json=body, + headers=auth_header(user), + timeout=TIMEOUT, + ) + resp.raise_for_status() + return resp.json() + + +class TestEntryHistory: + """Per-entry history, rollback, and undelete.""" + + def test_create_generates_history(self, api_url, user_a, hiveshare_id, hive_entry): + resp = requests.get( + f"{api_url}/hiveshares/{hiveshare_id}/hives/{hive_entry['id']}/history", + headers=auth_header(user_a), timeout=TIMEOUT, + ) + resp.raise_for_status() + versions = resp.json() + assert len(versions) >= 1 + assert versions[-1]["action"] == "insert" + + def test_update_generates_history(self, api_url, user_a, hiveshare_id): + entry = _create_hive( + api_url, user_a, hiveshare_id, + source_ref=f"update-hist-{time.time_ns()}", + content="Content before update", + summary="Before", + ) + requests.put( + f"{api_url}/hiveshares/{hiveshare_id}/hives/{entry['id']}", + json={"content": "Updated content", "summary": "Updated", "tags": ["test", "updated"]}, + headers=auth_header(user_a), timeout=TIMEOUT, + ).raise_for_status() + + resp = requests.get( + f"{api_url}/hiveshares/{hiveshare_id}/hives/{entry['id']}/history", + headers=auth_header(user_a), timeout=TIMEOUT, + ) + resp.raise_for_status() + versions = resp.json() + actions = [v["action"] for v in versions] + assert "update" in actions + # Async embed must not produce a second update row. + assert actions.count("update") == 1 + + def test_rollback_restores_content(self, api_url, user_a, hiveshare_id): + original = "Rollback original content" + entry = _create_hive( + api_url, user_a, hiveshare_id, + source_ref=f"rollback-{time.time_ns()}", + content=original, + summary="Original", + ) + requests.put( + f"{api_url}/hiveshares/{hiveshare_id}/hives/{entry['id']}", + json={"content": "Mutated content", "summary": "Mutated", "tags": ["x"]}, + headers=auth_header(user_a), timeout=TIMEOUT, + ).raise_for_status() + + resp = requests.get( + f"{api_url}/hiveshares/{hiveshare_id}/hives/{entry['id']}/history", + headers=auth_header(user_a), timeout=TIMEOUT, + ) + resp.raise_for_status() + versions = resp.json() + insert_version = [v for v in versions if v["action"] == "insert"][-1] + + resp = requests.post( + f"{api_url}/hiveshares/{hiveshare_id}/hives/{entry['id']}/rollback", + json={"history_id": insert_version["history_id"]}, + headers=auth_header(user_a), timeout=TIMEOUT, + ) + resp.raise_for_status() + restored = resp.json() + assert restored["content"] == original + + def test_delete_and_undelete(self, api_url, user_a, hiveshare_id): + entry = _create_hive( + api_url, user_a, hiveshare_id, + source_ref=f"delete-test-{time.time_ns()}", + content="Entry to be deleted and restored", + ) + entry_id = entry["id"] + + requests.delete( + f"{api_url}/hiveshares/{hiveshare_id}/hives/{entry_id}", + headers=auth_header(user_a), timeout=TIMEOUT, + ).raise_for_status() + + get_resp = requests.get( + f"{api_url}/hiveshares/{hiveshare_id}/hives/{entry_id}", + headers=auth_header(user_a), timeout=TIMEOUT, + ) + assert get_resp.status_code == 404 + + hist_resp = requests.get( + f"{api_url}/hiveshares/{hiveshare_id}/hives/{entry_id}/history", + headers=auth_header(user_a), timeout=TIMEOUT, + ) + hist_resp.raise_for_status() + versions = hist_resp.json() + delete_version = [v for v in versions if v["action"] == "delete"][0] + + undelete_resp = requests.post( + f"{api_url}/hiveshares/{hiveshare_id}/hives/undelete", + json={"history_id": delete_version["history_id"]}, + headers=auth_header(user_a), timeout=TIMEOUT, + ) + undelete_resp.raise_for_status() + assert undelete_resp.status_code == 201 + restored = undelete_resp.json() + assert restored["content"] == "Entry to be deleted and restored" + assert restored["id"] == entry_id + + +class TestSnapshots: + """Hiveshare-level snapshots and restore-to-new-hiveshare.""" + + def test_create_snapshot(self, api_url, user_a, hiveshare_id): + resp = requests.post( + f"{api_url}/hiveshares/{hiveshare_id}/snapshots", + json={"name": "test-snapshot", "description": "Integration test"}, + headers=auth_header(user_a), timeout=TIMEOUT, + ) + resp.raise_for_status() + assert resp.status_code == 201 + snap = resp.json() + assert snap["name"] == "test-snapshot" + assert snap["entry_count"] >= 1 + + def test_list_snapshots(self, api_url, user_a, hiveshare_id): + resp = requests.get( + f"{api_url}/hiveshares/{hiveshare_id}/snapshots", + headers=auth_header(user_a), timeout=TIMEOUT, + ) + resp.raise_for_status() + snaps = resp.json() + assert len(snaps) >= 1 + + def test_get_snapshot_detail(self, api_url, user_a, hiveshare_id): + list_resp = requests.get( + f"{api_url}/hiveshares/{hiveshare_id}/snapshots", + headers=auth_header(user_a), timeout=TIMEOUT, + ) + list_resp.raise_for_status() + snapshot_id = int(list_resp.json()[0]["snapshot_id"]) + + detail_resp = requests.get( + f"{api_url}/hiveshares/{hiveshare_id}/snapshots/{snapshot_id}", + headers=auth_header(user_a), timeout=TIMEOUT, + ) + detail_resp.raise_for_status() + data = detail_resp.json() + assert "snapshot" in data + assert "entries" in data + assert len(data["entries"]) >= 1 + # SnapshotEntry has no history fields. + assert "history_id" not in data["entries"][0] + assert "action" not in data["entries"][0] + + def test_restore_creates_new_hiveshare(self, api_url, user_a, hiveshare_id): + list_resp = requests.get( + f"{api_url}/hiveshares/{hiveshare_id}/snapshots", + headers=auth_header(user_a), timeout=TIMEOUT, + ) + list_resp.raise_for_status() + snapshot_id = list_resp.json()[0]["snapshot_id"] + + restore_resp = requests.post( + f"{api_url}/hiveshares/{hiveshare_id}/snapshots/{int(snapshot_id)}/restore", + json={"name": "Restored Hiveshare"}, + headers=auth_header(user_a), timeout=TIMEOUT, + ) + restore_resp.raise_for_status() + assert restore_resp.status_code == 201 + result = restore_resp.json() + assert result["hiveshare"]["name"] == "Restored Hiveshare" + assert result["hiveshare"]["id"] != hiveshare_id + assert result["entries_restored"] >= 1 + + def test_delete_snapshot(self, api_url, user_a, hiveshare_id): + create_resp = requests.post( + f"{api_url}/hiveshares/{hiveshare_id}/snapshots", + json={"name": "to-delete"}, + headers=auth_header(user_a), timeout=TIMEOUT, + ) + create_resp.raise_for_status() + snap_id = int(create_resp.json()["snapshot_id"]) + + del_resp = requests.delete( + f"{api_url}/hiveshares/{hiveshare_id}/snapshots/{snap_id}", + headers=auth_header(user_a), timeout=TIMEOUT, + ) + assert del_resp.status_code == 204 + + +class TestCopyEntries: + """Cross-hiveshare entry copy (rollforward merge).""" + + def test_copy_entry_to_another_hiveshare(self, api_url, user_a, hiveshare_id, hive_entry): + new_hs_resp = requests.post( + f"{api_url}/hiveshares", + json={"name": "Copy Target"}, + headers=auth_header(user_a), timeout=TIMEOUT, + ) + new_hs_resp.raise_for_status() + target_id = new_hs_resp.json()["id"] + + copy_resp = requests.post( + f"{api_url}/hiveshares/{target_id}/hives/copy", + json={"entry_ids": [hive_entry["id"]]}, + headers=auth_header(user_a), timeout=TIMEOUT, + ) + copy_resp.raise_for_status() + assert copy_resp.status_code == 201 + copied = copy_resp.json() + assert len(copied) == 1 + assert copied[0]["hiveshare_id"] == target_id + assert copied[0]["content"] == hive_entry.get("content", copied[0]["content"]) diff --git a/tests/test_hiveshares.py b/tests/test_hiveshares.py new file mode 100644 index 0000000..de42eb7 --- /dev/null +++ b/tests/test_hiveshares.py @@ -0,0 +1,104 @@ +"""Integration tests for hiveshare CRUD, members, and invitations.""" + +import requests + +from conftest import auth_header + +TIMEOUT = 10 + + +class TestHiveshareCRUD: + def test_create_hiveshare(self, api_url, user_a): + resp = requests.post(f"{api_url}/hiveshares", timeout=TIMEOUT, json={ + "name": "CRUD Test", + "description": "Testing create", + }, headers=auth_header(user_a)) + assert resp.status_code == 201 + data = resp.json() + assert data["name"] == "CRUD Test" + assert data["role"] == "all" + assert data["member_count"] == 1 + + def test_list_hiveshares(self, api_url, user_a, hiveshare_id): + resp = requests.get( + f"{api_url}/hiveshares", + headers=auth_header(user_a), timeout=TIMEOUT, + ) + assert resp.status_code == 200 + ids = [hs["id"] for hs in resp.json()] + assert hiveshare_id in ids + + def test_get_hiveshare(self, api_url, user_a, hiveshare_id): + resp = requests.get( + f"{api_url}/hiveshares/{hiveshare_id}", + headers=auth_header(user_a), timeout=TIMEOUT, + ) + assert resp.status_code == 200 + assert resp.json()["id"] == hiveshare_id + + def test_get_hiveshare_non_member_404(self, api_url, user_b, hiveshare_id): + resp = requests.get( + f"{api_url}/hiveshares/{hiveshare_id}", + headers=auth_header(user_b), timeout=TIMEOUT, + ) + assert resp.status_code == 404 + + def test_update_hiveshare(self, api_url, user_a, hiveshare_id): + resp = requests.put( + f"{api_url}/hiveshares/{hiveshare_id}", + json={"name": "Updated Name", "description": "Updated"}, + headers=auth_header(user_a), timeout=TIMEOUT, + ) + assert resp.status_code == 200 + assert resp.json()["name"] == "Updated Name" + + def test_delete_hiveshare(self, api_url, user_a): + create_resp = requests.post(f"{api_url}/hiveshares", timeout=TIMEOUT, json={ + "name": "To Delete", + }, headers=auth_header(user_a)) + hs_id = create_resp.json()["id"] + + del_resp = requests.delete( + f"{api_url}/hiveshares/{hs_id}", + headers=auth_header(user_a), timeout=TIMEOUT, + ) + assert del_resp.status_code == 204 + + +class TestMembers: + def test_list_members(self, api_url, user_a, hiveshare_id): + resp = requests.get( + f"{api_url}/hiveshares/{hiveshare_id}/members", + headers=auth_header(user_a), timeout=TIMEOUT, + ) + assert resp.status_code == 200 + assert len(resp.json()) >= 1 + + +class TestInvitations: + def test_invite_and_accept(self, api_url, user_a, user_b, hiveshare_id): + invite_resp = requests.post( + f"{api_url}/hiveshares/{hiveshare_id}/invite", + json={"email": user_b["email"], "role": "view"}, + headers=auth_header(user_a), timeout=TIMEOUT, + ) + assert invite_resp.status_code == 201 + token = invite_resp.json()["token"] + + accept_resp = requests.post( + f"{api_url}/invitations/{token}/accept", + json={}, timeout=TIMEOUT, + ) + assert accept_resp.status_code == 200 + + get_resp = requests.get( + f"{api_url}/hiveshares/{hiveshare_id}", + headers=auth_header(user_b), timeout=TIMEOUT, + ) + assert get_resp.status_code == 200 + + members_resp = requests.get( + f"{api_url}/hiveshares/{hiveshare_id}/members", + headers=auth_header(user_a), timeout=TIMEOUT, + ) + assert len(members_resp.json()) >= 2 diff --git a/tests/test_infrastructure.py b/tests/test_infrastructure.py new file mode 100644 index 0000000..c912815 --- /dev/null +++ b/tests/test_infrastructure.py @@ -0,0 +1,41 @@ +"""Integration tests for infrastructure endpoints (health, routing, auth enforcement).""" + +import requests + +from conftest import BASE_URL, API + +TIMEOUT = 10 + + +class TestHealth: + def test_health_returns_200(self): + resp = requests.get(f"{BASE_URL}/health", timeout=TIMEOUT) + assert resp.status_code == 200 + + def test_health_response_format(self): + resp = requests.get(f"{BASE_URL}/health", timeout=TIMEOUT) + data = resp.json() + assert data["status"] == "ok" + assert data["db"] == "ok" + assert data["redis"] == "ok" + assert "commit" in data + assert "build_time" in data + + +class TestRouting: + def test_unknown_route_returns_404_or_405(self): + resp = requests.get(f"{API}/nonexistent", timeout=TIMEOUT) + assert resp.status_code in (404, 405) + + +class TestAuthEnforcement: + def test_hiveshares_without_auth_returns_401(self): + resp = requests.get(f"{API}/hiveshares", timeout=TIMEOUT) + assert resp.status_code == 401 + + def test_hiveshares_invalid_key_returns_401(self): + resp = requests.get( + f"{API}/hiveshares", + headers={"Authorization": "Bearer hvs_invalid"}, timeout=TIMEOUT, + ) + assert resp.status_code == 401 diff --git a/tests/test_memory.py b/tests/test_memory.py new file mode 100644 index 0000000..28f04c4 --- /dev/null +++ b/tests/test_memory.py @@ -0,0 +1,152 @@ +"""Integration tests for memory CRUD and search.""" + +import requests + +from conftest import auth_header, API + +TIMEOUT = 10 + + +class TestMemoryCRUD: + def test_create_entry(self, api_url, user_a, hiveshare_id): + resp = requests.post( + f"{api_url}/hiveshares/{hiveshare_id}/hives", + json={ + "source_type": "jira", + "source_ref": "TEST-100", + "content": "Test hive content", + "summary": "Test summary", + "tool": "claude", + "tags": ["test"], + }, + headers=auth_header(user_a), timeout=TIMEOUT, + ) + assert resp.status_code == 201 + data = resp.json() + assert data["source_type"] == "jira" + assert data["source_ref"] == "TEST-100" + assert data["tool"] == "claude" + + def test_create_missing_fields_400(self, api_url, user_a, hiveshare_id): + resp = requests.post( + f"{api_url}/hiveshares/{hiveshare_id}/hives", + json={"content": "no source type"}, + headers=auth_header(user_a), timeout=TIMEOUT, + ) + assert resp.status_code == 400 + + def test_list_entries(self, api_url, user_a, hiveshare_id, hive_entry): + resp = requests.get( + f"{api_url}/hiveshares/{hiveshare_id}/hives", + headers=auth_header(user_a), timeout=TIMEOUT, + ) + assert resp.status_code == 200 + entries = resp.json() + assert len(entries) >= 1 + + def test_list_filter_by_source_type(self, api_url, user_a, hiveshare_id): + resp = requests.get( + f"{api_url}/hiveshares/{hiveshare_id}/hives?source_type=manual", + headers=auth_header(user_a), timeout=TIMEOUT, + ) + assert resp.status_code == 200 + for e in resp.json(): + assert e["source_type"] == "manual" + + def test_get_entry(self, api_url, user_a, hiveshare_id, hive_entry): + resp = requests.get( + f"{api_url}/hiveshares/{hiveshare_id}/hives/{hive_entry['id']}", + headers=auth_header(user_a), timeout=TIMEOUT, + ) + assert resp.status_code == 200 + assert resp.json()["id"] == hive_entry["id"] + assert "content" in resp.json() + + def test_update_entry(self, api_url, user_a, hiveshare_id, hive_entry): + resp = requests.put( + f"{api_url}/hiveshares/{hiveshare_id}/hives/{hive_entry['id']}", + json={"content": "Updated via test", "summary": "Updated", "tags": ["updated"]}, + headers=auth_header(user_a), timeout=TIMEOUT, + ) + assert resp.status_code == 200 + assert resp.json()["content"] == "Updated via test" + + def test_view_only_cannot_write_403(self, api_url, user_a, user_b, hiveshare_id): + invite_resp = requests.post( + f"{api_url}/hiveshares/{hiveshare_id}/invite", + json={"email": user_b["email"], "role": "view"}, + headers=auth_header(user_a), timeout=TIMEOUT, + ) + if invite_resp.status_code == 201: + token = invite_resp.json()["token"] + requests.post(f"{api_url}/invitations/{token}/accept", + json={}, timeout=TIMEOUT) + + resp = requests.post( + f"{api_url}/hiveshares/{hiveshare_id}/hives", + json={ + "source_type": "manual", + "source_ref": "view-write-test", + "content": "should be rejected", + "tool": "manual", + }, + headers=auth_header(user_b), timeout=TIMEOUT, + ) + assert resp.status_code == 403 + + def test_delete_entry(self, api_url, user_a, hiveshare_id): + create_resp = requests.post( + f"{api_url}/hiveshares/{hiveshare_id}/hives", + json={ + "source_type": "manual", + "source_ref": "to-delete", + "content": "Will be deleted", + "tool": "manual", + "tags": [], + }, + headers=auth_header(user_a), timeout=TIMEOUT, + ) + entry_id = create_resp.json()["id"] + + del_resp = requests.delete( + f"{api_url}/hiveshares/{hiveshare_id}/hives/{entry_id}", + headers=auth_header(user_a), timeout=TIMEOUT, + ) + assert del_resp.status_code == 204 + + +class TestSearch: + def test_search_fulltext(self, api_url, user_a, hiveshare_id): + requests.post( + f"{api_url}/hiveshares/{hiveshare_id}/hives", + json={ + "source_type": "manual", + "source_ref": "search-target", + "content": "unique searchable platypus content", + "tool": "manual", + "tags": [], + }, + headers=auth_header(user_a), timeout=TIMEOUT, + ).raise_for_status() + + resp = requests.post( + f"{api_url}/hiveshares/{hiveshare_id}/hives/search", + json={"query": "searchable platypus", "limit": 5}, + headers=auth_header(user_a), timeout=TIMEOUT, + ) + assert resp.status_code == 200 + data = resp.json() + assert "results" in data + assert "count" in data + assert data["count"] >= 1 + assert "query" in data + + def test_search_missing_query_400(self, api_url, user_a, hiveshare_id): + resp = requests.post( + f"{api_url}/hiveshares/{hiveshare_id}/hives/search", + json={"limit": 5}, + headers=auth_header(user_a), timeout=TIMEOUT, + ) + assert resp.status_code == 400 + + diff --git a/tests/test_metrics.py b/tests/test_metrics.py new file mode 100644 index 0000000..261077d --- /dev/null +++ b/tests/test_metrics.py @@ -0,0 +1,36 @@ +"""Integration tests for metrics endpoints.""" + +import requests + +from conftest import auth_header + +TIMEOUT = 10 + + +class TestHiveshareMetrics: + def test_hiveshare_metrics(self, api_url, user_a, hiveshare_id, hive_entry): + resp = requests.get( + f"{api_url}/hiveshares/{hiveshare_id}/metrics", + headers=auth_header(user_a), timeout=TIMEOUT, + ) + assert resp.status_code == 200 + data = resp.json() + assert "hiveshare" in data + assert "memory" in data + assert "collaboration" in data + assert "coverage" in data + assert "activity" in data + assert data["memory"]["total_entries"] >= 1 + + +class TestUserMetrics: + def test_user_metrics(self, api_url, user_a): + resp = requests.get( + f"{api_url}/metrics/me", + headers=auth_header(user_a), timeout=TIMEOUT, + ) + assert resp.status_code == 200 + data = resp.json() + assert "total_entries" in data + assert "total_searches" in data + assert "hiveshares_owned" in data