Files
foundry-core/internal/research/postgres_integration_test.go

187 lines
6.9 KiB
Go

package research
import (
"context"
"database/sql"
"encoding/json"
"errors"
"net/http"
"os"
"reflect"
"testing"
"time"
_ "github.com/jackc/pgx/v5/stdlib"
)
func integrationRepository(t *testing.T) (*PostgresRepository, context.Context) {
t.Helper()
dsn := os.Getenv("RESEARCH_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("requires the disposable research fixture database")
}
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Close() })
db.SetMaxOpenConns(2)
repository, err := NewPostgresRepository(db, "research_fixture")
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
t.Cleanup(cancel)
return repository, ctx
}
func paperIDs(papers []Paper) []string {
ids := []string{}
for _, paper := range papers {
ids = append(ids, paper.ID)
}
return ids
}
func TestPostgresPageSearchAndFilters(t *testing.T) {
r, ctx := integrationRepository(t)
if err := r.Check(ctx); err != nil {
t.Fatal(err)
}
for _, test := range []struct {
filters Filters
total int64
ids []string
}{
{Filters{Page: 1, PageSize: 2}, 5, []string{"5", "4"}},
{Filters{Page: 2, PageSize: 2}, 5, []string{"3", "2"}},
{Filters{Page: 3, PageSize: 2}, 5, []string{"1"}},
{Filters{Page: 1, PageSize: 2, Sort: "title"}, 5, []string{"2", "5"}},
{Filters{Page: 1, PageSize: 8, Query: "graph"}, 2, []string{"3", "1"}},
{Filters{Page: 1, PageSize: 8, Query: "graph OR zebra"}, 0, []string{}},
{Filters{Page: 1, PageSize: 8, Query: "Ada Smith", SearchIn: "author"}, 1, []string{"1"}},
{Filters{Page: 1, PageSize: 8, Topic: "cs.LG"}, 1, []string{"1"}},
{Filters{Page: 1, PageSize: 8, Topic: "cs.DC", Query: "graph"}, 1, []string{"3"}},
{Filters{Page: 1, PageSize: 8, Query: `x'); DROP TABLE papers;--`}, 0, []string{}},
} {
page, err := r.List(ctx, test.filters)
if err != nil {
t.Fatalf("%+v: %v", test.filters, err)
}
if page.Total != test.total || !reflect.DeepEqual(paperIDs(page.Items), test.ids) {
t.Fatalf("%+v: total=%d ids=%v", test.filters, page.Total, paperIDs(page.Items))
}
if !reflect.DeepEqual(page.Topics, []string{"cs.AI", "cs.DC", "cs.LG"}) {
t.Fatalf("mixed tag namespaces: %v", page.Topics)
}
if page.Page != test.filters.Page || page.PageSize != test.filters.PageSize {
t.Fatal("lost paging metadata")
}
}
}
func TestPostgresMetadataPreservesEvidence(t *testing.T) {
r, ctx := integrationRepository(t)
p, err := r.Get(ctx, 1)
if err != nil {
t.Fatal(err)
}
if p.Title != "Graph & learning <original>" || p.Abstract == nil || *p.Abstract != "Neural graph models preserve original abstracts." || !reflect.DeepEqual(p.Authors, []string{"Ada Smith", "The Research Group"}) {
t.Fatalf("original content changed: %+v", p)
}
if !reflect.DeepEqual(p.Topics, []string{"cs.AI", "cs.LG"}) || len(p.Sources) != 1 || p.Sources[0].ExternalID != "fixture-1" || p.Sources[0].Label != "arXiv" {
t.Fatalf("incorrect source/tag joins: %+v", p)
}
if p.DOI == nil || *p.DOI != "10.fixture/canonical" || p.Journal == nil || *p.Journal != "Journal Fixture" || p.Comments == nil || *p.Comments != "Original comment" {
t.Fatalf("metadata missing: %+v", p)
}
if len(p.Publications) != 1 || p.Publications[0].Status != "withdrawn" || p.Publications[0].EvidenceURL != "https://example.test/decision/3" {
t.Fatalf("latest decision ordering changed: %+v", p.Publications)
}
p, err = r.Get(ctx, 3)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(p.Authors, []string{"Carla Network"}) || len(p.Sources) != 2 || p.DOI == nil || *p.DOI != "10.fixture/other" || len(p.Publications) != 1 || p.Publications[0].Status != "unknown" || p.Publications[0].EvidenceURL != "" {
t.Fatalf("unknown decision/source mapping: %+v", p)
}
p, err = r.Get(ctx, 2)
if err != nil || p.Abstract != nil || p.PublishedAt != nil || p.Sources == nil || p.Topics == nil {
t.Fatalf("nullable data changed: %+v %v", p, err)
}
if _, err = r.Get(ctx, 99999); !errors.Is(err, sql.ErrNoRows) {
t.Fatalf("missing paper: %v", err)
}
}
func TestPostgresCorpusIsReadOnlyAndHTTPWorks(t *testing.T) {
r, ctx := integrationRepository(t)
tx, err := r.begin(ctx)
if err != nil {
t.Fatal(err)
}
var readOnly, isolation, jit string
if err = tx.QueryRowContext(ctx, "SHOW transaction_read_only").Scan(&readOnly); err != nil {
t.Fatal(err)
}
if err = tx.QueryRowContext(ctx, "SHOW transaction_isolation").Scan(&isolation); err != nil {
t.Fatal(err)
}
if err = tx.QueryRowContext(ctx, "SHOW jit").Scan(&jit); err != nil {
t.Fatal(err)
}
_ = tx.Rollback()
if readOnly != "on" || isolation != "repeatable read" || jit != "off" {
t.Fatalf("unexpected read transaction settings: %s %s jit=%s", readOnly, isolation, jit)
}
_, err = r.db.ExecContext(ctx, `UPDATE research_fixture.papers SET title='unexpected write' WHERE id=1`)
var state interface{ SQLState() string }
if !errors.As(err, &state) || state.SQLState() != "42501" {
t.Fatalf("expected database permission denial, got %v", err)
}
mux := http.NewServeMux()
Register(mux, r, map[string]string{"thesisly": "http://localhost:17322/auth/callback"})
w := request(mux, "GET", "/projects/thesisly/research/papers?q=graph")
var page Page
if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &page) != nil || page.Total != 2 {
t.Fatalf("SQL-backed HTTP list: %d %s", w.Code, w.Body.String())
}
w = request(mux, "GET", "/projects/thesisly/research/papers/1")
var paper Paper
if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &paper) != nil || paper.ID != "1" {
t.Fatalf("SQL-backed HTTP detail: %d %s", w.Code, w.Body.String())
}
if w = request(mux, "GET", "/projects/thesisly/research/papers/99999"); w.Code != 404 {
t.Fatalf("missing HTTP status %d", w.Code)
}
}
func TestPostgresPublishedTextCodec(t *testing.T) {
legacy, ctx := integrationRepository(t)
r, err := NewPostgresRepository(legacy.db, "research_encoded_fixture")
if err != nil {
t.Fatal(err)
}
if err = r.Check(ctx); err != nil {
t.Fatal(err)
}
p, err := r.Get(ctx, 1)
if err != nil {
t.Fatal(err)
}
if p.Title != "Graph\x00original\x01" || p.Abstract == nil || *p.Abstract != "Text\x00tail" || !reflect.DeepEqual(p.Authors, []string{"Ada\x00Smith", "Group\x01Name"}) || p.Comments == nil || *p.Comments != "Original\x00comment" {
t.Fatalf("published text was not restored: %+v", p)
}
mux := http.NewServeMux()
Register(mux, r, map[string]string{"thesisly": "http://localhost:17322/auth/callback"})
w := request(mux, "GET", "/projects/thesisly/research/papers/1")
var decoded Paper
if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &decoded) != nil || decoded.Title != p.Title || decoded.Abstract == nil || *decoded.Abstract != *p.Abstract {
t.Fatalf("HTTP lost original control characters: %d %s", w.Code, w.Body.String())
}
page, err := r.List(ctx, Filters{Page: 1, PageSize: 8, Query: "graph"})
if err != nil || page.Total != 2 || len(page.Items) != 2 || page.Items[1].Title != p.Title {
t.Fatalf("encoded corpus search: %+v %v", page, err)
}
}