Files
bwilliams 50ee510520 Start and end dates are now the contract's, with a new closing date
Find a Tender never sends tender.tenderPeriod.startDate, so an
opportunity's start date was always empty, and its end date was the
bid deadline. Start and end are now the contract period: from the
contracts' periods, else the awards' contract periods, else the lots'
contract periods, each date on its own, taking the earliest start and
the latest end of several.

The bid deadline (tenderPeriod.endDate) moves to a new closing_date
column (migration 00009), in the API as closing_date. EU Supply's
pages give no contract period, so its start and end are left empty
and its response deadline is the closing date.
2026-10-21 14:31:12 +01:00

334 lines
9.9 KiB
Go

package eusupply
import (
"context"
"errors"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"os"
"strings"
"sync"
"testing"
"time"
)
// readFixture returns a file in testdata.
func readFixture(t *testing.T, name string) []byte {
t.Helper()
body, err := os.ReadFile("testdata/" + name) //nolint:gosec // G304: a test fixture
if err != nil {
t.Fatal(err)
}
return body
}
// newTestClient returns a client for srv that doesn't wait between
// requests and logs nothing.
func newTestClient(srv *httptest.Server) *Client {
return New(
srv.URL,
WithRequestGap(0, 0),
WithLogger(slog.New(slog.DiscardHandler)),
)
}
func TestParseSearchPage(t *testing.T) {
c := New("https://uk.example", WithLogger(slog.New(slog.DiscardHandler)))
tenders, pages, err := c.parseSearchPage(readFixture(t, "search.html"))
if err != nil {
t.Fatal(err)
}
if pages != 2 {
t.Errorf("pages = %d; want 2", pages)
}
// The row with no link is skipped.
if len(tenders) != 2 {
t.Fatalf("tenders = %d; want 2: %+v", len(tenders), tenders)
}
first := tenders[0]
wantPublished := time.Date(2026, 10, 21, 11, 45, 0, 0, time.UTC) // BST
wantDeadline := time.Date(2026, 11, 4, 12, 0, 0, 0, time.UTC) // GMT
switch {
case first.ID != "100114946", first.PID != "109912",
first.Reference != "Proc2852":
t.Errorf("ids = %q, %q, %q", first.ID, first.PID, first.Reference)
case first.Title != "Jisc Google for Education Reseller Framework Agreement":
t.Errorf("title = %q", first.Title)
case first.URL != "https://uk.example/ctm/Supplier/PublicPurchase/109912/0/0?returnUrl=ctm/Supplier/PublicTenders&b=UK":
t.Errorf("url = %q", first.URL)
case !first.Published.Equal(wantPublished):
t.Errorf("published = %v; want %v", first.Published, wantPublished)
case first.Deadline == nil || !first.Deadline.Equal(wantDeadline):
t.Errorf("deadline = %v; want %v", first.Deadline, wantDeadline)
case first.Process != "PA23 - Competitive Flexible", first.Buyer != "Jisc",
first.Countries != "United Kingdom":
t.Errorf("cells = %q, %q, %q", first.Process, first.Buyer, first.Countries)
case first.HasDetails:
t.Error("HasDetails set from the search page")
}
second := tenders[1]
if second.Reference != "" || second.Deadline != nil {
t.Errorf("second = %+v; want no reference or deadline", second)
}
// Zero width spaces are dropped from titles.
if second.Title != "York and North Yorkshire Transport Sub-strategies" {
t.Errorf("second title = %q", second.Title)
}
}
func TestParseSearchPageChanged(t *testing.T) {
c := New("", WithLogger(slog.New(slog.DiscardHandler)))
page := []byte("<html><body>Maintenance</body></html>")
_, _, err := c.parseSearchPage(page)
if !errors.Is(err, ErrNoResults) {
t.Errorf("err = %v; want ErrNoResults", err)
}
}
func TestParseDetails(t *testing.T) {
tender := Tender{
ID: "100114946",
}
if err := parseDetails(readFixture(t, "tender.html"), &tender); err != nil {
t.Fatal(err)
}
wantDescription := "This opportunity is for a framework agreement.\n" +
"The Supplier(s) shall manage the full licence lifecycle (the 'Agreement')."
if tender.Description != wantDescription {
t.Errorf("description = %q", tender.Description)
}
if tender.ContractType != "Supplies" {
t.Errorf("contract type = %q", tender.ContractType)
}
if len(tender.CPVCodes) != 2 ||
tender.CPVCodes[0].Code != "48000000-8" ||
tender.CPVCodes[0].Description !=
"Software package and information systems" {
t.Errorf("cpv codes = %+v", tender.CPVCodes)
}
wantAddress := "Jisc|4 Portwall Lane|BS1 6NB|Bristol|United Kingdom"
if got := strings.Join(tender.BuyerAddress, "|"); got != wantAddress {
t.Errorf("buyer address = %q", got)
}
// The search had no deadline, so the page's is used.
if tender.Deadline == nil ||
!tender.Deadline.Equal(time.Date(2026, 11, 4, 12, 0, 0, 0, time.UTC)) {
t.Errorf("deadline = %v", tender.Deadline)
}
if tender.Lots != 2 || !tender.HasDetails {
t.Errorf("lots = %d, HasDetails = %v", tender.Lots, tender.HasDetails)
}
err := parseDetails([]byte("<html><body>Not found</body></html>"), &tender)
if !errors.Is(err, ErrNoDetails) {
t.Errorf("err = %v; want ErrNoDetails", err)
}
}
// Search posts the portal's form for each page, and keeps the pages read
// before one fails.
func TestSearch(t *testing.T) {
page := readFixture(t, "search.html")
var (
mu sync.Mutex
forms []url.Values
)
fail := false
srv := httptest.NewServer(
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != searchPath {
t.Errorf("request = %s %s", r.Method, r.URL.Path)
}
r.ParseForm()
mu.Lock()
forms = append(forms, r.PostForm)
number := len(forms)
mu.Unlock()
if fail && number == 2 {
w.WriteHeader(http.StatusInternalServerError)
return
}
w.Write(page)
}),
)
t.Cleanup(srv.Close)
c := newTestClient(srv)
from := time.Date(2026, 10, 14, 23, 30, 0, 0, time.UTC) // 15th in the UK
to := time.Date(2026, 10, 21, 9, 0, 0, 0, time.UTC)
tenders, err := c.Search(context.Background(), from, to)
if err != nil {
t.Fatal(err)
}
if len(tenders) != 4 || len(forms) != 2 {
t.Fatalf(
"%d tenders from %d pages; want 4 from 2", len(tenders), len(forms),
)
}
for i, form := range forms {
wantPage := []string{"1", "2"}[i]
if form.Get("SearchFilter.FromDate") != "15/10/2026" ||
form.Get("SearchFilter.ToDate") != "21/10/2026" ||
form.Get("SearchFilter.PagingInfo.PageNumber") != wantPage ||
form.Get("SearchFilter.PagingInfo.PageSize") != "100" ||
form.Get("Branding") != "UK" {
t.Errorf("form %d = %v", i+1, form)
}
}
forms = nil
fail = true
tenders, err = c.Search(context.Background(), from, to)
if !errors.Is(err, ErrUnexpectedStatus) {
t.Errorf("err = %v; want ErrUnexpectedStatus", err)
}
if len(tenders) != 2 {
t.Errorf("%d tenders; want the first page's 2", len(tenders))
}
}
func TestDetails(t *testing.T) {
var path string
srv := httptest.NewServer(
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path = r.URL.RequestURI()
w.Write(readFixture(t, "tender.html"))
}),
)
t.Cleanup(srv.Close)
tender := Tender{
ID: "100114946",
PID: "109912",
}
err := newTestClient(srv).Details(context.Background(), &tender)
if err != nil {
t.Fatal(err)
}
if path != tenderPath("109912") || !tender.HasDetails {
t.Errorf("path = %q, HasDetails = %v", path, tender.HasDetails)
}
}
// The client waits from minGap to maxGap between requests, and a cancelled
// context stops the wait.
func TestRequestGap(t *testing.T) {
var (
mu sync.Mutex
times []time.Time
)
srv := httptest.NewServer(
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
times = append(times, time.Now())
mu.Unlock()
io.WriteString(w, "ok")
}),
)
t.Cleanup(srv.Close)
const minGap, maxGap = 50 * time.Millisecond, 80 * time.Millisecond
c := New(
srv.URL,
WithRequestGap(minGap, maxGap),
WithLogger(slog.New(slog.DiscardHandler)),
)
for range 3 {
if _, err := c.get(context.Background(), "/"); err != nil {
t.Fatal(err)
}
}
for i := 1; i < len(times); i++ {
if gap := times[i].Sub(times[i-1]); gap < minGap {
t.Errorf("gap %d = %v; want at least %v", i, gap, minGap)
}
}
slow := New(
srv.URL,
WithRequestGap(time.Hour, time.Hour),
WithLogger(slog.New(slog.DiscardHandler)),
)
slow.get(context.Background(), "/")
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
defer cancel()
start := time.Now()
if _, err := slow.get(ctx, "/"); !errors.Is(err, context.DeadlineExceeded) {
t.Errorf("err = %v; want context.DeadlineExceeded", err)
}
if time.Since(start) > 5*time.Second {
t.Error("cancelling did not stop the wait")
}
}
func TestConvert(t *testing.T) {
published := time.Date(2026, 10, 21, 11, 45, 0, 0, time.UTC)
deadline := time.Date(2026, 11, 4, 12, 0, 0, 0, time.UTC)
tender := Tender{
ID: "100114946",
PID: "109912",
Title: "Hazard Management Tool",
Published: published,
Deadline: &deadline,
Buyer: "UK Atomic Energy Authority",
HasDetails: true,
Description: "A tool",
ContractType: "Services",
CPVCodes: []CPVCode{
{
Code: "48000000-8",
Description: "Software package and information systems",
},
},
Lots: 2,
}
opportunities := Convert([]Tender{tender})
if len(opportunities) != 1 {
t.Fatalf("%d opportunities; want 1", len(opportunities))
}
opportunity := opportunities[0]
switch {
case opportunity.UUID != "eusupply-uk-100114946",
opportunity.Source != Source:
t.Errorf("uuid, source = %q, %q", opportunity.UUID, opportunity.Source)
case opportunity.Title != tender.Title, opportunity.Description != "A tool",
opportunity.BuyerName != tender.Buyer, opportunity.Status != "active":
t.Errorf("opportunity = %+v", opportunity)
case opportunity.MainCategory != "services", !opportunity.MultiLot:
t.Errorf(
"category, multi-lot = %q, %v",
opportunity.MainCategory, opportunity.MultiLot,
)
case len(opportunity.Classifications) != 1 ||
opportunity.Classifications[0].Code != "48000000" ||
!opportunity.Classifications[0].IsCPV:
t.Errorf("classifications = %+v", opportunity.Classifications)
case !opportunity.LatestReleaseDate.Equal(published):
t.Errorf("latest release date = %v", opportunity.LatestReleaseDate)
// The portal gives no contract period; the deadline is the closing date.
case opportunity.StartDate != nil, opportunity.EndDate != nil,
opportunity.ClosingDate == nil ||
!opportunity.ClosingDate.Equal(deadline):
t.Errorf(
"start, end, closing = %v, %v, %v",
opportunity.StartDate, opportunity.EndDate, opportunity.ClosingDate,
)
case opportunity.Marks.Hidden || opportunity.Marks.Skipped:
t.Error("Convert set marks")
}
release := opportunity.Releases[0]
if len(opportunity.Releases) != 1 || release.ReleaseID != "100114946" ||
len(release.Tags) != 1 || release.Tags[0].Tag != "tender" ||
!strings.Contains(release.JSON, `"pid":"109912"`) {
t.Errorf("releases = %+v", opportunity.Releases)
}
}