diff --git a/apps/citycorners/CLAUDE.md b/apps/citycorners/CLAUDE.md index d67b62bcd..d61866012 100644 --- a/apps/citycorners/CLAUDE.md +++ b/apps/citycorners/CLAUDE.md @@ -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) | diff --git a/apps/citycorners/apps/backend/jest.config.js b/apps/citycorners/apps/backend/jest.config.js new file mode 100644 index 000000000..45440de67 --- /dev/null +++ b/apps/citycorners/apps/backend/jest.config.js @@ -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', +}; diff --git a/apps/citycorners/apps/backend/package.json b/apps/citycorners/apps/backend/package.json index 52467de11..3da4c21ba 100644 --- a/apps/citycorners/apps/backend/package.json +++ b/apps/citycorners/apps/backend/package.json @@ -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", diff --git a/apps/citycorners/apps/backend/src/__tests__/mock-factories.ts b/apps/citycorners/apps/backend/src/__tests__/mock-factories.ts new file mode 100644 index 000000000..342e69c7e --- /dev/null +++ b/apps/citycorners/apps/backend/src/__tests__/mock-factories.ts @@ -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 { + 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 { + 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(), + }; +} diff --git a/apps/citycorners/apps/backend/src/favorite/favorite.service.spec.ts b/apps/citycorners/apps/backend/src/favorite/favorite.service.spec.ts new file mode 100644 index 000000000..f513b6072 --- /dev/null +++ b/apps/citycorners/apps/backend/src/favorite/favorite.service.spec.ts @@ -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; + + beforeEach(async () => { + mockDb = createMockDb(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [FavoriteService, { provide: DATABASE_CONNECTION, useValue: mockDb }], + }).compile(); + + service = module.get(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); + }); + }); +}); diff --git a/apps/citycorners/apps/backend/src/location/location-lookup.service.spec.ts b/apps/citycorners/apps/backend/src/location/location-lookup.service.spec.ts new file mode 100644 index 000000000..74d640e75 --- /dev/null +++ b/apps/citycorners/apps/backend/src/location/location-lookup.service.spec.ts @@ -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); + }); + + 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); + }); + }); +}); diff --git a/apps/citycorners/apps/backend/src/location/location.controller.spec.ts b/apps/citycorners/apps/backend/src/location/location.controller.spec.ts new file mode 100644 index 000000000..31786ae02 --- /dev/null +++ b/apps/citycorners/apps/backend/src/location/location.controller.spec.ts @@ -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 }); + }); + }); +}); diff --git a/apps/citycorners/apps/backend/src/location/location.service.spec.ts b/apps/citycorners/apps/backend/src/location/location.service.spec.ts new file mode 100644 index 000000000..87a4dd3bb --- /dev/null +++ b/apps/citycorners/apps/backend/src/location/location.service.spec.ts @@ -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; + + beforeEach(async () => { + mockDb = createMockDb(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [LocationService, { provide: DATABASE_CONNECTION, useValue: mockDb }], + }).compile(); + + service = module.get(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); + }); + }); +});