From 9358d5f0fd508c14b35be0d297f0c1e01d117486 Mon Sep 17 00:00:00 2001 From: tyraziel Date: Wed, 22 Jul 2026 16:19:18 -0400 Subject: [PATCH 1/5] feat(history): add memory entry history, snapshots, and rollback Add trigger-based history tracking for memory entries, hiveshare snapshots with restore-to-new-hiveshare, and entry copy for rollforward merges. - migration 005: history table with embedding column, trigger on content/summary/tags/metadata/embedding changes and deletes, snapshot and snapshot_entries tables - HistoryStore with rollback, undelete, snapshot CRUD, restore, copy, and configurable purge (HISTORY_TTL_DAYS, HISTORY_MAX_VERSIONS) - 9 new API endpoints and 9 new CLI commands - rollback restores embedding from history when available, only enqueues re-embed job when history embedding is NULL Assisted-by: Claude Code / Opus 4.6 (Anthropic) --- cmd/hshare/main.go | 335 +++++++++++++++++++++++- cmd/server/main.go | 53 +++- internal/api/memory.go | 260 ++++++++++++++++++- internal/api/router.go | 13 +- internal/models/models.go | 30 +++ internal/store/history.go | 411 ++++++++++++++++++++++++++++++ migrations/005_memory_history.sql | 90 +++++++ 7 files changed, 1187 insertions(+), 5 deletions(-) create mode 100644 internal/store/history.go create mode 100644 migrations/005_memory_history.sql diff --git a/cmd/hshare/main.go b/cmd/hshare/main.go index 640fe0c..d14a78b 100644 --- a/cmd/hshare/main.go +++ b/cmd/hshare/main.go @@ -180,7 +180,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 } @@ -354,7 +541,151 @@ func memoryCmd() *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 memory entry", + 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/memory/%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 memory entry 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/memory/%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 memory entry", + 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/memory/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/memory/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 b10c1b5..382af59 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, memStore, 2, 64) worker.Start(ctx, 2) // rolling TTL for usage_events (retain 90 days) go purgeUsageEvents(ctx, metricsStore) - router := api.NewRouter(userStore, hsStore, memStore, 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, memStore, 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/internal/api/memory.go b/internal/api/memory.go index 6454e9f..1cf78d1 100644 --- a/internal/api/memory.go +++ b/internal/api/memory.go @@ -4,6 +4,7 @@ import ( "net/http" "strconv" + "github.com/go-chi/chi/v5" "github.com/google/uuid" "github.com/KB-perByte/hiveshare/internal/embed" "github.com/KB-perByte/hiveshare/internal/models" @@ -15,6 +16,7 @@ type MemoryHandler struct { mem *store.MemoryStore hs *store.HiveshareStore metrics *store.MetricsStore + history *store.HistoryStore embedder embed.Embedder hub *realtime.Hub worker *embed.Worker @@ -25,13 +27,14 @@ func NewMemoryHandler( mem *store.MemoryStore, hs *store.HiveshareStore, metrics *store.MetricsStore, + history *store.HistoryStore, embedder embed.Embedder, hub *realtime.Hub, worker *embed.Worker, views *store.ViewCounter, ) *MemoryHandler { return &MemoryHandler{ - mem: mem, hs: hs, metrics: metrics, embedder: embedder, + mem: mem, hs: hs, metrics: metrics, history: history, embedder: embedder, hub: hub, worker: worker, views: views, } } @@ -294,3 +297,258 @@ func (h *MemoryHandler) Stream(w http.ResponseWriter, r *http.Request) { } h.hub.ServeSSE(w, r, hsID) } + +// ── Per-entry history ──────────────────────────────────────────────────────── + +func (h *MemoryHandler) 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 *MemoryHandler) 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 { + writeError(w, http.StatusNotFound, "rollback failed: "+err.Error()) + 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: "memory_rolled_back", + HiveshareID: hsID, + Payload: entry, + }) + writeJSON(w, http.StatusOK, entry) +} + +func (h *MemoryHandler) 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 { + writeError(w, http.StatusNotFound, "undelete failed: "+err.Error()) + 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: "memory_undeleted", + HiveshareID: hsID, + Payload: entry, + }) + writeJSON(w, http.StatusCreated, entry) +} + +// ── Snapshots ──────────────────────────────────────────────────────────────── + +func (h *MemoryHandler) 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 { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusCreated, snap) +} + +func (h *MemoryHandler) 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 *MemoryHandler) 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 *MemoryHandler) RestoreSnapshot(w http.ResponseWriter, r *http.Request) { + u := currentUser(r) + hsID, ok := h.requireAccess(r, w, true) + if !ok { + return + } + _ = hsID + 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"` + } + decodeJSON(r, &req) + if req.Name == "" { + req.Name = "(restored)" + } + result, err := h.history.RestoreSnapshot(r.Context(), snapshotID, u.ID, req.Name) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + 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 *MemoryHandler) 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 + } + _ = h.history.DeleteSnapshot(r.Context(), snapshotID, hsID) + w.WriteHeader(http.StatusNoContent) +} + +// ── Copy entries ───────────────────────────────────────────────────────────── + +func (h *MemoryHandler) 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 { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + var entries []*models.MemoryEntry + 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) +} diff --git a/internal/api/router.go b/internal/api/router.go index 8c1d468..70d59c3 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -26,6 +26,7 @@ func NewRouter( hsStore *store.HiveshareStore, memStore *store.MemoryStore, metricsStore *store.MetricsStore, + historyStore *store.HistoryStore, embedder embed.Embedder, hub *realtime.Hub, worker *embed.Worker, @@ -56,7 +57,7 @@ func NewRouter( auth := NewAuthHandler(userStore) hs := NewHiveshareHandler(hsStore, metricsStore) - mem := NewMemoryHandler(memStore, hsStore, metricsStore, embedder, hub, worker, views) + mem := NewMemoryHandler(memStore, hsStore, metricsStore, historyStore, embedder, hub, worker, views) met := NewMetricsHandler(metricsStore) r.Get("/health", healthHandler(pool, rdb)) @@ -91,6 +92,16 @@ func NewRouter( r.Put("/hiveshares/{id}/memory/{entryId}", mem.Update) r.Delete("/hiveshares/{id}/memory/{entryId}", mem.Delete) r.Post("/hiveshares/{id}/memory/search", mem.Search) + r.Get("/hiveshares/{id}/memory/{entryId}/history", mem.ListHistory) + r.Post("/hiveshares/{id}/memory/{entryId}/rollback", mem.Rollback) + r.Post("/hiveshares/{id}/memory/undelete", mem.Undelete) + r.Post("/hiveshares/{id}/memory/copy", mem.CopyEntries) + + r.Post("/hiveshares/{id}/snapshots", mem.CreateSnapshot) + r.Get("/hiveshares/{id}/snapshots", mem.ListSnapshots) + r.Get("/hiveshares/{id}/snapshots/{snapshotId}", mem.GetSnapshot) + r.Post("/hiveshares/{id}/snapshots/{snapshotId}/restore", mem.RestoreSnapshot) + r.Delete("/hiveshares/{id}/snapshots/{snapshotId}", mem.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 3209e40..eb714f1 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -139,6 +139,36 @@ type UserMetrics struct { TotalReusesGiven int `json:"total_reuses_given"` } +// History + +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"` +} + +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"` +} + // SSE event payload type StreamEvent struct { diff --git a/internal/store/history.go b/internal/store/history.go new file mode 100644 index 0000000..f1fefcd --- /dev/null +++ b/internal/store/history.go @@ -0,0 +1,411 @@ +package store + +import ( + "context" + "fmt" + "time" + + "github.com/google/uuid" + "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 memory_entries_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) GetVersion(ctx context.Context, historyID int64) (*models.HistoryEntry, error) { + var h models.HistoryEntry + err := s.db.QueryRow(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 memory_entries_history + WHERE history_id = $1`, + historyID, + ).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) + if err != nil { + return nil, fmt.Errorf("get version: %w", err) + } + return &h, nil +} + +func (s *HistoryStore) Rollback(ctx context.Context, entryID, hiveshareID uuid.UUID, historyID int64) (*models.MemoryEntry, bool, error) { + var e models.MemoryEntry + var hasEmbedding bool + err := s.db.QueryRow(ctx, + `UPDATE memory_entries me + SET content = h.content, summary = h.summary, tags = h.tags, metadata = h.metadata, + embedding = h.embedding, updated_at = NOW() + FROM memory_entries_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.MemoryEntry, bool, error) { + var e models.MemoryEntry + var hasEmbedding bool + err := s.db.QueryRow(ctx, + `INSERT INTO memory_entries + (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, source_ref, source_url, + tool, content, summary, embedding, tags, metadata + FROM memory_entries_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, + ).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("undelete entry: %w", err) + } + return &e, hasEmbedding, nil +} + +// ── Purge ──────────────────────────────────────────────────────────────────── + +func (s *HistoryStore) PurgeByAge(ctx context.Context, olderThan time.Duration) (int64, error) { + tag, err := s.db.Exec(ctx, + `DELETE FROM memory_entries_history WHERE recorded_at < NOW() - $1::interval`, + olderThan.String(), + ) + 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 memory_entries_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 memory_entries_history + ) ranked + WHERE rn > $1 + )`, + maxVersions, + ) + if err != nil { + return 0, fmt.Errorf("purge history by count: %w", err) + } + return tag.RowsAffected(), nil +} + +// ── Snapshots ──────────────────────────────────────────────────────────────── + +func (s *HistoryStore) CreateSnapshot(ctx context.Context, hiveshareID, userID uuid.UUID, name, description string) (*models.Snapshot, error) { + 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 memory_entries + 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.HistoryEntry, 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.HistoryEntry + for rows.Next() { + e := &models.HistoryEntry{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, 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 + RETURNING id, name, description, owner_id, settings, created_at, updated_at`, + name, userID, snapshotID, + ).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 memory_entries + (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 memory_entries 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) error { + _, err := s.db.Exec(ctx, + `DELETE FROM hiveshare_snapshots WHERE snapshot_id = $1 AND hiveshare_id = $2`, + snapshotID, hiveshareID, + ) + return err +} + +// ── Copy entries ───────────────────────────────────────────────────────────── + +type CopyResult struct { + Entry *models.MemoryEntry + HasEmbedding bool +} + +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.MemoryEntry + var hasEmb bool + var emb *pgvector.Vector + err := tx.QueryRow(ctx, + `SELECT id, source_type, source_ref, source_url, tool, + content, summary, embedding, tags, metadata + FROM memory_entries WHERE id = $1`, + eid, + ).Scan(&e.ID, &e.SourceType, &e.SourceRef, &e.SourceURL, &e.Tool, + &e.Content, &e.Summary, &emb, &e.Tags, &e.Metadata) + if err != nil { + return nil, fmt.Errorf("read source entry %s: %w", eid, err) + } + + var embVal interface{} + if emb != nil { + embVal = emb + hasEmb = true + } + + var newEntry models.MemoryEntry + err = tx.QueryRow(ctx, + `INSERT INTO memory_entries + (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, e.SourceRef, 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 { + 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/005_memory_history.sql b/migrations/005_memory_history.sql new file mode 100644 index 0000000..dc5a05e --- /dev/null +++ b/migrations/005_memory_history.sql @@ -0,0 +1,90 @@ +-- Memory entry history: trigger-based audit trail for all content mutations. +-- Enables per-entry rollback/undelete and hiveshare-level snapshots. + +-- ── History table ──────────────────────────────────────────────────────────── + +CREATE TABLE IF NOT EXISTS memory_entries_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 memory_history_entry_idx + ON memory_entries_history (entry_id, recorded_at DESC); + +CREATE INDEX IF NOT EXISTS memory_history_hiveshare_idx + ON memory_entries_history (hiveshare_id, recorded_at DESC); + +-- ── Trigger function ───────────────────────────────────────────────────────── + +CREATE OR REPLACE FUNCTION record_memory_history() RETURNS TRIGGER AS $$ +BEGIN + IF TG_OP = 'DELETE' THEN + INSERT INTO memory_entries_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 memory_entries_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; + +DROP TRIGGER IF EXISTS memory_history_trigger ON memory_entries; +CREATE TRIGGER memory_history_trigger + AFTER INSERT OR UPDATE OF content, summary, tags, metadata, embedding OR DELETE + ON memory_entries + FOR EACH ROW EXECUTE FUNCTION record_memory_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); From ddce339287d11b510bc29f3a7c463501246756ec Mon Sep 17 00:00:00 2001 From: tyraziel Date: Thu, 23 Jul 2026 09:46:19 -0400 Subject: [PATCH 2/5] fix(history): backfill existing memory entries into history table Ensure pre-migration entries have a baseline history row so they can be rolled back or included in snapshots. Uses WHERE NOT EXISTS guard for idempotency. Assisted-by: Claude Code / Opus 4.6 (Anthropic) --- migrations/005_memory_history.sql | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/migrations/005_memory_history.sql b/migrations/005_memory_history.sql index dc5a05e..b52c185 100644 --- a/migrations/005_memory_history.sql +++ b/migrations/005_memory_history.sql @@ -88,3 +88,15 @@ CREATE TABLE IF NOT EXISTS hiveshare_snapshot_entries ( CREATE INDEX IF NOT EXISTS hiveshare_snapshots_hs_idx ON hiveshare_snapshots (hiveshare_id, created_at DESC); + +-- ── Backfill existing entries ──────────────────────────────────────────────── + +INSERT INTO memory_entries_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 memory_entries +WHERE NOT EXISTS ( + SELECT 1 FROM memory_entries_history WHERE entry_id = memory_entries.id +); From bbd572b931b42f6148c8781cc2a781beb61ee11d Mon Sep 17 00:00:00 2001 From: tyraziel Date: Thu, 23 Jul 2026 15:25:21 -0400 Subject: [PATCH 3/5] test(integration)/feat: add dual-framework test suites, support CONTAINER_RUNTIME Add pytest (tests/) and bash/curl (scripts/smoke-test-*.sh) integration tests with equivalent coverage across auth, hiveshare, memory, metrics, history, and infrastructure subsystems. Bash harness auto-discovers smoke-test-*.sh scripts. Both frameworks use unique timestamped users for re-runnability without a fresh database. Add make targets: smoke-test, smoke-test-full, integration-test, and dev-clean. Support CONTAINER_RUNTIME variable (default docker) for podman users. Migrate target falls back to container exec when psql is not installed locally. Assisted-by: Claude Code / Opus 4.6 (Anthropic) --- .gitignore | 4 + Makefile | 50 ++++++-- scripts/smoke-helpers.sh | 30 +++++ scripts/smoke-test-auth.sh | 51 ++++++++ scripts/smoke-test-full.sh | 53 ++++++++ scripts/smoke-test-history.sh | 149 +++++++++++++++++++++++ scripts/smoke-test-hiveshare.sh | 84 +++++++++++++ scripts/smoke-test-memory.sh | 107 ++++++++++++++++ scripts/smoke-test-metrics.sh | 45 +++++++ scripts/smoke-test.sh | 62 ++++++++++ tests/conftest.py | 75 ++++++++++++ tests/test_auth.py | 59 +++++++++ tests/test_history.py | 209 ++++++++++++++++++++++++++++++++ tests/test_hiveshares.py | 104 ++++++++++++++++ tests/test_infrastructure.py | 41 +++++++ tests/test_memory.py | 152 +++++++++++++++++++++++ tests/test_metrics.py | 36 ++++++ 17 files changed, 1304 insertions(+), 7 deletions(-) create mode 100755 scripts/smoke-helpers.sh create mode 100755 scripts/smoke-test-auth.sh create mode 100755 scripts/smoke-test-full.sh create mode 100755 scripts/smoke-test-history.sh create mode 100755 scripts/smoke-test-hiveshare.sh create mode 100755 scripts/smoke-test-memory.sh create mode 100755 scripts/smoke-test-metrics.sh create mode 100755 scripts/smoke-test.sh create mode 100644 tests/conftest.py create mode 100644 tests/test_auth.py create mode 100644 tests/test_history.py create mode 100644 tests/test_hiveshares.py create mode 100644 tests/test_infrastructure.py create mode 100644 tests/test_memory.py create mode 100644 tests/test_metrics.py 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/Makefile b/Makefile index 23c789f..408eeec 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 # ── Config ─────────────────────────────────────────────────────────────────── @@ -37,12 +38,25 @@ deps: POSTGRES_URL ?= postgres://hiveshare:hiveshare@localhost:5432/hiveshare?sslmode=disable +POSTGRES_CONTAINER ?= $(shell $(CONTAINER_RUNTIME) ps -q --filter ancestor=pgvector/pgvector:pg16 2>/dev/null) + 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 [ -n "$(POSTGRES_CONTAINER)" ]; then \ + for f in migrations/*.sql; do \ + echo " Running $$f (via container)..."; \ + $(CONTAINER_RUNTIME) exec -i $(POSTGRES_CONTAINER) \ + psql -U hiveshare -d hiveshare -f - < "$$f"; \ + done; \ + else \ + echo "Error: psql not found and no postgres container running"; \ + exit 1; \ + fi @echo "Migrations done." # ── Dev ─────────────────────────────────────────────────────────────────────── @@ -54,11 +68,16 @@ dev: docker-up @echo "Starting server..." EMBED_PROVIDER= go run ./cmd/server +CONTAINER_RUNTIME ?= docker + 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 # ── Install CLI ─────────────────────────────────────────────────────────────── @@ -93,6 +112,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 +146,9 @@ 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 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/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..8229ca6 --- /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/memory" \ + -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/memory/$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/memory/$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/memory/$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/memory/$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/memory" \ + -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/memory/$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/memory/$ENTRY2_ID" -H "$AUTH") +smoke_check "$GET_AFTER_DEL" "404" "deleted entry returns 404" + +DEL_HIST=$(curl -sf "$SMOKE_BASE/hiveshares/$HS_ID/memory/$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/memory/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/memory/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-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-memory.sh b/scripts/smoke-test-memory.sh new file mode 100755 index 0000000..00a3c39 --- /dev/null +++ b/scripts/smoke-test-memory.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/memory" \ + -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/memory" \ + -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/memory" \ + -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/memory" -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/memory?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/memory/$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/memory/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/memory/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/memory/$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/memory" \ + -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/memory/$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-metrics.sh b/scripts/smoke-test-metrics.sh new file mode 100755 index 0000000..6af52d7 --- /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/memory" \ + -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/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..3111d09 --- /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 memory_entry(api_url, user_a, hiveshare_id): + """Create a memory entry in the test hiveshare.""" + resp = requests.post(f"{api_url}/hiveshares/{hiveshare_id}/memory", 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..2dc0402 --- /dev/null +++ b/tests/test_history.py @@ -0,0 +1,209 @@ +"""Integration tests for memory 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 + + +class TestEntryHistory: + """Per-entry history, rollback, and undelete.""" + + def test_create_generates_history(self, api_url, user_a, hiveshare_id, memory_entry): + resp = requests.get( + f"{api_url}/hiveshares/{hiveshare_id}/memory/{memory_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, memory_entry): + requests.put( + f"{api_url}/hiveshares/{hiveshare_id}/memory/{memory_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}/memory/{memory_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 + + def test_rollback_restores_content(self, api_url, user_a, hiveshare_id, memory_entry): + resp = requests.get( + f"{api_url}/hiveshares/{hiveshare_id}/memory/{memory_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}/memory/{memory_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 content for testing history" + + def test_delete_and_undelete(self, api_url, user_a, hiveshare_id): + create_resp = requests.post( + f"{api_url}/hiveshares/{hiveshare_id}/memory", + json={ + "source_type": "manual", + "source_ref": "delete-test", + "content": "Entry to be deleted and restored", + "tool": "manual", + "tags": [], + }, + headers=auth_header(user_a), timeout=TIMEOUT, + ) + create_resp.raise_for_status() + entry_id = create_resp.json()["id"] + + requests.delete( + f"{api_url}/hiveshares/{hiveshare_id}/memory/{entry_id}", + headers=auth_header(user_a), timeout=TIMEOUT, + ).raise_for_status() + + get_resp = requests.get( + f"{api_url}/hiveshares/{hiveshare_id}/memory/{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}/memory/{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}/memory/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 + + 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, memory_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}/memory/copy", + json={"entry_ids": [memory_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"] == memory_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..4a3b016 --- /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}/memory", + json={ + "source_type": "jira", + "source_ref": "TEST-100", + "content": "Test memory entry 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}/memory", + 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, memory_entry): + resp = requests.get( + f"{api_url}/hiveshares/{hiveshare_id}/memory", + 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}/memory?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, memory_entry): + resp = requests.get( + f"{api_url}/hiveshares/{hiveshare_id}/memory/{memory_entry['id']}", + headers=auth_header(user_a), timeout=TIMEOUT, + ) + assert resp.status_code == 200 + assert resp.json()["id"] == memory_entry["id"] + assert "content" in resp.json() + + def test_update_entry(self, api_url, user_a, hiveshare_id, memory_entry): + resp = requests.put( + f"{api_url}/hiveshares/{hiveshare_id}/memory/{memory_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}/memory", + 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}/memory", + 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}/memory/{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}/memory", + 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}/memory/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}/memory/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..596792e --- /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, memory_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 From be31e92adf36859c577749a14ef1a83e6f3c6dd6 Mon Sep 17 00:00:00 2001 From: tyraziel Date: Fri, 24 Jul 2026 11:33:53 -0400 Subject: [PATCH 4/5] docs(api): add API.md reference with verified curl examples Document all 24 endpoints with inputs, outputs, status codes, and curl examples. Add scripts/test-api-examples.sh to verify every example against the running server (92 checks). Add make psql target for interactive database access. Assisted-by: Claude Code / Opus 4.6 (Anthropic) --- API.md | 1212 ++++++++++++++++++++++++++++++++++ Makefile | 12 +- scripts/test-api-examples.sh | 381 +++++++++++ 3 files changed, 1601 insertions(+), 4 deletions(-) create mode 100644 API.md create mode 100755 scripts/test-api-examples.sh diff --git a/API.md b/API.md new file mode 100644 index 0000000..4dadea1 --- /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) +- [Memory](#memory) + - [POST /api/v1/hiveshares/{id}/memory](#post-create-entry) + - [GET /api/v1/hiveshares/{id}/memory](#get-list-entries) + - [GET /api/v1/hiveshares/{id}/memory/{entryId}](#get-entry) + - [PUT /api/v1/hiveshares/{id}/memory/{entryId}](#put-update-entry) + - [DELETE /api/v1/hiveshares/{id}/memory/{entryId}](#delete-entry) + - [POST /api/v1/hiveshares/{id}/memory/search](#post-search) + - [POST /api/v1/hiveshares/{id}/memory/copy](#post-copy-entries) +- [History](#history) + - [GET /api/v1/hiveshares/{id}/memory/{entryId}/history](#get-history) + - [POST /api/v1/hiveshares/{id}/memory/{entryId}/rollback](#post-rollback) + - [POST /api/v1/hiveshares/{id}/memory/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" +``` + +--- + +## Memory + +### POST Create Entry + +`POST /api/v1/hiveshares/{id}/memory` + +**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/memory \ + -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}/memory` + +**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/memory?source_type=jira&limit=10" \ + -H "Authorization: Bearer hvs_YOUR_API_KEY" +``` + +--- + +### GET Entry + +`GET /api/v1/hiveshares/{id}/memory/{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/memory/ENTRY_ID \ + -H "Authorization: Bearer hvs_YOUR_API_KEY" +``` + +--- + +### PUT Update Entry + +`PUT /api/v1/hiveshares/{id}/memory/{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/memory/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}/memory/{entryId}` + +**Auth:** Required +**Access:** CanWrite + +**Response:** `204 No Content` + +**Example:** +```bash +curl -X DELETE http://localhost:8080/api/v1/hiveshares/HIVESHARE_ID/memory/ENTRY_ID \ + -H "Authorization: Bearer hvs_YOUR_API_KEY" +``` + +--- + +### POST Search + +`POST /api/v1/hiveshares/{id}/memory/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/memory/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}/memory/copy` + +**Auth:** Required +**Access:** CanWrite on target hiveshare; CanView on source hiveshare(s) + +Copies memory entries (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/memory/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}/memory/{entryId}/history` + +**Auth:** Required +**Access:** CanView + +Returns version history for a memory entry, 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/memory/ENTRY_ID/history?limit=10" \ + -H "Authorization: Bearer hvs_YOUR_API_KEY" +``` + +--- + +### POST Rollback + +`POST /api/v1/hiveshares/{id}/memory/{entryId}/rollback` + +**Auth:** Required +**Access:** CanWrite + +Restores a memory entry 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/memory/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}/memory/undelete` + +**Auth:** Required +**Access:** CanWrite + +Restores a deleted memory entry 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/memory/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 memory entries 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 | +| `memory_added` | Full memory entry | Entry created | +| `memory_updated` | Full memory entry | Entry updated | +| `memory_rolled_back` | Full memory entry | Entry rolled back | +| `memory_undeleted` | Full memory entry | 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 408eeec..88f8d37 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ .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 + smoke-test smoke-test-full integration-test psql # ── Config ─────────────────────────────────────────────────────────────────── @@ -9,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 ───────────────────────────────────────────────────────────────────── @@ -38,7 +39,7 @@ deps: POSTGRES_URL ?= postgres://hiveshare:hiveshare@localhost:5432/hiveshare?sslmode=disable -POSTGRES_CONTAINER ?= $(shell $(CONTAINER_RUNTIME) ps -q --filter ancestor=pgvector/pgvector:pg16 2>/dev/null) +POSTGRES_CONTAINER ?= $(shell $(CONTAINER_RUNTIME) compose ps --format '{{.Names}}' 2>/dev/null | grep hiveshare_postgres | head -1) migrate: @echo "Applying migrations..." @@ -68,8 +69,6 @@ dev: docker-up @echo "Starting server..." EMBED_PROVIDER= go run ./cmd/server -CONTAINER_RUNTIME ?= docker - docker-up: $(CONTAINER_RUNTIME) compose up -d @@ -79,6 +78,10 @@ docker-down: dev-clean: $(CONTAINER_RUNTIME) compose down -v +psql: + @echo "**INFO**: Found Container '$(POSTGRES_CONTAINER)' using it to '$(CONTAINER_RUNTIME) exec' for a psql prompt" + $(CONTAINER_RUNTIME) exec -it $(POSTGRES_CONTAINER) psql -U hiveshare -d hiveshare + # ── Install CLI ─────────────────────────────────────────────────────────────── install: cli @@ -147,6 +150,7 @@ help: @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" diff --git a/scripts/test-api-examples.sh b/scripts/test-api-examples.sh new file mode 100755 index 0000000..8852676 --- /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/memory" \ + -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}/memory" +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/memory" \ + -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/memory" \ + -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/memory?source_type=jira&limit=10" -H "$AUTH_A") +check_code "$CODE" "200" "GET /hiveshares/{id}/memory" +[ "$(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/memory/$ENTRY_ID" -H "$AUTH_A") +check_code "$CODE" "200" "GET /hiveshares/{id}/memory/{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/memory/$ENTRY_ID" \ + -H "$AUTH_A" -H "Content-Type: application/json" \ + -d '{"content":"Updated analysis...","tags":["auth","updated"]}') +check_code "$CODE" "200" "PUT /hiveshares/{id}/memory/{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/memory/search" \ + -H "$AUTH_A" -H "Content-Type: application/json" \ + -d '{"query":"auth refactor","limit":5}') +check_code "$CODE" "200" "POST /hiveshares/{id}/memory/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/memory/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/memory/$ENTRY_ID/history?limit=10" -H "$AUTH_A") +check_code "$CODE" "200" "GET /memory/{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/memory/$ENTRY_ID/rollback" \ + -H "$AUTH_A" -H "Content-Type: application/json" \ + -d "{\"history_id\":$HIST_ID}") +check_code "$CODE" "200" "POST /memory/{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/memory" \ + -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/memory/$ENTRY2_ID" -H "$AUTH_A" > /dev/null + +CODE=$(curl -s -o /dev/null -w "%{http_code}" "$API/hiveshares/$HS_ID/memory/$ENTRY2_ID" -H "$AUTH_A") +check_code "$CODE" "404" "deleted entry returns 404" + +DEL_HIST=$(curl -s "$API/hiveshares/$HS_ID/memory/$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/memory/undelete" \ + -H "$AUTH_A" -H "Content-Type: application/json" \ + -d "{\"history_id\":$DEL_HIST_ID}") +check_code "$CODE" "201" "POST /memory/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/memory/copy" \ + -H "$AUTH_A" -H "Content-Type: application/json" \ + -d "{\"entry_ids\":[\"$ENTRY_ID\"]}") +check_code "$CODE" "201" "POST /hiveshares/{id}/memory/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/memory/$ENTRY2_ID" -H "$AUTH_A") +check_code "$CODE" "204" "DELETE /hiveshares/{id}/memory/{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 From 84b89742a6ed37f196945fb581d71f490d163fef Mon Sep 17 00:00:00 2001 From: Sagar Paul Date: Sat, 25 Jul 2026 16:36:50 +0530 Subject: [PATCH 5/5] So I renamed memory as hive which is fixed here --- Makefile | 16 ++-- internal/api/memory.go | 33 +++++--- internal/models/models.go | 15 ++++ internal/store/errors.go | 19 +++++ internal/store/history.go | 36 +++++---- migrations/006_hive_history.sql | 4 +- ...oke-test-memory.sh => smoke-test-hives.sh} | 0 tests/test_history.py | 76 ++++++++++++++----- 8 files changed, 145 insertions(+), 54 deletions(-) create mode 100644 internal/store/errors.go rename scripts/{smoke-test-memory.sh => smoke-test-hives.sh} (100%) diff --git a/Makefile b/Makefile index 88f8d37..7650dbb 100644 --- a/Makefile +++ b/Makefile @@ -38,8 +38,8 @@ deps: # ── Database ───────────────────────────────────────────────────────────────── POSTGRES_URL ?= postgres://hiveshare:hiveshare@localhost:5432/hiveshare?sslmode=disable - -POSTGRES_CONTAINER ?= $(shell $(CONTAINER_RUNTIME) compose ps --format '{{.Names}}' 2>/dev/null | grep hiveshare_postgres | head -1) +# Compose service name (stable across project prefixes / renamed containers). +POSTGRES_SERVICE ?= postgres migrate: @echo "Applying migrations..." @@ -48,14 +48,14 @@ migrate: echo " Running $$f..."; \ psql "$(POSTGRES_URL)" -f "$$f"; \ done; \ - elif [ -n "$(POSTGRES_CONTAINER)" ]; then \ + elif $(CONTAINER_RUNTIME) compose ps --status running $(POSTGRES_SERVICE) >/dev/null 2>&1; then \ for f in migrations/*.sql; do \ - echo " Running $$f (via container)..."; \ - $(CONTAINER_RUNTIME) exec -i $(POSTGRES_CONTAINER) \ + 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 no postgres container running"; \ + echo "Error: psql not found and compose service '$(POSTGRES_SERVICE)' is not running"; \ exit 1; \ fi @echo "Migrations done." @@ -79,8 +79,8 @@ dev-clean: $(CONTAINER_RUNTIME) compose down -v psql: - @echo "**INFO**: Found Container '$(POSTGRES_CONTAINER)' using it to '$(CONTAINER_RUNTIME) exec' for a psql prompt" - $(CONTAINER_RUNTIME) exec -it $(POSTGRES_CONTAINER) psql -U hiveshare -d hiveshare + @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 ─────────────────────────────────────────────────────────────── diff --git a/internal/api/memory.go b/internal/api/memory.go index f0b9221..7e9a632 100644 --- a/internal/api/memory.go +++ b/internal/api/memory.go @@ -8,18 +8,13 @@ import ( "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 @@ -155,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 } @@ -392,7 +387,7 @@ func (h *HiveHandler) Rollback(w http.ResponseWriter, r *http.Request) { } entry, hasEmb, err := h.history.Rollback(r.Context(), entryID, hsID, req.HistoryID) if err != nil { - writeError(w, http.StatusNotFound, "rollback failed: "+err.Error()) + writeHistoryErr(w, "rollback failed", err) return } if !hasEmb { @@ -427,7 +422,7 @@ func (h *HiveHandler) Undelete(w http.ResponseWriter, r *http.Request) { } entry, hasEmb, err := h.history.Undelete(r.Context(), req.HistoryID, hsID) if err != nil { - writeError(w, http.StatusNotFound, "undelete failed: "+err.Error()) + writeHistoryErr(w, "undelete failed", err) return } if !hasEmb { @@ -469,6 +464,10 @@ func (h *HiveHandler) CreateSnapshot(w http.ResponseWriter, r *http.Request) { } 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 } @@ -526,13 +525,16 @@ func (h *HiveHandler) RestoreSnapshot(w http.ResponseWriter, r *http.Request) { var req struct { Name string `json:"name"` } - decodeJSON(r, &req) + 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 { - writeError(w, http.StatusNotFound, "restore failed: "+err.Error()) + writeHistoryErr(w, "restore failed", err) return } for _, id := range result.NullEmbeddings { @@ -611,3 +613,12 @@ func (h *HiveHandler) CopyEntries(w http.ResponseWriter, r *http.Request) { }) 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/models/models.go b/internal/models/models.go index d7850e1..30c74b6 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -191,6 +191,21 @@ type Snapshot struct { 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 index 21e67fe..cd2f9a1 100644 --- a/internal/store/history.go +++ b/internal/store/history.go @@ -8,15 +8,11 @@ import ( "github.com/google/uuid" "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgxpool" "github.com/pgvector/pgvector-go" "github.com/KB-perByte/hiveshare/internal/models" ) -// ErrForbidden is returned when the caller lacks membership on a source hiveshare. -var ErrForbidden = errors.New("forbidden") - type HistoryStore struct { db *pgxpool.Pool } @@ -25,11 +21,6 @@ func NewHistoryStore(db *pgxpool.Pool) *HistoryStore { return &HistoryStore{db: db} } -func isUniqueViolation(err error) bool { - var pgErr *pgconn.PgError - return errors.As(err, &pgErr) && pgErr.Code == "23505" -} - // ── Per-entry history ──────────────────────────────────────────────────────── func (s *HistoryStore) ListVersions(ctx context.Context, entryID, hiveshareID uuid.UUID, limit, offset int) ([]*models.HistoryEntry, error) { @@ -127,7 +118,7 @@ func (s *HistoryStore) Undelete(ctx context.Context, historyID int64, hiveshareI if err == nil { return &e, hasEmbedding, nil } - if n >= 9 || !isUniqueViolation(err) { + if n >= 9 || !IsUniqueViolation(err) { return nil, false, fmt.Errorf("undelete entry: %w", err) } } @@ -168,7 +159,21 @@ func (s *HistoryStore) PurgeByCount(ctx context.Context, maxVersions int) (int64 // ── 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 @@ -233,7 +238,7 @@ func (s *HistoryStore) ListSnapshots(ctx context.Context, hiveshareID uuid.UUID) return result, rows.Err() } -func (s *HistoryStore) GetSnapshot(ctx context.Context, snapshotID int64, hiveshareID uuid.UUID) (*models.Snapshot, []*models.HistoryEntry, error) { +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, @@ -259,9 +264,9 @@ func (s *HistoryStore) GetSnapshot(ctx context.Context, snapshotID int64, hivesh } defer rows.Close() - var entries []*models.HistoryEntry + var entries []*models.SnapshotEntry for rows.Next() { - e := &models.HistoryEntry{HiveshareID: snap.HiveshareID} + 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 @@ -370,6 +375,9 @@ type CopyResult struct { 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 @@ -439,7 +447,7 @@ func (s *HistoryStore) CopyEntries(ctx context.Context, targetHiveshareID, userI if err == nil { break } - if n >= 9 || !isUniqueViolation(err) { + if n >= 9 || !IsUniqueViolation(err) { return nil, fmt.Errorf("copy entry %s: %w", eid, err) } } diff --git a/migrations/006_hive_history.sql b/migrations/006_hive_history.sql index dad07b4..1870a82 100644 --- a/migrations/006_hive_history.sql +++ b/migrations/006_hive_history.sql @@ -55,9 +55,11 @@ BEGIN 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, embedding OR DELETE + AFTER INSERT OR UPDATE OF content, summary, tags, metadata OR DELETE ON hives FOR EACH ROW EXECUTE FUNCTION record_hive_history(); diff --git a/scripts/smoke-test-memory.sh b/scripts/smoke-test-hives.sh similarity index 100% rename from scripts/smoke-test-memory.sh rename to scripts/smoke-test-hives.sh diff --git a/tests/test_history.py b/tests/test_history.py index 72fc7f7..914c451 100644 --- a/tests/test_history.py +++ b/tests/test_history.py @@ -1,4 +1,4 @@ -"""Integration tests for memory history, snapshots, rollback, and copy. +"""Integration tests for hive history, snapshots, rollback, and copy. Run: pytest tests/ -v Requires: make dev (server + postgres + redis running) @@ -13,6 +13,25 @@ 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.""" @@ -26,25 +45,46 @@ def test_create_generates_history(self, api_url, user_a, hiveshare_id, hive_entr assert len(versions) >= 1 assert versions[-1]["action"] == "insert" - def test_update_generates_history(self, api_url, user_a, hiveshare_id, hive_entry): + 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/{hive_entry['id']}", + 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/{hive_entry['id']}/history", + 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() - def test_rollback_restores_content(self, api_url, user_a, hiveshare_id, hive_entry): resp = requests.get( - f"{api_url}/hiveshares/{hiveshare_id}/hives/{hive_entry['id']}/history", + f"{api_url}/hiveshares/{hiveshare_id}/hives/{entry['id']}/history", headers=auth_header(user_a), timeout=TIMEOUT, ) resp.raise_for_status() @@ -52,28 +92,21 @@ def test_rollback_restores_content(self, api_url, user_a, hiveshare_id, hive_ent insert_version = [v for v in versions if v["action"] == "insert"][-1] resp = requests.post( - f"{api_url}/hiveshares/{hiveshare_id}/hives/{hive_entry['id']}/rollback", + 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 content for testing history" + assert restored["content"] == original def test_delete_and_undelete(self, api_url, user_a, hiveshare_id): - create_resp = requests.post( - f"{api_url}/hiveshares/{hiveshare_id}/hives", - json={ - "source_type": "manual", - "source_ref": "delete-test", - "content": "Entry to be deleted and restored", - "tool": "manual", - "tags": [], - }, - headers=auth_header(user_a), timeout=TIMEOUT, + entry = _create_hive( + api_url, user_a, hiveshare_id, + source_ref=f"delete-test-{time.time_ns()}", + content="Entry to be deleted and restored", ) - create_resp.raise_for_status() - entry_id = create_resp.json()["id"] + entry_id = entry["id"] requests.delete( f"{api_url}/hiveshares/{hiveshare_id}/hives/{entry_id}", @@ -147,6 +180,9 @@ def test_get_snapshot_detail(self, api_url, user_a, hiveshare_id): 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(