Adds a new site extractor for pizzint.watch, which tracks pizza shop activity near the Pentagon as an OSINT indicator. The extractor fetches the dashboard API and exposes DOUGHCON levels, restaurant activity, and spike events. Includes a CLI tool with an HTTP server mode (--serve) for embedding the pizza status in dashboards or status displays. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
307 lines
7.4 KiB
Go
307 lines
7.4 KiB
Go
package pizzint
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
|
|
"gitea.stevedudenhoeffer.com/steve/go-extractor/extractortest"
|
|
)
|
|
|
|
const sampleAPIResponse = `{
|
|
"success": true,
|
|
"overall_index": 42,
|
|
"defcon_level": 3,
|
|
"data": [
|
|
{
|
|
"place_id": "abc123",
|
|
"name": "DOMINO'S PIZZA",
|
|
"address": "https://maps.google.com/test",
|
|
"current_popularity": 15,
|
|
"percentage_of_usual": null,
|
|
"is_spike": false,
|
|
"spike_magnitude": null,
|
|
"data_source": "live",
|
|
"data_freshness": "fresh",
|
|
"is_closed_now": false
|
|
},
|
|
{
|
|
"place_id": "def456",
|
|
"name": "EXTREME PIZZA",
|
|
"address": "https://maps.google.com/test2",
|
|
"current_popularity": 0,
|
|
"percentage_of_usual": null,
|
|
"is_spike": false,
|
|
"spike_magnitude": null,
|
|
"data_source": "live",
|
|
"data_freshness": "stale",
|
|
"is_closed_now": true
|
|
},
|
|
{
|
|
"place_id": "ghi789",
|
|
"name": "PIZZATO PIZZA",
|
|
"address": "https://maps.google.com/test3",
|
|
"current_popularity": 85,
|
|
"percentage_of_usual": 239,
|
|
"is_spike": true,
|
|
"spike_magnitude": "EXTREME",
|
|
"data_source": "live",
|
|
"data_freshness": "fresh",
|
|
"is_closed_now": false
|
|
}
|
|
],
|
|
"events": [
|
|
{
|
|
"name": "PIZZATO PIZZA",
|
|
"minutes_ago": 5
|
|
}
|
|
]
|
|
}`
|
|
|
|
func TestExtractStatus(t *testing.T) {
|
|
doc := &extractortest.MockDocument{
|
|
URLValue: dashboardAPIURL,
|
|
MockNode: extractortest.MockNode{
|
|
TextValue: sampleAPIResponse,
|
|
},
|
|
}
|
|
|
|
status, err := extractStatus(doc)
|
|
if err != nil {
|
|
t.Fatalf("extractStatus returned error: %v", err)
|
|
}
|
|
|
|
if status.DoughconLevel != DoughconElevated {
|
|
t.Errorf("DoughconLevel = %d, want %d", status.DoughconLevel, DoughconElevated)
|
|
}
|
|
|
|
if status.OverallIndex != 42 {
|
|
t.Errorf("OverallIndex = %d, want 42", status.OverallIndex)
|
|
}
|
|
|
|
if status.DoughconLabel != "ELEVATED" {
|
|
t.Errorf("DoughconLabel = %q, want %q", status.DoughconLabel, "ELEVATED")
|
|
}
|
|
|
|
if len(status.Restaurants) != 3 {
|
|
t.Fatalf("len(Restaurants) = %d, want 3", len(status.Restaurants))
|
|
}
|
|
|
|
// First restaurant: quiet
|
|
r0 := status.Restaurants[0]
|
|
if r0.Name != "DOMINO'S PIZZA" {
|
|
t.Errorf("Restaurants[0].Name = %q, want %q", r0.Name, "DOMINO'S PIZZA")
|
|
}
|
|
if r0.Status() != "QUIET" {
|
|
t.Errorf("Restaurants[0].Status() = %q, want %q", r0.Status(), "QUIET")
|
|
}
|
|
if r0.CurrentPopularity != 15 {
|
|
t.Errorf("Restaurants[0].CurrentPopularity = %d, want 15", r0.CurrentPopularity)
|
|
}
|
|
|
|
// Second restaurant: closed
|
|
r1 := status.Restaurants[1]
|
|
if r1.Status() != "CLOSED" {
|
|
t.Errorf("Restaurants[1].Status() = %q, want %q", r1.Status(), "CLOSED")
|
|
}
|
|
if !r1.IsClosed {
|
|
t.Error("Restaurants[1].IsClosed = false, want true")
|
|
}
|
|
|
|
// Third restaurant: spike
|
|
r2 := status.Restaurants[2]
|
|
if r2.Status() != "239% SPIKE" {
|
|
t.Errorf("Restaurants[2].Status() = %q, want %q", r2.Status(), "239% SPIKE")
|
|
}
|
|
if r2.SpikeMagnitude != "EXTREME" {
|
|
t.Errorf("Restaurants[2].SpikeMagnitude = %q, want %q", r2.SpikeMagnitude, "EXTREME")
|
|
}
|
|
if r2.CurrentPopularity != 85 {
|
|
t.Errorf("Restaurants[2].CurrentPopularity = %d, want 85", r2.CurrentPopularity)
|
|
}
|
|
|
|
// Events
|
|
if len(status.Events) != 1 {
|
|
t.Fatalf("len(Events) = %d, want 1", len(status.Events))
|
|
}
|
|
if status.Events[0].Name != "PIZZATO PIZZA" {
|
|
t.Errorf("Events[0].Name = %q, want %q", status.Events[0].Name, "PIZZATO PIZZA")
|
|
}
|
|
if status.Events[0].MinutesAgo != 5 {
|
|
t.Errorf("Events[0].MinutesAgo = %d, want 5", status.Events[0].MinutesAgo)
|
|
}
|
|
}
|
|
|
|
func TestExtractStatusFromHTML(t *testing.T) {
|
|
// Simulate Chromium wrapping JSON in a <pre> tag. doc.Text() returns
|
|
// the InnerText which may include the JSON, but Content() returns the
|
|
// raw HTML with the JSON inside <pre>.
|
|
htmlWrapped := `<html><head></head><body><pre style="word-wrap: break-word;">` + sampleAPIResponse + `</pre></body></html>`
|
|
|
|
doc := &extractortest.MockDocument{
|
|
URLValue: dashboardAPIURL,
|
|
MockNode: extractortest.MockNode{
|
|
// Text() might fail or return garbage
|
|
TextValue: "",
|
|
// Content() returns the HTML
|
|
ContentValue: htmlWrapped,
|
|
},
|
|
}
|
|
|
|
status, err := extractStatus(doc)
|
|
if err != nil {
|
|
t.Fatalf("extractStatus returned error: %v", err)
|
|
}
|
|
|
|
if status.DoughconLevel != DoughconElevated {
|
|
t.Errorf("DoughconLevel = %d, want %d", status.DoughconLevel, DoughconElevated)
|
|
}
|
|
|
|
if len(status.Restaurants) != 3 {
|
|
t.Errorf("len(Restaurants) = %d, want 3", len(status.Restaurants))
|
|
}
|
|
}
|
|
|
|
func TestExtractStatusFailure(t *testing.T) {
|
|
doc := &extractortest.MockDocument{
|
|
URLValue: dashboardAPIURL,
|
|
MockNode: extractortest.MockNode{
|
|
TextValue: `{"success": false}`,
|
|
ContentValue: `{"success": false}`,
|
|
},
|
|
}
|
|
|
|
_, err := extractStatus(doc)
|
|
if err == nil {
|
|
t.Fatal("expected error for success=false response")
|
|
}
|
|
}
|
|
|
|
func TestGetStatus(t *testing.T) {
|
|
mock := &extractortest.MockBrowser{
|
|
Documents: map[string]*extractortest.MockDocument{
|
|
dashboardAPIURL: {
|
|
URLValue: dashboardAPIURL,
|
|
MockNode: extractortest.MockNode{
|
|
TextValue: sampleAPIResponse,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
status, err := GetStatus(context.Background(), mock)
|
|
if err != nil {
|
|
t.Fatalf("GetStatus returned error: %v", err)
|
|
}
|
|
|
|
if status.OverallIndex != 42 {
|
|
t.Errorf("OverallIndex = %d, want 42", status.OverallIndex)
|
|
}
|
|
}
|
|
|
|
func TestFindJSON(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
want string
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "plain JSON",
|
|
input: `{"key": "value"}`,
|
|
want: `{"key": "value"}`,
|
|
},
|
|
{
|
|
name: "JSON in HTML",
|
|
input: `<html><pre>{"key": "value"}</pre></html>`,
|
|
want: `{"key": "value"}`,
|
|
},
|
|
{
|
|
name: "nested braces",
|
|
input: `{"a": {"b": "c"}}`,
|
|
want: `{"a": {"b": "c"}}`,
|
|
},
|
|
{
|
|
name: "braces in strings",
|
|
input: `{"a": "hello {world}"}`,
|
|
want: `{"a": "hello {world}"}`,
|
|
},
|
|
{
|
|
name: "escaped quotes",
|
|
input: `{"a": "he said \"hi\""}`,
|
|
want: `{"a": "he said \"hi\""}`,
|
|
},
|
|
{
|
|
name: "no JSON",
|
|
input: "just some text",
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "empty string",
|
|
input: "",
|
|
wantErr: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got, err := findJSON(tt.input)
|
|
if tt.wantErr {
|
|
if err == nil {
|
|
t.Error("expected error, got nil")
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if got != tt.want {
|
|
t.Errorf("findJSON() = %q, want %q", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDoughconLevelString(t *testing.T) {
|
|
tests := []struct {
|
|
level DoughconLevel
|
|
want string
|
|
}{
|
|
{DoughconQuiet, "DOUGHCON 5 - ALL QUIET"},
|
|
{DoughconWatch, "DOUGHCON 4 - DOUBLE TAKE"},
|
|
{DoughconElevated, "DOUGHCON 3 - ELEVATED"},
|
|
{DoughconHigh, "DOUGHCON 2 - HIGH ACTIVITY"},
|
|
{DoughconMaximum, "DOUGHCON 1 - MAXIMUM ALERT"},
|
|
{DoughconLevel(99), "DOUGHCON 99"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.want, func(t *testing.T) {
|
|
if got := tt.level.String(); got != tt.want {
|
|
t.Errorf("String() = %q, want %q", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRestaurantStatus(t *testing.T) {
|
|
pct := 150
|
|
tests := []struct {
|
|
name string
|
|
r Restaurant
|
|
want string
|
|
}{
|
|
{"quiet", Restaurant{Name: "Test"}, "QUIET"},
|
|
{"closed", Restaurant{IsClosed: true}, "CLOSED"},
|
|
{"spike with percent", Restaurant{IsSpike: true, PercentOfUsual: &pct}, "150% SPIKE"},
|
|
{"spike without percent", Restaurant{IsSpike: true}, "SPIKE"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := tt.r.Status(); got != tt.want {
|
|
t.Errorf("Status() = %q, want %q", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|