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{"", "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&owner_id=holder-42&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, "") || !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&page=1&wallet=wallet-42", "/transactions?effect=transfer&page=3&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) } }