Skip to content

Commit 1d58a16

Browse files
GeekTrainerCopilot
andcommitted
Add pagination to dogs API and homepage
Add page/per_page query parameters to GET /api/dogs endpoint with paginated JSON response. Update homepage to display pagination controls. Add test dogs to seed data and update e2e tests for pagination. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 6f610c0 commit 1d58a16

6 files changed

Lines changed: 536 additions & 0 deletions

File tree

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { test, expect } from '@playwright/test';
2+
3+
test.describe('API Integration', () => {
4+
test('should render dogs from the API on the homepage', async ({ page }) => {
5+
await page.goto('/');
6+
7+
const dogCards = page.getByTestId('dog-card');
8+
await expect(dogCards).toHaveCount(6);
9+
10+
await expect(page.getByTestId('dog-name').nth(0)).toHaveText('Buddy');
11+
await expect(page.getByTestId('dog-breed').nth(0)).toHaveText('Golden Retriever');
12+
13+
await expect(page.getByTestId('dog-name').nth(1)).toHaveText('Luna');
14+
await expect(page.getByTestId('dog-breed').nth(1)).toHaveText('Husky');
15+
16+
await expect(page.getByTestId('dog-name').nth(2)).toHaveText('Max');
17+
await expect(page.getByTestId('dog-breed').nth(2)).toHaveText('German Shepherd');
18+
});
19+
20+
test('should render dog details from the API', async ({ page }) => {
21+
await page.goto('/dog/1');
22+
23+
await expect(page.getByTestId('dog-details')).toBeVisible();
24+
await expect(page.getByTestId('dog-name')).toHaveText('Buddy');
25+
await expect(page.getByTestId('dog-breed')).toContainText('Golden Retriever');
26+
await expect(page.getByTestId('dog-age')).toContainText('3');
27+
await expect(page.getByTestId('dog-gender')).toContainText('Male');
28+
await expect(page.getByTestId('dog-status')).toHaveText('Available');
29+
});
30+
31+
test('should return 404 details for non-existent dog', async ({ page }) => {
32+
await page.goto('/dog/99999');
33+
34+
await expect(page.getByTestId('error-message')).toBeVisible();
35+
await expect(page.getByTestId('error-message')).toContainText('not found');
36+
});
37+
38+
test('should link from dog card to detail page', async ({ page }) => {
39+
await page.goto('/');
40+
41+
const firstCard = page.getByTestId('dog-card').first();
42+
await firstCard.click();
43+
44+
await expect(page).toHaveURL(/\/dog\/1$/);
45+
await expect(page.getByTestId('dog-details')).toBeVisible();
46+
});
47+
});
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { test, expect } from '@playwright/test';
2+
3+
test.describe('Tailspin Shelter Homepage', () => {
4+
test('should load homepage and display title', async ({ page }) => {
5+
await page.goto('/');
6+
7+
await expect(page).toHaveTitle(/Tailspin Shelter - Find Your Forever Friend/);
8+
9+
await expect(page.getByRole('heading', { name: 'Welcome to Tailspin Shelter' })).toBeVisible();
10+
11+
await expect(page.getByText('Find your perfect companion from our wonderful selection')).toBeVisible();
12+
});
13+
14+
test('should display dog list', async ({ page }) => {
15+
await page.goto('/');
16+
17+
await expect(page.getByRole('heading', { name: 'Available Dogs' })).toBeVisible();
18+
19+
const dogList = page.getByTestId('dog-list');
20+
await expect(dogList).toBeVisible();
21+
22+
const dogCards = page.getByTestId('dog-card');
23+
await expect(dogCards).toHaveCount(6);
24+
});
25+
26+
test('should display dog names and breeds', async ({ page }) => {
27+
await page.goto('/');
28+
29+
await expect(page.getByTestId('dog-name').nth(0)).toHaveText('Buddy');
30+
await expect(page.getByTestId('dog-breed').nth(0)).toHaveText('Golden Retriever');
31+
32+
await expect(page.getByTestId('dog-name').nth(1)).toHaveText('Luna');
33+
await expect(page.getByTestId('dog-breed').nth(1)).toHaveText('Husky');
34+
35+
await expect(page.getByTestId('dog-name').nth(2)).toHaveText('Max');
36+
await expect(page.getByTestId('dog-breed').nth(2)).toHaveText('German Shepherd');
37+
});
38+
39+
test('should display pagination controls', async ({ page }) => {
40+
await page.goto('/');
41+
42+
const pagination = page.getByTestId('pagination');
43+
await expect(pagination).toBeVisible();
44+
await expect(page.getByTestId('pagination-info')).toContainText('Page 1 of 2');
45+
await expect(page.getByTestId('pagination-next')).toBeVisible();
46+
});
47+
48+
test('should navigate to page 2', async ({ page }) => {
49+
await page.goto('/');
50+
51+
await page.getByTestId('pagination-next').click();
52+
await expect(page).toHaveURL(/page=2/);
53+
54+
const dogCards = page.getByTestId('dog-card');
55+
await expect(dogCards).toHaveCount(4);
56+
await expect(page.getByTestId('dog-name').nth(0)).toHaveText('Rocky');
57+
});
58+
});

app/client/src/pages/index.astro

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
---
2+
import Layout from '../layouts/Layout.astro';
3+
import DogList from '../components/DogList.astro';
4+
5+
const API_SERVER_URL = process.env.API_SERVER_URL || 'http://localhost:5100';
6+
const PAGE_SIZE = 6;
7+
8+
let dogs = [];
9+
let page = 1;
10+
let totalPages = 1;
11+
let error: string | null = null;
12+
13+
const currentPage = Math.max(1, Number(Astro.url.searchParams.get('page')) || 1);
14+
15+
try {
16+
const response = await fetch(`${API_SERVER_URL}/api/dogs?page=${currentPage}&per_page=${PAGE_SIZE}`);
17+
if (response.ok) {
18+
const data = await response.json();
19+
dogs = data.dogs;
20+
page = data.page;
21+
totalPages = data.total_pages;
22+
} else {
23+
error = `Failed to fetch data: ${response.status} ${response.statusText}`;
24+
}
25+
} catch (err) {
26+
error = `Error: ${err instanceof Error ? err.message : String(err)}`;
27+
}
28+
---
29+
30+
<Layout title="Tailspin Shelter - Find Your Forever Friend">
31+
<div class="py-8">
32+
<div class="max-w-3xl mb-10">
33+
<h1 class="text-4xl font-bold mb-4 text-slate-100">Welcome to Tailspin Shelter</h1>
34+
<p class="text-xl text-slate-300">Find your perfect companion from our wonderful selection of dogs looking for their forever homes.</p>
35+
</div>
36+
37+
{error ? (
38+
<div class="text-center py-12 bg-slate-800/50 backdrop-blur-sm rounded-xl border border-slate-700" data-testid="error-message">
39+
<p class="text-red-400">{error}</p>
40+
</div>
41+
) : (
42+
<>
43+
<DogList dogs={dogs} />
44+
45+
{totalPages > 1 && (
46+
<nav class="flex justify-center items-center gap-2 mt-10" data-testid="pagination">
47+
{page > 1 ? (
48+
<a
49+
href={`/?page=${page - 1}`}
50+
class="px-4 py-2 rounded-lg bg-slate-800 border border-slate-700 text-slate-300 hover:border-blue-500/50 hover:text-blue-400 transition-colors"
51+
data-testid="pagination-prev"
52+
>
53+
← Previous
54+
</a>
55+
) : (
56+
<span class="px-4 py-2 rounded-lg bg-slate-800/50 border border-slate-700/50 text-slate-600 cursor-not-allowed">
57+
← Previous
58+
</span>
59+
)}
60+
61+
<span class="px-4 py-2 text-slate-300" data-testid="pagination-info">
62+
Page {page} of {totalPages}
63+
</span>
64+
65+
{page < totalPages ? (
66+
<a
67+
href={`/?page=${page + 1}`}
68+
class="px-4 py-2 rounded-lg bg-slate-800 border border-slate-700 text-slate-300 hover:border-blue-500/50 hover:text-blue-400 transition-colors"
69+
data-testid="pagination-next"
70+
>
71+
Next →
72+
</a>
73+
) : (
74+
<span class="px-4 py-2 rounded-lg bg-slate-800/50 border border-slate-700/50 text-slate-600 cursor-not-allowed">
75+
Next →
76+
</span>
77+
)}
78+
</nav>
79+
)}
80+
</>
81+
)}
82+
</div>
83+
</Layout>

app/server/app.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import os
2+
from typing import Dict, List, Any, Optional
3+
from flask import Flask, jsonify, request, Response
4+
from models import init_db, db, Dog, Breed
5+
6+
# Get the server directory path
7+
base_dir: str = os.path.abspath(os.path.dirname(__file__))
8+
9+
app: Flask = Flask(__name__)
10+
db_path: str = os.environ.get('DATABASE_PATH', os.path.join(base_dir, 'dogshelter.db'))
11+
app.config['SQLALCHEMY_DATABASE_URI'] = f'sqlite:///{db_path}'
12+
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
13+
14+
# Initialize the database with the app
15+
init_db(app)
16+
17+
@app.route('/api/dogs', methods=['GET'])
18+
def get_dogs() -> Response:
19+
page = request.args.get('page', 1, type=int)
20+
per_page = request.args.get('per_page', 6, type=int)
21+
page = max(1, page)
22+
per_page = max(1, min(per_page, 100))
23+
24+
query = db.session.query(
25+
Dog.id,
26+
Dog.name,
27+
Breed.name.label('breed')
28+
).join(Breed, Dog.breed_id == Breed.id)
29+
30+
total = query.count()
31+
dogs_query = query.offset((page - 1) * per_page).limit(per_page).all()
32+
33+
dogs_list: List[Dict[str, Any]] = [
34+
{
35+
'id': dog.id,
36+
'name': dog.name,
37+
'breed': dog.breed
38+
}
39+
for dog in dogs_query
40+
]
41+
42+
return jsonify({
43+
'dogs': dogs_list,
44+
'page': page,
45+
'per_page': per_page,
46+
'total': total,
47+
'total_pages': max(1, -(-total // per_page))
48+
})
49+
50+
@app.route('/api/dogs/<int:id>', methods=['GET'])
51+
def get_dog(id: int) -> tuple[Response, int] | Response:
52+
# Query the specific dog by ID and join with breed to get breed name
53+
dog_query = db.session.query(
54+
Dog.id,
55+
Dog.name,
56+
Breed.name.label('breed'),
57+
Dog.age,
58+
Dog.description,
59+
Dog.gender,
60+
Dog.status
61+
).join(Breed, Dog.breed_id == Breed.id).filter(Dog.id == id).first()
62+
63+
# Return 404 if dog not found
64+
if not dog_query:
65+
return jsonify({"error": "Dog not found"}), 404
66+
67+
# Convert the result to a dictionary
68+
dog: Dict[str, Any] = {
69+
'id': dog_query.id,
70+
'name': dog_query.name,
71+
'breed': dog_query.breed,
72+
'age': dog_query.age,
73+
'description': dog_query.description,
74+
'gender': dog_query.gender,
75+
'status': dog_query.status.name
76+
}
77+
78+
return jsonify(dog)
79+
80+
## HERE
81+
82+
if __name__ == '__main__':
83+
app.run(debug=True, port=5100) # Port 5100 to avoid macOS conflicts

app/server/test_app.py

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import unittest
2+
from unittest.mock import patch, MagicMock
3+
import json
4+
from app import app # Changed from relative import to absolute import
5+
6+
# filepath: app/server/test_app.py
7+
class TestApp(unittest.TestCase):
8+
def setUp(self):
9+
# Create a test client using Flask's test client
10+
self.app = app.test_client()
11+
self.app.testing = True
12+
# Turn off database initialization for tests
13+
app.config['TESTING'] = True
14+
15+
def _create_mock_dog(self, dog_id, name, breed):
16+
"""Helper method to create a mock dog with standard attributes"""
17+
dog = MagicMock(spec=['to_dict', 'id', 'name', 'breed'])
18+
dog.id = dog_id
19+
dog.name = name
20+
dog.breed = breed
21+
dog.to_dict.return_value = {'id': dog_id, 'name': name, 'breed': breed}
22+
return dog
23+
24+
def _setup_query_mock(self, mock_query, dogs):
25+
"""Helper method to configure the query mock"""
26+
mock_query_instance = MagicMock()
27+
mock_query.return_value = mock_query_instance
28+
mock_query_instance.join.return_value = mock_query_instance
29+
mock_query_instance.count.return_value = len(dogs)
30+
mock_query_instance.offset.return_value = mock_query_instance
31+
mock_query_instance.limit.return_value = mock_query_instance
32+
mock_query_instance.all.return_value = dogs
33+
return mock_query_instance
34+
35+
@patch('app.db.session.query')
36+
def test_get_dogs_success(self, mock_query):
37+
"""Test successful retrieval of multiple dogs"""
38+
# Arrange
39+
dog1 = self._create_mock_dog(1, "Buddy", "Labrador")
40+
dog2 = self._create_mock_dog(2, "Max", "German Shepherd")
41+
mock_dogs = [dog1, dog2]
42+
43+
self._setup_query_mock(mock_query, mock_dogs)
44+
45+
# Act
46+
response = self.app.get('/api/dogs')
47+
48+
# Assert
49+
self.assertEqual(response.status_code, 200)
50+
51+
data = json.loads(response.data)
52+
self.assertEqual(len(data['dogs']), 2)
53+
self.assertEqual(data['page'], 1)
54+
self.assertEqual(data['total'], 2)
55+
56+
# Verify first dog
57+
self.assertEqual(data['dogs'][0]['id'], 1)
58+
self.assertEqual(data['dogs'][0]['name'], "Buddy")
59+
self.assertEqual(data['dogs'][0]['breed'], "Labrador")
60+
61+
# Verify second dog
62+
self.assertEqual(data['dogs'][1]['id'], 2)
63+
self.assertEqual(data['dogs'][1]['name'], "Max")
64+
self.assertEqual(data['dogs'][1]['breed'], "German Shepherd")
65+
66+
# Verify query was called
67+
mock_query.assert_called_once()
68+
69+
@patch('app.db.session.query')
70+
def test_get_dogs_empty(self, mock_query):
71+
"""Test retrieval when no dogs are available"""
72+
# Arrange
73+
self._setup_query_mock(mock_query, [])
74+
75+
# Act
76+
response = self.app.get('/api/dogs')
77+
78+
# Assert
79+
self.assertEqual(response.status_code, 200)
80+
data = json.loads(response.data)
81+
self.assertEqual(data['dogs'], [])
82+
self.assertEqual(data['total'], 0)
83+
84+
@patch('app.db.session.query')
85+
def test_get_dogs_structure(self, mock_query):
86+
"""Test the response structure for a single dog"""
87+
# Arrange
88+
dog = self._create_mock_dog(1, "Buddy", "Labrador")
89+
self._setup_query_mock(mock_query, [dog])
90+
91+
# Act
92+
response = self.app.get('/api/dogs')
93+
94+
# Assert
95+
data = json.loads(response.data)
96+
self.assertIn('dogs', data)
97+
self.assertIn('page', data)
98+
self.assertIn('total', data)
99+
self.assertIn('total_pages', data)
100+
self.assertTrue(isinstance(data['dogs'], list))
101+
self.assertEqual(len(data['dogs']), 1)
102+
self.assertEqual(set(data['dogs'][0].keys()), {'id', 'name', 'breed'})
103+
104+
105+
if __name__ == '__main__':
106+
unittest.main()

0 commit comments

Comments
 (0)