mirror of
https://github.com/luxfi/captable.git
synced 2026-10-30 17:01:02 +00:00
685 lines
21 KiB
Go
685 lines
21 KiB
Go
// Conformance suite for the SEC EDGAR Form D filing adapter.
|
|
//
|
|
// Drives every method of the Filer against httptest.Server fixtures
|
|
// and asserts the wire-level behavior promised by the Form D XML
|
|
// schema and the EDGAR Filer Manual:
|
|
//
|
|
// - validateFormD enforces every must-have field
|
|
// - marshalFormD emits a well-formed Form D primaryDocument
|
|
// - buildSGMLSubmission wraps the XML in the EDGAR SGML envelope
|
|
// with TYPE / CIK / CCC / CONTACT / DOCUMENT segments
|
|
// - FileFormD posts multipart/form-data with the SGML submission and
|
|
// primary_doc.xml part; happy path returns a parsed Acknowledgment
|
|
// - 429 + Retry-After honored; exponential backoff; sustained 429 →
|
|
// ErrRateLimited
|
|
// - Non-retryable 4xx → *APIError
|
|
// - REJECTED status → ErrSubmissionRejected
|
|
// - AmendFormD requires FileNumber and emits the amendment marker
|
|
// - GetFilingStatus returns ErrAccessionNotFound on 404
|
|
// - normalizeCIK left-pads numeric input to ten digits
|
|
//
|
|
// No production HTTP traffic. All fixtures are httptest.Server.
|
|
//
|
|
// Source-of-design: Public-Spec
|
|
// Source-ref: https://www.sec.gov/info/edgar/specifications/formdxml.pdf
|
|
// Source-ref: https://www.sec.gov/edgar/filer-information/current-edgar-technical-specifications
|
|
|
|
package edgar
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/xml"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"mime"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// --- helpers ---
|
|
|
|
func newTestFiler(t *testing.T, srv *httptest.Server) *Filer {
|
|
t.Helper()
|
|
return NewFiler(Config{
|
|
BaseURL: srv.URL,
|
|
CIK: "1234567",
|
|
CCC: "Aa1!Bb2@",
|
|
ContactEmail: "[email protected]",
|
|
UserAgent: "Lux Captable [email protected]",
|
|
MaxRetries: 2,
|
|
RetryBaseDelay: time.Millisecond,
|
|
RetryMaxDelay: 10 * time.Millisecond,
|
|
HTTPClient: srv.Client(),
|
|
})
|
|
}
|
|
|
|
func sampleFormD() *FormDFiling {
|
|
when := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
|
|
return &FormDFiling{
|
|
SubmissionType: SubmissionFormD,
|
|
IsNewFiling: true,
|
|
PrimaryIssuer: Issuer{
|
|
CIK: "1234567",
|
|
EntityName: "Osage Group LLC",
|
|
EntityType: EntityLLC,
|
|
YearOfIncorporation: "2022",
|
|
JurisdictionOfIncorporation: "OK",
|
|
PrimaryAddress: Address{
|
|
Street1: "1500 S Utica Ave Ste 400",
|
|
City: "Tulsa",
|
|
StateOrCountry: "OK",
|
|
ZipCode: "74104",
|
|
},
|
|
Phone: "+19137779708",
|
|
},
|
|
RelatedPersons: []RelatedPerson{{
|
|
FirstName: "H.", LastName: "Dupont",
|
|
Address: Address{
|
|
Street1: "1500 S Utica Ave Ste 400", City: "Tulsa",
|
|
StateOrCountry: "OK", ZipCode: "74104",
|
|
},
|
|
Relationships: []string{"Executive Officer", "Director"},
|
|
Clarification: "Chief Executive Officer & Chairman of the Board",
|
|
}},
|
|
IndustryGroup: IndustryTechnology,
|
|
IssuerRevenueRange: "Decline to Disclose",
|
|
FederalExemptions: []FederalExemption{ExemptionRule506b},
|
|
TypesOfSecurities: []string{"Equity"},
|
|
DateOfFirstSale: &when,
|
|
OfferingSalesAmount: OfferingSalesAmount{
|
|
TotalOfferingAmount: 5_000_000,
|
|
TotalAmountSold: 1_000_000,
|
|
},
|
|
InvestorCount: InvestorCount{
|
|
TotalAlreadyInvested: 10,
|
|
NonAccreditedInvested: 0,
|
|
},
|
|
MinimumInvestmentAccepted: 25_000,
|
|
Signatures: []Signature{{
|
|
IssuerName: "Osage Group LLC",
|
|
SignatureName: "H. Dupont",
|
|
NameOfSigner: "H. Dupont",
|
|
SignatureTitle: "Chief Executive Officer",
|
|
SignatureDate: "2026-05-15",
|
|
}},
|
|
}
|
|
}
|
|
|
|
// writeSubmissionReceipt writes a canonical SubmissionReceipt body.
|
|
func writeSubmissionReceipt(t *testing.T, w http.ResponseWriter, accession, status string, messages ...string) {
|
|
t.Helper()
|
|
w.Header().Set("Content-Type", "application/xml")
|
|
type msgs struct {
|
|
Message []string `xml:"message"`
|
|
}
|
|
type receipt struct {
|
|
XMLName xml.Name `xml:"SubmissionReceipt"`
|
|
SubmissionID string `xml:"submissionId"`
|
|
AccessionNumber string `xml:"accessionNumber"`
|
|
Status string `xml:"status"`
|
|
FileNumber string `xml:"fileNumber"`
|
|
ReceivedAt string `xml:"receivedAt"`
|
|
Messages msgs `xml:"messages"`
|
|
}
|
|
r := receipt{
|
|
SubmissionID: "S-abc-123",
|
|
AccessionNumber: accession,
|
|
Status: status,
|
|
FileNumber: "021-987654",
|
|
ReceivedAt: "2026-05-15T19:00:00Z",
|
|
Messages: msgs{Message: messages},
|
|
}
|
|
if err := xml.NewEncoder(w).Encode(r); err != nil {
|
|
t.Fatalf("encode receipt: %v", err)
|
|
}
|
|
}
|
|
|
|
// parseMultipartSubmission decodes the multipart body sent by the
|
|
// adapter and returns the parsed form fields keyed by name.
|
|
func parseMultipartSubmission(t *testing.T, r *http.Request) (fields map[string]string, primaryDoc []byte) {
|
|
t.Helper()
|
|
mediaType, params, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
|
|
if err != nil {
|
|
t.Fatalf("parseMediaType: %v", err)
|
|
}
|
|
if !strings.HasPrefix(mediaType, "multipart/") {
|
|
t.Fatalf("content-type not multipart: %q", mediaType)
|
|
}
|
|
fields = make(map[string]string)
|
|
reader := multipart.NewReader(r.Body, params["boundary"])
|
|
for {
|
|
part, err := reader.NextPart()
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("nextPart: %v", err)
|
|
}
|
|
data, err := io.ReadAll(part)
|
|
if err != nil {
|
|
t.Fatalf("read part: %v", err)
|
|
}
|
|
if part.FileName() == "primary_doc.xml" {
|
|
primaryDoc = data
|
|
continue
|
|
}
|
|
fields[part.FormName()] = string(data)
|
|
}
|
|
return fields, primaryDoc
|
|
}
|
|
|
|
// --- validateFormD ---
|
|
|
|
func TestValidateFormD_OK(t *testing.T) {
|
|
if err := validateFormD(sampleFormD(), false); err != nil {
|
|
t.Fatalf("expected valid, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidateFormD_MissingCIK(t *testing.T) {
|
|
fd := sampleFormD()
|
|
fd.PrimaryIssuer.CIK = ""
|
|
if err := validateFormD(fd, false); err == nil || !strings.Contains(err.Error(), "CIK") {
|
|
t.Fatalf("expected CIK error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidateFormD_MissingRelatedPersons(t *testing.T) {
|
|
fd := sampleFormD()
|
|
fd.RelatedPersons = nil
|
|
if err := validateFormD(fd, false); err == nil || !strings.Contains(err.Error(), "related person") {
|
|
t.Fatalf("expected related-persons error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidateFormD_MissingFederalExemption(t *testing.T) {
|
|
fd := sampleFormD()
|
|
fd.FederalExemptions = nil
|
|
if err := validateFormD(fd, false); err == nil || !strings.Contains(err.Error(), "federal exemption") {
|
|
t.Fatalf("expected federal-exemption error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidateFormD_Rule506cBlocksNonAccredited(t *testing.T) {
|
|
fd := sampleFormD()
|
|
fd.FederalExemptions = []FederalExemption{ExemptionRule506c}
|
|
fd.InvestorCount.NonAccreditedInvested = 1
|
|
if err := validateFormD(fd, false); err == nil || !strings.Contains(err.Error(), "506(c)") {
|
|
t.Fatalf("expected 506(c) violation, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidateFormD_Rule504Caps10M(t *testing.T) {
|
|
fd := sampleFormD()
|
|
fd.FederalExemptions = []FederalExemption{ExemptionRule504}
|
|
fd.OfferingSalesAmount.TotalOfferingAmount = 15_000_000
|
|
if err := validateFormD(fd, false); err == nil || !strings.Contains(err.Error(), "504") {
|
|
t.Fatalf("expected 504 cap violation, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidateFormD_AmendmentRequiresFileNumber(t *testing.T) {
|
|
fd := sampleFormD()
|
|
fd.SubmissionType = SubmissionFormDA
|
|
if err := validateFormD(fd, true); err == nil || !strings.Contains(err.Error(), "FileNumber") {
|
|
t.Fatalf("expected FileNumber error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidateFormD_BadSignatureDate(t *testing.T) {
|
|
fd := sampleFormD()
|
|
fd.Signatures[0].SignatureDate = "May 15, 2026"
|
|
if err := validateFormD(fd, false); err == nil || !strings.Contains(err.Error(), "date") {
|
|
t.Fatalf("expected date format error, got %v", err)
|
|
}
|
|
}
|
|
|
|
// --- marshalFormD ---
|
|
|
|
func TestMarshalFormD_WellFormed(t *testing.T) {
|
|
out, err := marshalFormD(sampleFormD())
|
|
if err != nil {
|
|
t.Fatalf("marshalFormD: %v", err)
|
|
}
|
|
if !bytes.HasPrefix(out, []byte(`<?xml version="1.0" encoding="UTF-8"?>`)) {
|
|
t.Fatalf("missing XML prolog: %s", string(out[:60]))
|
|
}
|
|
if !bytes.Contains(out, []byte("<edgarSubmission")) {
|
|
t.Fatalf("missing root element")
|
|
}
|
|
if !bytes.Contains(out, []byte("<submissionType>D</submissionType>")) {
|
|
t.Fatalf("missing submissionType=D")
|
|
}
|
|
if !bytes.Contains(out, []byte("<entityName>Osage Group LLC</entityName>")) {
|
|
t.Fatalf("missing entity name")
|
|
}
|
|
if !bytes.Contains(out, []byte("<isEquityType>true</isEquityType>")) {
|
|
t.Fatalf("missing equity-type marker")
|
|
}
|
|
if !bytes.Contains(out, []byte("<totalOfferingAmount>5000000.00</totalOfferingAmount>")) {
|
|
t.Fatalf("missing totalOfferingAmount")
|
|
}
|
|
if !bytes.Contains(out, []byte("<totalRemaining>4000000.00</totalRemaining>")) {
|
|
t.Fatalf("missing totalRemaining; got %s", out)
|
|
}
|
|
// Round-trip parse.
|
|
var sanity edgarSubmission
|
|
if err := xml.Unmarshal(out, &sanity); err != nil {
|
|
t.Fatalf("round-trip parse: %v", err)
|
|
}
|
|
if sanity.PrimaryIssuer.EntityName != "Osage Group LLC" {
|
|
t.Fatalf("round-trip entity name = %q", sanity.PrimaryIssuer.EntityName)
|
|
}
|
|
}
|
|
|
|
func TestMarshalFormD_Indefinite(t *testing.T) {
|
|
fd := sampleFormD()
|
|
fd.OfferingSalesAmount = OfferingSalesAmount{IsIndefinite: true, TotalAmountSold: 250_000}
|
|
out, err := marshalFormD(fd)
|
|
if err != nil {
|
|
t.Fatalf("marshalFormD: %v", err)
|
|
}
|
|
if !bytes.Contains(out, []byte("<totalOfferingAmount>Indefinite</totalOfferingAmount>")) {
|
|
t.Fatalf("expected Indefinite sentinel; got %s", out)
|
|
}
|
|
}
|
|
|
|
func TestMarshalFormD_FirstSaleYetToOccur(t *testing.T) {
|
|
fd := sampleFormD()
|
|
fd.DateOfFirstSale = nil
|
|
fd.FirstSaleYetToOccur = true
|
|
out, err := marshalFormD(fd)
|
|
if err != nil {
|
|
t.Fatalf("marshalFormD: %v", err)
|
|
}
|
|
if !bytes.Contains(out, []byte("<yetToOccur>true</yetToOccur>")) {
|
|
t.Fatalf("expected yetToOccur=true; got %s", out)
|
|
}
|
|
}
|
|
|
|
// --- buildSGMLSubmission ---
|
|
|
|
func TestBuildSGML_HeaderAndDocument(t *testing.T) {
|
|
fd := sampleFormD()
|
|
xmlPayload, err := marshalFormD(fd)
|
|
if err != nil {
|
|
t.Fatalf("marshalFormD: %v", err)
|
|
}
|
|
sgml := buildSGMLSubmission(Config{
|
|
CIK: "0001234567", CCC: "Aa1!Bb2@", ContactEmail: "[email protected]",
|
|
}, fd, xmlPayload)
|
|
s := string(sgml)
|
|
for _, want := range []string{
|
|
"<SUBMISSION>", "</SUBMISSION>",
|
|
"<TYPE>D", "<CIK>0001234567", "<CCC>Aa1!Bb2@",
|
|
"<NOTIFY-INTERNET>[email protected]",
|
|
"<DOCUMENT>", "</DOCUMENT>",
|
|
"<FILENAME>primary_doc.xml", "<TEXT>",
|
|
"<edgarSubmission",
|
|
} {
|
|
if !strings.Contains(s, want) {
|
|
t.Fatalf("SGML missing %q in:\n%s", want, s)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuildSGML_AmendmentCarriesFileNumber(t *testing.T) {
|
|
fd := sampleFormD()
|
|
fd.SubmissionType = SubmissionFormDA
|
|
fd.FileNumber = "021-987654"
|
|
xmlPayload, err := marshalFormD(fd)
|
|
if err != nil {
|
|
t.Fatalf("marshalFormD: %v", err)
|
|
}
|
|
sgml := buildSGMLSubmission(Config{CIK: "0001234567", CCC: "X"}, fd, xmlPayload)
|
|
if !strings.Contains(string(sgml), "<FILE-NUMBER>021-987654") {
|
|
t.Fatalf("missing FILE-NUMBER on amendment: %s", sgml)
|
|
}
|
|
}
|
|
|
|
// --- FileFormD HTTP transport ---
|
|
|
|
func TestFileFormD_HappyPath(t *testing.T) {
|
|
mux := http.NewServeMux()
|
|
var captured atomic.Value
|
|
mux.HandleFunc("/cgi-bin/edgarsubmit", func(w http.ResponseWriter, r *http.Request) {
|
|
fields, primaryDoc := parseMultipartSubmission(t, r)
|
|
captured.Store(fields)
|
|
if fields["CIK"] != "0001234567" {
|
|
t.Errorf("CIK field = %q", fields["CIK"])
|
|
}
|
|
if fields["CCC"] != "Aa1!Bb2@" {
|
|
t.Errorf("CCC field = %q", fields["CCC"])
|
|
}
|
|
if !strings.Contains(fields["submission"], "<SUBMISSION>") {
|
|
t.Errorf("submission missing SGML envelope")
|
|
}
|
|
if !bytes.Contains(primaryDoc, []byte("<edgarSubmission")) {
|
|
t.Errorf("primary_doc.xml missing edgarSubmission")
|
|
}
|
|
if r.Header.Get("User-Agent") == "" {
|
|
t.Errorf("missing User-Agent")
|
|
}
|
|
writeSubmissionReceipt(t, w, "0001234567-26-000001", "RECEIVED")
|
|
})
|
|
srv := httptest.NewServer(mux)
|
|
defer srv.Close()
|
|
|
|
f := newTestFiler(t, srv)
|
|
ack, err := f.FileFormD(context.Background(), sampleFormD())
|
|
if err != nil {
|
|
t.Fatalf("FileFormD: %v", err)
|
|
}
|
|
if ack.AccessionNumber != "0001234567-26-000001" {
|
|
t.Fatalf("accession = %q", ack.AccessionNumber)
|
|
}
|
|
if ack.Status != "RECEIVED" {
|
|
t.Fatalf("status = %q", ack.Status)
|
|
}
|
|
if ack.SubmissionID != "S-abc-123" {
|
|
t.Fatalf("submissionId = %q", ack.SubmissionID)
|
|
}
|
|
}
|
|
|
|
func TestFileFormD_MissingCIK(t *testing.T) {
|
|
f := NewFiler(Config{BaseURL: "https://example", CCC: "x"})
|
|
_, err := f.FileFormD(context.Background(), sampleFormD())
|
|
if !errors.Is(err, ErrMissingCIK) {
|
|
t.Fatalf("expected ErrMissingCIK, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestFileFormD_MissingCCC(t *testing.T) {
|
|
f := NewFiler(Config{BaseURL: "https://example", CIK: "1234567"})
|
|
_, err := f.FileFormD(context.Background(), sampleFormD())
|
|
if !errors.Is(err, ErrMissingCCC) {
|
|
t.Fatalf("expected ErrMissingCCC, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestFileFormD_ValidationError(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
t.Fatal("should not POST when validation fails")
|
|
}))
|
|
defer srv.Close()
|
|
f := newTestFiler(t, srv)
|
|
fd := sampleFormD()
|
|
fd.PrimaryIssuer.EntityName = ""
|
|
_, err := f.FileFormD(context.Background(), fd)
|
|
if !errors.Is(err, ErrInvalidFormD) {
|
|
t.Fatalf("expected ErrInvalidFormD, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestFileFormD_RejectedByEDGAR(t *testing.T) {
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/cgi-bin/edgarsubmit", func(w http.ResponseWriter, r *http.Request) {
|
|
writeSubmissionReceipt(t, w, "0001234567-26-000002", "REJECTED", "Issuer name conflicts with EDGAR record")
|
|
})
|
|
srv := httptest.NewServer(mux)
|
|
defer srv.Close()
|
|
f := newTestFiler(t, srv)
|
|
ack, err := f.FileFormD(context.Background(), sampleFormD())
|
|
if !errors.Is(err, ErrSubmissionRejected) {
|
|
t.Fatalf("expected ErrSubmissionRejected, got %v", err)
|
|
}
|
|
if ack == nil || ack.Status != "REJECTED" {
|
|
t.Fatalf("expected REJECTED ack, got %+v", ack)
|
|
}
|
|
}
|
|
|
|
func TestFileFormD_4xxNonRetryable(t *testing.T) {
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/cgi-bin/edgarsubmit", func(w http.ResponseWriter, r *http.Request) {
|
|
http.Error(w, `<error>bad CCC</error>`, http.StatusUnauthorized)
|
|
})
|
|
srv := httptest.NewServer(mux)
|
|
defer srv.Close()
|
|
f := newTestFiler(t, srv)
|
|
_, err := f.FileFormD(context.Background(), sampleFormD())
|
|
var apiErr *APIError
|
|
if !errors.As(err, &apiErr) || apiErr.StatusCode != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 APIError, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestFileFormD_429RetriedThenSuccess(t *testing.T) {
|
|
var calls atomic.Int32
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/cgi-bin/edgarsubmit", func(w http.ResponseWriter, r *http.Request) {
|
|
n := calls.Add(1)
|
|
if n == 1 {
|
|
w.Header().Set("Retry-After", "0")
|
|
http.Error(w, "throttled", http.StatusTooManyRequests)
|
|
return
|
|
}
|
|
writeSubmissionReceipt(t, w, "0001234567-26-000003", "RECEIVED")
|
|
})
|
|
srv := httptest.NewServer(mux)
|
|
defer srv.Close()
|
|
f := newTestFiler(t, srv)
|
|
ack, err := f.FileFormD(context.Background(), sampleFormD())
|
|
if err != nil {
|
|
t.Fatalf("FileFormD: %v", err)
|
|
}
|
|
if ack.Status != "RECEIVED" {
|
|
t.Fatalf("status = %q", ack.Status)
|
|
}
|
|
if calls.Load() < 2 {
|
|
t.Fatalf("expected at least 2 calls, got %d", calls.Load())
|
|
}
|
|
}
|
|
|
|
func TestFileFormD_SustainedRateLimit(t *testing.T) {
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/cgi-bin/edgarsubmit", func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Retry-After", "0")
|
|
http.Error(w, "throttled", http.StatusTooManyRequests)
|
|
})
|
|
srv := httptest.NewServer(mux)
|
|
defer srv.Close()
|
|
f := newTestFiler(t, srv)
|
|
_, err := f.FileFormD(context.Background(), sampleFormD())
|
|
if !errors.Is(err, ErrRateLimited) {
|
|
t.Fatalf("expected ErrRateLimited, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestFileFormD_Retries5xx(t *testing.T) {
|
|
var calls atomic.Int32
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/cgi-bin/edgarsubmit", func(w http.ResponseWriter, r *http.Request) {
|
|
if calls.Add(1) == 1 {
|
|
http.Error(w, "edgar down", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
writeSubmissionReceipt(t, w, "0001234567-26-000004", "RECEIVED")
|
|
})
|
|
srv := httptest.NewServer(mux)
|
|
defer srv.Close()
|
|
f := newTestFiler(t, srv)
|
|
if _, err := f.FileFormD(context.Background(), sampleFormD()); err != nil {
|
|
t.Fatalf("FileFormD: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestFileFormD_RespectsContextCancellation(t *testing.T) {
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/cgi-bin/edgarsubmit", func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Retry-After", "60")
|
|
http.Error(w, "throttled", http.StatusTooManyRequests)
|
|
})
|
|
srv := httptest.NewServer(mux)
|
|
defer srv.Close()
|
|
f := newTestFiler(t, srv)
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
go func() {
|
|
time.Sleep(5 * time.Millisecond)
|
|
cancel()
|
|
}()
|
|
_, err := f.FileFormD(ctx, sampleFormD())
|
|
if err == nil {
|
|
t.Fatal("expected error")
|
|
}
|
|
if !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("expected ctx Canceled, got %v", err)
|
|
}
|
|
}
|
|
|
|
// --- AmendFormD ---
|
|
|
|
func TestAmendFormD_HappyPath(t *testing.T) {
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/cgi-bin/edgarsubmit", func(w http.ResponseWriter, r *http.Request) {
|
|
fields, primaryDoc := parseMultipartSubmission(t, r)
|
|
if !strings.Contains(fields["submission"], "<FILE-NUMBER>021-987654") {
|
|
t.Errorf("missing FILE-NUMBER on amendment SGML")
|
|
}
|
|
if !bytes.Contains(primaryDoc, []byte("<submissionType>D/A</submissionType>")) {
|
|
t.Errorf("primaryDoc missing D/A submission type: %s", primaryDoc)
|
|
}
|
|
writeSubmissionReceipt(t, w, "0001234567-26-000005", "RECEIVED")
|
|
})
|
|
srv := httptest.NewServer(mux)
|
|
defer srv.Close()
|
|
f := newTestFiler(t, srv)
|
|
fd := sampleFormD()
|
|
fd.FileNumber = "021-987654"
|
|
ack, err := f.AmendFormD(context.Background(), fd)
|
|
if err != nil {
|
|
t.Fatalf("AmendFormD: %v", err)
|
|
}
|
|
if ack.AccessionNumber != "0001234567-26-000005" {
|
|
t.Fatalf("accession = %q", ack.AccessionNumber)
|
|
}
|
|
}
|
|
|
|
func TestAmendFormD_MissingFileNumber(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
t.Fatal("should not POST when validation fails")
|
|
}))
|
|
defer srv.Close()
|
|
f := newTestFiler(t, srv)
|
|
_, err := f.AmendFormD(context.Background(), sampleFormD())
|
|
if !errors.Is(err, ErrInvalidFormD) {
|
|
t.Fatalf("expected ErrInvalidFormD, got %v", err)
|
|
}
|
|
}
|
|
|
|
// --- GetFilingStatus ---
|
|
|
|
func TestGetFilingStatus_HappyPath(t *testing.T) {
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/cgi-bin/edgarstatus", func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Query().Get("accession") != "0001234567-26-000001" {
|
|
t.Errorf("missing accession query: %s", r.URL.RawQuery)
|
|
}
|
|
w.Header().Set("Content-Type", "application/xml")
|
|
fmt.Fprintln(w, `<FilingStatus>
|
|
<accessionNumber>0001234567-26-000001</accessionNumber>
|
|
<status>ACCEPTED</status>
|
|
<fileNumber>021-987654</fileNumber>
|
|
<acceptedAt>2026-05-15T19:00:30Z</acceptedAt>
|
|
</FilingStatus>`)
|
|
})
|
|
srv := httptest.NewServer(mux)
|
|
defer srv.Close()
|
|
f := newTestFiler(t, srv)
|
|
st, err := f.GetFilingStatus(context.Background(), "0001234567-26-000001")
|
|
if err != nil {
|
|
t.Fatalf("GetFilingStatus: %v", err)
|
|
}
|
|
if st.Status != "ACCEPTED" {
|
|
t.Fatalf("status = %q", st.Status)
|
|
}
|
|
if st.FileNumber != "021-987654" {
|
|
t.Fatalf("fileNumber = %q", st.FileNumber)
|
|
}
|
|
if st.AcceptedAt.IsZero() {
|
|
t.Fatalf("acceptedAt zero")
|
|
}
|
|
}
|
|
|
|
func TestGetFilingStatus_NotFound(t *testing.T) {
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/cgi-bin/edgarstatus", func(w http.ResponseWriter, r *http.Request) {
|
|
http.Error(w, `<error>unknown accession</error>`, http.StatusNotFound)
|
|
})
|
|
srv := httptest.NewServer(mux)
|
|
defer srv.Close()
|
|
f := newTestFiler(t, srv)
|
|
_, err := f.GetFilingStatus(context.Background(), "0001234567-26-999999")
|
|
if !errors.Is(err, ErrAccessionNotFound) {
|
|
t.Fatalf("expected ErrAccessionNotFound, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestGetFilingStatus_MissingAccession(t *testing.T) {
|
|
f := NewFiler(Config{BaseURL: "https://example", CIK: "1234567", CCC: "x"})
|
|
if _, err := f.GetFilingStatus(context.Background(), ""); err == nil {
|
|
t.Fatal("expected error for empty accession")
|
|
}
|
|
}
|
|
|
|
// --- normalizeCIK ---
|
|
|
|
func TestNormalizeCIK(t *testing.T) {
|
|
cases := map[string]string{
|
|
"": "",
|
|
"1234567": "0001234567",
|
|
"0001234567": "0001234567",
|
|
"99": "0000000099",
|
|
"abc": "abc",
|
|
" 12345 ": "0000012345",
|
|
}
|
|
for in, want := range cases {
|
|
if got := normalizeCIK(in); got != want {
|
|
t.Errorf("normalizeCIK(%q) = %q, want %q", in, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Acknowledgment parsing edge cases ---
|
|
|
|
func TestParseAcknowledgment_DefaultsReceived(t *testing.T) {
|
|
body := []byte(`<SubmissionReceipt><submissionId>S-x</submissionId><accessionNumber>0001234567-26-000007</accessionNumber></SubmissionReceipt>`)
|
|
ack, err := parseAcknowledgment(body)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if ack.Status != "RECEIVED" {
|
|
t.Fatalf("status = %q", ack.Status)
|
|
}
|
|
}
|
|
|
|
func TestParseAcknowledgment_BadXML(t *testing.T) {
|
|
if _, err := parseAcknowledgment([]byte("not xml")); err == nil {
|
|
t.Fatal("expected error")
|
|
}
|
|
}
|
|
|
|
// --- parseRetryAfter ---
|
|
|
|
func TestParseRetryAfter(t *testing.T) {
|
|
if got := parseRetryAfter(""); got != 0 {
|
|
t.Fatalf("empty got %v", got)
|
|
}
|
|
if got := parseRetryAfter("5"); got != 5*time.Second {
|
|
t.Fatalf("5 got %v", got)
|
|
}
|
|
if got := parseRetryAfter("garbage"); got != 0 {
|
|
t.Fatalf("garbage got %v", got)
|
|
}
|
|
future := time.Now().Add(2 * time.Second).UTC().Format(http.TimeFormat)
|
|
if got := parseRetryAfter(future); got <= 0 {
|
|
t.Fatalf("future date got %v", got)
|
|
}
|
|
}
|