Files
GL/interface/web/handler_test.go
nfel 32e7b6b749
CI/CD Dev / dev (push) Failing after 44s
feat: add persistent dashboard dark mode
2026-08-15 00:53:58 +03:30

300 lines
11 KiB
Go

package web
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"gl/application/explorer"
"gl/domain/ledger"
)
type explorerStub struct {
dashboard explorer.Dashboard
dashboardErr error
assets explorer.Assets
assetsErr error
listing explorer.TransactionListing
listingErr error
transaction []ledger.Journal
transactionErr error
account explorer.Account
accountErr error
holder explorer.HolderAccount
holderErr error
lastHash string
lastFilter explorer.TransactionFilter
lastAccount ledger.AccountReference
}
func (s *explorerStub) Dashboard(context.Context) (explorer.Dashboard, error) {
return s.dashboard, s.dashboardErr
}
func (s *explorerStub) Assets(context.Context) (explorer.Assets, error) {
return s.assets, s.assetsErr
}
func (s *explorerStub) Transactions(_ context.Context, filter explorer.TransactionFilter) (explorer.TransactionListing, error) {
s.lastFilter = filter
return s.listing, s.listingErr
}
func (s *explorerStub) Transaction(_ context.Context, hash string) ([]ledger.Journal, error) {
s.lastHash = hash
return s.transaction, s.transactionErr
}
func (s *explorerStub) Account(_ context.Context, account ledger.AccountReference) (explorer.Account, error) {
s.lastAccount = account
return s.account, s.accountErr
}
func (s *explorerStub) Holder(_ context.Context, reference explorer.HolderReference) (explorer.HolderAccount, error) {
s.holder.Reference = reference
return s.holder, s.holderErr
}
func TestDashboardRendersFullTemplPage(t *testing.T) {
service := &explorerStub{dashboard: explorer.Dashboard{
Stats: explorer.Stats{JournalCount: 1234, LastRecordedAt: time.Unix(10, 0).UTC()},
Journals: []ledger.Journal{{
ID: "journal-1", EffectKind: "deposit", SourceService: "wallet",
Blockchain: ledger.BlockchainReference{TransactionHash: "abc123"},
}},
}}
request := httptest.NewRequest(http.MethodGet, "/", nil)
response := httptest.NewRecorder()
NewHandler(service).ServeHTTP(response, request)
body := response.Body.String()
for _, expected := range []string{"<!doctype html>", "DARANO", "1,234", "abc123", "htmx.org@2.0.10", "src=\"/theme.js\"", "data-theme-toggle", "html[data-theme=\"dark\"]", "class=\"mark\" src=\"/favicon.ico\"", "#F5F5F5", "#DFF1F1", "#BBD5DA", "#FF0000"} {
if !strings.Contains(body, expected) {
t.Fatalf("response did not contain %q", expected)
}
}
if response.Header().Get("Content-Security-Policy") == "" {
t.Fatal("expected content security policy")
}
}
func TestDashboardLinksInternalTransactionsByJournalID(t *testing.T) {
const journalID = "5c1e31b0-0000-4000-8000-0000084accc8"
service := &explorerStub{dashboard: explorer.Dashboard{Journals: []ledger.Journal{{ID: journalID}}}}
request := httptest.NewRequest(http.MethodGet, "/", nil)
response := httptest.NewRecorder()
NewHandler(service).ServeHTTP(response, request)
if !strings.Contains(response.Body.String(), `href="/transactions/`+journalID+`"`) {
t.Fatalf("internal transaction was not linked: %s", response.Body.String())
}
}
func TestAssetsPageRendersLinkedTopAssetHolders(t *testing.T) {
balance, err := ledger.ParseAmount("987.25")
if err != nil {
t.Fatal(err)
}
service := &explorerStub{assets: explorer.Assets{TopHolders: []explorer.Holder{{
Rank: 1, OwnerType: "user", OwnerID: "holder-42", AssetID: 7, Balance: balance,
}}}}
request := httptest.NewRequest(http.MethodGet, "/assets", nil)
response := httptest.NewRecorder()
NewHandler(service).ServeHTTP(response, request)
body := response.Body.String()
for _, expected := range []string{"Top asset holders", "holder-42", "Asset 7", "987.25", "/holders?asset_id=7&amp;owner_id=holder-42&amp;owner_type=user"} {
if !strings.Contains(body, expected) {
t.Fatalf("top holder response did not contain %q: %s", expected, body)
}
}
}
func TestHolderPageLoadsAggregateAccountDetail(t *testing.T) {
balance, err := ledger.ParseAmount("42.5")
if err != nil {
t.Fatal(err)
}
service := &explorerStub{holder: explorer.HolderAccount{Balance: balance}}
request := httptest.NewRequest(http.MethodGet, "/holders?owner_type=user&owner_id=holder-42&asset_id=7", nil)
response := httptest.NewRecorder()
NewHandler(service).ServeHTTP(response, request)
body := response.Body.String()
for _, expected := range []string{"holder-42", "Asset 7", "42.5", "AVAILABLE + FROZEN"} {
if !strings.Contains(body, expected) {
t.Fatalf("holder response did not contain %q: %s", expected, body)
}
}
}
func TestDashboardDefaultsToEnglishAndSupportsPersian(t *testing.T) {
service := &explorerStub{}
handler := NewHandler(service)
englishRequest := httptest.NewRequest(http.MethodGet, "/", nil)
englishResponse := httptest.NewRecorder()
handler.ServeHTTP(englishResponse, englishRequest)
if !strings.Contains(englishResponse.Body.String(), `lang="en" dir="ltr"`) || !strings.Contains(englishResponse.Body.String(), "Every movement") {
t.Fatalf("dashboard did not default to English: %s", englishResponse.Body.String())
}
persianRequest := httptest.NewRequest(http.MethodGet, "/?lang=fa", nil)
persianResponse := httptest.NewRecorder()
handler.ServeHTTP(persianResponse, persianRequest)
if !strings.Contains(persianResponse.Body.String(), `lang="fa" dir="rtl"`) || !strings.Contains(persianResponse.Body.String(), "هر جابه‌جایی") {
t.Fatalf("dashboard did not render Persian: %s", persianResponse.Body.String())
}
if !strings.Contains(persianResponse.Header().Get("Set-Cookie"), "gl_locale=fa") {
t.Fatal("Persian locale was not persisted")
}
}
func TestDashboardUsesLocaleCookie(t *testing.T) {
request := httptest.NewRequest(http.MethodGet, "/transactions", nil)
request.AddCookie(&http.Cookie{Name: localeCookie, Value: "fa"})
response := httptest.NewRecorder()
NewHandler(&explorerStub{}).ServeHTTP(response, request)
if !strings.Contains(response.Body.String(), "کاوشگر تراکنش") {
t.Fatalf("locale cookie was ignored: %s", response.Body.String())
}
}
func TestFaviconIsServedFromEmbeddedBrandAsset(t *testing.T) {
request := httptest.NewRequest(http.MethodGet, "/favicon.ico", nil)
response := httptest.NewRecorder()
NewHandler(&explorerStub{}).ServeHTTP(response, request)
if response.Code != http.StatusOK || response.Body.Len() < 1000 {
t.Fatalf("unexpected favicon response: status=%d bytes=%d", response.Code, response.Body.Len())
}
if !strings.Contains(response.Header().Get("Content-Type"), "image/") {
t.Fatalf("unexpected favicon content type: %s", response.Header().Get("Content-Type"))
}
}
func TestThemeScriptIsServedFromEmbeddedAsset(t *testing.T) {
request := httptest.NewRequest(http.MethodGet, "/theme.js", nil)
response := httptest.NewRecorder()
NewHandler(&explorerStub{}).ServeHTTP(response, request)
body := response.Body.String()
if response.Code != http.StatusOK || !strings.Contains(body, "localStorage") || !strings.Contains(body, "prefers-color-scheme") {
t.Fatalf("unexpected theme script response: status=%d body=%s", response.Code, body)
}
if !strings.Contains(response.Header().Get("Content-Type"), "javascript") {
t.Fatalf("unexpected theme script content type: %s", response.Header().Get("Content-Type"))
}
}
func TestTransactionHTMXRequestRendersOnlyExplorerContent(t *testing.T) {
service := &explorerStub{transaction: []ledger.Journal{{
ID: "journal-1", EffectKind: "withdrawal",
Blockchain: ledger.BlockchainReference{TransactionHash: "tx-hash"},
}}}
request := httptest.NewRequest(http.MethodGet, "/transactions?q=tx-hash", nil)
request.Header.Set("HX-Request", "true")
response := httptest.NewRecorder()
NewHandler(service).ServeHTTP(response, request)
body := response.Body.String()
if strings.Contains(body, "<!doctype html>") || !strings.Contains(body, `id="explorer-content"`) {
t.Fatalf("unexpected HTMX fragment: %s", body)
}
if service.lastHash != "tx-hash" || !strings.Contains(body, "COMMITTED") {
t.Fatalf("transaction was not rendered: %s", body)
}
}
func TestTransactionsPageShowsLatestTransactionsWithPagination(t *testing.T) {
service := &explorerStub{listing: explorer.TransactionListing{
Journals: []ledger.Journal{{ID: "latest-journal", EffectKind: "transfer"}},
Filter: explorer.TransactionFilter{Page: 2, Wallet: "wallet-42", EffectKind: "transfer"},
HasPrevious: true, HasNext: true,
}}
request := httptest.NewRequest(http.MethodGet, "/transactions?page=2&wallet=wallet-42&effect=transfer", nil)
response := httptest.NewRecorder()
NewHandler(service).ServeHTTP(response, request)
body := response.Body.String()
for _, expected := range []string{"Latest transactions", "latest-journal", "value=\"wallet-42\"", "value=\"transfer\"", "/transactions?effect=transfer&amp;page=1&amp;wallet=wallet-42", "/transactions?effect=transfer&amp;page=3&amp;wallet=wallet-42"} {
if !strings.Contains(body, expected) {
t.Fatalf("transaction listing did not contain %q: %s", expected, body)
}
}
if service.lastFilter.Page != 2 || service.lastFilter.Wallet != "wallet-42" || service.lastFilter.EffectKind != "transfer" {
t.Fatalf("unexpected transaction filter: %+v", service.lastFilter)
}
}
func TestMissingTransactionShowsLatestTransactions(t *testing.T) {
service := &explorerStub{
transactionErr: ledger.ErrNotFound,
listing: explorer.TransactionListing{Journals: []ledger.Journal{{ID: "latest-journal"}}, Filter: explorer.TransactionFilter{Page: 1}},
}
request := httptest.NewRequest(http.MethodGet, "/transactions?q=missing", nil)
response := httptest.NewRecorder()
NewHandler(service).ServeHTTP(response, request)
body := response.Body.String()
if !strings.Contains(body, "No committed transaction matches") || !strings.Contains(body, "latest-journal") {
t.Fatalf("missing transaction did not include latest activity: %s", body)
}
}
func TestAccountExplorerParsesStableAccountIdentity(t *testing.T) {
balance, err := ledger.ParseAmount("42.5")
if err != nil {
t.Fatal(err)
}
service := &explorerStub{account: explorer.Account{Balance: balance, Reference: ledger.AccountReference{
Class: ledger.AccountClassUserAvailable, OwnerType: "user", OwnerID: "17", AssetID: 9,
}}}
request := httptest.NewRequest(http.MethodGet, "/accounts?class=USER_AVAILABLE&owner_type=user&owner_id=17&asset_id=9", nil)
response := httptest.NewRecorder()
NewHandler(service).ServeHTTP(response, request)
if service.lastAccount.OwnerID != "17" || service.lastAccount.AssetID != 9 {
t.Fatalf("unexpected account lookup: %+v", service.lastAccount)
}
if !strings.Contains(response.Body.String(), "42.5") {
t.Fatal("account balance was not rendered")
}
}
func TestCanceledDashboardRequestDoesNotRenderAnErrorPage(t *testing.T) {
service := &explorerStub{dashboardErr: context.Canceled}
ctx, cancel := context.WithCancel(context.Background())
cancel()
request := httptest.NewRequest(http.MethodGet, "/", nil).WithContext(ctx)
response := httptest.NewRecorder()
NewHandler(service).ServeHTTP(response, request)
if response.Body.Len() != 0 {
t.Fatalf("expected canceled request to produce no response, got %q", response.Body.String())
}
}
func TestFormatTimeUsesTehranTimezone(t *testing.T) {
value := time.Date(2026, time.August, 14, 0, 0, 0, 0, time.UTC)
if got := formatTime(value); got != "14 Aug 2026 · 03:30:00 Asia/Tehran" {
t.Fatalf("unexpected localized time: %q", got)
}
}