added functional tests

This commit is contained in:
Mathias Beaulieu-Duncan 2025-10-06 14:46:21 -04:00
parent 6335c8e942
commit 70d52fbe36
13 changed files with 864 additions and 6 deletions

5
.gitignore vendored
View File

@ -2,4 +2,7 @@ bin/
obj/
/packages/
riderModule.iml
/_ReSharper.Caches/
/_ReSharper.Caches/
# Development configuration files with secrets
**/appsettings.Development.json

View File

@ -0,0 +1,22 @@
namespace Svrnty.GeoManagement.Google.Configuration;
/// <summary>
/// Configuration options for Google Geocoding API
/// </summary>
public class GoogleGeoManagementOptions
{
/// <summary>
/// Google Maps API key for authentication
/// </summary>
public string ApiKey { get; set; } = string.Empty;
/// <summary>
/// Optional language code for address results (e.g., "en", "fr", "de")
/// </summary>
public string? Language { get; set; }
/// <summary>
/// Optional region code for biasing results (e.g., "us", "uk", "ca")
/// </summary>
public string? Region { get; set; }
}

View File

@ -0,0 +1,44 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Svrnty.GeoManagement.Abstractions.Abstractions;
using Svrnty.GeoManagement.Google.Configuration;
namespace Svrnty.GeoManagement.Google.Extensions;
/// <summary>
/// Extension methods for registering Google Geocoding services
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// Adds Google Geocoding provider to the service collection
/// </summary>
/// <param name="services">The service collection</param>
/// <param name="configuration">Configuration section containing GoogleGeoManagementOptions</param>
/// <returns>The service collection for chaining</returns>
public static IServiceCollection AddGoogleGeoManagement(
this IServiceCollection services,
IConfiguration configuration)
{
services.Configure<GoogleGeoManagementOptions>(configuration);
services.AddScoped<IGeoManagementProvider, GeoManagementGoogleProvider>();
return services;
}
/// <summary>
/// Adds Google Geocoding provider to the service collection with manual configuration
/// </summary>
/// <param name="services">The service collection</param>
/// <param name="configureOptions">Action to configure GoogleGeoManagementOptions</param>
/// <returns>The service collection for chaining</returns>
public static IServiceCollection AddGoogleGeoManagement(
this IServiceCollection services,
Action<GoogleGeoManagementOptions> configureOptions)
{
services.Configure(configureOptions);
services.AddScoped<IGeoManagementProvider, GeoManagementGoogleProvider>();
return services;
}
}

View File

@ -0,0 +1,209 @@
using GoogleApi;
using GoogleApi.Entities.Maps.Geocoding.Address.Request;
using GoogleApi.Entities.Maps.Geocoding.Location.Request;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Svrnty.GeoManagement.Abstractions.Abstractions;
using Svrnty.GeoManagement.Abstractions.Models;
using Svrnty.GeoManagement.Google.Configuration;
using Svrnty.GeoManagement.Google.Mapping;
namespace Svrnty.GeoManagement.Google;
/// <summary>
/// Google Geocoding API implementation of IGeoManagementProvider
/// </summary>
public class GeoManagementGoogleProvider : IGeoManagementProvider
{
private readonly GoogleGeoManagementOptions _options;
private readonly ILogger<GeoManagementGoogleProvider> _logger;
public GeoManagementGoogleProvider(
IOptions<GoogleGeoManagementOptions> options,
ILogger<GeoManagementGoogleProvider> logger)
{
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
if (string.IsNullOrWhiteSpace(_options.ApiKey))
{
throw new ArgumentException("Google API key is required", nameof(options));
}
}
public async Task<GeoPoint?> GetGeoPointAsync(
Address address,
CancellationToken cancellationToken = default)
{
try
{
var addressString = address.GetFormattedAddress();
_logger.LogDebug("Geocoding address: {Address}", addressString);
var request = new AddressGeocodeRequest
{
Key = _options.ApiKey,
Address = addressString
};
if (!string.IsNullOrWhiteSpace(_options.Language) &&
Enum.TryParse<GoogleApi.Entities.Common.Enums.Language>(_options.Language, true, out var language))
request.Language = language;
if (!string.IsNullOrWhiteSpace(_options.Region))
request.Region = _options.Region;
var response = await GoogleMaps.Geocode.AddressGeocode.QueryAsync(request, cancellationToken);
if (response.Status != GoogleApi.Entities.Common.Enums.Status.Ok)
{
_logger.LogWarning("Google Geocoding API returned status: {Status}", response.Status);
return null;
}
var result = response.Results?.FirstOrDefault();
var geoPoint = GoogleAddressMapper.MapToGeoPoint(result);
if (geoPoint != null)
{
_logger.LogDebug("Successfully geocoded address to: {Latitude}, {Longitude}",
geoPoint.Latitude, geoPoint.Longitude);
}
return geoPoint;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error geocoding address: {Address}", address.GetFormattedAddress());
return null;
}
}
public async Task<Address?> ReverseGeocodeAsync(
GeoPoint geoPoint,
CancellationToken cancellationToken = default)
{
try
{
_logger.LogDebug("Reverse geocoding location: {Latitude}, {Longitude}",
geoPoint.Latitude, geoPoint.Longitude);
var request = new LocationGeocodeRequest
{
Key = _options.ApiKey,
Location = new GoogleApi.Entities.Common.Coordinate(geoPoint.Latitude, geoPoint.Longitude)
};
if (!string.IsNullOrWhiteSpace(_options.Language) &&
Enum.TryParse<GoogleApi.Entities.Common.Enums.Language>(_options.Language, true, out var language))
request.Language = language;
var response = await GoogleMaps.Geocode.LocationGeocode.QueryAsync(request, cancellationToken);
if (response.Status != GoogleApi.Entities.Common.Enums.Status.Ok)
{
_logger.LogWarning("Google Reverse Geocoding API returned status: {Status}", response.Status);
return null;
}
var result = response.Results?.FirstOrDefault();
var address = GoogleAddressMapper.MapToAddress(result);
if (address != null)
{
_logger.LogDebug("Successfully reverse geocoded to: {Address}", address.GetFormattedAddress());
}
return address;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error reverse geocoding location: {Latitude}, {Longitude}",
geoPoint.Latitude, geoPoint.Longitude);
return null;
}
}
public async Task<Address?> NormalizeAddressAsync(
Address address,
CancellationToken cancellationToken = default)
{
try
{
_logger.LogDebug("Normalizing address: {Address}", address.GetFormattedAddress());
// First geocode to get coordinates
var geoPoint = await GetGeoPointAsync(address, cancellationToken);
if (geoPoint == null)
{
_logger.LogWarning("Could not geocode address for normalization: {Address}",
address.GetFormattedAddress());
return null;
}
// Then reverse geocode to get normalized address
var normalizedAddress = await ReverseGeocodeAsync(geoPoint, cancellationToken);
if (normalizedAddress != null)
{
_logger.LogDebug("Successfully normalized address from {Original} to {Normalized}",
address.GetFormattedAddress(), normalizedAddress.GetFormattedAddress());
}
return normalizedAddress;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error normalizing address: {Address}", address.GetFormattedAddress());
return null;
}
}
public async Task<Address?> NormalizeAddressAsync(
string address,
CancellationToken cancellationToken = default)
{
try
{
_logger.LogDebug("Normalizing address string: {Address}", address);
var request = new AddressGeocodeRequest
{
Key = _options.ApiKey,
Address = address
};
if (!string.IsNullOrWhiteSpace(_options.Language) &&
Enum.TryParse<GoogleApi.Entities.Common.Enums.Language>(_options.Language, true, out var language))
request.Language = language;
if (!string.IsNullOrWhiteSpace(_options.Region))
request.Region = _options.Region;
var response = await GoogleMaps.Geocode.AddressGeocode.QueryAsync(request, cancellationToken);
if (response.Status != GoogleApi.Entities.Common.Enums.Status.Ok)
{
_logger.LogWarning("Google Geocoding API returned status: {Status} for address: {Address}",
response.Status, address);
return null;
}
var result = response.Results?.FirstOrDefault();
var normalizedAddress = GoogleAddressMapper.MapToAddress(result);
if (normalizedAddress != null)
{
_logger.LogDebug("Successfully normalized address string to: {Normalized}",
normalizedAddress.GetFormattedAddress());
}
return normalizedAddress;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error normalizing address string: {Address}", address);
return null;
}
}
}

View File

@ -0,0 +1,86 @@
using GoogleApi.Entities.Common;
using GoogleApi.Entities.Maps.Geocoding.Common;
using Svrnty.GeoManagement.Abstractions.Models;
namespace Svrnty.GeoManagement.Google.Mapping;
/// <summary>
/// Maps Google Geocoding API responses to Address models
/// </summary>
internal static class GoogleAddressMapper
{
public static Abstractions.Models.Address? MapToAddress(Result? googleResult)
{
if (googleResult == null)
return null;
var components = googleResult.AddressComponents;
if (components == null || !components.Any())
return null;
var line1 = BuildLine1(components);
var line2 = GetComponentValue(components, "subpremise");
var city = GetCity(components);
var subdivision = GetSubdivision(components);
var postalCode = GetComponentValue(components, "postal_code") ?? string.Empty;
var country = GetComponentValue(components, "country") ?? string.Empty;
var location = googleResult.Geometry?.Location != null
? new GeoPoint(googleResult.Geometry.Location.Latitude, googleResult.Geometry.Location.Longitude)
: null;
return new Abstractions.Models.Address(
Line1: line1,
Line2: line2,
City: city,
Subdivision: subdivision,
PostalCode: postalCode,
Country: country,
Location: location,
Note: null,
IsNormalized: true
);
}
private static string BuildLine1(IEnumerable<AddressComponent> components)
{
var streetNumber = GetComponentValue(components, "street_number");
var route = GetComponentValue(components, "route");
if (!string.IsNullOrWhiteSpace(streetNumber) && !string.IsNullOrWhiteSpace(route))
return $"{streetNumber} {route}";
return route ?? streetNumber ?? string.Empty;
}
private static string GetCity(IEnumerable<AddressComponent> components)
{
return GetComponentValue(components, "locality")
?? GetComponentValue(components, "postal_town")
?? GetComponentValue(components, "administrative_area_level_2")
?? string.Empty;
}
private static string GetSubdivision(IEnumerable<AddressComponent> components)
{
return GetComponentValue(components, "administrative_area_level_1") ?? string.Empty;
}
private static string? GetComponentValue(IEnumerable<AddressComponent> components, string type)
{
var component = components.FirstOrDefault(c =>
c.Types.Any(t => t.ToString().ToLower().Replace("_", "") == type.Replace("_", "")));
return component?.ShortName;
}
public static GeoPoint? MapToGeoPoint(Result? googleResult)
{
if (googleResult?.Geometry?.Location == null)
return null;
return new GeoPoint(
googleResult.Geometry.Location.Latitude,
googleResult.Geometry.Location.Longitude
);
}
}

View File

@ -0,0 +1,106 @@
# Svrnty.GeoManagement.Google
Google Geocoding API provider implementation for the Svrnty.GeoManagement library.
## Installation
Add reference to this project or install via NuGet (when published).
## Configuration
### Using appsettings.json
```json
{
"GoogleGeoManagement": {
"ApiKey": "your-google-api-key-here",
"Language": "en",
"Region": "us"
}
}
```
### Register in Dependency Injection
```csharp
using Svrnty.GeoManagement.Google.Extensions;
// In your Program.cs or Startup.cs
builder.Services.AddGoogleGeoManagement(
builder.Configuration.GetSection("GoogleGeoManagement"));
```
### Manual Configuration
```csharp
builder.Services.AddGoogleGeoManagement(options =>
{
options.ApiKey = "your-google-api-key-here";
options.Language = "en";
options.Region = "us";
});
```
## Usage
```csharp
using Svrnty.GeoManagement.Abstractions.Abstractions;
using Svrnty.GeoManagement.Abstractions.Models;
public class MyService
{
private readonly IGeoManagementProvider _geoProvider;
public MyService(IGeoManagementProvider geoProvider)
{
_geoProvider = geoProvider;
}
public async Task Example()
{
// Forward geocoding - address to coordinates
var address = new Address(
Line1: "1600 Amphitheatre Parkway",
Line2: null,
City: "Mountain View",
Subdivision: "CA",
PostalCode: "94043",
Country: "US",
Location: null,
Note: null);
var geoPoint = await _geoProvider.GetGeoPointAsync(address);
Console.WriteLine($"Coordinates: {geoPoint?.Latitude}, {geoPoint?.Longitude}");
// Reverse geocoding - coordinates to address
var location = new GeoPoint(37.4224764, -122.0842499);
var foundAddress = await _geoProvider.ReverseGeocodeAsync(location);
Console.WriteLine($"Address: {foundAddress?.GetFormattedAddress()}");
// Normalize address
var normalized = await _geoProvider.NormalizeAddressAsync(address);
Console.WriteLine($"Normalized: {normalized?.GetFormattedAddress()}");
// Normalize from string
var normalizedFromString = await _geoProvider.NormalizeAddressAsync(
"1600 Amphitheatre Parkway, Mountain View, CA");
Console.WriteLine($"Normalized: {normalizedFromString?.GetFormattedAddress()}");
}
}
```
## Configuration Options
- **ApiKey** (required): Your Google Maps API key
- **Language** (optional): Language code for results (e.g., "en", "fr", "de")
- **Region** (optional): Region code for biasing results (e.g., "us", "uk", "ca")
## Requirements
- .NET 8.0
- Google Maps API key with Geocoding API enabled
- Internet connection for API calls
## Error Handling
The provider returns `null` for operations that fail (e.g., invalid address, API errors, network issues). All errors are logged using `ILogger<GeoManagementGoogleProvider>`.

View File

@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\Svrnty.GeoManagement.Abstractions\Svrnty.GeoManagement.Abstractions.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="GoogleApi" Version="5.8.11" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.2" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.3" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="8.0.0" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@ -0,0 +1,173 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Svrnty.GeoManagement.Abstractions.Models;
using Svrnty.GeoManagement.Google;
using Svrnty.GeoManagement.Google.Configuration;
namespace Svrnty.GeoManagement.Tests;
public class GoogleProviderTests : IDisposable
{
private readonly GeoManagementGoogleProvider _provider;
private readonly IConfiguration _configuration;
private readonly ILogger<GeoManagementGoogleProvider> _logger;
public GoogleProviderTests()
{
// Build configuration from appsettings.Development.json
_configuration = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("appsettings.Development.json", optional: false, reloadOnChange: false)
.AddEnvironmentVariables()
.Build();
// Setup logger
var loggerFactory = LoggerFactory.Create(builder =>
{
builder.AddConsole();
builder.SetMinimumLevel(LogLevel.Debug);
});
_logger = loggerFactory.CreateLogger<GeoManagementGoogleProvider>();
// Get options from configuration
var options = _configuration.GetSection("GoogleGeoManagement").Get<GoogleGeoManagementOptions>();
if (options == null || string.IsNullOrWhiteSpace(options.ApiKey))
{
throw new InvalidOperationException(
"Google API key not configured. Please add your API key to appsettings.Development.json");
}
_provider = new GeoManagementGoogleProvider(
Options.Create(options),
_logger);
}
[Fact]
public async Task GetGeoPointAsync_WithValidAddress_ReturnsCoordinates()
{
// Arrange
var address = new Address(
Line1: "1600 Amphitheatre Parkway",
Line2: null,
City: "Mountain View",
Subdivision: "CA",
PostalCode: "94043",
Country: "US",
Location: null,
Note: null);
// Act
var result = await _provider.GetGeoPointAsync(address);
// Assert
Assert.NotNull(result);
Assert.InRange(result.Latitude, 37.0, 38.0); // Approximate range for Mountain View, CA
Assert.InRange(result.Longitude, -123.0, -122.0);
}
[Fact]
public async Task ReverseGeocodeAsync_WithValidCoordinates_ReturnsAddress()
{
// Arrange - Google HQ coordinates
var geoPoint = new GeoPoint(37.4224764, -122.0842499);
// Act
var result = await _provider.ReverseGeocodeAsync(geoPoint);
// Assert
Assert.NotNull(result);
Assert.NotNull(result.Line1);
Assert.NotNull(result.City);
Assert.NotNull(result.Country);
Assert.True(result.IsNormalized);
Assert.NotNull(result.Location);
}
[Fact]
public async Task NormalizeAddressAsync_WithAddressObject_ReturnsNormalizedAddress()
{
// Arrange
var address = new Address(
Line1: "1600 Amphitheatre Pkwy", // Abbreviated
Line2: null,
City: "Mountain View",
Subdivision: "California",
PostalCode: "94043",
Country: "USA",
Location: null,
Note: null);
// Act
var result = await _provider.NormalizeAddressAsync(address);
// Assert
Assert.NotNull(result);
Assert.True(result.IsNormalized);
Assert.NotNull(result.Location);
Assert.NotNull(result.Line1);
Assert.Contains("Amphitheatre", result.Line1, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task NormalizeAddressAsync_WithAddressString_ReturnsNormalizedAddress()
{
// Arrange
var addressString = "1600 Amphitheatre Parkway, Mountain View, CA";
// Act
var result = await _provider.NormalizeAddressAsync(addressString);
// Assert
Assert.NotNull(result);
Assert.True(result.IsNormalized);
Assert.NotNull(result.Location);
Assert.NotNull(result.Line1);
Assert.Contains("Mountain View", result.City, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task GetGeoPointAsync_WithInvalidAddress_ReturnsNull()
{
// Arrange
var address = new Address(
Line1: "This is not a real address at all",
Line2: null,
City: "Fake City",
Subdivision: "XX",
PostalCode: "00000",
Country: "Nowhere",
Location: null,
Note: null);
// Act
var result = await _provider.GetGeoPointAsync(address);
// Assert - Google might still return something, so we just verify it doesn't crash
// In real scenarios, very invalid addresses should return null
Assert.True(result == null || result.Latitude != 0 || result.Longitude != 0);
}
[Fact]
public async Task ReverseGeocodeAsync_WithOceanCoordinates_ReturnsAddress()
{
// Arrange - Coordinates in the middle of the Pacific Ocean
var geoPoint = new GeoPoint(0, -160);
// Act
var result = await _provider.ReverseGeocodeAsync(geoPoint);
// Assert - May return null or a very general location
// This test just verifies it doesn't crash
if (result != null)
{
Assert.True(result.IsNormalized);
}
}
public void Dispose()
{
// Cleanup if needed
GC.SuppressFinalize(this);
}
}

View File

@ -0,0 +1,66 @@
# Svrnty.GeoManagement.Tests
Integration tests for the Svrnty.GeoManagement library.
## Setup
The tests require a Google Maps API key to run the integration tests.
### Step 1: Get a Google Maps API Key
1. Go to [Google Cloud Console](https://console.cloud.google.com/)
2. Create a new project or select an existing one
3. Enable the **Geocoding API**
4. Create credentials (API Key)
5. (Optional) Restrict the API key to only allow Geocoding API calls
### Step 2: Configure the API Key
1. Copy `appsettings.Example.json` to `appsettings.Development.json`:
```bash
cp appsettings.Example.json appsettings.Development.json
```
2. Edit `appsettings.Development.json` and replace `YOUR_GOOGLE_API_KEY_HERE` with your actual API key:
```json
{
"GoogleGeoManagement": {
"ApiKey": "AIza...", // Your actual API key
"Language": "en",
"Region": "us"
}
}
```
3. **Important**: `appsettings.Development.json` is in `.gitignore` and will not be committed to version control.
### Alternative: Using Environment Variables
You can also set the API key via environment variables:
```bash
export GoogleGeoManagement__ApiKey="your-api-key-here"
```
Note: Use double underscores (`__`) to represent nested configuration sections.
## Running Tests
```bash
dotnet test
```
The integration tests will make actual calls to the Google Geocoding API. Be aware of:
- **API Rate Limits**: Google has rate limits on API calls
- **API Costs**: While Google provides free tier usage, be aware of potential costs
- **Network Dependency**: Tests require an internet connection
## Test Coverage
The test suite includes:
- Forward geocoding (address to coordinates)
- Reverse geocoding (coordinates to address)
- Address normalization (both object and string)
- Edge cases (invalid addresses, ocean coordinates)
All tests use real Google API calls to ensure the integration works correctly.

View File

@ -10,14 +10,29 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.0"/>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0"/>
<PackageReference Include="xunit" Version="2.5.3"/>
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3"/>
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="8.0.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="xunit" Version="2.5.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit"/>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Svrnty.GeoManagement.Google\Svrnty.GeoManagement.Google.csproj" />
</ItemGroup>
<ItemGroup>
<None Update="appsettings.Development.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="appsettings.Example.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

View File

@ -0,0 +1,7 @@
{
"GoogleGeoManagement": {
"ApiKey": "YOUR_GOOGLE_API_KEY_HERE",
"Language": "en",
"Region": "ca"
}
}

View File

@ -1,9 +1,12 @@

Microsoft Visual Studio Solution File, Format Version 12.00
#
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Svrnty.GeoManagement.Tests", "Svrnty.GeoManagement.Tests\Svrnty.GeoManagement.Tests.csproj", "{C2DE2776-E457-49B0-A3E2-E31AFE8C922C}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Svrnty.GeoManagement.Abstractions", "Svrnty.GeoManagement.Abstractions\Svrnty.GeoManagement.Abstractions.csproj", "{FB10FC74-7B99-4B3C-A671-6DF451157C98}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Svrnty.GeoManagement.Google", "Svrnty.GeoManagement.Google\Svrnty.GeoManagement.Google.csproj", "{1EE527A7-30CD-4280-B006-2F39474325D6}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@ -18,5 +21,9 @@ Global
{FB10FC74-7B99-4B3C-A671-6DF451157C98}.Debug|Any CPU.Build.0 = Debug|Any CPU
{FB10FC74-7B99-4B3C-A671-6DF451157C98}.Release|Any CPU.ActiveCfg = Release|Any CPU
{FB10FC74-7B99-4B3C-A671-6DF451157C98}.Release|Any CPU.Build.0 = Release|Any CPU
{1EE527A7-30CD-4280-B006-2F39474325D6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{1EE527A7-30CD-4280-B006-2F39474325D6}.Debug|Any CPU.Build.0 = Debug|Any CPU
{1EE527A7-30CD-4280-B006-2F39474325D6}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1EE527A7-30CD-4280-B006-2F39474325D6}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal

100
TESTING.md Normal file
View File

@ -0,0 +1,100 @@
# Testing Guide
This document explains how to run tests for the Svrnty.GeoManagement library.
## Test Types
The test suite includes **integration tests** that make real API calls to the Google Geocoding API.
## Prerequisites
Before running tests, you need:
1. **Google Maps API Key** - Get one from [Google Cloud Console](https://console.cloud.google.com/)
- Enable the **Geocoding API** for your project
- Create an API key credential
- (Optional) Restrict the key to Geocoding API only
2. **Configuration File** - Set up your API key:
```bash
cd Svrnty.GeoManagement.Tests
cp appsettings.Example.json appsettings.Development.json
```
Then edit `appsettings.Development.json` and add your API key:
```json
{
"GoogleGeoManagement": {
"ApiKey": "YOUR_ACTUAL_API_KEY_HERE",
"Language": "en",
"Region": "us"
}
}
```
> **Note**: The `appsettings.Development.json` file is in `.gitignore` and will not be committed to source control.
## Running Tests
From the solution root:
```bash
dotnet test
```
From the test project directory:
```bash
cd Svrnty.GeoManagement.Tests
dotnet test
```
## Important Notes
⚠️ **API Costs & Limits**:
- Tests make real API calls to Google
- Google provides a free tier but be aware of potential costs
- API has rate limits - don't run tests too frequently
- Each test run may use several API calls
🌐 **Network Dependency**:
- Tests require internet connection
- Tests may fail if Google API is down or rate limits are exceeded
## Alternative: Environment Variables
Instead of using `appsettings.Development.json`, you can set the API key via environment variables:
```bash
export GoogleGeoManagement__ApiKey="your-api-key-here"
dotnet test
```
Note: Use double underscores (`__`) to represent nested configuration.
## Test Coverage
Current integration tests cover:
- ✅ Forward geocoding (address → coordinates)
- ✅ Reverse geocoding (coordinates → address)
- ✅ Address normalization from object
- ✅ Address normalization from string
- ✅ Edge cases (invalid addresses, ocean coordinates)
## Troubleshooting
**Error: "Google API key not configured"**
- Ensure `appsettings.Development.json` exists in the test project
- Verify the API key is correctly set in the configuration file
- Check that the file is being copied to the output directory
**Error: "REQUEST_DENIED" or "OVER_QUERY_LIMIT"**
- Verify the Geocoding API is enabled in your Google Cloud project
- Check your API key has permission to use the Geocoding API
- You may have exceeded your quota - wait before retrying
**Tests are slow**
- This is expected - tests make real network calls to Google's API
- Each test can take 1-2 seconds to complete