initial commit

This commit is contained in:
mr0xb 2026-08-24 14:44:54 -04:00
commit 885e7d6e02
22 changed files with 3001 additions and 0 deletions

View file

@ -0,0 +1,520 @@
package report
import (
"strings"
"testing"
"time"
"github.com/mr0xb/nxstats/internal/parser"
)
func makeTestData() ReportData {
t1 := time.Date(2024, 2, 20, 10, 0, 0, 0, time.UTC)
t2 := time.Date(2024, 2, 20, 11, 0, 0, 0, time.UTC)
t3 := time.Date(2024, 2, 20, 11, 30, 0, 0, time.UTC)
return ReportData{
GeneratedAt: time.Now(),
AccessEntries: []parser.AccessEntry{
{RemoteAddr: "1.1.1.1", Method: "GET", Path: "/", Status: 200, BytesSent: 1024, Time: t1, UserAgent: "TestAgent/1.0"},
{RemoteAddr: "2.2.2.2", Method: "POST", Path: "/api", Status: 201, BytesSent: 512, Time: t2, UserAgent: "curl/7.0"},
{RemoteAddr: "1.1.1.1", Method: "GET", Path: "/about", Status: 200, BytesSent: 2048, Time: t3, UserAgent: "TestAgent/1.0"},
{RemoteAddr: "3.3.3.3", Method: "GET", Path: "/", Status: 500, BytesSent: 128, Time: t3, UserAgent: "BadBot/1.0"},
},
ErrorEntries: []parser.ErrorEntry{
{Time: t1, Level: "error", PID: 100, TID: 200, Message: "connection refused"},
{Time: t2, Level: "warn", PID: 100, TID: 200, Message: "slow query"},
},
}
}
func TestGenerateHTML_ReturnsHTML(t *testing.T) {
data := makeTestData()
ComputeStats(&data)
html, err := GenerateHTML(data)
if err != nil {
t.Fatalf("GenerateHTML error: %v", err)
}
if !strings.HasPrefix(strings.TrimSpace(html), "<!DOCTYPE html>") {
t.Error("expected output to start with <!DOCTYPE html>")
}
}
func TestGenerateHTML_ContainsStatsCards(t *testing.T) {
data := makeTestData()
ComputeStats(&data)
html, err := GenerateHTML(data)
if err != nil {
t.Fatalf("GenerateHTML error: %v", err)
}
checks := []string{
"Total Requests",
"Unique IPs",
"Total Bytes",
"Error Rate",
}
for _, c := range checks {
if !strings.Contains(html, c) {
t.Errorf("expected %q in HTML output", c)
}
}
}
func TestGenerateHTML_ContainsSections(t *testing.T) {
data := makeTestData()
ComputeStats(&data)
html, err := GenerateHTML(data)
if err != nil {
t.Fatalf("GenerateHTML error: %v", err)
}
sections := []string{
"Access Log",
"Error Log",
"Top IPs",
"Top Paths",
"chart",
}
for _, s := range sections {
if !strings.Contains(html, s) {
t.Errorf("expected section %q in HTML output", s)
}
}
}
func TestGenerateHTML_ContainsIPData(t *testing.T) {
data := makeTestData()
ComputeStats(&data)
html, err := GenerateHTML(data)
if err != nil {
t.Fatalf("GenerateHTML error: %v", err)
}
if !strings.Contains(html, "1.1.1.1") {
t.Error("expected top IP 1.1.1.1 in output")
}
}
func TestComputeStats_Counts(t *testing.T) {
data := makeTestData()
ComputeStats(&data)
if data.TotalRequests != 4 {
t.Errorf("TotalRequests = %d, want 4", data.TotalRequests)
}
if data.UniqueIPs != 3 {
t.Errorf("UniqueIPs = %d, want 3", data.UniqueIPs)
}
if data.TotalBytes != 3712 {
t.Errorf("TotalBytes = %d, want 3712", data.TotalBytes)
}
}
func TestComputeStats_ErrorRate(t *testing.T) {
data := makeTestData()
ComputeStats(&data)
// 1 out of 4 requests is 5xx → 25%
if data.ErrorRate < 24.9 || data.ErrorRate > 25.1 {
t.Errorf("ErrorRate = %f, want ~25.0", data.ErrorRate)
}
}
func TestComputeStats_TopIPs(t *testing.T) {
data := makeTestData()
ComputeStats(&data)
if len(data.TopIPs) == 0 {
t.Fatal("expected TopIPs to be non-empty")
}
if data.TopIPs[0].IP != "1.1.1.1" {
t.Errorf("top IP = %q, want 1.1.1.1", data.TopIPs[0].IP)
}
if data.TopIPs[0].Count != 2 {
t.Errorf("top IP count = %d, want 2", data.TopIPs[0].Count)
}
}
func TestComputeStats_TopPaths(t *testing.T) {
data := makeTestData()
ComputeStats(&data)
if len(data.TopPaths) == 0 {
t.Fatal("expected TopPaths to be non-empty")
}
if data.TopPaths[0].Path != "/" {
t.Errorf("top path = %q, want /", data.TopPaths[0].Path)
}
}
func TestComputeStats_TopPathsTopStatus(t *testing.T) {
// "/" gets two requests: one 200 and one 500.
// TopStatus should be 200 (the most frequent), not an average (350).
data := makeTestData()
ComputeStats(&data)
var slashEntry *PathCount
for i := range data.TopPaths {
if data.TopPaths[i].Path == "/" {
slashEntry = &data.TopPaths[i]
break
}
}
if slashEntry == nil {
t.Fatal("expected / in TopPaths")
}
if slashEntry.TopStatus != 200 {
t.Errorf("TopStatus for / = %d, want 200 (most frequent, not an average)", slashEntry.TopStatus)
}
}
func TestComputeStats_StatusCounts(t *testing.T) {
data := makeTestData()
ComputeStats(&data)
if data.StatusCounts[200] != 2 {
t.Errorf("StatusCounts[200] = %d, want 2", data.StatusCounts[200])
}
if data.StatusCounts[500] != 1 {
t.Errorf("StatusCounts[500] = %d, want 1", data.StatusCounts[500])
}
}
func TestGenerateHTML_SearchInputs(t *testing.T) {
data := makeTestData()
ComputeStats(&data)
html, err := GenerateHTML(data)
if err != nil {
t.Fatalf("GenerateHTML error: %v", err)
}
// One search input per paginated table
if strings.Count(html, `type="text"`) < 2 {
t.Error("expected at least 2 search text inputs")
}
if !strings.Contains(html, "onkeyup") {
t.Error("expected onkeyup handler on search inputs")
}
}
func TestGenerateHTML_PaginationControls(t *testing.T) {
data := makeTestData()
ComputeStats(&data)
html, err := GenerateHTML(data)
if err != nil {
t.Fatalf("GenerateHTML error: %v", err)
}
// Prev/Next buttons for both tables
if strings.Count(html, "Prev") < 2 {
t.Error("expected Prev button for each paginated table")
}
if strings.Count(html, "Next") < 2 {
t.Error("expected Next button for each paginated table")
}
// Page-size selector
if strings.Count(html, `<select `) < 2 {
t.Error("expected page-size <select> for each paginated table")
}
// makePaginator JS factory must be present
if !strings.Contains(html, "makePaginator") {
t.Error("expected makePaginator JS function")
}
}
func TestGenerateHTML_AccessDataEmbeddedAsJSON(t *testing.T) {
data := makeTestData()
ComputeStats(&data)
html, err := GenerateHTML(data)
if err != nil {
t.Fatalf("GenerateHTML error: %v", err)
}
// The access tbody must be empty in the static HTML — rows are JS-rendered
if !strings.Contains(html, `<tbody id="accessTbody"></tbody>`) {
t.Error("accessTbody should be empty in static HTML (rows rendered by JS)")
}
// But the IP must still appear inside the embedded JSON data
if !strings.Contains(html, "1.1.1.1") {
t.Error("access entry IP must be present in embedded JSON")
}
}
func TestGenerateHTML_WithGeoLocations(t *testing.T) {
data := makeTestData()
ComputeStats(&data)
data.GeoLocations = []GeoPoint{
{IP: "1.1.1.1", Lat: 37.751, Lon: -97.822, Country: "United States", CountryCode: "US", Count: 2},
}
html, err := GenerateHTML(data)
if err != nil {
t.Fatalf("GenerateHTML error: %v", err)
}
if !strings.Contains(html, "leaflet") {
t.Error("expected Leaflet.js reference when GeoLocations present")
}
// Flag helper must be present
if !strings.Contains(html, "countryFlag") {
t.Error("expected countryFlag JS function")
}
// Country code must be in the embedded geo JSON
if !strings.Contains(html, `"cc":"US"`) {
t.Error("expected CC field in geoJSON output")
}
// Map must appear before Top IPs (i.e. after Charts, not at the end)
mapIdx := strings.Index(html, `id="map"`)
topIPsIdx := strings.Index(html, "Top IPs")
if mapIdx == -1 || topIPsIdx == -1 {
t.Fatal("expected both map and Top IPs sections")
}
if mapIdx > topIPsIdx {
t.Error("map section should appear before Top IPs (i.e. after Charts)")
}
}
// ── multi-day fixture ──────────────────────────────────────────────────────
func makeMultiDayEntries() ([]parser.AccessEntry, []parser.ErrorEntry) {
day1 := time.Date(2024, 2, 20, 10, 0, 0, 0, time.UTC)
day2a := time.Date(2024, 2, 21, 14, 0, 0, 0, time.UTC)
day2b := time.Date(2024, 2, 21, 15, 0, 0, 0, time.UTC)
access := []parser.AccessEntry{
{RemoteAddr: "1.1.1.1", Method: "GET", Path: "/", Status: 200, BytesSent: 1024, Time: day1},
{RemoteAddr: "2.2.2.2", Method: "POST", Path: "/api", Status: 500, BytesSent: 256, Time: day2a},
{RemoteAddr: "1.1.1.1", Method: "GET", Path: "/", Status: 200, BytesSent: 512, Time: day2b},
}
errors := []parser.ErrorEntry{
{Time: day1, Level: "error", PID: 1, TID: 2, Message: "day1 error"},
{Time: day2a, Level: "warn", PID: 3, TID: 4, Message: "day2 warn"},
}
return access, errors
}
// ── DeriveDailyFilename ────────────────────────────────────────────────────
func TestDeriveDailyFilename(t *testing.T) {
cases := []struct{ output, date, want string }{
{"report.html", "2024-02-20", "report-2024-02-20.html"},
{"stats.html", "2024-03-01", "stats-2024-03-01.html"},
{"report", "2024-02-20", "report-2024-02-20"},
}
for _, c := range cases {
got := DeriveDailyFilename(c.output, c.date)
if got != c.want {
t.Errorf("DeriveDailyFilename(%q, %q) = %q, want %q", c.output, c.date, got, c.want)
}
}
}
// ── GroupByDay ─────────────────────────────────────────────────────────────
func TestGroupByDay_Partitioning(t *testing.T) {
access, errors := makeMultiDayEntries()
days := GroupByDay("report.html", access, errors, nil)
if len(days) != 2 {
t.Fatalf("expected 2 days, got %d", len(days))
}
if days[0].Date.Format("2006-01-02") != "2024-02-20" {
t.Errorf("expected oldest day first, got %s", days[0].Date.Format("2006-01-02"))
}
if days[1].Date.Format("2006-01-02") != "2024-02-21" {
t.Errorf("expected newest day second, got %s", days[1].Date.Format("2006-01-02"))
}
}
func TestGroupByDay_RequestCounts(t *testing.T) {
access, errors := makeMultiDayEntries()
days := GroupByDay("report.html", access, errors, nil)
if days[0].Data.TotalRequests != 1 {
t.Errorf("day1 requests = %d, want 1", days[0].Data.TotalRequests)
}
if days[1].Data.TotalRequests != 2 {
t.Errorf("day2 requests = %d, want 2", days[1].Data.TotalRequests)
}
}
func TestGroupByDay_ErrorPartitioning(t *testing.T) {
access, errors := makeMultiDayEntries()
days := GroupByDay("report.html", access, errors, nil)
if len(days[0].Data.ErrorEntries) != 1 {
t.Errorf("day1 error entries = %d, want 1", len(days[0].Data.ErrorEntries))
}
if len(days[1].Data.ErrorEntries) != 1 {
t.Errorf("day2 error entries = %d, want 1", len(days[1].Data.ErrorEntries))
}
}
func TestGroupByDay_Filenames(t *testing.T) {
access, errors := makeMultiDayEntries()
days := GroupByDay("report.html", access, errors, nil)
if days[0].Filename != "report-2024-02-20.html" {
t.Errorf("day1 filename = %q, want report-2024-02-20.html", days[0].Filename)
}
if days[1].Filename != "report-2024-02-21.html" {
t.Errorf("day2 filename = %q, want report-2024-02-21.html", days[1].Filename)
}
}
func TestGroupByDay_GeoFiltering(t *testing.T) {
access, errors := makeMultiDayEntries()
geoPoints := map[string]GeoPoint{
"1.1.1.1": {IP: "1.1.1.1", Lat: 37.7, Lon: -97.8, Country: "United States", CountryCode: "US"},
"2.2.2.2": {IP: "2.2.2.2", Lat: 51.5, Lon: -0.1, Country: "United Kingdom", CountryCode: "GB"},
}
days := GroupByDay("report.html", access, errors, geoPoints)
// day1 only has 1.1.1.1
if len(days[0].Data.GeoLocations) != 1 {
t.Errorf("day1 geo = %d, want 1", len(days[0].Data.GeoLocations))
}
if days[0].Data.GeoLocations[0].IP != "1.1.1.1" {
t.Errorf("day1 geo IP = %q, want 1.1.1.1", days[0].Data.GeoLocations[0].IP)
}
// day2 has both IPs
if len(days[1].Data.GeoLocations) != 2 {
t.Errorf("day2 geo = %d, want 2", len(days[1].Data.GeoLocations))
}
}
func TestGroupByDay_EmptyInput(t *testing.T) {
days := GroupByDay("report.html", nil, nil, nil)
if len(days) != 0 {
t.Errorf("expected 0 days, got %d", len(days))
}
}
func TestGroupByDay_StatsComputed(t *testing.T) {
access, errors := makeMultiDayEntries()
days := GroupByDay("report.html", access, errors, nil)
// day2 has one 500 → error rate 50%
if days[1].Data.ErrorRate < 49.9 || days[1].Data.ErrorRate > 50.1 {
t.Errorf("day2 error rate = %.2f, want ~50.0", days[1].Data.ErrorRate)
}
}
// ── GenerateIndexHTML ──────────────────────────────────────────────────────
func makeTestIndexData() IndexData {
return IndexData{
GeneratedAt: time.Date(2024, 2, 22, 12, 0, 0, 0, time.UTC),
TotalRequests: 300,
TotalUniqueIPs: 42,
TotalBytes: 1 << 20,
OverallErrorRate: 3.5,
Days: []DaySummary{
{
Date: time.Date(2024, 2, 21, 0, 0, 0, 0, time.UTC),
Filename: "report-2024-02-21.html",
Requests: 200, UniqueIPs: 30, TotalBytes: 700000, ErrorRate: 5.0,
},
{
Date: time.Date(2024, 2, 20, 0, 0, 0, 0, time.UTC),
Filename: "report-2024-02-20.html",
Requests: 100, UniqueIPs: 12, TotalBytes: 348000, ErrorRate: 1.0,
},
},
}
}
func TestGenerateIndexHTML_ReturnsHTML(t *testing.T) {
idx := makeTestIndexData()
html, err := GenerateIndexHTML(idx)
if err != nil {
t.Fatalf("GenerateIndexHTML error: %v", err)
}
if !strings.HasPrefix(strings.TrimSpace(html), "<!DOCTYPE html>") {
t.Error("expected output to start with <!DOCTYPE html>")
}
}
func TestGenerateIndexHTML_ContainsAggregateCards(t *testing.T) {
idx := makeTestIndexData()
html, _ := GenerateIndexHTML(idx)
for _, want := range []string{"Total Requests", "Unique IPs", "Total Bytes", "Overall Error Rate", "Days Covered"} {
if !strings.Contains(html, want) {
t.Errorf("missing card label %q", want)
}
}
}
func TestGenerateIndexHTML_ContainsDayLinks(t *testing.T) {
idx := makeTestIndexData()
html, _ := GenerateIndexHTML(idx)
if !strings.Contains(html, `href="report-2024-02-21.html"`) {
t.Error("expected link to report-2024-02-21.html")
}
if !strings.Contains(html, "2024-02-20") {
t.Error("expected date 2024-02-20 in table")
}
}
func TestGenerateIndexHTML_ErrorRateColouring(t *testing.T) {
idx := makeTestIndexData()
html, _ := GenerateIndexHTML(idx)
if !strings.Contains(html, "err-med") {
t.Error("expected err-med class for 5% error rate")
}
if !strings.Contains(html, "err-low") {
t.Error("expected err-low class for 1% error rate")
}
}
func TestGenerateIndexHTML_DaysNewestFirst(t *testing.T) {
idx := makeTestIndexData()
html, _ := GenerateIndexHTML(idx)
i21 := strings.Index(html, "2024-02-21")
i20 := strings.Index(html, "2024-02-20")
if i21 == -1 || i20 == -1 {
t.Fatal("expected both dates in index HTML")
}
if i21 > i20 {
t.Error("newest day (2024-02-21) should appear before older day in table")
}
}
// ── Back link in daily reports ─────────────────────────────────────────────
func TestGenerateHTML_BackLink(t *testing.T) {
data := makeTestData()
ComputeStats(&data)
data.IndexFile = "report.html"
html, err := GenerateHTML(data)
if err != nil {
t.Fatalf("GenerateHTML error: %v", err)
}
if !strings.Contains(html, `href="report.html"`) {
t.Error("expected back link href when IndexFile is set")
}
if !strings.Contains(html, "Back to Index") {
t.Error("expected 'Back to Index' text in nav")
}
}
func TestGenerateHTML_NoBackLinkByDefault(t *testing.T) {
data := makeTestData()
ComputeStats(&data)
// IndexFile is zero value ""
html, err := GenerateHTML(data)
if err != nil {
t.Fatalf("GenerateHTML error: %v", err)
}
if strings.Contains(html, "Back to Index") {
t.Error("expected no back link when IndexFile is empty")
}
}
func TestGenerateHTML_DayTitleInHeader(t *testing.T) {
data := makeTestData()
ComputeStats(&data)
data.DayTitle = "2024-02-20"
html, err := GenerateHTML(data)
if err != nil {
t.Fatalf("GenerateHTML error: %v", err)
}
if !strings.Contains(html, "2024-02-20") {
t.Error("expected DayTitle in report HTML")
}
}