Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
280 changes: 280 additions & 0 deletions backend/on-this-day/main_integration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,280 @@
//go:build integration

package main

import (
"context"
"encoding/json"
"fmt"
"testing"
"time"

"epac/_testdb"
"github.com/aws/aws-lambda-go/events"
"github.com/jackc/pgx/v5"
)

// resetOnThisDaySpeeches deletes all rows from speeches to isolate each test.
func resetOnThisDaySpeeches(t *testing.T, conn *pgx.Conn) {
t.Helper()
if _, err := conn.Exec(context.Background(), "DELETE FROM speeches"); err != nil {
t.Fatalf("reset speeches: %v", err)
}
}

// seedSpeechOnDate inserts a speech row with the given sitting_date.
func seedSpeechOnDate(t *testing.T, conn *pgx.Conn, id string, sittingDate time.Time) {
t.Helper()
_testdb.SeedSpeech(t, conn, id,
"This is a speech about parliamentary procedures and governance in the House of Commons.",
"Test Speaker", "", "Test Subject", &sittingDate)
}

// mustParseDate parses "YYYY-MM-DD" or fatals.
func mustParseDate(t *testing.T, s string) time.Time {
t.Helper()
d, err := time.Parse("2006-01-02", s)
if err != nil {
t.Fatalf("parse date %q: %v", s, err)
}
return d
}

// callOnThisDaySQL calls queryOnThisDay directly against the test DB connection,
// bypassing the package-level dbConn and DATABASE_URL resolution.
func callOnThisDaySQL(t *testing.T, conn *pgx.Conn, dateStr string, limit int) ([]OnThisDayItem, error) {
t.Helper()
date, err := parseDate(dateStr)
if err != nil {
return nil, fmt.Errorf("parse date: %w", err)
}
return queryOnThisDay(context.Background(), conn, date, limit)
}

// callHandlerHTTP sends an APIGateway request through HandleRequest and returns the response.
// Use this only for tests that verify HTTP-layer behavior (status codes, JSON shape)
// where the date check happens before any DB access (e.g., 400 cases).
func callHandlerHTTP(t *testing.T, params map[string]string) events.APIGatewayProxyResponse {
t.Helper()
resp, err := HandleRequest(context.Background(), events.APIGatewayProxyRequest{
QueryStringParameters: params,
})
if err != nil {
t.Fatalf("HandleRequest returned unexpected error: %v", err)
}
return resp
}

// TestOnThisDayHappyPath_ReturnsMatchingSittings seeds speeches on March 15 across
// two prior years and unrelated dates, then asserts only March-15 records appear
// when querying date=2026-03-15.
func TestOnThisDayHappyPath_ReturnsMatchingSittings(t *testing.T) {
conn := _testdb.Connect(t)
resetOnThisDaySpeeches(t, conn)

// 3 speeches on 2010-03-15
for i := 0; i < 3; i++ {
seedSpeechOnDate(t, conn, fmt.Sprintf("march-2010-%d", i), mustParseDate(t, "2010-03-15"))
}
// 2 speeches on 2015-03-15
for i := 0; i < 2; i++ {
seedSpeechOnDate(t, conn, fmt.Sprintf("march-2015-%d", i), mustParseDate(t, "2015-03-15"))
}
// 5 speeches on other dates — none should appear in results
otherDates := []string{"2010-04-15", "2015-02-15", "2018-03-16", "2020-01-01", "2012-07-04"}
for i, d := range otherDates {
seedSpeechOnDate(t, conn, fmt.Sprintf("other-%d", i), mustParseDate(t, d))
}

items, err := callOnThisDaySQL(t, conn, "2026-03-15", 10)
if err != nil {
t.Fatalf("query failed: %v", err)
}

if len(items) != 5 {
t.Fatalf("got %d items, want 5 (3 from 2010-03-15 + 2 from 2015-03-15)", len(items))
}
for _, item := range items {
// Date field is formatted as "YYYY-MM-DD"; verify MM-DD is 03-15.
if len(item.Date) < 10 || item.Date[5:10] != "03-15" {
t.Errorf("item %q has date %q — want March 15", item.ID, item.Date)
}
}
}

// TestOnThisDayLeapYearFeb29 covers two leap-year sub-cases:
// - Speeches seeded on 2020-02-29 must be returned when querying 2024-02-29.
// - 2023-02-29 is not a valid calendar date; Go's time.Parse rejects it,
// so the handler returns HTTP 400 before touching the DB.
func TestOnThisDayLeapYearFeb29(t *testing.T) {
conn := _testdb.Connect(t)
resetOnThisDaySpeeches(t, conn)

feb29 := mustParseDate(t, "2020-02-29")
seedSpeechOnDate(t, conn, "leap-2020-0", feb29)
seedSpeechOnDate(t, conn, "leap-2020-1", feb29)

// Part 1: valid leap date 2024-02-29 — 2020 speeches must appear.
items, err := callOnThisDaySQL(t, conn, "2024-02-29", 10)
if err != nil {
t.Fatalf("unexpected error for 2024-02-29: %v", err)
}
if len(items) != 2 {
t.Fatalf("got %d items for 2024-02-29, want 2", len(items))
}
for _, item := range items {
if item.Date != "2020-02-29" {
t.Errorf("item %q has date %q, want 2020-02-29", item.ID, item.Date)
}
}

// Part 2: 2023-02-29 is rejected as HTTP 400.
// time.Parse("2006-01-02", "2023-02-29") returns "day out of range" in Go,
// so parseDate fails and HandleRequest returns 400 before any DB access.
resp := callHandlerHTTP(t, map[string]string{"date": "2023-02-29"})
if resp.StatusCode != 400 {
t.Fatalf("date=2023-02-29: got status %d, want 400", resp.StatusCode)
}
var errBody map[string]string
if err := json.Unmarshal([]byte(resp.Body), &errBody); err != nil {
t.Fatalf("parse error body: %v (body=%s)", err, resp.Body)
}
if errBody["error"] == "" {
t.Errorf("response missing non-empty 'error' field: %s", resp.Body)
}
}

// TestOnThisDayNoMatchingDate_ReturnsEmptyShape verifies that when no speeches exist
// for the requested calendar date, the handler returns HTTP 200 with an items field
// that is an empty (non-null) JSON array and a date field that echoes the input.
func TestOnThisDayNoMatchingDate_ReturnsEmptyShape(t *testing.T) {
conn := _testdb.Connect(t)
resetOnThisDaySpeeches(t, conn)

// Seed a speech on a different calendar date to confirm the filter is active.
seedSpeechOnDate(t, conn, "noise-001", mustParseDate(t, "2020-06-15"))

// No speeches on January 1 of any prior year — expect empty results.
items, err := callOnThisDaySQL(t, conn, "1999-01-01", 10)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if items == nil {
t.Fatal("items must be non-nil even when empty")
}
if len(items) != 0 {
t.Fatalf("got %d items, want 0", len(items))
}

// Verify the HTTP response shape via the full handler path.
// DATABASE_URL is set in the integration-test environment; the handler connects
// to the same DB that has no Jan-1 speeches after the DELETE above.
resp := callHandlerHTTP(t, map[string]string{"date": "1999-01-01"})
if resp.StatusCode != 200 {
t.Fatalf("got status %d, want 200", resp.StatusCode)
}
var body OnThisDayResponse
if err := json.Unmarshal([]byte(resp.Body), &body); err != nil {
t.Fatalf("parse response: %v (body=%s)", err, resp.Body)
}
if body.Date != "1999-01-01" {
t.Errorf("date field = %q, want 1999-01-01", body.Date)
}
if body.Items == nil {
t.Error("items must be a non-null JSON array, not null")
}
if len(body.Items) != 0 {
t.Errorf("got %d items in HTTP response, want 0", len(body.Items))
}
}

// TestOnThisDayLimitBound verifies that limit=1 returns exactly 1 item and that
// limit=999 is clamped to the handler's declared maxLimit (20).
func TestOnThisDayLimitBound(t *testing.T) {
conn := _testdb.Connect(t)
resetOnThisDaySpeeches(t, conn)

sittingDate := mustParseDate(t, "2010-06-01")
for i := 0; i < 50; i++ {
seedSpeechOnDate(t, conn, fmt.Sprintf("limit-test-%02d", i), sittingDate)
}

// limit=1: exactly 1 item returned.
items, err := callOnThisDaySQL(t, conn, "2026-06-01", 1)
if err != nil {
t.Fatalf("limit=1 query failed: %v", err)
}
if len(items) != 1 {
t.Fatalf("limit=1: got %d items, want 1", len(items))
}

// parseLimit("999") must clamp to maxLimit before any SQL executes.
clamped := parseLimit("999")
if clamped != maxLimit {
t.Fatalf("parseLimit(999) = %d, want %d (maxLimit)", clamped, maxLimit)
}

// Confirm the clamped limit is enforced end-to-end: 50 seeded rows, limit=maxLimit → maxLimit items.
items, err = callOnThisDaySQL(t, conn, "2026-06-01", maxLimit)
if err != nil {
t.Fatalf("limit=maxLimit query failed: %v", err)
}
if len(items) != maxLimit {
t.Fatalf("limit=maxLimit: got %d items with 50 seeded rows, want %d", len(items), maxLimit)
}
}

// TestOnThisDayInvalidDateFormat_Returns400 asserts that malformed date strings
// cause the handler to return HTTP 400 with a non-empty "error" field.
// These cases fail parseDate before getDBConn is called, so no DB access occurs.
func TestOnThisDayInvalidDateFormat_Returns400(t *testing.T) {
cases := []string{
"not-a-date", // length 10, time.Parse fails
"2026/06/01", // length 10, wrong separator
"20260601", // length 8, fails length check
"2026-6-1", // length 8, fails length check
}
for _, dateStr := range cases {
t.Run(dateStr, func(t *testing.T) {
resp := callHandlerHTTP(t, map[string]string{"date": dateStr})
if resp.StatusCode != 400 {
t.Fatalf("date=%q: got status %d, want 400", dateStr, resp.StatusCode)
}
var body map[string]string
if err := json.Unmarshal([]byte(resp.Body), &body); err != nil {
t.Fatalf("parse response: %v (body=%s)", err, resp.Body)
}
if body["error"] == "" {
t.Errorf("date=%q: response missing non-empty 'error' field: %s", dateStr, resp.Body)
}
})
}
}

// TestOnThisDayTimezoneBoundary verifies that sitting_date comparisons are
// calendar-date-only. The sitting_date column is DATE (not TIMESTAMP), so a speech
// seeded as 2020-03-15 must appear for date=2026-03-15 regardless of the UTC offset
// of the process or Postgres server.
func TestOnThisDayTimezoneBoundary(t *testing.T) {
conn := _testdb.Connect(t)
resetOnThisDaySpeeches(t, conn)

// pgx stores time.Time as a DATE value using the calendar date, not a TIMESTAMP.
// We seed using UTC midnight to confirm the DATE column stores the calendar date.
sittingDate := mustParseDate(t, "2020-03-15")
seedSpeechOnDate(t, conn, "tz-boundary-001", sittingDate)

items, err := callOnThisDaySQL(t, conn, "2026-03-15", 10)
if err != nil {
t.Fatalf("query failed: %v", err)
}
if len(items) != 1 {
t.Fatalf("got %d items, want 1", len(items))
}
if items[0].Date != "2020-03-15" {
t.Errorf("item Date = %q, want 2020-03-15", items[0].Date)
}
if items[0].ID != "speech:tz-boundary-001" {
t.Errorf("item ID = %q, want speech:tz-boundary-001", items[0].ID)
}
}
13 changes: 13 additions & 0 deletions backend/on-this-day/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,20 @@ func TestHandleRequest_InvalidDate(t *testing.T) {
}

func TestHandleRequest_MissingDatabaseURL(t *testing.T) {
// Close and nil the cached connection so getDBConn is forced to re-read
// DATABASE_URL rather than reusing a warm connection from a prior test.
if dbConn != nil {
dbConn.Close(context.Background())
dbConn = nil
}
orig := os.Getenv("DATABASE_URL")
os.Unsetenv("DATABASE_URL")
t.Cleanup(func() {
if orig != "" {
os.Setenv("DATABASE_URL", orig)
}
})

resp, err := HandleRequest(context.Background(), events.APIGatewayProxyRequest{
QueryStringParameters: map[string]string{"date": "2026-04-29"},
})
Expand Down
Loading