owpengram-server/internal/app/files/backend_switch_test.go
2026-08-05 02:34:24 +03:00

173 lines
6.8 KiB
Go

package files
import (
"context"
"os"
"testing"
"telesrv/internal/domain"
)
// fakeNamedBackend is a minimal BlobBackend double with a settable Name(),
// used to prove Service.backendFor's routing logic without needing two real
// backend implementations (LocalFS always reports "localfs").
type fakeNamedBackend struct {
BlobBackend
name string
}
func (f fakeNamedBackend) Name() string { return f.name }
// TestBackendForRoutesToTheBackendThatActuallyWroteTheBlob proves the core
// safety property behind switching TELESRV_BLOB_BACKEND: a row whose
// recorded backend differs from the currently active one must still
// resolve to the backend that actually holds its bytes (via
// WithAdditionalBlobBackend), not silently fall through to the active one
// (which would 404 -- the row/bytes still exist, but the code would be
// looking in the wrong place).
func TestBackendForRoutesToTheBackendThatActuallyWroteTheBlob(t *testing.T) {
active := fakeNamedBackend{name: "s3"}
old := fakeNamedBackend{name: "localfs"}
s := NewService(nil, active, 2, WithAdditionalBlobBackend(old))
got, err := s.backendFor(domain.MediaBackendS3)
if err != nil || got.Name() != "s3" {
t.Fatalf("resolve active backend: got %v err=%v, want s3", got, err)
}
got, err = s.backendFor(domain.MediaBackendLocalFS)
if err != nil || got.Name() != "localfs" {
t.Fatalf("resolve registered old backend: got %v err=%v, want localfs", got, err)
}
// A row predating the owner/backend column (empty string) must not be
// treated as unreachable -- it falls back to the active backend, which
// is where every pre-migration row's bytes actually are.
got, err = s.backendFor("")
if err != nil || got.Name() != "s3" {
t.Fatalf("resolve empty/legacy backend: got %v err=%v, want active s3", got, err)
}
// A backend that was never configured (e.g. its config was wiped after
// switching away from it) must fail loudly, not silently read from the
// wrong place.
if _, err := s.backendFor("gcs"); err == nil {
t.Fatal("expected an error resolving an unconfigured backend, got nil")
}
}
// TestServiceGetFileReadsFromRegisteredOldBackendAfterSwitch is the
// end-to-end version: a Service whose active backend is A must still serve
// a file whose file_blobs row says it lives on B, as long as B was
// registered via WithAdditionalBlobBackend -- exactly the "I switched
// TELESRV_BLOB_BACKEND, did my old files disappear?" scenario.
func TestServiceGetFileReadsFromRegisteredOldBackendAfterSwitch(t *testing.T) {
ctx := context.Background()
oldBackend, err := NewLocalFS(t.TempDir())
if err != nil {
t.Fatalf("new old backend: %v", err)
}
newBackend, err := NewLocalFS(t.TempDir())
if err != nil {
t.Fatalf("new active backend: %v", err)
}
// Give the "old" backend a distinct name so backendFor can tell them
// apart, the way a real second BlobBackend implementation would.
oldNamed := fakeNamedBackend{BlobBackend: oldBackend, name: "old-backend"}
data := []byte("file written before the backend switch")
objectKey, err := oldBackend.Put(ctx, data)
if err != nil {
t.Fatalf("write to old backend: %v", err)
}
media := newFakeMediaStore()
if err := media.PutFileBlob(ctx, domain.FileBlob{
LocationKey: "doc:1",
Backend: "old-backend",
ObjectKey: objectKey,
Size: int64(len(data)),
}); err != nil {
t.Fatalf("put file blob metadata: %v", err)
}
// Active backend is the NEW one; the old one is only reachable via the
// registry -- mirrors production after a TELESRV_BLOB_BACKEND switch.
s := NewService(media, newBackend, 2, WithAdditionalBlobBackend(oldNamed))
chunk, found, err := s.GetFile(ctx, domain.FileDownloadRequest{LocationKey: "doc:1", Limit: 1 << 20})
if err != nil {
t.Fatalf("get file after backend switch: %v", err)
}
if !found {
t.Fatal("file written before the switch was not found -- it would appear to have vanished")
}
if string(chunk.Bytes) != string(data) {
t.Fatalf("chunk = %q, want %q", chunk.Bytes, data)
}
}
// TestBackendSwitchRoundTripsThroughRealMinIO exercises the actual
// scenario the user asked about: files written while s3 (MinIO) was active
// stay readable after switching to localfs, and files written while
// localfs was active stay readable after switching to s3 -- both directions,
// against a real MinIO instance (deploy/docker-compose.yml's "minio"
// service). Skips if MinIO isn't reachable at the default dev endpoint.
func TestBackendSwitchRoundTripsThroughRealMinIO(t *testing.T) {
endpoint := os.Getenv("TELESRV_TEST_S3_ENDPOINT")
if endpoint == "" {
endpoint = "localhost:9000"
}
ctx := context.Background()
s3, err := NewS3FS(ctx, endpoint, "owpengram", "owpengram123", "telesrv-test-backend-switch", "us-east-1", false, true)
if err != nil {
t.Skipf("minio not reachable at %s (start deploy/docker-compose.yml's minio service to run this test): %v", endpoint, err)
}
local, err := NewLocalFS(t.TempDir())
if err != nil {
t.Fatalf("new local backend: %v", err)
}
t.Run("s3_active_localfs_switch", func(t *testing.T) {
s3Data := []byte("written while s3 was the active backend")
s3Key, err := s3.Put(ctx, s3Data)
if err != nil {
t.Fatalf("write to s3: %v", err)
}
t.Cleanup(func() { _ = s3.Delete(ctx, s3Key) })
media := newFakeMediaStore()
if err := media.PutFileBlob(ctx, domain.FileBlob{
LocationKey: "doc:1", Backend: domain.MediaBackendS3, ObjectKey: s3Key, Size: int64(len(s3Data)),
}); err != nil {
t.Fatalf("put file blob: %v", err)
}
// Switched: localfs is now active, s3 kept reachable as the old backend.
s := NewService(media, local, 2, WithAdditionalBlobBackend(s3))
chunk, found, err := s.GetFile(ctx, domain.FileDownloadRequest{LocationKey: "doc:1", Limit: 1 << 20})
if err != nil || !found || string(chunk.Bytes) != string(s3Data) {
t.Fatalf("read s3-written file after switching to localfs: found=%v err=%v bytes=%q, want %q", found, err, chunk.Bytes, s3Data)
}
})
t.Run("localfs_active_s3_switch", func(t *testing.T) {
localData := []byte("written while localfs was the active backend")
localKey, err := local.Put(ctx, localData)
if err != nil {
t.Fatalf("write to localfs: %v", err)
}
media := newFakeMediaStore()
if err := media.PutFileBlob(ctx, domain.FileBlob{
LocationKey: "doc:2", Backend: domain.MediaBackendLocalFS, ObjectKey: localKey, Size: int64(len(localData)),
}); err != nil {
t.Fatalf("put file blob: %v", err)
}
// Switched: s3 is now active, localfs kept reachable as the old backend.
s := NewService(media, s3, 2, WithAdditionalBlobBackend(local))
chunk, found, err := s.GetFile(ctx, domain.FileDownloadRequest{LocationKey: "doc:2", Limit: 1 << 20})
if err != nil || !found || string(chunk.Bytes) != string(localData) {
t.Fatalf("read localfs-written file after switching to s3: found=%v err=%v bytes=%q, want %q", found, err, chunk.Bytes, localData)
}
})
}