From c93bc90837f37206762cbb7fb59573275cf3d461 Mon Sep 17 00:00:00 2001 From: Carpenteri1 Date: Fri, 7 Aug 2026 17:16:29 +0200 Subject: [PATCH 1/3] 330-junction-table-sql-for-weather --- CQRS/Commands/SaveWeatherCommand.cs | 1 + Constants/QueryStrings.cs | 60 ++++-- Data/DbInitializer.cs | 18 +- Dtos/CardWeatherDataDtoModel.cs | 16 ++ Dtos/WeatherDataConnectionDtoModel.cs | 8 + Factories/WeatherDataConnectionFactory.cs | 14 ++ .../edit-card-dialog.facade.spec.ts | 4 +- .../editCardDialog/edit-card-dialog.facade.ts | 7 +- ...-location-for-provider-dialog.component.ts | 5 +- .../src/app/models/cardWeatherData.Model.ts | 12 ++ .../src/app/models/weatherData.Model.ts | 1 - .../weather.endpoint.service.ts | 9 +- .../weather-provider.service.ts | 16 +- Handlers/ColumnRowHandler.cs | 7 + Handlers/WeatherHandler.cs | 21 +- Models/WeatherDataModel.cs | 1 - Program.cs | 1 + .../IWeatherDataConnectionRepository.cs | 10 + Repositories/IWeatherRepository.cs | 7 +- .../WeatherDataConnectionRepository.cs | 38 ++++ Repositories/WeatherRepository.cs | 28 +-- Tests/Handlers/ColumnRowHandlerTests.cs | 72 +++++++ Tests/Handlers/WeatherHandlerTests.cs | 110 ++++++---- Tests/Infrastructure/TestDoubles.cs | 84 +++++--- .../WeatherDataConnectionRepositoryTests.cs | 108 ++++++++++ Tests/Repositories/WeatherRepositoryTests.cs | 203 ++++++++++++++++++ 26 files changed, 709 insertions(+), 152 deletions(-) create mode 100644 Dtos/CardWeatherDataDtoModel.cs create mode 100644 Dtos/WeatherDataConnectionDtoModel.cs create mode 100644 Factories/WeatherDataConnectionFactory.cs create mode 100644 Gridly-Client/src/app/models/cardWeatherData.Model.ts create mode 100644 Repositories/IWeatherDataConnectionRepository.cs create mode 100644 Repositories/WeatherDataConnectionRepository.cs create mode 100644 Tests/Repositories/WeatherDataConnectionRepositoryTests.cs create mode 100644 Tests/Repositories/WeatherRepositoryTests.cs diff --git a/CQRS/Commands/SaveWeatherCommand.cs b/CQRS/Commands/SaveWeatherCommand.cs index a8433652..42bc59d3 100644 --- a/CQRS/Commands/SaveWeatherCommand.cs +++ b/CQRS/Commands/SaveWeatherCommand.cs @@ -6,4 +6,5 @@ namespace Gridly.Commands; public class SaveWeatherCommand : IRequest { public WeatherDataModel Weather { get; set; } + public int CardId { get; set; } } \ No newline at end of file diff --git a/Constants/QueryStrings.cs b/Constants/QueryStrings.cs index 4d709867..a66b5ece 100644 --- a/Constants/QueryStrings.cs +++ b/Constants/QueryStrings.cs @@ -27,11 +27,32 @@ INSERT INTO IconsConnected (CardId, IconId) VALUES (@CardId, @IconId); SELECT * FROM IconsConnected WHERE Id = last_insert_rowid();"; - public const string InsertWeatherDataQuery = @" - INSERT INTO WeatherData (CardId, Address, Timezone, Description, Temp, FeelsLike, Humidity, WindSpeed, WindDir, FetchedAt) - VALUES (@CardId, @Address, @Timezone, @Description, @Temp, @FeelsLike, @Humidity, @WindSpeed, @WindDir, @FetchedAt); - SELECT * FROM RowColumn WHERE Id = last_insert_rowid();"; - + public const string UpsertWeatherDataQuery = @" + INSERT INTO WeatherData (Address, Timezone, Description, Temp, FeelsLike, Humidity, WindSpeed, WindDir, FetchedAt) + VALUES (@Address, @Timezone, @Description, @Temp, @FeelsLike, @Humidity, @WindSpeed, @WindDir, @FetchedAt) + ON CONFLICT(Address) DO UPDATE SET + Timezone = excluded.Timezone, + Description = excluded.Description, + Temp = excluded.Temp, + FeelsLike = excluded.FeelsLike, + Humidity = excluded.Humidity, + WindSpeed = excluded.WindSpeed, + WindDir = excluded.WindDir, + FetchedAt = excluded.FetchedAt + RETURNING *;"; + + public const string UpsertWeatherDataConnectionQuery = @" + INSERT INTO WeatherDataConnection (CardId, WeatherId) + VALUES (@CardId, @WeatherId) + ON CONFLICT(CardId) DO UPDATE SET + WeatherId = excluded.WeatherId + RETURNING *;"; + + public const string DeleteOrphanedWeatherDataQuery = @" + DELETE FROM WeatherData + WHERE Id = @Id + AND NOT EXISTS (SELECT 1 FROM WeatherDataConnection WHERE WeatherId = @Id);"; + public const string SelectCardQuery = @" SELECT co.Id AS CardId, @@ -141,23 +162,22 @@ UPDATE ProviderKeys FROM ProviderKeys WHERE Provider = @Provider;"; - public const string UpdateWeatherDataQuery = @" - UPDATE WeatherData - SET Address = @Address, - Timezone = @Timezone, - Description = @Description, - Temp = @Temp, - FeelsLike = @FeelsLike, - Humidity = @Humidity, - WindSpeed = @WindSpeed, - WindDir = @WindDir, - FetchedAt = @FetchedAt - WHERE CardId = @CardId;"; - public const string SelectWeatherDataQuery = @" - SELECT Id, CardId, Address, Timezone, Description, Temp, FeelsLike, Humidity, WindSpeed, WindDir, FetchedAt + SELECT Id, Address, Timezone, Description, Temp, FeelsLike, Humidity, WindSpeed, WindDir, FetchedAt FROM WeatherData /**where**/"; + public const string SelectCardWeatherDataQuery = @" + SELECT wc.CardId, w.Id AS WeatherId, w.Address, w.Timezone, w.Description, + w.Temp, w.FeelsLike, w.Humidity, w.WindSpeed, w.WindDir, w.FetchedAt + FROM WeatherDataConnection wc + INNER JOIN WeatherData w ON w.Id = wc.WeatherId /**where**/"; + + public const string SelectWeatherDataConnectionQuery = @" + SELECT * + FROM WeatherDataConnection wc /**where**/"; + + public const string DeleteFromWeatherDataConnectionQuery = "DELETE FROM WeatherDataConnection /**where**/"; + public const string JoinIconDataQuery = "Icon i ON i.Id = ic.IconId"; public const string JoinIconsConnectedDataQuery = "IconsConnected ic ON ic.CardId = co.Id"; public const string JoinSettingsQuery = "Settings cs ON cs.CardId = co.Id"; @@ -166,6 +186,8 @@ UPDATE WeatherData public const string WhereCardIdForeignKeyEqualId = "CardId = @CardId"; public const string WhereLocationEqualsLocation = "Address = @Address"; public const string WhereIdEqualsId = "Id = @Id"; + public const string WhereWeatherConnectedCardIdForeignKeyEqualIdWithAlias = "wc.CardId = @CardId"; + public const string WhereWeatherConnectedWeatherIdForeignKeyEqualIdWithAlias = "wc.WeatherId = @WeatherId"; public const string WhereIconConnectedIconIdForeignKeyEqualIdWithAlias = "ic.IconId = @IconId"; public const string WhereIconConnectedCardIdForeignKeyEqualIdWithAlias = "ic.CardId = @CardId"; public const string WhereCardIdEqualsCardIdWithAlias = "co.Id = @cardId"; diff --git a/Data/DbInitializer.cs b/Data/DbInitializer.cs index d5c72409..0dd22f36 100644 --- a/Data/DbInitializer.cs +++ b/Data/DbInitializer.cs @@ -1,5 +1,6 @@ using Dapper; using System.Data; +using Microsoft.Data.Sqlite; namespace Gridly.Data; @@ -11,7 +12,7 @@ public DbInitializer(IDbConnection connection) { this.connection = connection; } - + public async Task EnsureTablesCreatedAsync() { await connection.ExecuteAsync( @@ -75,8 +76,7 @@ CREATE TABLE IF NOT EXISTS ProviderKeys( CREATE TABLE IF NOT EXISTS WeatherData( Id INTEGER PRIMARY KEY AUTOINCREMENT, - CardId INTEGER NOT NULL, - Address TEXT NOT NULL, + Address TEXT NOT NULL UNIQUE, Timezone TEXT NOT NULL, Description TEXT NOT NULL, Temp REAL NOT NULL, @@ -84,8 +84,16 @@ CREATE TABLE IF NOT EXISTS WeatherData( Humidity REAL NOT NULL, WindSpeed REAL NOT NULL, WindDir REAL NOT NULL, - FetchedAt TEXT NOT NULL, - FOREIGN KEY(CardId) REFERENCES Card(Id)); + FetchedAt TEXT NOT NULL); + + CREATE TABLE IF NOT EXISTS WeatherDataConnection( + Id INTEGER PRIMARY KEY AUTOINCREMENT, + CardId INTEGER NOT NULL, + WeatherId INTEGER NOT NULL, + FOREIGN KEY(CardId) REFERENCES Card(Id) ON DELETE CASCADE, + FOREIGN KEY(WeatherId) REFERENCES WeatherData(Id) ON DELETE CASCADE); + + CREATE INDEX IF NOT EXISTS idx_weatherdataconnection_weatherid ON WeatherDataConnection(WeatherId); INSERT INTO WidgetType(Name) SELECT 'Empty' diff --git a/Dtos/CardWeatherDataDtoModel.cs b/Dtos/CardWeatherDataDtoModel.cs new file mode 100644 index 00000000..23f32729 --- /dev/null +++ b/Dtos/CardWeatherDataDtoModel.cs @@ -0,0 +1,16 @@ +namespace Gridly.Dtos; + +public class CardWeatherDataDtoModel +{ + public int CardId { get; set; } + public int WeatherId { get; set; } + public string Address { get; set; } + public string Timezone { get; set; } + public string Description { get; set; } + public double Temp { get; set; } + public double FeelsLike { get; set; } + public double Humidity { get; set; } + public double WindSpeed { get; set; } + public double WindDir { get; set; } + public DateTime FetchedAt { get; set; } +} diff --git a/Dtos/WeatherDataConnectionDtoModel.cs b/Dtos/WeatherDataConnectionDtoModel.cs new file mode 100644 index 00000000..c568da39 --- /dev/null +++ b/Dtos/WeatherDataConnectionDtoModel.cs @@ -0,0 +1,8 @@ +namespace Gridly.Dtos; + +public class WeatherDataConnectionDtoModel +{ + public int? Id { get; set; } + public int? CardId { get; set; } + public int? WeatherId { get; set; } +} diff --git a/Factories/WeatherDataConnectionFactory.cs b/Factories/WeatherDataConnectionFactory.cs new file mode 100644 index 00000000..6adf5371 --- /dev/null +++ b/Factories/WeatherDataConnectionFactory.cs @@ -0,0 +1,14 @@ +using Gridly.Dtos; + +namespace Gridly.Factories +{ + public class WeatherDataConnectionFactory + { + public static WeatherDataConnectionDtoModel Create(int cardId, int weatherId) + => new WeatherDataConnectionDtoModel + { + CardId = cardId, + WeatherId = weatherId, + }; + } +} diff --git a/Gridly-Client/src/app/components/dialogs/editCardDialog/edit-card-dialog.facade.spec.ts b/Gridly-Client/src/app/components/dialogs/editCardDialog/edit-card-dialog.facade.spec.ts index eb8b61cf..f16756f4 100644 --- a/Gridly-Client/src/app/components/dialogs/editCardDialog/edit-card-dialog.facade.spec.ts +++ b/Gridly-Client/src/app/components/dialogs/editCardDialog/edit-card-dialog.facade.spec.ts @@ -22,7 +22,6 @@ describe('EditCardDialogFacade', () => { }; const makeWeather = (): WeatherDataModel => ({ - cardId: 0, address: 'Stockholm, Sweden', timezone: 'Europe/Stockholm', description: 'clear', @@ -158,7 +157,8 @@ describe('EditCardDialogFacade', () => { expect(result).toBe(true); expect(weatherProviderServiceMock.getVisualCrossingData).toHaveBeenCalledWith('Sweden,Stockholm'); expect(weatherProviderServiceMock.save).toHaveBeenCalledWith( - expect.objectContaining({"address": "Stockholm, Sweden", "cardId": 9, "description": "clear", "feelsLike": 20, "humidity": 50, "id": 0, "temp": 20, "timezone": "Europe/Stockholm", "windDir": 180, "windSpeed": 5}) + expect.objectContaining({"address": "Stockholm, Sweden", "description": "clear", "feelsLike": 20, "humidity": 50, "id": 0, "temp": 20, "timezone": "Europe/Stockholm", "windDir": 180, "windSpeed": 5}), + 9 ); }); diff --git a/Gridly-Client/src/app/components/dialogs/editCardDialog/edit-card-dialog.facade.ts b/Gridly-Client/src/app/components/dialogs/editCardDialog/edit-card-dialog.facade.ts index 797d7780..4b46eec4 100644 --- a/Gridly-Client/src/app/components/dialogs/editCardDialog/edit-card-dialog.facade.ts +++ b/Gridly-Client/src/app/components/dialogs/editCardDialog/edit-card-dialog.facade.ts @@ -94,11 +94,8 @@ export class EditCardDialogFacade { } } if (status === 200 && weather !== undefined) { - if (weather.cardId !== cardId) { - weather.cardId = cardId; - await this.#weatherProviderService.save(weather); - return true; - } + await this.#weatherProviderService.save(weather, cardId); + return true; } this.errorStatus = status; return false; diff --git a/Gridly-Client/src/app/components/dialogs/setLocationForProviderDialog/set-location-for-provider-dialog.component.ts b/Gridly-Client/src/app/components/dialogs/setLocationForProviderDialog/set-location-for-provider-dialog.component.ts index 7546ec33..104effe6 100644 --- a/Gridly-Client/src/app/components/dialogs/setLocationForProviderDialog/set-location-for-provider-dialog.component.ts +++ b/Gridly-Client/src/app/components/dialogs/setLocationForProviderDialog/set-location-for-provider-dialog.component.ts @@ -50,10 +50,7 @@ export class SetLocationForProviderDialogComponent extends BaseDialogComponent{ this.countryInput = ''; this.cityInput = ''; - if(weather.cardId !== this.id){ - weather.cardId = this.id; - await this.#weatherProviderService.save(weather); - } + await this.#weatherProviderService.save(weather, this.id); this.close(); } diff --git a/Gridly-Client/src/app/models/cardWeatherData.Model.ts b/Gridly-Client/src/app/models/cardWeatherData.Model.ts new file mode 100644 index 00000000..bf15eb25 --- /dev/null +++ b/Gridly-Client/src/app/models/cardWeatherData.Model.ts @@ -0,0 +1,12 @@ +export class CardWeatherDataModel { + cardId!:number; + weatherId!:number; + address!:string; + timezone!:string; + description!:string; + temp!:number; + feelsLike!:number; + humidity!:number; + windSpeed!:number; + windDir!:number; +} diff --git a/Gridly-Client/src/app/models/weatherData.Model.ts b/Gridly-Client/src/app/models/weatherData.Model.ts index 1bffc252..7bb27ffa 100644 --- a/Gridly-Client/src/app/models/weatherData.Model.ts +++ b/Gridly-Client/src/app/models/weatherData.Model.ts @@ -1,6 +1,5 @@ export class WeatherDataModel { id!:number; - cardId!:number; address!:string; timezone!:string; description!:string; diff --git a/Gridly-Client/src/app/services/endpoint_services/weather.endpoint.service.ts b/Gridly-Client/src/app/services/endpoint_services/weather.endpoint.service.ts index b2e8cbe6..95d5be8c 100644 --- a/Gridly-Client/src/app/services/endpoint_services/weather.endpoint.service.ts +++ b/Gridly-Client/src/app/services/endpoint_services/weather.endpoint.service.ts @@ -3,6 +3,7 @@ import {HttpClient, HttpParams} from '@angular/common/http'; import {urlConstants} from "../../constants/url.constants"; import {Observable, take} from "rxjs"; import {WeatherDataModel} from "../../models/weatherData.Model"; +import {CardWeatherDataModel} from "../../models/cardWeatherData.Model"; @Injectable({ providedIn: 'root' @@ -15,14 +16,14 @@ export class WeatherEndpointService{ const params = new HttpParams().set('Address', address); return this.http.get(urlConstants.weather.get,{params}).pipe(take(1)); } - getStoredWeatherData(): Observable { - return this.http.get(urlConstants.weather.getStoredWeatherData).pipe(take(1)); + getStoredWeatherData(): Observable { + return this.http.get(urlConstants.weather.getStoredWeatherData).pipe(take(1)); } getvisualcrossingdata(address: string): Observable { const params = new HttpParams().set('Address', address); return this.http.get(urlConstants.weather.getVisualCrossingData,{params}).pipe(take(1)); } - save(weather: WeatherDataModel){ - return this.http.post(urlConstants.weather.save, {weather}).pipe(take(1)); + save(weather: WeatherDataModel, cardId: number){ + return this.http.post(urlConstants.weather.save, {weather, cardId}).pipe(take(1)); } } diff --git a/Gridly-Client/src/app/services/weather_services/weather-provider.service.ts b/Gridly-Client/src/app/services/weather_services/weather-provider.service.ts index adc83429..c2cd579c 100644 --- a/Gridly-Client/src/app/services/weather_services/weather-provider.service.ts +++ b/Gridly-Client/src/app/services/weather_services/weather-provider.service.ts @@ -4,27 +4,29 @@ import { toSignal } from "@angular/core/rxjs-interop"; import { HttpErrorResponse } from "@angular/common/http"; import {WeatherEndpointService} from "../endpoint_services/weather.endpoint.service"; import {WeatherDataModel} from "../../models/weatherData.Model"; +import {CardWeatherDataModel} from "../../models/cardWeatherData.Model"; @Injectable({providedIn: 'root'}) export class WeatherProviderService { - private readonly storedWeatherDataSubject = new BehaviorSubject([]); - readonly storedWeatherData$: Observable; - readonly storedWeatherData!: Signal; + private readonly storedWeatherDataSubject = new BehaviorSubject([]); + readonly storedWeatherData$: Observable; + readonly storedWeatherData!: Signal; #api = inject(WeatherEndpointService); constructor() { this.storedWeatherData$ = this.storedWeatherDataSubject.asObservable(); - this.storedWeatherData = toSignal(this.storedWeatherData$, { initialValue: [] as WeatherDataModel[] }); + this.storedWeatherData = toSignal(this.storedWeatherData$, { initialValue: [] as CardWeatherDataModel[] }); this.refresh(); } - private save$ = (weather: WeatherDataModel) => this.#api.save(weather); + private save$ = (weather: WeatherDataModel, cardId: number) => this.#api.save(weather, cardId); private getWeather$ = (address: string) => this.#api.get(address); private getVisualCrossingData$ = (address: string) => this.#api.getvisualcrossingdata(address); - save = async (weather: WeatherDataModel) => { - await firstValueFrom(this.save$(weather)); + save = async (weather: WeatherDataModel, cardId: number) => { + await firstValueFrom(this.save$(weather, cardId)); + this.refresh(); } diff --git a/Handlers/ColumnRowHandler.cs b/Handlers/ColumnRowHandler.cs index de52d402..9f13828d 100644 --- a/Handlers/ColumnRowHandler.cs +++ b/Handlers/ColumnRowHandler.cs @@ -14,6 +14,8 @@ public class ColumnRowHandler( ISettingsRepository settingsRepository, IIconRepository iconRepository, IIconConnectedRepository iconConnectedRepository, + IWeatherRepository weatherRepository, + IWeatherDataConnectionRepository weatherDataConnectionRepository, IFileService fileService): IRequestHandler, IRequestHandler @@ -149,6 +151,11 @@ async Task DeleteCard(CardModel card) if (card.IconData is not null) await iconConnectedRepository.Delete(card.Id); + var weatherConnection = (await weatherDataConnectionRepository.GetManyById(card.Id, null)).FirstOrDefault(); + await weatherDataConnectionRepository.Delete(card.Id); + if (weatherConnection?.WeatherId is not null) + await weatherRepository.DeleteIfOrphaned(weatherConnection.WeatherId.Value); + await cardRepository.Delete(card.Id); if (card.IconData is null) diff --git a/Handlers/WeatherHandler.cs b/Handlers/WeatherHandler.cs index 1dcdbc5b..c415de9a 100644 --- a/Handlers/WeatherHandler.cs +++ b/Handlers/WeatherHandler.cs @@ -13,6 +13,7 @@ namespace Gridly.Handlers; public class WeatherHandler( IWeatherEndPoint weatherEndpoint, IWeatherRepository weatherRepository, + IWeatherDataConnectionRepository weatherDataConnectionRepository, ILocalProvidersRepository localProvidersRepository, IProviderKeysProtectionService providerKeysProtectionService) : IRequestHandler, @@ -72,14 +73,16 @@ public async Task Handle(GetStoredWeatheDataQuery request, Cancellation public async Task Handle(SaveWeatherCommand command, CancellationToken cancellationToken) { - var success = false; - var weather = await weatherRepository.GetById(command.Weather.CardId); - - if (weather is null) - success = await weatherRepository.Insert(command.Weather); - else - success = await weatherRepository.Update(command.Weather); - - return success ? Results.Ok() : Results.BadRequest(); + var previousConnection = (await weatherDataConnectionRepository.GetManyById(command.CardId, null)).FirstOrDefault(); + + var weather = await weatherRepository.Upsert(command.Weather); + if (weather is null) return Results.BadRequest(); + + await weatherDataConnectionRepository.Upsert(WeatherDataConnectionFactory.Create(command.CardId, weather.Id)); + + if (previousConnection?.WeatherId is not null && previousConnection.WeatherId != weather.Id) + await weatherRepository.DeleteIfOrphaned(previousConnection.WeatherId.Value); + + return Results.Ok(); } } diff --git a/Models/WeatherDataModel.cs b/Models/WeatherDataModel.cs index 0fc51eac..8186b5dc 100644 --- a/Models/WeatherDataModel.cs +++ b/Models/WeatherDataModel.cs @@ -4,7 +4,6 @@ namespace Gridly.Dtos; public class WeatherDataModel { public int Id { get; set; } - public int CardId { get; set; } public string Address { get; set; } public string Timezone { get; set; } public string Description { get; set; } diff --git a/Program.cs b/Program.cs index 5625c1a8..2fa614e5 100644 --- a/Program.cs +++ b/Program.cs @@ -33,6 +33,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); diff --git a/Repositories/IWeatherDataConnectionRepository.cs b/Repositories/IWeatherDataConnectionRepository.cs new file mode 100644 index 00000000..1ad43261 --- /dev/null +++ b/Repositories/IWeatherDataConnectionRepository.cs @@ -0,0 +1,10 @@ +using Gridly.Dtos; + +namespace Gridly.Repositories; + +public interface IWeatherDataConnectionRepository +{ + public Task> GetManyById(int? cardId, int? weatherId); + public Task Upsert(WeatherDataConnectionDtoModel model); + public Task Delete(int cardId); +} diff --git a/Repositories/IWeatherRepository.cs b/Repositories/IWeatherRepository.cs index bbaedcb8..452c7c77 100644 --- a/Repositories/IWeatherRepository.cs +++ b/Repositories/IWeatherRepository.cs @@ -5,8 +5,7 @@ namespace Gridly.Repositories; public interface IWeatherRepository { public Task Get(string address); - public Task GetById(int cardId); - public Task?> GetStoredWeatherData(); - public Task Update(WeatherDataModel weather); - public Task Insert(WeatherDataModel weather); + public Task?> GetStoredWeatherData(); + public Task Upsert(WeatherDataModel weather); + public Task DeleteIfOrphaned(int weatherId); } diff --git a/Repositories/WeatherDataConnectionRepository.cs b/Repositories/WeatherDataConnectionRepository.cs new file mode 100644 index 00000000..c66a23d6 --- /dev/null +++ b/Repositories/WeatherDataConnectionRepository.cs @@ -0,0 +1,38 @@ +using System.Data; +using Dapper; +using Gridly.Constants; +using Gridly.Data; +using Gridly.Dtos; + +namespace Gridly.Repositories; + +public class WeatherDataConnectionRepository(IDbConnection connection) : IWeatherDataConnectionRepository +{ + private DbCommandRunner _dbCommandRunner = new(connection); + + public async Task Upsert(WeatherDataConnectionDtoModel model) + { + return await _dbCommandRunner.Execute(QueryStrings.UpsertWeatherDataConnectionQuery, model); + } + + public async Task> GetManyById(int? cardId, int? weatherId) + { + var builder = new SqlBuilder(); + var template = builder.AddTemplate(QueryStrings.SelectWeatherDataConnectionQuery); + + if (cardId != null) + builder.Where(QueryStrings.WhereWeatherConnectedCardIdForeignKeyEqualIdWithAlias, new { CardId = cardId }); + if (weatherId != null) + builder.Where(QueryStrings.WhereWeatherConnectedWeatherIdForeignKeyEqualIdWithAlias, new { WeatherId = weatherId }); + + return await _dbCommandRunner.SelectMany(template.RawSql, template.Parameters); + } + + public async Task Delete(int cardId) + { + var builder = new SqlBuilder(); + var template = builder.AddTemplate(QueryStrings.DeleteFromWeatherDataConnectionQuery); + builder.Where(QueryStrings.WhereCardIdForeignKeyEqualId, new { CardId = cardId }); + return await _dbCommandRunner.Execute(template.RawSql, template.Parameters) != null; + } +} diff --git a/Repositories/WeatherRepository.cs b/Repositories/WeatherRepository.cs index e823ab96..e6e8eff5 100644 --- a/Repositories/WeatherRepository.cs +++ b/Repositories/WeatherRepository.cs @@ -22,28 +22,16 @@ public class WeatherRepository(IDbConnection connection) : IWeatherRepository return dto; } - public async Task GetById(int cardId) - { - var builder = new SqlBuilder(); - builder.Where(QueryStrings.WhereCardIdForeignKeyEqualId); - var template = builder.AddTemplate(QueryStrings.SelectWeatherDataQuery); - - var weather = await _dbCommandRunner.Select( - template.RawSql, new { CardId = cardId }); - - return weather; - } - - public async Task?> GetStoredWeatherData() + public async Task?> GetStoredWeatherData() { var storedWeatherData = - await _dbCommandRunner.SelectMany(QueryStrings.SelectWeatherDataQuery, string.Empty); - return storedWeatherData; + await _dbCommandRunner.SelectMany(QueryStrings.SelectCardWeatherDataQuery, string.Empty); + return storedWeatherData; } - public async Task Update(WeatherDataModel weather) => - await _dbCommandRunner.Execute(QueryStrings.UpdateWeatherDataQuery, weather as object); - - public async Task Insert(WeatherDataModel weather) => - await _dbCommandRunner.Execute(QueryStrings.InsertWeatherDataQuery, weather as object); + public async Task Upsert(WeatherDataModel weather) => + await _dbCommandRunner.Execute(QueryStrings.UpsertWeatherDataQuery, weather); + + public async Task DeleteIfOrphaned(int weatherId) => + await _dbCommandRunner.Execute(QueryStrings.DeleteOrphanedWeatherDataQuery, new { Id = weatherId } as object); } diff --git a/Tests/Handlers/ColumnRowHandlerTests.cs b/Tests/Handlers/ColumnRowHandlerTests.cs index 7128a029..65987822 100644 --- a/Tests/Handlers/ColumnRowHandlerTests.cs +++ b/Tests/Handlers/ColumnRowHandlerTests.cs @@ -4,6 +4,7 @@ using Gridly.Repositories; using Gridly.Services; using Gridly.Dtos; +using Gridly.Tests.Infrastructure; namespace Gridly.Tests.Handlers; @@ -35,6 +36,8 @@ public async Task Handle_WhenRowIsRemoved_AttachesStoredCardsToMissingRowsBefore new FakeSettingsRepository(), new FakeIconRepository(), new FakeIconConnectedRepository(), + new FakeWeatherRepository(), + new FakeWeatherDataConnectionRepository(), new FakeFileService()); var command = new BatchSaveColumnRowCommands { @@ -60,6 +63,75 @@ public async Task Handle_WhenRowIsRemoved_AttachesStoredCardsToMissingRowsBefore Assert.All(cardRepository.BatchEditedCards, card => Assert.Equal(2, card.RowColumnId)); } + [Fact] + public async Task Handle_WhenDeletingACardWithAWeatherConnection_DeletesConnectionAndAttemptsOrphanCleanup() + { + var operations = new List(); + var columnRowRepository = new FakeColumnRowRepository(operations) + { + Rows = [new ColumnRowModel { Id = 1, RowPosition = 1, Cards = [] }], + }; + var cardRepository = new FakeCardRepository(operations) + { + Cards = [new CardModel { Id = 10, RowColumnId = 1, IndexPosition = 1, Name = "Weather", Url = "" }], + }; + var weatherRepository = new FakeWeatherRepository(); + var weatherDataConnectionRepository = new FakeWeatherDataConnectionRepository(); + weatherDataConnectionRepository.Seed(10, 99); + var handler = new ColumnRowHandler( + columnRowRepository, + cardRepository, + new FakeSettingsRepository(), + new FakeIconRepository(), + new FakeIconConnectedRepository(), + weatherRepository, + weatherDataConnectionRepository, + new FakeFileService()); + var command = new BatchSaveColumnRowCommands + { + new() { Id = 1, RowPosition = 1, Cards = [] }, + }; + + await handler.Handle(command, CancellationToken.None); + + Assert.Equal([10], weatherDataConnectionRepository.DeletedCardIds); + Assert.Equal([99], weatherRepository.DeleteIfOrphanedCalls); + } + + [Fact] + public async Task Handle_WhenDeletingACardWithoutAWeatherConnection_NeverCallsDeleteIfOrphaned() + { + var operations = new List(); + var columnRowRepository = new FakeColumnRowRepository(operations) + { + Rows = [new ColumnRowModel { Id = 1, RowPosition = 1, Cards = [] }], + }; + var cardRepository = new FakeCardRepository(operations) + { + Cards = [new CardModel { Id = 10, RowColumnId = 1, IndexPosition = 1, Name = "Plain", Url = "https://plain.example" }], + }; + var weatherRepository = new FakeWeatherRepository(); + var weatherDataConnectionRepository = new FakeWeatherDataConnectionRepository(); + var handler = new ColumnRowHandler( + columnRowRepository, + cardRepository, + new FakeSettingsRepository(), + new FakeIconRepository(), + new FakeIconConnectedRepository(), + weatherRepository, + weatherDataConnectionRepository, + new FakeFileService()); + var command = new BatchSaveColumnRowCommands + { + new() { Id = 1, RowPosition = 1, Cards = [] }, + }; + + await handler.Handle(command, CancellationToken.None); + + Assert.Equal([10], weatherDataConnectionRepository.DeletedCardIds); + Assert.Empty(weatherRepository.DeleteIfOrphanedCalls); + } + private sealed class FakeColumnRowRepository(List operations) : IColumnRowRepository { public List Rows { get; set; } = []; diff --git a/Tests/Handlers/WeatherHandlerTests.cs b/Tests/Handlers/WeatherHandlerTests.cs index e6d00529..759d1a30 100644 --- a/Tests/Handlers/WeatherHandlerTests.cs +++ b/Tests/Handlers/WeatherHandlerTests.cs @@ -1,17 +1,19 @@ +using Gridly.Commands; using Gridly.Constants; using Gridly.Dtos; using Gridly.Enums; +using Gridly.Handlers; +using Gridly.Querys; using Gridly.Tests.Infrastructure; namespace Gridly.Tests.Handlers; public class WeatherHandlerTests { - private static WeatherDataModel MakeWeather(string location = "Stockholm") => + private static WeatherDataModel MakeWeather(string address = "Stockholm") => new() { - CardId = 1, - Address = location, + Address = address, Timezone = "Europe/Stockholm", Description = "clear", Temp = 20, @@ -33,14 +35,16 @@ private static FakeLocalProvidersRepository MakeProvidersRepository(string statu LastValidatedAt = DateTime.UtcNow } }; -/* + private static WeatherHandler MakeHandler( FakeWeatherEndPoint? endPoint = null, FakeWeatherRepository? weatherRepository = null, + FakeWeatherDataConnectionRepository? connectionRepository = null, FakeLocalProvidersRepository? providersRepository = null) => new( endPoint ?? new FakeWeatherEndPoint(), weatherRepository ?? new FakeWeatherRepository(), + connectionRepository ?? new FakeWeatherDataConnectionRepository(), providersRepository ?? new FakeLocalProvidersRepository(), new FakeProviderKeysProtectionService()); @@ -49,25 +53,26 @@ public async Task HandleGetWeather_WhenCacheIsFresh_ReturnsCachedWeather() { var repository = new FakeWeatherRepository(); var weather = MakeWeather(); - repository.Seed("Stockholm", weather, DateTime.UtcNow.AddMinutes(-5)); - repository.CardIdSeed(1, weather, DateTime.UtcNow.AddMinutes(-5)); + weather.FetchedAt = DateTime.UtcNow.AddMinutes(-5); + repository.Seed(weather); var handler = MakeHandler(weatherRepository: repository); - var result = await handler.Handle(new GetWeatherQuery { SearchTerm = "Stockholm" }, CancellationToken.None); - var payload = ResultAssertions.AssertOk(result); + var result = await handler.Handle(new GetWeatherQuery { Address = "Stockholm" }, CancellationToken.None); + var payload = ResultAssertions.AssertOk(result); - Assert.Equal("Stockholm", payload.Location); + Assert.Equal("Stockholm", payload.Address); } [Fact] public async Task HandleGetWeather_WhenCacheIsStale_ReturnsNotFound() { var repository = new FakeWeatherRepository(); - repository.Seed("Stockholm", MakeWeather(), DateTime.UtcNow.AddHours(-9)); - repository.CardIdSeed(1, MakeWeather(), DateTime.UtcNow.AddHours(-9)); + var weather = MakeWeather(); + weather.FetchedAt = DateTime.UtcNow.AddHours(-9); + repository.Seed(weather); var handler = MakeHandler(weatherRepository: repository); - var result = await handler.Handle(new GetWeatherQuery { SearchTerm = "Stockholm" }, CancellationToken.None); + var result = await handler.Handle(new GetWeatherQuery { Address = "Stockholm" }, CancellationToken.None); ResultAssertions.AssertStatusCode(result, StatusCodes.Status404NotFound); } @@ -77,7 +82,7 @@ public async Task HandleGetWeather_WhenNothingStored_ReturnsNotFound() { var handler = MakeHandler(); - var result = await handler.Handle(new GetWeatherQuery { SearchTerm = "Stockholm" }, CancellationToken.None); + var result = await handler.Handle(new GetWeatherQuery { Address = "Stockholm" }, CancellationToken.None); ResultAssertions.AssertStatusCode(result, StatusCodes.Status404NotFound); } @@ -86,15 +91,15 @@ public async Task HandleGetWeather_WhenNothingStored_ReturnsNotFound() public async Task HandleGetVisualCrossingData_WhenSuccessful_ReturnsWeatherAndMarksKeyValid() { var weather = MakeWeather(); - var endPoint = new FakeWeatherEndPoint { Result = (StatusCodes.Status200OK, weather) }; - var repository = new FakeWeatherRepository(); + var days = new[] { new DaysDto(weather.Temp, weather.FeelsLike, weather.Humidity, weather.WindSpeed, weather.WindDir) }; + var endPoint = new FakeWeatherEndPoint { Result = (StatusCodes.Status200OK, new WeatherDataDto("Stockholm", weather.Timezone, weather.Description, days, weather.FetchedAt)) }; var providersRepository = MakeProvidersRepository(); - var handler = MakeHandler(endPoint, repository, providersRepository); + var handler = MakeHandler(endPoint, providersRepository: providersRepository); - var result = await handler.Handle(new GetVisualCrossingDataQuery { SearchTerm = "Stockholm" }, CancellationToken.None); - var payload = ResultAssertions.AssertOk(result); - - Assert.Equal("Stockholm", payload.Location); + var result = await handler.Handle(new GetVisualCrossingDataQuery { Address = "Stockholm" }, CancellationToken.None); + var payload = ResultAssertions.AssertOk(result); + + Assert.Equal("Stockholm", payload.Address); Assert.Equal(1, endPoint.GetCallCount); Assert.Equal(1, providersRepository.UpdateStatusCallCount); Assert.Equal(nameof(ProvidersKeyStatusEnum.Valid), providersRepository.LastUpdatedStatus); @@ -103,11 +108,12 @@ public async Task HandleGetVisualCrossingData_WhenSuccessful_ReturnsWeatherAndMa [Fact] public async Task HandleGetVisualCrossingData_WhenKeyIsInvalid_MarksKeyInvalidAndReturns401() { - var endPoint = new FakeWeatherEndPoint { Result = (StatusCodes.Status401Unauthorized, null) }; + var placeholderDto = new WeatherDataDto("Stockholm", "Europe/Stockholm", "", [], DateTime.UtcNow); + var endPoint = new FakeWeatherEndPoint { Result = (StatusCodes.Status401Unauthorized, placeholderDto) }; var apiKeyRepository = MakeProvidersRepository(); var handler = MakeHandler(endPoint, providersRepository: apiKeyRepository); - var result = await handler.Handle(new GetVisualCrossingDataQuery { SearchTerm = "Stockholm" }, CancellationToken.None); + var result = await handler.Handle(new GetVisualCrossingDataQuery { Address = "Stockholm" }, CancellationToken.None); ResultAssertions.AssertStatusCode(result, StatusCodes.Status401Unauthorized); Assert.Equal(1, apiKeyRepository.UpdateStatusCallCount); @@ -115,42 +121,52 @@ public async Task HandleGetVisualCrossingData_WhenKeyIsInvalid_MarksKeyInvalidAn } [Fact] - public async Task HandleGetVisualCrossingData_WhenNoKeyConfigured_Returns401WithoutTouchingKeyStatus() + public async Task HandleSaveWeather_WhenAddressIsNew_InsertsWeatherAndCreatesConnection() { - var endPoint = new FakeWeatherEndPoint { Result = (StatusCodes.Status401Unauthorized, null) }; - var apiKeyRepository = new FakeLocalProvidersRepository(); - var handler = MakeHandler(endPoint, providersRepository: apiKeyRepository); - - var result = await handler.Handle(new GetVisualCrossingDataQuery { SearchTerm = "Stockholm" }, CancellationToken.None); - - ResultAssertions.AssertStatusCode(result, StatusCodes.Status401Unauthorized); - Assert.Equal(0, apiKeyRepository.UpdateStatusCallCount); + var weatherRepository = new FakeWeatherRepository(); + var connectionRepository = new FakeWeatherDataConnectionRepository(); + var handler = MakeHandler(weatherRepository: weatherRepository, connectionRepository: connectionRepository); + + var result = await handler.Handle(new SaveWeatherCommand { Weather = MakeWeather(), CardId = 1 }, CancellationToken.None); + + ResultAssertions.AssertStatusCode(result, StatusCodes.Status200OK); + Assert.Equal(1, weatherRepository.UpsertCallCount); + Assert.Equal(1, connectionRepository.UpsertCallCount); + var connections = await connectionRepository.GetManyById(1, null); + Assert.Single(connections); + Assert.Empty(weatherRepository.DeleteIfOrphanedCalls); } [Fact] - public async Task HandleGetVisualCrossingData_WhenProviderDownAndStaleDataExists_ReturnsBadRequest() + public async Task HandleSaveWeather_WhenTwoCardsShareAnAddress_ReuseTheSameWeatherRow() { - var endPoint = new FakeWeatherEndPoint { Result = (StatusCodes.Status500InternalServerError, null) }; - var repository = new FakeWeatherRepository(); - var staleWeather = MakeWeather(); - repository.Seed("Stockholm", staleWeather, DateTime.UtcNow.AddHours(-3)); - repository.CardIdSeed(1, staleWeather, DateTime.UtcNow.AddHours(-3)); - var handler = MakeHandler(endPoint, repository, MakeProvidersRepository()); + var weatherRepository = new FakeWeatherRepository(); + var connectionRepository = new FakeWeatherDataConnectionRepository(); + var handler = MakeHandler(weatherRepository: weatherRepository, connectionRepository: connectionRepository); - var result = await handler.Handle(new GetVisualCrossingDataQuery { SearchTerm = "Stockholm" }, CancellationToken.None); + await handler.Handle(new SaveWeatherCommand { Weather = MakeWeather("Stockholm"), CardId = 1 }, CancellationToken.None); + await handler.Handle(new SaveWeatherCommand { Weather = MakeWeather("Stockholm"), CardId = 2 }, CancellationToken.None); - ResultAssertions.AssertStatusCode(result, StatusCodes.Status400BadRequest); - Assert.Equal(0, repository.UpsertCallCount); + var connectionsForCard1 = (await connectionRepository.GetManyById(1, null)).Single(); + var connectionsForCard2 = (await connectionRepository.GetManyById(2, null)).Single(); + Assert.Equal(connectionsForCard1.WeatherId, connectionsForCard2.WeatherId); + Assert.Empty(weatherRepository.DeleteIfOrphanedCalls); } [Fact] - public async Task HandleGetVisualCrossingData_WhenProviderDownAndNoStaleData_ReturnsBadRequest() + public async Task HandleSaveWeather_WhenCardMovesToANewAddress_DeletesTheOldWeatherRowIfOrphaned() { - var endPoint = new FakeWeatherEndPoint { Result = (StatusCodes.Status500InternalServerError, null) }; - var handler = MakeHandler(endPoint, providersRepository: MakeProvidersRepository()); + var weatherRepository = new FakeWeatherRepository(); + var connectionRepository = new FakeWeatherDataConnectionRepository(); + var handler = MakeHandler(weatherRepository: weatherRepository, connectionRepository: connectionRepository); - var result = await handler.Handle(new GetVisualCrossingDataQuery { SearchTerm = "Stockholm" }, CancellationToken.None); + await handler.Handle(new SaveWeatherCommand { Weather = MakeWeather("Stockholm"), CardId = 1 }, CancellationToken.None); + var originalWeatherId = (await connectionRepository.GetManyById(1, null)).Single().WeatherId; - ResultAssertions.AssertStatusCode(result, StatusCodes.Status400BadRequest); - }*/ + await handler.Handle(new SaveWeatherCommand { Weather = MakeWeather("Gothenburg"), CardId = 1 }, CancellationToken.None); + + var updatedConnection = (await connectionRepository.GetManyById(1, null)).Single(); + Assert.NotEqual(originalWeatherId, updatedConnection.WeatherId); + Assert.Contains(originalWeatherId!.Value, weatherRepository.DeleteIfOrphanedCalls); + } } diff --git a/Tests/Infrastructure/TestDoubles.cs b/Tests/Infrastructure/TestDoubles.cs index 75dd1e2a..af4b8193 100644 --- a/Tests/Infrastructure/TestDoubles.cs +++ b/Tests/Infrastructure/TestDoubles.cs @@ -117,44 +117,80 @@ internal sealed class FakeWeatherEndPoint : IWeatherEndPoint return Task.FromResult(Result); } } -/* internal sealed class FakeWeatherRepository : IWeatherRepository { - private readonly Dictionary _stored = new(); - private readonly Dictionary _storedCardId = new(); + private readonly Dictionary _byAddress = new(); + private int _nextId = 1; public int UpsertCallCount { get; private set; } - public string? LastUpsertedLocation { get; private set; } - public int DeleteCallCount { get; private set; } - public int? LastDeletedCardId { get; private set; } + public List DeleteIfOrphanedCalls { get; } = new(); + public bool DeleteIfOrphanedResult { get; set; } = true; - public void Seed(string location, WeatherModel weather) => - _stored[location] = weather; - - public void CardIdSeed(int cardId, WeatherModel weather) => - _storedCardId[cardId] = weather; + public void Seed(WeatherDataModel weather) + { + if (weather.Id == 0) weather.Id = _nextId; + _nextId = Math.Max(_nextId, weather.Id + 1); + _byAddress[weather.Address] = weather; + } - public Task Get(string location) => - Task.FromResult(_stored.TryGetValue(location, out var value) ? value : null); + public Task Get(string address) => + Task.FromResult(_byAddress.TryGetValue(address, out var value) ? value : null); - public Task GetById(int cardId) => - Task.FromResult(_storedCardId.TryGetValue(cardId, out var value) ? value : null); + public Task?> GetStoredWeatherData() => + Task.FromResult?>(Array.Empty()); - public Task Delete(int CardId) + public Task Upsert(WeatherDataModel weather) { - DeleteCallCount++; - LastDeletedCardId = CardId; - return Task.FromResult(true); + UpsertCallCount++; + weather.Id = _byAddress.TryGetValue(weather.Address, out var existing) ? existing.Id : _nextId++; + _byAddress[weather.Address] = weather; + return Task.FromResult(weather); } - public Task Upsert(WeatherDataModel weather) + public Task DeleteIfOrphaned(int weatherId) + { + DeleteIfOrphanedCalls.Add(weatherId); + var entry = _byAddress.Values.FirstOrDefault(w => w.Id == weatherId); + if (entry is not null) _byAddress.Remove(entry.Address); + return Task.FromResult(DeleteIfOrphanedResult); + } +} + +internal sealed class FakeWeatherDataConnectionRepository : IWeatherDataConnectionRepository +{ + private readonly List _connections = new(); + + public int UpsertCallCount { get; private set; } + public int DeleteCallCount { get; private set; } + public List DeletedCardIds { get; } = new(); + + public void Seed(int cardId, int weatherId) => + _connections.Add(new WeatherDataConnectionDtoModel { CardId = cardId, WeatherId = weatherId }); + + public Task> GetManyById(int? cardId, int? weatherId) + { + var results = _connections.Where(c => + (cardId is null || c.CardId == cardId) && + (weatherId is null || c.WeatherId == weatherId)); + return Task.FromResult>(results.ToList()); + } + + public Task Upsert(WeatherDataConnectionDtoModel model) { UpsertCallCount++; - LastUpsertedLocation = weather.Location; - _stored[weather.Location] = (WeatherDataFactory.Create(weather), DateTime.UtcNow); - return Task.FromResult(true); + _connections.RemoveAll(c => c.CardId == model.CardId); + _connections.Add(model); + return Task.FromResult(model); + } + + public Task Delete(int cardId) + { + DeleteCallCount++; + DeletedCardIds.Add(cardId); + var removed = _connections.RemoveAll(c => c.CardId == cardId); + return Task.FromResult(removed > 0); } -}*/ +} internal sealed class FakeLocalProvidersRepository : ILocalProvidersRepository { diff --git a/Tests/Repositories/WeatherDataConnectionRepositoryTests.cs b/Tests/Repositories/WeatherDataConnectionRepositoryTests.cs new file mode 100644 index 00000000..1beefb12 --- /dev/null +++ b/Tests/Repositories/WeatherDataConnectionRepositoryTests.cs @@ -0,0 +1,108 @@ +using System.Data; +using Dapper; +using Gridly.Data; +using Gridly.Dtos; +using Gridly.Repositories; +using Microsoft.Data.Sqlite; + +namespace Gridly.Tests.Repositories; + +public sealed class WeatherDataConnectionRepositoryTests : IDisposable +{ + private readonly string _dbPath = Path.Combine(Path.GetTempPath(), $"gridly-weather-connections-{Guid.NewGuid():N}.db"); + private readonly IDbConnection _connection; + + public WeatherDataConnectionRepositoryTests() + { + _connection = new SqliteConnection($"Data Source={_dbPath}"); + } + + private async Task<(int card1, int card2, int weather1, int weather2)> SeedFixtureAsync() + { + await new DbInitializer(_connection).EnsureTablesCreatedAsync(); + + var rowColumnId = await _connection.QuerySingleAsync( + "INSERT INTO RowColumn (RowPosition, RowWidth) VALUES (1,1); SELECT last_insert_rowid();"); + var cardAId = await _connection.QuerySingleAsync( + "INSERT INTO Card (IndexPosition, RowColumnId, Name, Url, Type, IconUrl) VALUES (1, @row, 'A', '', 'Weather', ''); SELECT last_insert_rowid();", + new { row = rowColumnId }); + var cardBId = await _connection.QuerySingleAsync( + "INSERT INTO Card (IndexPosition, RowColumnId, Name, Url, Type, IconUrl) VALUES (2, @row, 'B', '', 'Weather', ''); SELECT last_insert_rowid();", + new { row = rowColumnId }); + + var weatherRepository = new WeatherRepository(_connection); + var weather1 = await weatherRepository.Upsert(new() + { + Address = "Stockholm", Timezone = "Europe/Stockholm", Description = "clear", + Temp = 20, FeelsLike = 20, Humidity = 50, WindSpeed = 5, WindDir = 180, FetchedAt = DateTime.UtcNow, + }); + var weather2 = await weatherRepository.Upsert(new() + { + Address = "Gothenburg", Timezone = "Europe/Stockholm", Description = "rain", + Temp = 15, FeelsLike = 14, Humidity = 80, WindSpeed = 8, WindDir = 200, FetchedAt = DateTime.UtcNow, + }); + + return ((int)cardAId, (int)cardBId, weather1.Id, weather2.Id); + } + + [Fact] + public async Task Upsert_WhenCardHasNoConnectionYet_CreatesOne() + { + var (card1, _, weather1, _) = await SeedFixtureAsync(); + var repository = new WeatherDataConnectionRepository(_connection); + + await repository.Upsert(new WeatherDataConnectionDtoModel { CardId = card1, WeatherId = weather1 }); + + var connections = await repository.GetManyById(card1, null); + Assert.Single(connections); + } + + [Fact] + public async Task CardIdUniqueConstraint_RepointsExistingConnectionInsteadOfDuplicating() + { + var (card1, _, weather1, weather2) = await SeedFixtureAsync(); + var repository = new WeatherDataConnectionRepository(_connection); + await repository.Upsert(new WeatherDataConnectionDtoModel { CardId = card1, WeatherId = weather1 }); + + await repository.Upsert(new WeatherDataConnectionDtoModel { CardId = card1, WeatherId = weather2 }); + + var connections = (await repository.GetManyById(card1, null)).ToList(); + var connection = Assert.Single(connections); + Assert.Equal(weather2, connection.WeatherId); + } + + [Fact] + public async Task GetManyById_WhenFilteringByWeatherId_ReturnsAllCardsSharingThatRow() + { + var (card1, card2, weather1, _) = await SeedFixtureAsync(); + var repository = new WeatherDataConnectionRepository(_connection); + await repository.Upsert(new WeatherDataConnectionDtoModel { CardId = card1, WeatherId = weather1 }); + await repository.Upsert(new WeatherDataConnectionDtoModel { CardId = card2, WeatherId = weather1 }); + + var connections = await repository.GetManyById(null, weather1); + + Assert.Equal(2, connections.Count()); + } + + [Fact] + public async Task Delete_RemovesOnlyTheGivenCardsConnection() + { + var (card1, card2, weather1, _) = await SeedFixtureAsync(); + var repository = new WeatherDataConnectionRepository(_connection); + await repository.Upsert(new WeatherDataConnectionDtoModel { CardId = card1, WeatherId = weather1 }); + await repository.Upsert(new WeatherDataConnectionDtoModel { CardId = card2, WeatherId = weather1 }); + + await repository.Delete(card1); + + Assert.Empty(await repository.GetManyById(card1, null)); + Assert.Single(await repository.GetManyById(card2, null)); + } + + public void Dispose() + { + _connection.Dispose(); + SqliteConnection.ClearAllPools(); + if (File.Exists(_dbPath)) File.Delete(_dbPath); + if (File.Exists(_dbPath + ".bak")) File.Delete(_dbPath + ".bak"); + } +} diff --git a/Tests/Repositories/WeatherRepositoryTests.cs b/Tests/Repositories/WeatherRepositoryTests.cs new file mode 100644 index 00000000..7ce68b5a --- /dev/null +++ b/Tests/Repositories/WeatherRepositoryTests.cs @@ -0,0 +1,203 @@ +using System.Data; +using System.Linq; +using Dapper; +using Gridly.Data; +using Gridly.Dtos; +using Gridly.Repositories; +using Microsoft.Data.Sqlite; + +namespace Gridly.Tests.Repositories; + +public sealed class WeatherRepositoryTests : IDisposable +{ + private readonly string _dbPath = Path.Combine(Path.GetTempPath(), $"gridly-weather-{Guid.NewGuid():N}.db"); + private readonly IDbConnection _connection; + + public WeatherRepositoryTests() + { + _connection = new SqliteConnection($"Data Source={_dbPath}"); + } + + private static WeatherDataModel MakeWeather(string address, string description) => + new() + { + Address = address, + Timezone = "Europe/Stockholm", + Description = description, + Temp = 20, + FeelsLike = 20, + Humidity = 50, + WindSpeed = 5, + WindDir = 180, + FetchedAt = DateTime.UtcNow, + }; + + [Fact] + public async Task Upsert_WhenAddressAlreadyExists_UpdatesInPlaceInsteadOfDuplicating() + { + await new DbInitializer(_connection).EnsureTablesCreatedAsync(); + var repository = new WeatherRepository(_connection); + + var first = await repository.Upsert(MakeWeather("Stockholm", "clear")); + var second = await repository.Upsert(MakeWeather("Stockholm", "cloudy")); + + Assert.Equal(first.Id, second.Id); + var stored = await repository.Get("Stockholm"); + Assert.Equal("cloudy", stored.Description); + var rowCount = await _connection.QuerySingleAsync("SELECT COUNT(*) FROM WeatherData;"); + Assert.Equal(1, rowCount); + } + + [Fact] + public async Task AddressUniqueConstraint_RejectsDirectDuplicateInsert() + { + await new DbInitializer(_connection).EnsureTablesCreatedAsync(); + const string insert = @" + INSERT INTO WeatherData (Address, Timezone, Description, Temp, FeelsLike, Humidity, WindSpeed, WindDir, FetchedAt) + VALUES ('Stockholm','Europe/Stockholm','clear',1,1,1,1,1,'2026-01-01');"; + + await _connection.ExecuteAsync(insert); + + await Assert.ThrowsAsync(() => _connection.ExecuteAsync(insert)); + } + + private async Task<(int card1, int card2)> SeedTwoCardsAsync() + { + var rowColumnId = await _connection.QuerySingleAsync( + "INSERT INTO RowColumn (RowPosition, RowWidth) VALUES (1,1); SELECT last_insert_rowid();"); + var card1 = await _connection.QuerySingleAsync( + "INSERT INTO Card (IndexPosition, RowColumnId, Name, Url, Type, IconUrl) VALUES (1, @row, 'A', '', 'Weather', ''); SELECT last_insert_rowid();", + new { row = rowColumnId }); + var card2 = await _connection.QuerySingleAsync( + "INSERT INTO Card (IndexPosition, RowColumnId, Name, Url, Type, IconUrl) VALUES (2, @row, 'B', '', 'Weather', ''); SELECT last_insert_rowid();", + new { row = rowColumnId }); + return ((int)card1, (int)card2); + } + + [Fact] + public async Task DeleteIfOrphaned_WhenAnotherConnectionStillReferencesIt_KeepsTheRow() + { + await new DbInitializer(_connection).EnsureTablesCreatedAsync(); + var (card1, card2) = await SeedTwoCardsAsync(); + var weatherRepository = new WeatherRepository(_connection); + var connectionRepository = new WeatherDataConnectionRepository(_connection); + var weather = await weatherRepository.Upsert(MakeWeather("Stockholm", "clear")); + await connectionRepository.Upsert(new WeatherDataConnectionDtoModel { CardId = card1, WeatherId = weather.Id }); + await connectionRepository.Upsert(new WeatherDataConnectionDtoModel { CardId = card2, WeatherId = weather.Id }); + + await connectionRepository.Delete(card1); + await weatherRepository.DeleteIfOrphaned(weather.Id); + + Assert.NotNull(await weatherRepository.Get("Stockholm")); + } + + [Fact] + public async Task DeleteIfOrphaned_WhenNoConnectionsRemain_DeletesTheRow() + { + await new DbInitializer(_connection).EnsureTablesCreatedAsync(); + var (card1, _) = await SeedTwoCardsAsync(); + var weatherRepository = new WeatherRepository(_connection); + var connectionRepository = new WeatherDataConnectionRepository(_connection); + var weather = await weatherRepository.Upsert(MakeWeather("Stockholm", "clear")); + await connectionRepository.Upsert(new WeatherDataConnectionDtoModel { CardId = card1, WeatherId = weather.Id }); + + await connectionRepository.Delete(card1); + await weatherRepository.DeleteIfOrphaned(weather.Id); + + Assert.Null(await weatherRepository.Get("Stockholm")); + } + + [Fact] + public async Task EnsureTablesCreatedAsync_WhenLegacySchemaExists_MigratesDataIntoJunctionTableAndDropsCardId() + { + await _connection.ExecuteAsync(@" + CREATE TABLE RowColumn(Id INTEGER PRIMARY KEY AUTOINCREMENT, RowPosition INTEGER NOT NULL, RowWidth INTEGER NOT NULL); + CREATE TABLE Card(Id INTEGER PRIMARY KEY AUTOINCREMENT, IndexPosition INTEGER NOT NULL, RowColumnId INTEGER NOT NULL, Name TEXT, URL TEXT, Type TEXT, IconUrl TEXT); + CREATE TABLE WeatherData( + Id INTEGER PRIMARY KEY AUTOINCREMENT, + CardId INTEGER NOT NULL, + Address TEXT NOT NULL, + Timezone TEXT NOT NULL, + Description TEXT NOT NULL, + Temp REAL NOT NULL, + FeelsLike REAL NOT NULL, + Humidity REAL NOT NULL, + WindSpeed REAL NOT NULL, + WindDir REAL NOT NULL, + FetchedAt TEXT NOT NULL, + FOREIGN KEY(CardId) REFERENCES Card(Id)); + + INSERT INTO RowColumn (RowPosition, RowWidth) VALUES (1,1); + INSERT INTO Card (IndexPosition, RowColumnId, Name, Url, Type, IconUrl) VALUES + (1,1,'A','','Weather',''), + (1,1,'B','','Weather',''), + (1,1,'C','','Weather',''); + + INSERT INTO WeatherData (CardId, Address, Timezone, Description, Temp, FeelsLike, Humidity, WindSpeed, WindDir, FetchedAt) VALUES + (1, 'Stockholm', 'Europe/Stockholm', 'clear', 20, 20, 50, 5, 180, '2026-01-01T10:00:00'), + (2, 'Stockholm', 'Europe/Stockholm', 'cloudy', 18, 17, 60, 6, 190, '2026-01-02T10:00:00'), + (3, 'Gothenburg', 'Europe/Stockholm', 'rain', 15, 14, 80, 8, 200, '2026-01-01T10:00:00');"); + + await new DbInitializer(_connection).EnsureTablesCreatedAsync(); + + var weatherRows = (await _connection.QueryAsync("SELECT Id, Address, Description FROM WeatherData;")).ToList(); + Assert.Equal(2, weatherRows.Count); + + var stockholmRow = weatherRows.Single(r => (string)r.Address == "Stockholm"); + Assert.Equal("cloudy", (string)stockholmRow.Description); + var stockholmWeatherId = (int)(long)stockholmRow.Id; + + var connections = (await _connection.QueryAsync("SELECT * FROM WeatherDataConnection;")).ToList(); + Assert.Equal(3, connections.Count); + Assert.Equal(2, connections.Count(c => c.WeatherId == stockholmWeatherId)); + Assert.Equal(3, connections.Select(c => c.CardId).Distinct().Count()); + + var columnNames = (await _connection.QueryAsync("SELECT name FROM pragma_table_info('WeatherData');")).ToList(); + Assert.DoesNotContain("CardId", columnNames); + + Assert.True(File.Exists(_dbPath + ".bak")); + } + + [Fact] + public async Task EnsureTablesCreatedAsync_WhenRunTwice_IsIdempotent() + { + await _connection.ExecuteAsync(@" + CREATE TABLE RowColumn(Id INTEGER PRIMARY KEY AUTOINCREMENT, RowPosition INTEGER NOT NULL, RowWidth INTEGER NOT NULL); + CREATE TABLE Card(Id INTEGER PRIMARY KEY AUTOINCREMENT, IndexPosition INTEGER NOT NULL, RowColumnId INTEGER NOT NULL, Name TEXT, URL TEXT, Type TEXT, IconUrl TEXT); + CREATE TABLE WeatherData( + Id INTEGER PRIMARY KEY AUTOINCREMENT, + CardId INTEGER NOT NULL, + Address TEXT NOT NULL, + Timezone TEXT NOT NULL, + Description TEXT NOT NULL, + Temp REAL NOT NULL, + FeelsLike REAL NOT NULL, + Humidity REAL NOT NULL, + WindSpeed REAL NOT NULL, + WindDir REAL NOT NULL, + FetchedAt TEXT NOT NULL, + FOREIGN KEY(CardId) REFERENCES Card(Id)); + + INSERT INTO RowColumn (RowPosition, RowWidth) VALUES (1,1); + INSERT INTO Card (IndexPosition, RowColumnId, Name, Url, Type, IconUrl) VALUES (1,1,'A','','Weather',''); + INSERT INTO WeatherData (CardId, Address, Timezone, Description, Temp, FeelsLike, Humidity, WindSpeed, WindDir, FetchedAt) + VALUES (1, 'Stockholm', 'Europe/Stockholm', 'clear', 20, 20, 50, 5, 180, '2026-01-01T10:00:00');"); + + var initializer = new DbInitializer(_connection); + await initializer.EnsureTablesCreatedAsync(); + await initializer.EnsureTablesCreatedAsync(); + + var weatherRowCount = await _connection.QuerySingleAsync("SELECT COUNT(*) FROM WeatherData;"); + var connectionRowCount = await _connection.QuerySingleAsync("SELECT COUNT(*) FROM WeatherDataConnection;"); + Assert.Equal(1, weatherRowCount); + Assert.Equal(1, connectionRowCount); + } + + public void Dispose() + { + _connection.Dispose(); + SqliteConnection.ClearAllPools(); + if (File.Exists(_dbPath)) File.Delete(_dbPath); + if (File.Exists(_dbPath + ".bak")) File.Delete(_dbPath + ".bak"); + } +} From 2068236f56125694183ddfa3f8699a5527d0cf15 Mon Sep 17 00:00:00 2001 From: Carpenteri1 Date: Fri, 7 Aug 2026 17:55:45 +0200 Subject: [PATCH 2/3] Fix WeatherDataConnection schema missing UNIQUE(CardId) and lost migration logic The junction table was missing its UNIQUE(CardId) constraint and the one-time legacy-data migration method had been dropped, causing every weather-connection upsert to fail and the migration/idempotency tests to fail. Co-Authored-By: Claude Sonnet 5 --- Data/DbInitializer.cs | 77 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 74 insertions(+), 3 deletions(-) diff --git a/Data/DbInitializer.cs b/Data/DbInitializer.cs index 0dd22f36..1d5ae57e 100644 --- a/Data/DbInitializer.cs +++ b/Data/DbInitializer.cs @@ -58,7 +58,7 @@ CREATE TABLE IF NOT EXISTS Settings( CREATE TABLE IF NOT EXISTS WidgetType( Id INTEGER PRIMARY KEY AUTOINCREMENT, Name TEXT NOT NULL); - + CREATE TABLE IF NOT EXISTS Widget( Id INTEGER PRIMARY KEY AUTOINCREMENT, WidgetType INTEGER, @@ -90,6 +90,7 @@ CREATE TABLE IF NOT EXISTS WeatherDataConnection( Id INTEGER PRIMARY KEY AUTOINCREMENT, CardId INTEGER NOT NULL, WeatherId INTEGER NOT NULL, + UNIQUE(CardId), FOREIGN KEY(CardId) REFERENCES Card(Id) ON DELETE CASCADE, FOREIGN KEY(WeatherId) REFERENCES WeatherData(Id) ON DELETE CASCADE); @@ -109,8 +110,8 @@ INSERT INTO WidgetType(Name) INSERT INTO Widget(WidgetType, Label, Description, Icon) SELECT Id, 'Weather widget', '', 'clouds' - FROM WidgetType - WHERE Name = 'Weather' + FROM WidgetType + WHERE Name = 'Weather' AND NOT EXISTS (SELECT 1 FROM Widget WHERE Id = 1); INSERT INTO Widget(WidgetType, Label, Description, Icon) @@ -125,5 +126,75 @@ FROM WidgetType WHERE Name = 'Custom' AND NOT EXISTS (SELECT 1 FROM Widget WHERE Id = 3);", commandTimeout:150); + + await MigrateLegacyWeatherDataAsync(); + } + + private async Task MigrateLegacyWeatherDataAsync() + { + var columnNames = await connection.QueryAsync( + "SELECT name FROM pragma_table_info('WeatherData');"); + + if (!columnNames.Contains("CardId")) + return; + + BackupDatabaseFile(); + + await connection.ExecuteAsync( + sql: @" + PRAGMA foreign_keys=OFF; + BEGIN TRANSACTION; + + CREATE TABLE WeatherData_new( + Id INTEGER PRIMARY KEY AUTOINCREMENT, + Address TEXT NOT NULL UNIQUE, + Timezone TEXT NOT NULL, + Description TEXT NOT NULL, + Temp REAL NOT NULL, + FeelsLike REAL NOT NULL, + Humidity REAL NOT NULL, + WindSpeed REAL NOT NULL, + WindDir REAL NOT NULL, + FetchedAt TEXT NOT NULL); + + INSERT INTO WeatherData_new (Address, Timezone, Description, Temp, FeelsLike, Humidity, WindSpeed, WindDir, FetchedAt) + SELECT Address, Timezone, Description, Temp, FeelsLike, Humidity, WindSpeed, WindDir, FetchedAt + FROM WeatherData wd + WHERE wd.Id = ( + SELECT wd2.Id FROM WeatherData wd2 + WHERE wd2.Address = wd.Address + ORDER BY wd2.FetchedAt DESC, wd2.Id DESC + LIMIT 1 + ); + + INSERT INTO WeatherDataConnection (CardId, WeatherId) + SELECT legacy.CardId, wn.Id + FROM ( + SELECT wd.CardId, wd.Address + FROM WeatherData wd + WHERE wd.Id = ( + SELECT wd2.Id FROM WeatherData wd2 + WHERE wd2.CardId = wd.CardId + ORDER BY wd2.FetchedAt DESC, wd2.Id DESC + LIMIT 1 + ) + ) legacy + INNER JOIN WeatherData_new wn ON wn.Address = legacy.Address; + + DROP TABLE WeatherData; + ALTER TABLE WeatherData_new RENAME TO WeatherData; + + COMMIT; + PRAGMA foreign_keys=ON;", + commandTimeout: 150); + } + + private void BackupDatabaseFile() + { + var dataSource = new SqliteConnectionStringBuilder(connection.ConnectionString).DataSource; + if (string.IsNullOrWhiteSpace(dataSource) || !File.Exists(dataSource)) + return; + + File.Copy(dataSource, dataSource + ".bak", overwrite: true); } } From e58bcb08d5f52f2cab83cb9ec3a4e084b42fcdcc Mon Sep 17 00:00:00 2001 From: Carpenteri1 Date: Sun, 9 Aug 2026 23:29:30 +0200 Subject: [PATCH 3/3] update to test, removed dead code --- Constants/QueryStrings.cs | 2 +- Dtos/CardWeatherDataDtoModel.cs | 16 ---------------- .../src/app/components/card/card.component.html | 2 +- .../edit-card-dialog.facade.spec.ts | 11 ----------- ...ocation-for-provider-dialog.component.spec.ts | 15 +-------------- .../weather.endpoint.service.spec.ts | 7 ++++--- .../weather.endpoint.service.ts | 5 ++--- .../weather-provider.service.spec.ts | 7 ++++--- .../weather_services/weather-provider.service.ts | 9 ++++----- Repositories/IWeatherRepository.cs | 2 +- Repositories/WeatherRepository.cs | 4 ++-- Tests/Infrastructure/TestDoubles.cs | 4 ++-- 12 files changed, 22 insertions(+), 62 deletions(-) delete mode 100644 Dtos/CardWeatherDataDtoModel.cs diff --git a/Constants/QueryStrings.cs b/Constants/QueryStrings.cs index a66b5ece..4a5a4763 100644 --- a/Constants/QueryStrings.cs +++ b/Constants/QueryStrings.cs @@ -166,7 +166,7 @@ FROM ProviderKeys SELECT Id, Address, Timezone, Description, Temp, FeelsLike, Humidity, WindSpeed, WindDir, FetchedAt FROM WeatherData /**where**/"; - public const string SelectCardWeatherDataQuery = @" + public const string SelectAllWeatherDataQuery = @" SELECT wc.CardId, w.Id AS WeatherId, w.Address, w.Timezone, w.Description, w.Temp, w.FeelsLike, w.Humidity, w.WindSpeed, w.WindDir, w.FetchedAt FROM WeatherDataConnection wc diff --git a/Dtos/CardWeatherDataDtoModel.cs b/Dtos/CardWeatherDataDtoModel.cs deleted file mode 100644 index 23f32729..00000000 --- a/Dtos/CardWeatherDataDtoModel.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace Gridly.Dtos; - -public class CardWeatherDataDtoModel -{ - public int CardId { get; set; } - public int WeatherId { get; set; } - public string Address { get; set; } - public string Timezone { get; set; } - public string Description { get; set; } - public double Temp { get; set; } - public double FeelsLike { get; set; } - public double Humidity { get; set; } - public double WindSpeed { get; set; } - public double WindDir { get; set; } - public DateTime FetchedAt { get; set; } -} diff --git a/Gridly-Client/src/app/components/card/card.component.html b/Gridly-Client/src/app/components/card/card.component.html index 71de364e..650bc659 100644 --- a/Gridly-Client/src/app/components/card/card.component.html +++ b/Gridly-Client/src/app/components/card/card.component.html @@ -50,7 +50,7 @@ {{card.iconData?.materialIcon}} @if(storedWeatherData$ | async; as weatherData){ @for(weather of weatherData; track weather){ - @if (card.id === weather.cardId && card.type === CardTypes.Weather) { + @if (card.type === CardTypes.Weather) {
  • {{ 'card.weatherSection.address' | translate }} {{weather.address}}
  • {{ 'card.weatherSection.temp' | translate }} {{weather.temp}}
  • diff --git a/Gridly-Client/src/app/components/dialogs/editCardDialog/edit-card-dialog.facade.spec.ts b/Gridly-Client/src/app/components/dialogs/editCardDialog/edit-card-dialog.facade.spec.ts index 7339e58b..289ee513 100644 --- a/Gridly-Client/src/app/components/dialogs/editCardDialog/edit-card-dialog.facade.spec.ts +++ b/Gridly-Client/src/app/components/dialogs/editCardDialog/edit-card-dialog.facade.spec.ts @@ -154,17 +154,6 @@ describe('EditCardDialogFacade', () => { expect(facade.cityInput).toBe(''); }); - it('does not re-save when the returned weather already belongs to the requested card', async () => { - facade.countryInput = 'Sweden'; - facade.cityInput = 'Stockholm'; - weatherProviderServiceMock.getWeather.mockResolvedValue([{ ...makeWeather(), cardId: 9 }, 200]); - - const result = await facade.saveLocation(9); - - expect(result).toBe(false); - expect(weatherProviderServiceMock.save).not.toHaveBeenCalled(); - }); - it('falls back to getVisualCrossingData and saves the freshly-fetched weather when getWeather fails', async () => { facade.countryInput = 'Sweden'; facade.cityInput = 'Stockholm'; diff --git a/Gridly-Client/src/app/components/dialogs/setLocationForProviderDialog/set-location-for-provider-dialog.component.spec.ts b/Gridly-Client/src/app/components/dialogs/setLocationForProviderDialog/set-location-for-provider-dialog.component.spec.ts index e981827b..f46760ea 100644 --- a/Gridly-Client/src/app/components/dialogs/setLocationForProviderDialog/set-location-for-provider-dialog.component.spec.ts +++ b/Gridly-Client/src/app/components/dialogs/setLocationForProviderDialog/set-location-for-provider-dialog.component.spec.ts @@ -13,7 +13,6 @@ describe('SetLocationForProviderDialogComponent', () => { const weather: WeatherDataModel = { id: 1, - cardId: 99, address: 'Sweden,Stockholm', timezone: 'Europe/Stockholm', description: 'Clear', @@ -70,30 +69,18 @@ describe('SetLocationForProviderDialogComponent', () => { }); it('saves the weather, resets inputs, and closes on a successful lookup', async () => { - weatherProviderServiceMock.getWeather.mockResolvedValue([{ ...weather, cardId: 1 }, 200]); + weatherProviderServiceMock.getWeather.mockResolvedValue([{ ...weather }, 200]); component.countryInput = 'Sweden'; component.cityInput = 'Stockholm'; await component.onSubmit(); expect(weatherProviderServiceMock.getWeather).toHaveBeenCalledWith('Sweden,Stockholm'); - expect(weatherProviderServiceMock.save).toHaveBeenCalledWith(expect.objectContaining({ cardId: 99 })); expect(component.countryInput).toBe(''); expect(component.cityInput).toBe(''); expect(component.close).toHaveBeenCalled(); }); - it('does not re-save when the weather already belongs to the current card', async () => { - weatherProviderServiceMock.getWeather.mockResolvedValue([{ ...weather, cardId: 99 }, 200]); - component.countryInput = 'Sweden'; - component.cityInput = 'Stockholm'; - - await component.onSubmit(); - - expect(weatherProviderServiceMock.save).not.toHaveBeenCalled(); - expect(component.close).toHaveBeenCalled(); - }); - it('falls back to visual crossing and prompts for an invalid key on a 401', async () => { weatherProviderServiceMock.getWeather.mockResolvedValue([undefined, 401]); weatherProviderServiceMock.getVisualCrossingData.mockResolvedValue([undefined, 401]); diff --git a/Gridly-Client/src/app/services/endpoint_services/weather.endpoint.service.spec.ts b/Gridly-Client/src/app/services/endpoint_services/weather.endpoint.service.spec.ts index e54fcd09..c1c81bdd 100644 --- a/Gridly-Client/src/app/services/endpoint_services/weather.endpoint.service.spec.ts +++ b/Gridly-Client/src/app/services/endpoint_services/weather.endpoint.service.spec.ts @@ -9,9 +9,10 @@ describe('WeatherEndpointService', () => { let service: WeatherEndpointService; let httpMock: HttpTestingController; + const cardId = 1; + const weather: WeatherDataModel = { id: 1, - cardId: 1, address: 'Sweden,Stockholm', timezone: 'Europe/Stockholm', description: 'Clear', @@ -77,11 +78,11 @@ describe('WeatherEndpointService', () => { it('sends a POST request to save weather data with the weather wrapped in the body', () => { let completed = false; - service.save(weather).subscribe(() => (completed = true)); + service.save(weather,cardId).subscribe(() => (completed = true)); const req = httpMock.expectOne(urlConstants.weather.save); expect(req.request.method).toBe('POST'); - expect(req.request.body).toEqual({ weather }); + expect(req.request.body).toEqual({ weather, cardId }); req.flush(null); expect(completed).toBe(true); diff --git a/Gridly-Client/src/app/services/endpoint_services/weather.endpoint.service.ts b/Gridly-Client/src/app/services/endpoint_services/weather.endpoint.service.ts index 95d5be8c..e41f63f0 100644 --- a/Gridly-Client/src/app/services/endpoint_services/weather.endpoint.service.ts +++ b/Gridly-Client/src/app/services/endpoint_services/weather.endpoint.service.ts @@ -3,7 +3,6 @@ import {HttpClient, HttpParams} from '@angular/common/http'; import {urlConstants} from "../../constants/url.constants"; import {Observable, take} from "rxjs"; import {WeatherDataModel} from "../../models/weatherData.Model"; -import {CardWeatherDataModel} from "../../models/cardWeatherData.Model"; @Injectable({ providedIn: 'root' @@ -16,8 +15,8 @@ export class WeatherEndpointService{ const params = new HttpParams().set('Address', address); return this.http.get(urlConstants.weather.get,{params}).pipe(take(1)); } - getStoredWeatherData(): Observable { - return this.http.get(urlConstants.weather.getStoredWeatherData).pipe(take(1)); + getStoredWeatherData(): Observable { + return this.http.get(urlConstants.weather.getStoredWeatherData).pipe(take(1)); } getvisualcrossingdata(address: string): Observable { const params = new HttpParams().set('Address', address); diff --git a/Gridly-Client/src/app/services/weather_services/weather-provider.service.spec.ts b/Gridly-Client/src/app/services/weather_services/weather-provider.service.spec.ts index 410b5805..7ca91b75 100644 --- a/Gridly-Client/src/app/services/weather_services/weather-provider.service.spec.ts +++ b/Gridly-Client/src/app/services/weather_services/weather-provider.service.spec.ts @@ -8,9 +8,10 @@ import { WeatherProviderService } from './weather-provider.service'; describe('WeatherProviderService', () => { let service: WeatherProviderService; + const cardId = 1; + const weather: WeatherDataModel = { id: 1, - cardId: 1, address: 'Sweden,Stockholm', timezone: 'Europe/Stockholm', description: 'Clear', @@ -59,9 +60,9 @@ describe('WeatherProviderService', () => { it('saves weather data through the endpoint', async () => { endpointMock.save.mockReturnValue(of(undefined)); - await service.save(weather); + await service.save(weather, cardId); - expect(endpointMock.save).toHaveBeenCalledWith(weather); + expect(endpointMock.save).toHaveBeenCalledWith(weather,cardId); }); it('returns weather data with a 200 status on a successful getWeather call', async () => { diff --git a/Gridly-Client/src/app/services/weather_services/weather-provider.service.ts b/Gridly-Client/src/app/services/weather_services/weather-provider.service.ts index c2cd579c..8741e358 100644 --- a/Gridly-Client/src/app/services/weather_services/weather-provider.service.ts +++ b/Gridly-Client/src/app/services/weather_services/weather-provider.service.ts @@ -4,18 +4,17 @@ import { toSignal } from "@angular/core/rxjs-interop"; import { HttpErrorResponse } from "@angular/common/http"; import {WeatherEndpointService} from "../endpoint_services/weather.endpoint.service"; import {WeatherDataModel} from "../../models/weatherData.Model"; -import {CardWeatherDataModel} from "../../models/cardWeatherData.Model"; @Injectable({providedIn: 'root'}) export class WeatherProviderService { - private readonly storedWeatherDataSubject = new BehaviorSubject([]); - readonly storedWeatherData$: Observable; - readonly storedWeatherData!: Signal; + private readonly storedWeatherDataSubject = new BehaviorSubject([]); + readonly storedWeatherData$: Observable; + readonly storedWeatherData!: Signal; #api = inject(WeatherEndpointService); constructor() { this.storedWeatherData$ = this.storedWeatherDataSubject.asObservable(); - this.storedWeatherData = toSignal(this.storedWeatherData$, { initialValue: [] as CardWeatherDataModel[] }); + this.storedWeatherData = toSignal(this.storedWeatherData$, { initialValue: [] as WeatherDataModel[] }); this.refresh(); } diff --git a/Repositories/IWeatherRepository.cs b/Repositories/IWeatherRepository.cs index 3a6608d4..ce3fc0e1 100644 --- a/Repositories/IWeatherRepository.cs +++ b/Repositories/IWeatherRepository.cs @@ -5,7 +5,7 @@ namespace Gridly.Repositories; public interface IWeatherRepository { public Task Get(string address); - public Task> GetStoredWeatherData(); + public Task> GetStoredWeatherData(); public Task Upsert(WeatherDataModel weather); public Task DeleteIfOrphaned(int weatherId); } diff --git a/Repositories/WeatherRepository.cs b/Repositories/WeatherRepository.cs index e6e8eff5..3917b971 100644 --- a/Repositories/WeatherRepository.cs +++ b/Repositories/WeatherRepository.cs @@ -22,10 +22,10 @@ public class WeatherRepository(IDbConnection connection) : IWeatherRepository return dto; } - public async Task?> GetStoredWeatherData() + public async Task?> GetStoredWeatherData() { var storedWeatherData = - await _dbCommandRunner.SelectMany(QueryStrings.SelectCardWeatherDataQuery, string.Empty); + await _dbCommandRunner.SelectMany(QueryStrings.SelectAllWeatherDataQuery, string.Empty); return storedWeatherData; } diff --git a/Tests/Infrastructure/TestDoubles.cs b/Tests/Infrastructure/TestDoubles.cs index a4382771..5f28ee89 100644 --- a/Tests/Infrastructure/TestDoubles.cs +++ b/Tests/Infrastructure/TestDoubles.cs @@ -143,8 +143,8 @@ public void Seed(WeatherDataModel weather) public Task Get(string address) => Task.FromResult(_byAddress.TryGetValue(address, out var value) ? value : null); - public Task> GetStoredWeatherData() => - Task.FromResult?>(Array.Empty()); + public Task> GetStoredWeatherData() => + Task.FromResult?>(Array.Empty()); public Task Upsert(WeatherDataModel weather) {