Skip to content
Open
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
15 changes: 15 additions & 0 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "chrome",
"request": "launch",
"name": "Launch Chrome against localhost",
"url": "http://localhost:8080",
"webRoot": "${workspaceFolder}"
}
]
}
84 changes: 82 additions & 2 deletions client/src/components/DogList.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,47 @@
id: number;
name: string;
breed: string;
status: string;
}
export let dogs: Dog[] = [];
let loading = true;
let error: string | null = null;
let breeds: string[] = [];
let selectedBreed = '';
let availableOnly = false;
const fetchBreeds = async () => {
try {
const response = await fetch('/api/breeds');
if(response.ok) {
breeds = await response.json();
} else {
console.error('Failed to fetch breeds:', response.status);
}
} catch (err) {
console.error('Error fetching breeds:', err);
}
};
const fetchDogs = async () => {
loading = true;
try {
const response = await fetch('/api/dogs');
let url = '/api/dogs';
const params = new URLSearchParams();
if (selectedBreed) {
params.append('breed', selectedBreed);
}
if (availableOnly) {
params.append('available', 'true');
}
if (params.toString()) {
url += '?' + params.toString();
}
const response = await fetch(url);
if(response.ok) {
dogs = await response.json();
} else {
Expand All @@ -28,13 +59,53 @@
};
onMount(() => {
fetchBreeds();
fetchDogs();
});
// Reactive statement to refetch dogs when filters change
$: if (selectedBreed !== undefined && availableOnly !== undefined) {
fetchDogs();
}
Comment on lines +67 to +69
Copy link

Copilot AI Sep 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This reactive statement will trigger fetchDogs() on component initialization when both variables are defined, causing an unnecessary duplicate API call since fetchDogs() is already called in onMount(). Consider checking if the component has been mounted or if the values have actually changed from their initial state.

Copilot uses AI. Check for mistakes.

</script>

<div>
<h2 class="text-2xl font-medium mb-6 text-slate-100">Available Dogs</h2>

<!-- Filter Controls -->
<div class="mb-6 p-4 bg-slate-800/50 backdrop-blur-sm rounded-xl border border-slate-700/50">
<div class="flex flex-col sm:flex-row gap-4">
<!-- Breed Filter -->
<div class="flex-1">
<label for="breed-filter" class="block text-sm font-medium text-slate-300 mb-2">
Filter by Breed
</label>
<select
id="breed-filter"
bind:value={selectedBreed}
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="">All Breeds</option>
{#each breeds as breed}
<option value={breed}>{breed}</option>
{/each}
</select>
</div>

<!-- Availability Filter -->
<div class="flex items-end">
<label class="flex items-center space-x-2 text-slate-300">
<input
type="checkbox"
bind:checked={availableOnly}
class="w-4 h-4 text-blue-600 bg-slate-700 border-slate-600 rounded focus:ring-blue-500 focus:ring-2"
/>
<span class="text-sm font-medium">Available dogs only</span>
</label>
</div>
</div>
</div>

{#if loading}
<!-- loading animation -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
Expand Down Expand Up @@ -72,7 +143,16 @@
<div class="absolute inset-0 bg-gradient-to-r from-blue-600/10 to-purple-600/5 opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
<div class="relative z-10">
<h3 class="text-xl font-semibold text-slate-100 mb-2 group-hover:text-blue-400 transition-colors">{dog.name}</h3>
<p class="text-slate-400 mb-4">{dog.breed}</p>
<p class="text-slate-400 mb-2">{dog.breed}</p>
<div class="mb-4">
{#if dog.status === 'AVAILABLE'}
<span class="inline-block px-2 py-1 text-xs font-medium bg-green-500/20 text-green-400 rounded-full">Available</span>
{:else if dog.status === 'PENDING'}
<span class="inline-block px-2 py-1 text-xs font-medium bg-amber-500/20 text-amber-400 rounded-full">Pending Adoption</span>
{:else if dog.status === 'ADOPTED'}
<span class="inline-block px-2 py-1 text-xs font-medium bg-red-500/20 text-red-400 rounded-full">Adopted</span>
{/if}
</div>
<div class="mt-4 text-sm text-blue-400 font-medium flex items-center">
<span>View details</span>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 ml-1 transform transition-transform duration-300 group-hover:translate-x-2" viewBox="0 0 20 20" fill="currentColor">
Expand Down
32 changes: 27 additions & 5 deletions server/app.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import os
from typing import Dict, List, Any, Optional
from flask import Flask, jsonify, Response
from models import init_db, db, Dog, Breed
from flask import Flask, jsonify, Response, request
from server.models import init_db, db, Dog, Breed

# Get the server directory path
base_dir: str = os.path.abspath(os.path.dirname(__file__))
Expand All @@ -15,20 +15,35 @@

@app.route('/api/dogs', methods=['GET'])
def get_dogs() -> Response:
# Get query parameters for filtering
breed_filter = request.args.get('breed')
available_only = request.args.get('available') == 'true'

query = db.session.query(
Dog.id,
Dog.name,
Breed.name.label('breed')
Breed.name.label('breed'),
Dog.status
).join(Breed, Dog.breed_id == Breed.id)

# Apply breed filter if provided
if breed_filter:
query = query.filter(Breed.name == breed_filter)

# Apply availability filter if requested
if available_only:
from server.models.dog import AdoptionStatus
Copy link

Copilot AI Sep 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The import statement is inside the function scope. For better maintainability and performance, consider moving this import to the top of the file with the other imports.

Copilot uses AI. Check for mistakes.

query = query.filter(Dog.status == AdoptionStatus.AVAILABLE)

dogs_query = query.all()

# Convert the result to a list of dictionaries
dogs_list: List[Dict[str, Any]] = [
{
'id': dog.id,
'name': dog.name,
'breed': dog.breed
'breed': dog.breed,
'status': dog.status.name if dog.status else 'UNKNOWN'
}
for dog in dogs_query
]
Expand Down Expand Up @@ -65,7 +80,14 @@ def get_dog(id: int) -> tuple[Response, int] | Response:

return jsonify(dog)

## HERE
@app.route('/api/breeds', methods=['GET'])
def get_breeds() -> Response:
breeds_query = db.session.query(Breed.name).distinct().all()

# Convert the result to a list of breed names
breeds_list: List[str] = [breed.name for breed in breeds_query]

return jsonify(breeds_list)

if __name__ == '__main__':
app.run(debug=True, port=5100) # Port 5100 to avoid macOS conflicts
118 changes: 110 additions & 8 deletions server/test_app.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import unittest
from unittest.mock import patch, MagicMock
import json
from app import app # Changed from relative import to absolute import
from server.app import app # Changed to absolute import for running from project root

# filepath: server/test_app.py
class TestApp(unittest.TestCase):
Expand All @@ -12,24 +12,33 @@ def setUp(self):
# Turn off database initialization for tests
app.config['TESTING'] = True

def _create_mock_dog(self, dog_id, name, breed):
def _create_mock_dog(self, dog_id, name, breed, status='AVAILABLE'):
"""Helper method to create a mock dog with standard attributes"""
dog = MagicMock(spec=['to_dict', 'id', 'name', 'breed'])
dog = MagicMock(spec=['to_dict', 'id', 'name', 'breed', 'status'])
dog.id = dog_id
dog.name = name
dog.breed = breed
dog.to_dict.return_value = {'id': dog_id, 'name': name, 'breed': breed}
dog.status = MagicMock()
dog.status.name = status
Comment on lines +21 to +22
Copy link

Copilot AI Sep 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The mock setup creates a nested MagicMock for the status attribute. This could be simplified by directly setting dog.status.name = status without the intermediate MagicMock assignment, or by using a more explicit mock setup that clearly represents the enum structure.

Copilot uses AI. Check for mistakes.

dog.to_dict.return_value = {'id': dog_id, 'name': name, 'breed': breed, 'status': status}
return dog

def _setup_query_mock(self, mock_query, dogs):
"""Helper method to configure the query mock"""
mock_query_instance = MagicMock()
mock_query.return_value = mock_query_instance
mock_query_instance.join.return_value = mock_query_instance
mock_query_instance.filter.return_value = mock_query_instance
mock_query_instance.all.return_value = dogs
return mock_query_instance

@patch('app.db.session.query')
def _create_mock_breed(self, name):
"""Helper method to create a mock breed"""
breed = MagicMock(spec=['name'])
breed.name = name
return breed

@patch('server.app.db.session.query')
def test_get_dogs_success(self, mock_query):
"""Test successful retrieval of multiple dogs"""
# Arrange
Expand All @@ -52,16 +61,18 @@ def test_get_dogs_success(self, mock_query):
self.assertEqual(data[0]['id'], 1)
self.assertEqual(data[0]['name'], "Buddy")
self.assertEqual(data[0]['breed'], "Labrador")
self.assertEqual(data[0]['status'], "AVAILABLE")

# Verify second dog
self.assertEqual(data[1]['id'], 2)
self.assertEqual(data[1]['name'], "Max")
self.assertEqual(data[1]['breed'], "German Shepherd")
self.assertEqual(data[1]['status'], "AVAILABLE")

# Verify query was called
mock_query.assert_called_once()

@patch('app.db.session.query')
@patch('server.app.db.session.query')
def test_get_dogs_empty(self, mock_query):
"""Test retrieval when no dogs are available"""
# Arrange
Expand All @@ -75,7 +86,7 @@ def test_get_dogs_empty(self, mock_query):
data = json.loads(response.data)
self.assertEqual(data, [])

@patch('app.db.session.query')
@patch('server.app.db.session.query')
def test_get_dogs_structure(self, mock_query):
"""Test the response structure for a single dog"""
# Arrange
Expand All @@ -89,7 +100,98 @@ def test_get_dogs_structure(self, mock_query):
data = json.loads(response.data)
self.assertTrue(isinstance(data, list))
self.assertEqual(len(data), 1)
self.assertEqual(set(data[0].keys()), {'id', 'name', 'breed'})
self.assertEqual(set(data[0].keys()), {'id', 'name', 'breed', 'status'})

@patch('server.app.db.session.query')
def test_get_dogs_breed_filter(self, mock_query):
"""Test filtering dogs by breed"""
# Arrange
beagle1 = self._create_mock_dog(1, "Buddy", "Beagle")
beagle2 = self._create_mock_dog(2, "Max", "Beagle")
self._setup_query_mock(mock_query, [beagle1, beagle2])

# Act
response = self.app.get('/api/dogs?breed=Beagle')

# Assert
self.assertEqual(response.status_code, 200)
data = json.loads(response.data)
self.assertEqual(len(data), 2)
for dog in data:
self.assertEqual(dog['breed'], 'Beagle')

@patch('server.app.db.session.query')
def test_get_dogs_available_filter(self, mock_query):
"""Test filtering dogs by availability"""
# Arrange
available_dog = self._create_mock_dog(1, "Buddy", "Labrador", "AVAILABLE")
self._setup_query_mock(mock_query, [available_dog])

# Act
response = self.app.get('/api/dogs?available=true')

# Assert
self.assertEqual(response.status_code, 200)
data = json.loads(response.data)
self.assertEqual(len(data), 1)
self.assertEqual(data[0]['status'], 'AVAILABLE')

@patch('server.app.db.session.query')
def test_get_dogs_combined_filters(self, mock_query):
"""Test filtering dogs by both breed and availability"""
# Arrange
available_beagle = self._create_mock_dog(1, "Buddy", "Beagle", "AVAILABLE")
self._setup_query_mock(mock_query, [available_beagle])

# Act
response = self.app.get('/api/dogs?breed=Beagle&available=true')

# Assert
self.assertEqual(response.status_code, 200)
data = json.loads(response.data)
self.assertEqual(len(data), 1)
self.assertEqual(data[0]['breed'], 'Beagle')
self.assertEqual(data[0]['status'], 'AVAILABLE')

@patch('server.app.db.session.query')
def test_get_breeds_success(self, mock_query):
"""Test successful retrieval of breed list"""
# Arrange
breed1 = self._create_mock_breed("Labrador")
breed2 = self._create_mock_breed("Beagle")
mock_breeds = [breed1, breed2]

mock_query_instance = MagicMock()
mock_query.return_value = mock_query_instance
mock_query_instance.distinct.return_value = mock_query_instance
mock_query_instance.all.return_value = mock_breeds

# Act
response = self.app.get('/api/breeds')

# Assert
self.assertEqual(response.status_code, 200)
data = json.loads(response.data)
self.assertEqual(len(data), 2)
self.assertIn("Labrador", data)
self.assertIn("Beagle", data)

@patch('server.app.db.session.query')
def test_get_breeds_empty(self, mock_query):
"""Test retrieval when no breeds are available"""
# Arrange
mock_query_instance = MagicMock()
mock_query.return_value = mock_query_instance
mock_query_instance.distinct.return_value = mock_query_instance
mock_query_instance.all.return_value = []

# Act
response = self.app.get('/api/breeds')

# Assert
self.assertEqual(response.status_code, 200)
data = json.loads(response.data)
self.assertEqual(data, [])


if __name__ == '__main__':
Expand Down