test(citycorners): add backend test suite (31 tests) and update documentation

Tests: Jest + ts-jest with mock factories. 4 test suites covering LocationService (CRUD, search), FavoriteService (add/remove, conflicts), LocationLookupService (web search, extraction, error handling), LocationController (endpoints, query params).

Docs: Complete CLAUDE.md rewrite with live URLs, all endpoints, web pages, features, environment variables, Docker config, and test overview.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-23 12:44:46 +01:00
parent 79207bf43f
commit c59eba7285
8 changed files with 655 additions and 15 deletions

View file

@ -2,17 +2,31 @@
City guide for Konstanz (Bodensee) showcasing locations, restaurants, museums, and sights.
## Live URLs
| Service | URL |
|---------|-----|
| **Web App** | https://citycorners.mana.how |
| **API** | https://citycorners-api.mana.how |
| **Landing** | https://citycorners-landing.pages.dev |
## Architecture
```
apps/citycorners/
├── apps/
│ ├── landing/ # Astro static site
│ ├── backend/ # NestJS API (port 3025)
│ └── web/ # SvelteKit web app (port 5196)
│ ├── landing/ # Astro static site (Tailwind, Cloudflare Pages)
│ ├── backend/ # NestJS API (port 3025 dev, 3041 prod)
│ └── web/ # SvelteKit web app (port 5196 dev, 5022 prod)
└── CLAUDE.md
```
### Tech Stack
- **Backend:** NestJS 10, Drizzle ORM, PostgreSQL, mana-core-auth (JWT)
- **Web:** SvelteKit 2, Svelte 5 runes, Tailwind 4, Leaflet maps, svelte-i18n (DE/EN), PWA
- **Landing:** Astro 5, Tailwind 3, static site generation
- **Search:** mana-search integration (SearXNG + content extraction)
## Development
```bash
@ -27,10 +41,15 @@ pnpm dev:citycorners:web
# Database
pnpm citycorners:db:push # Push schema
pnpm citycorners:db:studio # Drizzle Studio
pnpm citycorners:db:seed # Seed sample data
pnpm citycorners:db:seed # Seed 6 sample locations
# Deploy landing
pnpm deploy:landing:citycorners
# Tests
pnpm --filter @citycorners/backend test # Run all tests (31 tests)
pnpm --filter @citycorners/backend test:watch # Watch mode
pnpm --filter @citycorners/backend test:cov # Coverage report
# Deploy
pnpm deploy:landing:citycorners # Landing to Cloudflare Pages
```
## Database
@ -39,27 +58,85 @@ PostgreSQL database `citycorners` with Drizzle ORM.
### Schema
- **locations** name, category (sight/restaurant/shop/museum), description, address, coordinates, imageUrl, timeline (JSONB)
- **locations** name, category (enum: sight/restaurant/shop/museum), description, address, lat/lng, imageUrl, timeline (JSONB array of {year, event})
- **favorites** userId, locationId (FK → locations, cascade delete), unique constraint on (userId, locationId)
## API Endpoints
All endpoints are prefixed with `/api/v1/` in production (via shared-nestjs-setup).
### Locations
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/locations` | No | List all locations (optional `?category=` filter) |
| GET | `/locations` | No | List all (optional `?category=sight\|restaurant\|shop\|museum`) |
| GET | `/locations/search?q=` | No | Text search (ILIKE on name, description, address) |
| GET | `/locations/lookup?q=` | No | Web lookup via mana-search (scrapes info, auto-fills form) |
| GET | `/locations/:id` | No | Get single location |
| POST | `/locations` | Yes | Create location |
| PUT | `/locations/:id` | Yes | Update location |
| DELETE | `/locations/:id` | Yes | Delete location |
| GET | `/favorites` | Yes | List user's favorites |
### Favorites
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/favorites` | Yes | List user's favorite location IDs |
| POST | `/favorites/:locationId` | Yes | Add to favorites |
| DELETE | `/favorites/:locationId` | Yes | Remove from favorites |
## Web App Pages
| Route | Description |
|-------|-------------|
| `/` | Location grid with category filter pills |
| `/map` | Leaflet map with color-coded markers |
| `/locations/:id` | Detail page with mini-map, timeline, favorite button |
| `/add` | Two-step flow: web lookup → edit form → submit |
| `/favorites` | User's saved locations |
| `/settings` | Theme mode/variant, account, about |
| `/login`, `/register` | Auth via shared-auth-ui |
| `/offline` | PWA offline fallback |
## Features
- **PWA:** Installable, offline fallback, service worker caching (API: NetworkFirst, images: CacheFirst)
- **i18n:** German + English, language switcher in PillNav, localStorage persistence
- **Favorites:** Optimistic updates, auth-gated heart button on cards + detail page
- **Search:** QuickInputBar in PillNav, backend ILIKE search
- **Web Lookup:** mana-search integration for auto-filling location data from the web
- **Branding:** Registered in shared-branding (AppId, icon, APP_URLS, app switcher)
## Categories
| DB Value | Label (DE) |
|----------|------------|
| `sight` | Sehenswürdigkeit |
| `restaurant` | Restaurant |
| `shop` | Laden |
| `museum` | Museum |
| DB Value | Label (DE) | Label (EN) | Card Color |
|----------|------------|------------|------------|
| `sight` | Sehenswürdigkeit | Sight | Blue |
| `restaurant` | Restaurant | Restaurant | Red |
| `shop` | Laden | Shop | Green |
| `museum` | Museum | Museum | Purple |
## Tests
4 test suites, 31 tests covering:
- `LocationService` CRUD, search, category filtering
- `FavoriteService` add/remove/check, conflict handling
- `LocationLookupService` web search, content extraction, address/category detection, error handling
- `LocationController` endpoint routing, query params, auth guards
## Docker
- **Backend:** `apps/citycorners/apps/backend/Dockerfile` (multi-stage, port 3041 prod)
- **Web:** `apps/citycorners/apps/web/Dockerfile` (multi-stage, port 5022 prod)
- **Entrypoints:** Auto schema push, optional seed on start
- **docker-compose.macmini.yml:** Both services configured with health checks
## Environment Variables
| Variable | Used by | Description |
|----------|---------|-------------|
| `DATABASE_URL` | Backend | PostgreSQL connection string |
| `MANA_CORE_AUTH_URL` | Backend | Auth service URL |
| `MANA_SEARCH_URL` | Backend | mana-search service URL |
| `PUBLIC_BACKEND_URL` | Web | Backend API URL |
| `PUBLIC_MANA_CORE_AUTH_URL` | Web | Auth service URL (client) |

View file

@ -0,0 +1,17 @@
module.exports = {
moduleFileExtensions: ['js', 'json', 'ts'],
rootDir: 'src',
testRegex: '.*\\.spec\\.ts$',
transform: {
'^.+\\.(t|j)s$': 'ts-jest',
},
collectCoverageFrom: [
'**/*.(t|j)s',
'!**/*.spec.ts',
'!**/index.ts',
'!main.ts',
'!instrument.ts',
],
coverageDirectory: '../coverage',
testEnvironment: 'node',
};

View file

@ -10,6 +10,9 @@
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"type-check": "tsc --noEmit",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"migration:generate": "drizzle-kit generate",
"migration:run": "tsx src/db/migrate.ts",
"db:push": "drizzle-kit push",
@ -38,9 +41,13 @@
"devDependencies": {
"@nestjs/cli": "^10.4.9",
"@nestjs/schematics": "^10.2.3",
"@nestjs/testing": "^10.4.15",
"@types/express": "^5.0.0",
"@types/jest": "^30.0.0",
"@types/node": "^22.10.2",
"jest": "^30.0.0",
"source-map-support": "^0.5.21",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.1",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",

View file

@ -0,0 +1,48 @@
import type { Location } from '../db/schema/locations.schema';
import type { Favorite } from '../db/schema/favorites.schema';
export const TEST_USER_ID = 'test-user-123';
export const TEST_USER_EMAIL = 'test@example.com';
export function createMockLocation(overrides: Partial<Location> = {}): Location {
return {
id: 'loc-1',
name: 'Konstanzer Münster',
category: 'sight',
description: 'Historic cathedral in Konstanz.',
address: 'Münsterplatz 1, 78462 Konstanz',
latitude: 47.6603,
longitude: 9.1757,
imageUrl: '/images/muenster.svg',
timeline: [{ year: '615', event: 'Founded' }],
createdAt: new Date('2026-01-01'),
updatedAt: new Date('2026-01-01'),
...overrides,
};
}
export function createMockFavorite(overrides: Partial<Favorite> = {}): Favorite {
return {
id: 'fav-1',
userId: TEST_USER_ID,
locationId: 'loc-1',
createdAt: new Date('2026-01-01'),
...overrides,
};
}
export function createMockDb() {
return {
select: jest.fn().mockReturnThis(),
from: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
limit: jest.fn().mockReturnThis(),
insert: jest.fn().mockReturnThis(),
values: jest.fn().mockReturnThis(),
returning: jest.fn(),
update: jest.fn().mockReturnThis(),
set: jest.fn().mockReturnThis(),
delete: jest.fn().mockReturnThis(),
};
}

View file

@ -0,0 +1,92 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ConflictException } from '@nestjs/common';
import { FavoriteService } from './favorite.service';
import { DATABASE_CONNECTION } from '../db/database.module';
import { createMockDb, createMockFavorite, TEST_USER_ID } from '../__tests__/mock-factories';
describe('FavoriteService', () => {
let service: FavoriteService;
let mockDb: ReturnType<typeof createMockDb>;
beforeEach(async () => {
mockDb = createMockDb();
const module: TestingModule = await Test.createTestingModule({
providers: [FavoriteService, { provide: DATABASE_CONNECTION, useValue: mockDb }],
}).compile();
service = module.get<FavoriteService>(FavoriteService);
});
afterEach(() => jest.clearAllMocks());
describe('findByUserId', () => {
it('should return user favorites', async () => {
const favorites = [
createMockFavorite(),
createMockFavorite({ id: 'fav-2', locationId: 'loc-2' }),
];
mockDb.where.mockResolvedValue(favorites);
const result = await service.findByUserId(TEST_USER_ID);
expect(result).toEqual(favorites);
expect(result).toHaveLength(2);
});
it('should return empty array if no favorites', async () => {
mockDb.where.mockResolvedValue([]);
const result = await service.findByUserId(TEST_USER_ID);
expect(result).toEqual([]);
});
});
describe('add', () => {
it('should add a location to favorites', async () => {
const favorite = createMockFavorite();
// First call: check existence -> empty
mockDb.where.mockResolvedValueOnce([]);
// Second call: insert + returning
mockDb.returning.mockResolvedValue([favorite]);
const result = await service.add(TEST_USER_ID, 'loc-1');
expect(result).toEqual(favorite);
});
it('should throw ConflictException if already favorited', async () => {
mockDb.where.mockResolvedValue([createMockFavorite()]);
await expect(service.add(TEST_USER_ID, 'loc-1')).rejects.toThrow(ConflictException);
});
});
describe('remove', () => {
it('should remove a favorite', async () => {
mockDb.where.mockResolvedValue(undefined);
await expect(service.remove(TEST_USER_ID, 'loc-1')).resolves.not.toThrow();
expect(mockDb.delete).toHaveBeenCalled();
});
});
describe('isFavorite', () => {
it('should return true if favorited', async () => {
mockDb.where.mockResolvedValue([createMockFavorite()]);
const result = await service.isFavorite(TEST_USER_ID, 'loc-1');
expect(result).toBe(true);
});
it('should return false if not favorited', async () => {
mockDb.where.mockResolvedValue([]);
const result = await service.isFavorite(TEST_USER_ID, 'loc-2');
expect(result).toBe(false);
});
});
});

View file

@ -0,0 +1,151 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';
import { LocationLookupService } from './location-lookup.service';
// Mock global fetch
const mockFetch = jest.fn();
global.fetch = mockFetch as any;
describe('LocationLookupService', () => {
let service: LocationLookupService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
LocationLookupService,
{
provide: ConfigService,
useValue: {
get: jest.fn().mockReturnValue('http://localhost:3021'),
},
},
],
}).compile();
service = module.get<LocationLookupService>(LocationLookupService);
});
afterEach(() => jest.clearAllMocks());
describe('lookup', () => {
it('should return location data from search results', async () => {
// Mock search response
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
results: [
{
url: 'https://example.com/muenster',
title: 'Konstanzer Münster',
snippet: 'Das Münster ist eine historische Basilika in Konstanz am Bodensee.',
engine: 'google',
score: 1,
},
{
url: 'https://example.com/muenster2',
title: 'Münster Konstanz - Wikipedia',
snippet: 'Die Basilika befindet sich in der Münsterplatz 1, 78462 Konstanz.',
engine: 'bing',
score: 0.9,
},
],
}),
});
// Mock bulk extract response
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
results: [
{
success: true,
content: {
text: 'Das Konstanzer Münster ist eine imposante Basilika. Die Adresse ist Münsterplatz 1, 78462 Konstanz. Es wurde im Jahr 615 gegründet.',
},
},
],
}),
});
const result = await service.lookup('Konstanzer Münster');
expect(result).not.toBeNull();
expect(result!.name).toBe('Konstanzer Münster');
expect(result!.description.length).toBeGreaterThan(0);
expect(result!.sources).toHaveLength(2);
expect(result!.category).toBe('sight');
});
it('should detect restaurant category', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
results: [
{
url: 'https://example.com',
title: 'Restaurant Test',
snippet: 'Ein wunderbares Restaurant mit feiner Küche und exzellentem Essen.',
engine: 'google',
score: 1,
},
],
}),
});
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({ results: [] }),
});
const result = await service.lookup('Restaurant Ophelia');
expect(result).not.toBeNull();
expect(result!.category).toBe('restaurant');
});
it('should return null on empty search results', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({ results: [] }),
});
const result = await service.lookup('xyznonexistent');
expect(result).toBeNull();
});
it('should return null on search API failure', async () => {
mockFetch.mockResolvedValueOnce({ ok: false, status: 500 });
const result = await service.lookup('Test');
expect(result).toBeNull();
});
it('should handle extract failure gracefully', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
results: [
{
url: 'https://example.com',
title: 'Test Place',
snippet: 'A nice place in Konstanz with great atmosphere.',
engine: 'google',
score: 1,
},
],
}),
});
// Extract fails
mockFetch.mockRejectedValueOnce(new Error('Timeout'));
const result = await service.lookup('Test Place');
// Should still return result using search snippets
expect(result).not.toBeNull();
expect(result!.description.length).toBeGreaterThan(0);
});
});
});

View file

@ -0,0 +1,112 @@
import { LocationController } from './location.controller';
import { createMockLocation, TEST_USER_ID, TEST_USER_EMAIL } from '../__tests__/mock-factories';
const mockUser = { userId: TEST_USER_ID, email: TEST_USER_EMAIL } as any;
describe('LocationController', () => {
let controller: LocationController;
let locationService: any;
let lookupService: any;
beforeEach(() => {
locationService = {
findAll: jest.fn(),
findById: jest.fn(),
search: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
};
lookupService = {
lookup: jest.fn(),
};
controller = new LocationController(locationService, lookupService);
});
afterEach(() => jest.clearAllMocks());
describe('findAll', () => {
it('should return all locations', async () => {
const locations = [createMockLocation(), createMockLocation({ id: 'loc-2' })];
locationService.findAll.mockResolvedValue(locations);
const result = await controller.findAll();
expect(result).toEqual({ locations });
});
it('should filter by category', async () => {
const museums = [createMockLocation({ category: 'museum' })];
locationService.findAll.mockResolvedValue(museums);
const result = await controller.findAll('museum');
expect(result).toEqual({ locations: museums });
expect(locationService.findAll).toHaveBeenCalledWith('museum');
});
});
describe('search', () => {
it('should search locations', async () => {
const locations = [createMockLocation()];
locationService.search.mockResolvedValue(locations);
const result = await controller.search('Münster');
expect(result).toEqual({ locations });
});
it('should return empty for empty query', async () => {
const result = await controller.search('');
expect(result).toEqual({ locations: [] });
});
});
describe('lookup', () => {
it('should return lookup result', async () => {
const lookupResult = {
name: 'Konzil',
description: 'Historic building',
category: 'sight',
sources: [{ url: 'https://example.com', title: 'Konzil' }],
};
lookupService.lookup.mockResolvedValue(lookupResult);
const result = await controller.lookup('Konzil');
expect(result).toEqual({ result: lookupResult });
});
it('should return null for empty query', async () => {
const result = await controller.lookup('');
expect(result).toEqual({ result: null });
});
});
describe('create', () => {
it('should create a location', async () => {
const location = createMockLocation({ id: 'new-loc' });
locationService.create.mockResolvedValue(location);
const result = await controller.create(mockUser, {
name: 'Test',
category: 'sight' as const,
description: 'A test location',
});
expect(result).toEqual({ location });
});
});
describe('delete', () => {
it('should delete a location', async () => {
locationService.delete.mockResolvedValue(undefined);
const result = await controller.delete(mockUser, 'loc-1');
expect(result).toEqual({ success: true });
});
});
});

View file

@ -0,0 +1,136 @@
import { Test, TestingModule } from '@nestjs/testing';
import { NotFoundException } from '@nestjs/common';
import { LocationService } from './location.service';
import { DATABASE_CONNECTION } from '../db/database.module';
import { createMockDb, createMockLocation } from '../__tests__/mock-factories';
describe('LocationService', () => {
let service: LocationService;
let mockDb: ReturnType<typeof createMockDb>;
beforeEach(async () => {
mockDb = createMockDb();
const module: TestingModule = await Test.createTestingModule({
providers: [LocationService, { provide: DATABASE_CONNECTION, useValue: mockDb }],
}).compile();
service = module.get<LocationService>(LocationService);
});
afterEach(() => jest.clearAllMocks());
describe('findAll', () => {
it('should return all locations', async () => {
const locations = [
createMockLocation(),
createMockLocation({ id: 'loc-2', name: 'Imperia' }),
];
mockDb.where.mockResolvedValue(locations);
// findAll without category calls db.select().from(locations) which resolves via the chain
// Need to handle the case without category
mockDb.from.mockResolvedValue(locations);
const result = await service.findAll();
expect(result).toEqual(locations);
expect(mockDb.select).toHaveBeenCalled();
});
it('should filter by category', async () => {
const museums = [
createMockLocation({ id: 'loc-3', category: 'museum', name: 'Rosgartenmuseum' }),
];
mockDb.where.mockResolvedValue(museums);
const result = await service.findAll('museum');
expect(result).toEqual(museums);
});
});
describe('findById', () => {
it('should return a location by id', async () => {
const location = createMockLocation();
mockDb.where.mockResolvedValue([location]);
const result = await service.findById('loc-1');
expect(result).toEqual(location);
});
it('should throw NotFoundException if not found', async () => {
mockDb.where.mockResolvedValue([]);
await expect(service.findById('nonexistent')).rejects.toThrow(NotFoundException);
});
});
describe('search', () => {
it('should search locations by query', async () => {
const locations = [createMockLocation()];
mockDb.where.mockResolvedValue(locations);
const result = await service.search('Münster');
expect(result).toEqual(locations);
});
it('should return empty array for no matches', async () => {
mockDb.where.mockResolvedValue([]);
const result = await service.search('nonexistent');
expect(result).toEqual([]);
});
});
describe('create', () => {
it('should create a new location', async () => {
const newLocation = createMockLocation({ id: 'loc-new' });
mockDb.returning.mockResolvedValue([newLocation]);
const result = await service.create({
name: 'Test Location',
category: 'sight',
description: 'A test location',
});
expect(result).toEqual(newLocation);
expect(mockDb.insert).toHaveBeenCalled();
});
});
describe('update', () => {
it('should update a location', async () => {
const updated = createMockLocation({ name: 'Updated Name' });
mockDb.returning.mockResolvedValue([updated]);
const result = await service.update('loc-1', { name: 'Updated Name' });
expect(result.name).toBe('Updated Name');
});
it('should throw NotFoundException if not found', async () => {
mockDb.returning.mockResolvedValue([]);
await expect(service.update('nonexistent', { name: 'Test' })).rejects.toThrow(
NotFoundException
);
});
});
describe('delete', () => {
it('should delete a location', async () => {
mockDb.returning.mockResolvedValue([createMockLocation()]);
await expect(service.delete('loc-1')).resolves.not.toThrow();
});
it('should throw NotFoundException if not found', async () => {
mockDb.returning.mockResolvedValue([]);
await expect(service.delete('nonexistent')).rejects.toThrow(NotFoundException);
});
});
});