From d63cf48bce4c65d7c6cad82445e5090d393590cf Mon Sep 17 00:00:00 2001
From: Zenitsu <174907517+nayandas69@users.noreply.github.com>
Date: Sat, 31 Oct 2026 18:45:20 +0000
Subject: [PATCH 1/3] feat: release v0.1.7 with persistent profile view badges
---
.env.example | 113 ++--
.github/workflows/ci.yml | 157 +----
.node-version | 1 +
ARCHITECTURE.md | 31 -
README.md | 53 +-
__tests__/card-renderer.test.ts | 9 +
__tests__/card-route.test.ts | 29 +
__tests__/profile-views.test.ts | 157 +++++
__tests__/views-storage.integration.test.ts | 153 +++++
__tests__/views-storage.test.ts | 36 ++
package.json | 98 +--
pnpm-lock.yaml | 651 ++++++++++----------
pnpm-workspace.yaml | 4 +
src/app.ts | 36 +-
src/services/card.ts | 6 +-
src/services/github.ts | 9 +-
src/services/redis-credentials.ts | 9 +
src/services/views.ts | 100 +++
vercel.json | 14 +-
19 files changed, 1043 insertions(+), 623 deletions(-)
create mode 100644 .node-version
delete mode 100644 ARCHITECTURE.md
create mode 100644 __tests__/card-route.test.ts
create mode 100644 __tests__/profile-views.test.ts
create mode 100644 __tests__/views-storage.integration.test.ts
create mode 100644 __tests__/views-storage.test.ts
create mode 100644 src/services/redis-credentials.ts
create mode 100644 src/services/views.ts
diff --git a/.env.example b/.env.example
index b6e04a8..58149dc 100644
--- a/.env.example
+++ b/.env.example
@@ -1,55 +1,58 @@
-# ============================================================
-# GitHub Profile Card - Environment Variables
-# Copy this file to .env and fill in your values:
-# cp .env.example .env
-# ============================================================
-
-# -----------------------------------------------
-# REQUIRED: GitHub Personal Access Token
-# -----------------------------------------------
-# Used to query the GitHub GraphQL API for user profiles.
-#
-# How to get it:
-# 1. Go to https://github.com/settings/tokens
-# 2. Click "Generate new token (classic)"
-# 3. Select scopes: "read:user" and "repo" (for public repo data)
-# 4. Generate and copy the token
-#
-# Or use a fine-grained token:
-# 1. Go to https://github.com/settings/personal-access-tokens/new
-# 2. Set permissions: "Account" -> "Read-only" for Profile
-# 3. Generate and copy the token
-#
-GITHUB_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
-
-# -----------------------------------------------
-# OPTIONAL: Upstash Redis (for caching)
-# -----------------------------------------------
-# Provides a distributed cache layer so responses are fast
-# even on cold starts. Without these, only in-memory caching is used.
-#
-# How to get it:
-# 1. Go to https://console.upstash.com/
-# 2. Create a new Redis database (free tier is fine)
-# 3. Copy the REST URL and Token from the database details page
-#
-# IMPORTANT: Free-tier Upstash databases are deleted after 14 days
-# of inactivity. Use the included GitHub Action (upstash-keep-alive.yml)
-# to prevent this by pinging your database every 24 hours.
-#
-UPSTASH_REDIS_REST_URL=https://xxxxxxxx.upstash.io
-UPSTASH_REDIS_REST_TOKEN=AxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxE=
-
-# -----------------------------------------------
-# OPTIONAL: Server Port (for local development)
-# -----------------------------------------------
-# The port the dev server listens on. Defaults to 3000.
-#
-# PORT=3000
-
-# -----------------------------------------------
-# OPTIONAL: Max In-Flight Requests
-# -----------------------------------------------
-# Limits the number of concurrent requests to the GitHub API.
-# Adjust this based on your expected traffic and GitHub API rate limits.
-# MAX_IN_FLIGHT_REQUESTS=100
+# ============================================================
+# GitHub Profile Card - Environment Variables
+# Copy this file to .env and fill in your values:
+# cp .env.example .env
+# ============================================================
+
+# -----------------------------------------------
+# REQUIRED: GitHub Personal Access Token
+# -----------------------------------------------
+# Used to query the GitHub GraphQL API for user profiles.
+#
+# How to get it:
+# 1. Go to https://github.com/settings/tokens
+# 2. Click "Generate new token (classic)"
+# 3. Select scopes: "read:user" and "repo" (for public repo data)
+# 4. Generate and copy the token
+#
+# Or use a fine-grained token:
+# 1. Go to https://github.com/settings/personal-access-tokens/new
+# 2. Set permissions: "Account" -> "Read-only" for Profile
+# 3. Generate and copy the token
+#
+GITHUB_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
+
+# -----------------------------------------------
+# REQUIRED for profile view badges; OPTIONAL for card caching
+# -----------------------------------------------
+# Provides a distributed cache layer so responses are fast
+# even on cold starts. Without these, cards use in-memory caching
+# and /profile/:username returns 503. Badge counters do not expire.
+# Use persistent storage with no eviction for profile-views:v1:*
+# and profile-views:v2. New counters are limited to 100,000 usernames.
+#
+# How to get it:
+# 1. Go to https://console.upstash.com/
+# 2. Create a new Redis database (free tier is fine)
+# 3. Copy the REST URL and Token from the database details page
+#
+# IMPORTANT: Free-tier Upstash databases are deleted after 14 days
+# of inactivity. Use the included GitHub Action (upstash-keep-alive.yml)
+# to prevent this by pinging your database every 24 hours.
+#
+UPSTASH_REDIS_REST_URL=https://xxxxxxxx.upstash.io
+UPSTASH_REDIS_REST_TOKEN=AxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxE=
+
+# -----------------------------------------------
+# OPTIONAL: Server Port (for local development)
+# -----------------------------------------------
+# The port the dev server listens on. Defaults to 3000.
+#
+# PORT=3000
+
+# -----------------------------------------------
+# OPTIONAL: Max In-Flight Requests
+# -----------------------------------------------
+# Limits the number of concurrent requests to the GitHub API.
+# Adjust this based on your expected traffic and GitHub API rate limits.
+# MAX_IN_FLIGHT_REQUESTS=100
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 493f9d6..debf92b 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,12 +1,3 @@
-# CI Pipeline - Sequential Node versions: 20 → 22 → 24 + final summary
-# All three versions run one after another regardless of the previous result.
-# A final "CI Summary" job reports the outcome:
-# - all 3 passed -> "All passed" (green)
-# - none passed -> "All failed/skipped/cancelled" (red)
-# - some passed -> "N passed, M failed/cancelled/skipped" (red)
-#
-# Author: Nayan Das (https://github.com/nayandas69)
-
name: CI
on:
@@ -16,140 +7,22 @@ on:
branches: [main]
jobs:
- test-node-20:
- name: Test (Node 20)
- runs-on: ubuntu-latest
- continue-on-error: true
- outputs:
- passed: ${{ steps.check.outcome == 'success' }}
- steps:
- - name: Checkout repository
- uses: actions/checkout@v7.0.1
-
- - name: Install pnpm
- uses: pnpm/action-setup@v6.1.0
-
- - name: Setup Node.js 20
- uses: actions/setup-node@v7.1.0
- with:
- node-version: 20
- cache: pnpm
-
- - name: Install dependencies
- run: pnpm install --frozen-lockfile
-
- - name: Type check
- run: pnpm typecheck
-
- - name: Lint
- run: pnpm lint
-
- - name: Run tests
- id: check
- run: pnpm test
- env:
- GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
-
- test-node-22:
- name: Test (Node 22)
- needs: test-node-20
- if: always()
+ check:
+ name: Check (Node 26 LTS)
runs-on: ubuntu-latest
- continue-on-error: true
- outputs:
- passed: ${{ steps.check.outcome == 'success' }}
steps:
- - name: Checkout repository
- uses: actions/checkout@v7.0.1
-
- - name: Install pnpm
- uses: pnpm/action-setup@v6.1.0
-
- - name: Setup Node.js 22
- uses: actions/setup-node@v7.1.0
+ - uses: actions/checkout@v7.0.1
+ - uses: actions/setup-node@v7.1.0
with:
- node-version: 22
- cache: pnpm
-
- - name: Install dependencies
- run: pnpm install --frozen-lockfile
-
- - name: Type check
- run: pnpm typecheck
-
- - name: Lint
- run: pnpm lint
-
- - name: Run tests
- id: check
- run: pnpm test
- env:
- GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
-
- test-node-24:
- name: Test (Node 24)
- needs: test-node-22
- if: always()
- runs-on: ubuntu-latest
- continue-on-error: true
- outputs:
- passed: ${{ steps.check.outcome == 'success' }}
- steps:
- - name: Checkout repository
- uses: actions/checkout@v7.0.1
-
- - name: Install pnpm
- uses: pnpm/action-setup@v6.1.0
-
- - name: Setup Node.js 24
- uses: actions/setup-node@v7.1.0
- with:
- node-version: 24
- cache: pnpm
-
- - name: Install dependencies
- run: pnpm install --frozen-lockfile
-
- - name: Type check
- run: pnpm typecheck
-
- - name: Lint
- run: pnpm lint
-
- - name: Run tests
- id: check
- run: pnpm test
- env:
- GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
-
- ci-summary:
- name: CI Summary
- needs: [test-node-20, test-node-22, test-node-24]
- if: always()
- runs-on: ubuntu-latest
- steps:
- - name: Report results across all Node versions
+ node-version-file: .node-version
+ - uses: pnpm/action-setup@v6.1.0
+ - name: Install Redis for integration tests
run: |
- PASS=0
- for r in \
- "${{ needs.test-node-20.outputs.passed }}" \
- "${{ needs.test-node-22.outputs.passed }}" \
- "${{ needs.test-node-24.outputs.passed }}"; do
- [ "$r" = "true" ] && PASS=$((PASS+1))
- done
- FAIL=$((3 - PASS))
-
- case "$PASS" in
- 3)
- echo "✅ All passed - Node 20, 22, 24 (3/3)"
- exit 0
- ;;
- 0)
- echo "❌ All failed - no Node version passed (0/3 failed/skipped/cancelled)"
- exit 1
- ;;
- *)
- echo "⚠️ $PASS passed, $FAIL failed/cancelled/skipped"
- exit 1
- ;;
- esac
+ sudo apt-get update -qq
+ sudo apt-get install -y redis-server
+ - run: pnpm install --frozen-lockfile
+ - run: pnpm format:check
+ - run: pnpm typecheck
+ - run: pnpm lint
+ - run: pnpm test
+ - run: pnpm build
diff --git a/.node-version b/.node-version
new file mode 100644
index 0000000..1dda03d
--- /dev/null
+++ b/.node-version
@@ -0,0 +1 @@
+26.13.0
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
deleted file mode 100644
index a0bc704..0000000
--- a/ARCHITECTURE.md
+++ /dev/null
@@ -1,31 +0,0 @@
-### Architectural Organisation
-
-```
-github-profile-card/
-├── .github/
-│ └── workflows/
-│ └── ci.yml # Continuous integration pipeline definition
-│ └── upstash-keep-alive.yml # Upstash Redis keep-alive workflow
-├── api/
-│ └── index.ts # Vercel serverless function export
-├── src/
-│ ├── app.ts # Hono framework application and route definitions
-│ ├── server.ts # Node.js server principal execution point
-│ ├── services/
-│ │ ├── card.ts # SVG card rendering engine
-│ │ ├── github.ts # GitHub API client with caching integration
-│ │ └── index.ts # Service module exports
-│ ├── types/
-│ │ └── index.ts # TypeScript interface declarations
-│ └── utils/
-│ ├── format.ts # Numeric notation conversion (k, M, etc.)
-│ ├── icons.ts # SVG iconographic components
-│ ├── languages.ts # Linguistic colour palette mapping
-│ ├── themes.ts # Thematic definitions and colour resolution
-│ └── index.ts # Utility module exports
-├── __tests__/ # Test specification files
-├── .env.example # Environment variable template
-├── package.json # Project metadata and executable scripts
-├── tsconfig.json # TypeScript configuration parameters
-└── vitest.config.ts # Test execution framework configuration
-```
diff --git a/README.md b/README.md
index 0cb460c..99cdada 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,6 @@

+
---
@@ -8,9 +9,9 @@
[](https://github.com/nayandas69/github-profile-card/actions/workflows/ci.yml)
[](LICENSE)
-[](package.json)
+[](package.json)
[](tsconfig.json)
-[](package.json)
+[](package.json)
[](https://github-profile-card-blue.vercel.app)
[](https://discord.gg/u9XfHZN8K9)
@@ -723,8 +724,12 @@ Replace `{username}` with any GitHub username and `{theme}` with one of our 56 a
## Quickstart Guide
### Prerequisites
-- **Node.js** version 24.x or contemporary iteration
-- **pnpm** version 12.14.0 or equivalent package manager (npm or yarn acceptable)
+
+- **Node.js** 26 LTS, version 26.13.0 (see `.node-version`)
+
+
+- **pnpm** 12.17.0 (pinned in `packageManager`)
+
- **GitHub Personal Access Token** for API authentication
### Installation Protocol
@@ -737,7 +742,15 @@ Replace `{username}` with any GitHub username and `{theme}` with one of our 56 a
2. **Dependency Installation**
```bash
+ sudo apt-get update
+ sudo apt-get install -y redis-server
pnpm install
+ pnpm run format:check
+ pnpm run format
+ pnpm typecheck
+ pnpm lint
+ pnpm test
+ pnpm build
```
3. **Environment Configuration Initialisation**
@@ -830,7 +843,7 @@ check the `.env.example` file for a detailed template of required environment va
```json
{
"name": "GitHub Profile Card API",
- "version": "0.1.6",
+ "version": "0.1.7",
"author": "Nayan Das (https://github.com/nayandas69)",
"usage": "GET /card/:username",
"themes": [
@@ -848,7 +861,7 @@ Returns comprehensive API metadata and accessible thematic variations.
```json
{
"name": "GitHub Profile Card API",
- "version": "0.1.6",
+ "version": "0.1.7",
"themes": ["default", "dark", "radical", ...]
}
```
@@ -872,7 +885,33 @@ Generates a customised SVG profile representation for the designated GitHub user
**Response Specification:** SVG image with appropriate HTTP caching directives
+### Profile View Badge: `GET /profile/:username`
+
+Add this to your GitHub profile repository (`username/username`):
+
+```markdown
+
+
+```
+
+Replace `nayandas69` with your GitHub username. The badge includes a GitHub icon and supports all 56 card themes using the `theme` parameter. Omitting the parameter keeps the gray and blue badge; unknown theme names use the card's default theme. Foreground colors adjust when needed for readability. All themes share the same username counter.
+
+Each successful GET reaching this service atomically increments a persistent Redis counter. Usernames are case insensitive; HEAD and OPTIONS requests do not increment it. The endpoint counts image requests, including bots and repeated visits, rather than unique visitors. It does not call GitHub to verify that an account exists.
+
+**GitHub caching:** this endpoint sends browser and CDN `no-store` headers, but GitHub serves README images through its Camo image proxy. A GitHub page refresh does not necessarily request the image from this service. Exact counts of every GitHub profile page refresh cannot be guaranteed by an embedded image.
+
+**Deployment:** configure a complete `UPSTASH_REDIS_REST_URL` and `UPSTASH_REDIS_REST_TOKEN` pair (or `KV_REST_API_URL` and `KV_REST_API_TOKEN`). Redis must support `EVAL`, `EXISTS`, `INCR`, `HEXISTS`, `HLEN`, and `HINCRBY`. Redis is required for badges and remains optional for card caching. New counters are stored without expiration in the `profile-views:v2` hash, limited atomically to 100,000 usernames across application instances. Existing counters can continue to increment when the limit is reached; new usernames receive HTTP 503. This cap bounds new counter storage, but does not prevent request floods or protect provider request quotas; configure appropriate request limits separately. Use persistent storage with no eviction and back up both counter namespaces.
+
+**Upgrading an earlier badge deployment:** existing `profile-views:v1:` counters are retained and incremented in place. Stop all instances of the earlier implementation before starting this one: older instances can create v1 keys while new instances create v2 fields, splitting the count. The v2 cap applies to new counters; existing v1 keys are not included in that cap. Do not delete old keys or the v2 hash unless resetting their counts is intended.
+
+Storage failures return an SVG marked `unavailable` with HTTP 503 and no cache; there is no temporary in-memory total. Ambiguous failed writes are not retried automatically. A timeout after Redis committed an increment can still increase the total without a successful image response. Invalid usernames return HTTP 400.
+
+**Testing:** `pnpm test` requires `redis-server` and `redis-cli` on the PATH for the isolated counter integration test. The test starts a temporary local Redis instance without persistence or external credentials and stops it afterward.
+
+Vercel rewrites application paths to `api/index.ts`; use the Node 26 LTS runtime in the deployment project. No GitHub token is needed for badges. Existing `/card/:username` requests retain their caching and GitHub data behavior.
+
### Health Status Endpoint: `GET /health`
+
Elementary server availability verification mechanism.
**Response Payload:**
@@ -929,5 +968,3 @@ For technical assistance, inquiries, or feature recommendations:
---
**Developed by Nayan Das**
-
-
diff --git a/__tests__/card-renderer.test.ts b/__tests__/card-renderer.test.ts
index e39af6e..0ed52bc 100644
--- a/__tests__/card-renderer.test.ts
+++ b/__tests__/card-renderer.test.ts
@@ -56,6 +56,15 @@ describe('renderCard', () => {
expect(svg).toContain('Test User');
});
+ it('escapes avatar URLs and language colors in SVG attributes', () => {
+ const user = { ...mockUser, avatarUrl: 'https://example.test/image?x=1&y="bad"' };
+ const langs = [{ name: 'Test', size: 1, color: '#fff" onload="alert(1)' }];
+ const svg = renderCard(user, mockStats, langs);
+ expect(svg).toContain('href="https://example.test/image?x=1&y="bad""');
+ expect(svg).toContain('fill="#fff" onload="alert(1)"');
+ expect(svg).not.toContain('" onload="alert(1)"');
+ });
+
it('includes the username with @ prefix', () => {
const svg = renderCard(mockUser, mockStats, mockLangs);
expect(svg).toContain('@testuser');
diff --git a/__tests__/card-route.test.ts b/__tests__/card-route.test.ts
new file mode 100644
index 0000000..6ef68f8
--- /dev/null
+++ b/__tests__/card-route.test.ts
@@ -0,0 +1,29 @@
+import { describe, expect, it, vi } from 'vitest';
+
+const { getProfileData } = vi.hoisted(() => ({ getProfileData: vi.fn() }));
+vi.mock('../src/services/index.js', () => ({ getProfileData, renderCard: vi.fn() }));
+import app from '../src/app.js';
+
+describe('card route errors', () => {
+ it.each([
+ [new Error('User not found'), 404, 'GitHub user "octocat" not found'],
+ [
+ new Error('GitHub API rate limit exceeded'),
+ 429,
+ 'GitHub API rate limit exceeded. Please try again later.',
+ ],
+ [new Error('GitHub API error (503)'), 503, 'GitHub API error. Please try again later.'],
+ [
+ new Error('GITHUB_TOKEN is missing'),
+ 500,
+ 'Server configuration error. Please contact the administrator.',
+ ],
+ [new Error('sensitive internal detail'), 500, 'An unexpected error occurred'],
+ [{ message: 'sensitive internal detail' }, 500, 'An unexpected error occurred'],
+ ])('handles failures without disclosing internal details', async (failure, status, message) => {
+ getProfileData.mockRejectedValueOnce(failure);
+ const response = await app.request('/card/octocat');
+ expect(response.status).toBe(status);
+ expect(await response.json()).toEqual({ error: message });
+ });
+});
diff --git a/__tests__/profile-views.test.ts b/__tests__/profile-views.test.ts
new file mode 100644
index 0000000..c615a1f
--- /dev/null
+++ b/__tests__/profile-views.test.ts
@@ -0,0 +1,157 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+const { evalMock, RedisMock } = vi.hoisted(() => {
+ const evalMock = vi.fn();
+ const RedisMock = vi.fn(function () {
+ return { eval: evalMock };
+ });
+ return { evalMock, RedisMock };
+});
+vi.mock('@upstash/redis', () => ({ Redis: RedisMock }));
+import app from '../src/app.js';
+import { incrementProfileViews, renderProfileViews } from '../src/services/views.js';
+import { themes } from '../src/utils/themes.js';
+
+beforeEach(() => {
+ process.env['UPSTASH_REDIS_REST_URL'] = 'https://redis.example.test';
+ process.env['UPSTASH_REDIS_REST_TOKEN'] = 'synthetic-test-token';
+ delete process.env['KV_REST_API_URL'];
+ delete process.env['KV_REST_API_TOKEN'];
+ evalMock.mockReset();
+ RedisMock.mockClear();
+});
+
+describe('profile view badge', () => {
+ it('counts each GET using case insensitive keys and no caching', async () => {
+ let count = 0;
+ evalMock.mockImplementation(async (_script, keys) =>
+ keys[0] === 'profile-views:v2' ? ++count : 0
+ );
+ const responses = await Promise.all(
+ Array.from({ length: 20 }, () => app.request('/profile/NayanDas69'))
+ );
+ const bodies = await Promise.all(responses.map((r) => r.text()));
+ expect(new Set(bodies).size).toBe(20);
+ expect(evalMock).toHaveBeenCalledTimes(40);
+ expect(evalMock).toHaveBeenCalledWith(expect.any(String), ['profile-views:v1:nayandas69'], []);
+ expect(evalMock).toHaveBeenCalledWith(
+ expect.any(String),
+ ['profile-views:v2'],
+ ['nayandas69', 100000]
+ );
+ for (const response of responses) {
+ expect(response.status).toBe(200);
+ expect(response.headers.get('content-type')).toContain('image/svg+xml');
+ for (const header of ['cache-control', 'cdn-cache-control', 'vercel-cdn-cache-control']) {
+ expect(response.headers.get(header)).toContain('no-store');
+ }
+ }
+ expect(RedisMock).toHaveBeenCalledWith(
+ expect.objectContaining({ retry: { retries: 0 }, signal: expect.any(Function) })
+ );
+ });
+
+ it('continues existing legacy counters without creating a new hash field', async () => {
+ evalMock.mockResolvedValueOnce(42);
+ expect(await incrementProfileViews('NayanDas69')).toBe(42);
+ expect(evalMock).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not increment on HEAD or OPTIONS probes', async () => {
+ const head = await app.request('/profile/nayandas69', { method: 'HEAD' });
+ expect(head.status).toBe(200);
+ expect(await head.text()).toBe('');
+ expect((await app.request('/profile/nayandas69', { method: 'OPTIONS' })).status).toBe(204);
+ expect(evalMock).not.toHaveBeenCalled();
+ });
+
+ it.each(['-bad', 'bad-', 'bad--name', 'bad_name', 'a'.repeat(40), '%3Cscript%3E'])(
+ 'rejects invalid username %s before storage',
+ async (name) => {
+ expect((await app.request(`/profile/${name}`)).status).toBe(400);
+ expect(evalMock).not.toHaveBeenCalled();
+ }
+ );
+
+ it('renders a themed non-cacheable failure for storage errors and capacity limits', async () => {
+ evalMock.mockResolvedValueOnce(0).mockResolvedValueOnce(-1);
+ const response = await app.request('/profile/nayandas69?theme=dracula');
+ expect(response.status).toBe(503);
+ const body = await response.text();
+ expect(body).toContain('unavailable');
+ expect(body).toContain('#282a36');
+ expect(response.headers.get('retry-after')).toBe('30');
+ expect(response.headers.get('cache-control')).toContain('no-store');
+ });
+
+ it('returns an unavailable badge when storage fails', async () => {
+ evalMock.mockRejectedValueOnce(new Error('sensitive provider details'));
+ const response = await app.request('/profile/nayandas69');
+ expect(response.status).toBe(503);
+ const body = await response.text();
+ expect(body).toContain('unavailable');
+ expect(body).not.toContain('sensitive');
+ expect(evalMock).toHaveBeenCalledTimes(1);
+ });
+
+ it('increments the same counter across themes', async () => {
+ let count = 0;
+ evalMock.mockImplementation(async (_script, keys) =>
+ keys[0] === 'profile-views:v2' ? ++count : 0
+ );
+ const first = await app.request('/profile/Example?theme=dracula');
+ const second = await app.request('/profile/example?theme=nord');
+ expect(await first.text()).toContain('Profile views: 1');
+ expect(await second.text()).toContain('Profile views: 2');
+ expect(evalMock).toHaveBeenCalledWith(
+ expect.any(String),
+ ['profile-views:v2'],
+ ['example', 100000]
+ );
+ });
+
+ it('chooses complete credential pairs and prefers Upstash', async () => {
+ process.env['UPSTASH_REDIS_REST_URL'] = 'https://partial.example.test';
+ delete process.env['UPSTASH_REDIS_REST_TOKEN'];
+ process.env['KV_REST_API_URL'] = 'https://kv.example.test';
+ process.env['KV_REST_API_TOKEN'] = 'kv-synthetic';
+ evalMock.mockResolvedValue(1);
+ expect(await incrementProfileViews('test')).toBe(1);
+ expect(RedisMock).toHaveBeenLastCalledWith(
+ expect.objectContaining({ url: 'https://kv.example.test', token: 'kv-synthetic' })
+ );
+ process.env['UPSTASH_REDIS_REST_TOKEN'] = 'upstash-synthetic';
+ expect(await incrementProfileViews('test')).toBe(1);
+ expect(RedisMock).toHaveBeenLastCalledWith(
+ expect.objectContaining({ url: 'https://partial.example.test', token: 'upstash-synthetic' })
+ );
+ delete process.env['KV_REST_API_URL'];
+ delete process.env['UPSTASH_REDIS_REST_TOKEN'];
+ await expect(incrementProfileViews('test')).rejects.toThrow('not configured');
+ });
+
+ it.each([-1, Number.MAX_SAFE_INTEGER + 1, NaN])('rejects invalid counter %s', async (count) => {
+ evalMock.mockResolvedValue(count);
+ await expect(incrementProfileViews('test')).rejects.toThrow('unavailable');
+ });
+
+ it('renders GitHub icon and accessible count for every supported theme', async () => {
+ expect(Object.keys(themes)).toHaveLength(56);
+ evalMock.mockResolvedValue(26703);
+ for (const [name, palette] of Object.entries(themes)) {
+ const response = await app.request(`/profile/test?theme=${name}`);
+ const svg = await response.text();
+ expect(response.status).toBe(200);
+ expect(svg).toContain('Profile views: 26,703');
+ expect(svg).toContain(`fill="#${palette.bg}"`);
+ expect(svg).toContain(`fill="#${palette.title}"`);
+ expect(svg).toContain('aria-hidden="true"');
+ expect(svg).toContain('M8 0C3.58');
+ }
+ expect(renderProfileViews(1, '__proto__')).toBe(renderProfileViews(1, 'default'));
+ expect(renderProfileViews(1, '