Renames: - platform/ → workspace-server/ (Go module path stays as "platform" for external dep compat — will update after plugin module republish) - workspace-template/ → workspace/ Removed (moved to separate repos or deleted): - PLAN.md — internal roadmap (move to private project board) - HANDOFF.md, AGENTS.md — one-time internal session docs - .claude/ — gitignored entirely (local agent config) - infra/cloudflare-worker/ → Molecule-AI/molecule-tenant-proxy - org-templates/molecule-dev/ → standalone template repo - .mcp-eval/ → molecule-mcp-server repo - test-results/ — ephemeral, gitignored Security scrubbing: - Cloudflare account/zone/KV IDs → placeholders - Real EC2 IPs → <EC2_IP> in all docs - CF token prefix, Neon project ID, Fly app names → redacted - Langfuse dev credentials → parameterized - Personal runner username/machine name → generic Community files: - CONTRIBUTING.md — build, test, branch conventions - CODE_OF_CONDUCT.md — Contributor Covenant 2.1 All Dockerfiles, CI workflows, docker-compose, railway.toml, render.yaml, README, CLAUDE.md updated for new directory names. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
148 lines
4.1 KiB
Go
148 lines
4.1 KiB
Go
package registry
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/DATA-DOG/go-sqlmock"
|
|
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
|
|
)
|
|
|
|
func setupHibernationMock(t *testing.T) sqlmock.Sqlmock {
|
|
t.Helper()
|
|
mockDB, mock, err := sqlmock.New()
|
|
if err != nil {
|
|
t.Fatalf("sqlmock.New: %v", err)
|
|
}
|
|
db.DB = mockDB
|
|
t.Cleanup(func() { mockDB.Close() })
|
|
return mock
|
|
}
|
|
|
|
// TestHibernateIdleWorkspaces_CallsHandlerForEachCandidate verifies that
|
|
// hibernateIdleWorkspaces calls onHibernate once for each workspace row
|
|
// returned by the DB query.
|
|
func TestHibernateIdleWorkspaces_CallsHandlerForEachCandidate(t *testing.T) {
|
|
mock := setupHibernationMock(t)
|
|
|
|
mock.ExpectQuery(`SELECT id FROM workspaces`).
|
|
WillReturnRows(sqlmock.NewRows([]string{"id"}).
|
|
AddRow("ws-idle-1").
|
|
AddRow("ws-idle-2"))
|
|
|
|
var called []string
|
|
hibernateIdleWorkspaces(context.Background(), func(ctx context.Context, id string) {
|
|
called = append(called, id)
|
|
})
|
|
|
|
if len(called) != 2 {
|
|
t.Fatalf("expected 2 hibernations, got %d: %v", len(called), called)
|
|
}
|
|
if called[0] != "ws-idle-1" || called[1] != "ws-idle-2" {
|
|
t.Errorf("unexpected IDs: %v", called)
|
|
}
|
|
if err := mock.ExpectationsWereMet(); err != nil {
|
|
t.Errorf("unmet expectations: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestHibernateIdleWorkspaces_NoRowsNoHandler verifies that no handler is
|
|
// called when the query returns zero rows (no idle workspaces).
|
|
func TestHibernateIdleWorkspaces_NoRowsNoHandler(t *testing.T) {
|
|
mock := setupHibernationMock(t)
|
|
|
|
mock.ExpectQuery(`SELECT id FROM workspaces`).
|
|
WillReturnRows(sqlmock.NewRows([]string{"id"})) // empty
|
|
|
|
var called int
|
|
hibernateIdleWorkspaces(context.Background(), func(_ context.Context, _ string) {
|
|
called++
|
|
})
|
|
|
|
if called != 0 {
|
|
t.Errorf("expected 0 hibernations, got %d", called)
|
|
}
|
|
if err := mock.ExpectationsWereMet(); err != nil {
|
|
t.Errorf("unmet expectations: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestHibernateIdleWorkspaces_DBErrorDoesNotPanic verifies that a DB error
|
|
// from the query is logged but does not crash the monitor loop.
|
|
func TestHibernateIdleWorkspaces_DBErrorDoesNotPanic(t *testing.T) {
|
|
mock := setupHibernationMock(t)
|
|
|
|
mock.ExpectQuery(`SELECT id FROM workspaces`).
|
|
WillReturnError(sql.ErrConnDone)
|
|
|
|
// Should not panic
|
|
hibernateIdleWorkspaces(context.Background(), func(_ context.Context, _ string) {
|
|
t.Error("handler should not be called on DB error")
|
|
})
|
|
|
|
if err := mock.ExpectationsWereMet(); err != nil {
|
|
t.Errorf("unmet expectations: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestStartHibernationMonitor_TicksAndCallsHandler verifies the monitor loop
|
|
// ticks at the configured interval and calls the handler.
|
|
func TestStartHibernationMonitor_TicksAndCallsHandler(t *testing.T) {
|
|
mock := setupHibernationMock(t)
|
|
|
|
// Expect at least one DB query (the first tick)
|
|
mock.ExpectQuery(`SELECT id FROM workspaces`).
|
|
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("ws-hibernate-me"))
|
|
|
|
var callCount int32
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
|
|
done := make(chan struct{})
|
|
go func() {
|
|
StartHibernationMonitorWithInterval(ctx, 50*time.Millisecond, func(_ context.Context, id string) {
|
|
if id == "ws-hibernate-me" {
|
|
atomic.AddInt32(&callCount, 1)
|
|
cancel() // stop after first hit
|
|
}
|
|
})
|
|
close(done)
|
|
}()
|
|
|
|
select {
|
|
case <-done:
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("monitor did not stop within timeout")
|
|
}
|
|
|
|
if atomic.LoadInt32(&callCount) == 0 {
|
|
t.Error("expected handler to be called at least once")
|
|
}
|
|
}
|
|
|
|
// TestStartHibernationMonitor_StopsOnContextCancel verifies clean shutdown
|
|
// when the context is cancelled before any tick fires.
|
|
func TestStartHibernationMonitor_StopsOnContextCancel(t *testing.T) {
|
|
_ = setupHibernationMock(t) // no DB calls expected
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel() // cancel immediately
|
|
|
|
done := make(chan struct{})
|
|
go func() {
|
|
// Very long interval — only context cancel should stop it
|
|
StartHibernationMonitorWithInterval(ctx, 10*time.Minute, func(_ context.Context, _ string) {
|
|
// should never be called
|
|
})
|
|
close(done)
|
|
}()
|
|
|
|
select {
|
|
case <-done:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("monitor did not stop on context cancel")
|
|
}
|
|
}
|