Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .github/workflows/integration-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
name: Supabase Integration Tests

on:
workflow_dispatch:

jobs:
integration-tests:
name: Run Supabase integration tests
runs-on: ubuntu-latest

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm

- name: Install dependencies
run: npm ci

- name: Typecheck
run: npm run typecheck

- name: Supabase integration tests
run: npm run test:integration
env:
SUPABASE_TEST_GAME_QUERY: ${{ vars.SUPABASE_TEST_GAME_QUERY || 'Street' }}
SUPABASE_TEST_KEY: ${{ secrets.SUPABASE_TEST_KEY }}
SUPABASE_TEST_LATITUDE: ${{ vars.SUPABASE_TEST_LATITUDE || '32.7767' }}
SUPABASE_TEST_LONGITUDE: ${{ vars.SUPABASE_TEST_LONGITUDE || '-96.797' }}
SUPABASE_TEST_URL: ${{ secrets.SUPABASE_TEST_URL }}
SUPABASE_TEST_USER_EMAIL: ${{ secrets.SUPABASE_TEST_USER_EMAIL }}
SUPABASE_TEST_USER_PASSWORD: ${{ secrets.SUPABASE_TEST_USER_PASSWORD }}
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,32 @@ Web export smoke test:
npm run build:web
```

Optional Supabase integration tests:

```bash
npm run test:integration
```

These tests are skipped unless test-specific Supabase env vars are present. They
exercise live RPC contracts and, when a test user is configured, submit a pending
inventory report, verify duplicate blocking, then withdraw the report.

Required local env vars for live integration tests:

- `SUPABASE_TEST_URL`
- `SUPABASE_TEST_KEY`

Required for writable contribution-flow tests:

- `SUPABASE_TEST_USER_EMAIL`
- `SUPABASE_TEST_USER_PASSWORD`

Optional:

- `SUPABASE_TEST_GAME_QUERY`
- `SUPABASE_TEST_LATITUDE`
- `SUPABASE_TEST_LONGITUDE`

Current test coverage starts with pure unit tests for formatting, distance/map
region calculations, and search helpers. The web export step acts as a lightweight
integration smoke test for Expo Router and the web bundle.
Expand All @@ -81,12 +107,17 @@ GitHub Actions workflows live in `.github/workflows`.
- `CI`: runs on pull requests and pushes to `main`.
- `Deploy Demo`: manual workflow that typechecks, tests, then deploys the EAS
Hosting `demo` alias.
- `Supabase Integration Tests`: manual workflow for live backend contract tests.

Required GitHub repository secrets:

- `EXPO_PUBLIC_SUPABASE_URL`
- `EXPO_PUBLIC_SUPABASE_KEY`
- `EXPO_TOKEN` for the manual EAS demo deploy workflow
- `SUPABASE_TEST_URL`
- `SUPABASE_TEST_KEY`
- `SUPABASE_TEST_USER_EMAIL`
- `SUPABASE_TEST_USER_PASSWORD`

The workflows use `npm ci`, so keep `package-lock.json` committed whenever
dependencies change.
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"test": "vitest run",
"test:watch": "vitest",
"test:ci": "vitest run --coverage=false",
"test:integration": "vitest run --coverage=false --config vitest.integration.config.ts",
"check": "npm run typecheck && npm run test:ci",
"build:web": "npx expo export --platform web"
},
Expand Down
216 changes: 216 additions & 0 deletions src/lib/live-data.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
import { describe, expect, it } from 'vitest';

import type { Game } from '@/types/domain';

import {
buildPlaceholderInventory,
buildVenueDetailsModel,
mapGame,
mapGameTableRow,
mapNearbyVenue,
mapVenueMatch,
toInventoryStatus,
} from './live-data';

const dallas = { latitude: 32.7767, longitude: -96.797 };

describe('live-data game mappers', () => {
it('maps search RPC rows into app games with safe defaults', () => {
expect(
mapGame({
aliases: [],
categories: [],
game_id: 'mvc2',
manufacturer: null,
release_year: null,
similarity_score: 0.9,
slug: 'marvel-vs-capcom-2',
title: 'Marvel vs. Capcom 2',
}),
).toEqual({
aliases: [],
categories: [],
id: 'mvc2',
manufacturer: 'Unknown',
releaseYear: 0,
slug: 'marvel-vs-capcom-2',
title: 'Marvel vs. Capcom 2',
});
});

it('maps game table rows into app games', () => {
expect(
mapGameTableRow({
aliases: ['3rd Strike'],
categories: ['Fighting'],
created_at: '2026-04-01T00:00:00Z',
id: 'sf3',
manufacturer: 'Capcom',
release_year: 1999,
slug: 'street-fighter-iii-3rd-strike',
title: 'Street Fighter III: 3rd Strike',
updated_at: '2026-04-01T00:00:00Z',
}),
).toMatchObject({
categories: ['Fighting'],
manufacturer: 'Capcom',
releaseYear: 1999,
title: 'Street Fighter III: 3rd Strike',
});
});
});

describe('live-data venue mappers', () => {
it('builds placeholder inventory from tracked game counts', () => {
expect(buildPlaceholderInventory(2)).toEqual([
expect.objectContaining({ gameId: 'tracked-1', quantity: 1 }),
expect.objectContaining({ gameId: 'tracked-2', quantity: 1 }),
]);
});

it('preserves known inventory statuses and defaults unknown values', () => {
expect(toInventoryStatus('confirmed_present')).toBe('confirmed_present');
expect(toInventoryStatus('temporarily_unavailable')).toBe(
'temporarily_unavailable',
);
expect(toInventoryStatus('removed')).toBe('removed');
expect(toInventoryStatus('not-real')).toBe('rumored_present');
expect(toInventoryStatus(null)).toBe('rumored_present');
});

it('maps nearest venue rows into nearby venue results', () => {
const result = mapNearbyVenue(
{
city: 'Dallas',
distance_meters: 0,
last_verified_at: null,
latitude: 32.805817,
longitude: -96.846625,
notes: 'Nearly 200 games.',
postal_code: '75207',
region: 'TX',
street_address: '2777 Irving Blvd',
tracked_game_count: 12,
venue_id: 'cidercade-dallas',
venue_name: 'Cidercade Dallas',
venue_slug: 'cidercade-dallas',
verified_report_count: 4,
},
dallas,
);

expect(result.venue).toMatchObject({
address: '2777 Irving Blvd',
city: 'Dallas',
inventory: expect.arrayContaining([
expect.objectContaining({ gameId: 'tracked-1' }),
]),
name: 'Cidercade Dallas',
verifiedByCount: 4,
});
expect(result.distanceMiles).toBeGreaterThan(0);
});

it('maps game-specific venue matches into venue results', () => {
const game: Game = {
aliases: ['MVC2'],
categories: ['Fighting'],
id: 'mvc2',
manufacturer: 'Capcom',
releaseYear: 2000,
slug: 'marvel-vs-capcom-2',
title: 'Marvel vs. Capcom 2',
};

const result = mapVenueMatch(
{
availability_status: 'temporarily_unavailable',
city: 'Dallas',
confidence_score: 0.9,
distance_meters: 0,
last_confirmed_at: '2026-04-20T00:00:00Z',
latitude: 32.805817,
longitude: -96.846625,
quantity: 1,
region: 'TX',
street_address: '2777 Irving Blvd',
venue_id: 'cidercade-dallas',
venue_name: 'Cidercade Dallas',
venue_slug: 'cidercade-dallas',
},
game,
dallas,
);

expect(result.game.title).toBe('Marvel vs. Capcom 2');
expect(result.inventory).toEqual({
gameId: 'mvc2',
lastVerifiedAt: '2026-04-20T00:00:00Z',
quantity: 1,
status: 'temporarily_unavailable',
});
expect(result.venue.inventory).toHaveLength(1);
});
});

describe('buildVenueDetailsModel', () => {
it('returns null for empty RPC responses', () => {
expect(buildVenueDetailsModel([])).toBeNull();
});

it('builds venue details, games, and inventory from detail rows', () => {
const details = buildVenueDetailsModel([
{
aliases: ['MVC2'],
availability_status: 'removed',
categories: ['Fighting'],
city: 'Dallas',
confidence_score: 0.8,
country: 'US',
game_id: 'mvc2',
game_slug: 'marvel-vs-capcom-2',
game_title: 'Marvel vs. Capcom 2',
last_confirmed_at: null,
last_seen_at: '2026-04-21T00:00:00Z',
last_verified_at: null,
latitude: 32.805817,
longitude: -96.846625,
machine_label: null,
manufacturer: 'Capcom',
metadata: { notes: 'Official site says nearly 200 games.' },
notes: 'Cabinet was removed.',
postal_code: '75207',
quantity: 1,
region: 'TX',
release_year: 2000,
source: 'seed',
street_address: '2777 Irving Blvd',
venue_id: 'cidercade-dallas',
venue_name: 'Cidercade Dallas',
venue_slug: 'cidercade-dallas',
venue_status: 'active',
verified_report_count: 5,
},
]);

expect(details?.venue).toMatchObject({
address: '2777 Irving Blvd',
inventory: [
{
gameId: 'mvc2',
lastVerifiedAt: '2026-04-21T00:00:00Z',
note: 'Cabinet was removed.',
quantity: 1,
status: 'removed',
},
],
name: 'Cidercade Dallas',
notes: 'Official site says nearly 200 games.',
verifiedByCount: 5,
});
expect(details?.gamesById.mvc2).toMatchObject({
categories: ['Fighting'],
title: 'Marvel vs. Capcom 2',
});
});
});
15 changes: 8 additions & 7 deletions src/lib/live-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ function assertSupabase() {
return supabase;
}

function mapGame(row: SearchGameRow): Game {
export function mapGame(row: SearchGameRow): Game {
return {
id: row.game_id,
slug: row.slug,
Expand All @@ -55,7 +55,7 @@ function mapGame(row: SearchGameRow): Game {
};
}

function mapGameTableRow(row: GameTableRow): Game {
export function mapGameTableRow(row: GameTableRow): Game {
return {
aliases: row.aliases ?? [],
categories: row.categories ?? [],
Expand All @@ -67,7 +67,7 @@ function mapGameTableRow(row: GameTableRow): Game {
};
}

function buildPlaceholderInventory(count: number): VenueInventoryItem[] {
export function buildPlaceholderInventory(count: number): VenueInventoryItem[] {
return Array.from({ length: count }, (_, index) => ({
gameId: `tracked-${index + 1}`,
lastVerifiedAt: new Date().toISOString(),
Expand All @@ -76,18 +76,19 @@ function buildPlaceholderInventory(count: number): VenueInventoryItem[] {
}));
}

function toInventoryStatus(status: string | null | undefined): InventoryStatus {
export function toInventoryStatus(status: string | null | undefined): InventoryStatus {
switch (status) {
case 'confirmed_present':
case 'rumored_present':
case 'temporarily_unavailable':
case 'removed':
return status;
default:
return 'rumored_present';
}
}

function mapNearbyVenue(row: NearbyVenueRow, userLocation: Coordinates): NearbyVenueResult {
export function mapNearbyVenue(row: NearbyVenueRow, userLocation: Coordinates): NearbyVenueResult {
return {
distanceMiles: distanceInMiles(userLocation, {
latitude: row.latitude,
Expand All @@ -109,7 +110,7 @@ function mapNearbyVenue(row: NearbyVenueRow, userLocation: Coordinates): NearbyV
};
}

function mapVenueMatch(row: VenueMatchRow, game: Game, userLocation: Coordinates): VenueMatch {
export function mapVenueMatch(row: VenueMatchRow, game: Game, userLocation: Coordinates): VenueMatch {
const inventory: VenueInventoryItem = {
gameId: game.id,
lastVerifiedAt:
Expand Down Expand Up @@ -140,7 +141,7 @@ function mapVenueMatch(row: VenueMatchRow, game: Game, userLocation: Coordinates
};
}

function buildVenueDetailsModel(rows: VenueDetailRow[]): VenueDetailsModel | null {
export function buildVenueDetailsModel(rows: VenueDetailRow[]): VenueDetailsModel | null {
const firstRow = rows[0];

if (!firstRow) {
Expand Down
Loading
Loading