From 6a9a9b7c996d00b3809a815deddfb6460bea2a2e Mon Sep 17 00:00:00 2001 From: 666mxvbee Date: Sat, 29 Aug 2026 20:06:39 +0300 Subject: [PATCH 01/11] chore(persistence): add ef core postgres dependencies --- .config/dotnet-tools.json | 13 + Directory.Build.props | 1 + global.json | 7 + .../packages.lock.json | 6 +- src/Atm.Application/Atm.Application.csproj | 2 +- src/Atm.Application/packages.lock.json | 6 +- .../Atm.Infrastructure.csproj | 12 +- src/Atm.Infrastructure/packages.lock.json | 326 +++++++++++++++++- src/Atm.Presentation/packages.lock.json | 129 ++++++- .../Atm.Application.Tests/packages.lock.json | 6 +- 10 files changed, 488 insertions(+), 20 deletions(-) create mode 100644 .config/dotnet-tools.json create mode 100644 global.json diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 0000000..e5d8851 --- /dev/null +++ b/.config/dotnet-tools.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-ef": { + "version": "9.0.19", + "commands": [ + "dotnet-ef" + ], + "rollForward": false + } + } +} \ No newline at end of file diff --git a/Directory.Build.props b/Directory.Build.props index fd2910d..85cf783 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -3,5 +3,6 @@ true true latest + true \ No newline at end of file diff --git a/global.json b/global.json new file mode 100644 index 0000000..bf1f678 --- /dev/null +++ b/global.json @@ -0,0 +1,7 @@ +{ + "sdk": { + "version": "9.0.100", + "rollForward": "latestFeature", + "allowPrerelease": false + } +} \ No newline at end of file diff --git a/src/Atm.Application.Contracts/packages.lock.json b/src/Atm.Application.Contracts/packages.lock.json index ba655ce..a07e21a 100644 --- a/src/Atm.Application.Contracts/packages.lock.json +++ b/src/Atm.Application.Contracts/packages.lock.json @@ -1,10 +1,6 @@ { "version": 1, "dependencies": { - "net9.0": { - "atm.domain": { - "type": "Project" - } - } + "net9.0": {} } } \ No newline at end of file diff --git a/src/Atm.Application/Atm.Application.csproj b/src/Atm.Application/Atm.Application.csproj index d64769e..2424e34 100644 --- a/src/Atm.Application/Atm.Application.csproj +++ b/src/Atm.Application/Atm.Application.csproj @@ -7,7 +7,7 @@ - + diff --git a/src/Atm.Application/packages.lock.json b/src/Atm.Application/packages.lock.json index 60a845a..e4e5f9e 100644 --- a/src/Atm.Application/packages.lock.json +++ b/src/Atm.Application/packages.lock.json @@ -4,9 +4,9 @@ "net9.0": { "Microsoft.Extensions.DependencyInjection.Abstractions": { "type": "Direct", - "requested": "[10.0.2, )", - "resolved": "10.0.2", - "contentHash": "zOIurr59+kUf9vNcsUkCvKWZv+fPosUZXURZesYkJCvl0EzTc9F7maAO4Cd2WEV7ZJJ0AZrFQvuH6Npph9wdBw==" + "requested": "[9.0.19, )", + "resolved": "9.0.19", + "contentHash": "XgQ0aVWClYjYSBqbYrKC/xyZ1KHf+VVwNF3+d54jcaqnst30Q//ugs/FJwUztLZiph0gp85g7i/eB0556twO0Q==" }, "atm.application.abstractions": { "type": "Project", diff --git a/src/Atm.Infrastructure/Atm.Infrastructure.csproj b/src/Atm.Infrastructure/Atm.Infrastructure.csproj index e759e49..6200b48 100644 --- a/src/Atm.Infrastructure/Atm.Infrastructure.csproj +++ b/src/Atm.Infrastructure/Atm.Infrastructure.csproj @@ -6,7 +6,17 @@ - + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + diff --git a/src/Atm.Infrastructure/packages.lock.json b/src/Atm.Infrastructure/packages.lock.json index 9fadaef..b27447b 100644 --- a/src/Atm.Infrastructure/packages.lock.json +++ b/src/Atm.Infrastructure/packages.lock.json @@ -2,11 +2,331 @@ "version": 1, "dependencies": { "net9.0": { + "Microsoft.EntityFrameworkCore": { + "type": "Direct", + "requested": "[9.0.19, )", + "resolved": "9.0.19", + "contentHash": "qWqqxrfEDfi3C/z0S/ACIA2gUnreRPHX74WE/dVnesbztNesYO4NLy76lE6FV/zDP4vH/pkvDr8h3Mq9+cFv/w==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Abstractions": "9.0.19", + "Microsoft.EntityFrameworkCore.Analyzers": "9.0.19", + "Microsoft.Extensions.Caching.Memory": "9.0.19", + "Microsoft.Extensions.Logging": "9.0.19" + } + }, + "Microsoft.EntityFrameworkCore.Design": { + "type": "Direct", + "requested": "[9.0.19, )", + "resolved": "9.0.19", + "contentHash": "O4o69Xne6dJ7+u21YPxevGt8F2wpxGz2bBiSa/SSyqb2/64OZtcbIOB264fuViRj2XgwzhSGErNP75fRVCk8Zw==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.Build.Framework": "17.8.43", + "Microsoft.Build.Locator": "1.7.8", + "Microsoft.CodeAnalysis.CSharp": "4.8.0", + "Microsoft.CodeAnalysis.CSharp.Workspaces": "4.8.0", + "Microsoft.CodeAnalysis.Workspaces.MSBuild": "4.8.0", + "Microsoft.EntityFrameworkCore.Relational": "9.0.19", + "Microsoft.Extensions.Caching.Memory": "9.0.19", + "Microsoft.Extensions.Configuration.Abstractions": "9.0.19", + "Microsoft.Extensions.DependencyModel": "9.0.19", + "Microsoft.Extensions.Logging": "9.0.19", + "Mono.TextTemplating": "3.0.0", + "System.Text.Json": "9.0.19" + } + }, + "Microsoft.EntityFrameworkCore.Relational": { + "type": "Direct", + "requested": "[9.0.19, )", + "resolved": "9.0.19", + "contentHash": "KAfYGG4LWRNn1G/vMRydxgOjuZoWUvdd5raUcIia/HPg1jUjlQzS386nUx5gt3YfKxpFJaCGxdsQ1nCZWuYR8A==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "9.0.19", + "Microsoft.Extensions.Caching.Memory": "9.0.19", + "Microsoft.Extensions.Configuration.Abstractions": "9.0.19", + "Microsoft.Extensions.Logging": "9.0.19" + } + }, "Microsoft.Extensions.DependencyInjection.Abstractions": { "type": "Direct", - "requested": "[10.0.2, )", - "resolved": "10.0.2", - "contentHash": "zOIurr59+kUf9vNcsUkCvKWZv+fPosUZXURZesYkJCvl0EzTc9F7maAO4Cd2WEV7ZJJ0AZrFQvuH6Npph9wdBw==" + "requested": "[9.0.19, )", + "resolved": "9.0.19", + "contentHash": "XgQ0aVWClYjYSBqbYrKC/xyZ1KHf+VVwNF3+d54jcaqnst30Q//ugs/FJwUztLZiph0gp85g7i/eB0556twO0Q==" + }, + "Npgsql.EntityFrameworkCore.PostgreSQL": { + "type": "Direct", + "requested": "[9.0.4, )", + "resolved": "9.0.4", + "contentHash": "mw5vcY2IEc7L+IeGrxpp/J5OSnCcjkjAgJYCm/eD52wpZze8zsSifdqV7zXslSMmfJG2iIUGZyo3KuDtEFKwMQ==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "[9.0.1, 10.0.0)", + "Microsoft.EntityFrameworkCore.Relational": "[9.0.1, 10.0.0)", + "Npgsql": "9.0.3" + } + }, + "Humanizer.Core": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "lQKvtaTDOXnoVJ20ibTuSIOf2i0uO0MPbDhd1jm238I+U/2ZnRENj0cktKZhtchBMtCUSRQ5v4xBCUbKNmyVMw==" + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "7.0.0", + "contentHash": "3aeMZ1N0lJoSyzqiP03hqemtb1BijhsJADdobn/4nsMJ8V1H+CrpuduUe4hlRdx+ikBQju1VGjMD1GJ3Sk05Eg==" + }, + "Microsoft.Build.Framework": { + "type": "Transitive", + "resolved": "17.8.43", + "contentHash": "TA6fCbd9qFSyhIKcUn5BhDN3/JrqpspO9KYsZKUvHyQpQrHVdzy4Hk6egjjBP3T0L562TLZuHEzQwJziyRBEkw==" + }, + "Microsoft.Build.Locator": { + "type": "Transitive", + "resolved": "1.7.8", + "contentHash": "sPy10x527Ph16S2u0yGME4S6ohBKJ69WfjeGG/bvELYeZVmJdKjxgnlL8cJJJLGV/cZIRqSfB12UDB8ICakOog==" + }, + "Microsoft.CodeAnalysis.Analyzers": { + "type": "Transitive", + "resolved": "3.3.4", + "contentHash": "AxkxcPR+rheX0SmvpLVIGLhOUXAKG56a64kV9VQZ4y9gR9ZmPXnqZvHJnmwLSwzrEP6junUF11vuc+aqo5r68g==" + }, + "Microsoft.CodeAnalysis.Common": { + "type": "Transitive", + "resolved": "4.8.0", + "contentHash": "/jR+e/9aT+BApoQJABlVCKnnggGQbvGh7BKq2/wI1LamxC+LbzhcLj4Vj7gXCofl1n4E521YfF9w0WcASGg/KA==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "3.3.4", + "System.Collections.Immutable": "7.0.0", + "System.Reflection.Metadata": "7.0.0", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "Microsoft.CodeAnalysis.CSharp": { + "type": "Transitive", + "resolved": "4.8.0", + "contentHash": "+3+qfdb/aaGD8PZRCrsdobbzGs1m9u119SkkJt8e/mk3xLJz/udLtS2T6nY27OTXxBBw10HzAbC8Z9w08VyP/g==", + "dependencies": { + "Microsoft.CodeAnalysis.Common": "[4.8.0]" + } + }, + "Microsoft.CodeAnalysis.CSharp.Workspaces": { + "type": "Transitive", + "resolved": "4.8.0", + "contentHash": "3amm4tq4Lo8/BGvg9p3BJh3S9nKq2wqCXfS7138i69TUpo/bD+XvD0hNurpEBtcNZhi1FyutiomKJqVF39ugYA==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.CodeAnalysis.CSharp": "[4.8.0]", + "Microsoft.CodeAnalysis.Common": "[4.8.0]", + "Microsoft.CodeAnalysis.Workspaces.Common": "[4.8.0]" + } + }, + "Microsoft.CodeAnalysis.Workspaces.Common": { + "type": "Transitive", + "resolved": "4.8.0", + "contentHash": "LXyV+MJKsKRu3FGJA3OmSk40OUIa/dQCFLOnm5X8MNcujx7hzGu8o+zjXlb/cy5xUdZK2UKYb9YaQ2E8m9QehQ==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.Bcl.AsyncInterfaces": "7.0.0", + "Microsoft.CodeAnalysis.Common": "[4.8.0]", + "System.Composition": "7.0.0", + "System.IO.Pipelines": "7.0.0", + "System.Threading.Channels": "7.0.0" + } + }, + "Microsoft.CodeAnalysis.Workspaces.MSBuild": { + "type": "Transitive", + "resolved": "4.8.0", + "contentHash": "IEYreI82QZKklp54yPHxZNG9EKSK6nHEkeuf+0Asie9llgS1gp0V1hw7ODG+QyoB7MuAnNQHmeV1Per/ECpv6A==", + "dependencies": { + "Microsoft.Build.Framework": "16.10.0", + "Microsoft.CodeAnalysis.Common": "[4.8.0]", + "Microsoft.CodeAnalysis.Workspaces.Common": "[4.8.0]", + "System.Text.Json": "7.0.3" + } + }, + "Microsoft.EntityFrameworkCore.Abstractions": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "igPnmVL3DU2ZH/4DNeQqWttNrQpwwg0q6MWj5peZxtl0QVMDSeFtnt2yhhdSffklZKYsAFko1sh/va3z4y7S6A==" + }, + "Microsoft.EntityFrameworkCore.Analyzers": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "yI5dQuvigJ6ICQECOlJDtrsUYcuDryv3qsZK1i6ZA/IzMzZ/VPkDzjE4r2LivuyVSGnkDRkH2cp4LmrhXARRKw==" + }, + "Microsoft.Extensions.Caching.Abstractions": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "qe/6m6wxRG+N5xTf4Z3lDBAiVu6oBCUyADJ5OzlFgukD9Vg/6zB3MNnNEFA7amZg8hjcS6F4OZBRmB07A+gmHQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "9.0.19" + } + }, + "Microsoft.Extensions.Caching.Memory": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "Y0w5LkBqWkDh5vYTKM+QKlzxpqegEtP86oDhFMXgx4tDkWL2t+rl4h2vxBHkvBtM/gM6PoLK9QCrnoclXtHtVQ==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "9.0.19", + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.19", + "Microsoft.Extensions.Logging.Abstractions": "9.0.19", + "Microsoft.Extensions.Options": "9.0.19", + "Microsoft.Extensions.Primitives": "9.0.19" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "M6h5lX6QWSSn7FDeRUHePZen/xvxPsuhcX1VpBfefafqavn2BnfxheR7BH5D5AIZGM7Nah0xJKnq2DublD4doQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "9.0.19" + } + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "o/XFYSiV4a7eiCkf7W1GFAviVhHQAPY5bHrQRsNWAPT3GhJ3Af63vcFQjHivIrTNZITlPPaGWhBy7jykKYYyaQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.19" + } + }, + "Microsoft.Extensions.DependencyModel": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "Jpe5up2VLMkjKavOXZzWRXBvy8SUPoFCRuVkq1wYrtVwtzuFEc7saw02dltASs5T8DXp6Ff/DPzmU95mDOiw6g==" + }, + "Microsoft.Extensions.Logging": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "3cnWzJ/FLEhLiW+TJTWtTjVj6fwlsP8OrbJqgOP4J9feAKjmT77vmClakHlej+jnzBgpnVU0+SZwtEIOr+KS6A==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "9.0.19", + "Microsoft.Extensions.Logging.Abstractions": "9.0.19", + "Microsoft.Extensions.Options": "9.0.19" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "RpqMVoZ+NECJxLOZRjWOfjST0mULs4wb848mxG/CS9SMkVL6yi1sSIda4dGcsB01J2jUFd8hNwsaYl1gplU+Cw==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.19" + } + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "FdPZy4kUSwFlIVZ3EkK9fdHw2RfblUwzflSb4kjVeGf9v2lX9IH+q2RGUovYMR7CVfKMc2y2Db/bZ2b4yiMfOg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.19", + "Microsoft.Extensions.Primitives": "9.0.19" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "9hIg8PQiMnpVFIsEHm25Wi1gBrPa2pS1G75uveyDUVLXrNKqBap9WGwQIgO0s6LUgRAOdsSYnbKbNC5b1G5Pcg==" + }, + "Mono.TextTemplating": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "YqueG52R/Xej4VVbKuRIodjiAhV0HR/XVbLbNrJhCZnzjnSjgMJ/dCdV0akQQxavX6hp/LC6rqLGLcXeQYU7XA==", + "dependencies": { + "System.CodeDom": "6.0.0" + } + }, + "Npgsql": { + "type": "Transitive", + "resolved": "9.0.3", + "contentHash": "tPvY61CxOAWxNsKLEBg+oR646X4Bc8UmyQ/tJszL/7mEmIXQnnBhVJZrZEEUv0Bstu0mEsHZD5At3EO8zQRAYw==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "8.0.2" + } + }, + "System.CodeDom": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "CPc6tWO1LAer3IzfZufDBRL+UZQcj5uS207NHALQzP84Vp/z6wF0Aa0YZImOQY8iStY0A2zI/e3ihKNPfUm8XA==" + }, + "System.Collections.Immutable": { + "type": "Transitive", + "resolved": "7.0.0", + "contentHash": "dQPcs0U1IKnBdRDBkrCTi1FoajSTBzLcVTpjO4MBCMC7f4pDOIPzgBoX8JjG7X6uZRJ8EBxsi8+DR1JuwjnzOQ==" + }, + "System.Composition": { + "type": "Transitive", + "resolved": "7.0.0", + "contentHash": "tRwgcAkDd85O8Aq6zHDANzQaq380cek9lbMg5Qma46u5BZXq/G+XvIYmu+UI+BIIZ9zssXLYrkTykEqxxvhcmg==", + "dependencies": { + "System.Composition.AttributedModel": "7.0.0", + "System.Composition.Convention": "7.0.0", + "System.Composition.Hosting": "7.0.0", + "System.Composition.Runtime": "7.0.0", + "System.Composition.TypedParts": "7.0.0" + } + }, + "System.Composition.AttributedModel": { + "type": "Transitive", + "resolved": "7.0.0", + "contentHash": "2QzClqjElKxgI1jK1Jztnq44/8DmSuTSGGahXqQ4TdEV0h9s2KikQZIgcEqVzR7OuWDFPGLHIprBJGQEPr8fAQ==" + }, + "System.Composition.Convention": { + "type": "Transitive", + "resolved": "7.0.0", + "contentHash": "IMhTlpCs4HmlD8B+J8/kWfwX7vrBBOs6xyjSTzBlYSs7W4OET4tlkR/Sg9NG8jkdJH9Mymq0qGdYS1VPqRTBnQ==", + "dependencies": { + "System.Composition.AttributedModel": "7.0.0" + } + }, + "System.Composition.Hosting": { + "type": "Transitive", + "resolved": "7.0.0", + "contentHash": "eB6gwN9S+54jCTBJ5bpwMOVerKeUfGGTYCzz3QgDr1P55Gg/Wb27ShfPIhLMjmZ3MoAKu8uUSv6fcCdYJTN7Bg==", + "dependencies": { + "System.Composition.Runtime": "7.0.0" + } + }, + "System.Composition.Runtime": { + "type": "Transitive", + "resolved": "7.0.0", + "contentHash": "aZJ1Zr5Txe925rbo4742XifEyW0MIni1eiUebmcrP3HwLXZ3IbXUj4MFMUH/RmnJOAQiS401leg/2Sz1MkApDw==" + }, + "System.Composition.TypedParts": { + "type": "Transitive", + "resolved": "7.0.0", + "contentHash": "ZK0KNPfbtxVceTwh+oHNGUOYV2WNOHReX2AXipuvkURC7s/jPwoWfsu3SnDBDgofqbiWr96geofdQ2erm/KTHg==", + "dependencies": { + "System.Composition.AttributedModel": "7.0.0", + "System.Composition.Hosting": "7.0.0", + "System.Composition.Runtime": "7.0.0" + } + }, + "System.IO.Pipelines": { + "type": "Transitive", + "resolved": "7.0.0", + "contentHash": "jRn6JYnNPW6xgQazROBLSfpdoczRw694vO5kKvMcNnpXuolEixUyw6IBuBs2Y2mlSX/LdLvyyWmfXhaI3ND1Yg==" + }, + "System.Reflection.Metadata": { + "type": "Transitive", + "resolved": "7.0.0", + "contentHash": "MclTG61lsD9sYdpNz9xsKBzjsmsfCtcMZYXz/IUr2zlhaTaABonlr1ESeompTgM+Xk+IwtGYU7/voh3YWB/fWw==", + "dependencies": { + "System.Collections.Immutable": "7.0.0" + } + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg==" + }, + "System.Text.Json": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "DjVVcIY/dS/rjb0b42nciHjUc0voQfUtrTos+wC0sgdx0VemTpYhx1jdeHowwFghygJ6QdaytcMHwA8jZ6MHFg==" + }, + "System.Threading.Channels": { + "type": "Transitive", + "resolved": "7.0.0", + "contentHash": "qmeeYNROMsONF6ndEZcIQ+VxR4Q/TX/7uIVLJqtwIWL7dDWeh0l1UIqgo4wYyjG//5lUNhwkLDSFl+pAWO6oiA==" }, "atm.application.abstractions": { "type": "Project", diff --git a/src/Atm.Presentation/packages.lock.json b/src/Atm.Presentation/packages.lock.json index dfd4ccc..dbf801e 100644 --- a/src/Atm.Presentation/packages.lock.json +++ b/src/Atm.Presentation/packages.lock.json @@ -14,21 +14,139 @@ "Swashbuckle.AspNetCore.SwaggerUI": "6.6.2" } }, + "Microsoft.EntityFrameworkCore": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "qWqqxrfEDfi3C/z0S/ACIA2gUnreRPHX74WE/dVnesbztNesYO4NLy76lE6FV/zDP4vH/pkvDr8h3Mq9+cFv/w==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Abstractions": "9.0.19", + "Microsoft.EntityFrameworkCore.Analyzers": "9.0.19", + "Microsoft.Extensions.Caching.Memory": "9.0.19", + "Microsoft.Extensions.Logging": "9.0.19" + } + }, + "Microsoft.EntityFrameworkCore.Abstractions": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "igPnmVL3DU2ZH/4DNeQqWttNrQpwwg0q6MWj5peZxtl0QVMDSeFtnt2yhhdSffklZKYsAFko1sh/va3z4y7S6A==" + }, + "Microsoft.EntityFrameworkCore.Analyzers": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "yI5dQuvigJ6ICQECOlJDtrsUYcuDryv3qsZK1i6ZA/IzMzZ/VPkDzjE4r2LivuyVSGnkDRkH2cp4LmrhXARRKw==" + }, + "Microsoft.EntityFrameworkCore.Relational": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "KAfYGG4LWRNn1G/vMRydxgOjuZoWUvdd5raUcIia/HPg1jUjlQzS386nUx5gt3YfKxpFJaCGxdsQ1nCZWuYR8A==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "9.0.19", + "Microsoft.Extensions.Caching.Memory": "9.0.19", + "Microsoft.Extensions.Configuration.Abstractions": "9.0.19", + "Microsoft.Extensions.Logging": "9.0.19" + } + }, "Microsoft.Extensions.ApiDescription.Server": { "type": "Transitive", "resolved": "6.0.5", "contentHash": "Ckb5EDBUNJdFWyajfXzUIMRkhf52fHZOQuuZg/oiu8y7zDCVwD0iHhew6MnThjHmevanpxL3f5ci2TtHQEN6bw==" }, + "Microsoft.Extensions.Caching.Abstractions": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "qe/6m6wxRG+N5xTf4Z3lDBAiVu6oBCUyADJ5OzlFgukD9Vg/6zB3MNnNEFA7amZg8hjcS6F4OZBRmB07A+gmHQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "9.0.19" + } + }, + "Microsoft.Extensions.Caching.Memory": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "Y0w5LkBqWkDh5vYTKM+QKlzxpqegEtP86oDhFMXgx4tDkWL2t+rl4h2vxBHkvBtM/gM6PoLK9QCrnoclXtHtVQ==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "9.0.19", + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.19", + "Microsoft.Extensions.Logging.Abstractions": "9.0.19", + "Microsoft.Extensions.Options": "9.0.19", + "Microsoft.Extensions.Primitives": "9.0.19" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "M6h5lX6QWSSn7FDeRUHePZen/xvxPsuhcX1VpBfefafqavn2BnfxheR7BH5D5AIZGM7Nah0xJKnq2DublD4doQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "9.0.19" + } + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "o/XFYSiV4a7eiCkf7W1GFAviVhHQAPY5bHrQRsNWAPT3GhJ3Af63vcFQjHivIrTNZITlPPaGWhBy7jykKYYyaQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.19" + } + }, "Microsoft.Extensions.DependencyInjection.Abstractions": { "type": "Transitive", - "resolved": "10.0.2", - "contentHash": "zOIurr59+kUf9vNcsUkCvKWZv+fPosUZXURZesYkJCvl0EzTc9F7maAO4Cd2WEV7ZJJ0AZrFQvuH6Npph9wdBw==" + "resolved": "9.0.19", + "contentHash": "XgQ0aVWClYjYSBqbYrKC/xyZ1KHf+VVwNF3+d54jcaqnst30Q//ugs/FJwUztLZiph0gp85g7i/eB0556twO0Q==" + }, + "Microsoft.Extensions.Logging": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "3cnWzJ/FLEhLiW+TJTWtTjVj6fwlsP8OrbJqgOP4J9feAKjmT77vmClakHlej+jnzBgpnVU0+SZwtEIOr+KS6A==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "9.0.19", + "Microsoft.Extensions.Logging.Abstractions": "9.0.19", + "Microsoft.Extensions.Options": "9.0.19" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "RpqMVoZ+NECJxLOZRjWOfjST0mULs4wb848mxG/CS9SMkVL6yi1sSIda4dGcsB01J2jUFd8hNwsaYl1gplU+Cw==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.19" + } + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "FdPZy4kUSwFlIVZ3EkK9fdHw2RfblUwzflSb4kjVeGf9v2lX9IH+q2RGUovYMR7CVfKMc2y2Db/bZ2b4yiMfOg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.19", + "Microsoft.Extensions.Primitives": "9.0.19" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "9hIg8PQiMnpVFIsEHm25Wi1gBrPa2pS1G75uveyDUVLXrNKqBap9WGwQIgO0s6LUgRAOdsSYnbKbNC5b1G5Pcg==" }, "Microsoft.OpenApi": { "type": "Transitive", "resolved": "1.6.14", "contentHash": "tTaBT8qjk3xINfESyOPE2rIellPvB7qpVqiWiyA/lACVvz+xOGiXhFUfohcx82NLbi5avzLW0lx+s6oAqQijfw==" }, + "Npgsql": { + "type": "Transitive", + "resolved": "9.0.3", + "contentHash": "tPvY61CxOAWxNsKLEBg+oR646X4Bc8UmyQ/tJszL/7mEmIXQnnBhVJZrZEEUv0Bstu0mEsHZD5At3EO8zQRAYw==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "8.0.2" + } + }, + "Npgsql.EntityFrameworkCore.PostgreSQL": { + "type": "Transitive", + "resolved": "9.0.4", + "contentHash": "mw5vcY2IEc7L+IeGrxpp/J5OSnCcjkjAgJYCm/eD52wpZze8zsSifdqV7zXslSMmfJG2iIUGZyo3KuDtEFKwMQ==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "[9.0.1, 10.0.0)", + "Microsoft.EntityFrameworkCore.Relational": "[9.0.1, 10.0.0)", + "Npgsql": "9.0.3" + } + }, "Swashbuckle.AspNetCore.Swagger": { "type": "Transitive", "resolved": "6.6.2", @@ -56,7 +174,7 @@ "Atm.Application.Abstractions": "[1.0.0, )", "Atm.Application.Contracts": "[1.0.0, )", "Atm.Domain": "[1.0.0, )", - "Microsoft.Extensions.DependencyInjection.Abstractions": "[10.0.2, )" + "Microsoft.Extensions.DependencyInjection.Abstractions": "[9.0.19, )" } }, "atm.application.abstractions": { @@ -76,7 +194,10 @@ "dependencies": { "Atm.Application.Abstractions": "[1.0.0, )", "Atm.Domain": "[1.0.0, )", - "Microsoft.Extensions.DependencyInjection.Abstractions": "[10.0.2, )" + "Microsoft.EntityFrameworkCore": "[9.0.19, )", + "Microsoft.EntityFrameworkCore.Relational": "[9.0.19, )", + "Microsoft.Extensions.DependencyInjection.Abstractions": "[9.0.19, )", + "Npgsql.EntityFrameworkCore.PostgreSQL": "[9.0.4, )" } } } diff --git a/tests/Atm.Application.Tests/packages.lock.json b/tests/Atm.Application.Tests/packages.lock.json index d8d50bb..93bcc28 100644 --- a/tests/Atm.Application.Tests/packages.lock.json +++ b/tests/Atm.Application.Tests/packages.lock.json @@ -59,8 +59,8 @@ }, "Microsoft.Extensions.DependencyInjection.Abstractions": { "type": "Transitive", - "resolved": "10.0.2", - "contentHash": "zOIurr59+kUf9vNcsUkCvKWZv+fPosUZXURZesYkJCvl0EzTc9F7maAO4Cd2WEV7ZJJ0AZrFQvuH6Npph9wdBw==" + "resolved": "9.0.19", + "contentHash": "XgQ0aVWClYjYSBqbYrKC/xyZ1KHf+VVwNF3+d54jcaqnst30Q//ugs/FJwUztLZiph0gp85g7i/eB0556twO0Q==" }, "Microsoft.TestPlatform.ObjectModel": { "type": "Transitive", @@ -140,7 +140,7 @@ "Atm.Application.Abstractions": "[1.0.0, )", "Atm.Application.Contracts": "[1.0.0, )", "Atm.Domain": "[1.0.0, )", - "Microsoft.Extensions.DependencyInjection.Abstractions": "[10.0.2, )" + "Microsoft.Extensions.DependencyInjection.Abstractions": "[9.0.19, )" } }, "atm.application.abstractions": { From 0887c0b5a037740098c0d041d9796492f1bf7b9a Mon Sep 17 00:00:00 2001 From: 666mxvbee Date: Wed, 2 Sep 2026 20:13:21 +0300 Subject: [PATCH 02/11] refactor(domain): simplify validation and remove money ceiling --- src/Atm.Domain/Common/DomainRules.cs | 30 +++++++ src/Atm.Domain/ValueObjects/AccountNumber.cs | 16 ++-- src/Atm.Domain/ValueObjects/Amount.cs | 9 ++- src/Atm.Domain/ValueObjects/Money.cs | 21 +++-- src/Atm.Domain/ValueObjects/PinCode.cs | 16 +--- src/Atm.Domain/ValueObjects/UserLogin.cs | 20 +++++ .../Atm.Application.Tests/ValueObjectTests.cs | 80 +++++++++++++++++++ 7 files changed, 159 insertions(+), 33 deletions(-) create mode 100644 src/Atm.Domain/Common/DomainRules.cs create mode 100644 src/Atm.Domain/ValueObjects/UserLogin.cs create mode 100644 tests/Atm.Application.Tests/ValueObjectTests.cs diff --git a/src/Atm.Domain/Common/DomainRules.cs b/src/Atm.Domain/Common/DomainRules.cs new file mode 100644 index 0000000..e5ffa74 --- /dev/null +++ b/src/Atm.Domain/Common/DomainRules.cs @@ -0,0 +1,30 @@ +namespace Atm.Domain.Common; + +internal static class DomainRules +{ + public static bool IsUtc(DateTimeOffset value) + { + return value.Offset == TimeSpan.Zero; + } + + public static bool HasScale(decimal value, int scale) + { + return decimal.Round(value, scale) == value; + } + + public static decimal AddExact(decimal left, decimal right) + { + decimal result = checked(left + right); + if (result - left != right || result - right != left) + throw new OverflowException("The amount cannot be represented exactly as decimal."); + return result; + } + + public static decimal SubtractExact(decimal left, decimal right) + { + decimal result = checked(left - right); + if (left - result != right || result + right != left) + throw new OverflowException("The amount cannot be represented exactly as decimal."); + return result; + } +} diff --git a/src/Atm.Domain/ValueObjects/AccountNumber.cs b/src/Atm.Domain/ValueObjects/AccountNumber.cs index 2535538..3587f1c 100644 --- a/src/Atm.Domain/ValueObjects/AccountNumber.cs +++ b/src/Atm.Domain/ValueObjects/AccountNumber.cs @@ -1,16 +1,20 @@ -namespace Atm.Domain.ValueObjects; +namespace Atm.Domain.ValueObjects; public sealed class AccountNumber { public AccountNumber(string value) { - if (string.IsNullOrEmpty(value)) - { - throw new ArgumentException("Value cannot be null or empty.", nameof(value)); - } + if (string.IsNullOrWhiteSpace(value)) + throw new ArgumentException("Account number is required.", nameof(value)); Value = value.Trim(); + NormalizedValue = Value.ToUpperInvariant(); + + if (Value.Length > 64 || NormalizedValue.Length > 64) + throw new ArgumentException("Account number cannot exceed 64 characters.", nameof(value)); } public string Value { get; } -} \ No newline at end of file + + public string NormalizedValue { get; } +} diff --git a/src/Atm.Domain/ValueObjects/Amount.cs b/src/Atm.Domain/ValueObjects/Amount.cs index 166e4b6..a7f3fd2 100644 --- a/src/Atm.Domain/ValueObjects/Amount.cs +++ b/src/Atm.Domain/ValueObjects/Amount.cs @@ -1,13 +1,16 @@ -namespace Atm.Domain.ValueObjects; +using Atm.Domain.Common; + +namespace Atm.Domain.ValueObjects; public sealed class Amount { public Amount(decimal value) { - ArgumentOutOfRangeException.ThrowIfNegativeOrZero(value); + if (value <= 0m || !DomainRules.HasScale(value, 4)) + throw new ArgumentException("Amount must be positive with at most four decimal places.", nameof(value)); Value = value; } public decimal Value { get; } -} \ No newline at end of file +} diff --git a/src/Atm.Domain/ValueObjects/Money.cs b/src/Atm.Domain/ValueObjects/Money.cs index 6ac8e22..6095ea7 100644 --- a/src/Atm.Domain/ValueObjects/Money.cs +++ b/src/Atm.Domain/ValueObjects/Money.cs @@ -1,13 +1,13 @@ -namespace Atm.Domain.ValueObjects; +using Atm.Domain.Common; + +namespace Atm.Domain.ValueObjects; public sealed class Money { public Money(decimal value) { - if (value < 0) - { - throw new ArgumentOutOfRangeException(nameof(value), value, "Value must be greater than zero."); - } + if (value < 0m || !DomainRules.HasScale(value, 4)) + throw new ArgumentException("Money must be non-negative with at most four decimal places.", nameof(value)); Value = value; } @@ -16,16 +16,13 @@ public Money(decimal value) public Money Add(Amount amount) { - return new Money(Value + amount.Value); + return new Money(DomainRules.AddExact(Value, amount.Value)); } public Money Subtract(Amount amount) { if (Value < amount.Value) - { - throw new InvalidOperationException("Cannot subtract from non-overflow."); - } - - return new Money(Value - amount.Value); + throw new InvalidOperationException("Not enough balance."); + return new Money(DomainRules.SubtractExact(Value, amount.Value)); } -} \ No newline at end of file +} diff --git a/src/Atm.Domain/ValueObjects/PinCode.cs b/src/Atm.Domain/ValueObjects/PinCode.cs index 088b9de..62dcfa2 100644 --- a/src/Atm.Domain/ValueObjects/PinCode.cs +++ b/src/Atm.Domain/ValueObjects/PinCode.cs @@ -1,21 +1,13 @@ -namespace Atm.Domain.ValueObjects; +namespace Atm.Domain.ValueObjects; public sealed class PinCode { public PinCode(string value) { - if (value.Length != 4) - { - throw new ArgumentOutOfRangeException(nameof(value)); - } - - if (value.Any(c => !char.IsDigit(c))) - { - throw new ArgumentException("Pin code must contain only digits"); - } - + if (value is null || value.Length != 4 || value.Any(character => character is < '0' or > '9')) + throw new ArgumentException("PIN must contain exactly four ASCII digits.", nameof(value)); Value = value; } public string Value { get; } -} \ No newline at end of file +} diff --git a/src/Atm.Domain/ValueObjects/UserLogin.cs b/src/Atm.Domain/ValueObjects/UserLogin.cs new file mode 100644 index 0000000..3a90359 --- /dev/null +++ b/src/Atm.Domain/ValueObjects/UserLogin.cs @@ -0,0 +1,20 @@ +namespace Atm.Domain.ValueObjects; + +public sealed class UserLogin +{ + public UserLogin(string value) + { + if (string.IsNullOrWhiteSpace(value)) + throw new ArgumentException("Login is required.", nameof(value)); + + Value = value.Trim(); + NormalizedValue = Value.ToUpperInvariant(); + + if (Value.Length > 64 || NormalizedValue.Length > 64) + throw new ArgumentException("Login cannot exceed 64 characters.", nameof(value)); + } + + public string Value { get; } + + public string NormalizedValue { get; } +} diff --git a/tests/Atm.Application.Tests/ValueObjectTests.cs b/tests/Atm.Application.Tests/ValueObjectTests.cs new file mode 100644 index 0000000..8fc1dcb --- /dev/null +++ b/tests/Atm.Application.Tests/ValueObjectTests.cs @@ -0,0 +1,80 @@ +using Atm.Domain.ValueObjects; +using System.Globalization; + +namespace Atm.Application.Tests; + +public sealed class ValueObjectTests +{ + [Fact] + public void NormalizationIsCultureIndependent() + { + CultureInfo original = CultureInfo.CurrentCulture; + try + { + CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("tr-TR"); + var number = new AccountNumber(" idi-01 "); + var login = new UserLogin(" idi "); + Assert.Equal("idi-01", number.Value); + Assert.Equal("IDI-01", number.NormalizedValue); + Assert.Equal("IDI", login.NormalizedValue); + } + finally + { + CultureInfo.CurrentCulture = original; + } + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void EmptyIdentifiersAreRejected(string value) + { + Assert.Throws(() => new AccountNumber(value)); + Assert.Throws(() => new UserLogin(value)); + } + + [Fact] + public void LongIdentifiersAreRejected() + { + string value = new string('a', 65); + Assert.Throws(() => new AccountNumber(value)); + Assert.Throws(() => new UserLogin(value)); + } + + [Fact] + public void MoneyHasNoNumeric19BusinessCeiling() + { + var money = new Money(1000000000000000m); + Assert.Equal(1000000000000001m, money.Add(new Amount(1m)).Value); + Assert.Equal(decimal.MaxValue, new Money(decimal.MaxValue).Value); + } + + [Fact] + public void InvalidAmountsAndPrecisionAreRejected() + { + Assert.Throws(() => new Amount(0m)); + Assert.Throws(() => new Amount(-1m)); + Assert.Throws(() => new Amount(1.00001m)); + Assert.Throws(() => new Money(-1m)); + Assert.Throws(() => new Money(1.00001m)); + } + + [Fact] + public void ArithmeticDoesNotSilentlyLoseSmallAmounts() + { + var money = new Money(decimal.MaxValue); + Assert.Throws(() => money.Add(new Amount(1m))); + Assert.Throws(() => money.Add(new Amount(0.0001m))); + Assert.Throws(() => money.Subtract(new Amount(0.0001m))); + } + + [Theory] + [InlineData("123")] + [InlineData("abcd")] + [InlineData("1234")] + public void InvalidPinIsRejectedWithoutEchoingInput(string pin) + { + ArgumentException exception = Assert.Throws(() => new PinCode(pin)); + Assert.DoesNotContain(pin, exception.Message, StringComparison.Ordinal); + } +} From 2d15b97227093f4fbf313826e116bcdfe8df2982 Mon Sep 17 00:00:00 2001 From: 666mxvbee Date: Wed, 2 Sep 2026 20:20:10 +0300 Subject: [PATCH 03/11] feat(domain): add users and canonical friendships --- src/Atm.Domain/Users/Friendship.cs | 33 ++++++++ src/Atm.Domain/Users/HairColor.cs | 13 ++++ .../Users/Results/FriendshipCreationResult.cs | 10 +++ .../Users/Results/ProfileUpdateResult.cs | 10 +++ .../Users/Results/UserCreationResult.cs | 10 +++ src/Atm.Domain/Users/User.cs | 77 +++++++++++++++++++ src/Atm.Domain/Users/UserGender.cs | 9 +++ tests/Atm.Application.Tests/UserTests.cs | 75 ++++++++++++++++++ 8 files changed, 237 insertions(+) create mode 100644 src/Atm.Domain/Users/Friendship.cs create mode 100644 src/Atm.Domain/Users/HairColor.cs create mode 100644 src/Atm.Domain/Users/Results/FriendshipCreationResult.cs create mode 100644 src/Atm.Domain/Users/Results/ProfileUpdateResult.cs create mode 100644 src/Atm.Domain/Users/Results/UserCreationResult.cs create mode 100644 src/Atm.Domain/Users/User.cs create mode 100644 src/Atm.Domain/Users/UserGender.cs create mode 100644 tests/Atm.Application.Tests/UserTests.cs diff --git a/src/Atm.Domain/Users/Friendship.cs b/src/Atm.Domain/Users/Friendship.cs new file mode 100644 index 0000000..13b6c38 --- /dev/null +++ b/src/Atm.Domain/Users/Friendship.cs @@ -0,0 +1,33 @@ +using Atm.Domain.Common; +using Atm.Domain.Users.Results; + +namespace Atm.Domain.Users; + +public sealed class Friendship +{ + private Friendship() { } + + public Guid UserId { get; private set; } + public Guid FriendUserId { get; private set; } + public DateTimeOffset CreatedAtUtc { get; private set; } + + public static FriendshipCreationResult Create( + Guid firstUserId, Guid secondUserId, DateTimeOffset createdAtUtc) + { + if (firstUserId == Guid.Empty || secondUserId == Guid.Empty) + return new FriendshipCreationResult.Failure("User IDs cannot be empty."); + if (firstUserId == secondUserId) + return new FriendshipCreationResult.Failure("A user cannot befriend themselves."); + if (!DomainRules.IsUtc(createdAtUtc)) + return new FriendshipCreationResult.Failure("Created timestamp must be UTC."); + + // Canonical hexadecimal order matches UUID byte order, not Guid.ToByteArray's little-endian layout. + bool firstIsSmaller = string.CompareOrdinal(firstUserId.ToString("N"), secondUserId.ToString("N")) < 0; + return new FriendshipCreationResult.Success(new Friendship + { + UserId = firstIsSmaller ? firstUserId : secondUserId, + FriendUserId = firstIsSmaller ? secondUserId : firstUserId, + CreatedAtUtc = createdAtUtc, + }); + } +} diff --git a/src/Atm.Domain/Users/HairColor.cs b/src/Atm.Domain/Users/HairColor.cs new file mode 100644 index 0000000..4e08a88 --- /dev/null +++ b/src/Atm.Domain/Users/HairColor.cs @@ -0,0 +1,13 @@ +namespace Atm.Domain.Users; + +public enum HairColor +{ + Unspecified = 0, + Black = 1, + Brown = 2, + Blond = 3, + Red = 4, + Gray = 5, + White = 6, + Other = 7, +} diff --git a/src/Atm.Domain/Users/Results/FriendshipCreationResult.cs b/src/Atm.Domain/Users/Results/FriendshipCreationResult.cs new file mode 100644 index 0000000..e0d9ec9 --- /dev/null +++ b/src/Atm.Domain/Users/Results/FriendshipCreationResult.cs @@ -0,0 +1,10 @@ +namespace Atm.Domain.Users.Results; + +public abstract record FriendshipCreationResult +{ + private FriendshipCreationResult() { } + + public sealed record Success(Friendship Friendship) : FriendshipCreationResult; + + public sealed record Failure(string ErrorMessage) : FriendshipCreationResult; +} diff --git a/src/Atm.Domain/Users/Results/ProfileUpdateResult.cs b/src/Atm.Domain/Users/Results/ProfileUpdateResult.cs new file mode 100644 index 0000000..57fb3eb --- /dev/null +++ b/src/Atm.Domain/Users/Results/ProfileUpdateResult.cs @@ -0,0 +1,10 @@ +namespace Atm.Domain.Users.Results; + +public abstract record ProfileUpdateResult +{ + private ProfileUpdateResult() { } + + public sealed record Success : ProfileUpdateResult; + + public sealed record Failure(string ErrorMessage) : ProfileUpdateResult; +} diff --git a/src/Atm.Domain/Users/Results/UserCreationResult.cs b/src/Atm.Domain/Users/Results/UserCreationResult.cs new file mode 100644 index 0000000..346f398 --- /dev/null +++ b/src/Atm.Domain/Users/Results/UserCreationResult.cs @@ -0,0 +1,10 @@ +namespace Atm.Domain.Users.Results; + +public abstract record UserCreationResult +{ + private UserCreationResult() { } + + public sealed record Success(User User) : UserCreationResult; + + public sealed record Failure(string ErrorMessage) : UserCreationResult; +} diff --git a/src/Atm.Domain/Users/User.cs b/src/Atm.Domain/Users/User.cs new file mode 100644 index 0000000..28146dd --- /dev/null +++ b/src/Atm.Domain/Users/User.cs @@ -0,0 +1,77 @@ +using Atm.Domain.Common; +using Atm.Domain.Users.Results; +using Atm.Domain.ValueObjects; + +namespace Atm.Domain.Users; + +public sealed class User +{ + // Public construction is validated by Create; private setters also allow ORM materialization. + private User() { } + + public Guid Id { get; private set; } + public UserLogin Login { get; private set; } = null!; + public string Name { get; private set; } = null!; + public short Age { get; private set; } + public UserGender Gender { get; private set; } + public HairColor HairColor { get; private set; } + public DateTimeOffset CreatedAtUtc { get; private set; } + public DateTimeOffset UpdatedAtUtc { get; private set; } + + public static UserCreationResult Create( + Guid id, UserLogin? login, string? name, short age, + UserGender gender, HairColor hairColor, DateTimeOffset createdAtUtc) + { + if (id == Guid.Empty || login is null) + return new UserCreationResult.Failure("User ID and login are required."); + + string? error = ValidateProfile(name, age, gender, hairColor); + if (error is not null) + return new UserCreationResult.Failure(error); + + if (!DomainRules.IsUtc(createdAtUtc)) + return new UserCreationResult.Failure("Created timestamp must be UTC."); + + return new UserCreationResult.Success(new User + { + Id = id, + Login = login, + Name = name!.Trim(), + Age = age, + Gender = gender, + HairColor = hairColor, + CreatedAtUtc = createdAtUtc, + UpdatedAtUtc = createdAtUtc, + }); + } + + public ProfileUpdateResult UpdateProfile( + string? name, short age, UserGender gender, + HairColor hairColor, DateTimeOffset updatedAtUtc) + { + string? error = ValidateProfile(name, age, gender, hairColor); + if (error is not null) + return new ProfileUpdateResult.Failure(error); + + if (!DomainRules.IsUtc(updatedAtUtc) || updatedAtUtc < UpdatedAtUtc) + return new ProfileUpdateResult.Failure("Updated timestamp must be UTC and cannot move backwards."); + + Name = name!.Trim(); + Age = age; + Gender = gender; + HairColor = hairColor; + UpdatedAtUtc = updatedAtUtc; + return new ProfileUpdateResult.Success(); + } + + private static string? ValidateProfile(string? name, short age, UserGender gender, HairColor hairColor) + { + if (string.IsNullOrWhiteSpace(name) || name.Trim().Length > 200) + return "Name must be non-empty and cannot exceed 200 characters."; + if (age is < 0 or > 150) + return "Age must be between zero and 150."; + if (!Enum.IsDefined(gender) || !Enum.IsDefined(hairColor)) + return "Unsupported gender or hair color."; + return null; + } +} diff --git a/src/Atm.Domain/Users/UserGender.cs b/src/Atm.Domain/Users/UserGender.cs new file mode 100644 index 0000000..5e61048 --- /dev/null +++ b/src/Atm.Domain/Users/UserGender.cs @@ -0,0 +1,9 @@ +namespace Atm.Domain.Users; + +public enum UserGender +{ + Unspecified = 0, + Male = 1, + Female = 2, + Other = 3, +} diff --git a/tests/Atm.Application.Tests/UserTests.cs b/tests/Atm.Application.Tests/UserTests.cs new file mode 100644 index 0000000..2ee6957 --- /dev/null +++ b/tests/Atm.Application.Tests/UserTests.cs @@ -0,0 +1,75 @@ +using Atm.Domain.Users; +using Atm.Domain.Users.Results; +using Atm.Domain.ValueObjects; + +namespace Atm.Application.Tests; + +public sealed class UserTests +{ + private static readonly DateTimeOffset Now = new DateTimeOffset(2026, 9, 2, 12, 0, 0, TimeSpan.Zero); + + [Fact] + public void ValidProfileIsCreatedAndUpdated() + { + User user = CreateUser(); + Assert.Equal("Test User", user.Name); + Assert.IsType( + user.UpdateProfile(" Changed ", 30, UserGender.Other, HairColor.Black, Now.AddMinutes(1))); + Assert.Equal("Changed", user.Name); + Assert.Equal(Now.AddMinutes(1), user.UpdatedAtUtc); + Assert.Equal(Now, user.CreatedAtUtc); + } + + [Theory] + [InlineData(-1)] + [InlineData(151)] + public void InvalidProfileDoesNotPartiallyMutateState(short age) + { + User user = CreateUser(); + Assert.IsType( + user.UpdateProfile("Changed", age, UserGender.Other, HairColor.Black, Now.AddMinutes(1))); + Assert.Equal("Test User", user.Name); + Assert.Equal((short)25, user.Age); + Assert.Equal(HairColor.Unspecified, user.HairColor); + Assert.Equal(Now, user.UpdatedAtUtc); + } + + [Fact] + public void InvalidIdentifiersEnumsAndTimestampsAreRejected() + { + var login = new UserLogin("user"); + Assert.IsType( + User.Create(Guid.Empty, login, "Name", 25, UserGender.Unspecified, HairColor.Unspecified, Now)); + Assert.IsType( + User.Create(Guid.NewGuid(), login, "Name", 25, (UserGender)99, HairColor.Unspecified, Now)); + User user = CreateUser(); + Assert.IsType( + user.UpdateProfile("Name", 25, UserGender.Unspecified, HairColor.Unspecified, Now.AddSeconds(-1))); + Assert.IsType( + user.UpdateProfile("Name", 25, UserGender.Unspecified, HairColor.Unspecified, Now.ToOffset(TimeSpan.FromHours(3)))); + } + + [Fact] + public void FriendshipIsCanonicalAndRejectsSelfReference() + { + var first = Guid.Parse("00000100-0000-0000-0000-000000000001"); + var second = Guid.Parse("000000ff-ffff-ffff-ffff-ffffffffffff"); + Friendship forward = Assert.IsType( + Friendship.Create(first, second, Now)).Friendship; + Friendship reverse = Assert.IsType( + Friendship.Create(second, first, Now)).Friendship; + Assert.Equal(second, forward.UserId); + Assert.Equal(first, forward.FriendUserId); + Assert.Equal(forward.UserId, reverse.UserId); + Assert.Equal(forward.FriendUserId, reverse.FriendUserId); + Assert.IsType(Friendship.Create(first, first, Now)); + Assert.IsType(Friendship.Create(first, Guid.Empty, Now)); + } + + private static User CreateUser() + { + return Assert.IsType( + User.Create(Guid.NewGuid(), new UserLogin("user"), " Test User ", 25, + UserGender.Unspecified, HairColor.Unspecified, Now)).User; + } +} From 2bd666d17a3bb6f9aa92300e155391e47ee719ca Mon Sep 17 00:00:00 2001 From: 666mxvbee Date: Wed, 2 Sep 2026 20:23:25 +0300 Subject: [PATCH 04/11] feat(security): add pin hashing and injectable clock --- .../Security/IPinHasher.cs | 10 ++ .../Time/IClock.cs | 6 + src/Atm.Domain/ValueObjects/PinHash.cs | 16 ++ .../Atm.Infrastructure.csproj | 1 + src/Atm.Infrastructure/Security/PinHasher.cs | 29 ++++ .../ServiceCollectionExtensions.cs | 10 +- src/Atm.Infrastructure/Time/SystemClock.cs | 8 + src/Atm.Infrastructure/packages.lock.json | 24 +++ src/Atm.Presentation/packages.lock.json | 24 +++ .../Atm.Application.Tests.csproj | 1 + tests/Atm.Application.Tests/PinHasherTests.cs | 34 ++++ .../Atm.Application.Tests/packages.lock.json | 153 ++++++++++++++++++ 12 files changed, 314 insertions(+), 2 deletions(-) create mode 100644 src/Atm.Application.Abstractions/Security/IPinHasher.cs create mode 100644 src/Atm.Application.Abstractions/Time/IClock.cs create mode 100644 src/Atm.Domain/ValueObjects/PinHash.cs create mode 100644 src/Atm.Infrastructure/Security/PinHasher.cs create mode 100644 src/Atm.Infrastructure/Time/SystemClock.cs create mode 100644 tests/Atm.Application.Tests/PinHasherTests.cs diff --git a/src/Atm.Application.Abstractions/Security/IPinHasher.cs b/src/Atm.Application.Abstractions/Security/IPinHasher.cs new file mode 100644 index 0000000..0131331 --- /dev/null +++ b/src/Atm.Application.Abstractions/Security/IPinHasher.cs @@ -0,0 +1,10 @@ +using Atm.Domain.ValueObjects; + +namespace Atm.Application.Abstractions.Security; + +public interface IPinHasher +{ + PinHash Hash(PinCode pin); + + bool Verify(PinHash hash, PinCode pin); +} diff --git a/src/Atm.Application.Abstractions/Time/IClock.cs b/src/Atm.Application.Abstractions/Time/IClock.cs new file mode 100644 index 0000000..c9b9680 --- /dev/null +++ b/src/Atm.Application.Abstractions/Time/IClock.cs @@ -0,0 +1,6 @@ +namespace Atm.Application.Abstractions.Time; + +public interface IClock +{ + DateTimeOffset UtcNow { get; } +} diff --git a/src/Atm.Domain/ValueObjects/PinHash.cs b/src/Atm.Domain/ValueObjects/PinHash.cs new file mode 100644 index 0000000..b3674e0 --- /dev/null +++ b/src/Atm.Domain/ValueObjects/PinHash.cs @@ -0,0 +1,16 @@ +namespace Atm.Domain.ValueObjects; + +public sealed record PinHash +{ + public PinHash(string value) + { + if (string.IsNullOrWhiteSpace(value) || value.Length > 512) + throw new ArgumentException("PIN hash must contain between 1 and 512 characters.", nameof(value)); + + Value = value; + } + + public string Value { get; } + + public override string ToString() => "[REDACTED]"; +} diff --git a/src/Atm.Infrastructure/Atm.Infrastructure.csproj b/src/Atm.Infrastructure/Atm.Infrastructure.csproj index 6200b48..4e2e946 100644 --- a/src/Atm.Infrastructure/Atm.Infrastructure.csproj +++ b/src/Atm.Infrastructure/Atm.Infrastructure.csproj @@ -15,6 +15,7 @@ + diff --git a/src/Atm.Infrastructure/Security/PinHasher.cs b/src/Atm.Infrastructure/Security/PinHasher.cs new file mode 100644 index 0000000..8a85026 --- /dev/null +++ b/src/Atm.Infrastructure/Security/PinHasher.cs @@ -0,0 +1,29 @@ +using Atm.Application.Abstractions.Security; +using Atm.Domain.ValueObjects; +using Microsoft.AspNetCore.Identity; + +namespace Atm.Infrastructure.Security; + +public sealed class PinHasher : IPinHasher +{ + private readonly PasswordHasher _hasher = new(); + + public PinHash Hash(PinCode pin) + { + return new PinHash(_hasher.HashPassword(this, pin.Value)); + } + + public bool Verify(PinHash hash, PinCode pin) + { + try + { + // Both Success and SuccessRehashNeeded mean the PIN is valid. + return _hasher.VerifyHashedPassword(this, hash.Value, pin.Value) + != PasswordVerificationResult.Failed; + } + catch (FormatException) + { + return false; + } + } +} diff --git a/src/Atm.Infrastructure/ServiceCollectionExtensions.cs b/src/Atm.Infrastructure/ServiceCollectionExtensions.cs index afe63f4..0c2d7ba 100644 --- a/src/Atm.Infrastructure/ServiceCollectionExtensions.cs +++ b/src/Atm.Infrastructure/ServiceCollectionExtensions.cs @@ -1,5 +1,9 @@ -using Atm.Infrastructure.Repositories; using Atm.Application.Abstractions.Repositories; +using Atm.Application.Abstractions.Security; +using Atm.Application.Abstractions.Time; +using Atm.Infrastructure.Repositories; +using Atm.Infrastructure.Security; +using Atm.Infrastructure.Time; using Microsoft.Extensions.DependencyInjection; namespace Atm.Infrastructure; @@ -11,7 +15,9 @@ public static IServiceCollection AddInfrastructure(this IServiceCollection servi services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); return services; } -} \ No newline at end of file +} diff --git a/src/Atm.Infrastructure/Time/SystemClock.cs b/src/Atm.Infrastructure/Time/SystemClock.cs new file mode 100644 index 0000000..a06421a --- /dev/null +++ b/src/Atm.Infrastructure/Time/SystemClock.cs @@ -0,0 +1,8 @@ +using Atm.Application.Abstractions.Time; + +namespace Atm.Infrastructure.Time; + +public sealed class SystemClock : IClock +{ + public DateTimeOffset UtcNow => DateTimeOffset.UtcNow; +} diff --git a/src/Atm.Infrastructure/packages.lock.json b/src/Atm.Infrastructure/packages.lock.json index b27447b..a16a968 100644 --- a/src/Atm.Infrastructure/packages.lock.json +++ b/src/Atm.Infrastructure/packages.lock.json @@ -53,6 +53,17 @@ "resolved": "9.0.19", "contentHash": "XgQ0aVWClYjYSBqbYrKC/xyZ1KHf+VVwNF3+d54jcaqnst30Q//ugs/FJwUztLZiph0gp85g7i/eB0556twO0Q==" }, + "Microsoft.Extensions.Identity.Core": { + "type": "Direct", + "requested": "[9.0.19, )", + "resolved": "9.0.19", + "contentHash": "6P2/iMA35j8RrjsJXhqaOy4Sturcu+NXzXSlKguGTzjBEVRf6atWPrKkUuZew9K5S5NbNh8gdYip2dXm8t5fLg==", + "dependencies": { + "Microsoft.AspNetCore.Cryptography.KeyDerivation": "9.0.19", + "Microsoft.Extensions.Logging": "9.0.19", + "Microsoft.Extensions.Options": "9.0.19" + } + }, "Npgsql.EntityFrameworkCore.PostgreSQL": { "type": "Direct", "requested": "[9.0.4, )", @@ -69,6 +80,19 @@ "resolved": "2.14.1", "contentHash": "lQKvtaTDOXnoVJ20ibTuSIOf2i0uO0MPbDhd1jm238I+U/2ZnRENj0cktKZhtchBMtCUSRQ5v4xBCUbKNmyVMw==" }, + "Microsoft.AspNetCore.Cryptography.Internal": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "zqiWdZ8Jw4/sfTBSFHchU3Iajm8EqLPUq/cUIuUjARwNGJ9hrKR7Yf0bs4WlF+GdPX6Bj7kmaf99aY0OTbLCOg==" + }, + "Microsoft.AspNetCore.Cryptography.KeyDerivation": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "w0ZZWnsz7U2dh5l9eytJYYHG3/HwafewFyZT+NsnBuQAt90cw2hmiX9J9OU1mrtn7EiIJqhRxjzY5kAbmhD2DA==", + "dependencies": { + "Microsoft.AspNetCore.Cryptography.Internal": "9.0.19" + } + }, "Microsoft.Bcl.AsyncInterfaces": { "type": "Transitive", "resolved": "7.0.0", diff --git a/src/Atm.Presentation/packages.lock.json b/src/Atm.Presentation/packages.lock.json index dbf801e..013d951 100644 --- a/src/Atm.Presentation/packages.lock.json +++ b/src/Atm.Presentation/packages.lock.json @@ -14,6 +14,19 @@ "Swashbuckle.AspNetCore.SwaggerUI": "6.6.2" } }, + "Microsoft.AspNetCore.Cryptography.Internal": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "zqiWdZ8Jw4/sfTBSFHchU3Iajm8EqLPUq/cUIuUjARwNGJ9hrKR7Yf0bs4WlF+GdPX6Bj7kmaf99aY0OTbLCOg==" + }, + "Microsoft.AspNetCore.Cryptography.KeyDerivation": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "w0ZZWnsz7U2dh5l9eytJYYHG3/HwafewFyZT+NsnBuQAt90cw2hmiX9J9OU1mrtn7EiIJqhRxjzY5kAbmhD2DA==", + "dependencies": { + "Microsoft.AspNetCore.Cryptography.Internal": "9.0.19" + } + }, "Microsoft.EntityFrameworkCore": { "type": "Transitive", "resolved": "9.0.19", @@ -92,6 +105,16 @@ "resolved": "9.0.19", "contentHash": "XgQ0aVWClYjYSBqbYrKC/xyZ1KHf+VVwNF3+d54jcaqnst30Q//ugs/FJwUztLZiph0gp85g7i/eB0556twO0Q==" }, + "Microsoft.Extensions.Identity.Core": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "6P2/iMA35j8RrjsJXhqaOy4Sturcu+NXzXSlKguGTzjBEVRf6atWPrKkUuZew9K5S5NbNh8gdYip2dXm8t5fLg==", + "dependencies": { + "Microsoft.AspNetCore.Cryptography.KeyDerivation": "9.0.19", + "Microsoft.Extensions.Logging": "9.0.19", + "Microsoft.Extensions.Options": "9.0.19" + } + }, "Microsoft.Extensions.Logging": { "type": "Transitive", "resolved": "9.0.19", @@ -197,6 +220,7 @@ "Microsoft.EntityFrameworkCore": "[9.0.19, )", "Microsoft.EntityFrameworkCore.Relational": "[9.0.19, )", "Microsoft.Extensions.DependencyInjection.Abstractions": "[9.0.19, )", + "Microsoft.Extensions.Identity.Core": "[9.0.19, )", "Npgsql.EntityFrameworkCore.PostgreSQL": "[9.0.4, )" } } diff --git a/tests/Atm.Application.Tests/Atm.Application.Tests.csproj b/tests/Atm.Application.Tests/Atm.Application.Tests.csproj index 6ba4c8d..76d3475 100644 --- a/tests/Atm.Application.Tests/Atm.Application.Tests.csproj +++ b/tests/Atm.Application.Tests/Atm.Application.Tests.csproj @@ -21,6 +21,7 @@ + diff --git a/tests/Atm.Application.Tests/PinHasherTests.cs b/tests/Atm.Application.Tests/PinHasherTests.cs new file mode 100644 index 0000000..da10d6c --- /dev/null +++ b/tests/Atm.Application.Tests/PinHasherTests.cs @@ -0,0 +1,34 @@ +using Atm.Domain.ValueObjects; +using Atm.Infrastructure.Security; + +namespace Atm.Application.Tests; + +public class PinHasherTests +{ + [Fact] + public void HashUsesDifferentSaltsAndVerifiesThePin() + { + var hasher = new PinHasher(); + var pin = new PinCode("1234"); + + PinHash first = hasher.Hash(pin); + PinHash second = hasher.Hash(pin); + + Assert.NotEqual(pin.Value, first.Value); + Assert.NotEqual(first.Value, second.Value); + Assert.True(hasher.Verify(first, pin)); + Assert.True(hasher.Verify(second, pin)); + Assert.False(hasher.Verify(first, new PinCode("4321"))); + Assert.Equal("[REDACTED]", first.ToString()); + } + + [Theory] + [InlineData("not-base64")] + [InlineData("AA==")] + public void VerifyInvalidStoredHashReturnsFalse(string value) + { + var hasher = new PinHasher(); + + Assert.False(hasher.Verify(new PinHash(value), new PinCode("1234"))); + } +} diff --git a/tests/Atm.Application.Tests/packages.lock.json b/tests/Atm.Application.Tests/packages.lock.json index 93bcc28..3080a22 100644 --- a/tests/Atm.Application.Tests/packages.lock.json +++ b/tests/Atm.Application.Tests/packages.lock.json @@ -52,16 +52,139 @@ "System.Diagnostics.EventLog": "6.0.0" } }, + "Microsoft.AspNetCore.Cryptography.Internal": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "zqiWdZ8Jw4/sfTBSFHchU3Iajm8EqLPUq/cUIuUjARwNGJ9hrKR7Yf0bs4WlF+GdPX6Bj7kmaf99aY0OTbLCOg==" + }, + "Microsoft.AspNetCore.Cryptography.KeyDerivation": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "w0ZZWnsz7U2dh5l9eytJYYHG3/HwafewFyZT+NsnBuQAt90cw2hmiX9J9OU1mrtn7EiIJqhRxjzY5kAbmhD2DA==", + "dependencies": { + "Microsoft.AspNetCore.Cryptography.Internal": "9.0.19" + } + }, "Microsoft.CodeCoverage": { "type": "Transitive", "resolved": "17.12.0", "contentHash": "4svMznBd5JM21JIG2xZKGNanAHNXplxf/kQDFfLHXQ3OnpJkayRK/TjacFjA+EYmoyuNXHo/sOETEfcYtAzIrA==" }, + "Microsoft.EntityFrameworkCore": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "qWqqxrfEDfi3C/z0S/ACIA2gUnreRPHX74WE/dVnesbztNesYO4NLy76lE6FV/zDP4vH/pkvDr8h3Mq9+cFv/w==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Abstractions": "9.0.19", + "Microsoft.EntityFrameworkCore.Analyzers": "9.0.19", + "Microsoft.Extensions.Caching.Memory": "9.0.19", + "Microsoft.Extensions.Logging": "9.0.19" + } + }, + "Microsoft.EntityFrameworkCore.Abstractions": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "igPnmVL3DU2ZH/4DNeQqWttNrQpwwg0q6MWj5peZxtl0QVMDSeFtnt2yhhdSffklZKYsAFko1sh/va3z4y7S6A==" + }, + "Microsoft.EntityFrameworkCore.Analyzers": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "yI5dQuvigJ6ICQECOlJDtrsUYcuDryv3qsZK1i6ZA/IzMzZ/VPkDzjE4r2LivuyVSGnkDRkH2cp4LmrhXARRKw==" + }, + "Microsoft.EntityFrameworkCore.Relational": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "KAfYGG4LWRNn1G/vMRydxgOjuZoWUvdd5raUcIia/HPg1jUjlQzS386nUx5gt3YfKxpFJaCGxdsQ1nCZWuYR8A==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "9.0.19", + "Microsoft.Extensions.Caching.Memory": "9.0.19", + "Microsoft.Extensions.Configuration.Abstractions": "9.0.19", + "Microsoft.Extensions.Logging": "9.0.19" + } + }, + "Microsoft.Extensions.Caching.Abstractions": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "qe/6m6wxRG+N5xTf4Z3lDBAiVu6oBCUyADJ5OzlFgukD9Vg/6zB3MNnNEFA7amZg8hjcS6F4OZBRmB07A+gmHQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "9.0.19" + } + }, + "Microsoft.Extensions.Caching.Memory": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "Y0w5LkBqWkDh5vYTKM+QKlzxpqegEtP86oDhFMXgx4tDkWL2t+rl4h2vxBHkvBtM/gM6PoLK9QCrnoclXtHtVQ==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "9.0.19", + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.19", + "Microsoft.Extensions.Logging.Abstractions": "9.0.19", + "Microsoft.Extensions.Options": "9.0.19", + "Microsoft.Extensions.Primitives": "9.0.19" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "M6h5lX6QWSSn7FDeRUHePZen/xvxPsuhcX1VpBfefafqavn2BnfxheR7BH5D5AIZGM7Nah0xJKnq2DublD4doQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "9.0.19" + } + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "o/XFYSiV4a7eiCkf7W1GFAviVhHQAPY5bHrQRsNWAPT3GhJ3Af63vcFQjHivIrTNZITlPPaGWhBy7jykKYYyaQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.19" + } + }, "Microsoft.Extensions.DependencyInjection.Abstractions": { "type": "Transitive", "resolved": "9.0.19", "contentHash": "XgQ0aVWClYjYSBqbYrKC/xyZ1KHf+VVwNF3+d54jcaqnst30Q//ugs/FJwUztLZiph0gp85g7i/eB0556twO0Q==" }, + "Microsoft.Extensions.Identity.Core": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "6P2/iMA35j8RrjsJXhqaOy4Sturcu+NXzXSlKguGTzjBEVRf6atWPrKkUuZew9K5S5NbNh8gdYip2dXm8t5fLg==", + "dependencies": { + "Microsoft.AspNetCore.Cryptography.KeyDerivation": "9.0.19", + "Microsoft.Extensions.Logging": "9.0.19", + "Microsoft.Extensions.Options": "9.0.19" + } + }, + "Microsoft.Extensions.Logging": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "3cnWzJ/FLEhLiW+TJTWtTjVj6fwlsP8OrbJqgOP4J9feAKjmT77vmClakHlej+jnzBgpnVU0+SZwtEIOr+KS6A==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "9.0.19", + "Microsoft.Extensions.Logging.Abstractions": "9.0.19", + "Microsoft.Extensions.Options": "9.0.19" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "RpqMVoZ+NECJxLOZRjWOfjST0mULs4wb848mxG/CS9SMkVL6yi1sSIda4dGcsB01J2jUFd8hNwsaYl1gplU+Cw==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.19" + } + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "FdPZy4kUSwFlIVZ3EkK9fdHw2RfblUwzflSb4kjVeGf9v2lX9IH+q2RGUovYMR7CVfKMc2y2Db/bZ2b4yiMfOg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.19", + "Microsoft.Extensions.Primitives": "9.0.19" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "9hIg8PQiMnpVFIsEHm25Wi1gBrPa2pS1G75uveyDUVLXrNKqBap9WGwQIgO0s6LUgRAOdsSYnbKbNC5b1G5Pcg==" + }, "Microsoft.TestPlatform.ObjectModel": { "type": "Transitive", "resolved": "17.12.0", @@ -84,6 +207,24 @@ "resolved": "13.0.1", "contentHash": "ppPFpBcvxdsfUonNcvITKqLl3bqxWbDCZIzDWHzjpdAHRFfZe0Dw9HmA0+za13IdyrgJwpkDTDA9fHaxOrt20A==" }, + "Npgsql": { + "type": "Transitive", + "resolved": "9.0.3", + "contentHash": "tPvY61CxOAWxNsKLEBg+oR646X4Bc8UmyQ/tJszL/7mEmIXQnnBhVJZrZEEUv0Bstu0mEsHZD5At3EO8zQRAYw==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "8.0.2" + } + }, + "Npgsql.EntityFrameworkCore.PostgreSQL": { + "type": "Transitive", + "resolved": "9.0.4", + "contentHash": "mw5vcY2IEc7L+IeGrxpp/J5OSnCcjkjAgJYCm/eD52wpZze8zsSifdqV7zXslSMmfJG2iIUGZyo3KuDtEFKwMQ==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "[9.0.1, 10.0.0)", + "Microsoft.EntityFrameworkCore.Relational": "[9.0.1, 10.0.0)", + "Npgsql": "9.0.3" + } + }, "System.Diagnostics.EventLog": { "type": "Transitive", "resolved": "6.0.0", @@ -154,6 +295,18 @@ }, "atm.domain": { "type": "Project" + }, + "atm.infrastructure": { + "type": "Project", + "dependencies": { + "Atm.Application.Abstractions": "[1.0.0, )", + "Atm.Domain": "[1.0.0, )", + "Microsoft.EntityFrameworkCore": "[9.0.19, )", + "Microsoft.EntityFrameworkCore.Relational": "[9.0.19, )", + "Microsoft.Extensions.DependencyInjection.Abstractions": "[9.0.19, )", + "Microsoft.Extensions.Identity.Core": "[9.0.19, )", + "Npgsql.EntityFrameworkCore.PostgreSQL": "[9.0.4, )" + } } } } From 8d1c7773d0972a04ae9a26894a87599bfb4f5dd5 Mon Sep 17 00:00:00 2001 From: 666mxvbee Date: Wed, 2 Sep 2026 20:26:00 +0300 Subject: [PATCH 05/11] feat(accounts): add owners hashes and balance versions --- .../Repositories/IAccountRepository.cs | 6 +- .../CreateAccount/CreateAccountRequest.cs | 5 +- .../Accounts/CreateAccountUseCase.cs | 62 +++++++---- .../Operations/DepositUseCase.cs | 13 ++- .../Operations/WithdrawUseCase.cs | 13 ++- .../Sessions/CreateUserSessionUseCase.cs | 13 ++- src/Atm.Domain/Accounts/Account.cs | 104 +++++++++++++++--- .../Accounts/Results/AccountCreationResult.cs | 10 ++ .../Repositories/AccountRepository.cs | 18 ++- src/Atm.Presentation/Contracts/ApiRequests.cs | 3 +- .../Controllers/AccountsController.cs | 9 +- tests/Atm.Application.Tests/AccountTests.cs | 63 +++++++++++ .../Atm.Application.Tests/BankingTestData.cs | 15 +++ .../DepositUseCaseTests.cs | 8 +- tests/Atm.Application.Tests/FixedClock.cs | 8 ++ .../WithdrawCaseTests.cs | 12 +- 16 files changed, 281 insertions(+), 81 deletions(-) create mode 100644 src/Atm.Domain/Accounts/Results/AccountCreationResult.cs create mode 100644 tests/Atm.Application.Tests/AccountTests.cs create mode 100644 tests/Atm.Application.Tests/BankingTestData.cs create mode 100644 tests/Atm.Application.Tests/FixedClock.cs diff --git a/src/Atm.Application.Abstractions/Repositories/IAccountRepository.cs b/src/Atm.Application.Abstractions/Repositories/IAccountRepository.cs index 25bc8b8..4770a4b 100644 --- a/src/Atm.Application.Abstractions/Repositories/IAccountRepository.cs +++ b/src/Atm.Application.Abstractions/Repositories/IAccountRepository.cs @@ -1,13 +1,15 @@ -using Atm.Domain.Accounts; +using Atm.Domain.Accounts; using Atm.Domain.ValueObjects; namespace Atm.Application.Abstractions.Repositories; public interface IAccountRepository { + Account? GetById(Guid id); + Account? GetByNumber(AccountNumber accountNumber); void Add(Account account); void Update(Account account); -} \ No newline at end of file +} diff --git a/src/Atm.Application.Contracts/Accounts/CreateAccount/CreateAccountRequest.cs b/src/Atm.Application.Contracts/Accounts/CreateAccount/CreateAccountRequest.cs index 5df84dc..60b1fac 100644 --- a/src/Atm.Application.Contracts/Accounts/CreateAccount/CreateAccountRequest.cs +++ b/src/Atm.Application.Contracts/Accounts/CreateAccount/CreateAccountRequest.cs @@ -1,7 +1,8 @@ -namespace Atm.Application.Contracts.Accounts.CreateAccount; +namespace Atm.Application.Contracts.Accounts.CreateAccount; public sealed record CreateAccountRequest( Guid AdminSessionId, string AccountNumber, string Pin, - decimal InitialBalance); \ No newline at end of file + decimal InitialBalance, + Guid OwnerUserId); diff --git a/src/Atm.Application/Accounts/CreateAccountUseCase.cs b/src/Atm.Application/Accounts/CreateAccountUseCase.cs index 4fe3ce3..ceeab30 100644 --- a/src/Atm.Application/Accounts/CreateAccountUseCase.cs +++ b/src/Atm.Application/Accounts/CreateAccountUseCase.cs @@ -1,7 +1,10 @@ -using Atm.Application.Abstractions.Repositories; +using Atm.Application.Abstractions.Repositories; +using Atm.Application.Abstractions.Security; +using Atm.Application.Abstractions.Time; using Atm.Application.Common; using Atm.Application.Contracts.Accounts.CreateAccount; using Atm.Domain.Accounts; +using Atm.Domain.Accounts.Results; using Atm.Domain.ValueObjects; namespace Atm.Application.Accounts; @@ -10,43 +13,54 @@ internal sealed class CreateAccountUseCase : ICreateAccountPort { private readonly IAccountRepository _accounts; private readonly SessionAuthorizer _auth; + private readonly IPinHasher _pinHasher; + private readonly IClock _clock; - public CreateAccountUseCase(IAccountRepository accounts, ISessionRepository sessions) + public CreateAccountUseCase( + IAccountRepository accounts, + SessionAuthorizer auth, + IPinHasher pinHasher, + IClock clock) { _accounts = accounts; - _auth = new SessionAuthorizer(sessions); + _auth = auth; + _pinHasher = pinHasher; + _clock = clock; } public CreateAccountResult Execute(CreateAccountRequest request) { AuthorizationResult auth = _auth.AuthorizeAdmin(request.AdminSessionId); + if (auth is AuthorizationResult.Unauthorized unauthorized) + return new CreateAccountResult.Unauthorized(unauthorized.ErrorMessage); - if (auth is AuthorizationResult.Unauthorized u) + AccountNumber number; + PinCode pin; + Money balance; + try { - return new CreateAccountResult.Unauthorized(u.ErrorMessage); + number = new AccountNumber(request.AccountNumber); + pin = new PinCode(request.Pin); + balance = new Money(request.InitialBalance); } - - try + catch (ArgumentException exception) { - var number = new AccountNumber(request.AccountNumber); - var pin = new PinCode(request.Pin); - var balance = new Money(request.InitialBalance); + return new CreateAccountResult.Failure(exception.Message); + } - Account? existing = _accounts.GetByNumber(number); + if (request.OwnerUserId == Guid.Empty) + return new CreateAccountResult.Failure("Owner ID must not be empty."); + if (_accounts.GetByNumber(number) is not null) + return new CreateAccountResult.Failure("Account number already exists."); - if (existing is not null) - { - return new CreateAccountResult.Failure("Account number already exists"); - } + AccountCreationResult result = Account.Create( + Guid.NewGuid(), number, request.OwnerUserId, + _pinHasher.Hash(pin), balance, _clock.UtcNow); - var account = new Account(number, pin, balance); - _accounts.Add(account); + if (result is AccountCreationResult.Failure failure) + return new CreateAccountResult.Failure(failure.ErrorMessage); - return new CreateAccountResult.Success(); - } - catch (Exception e) - { - return new CreateAccountResult.Failure(e.Message); - } + _accounts.Add(((AccountCreationResult.Success)result).Account); + return new CreateAccountResult.Success(); } -} \ No newline at end of file +} diff --git a/src/Atm.Application/Operations/DepositUseCase.cs b/src/Atm.Application/Operations/DepositUseCase.cs index 47273db..50bf22b 100644 --- a/src/Atm.Application/Operations/DepositUseCase.cs +++ b/src/Atm.Application/Operations/DepositUseCase.cs @@ -1,4 +1,5 @@ -using Atm.Application.Abstractions.Repositories; +using Atm.Application.Abstractions.Repositories; +using Atm.Application.Abstractions.Time; using Atm.Application.Common; using Atm.Application.Contracts.Operations.Deposit; using Atm.Domain.Operations; @@ -11,12 +12,14 @@ public sealed class DepositUseCase : IDepositPort private readonly IAccountRepository _accounts; private readonly IOperationRepository _operations; private readonly SessionAuthorizer _auth; + private readonly IClock _clock; - public DepositUseCase(IAccountRepository accounts, IOperationRepository operations, SessionAuthorizer auth) + public DepositUseCase(IAccountRepository accounts, IOperationRepository operations, SessionAuthorizer auth, IClock clock) { _accounts = accounts; _operations = operations; _auth = auth; + _clock = clock; } public DepositResult Execute(DepositRequest request) @@ -41,12 +44,12 @@ public DepositResult Execute(DepositRequest request) { amount = new Amount(request.Amount); } - catch (Exception e) + catch (ArgumentException e) { return new DepositResult.Failure(e.Message); } - Domain.Accounts.Results.DepositResult domainResult = acc.Deposit(amount); + Domain.Accounts.Results.DepositResult domainResult = acc.Deposit(amount, _clock.UtcNow); if (domainResult is Domain.Accounts.Results.DepositResult.Failure f) { @@ -58,4 +61,4 @@ public DepositResult Execute(DepositRequest request) return new DepositResult.Success(acc.Balance.Value); } -} \ No newline at end of file +} diff --git a/src/Atm.Application/Operations/WithdrawUseCase.cs b/src/Atm.Application/Operations/WithdrawUseCase.cs index da1c2b9..a2158d5 100644 --- a/src/Atm.Application/Operations/WithdrawUseCase.cs +++ b/src/Atm.Application/Operations/WithdrawUseCase.cs @@ -1,4 +1,5 @@ -using Atm.Application.Abstractions.Repositories; +using Atm.Application.Abstractions.Repositories; +using Atm.Application.Abstractions.Time; using Atm.Application.Common; using Atm.Application.Contracts.Operations.Withdraw; using Atm.Domain.Operations; @@ -11,12 +12,14 @@ public sealed class WithdrawUseCase : IWithdrawPort private readonly IAccountRepository _accounts; private readonly IOperationRepository _operations; private readonly SessionAuthorizer _auth; + private readonly IClock _clock; - public WithdrawUseCase(IAccountRepository accounts, IOperationRepository operations, SessionAuthorizer auth) + public WithdrawUseCase(IAccountRepository accounts, IOperationRepository operations, SessionAuthorizer auth, IClock clock) { _accounts = accounts; _operations = operations; _auth = auth; + _clock = clock; } public WithdrawResult Execute(WithdrawRequest request) @@ -41,12 +44,12 @@ public WithdrawResult Execute(WithdrawRequest request) { amount = new Amount(request.Amount); } - catch (Exception e) + catch (ArgumentException e) { return new WithdrawResult.Failure(e.Message); } - Domain.Accounts.Results.WithdrawResult domainResult = acc.Withdraw(amount); + Domain.Accounts.Results.WithdrawResult domainResult = acc.Withdraw(amount, _clock.UtcNow); if (domainResult is Domain.Accounts.Results.WithdrawResult.Failure result) { return new WithdrawResult.Failure(result.ErrorMessage); @@ -57,4 +60,4 @@ public WithdrawResult Execute(WithdrawRequest request) return new WithdrawResult.Success(acc.Balance.Value); } -} \ No newline at end of file +} diff --git a/src/Atm.Application/Sessions/CreateUserSessionUseCase.cs b/src/Atm.Application/Sessions/CreateUserSessionUseCase.cs index 6712e64..049dc05 100644 --- a/src/Atm.Application/Sessions/CreateUserSessionUseCase.cs +++ b/src/Atm.Application/Sessions/CreateUserSessionUseCase.cs @@ -1,4 +1,5 @@ -using Atm.Application.Abstractions.Repositories; +using Atm.Application.Abstractions.Repositories; +using Atm.Application.Abstractions.Security; using Atm.Application.Contracts.Sessions.CreateUserSession; using Atm.Domain.Sessions; using Atm.Domain.ValueObjects; @@ -9,11 +10,13 @@ internal sealed class CreateUserSessionUseCase : ICreateUserSessionPort { private readonly ISessionRepository _sessions; private readonly IAccountRepository _accounts; + private readonly IPinHasher _pinHasher; - public CreateUserSessionUseCase(ISessionRepository sessions, IAccountRepository accounts) + public CreateUserSessionUseCase(ISessionRepository sessions, IAccountRepository accounts, IPinHasher pinHasher) { _sessions = sessions; _accounts = accounts; + _pinHasher = pinHasher; } public CreateUserSessionResult Execute(CreateUserSessionRequest request) @@ -24,7 +27,7 @@ public CreateUserSessionResult Execute(CreateUserSessionRequest request) var pin = new PinCode(request.Pin); Domain.Accounts.Account? acc = _accounts.GetByNumber(number); - if (acc is null || acc.Pin.Value != pin.Value) + if (acc is null || !_pinHasher.Verify(acc.PinHash, pin)) { return new CreateUserSessionResult.Unauthorized("Invalid credentials"); } @@ -36,9 +39,9 @@ public CreateUserSessionResult Execute(CreateUserSessionRequest request) return new CreateUserSessionResult.Success(id); } - catch (Exception ex) + catch (ArgumentException ex) { return new CreateUserSessionResult.Failure(ex.Message); } } -} \ No newline at end of file +} diff --git a/src/Atm.Domain/Accounts/Account.cs b/src/Atm.Domain/Accounts/Account.cs index 0fba7e4..120defa 100644 --- a/src/Atm.Domain/Accounts/Account.cs +++ b/src/Atm.Domain/Accounts/Account.cs @@ -1,37 +1,107 @@ -using Atm.Domain.Accounts.Results; +using Atm.Domain.Accounts.Results; +using Atm.Domain.Common; using Atm.Domain.ValueObjects; namespace Atm.Domain.Accounts; public sealed class Account { - public Account(AccountNumber number, PinCode pin, Money balance) - { - Number = number; - Pin = pin; - Balance = balance; - } + private Account() { } - public AccountNumber Number { get; } + public Guid Id { get; private set; } + public AccountNumber Number { get; private set; } = null!; + public Guid OwnerUserId { get; private set; } + public PinHash PinHash { get; private set; } = null!; + public Money Balance { get; private set; } = null!; + public long Version { get; private set; } + public DateTimeOffset CreatedAtUtc { get; private set; } + public DateTimeOffset UpdatedAtUtc { get; private set; } - public PinCode Pin { get; } + public static AccountCreationResult Create( + Guid id, + AccountNumber? number, + Guid ownerUserId, + PinHash? pinHash, + Money? balance, + DateTimeOffset createdAtUtc) + { + if (id == Guid.Empty || ownerUserId == Guid.Empty) + return new AccountCreationResult.Failure("Account and owner IDs must not be empty."); + if (number is null || pinHash is null || balance is null) + return new AccountCreationResult.Failure("Number, PIN hash and balance are required."); + if (!DomainRules.IsUtc(createdAtUtc)) + return new AccountCreationResult.Failure("Account timestamps must be UTC."); - public Money Balance { get; private set; } + return new AccountCreationResult.Success(new Account + { + Id = id, + Number = number, + OwnerUserId = ownerUserId, + PinHash = pinHash, + Balance = balance, + Version = 0, + CreatedAtUtc = createdAtUtc, + UpdatedAtUtc = createdAtUtc, + }); + } - public DepositResult Deposit(Amount amount) + public DepositResult Deposit(Amount? amount, DateTimeOffset updatedAtUtc) { - Balance = new Money(Balance.Value + amount.Value); + string? error = ValidateChange(amount, updatedAtUtc); + if (error is not null) + return new DepositResult.Failure(error); + + Money balance; + try + { + balance = Balance.Add(amount!); + } + catch (OverflowException) + { + return new DepositResult.Failure("The balance cannot be represented exactly as decimal."); + } + + ApplyBalance(balance, updatedAtUtc); return new DepositResult.Success(Balance); } - public WithdrawResult Withdraw(Amount amount) + public WithdrawResult Withdraw(Amount? amount, DateTimeOffset updatedAtUtc) { - if (Balance.Value < amount.Value) + string? error = ValidateChange(amount, updatedAtUtc); + if (error is not null) + return new WithdrawResult.Failure(error); + if (Balance.Value < amount!.Value) + return new WithdrawResult.Failure("Not enough balance."); + + Money balance; + try + { + balance = Balance.Subtract(amount); + } + catch (OverflowException) { - return new WithdrawResult.Failure("Not enough balance"); + return new WithdrawResult.Failure("The balance cannot be represented exactly as decimal."); } - Balance = new Money(Balance.Value - amount.Value); + ApplyBalance(balance, updatedAtUtc); return new WithdrawResult.Success(Balance); } -} \ No newline at end of file + + private string? ValidateChange(Amount? amount, DateTimeOffset updatedAtUtc) + { + if (amount is null) + return "Amount is required."; + if (!DomainRules.IsUtc(updatedAtUtc) || updatedAtUtc < UpdatedAtUtc) + return "Update time must be UTC and must not precede the previous update."; + if (Version == long.MaxValue) + return "Account version is exhausted."; + return null; + } + + private void ApplyBalance(Money balance, DateTimeOffset updatedAtUtc) + { + Balance = balance; + Version++; + UpdatedAtUtc = updatedAtUtc; + } +} diff --git a/src/Atm.Domain/Accounts/Results/AccountCreationResult.cs b/src/Atm.Domain/Accounts/Results/AccountCreationResult.cs new file mode 100644 index 0000000..e23b0c9 --- /dev/null +++ b/src/Atm.Domain/Accounts/Results/AccountCreationResult.cs @@ -0,0 +1,10 @@ +namespace Atm.Domain.Accounts.Results; + +public abstract record AccountCreationResult +{ + private AccountCreationResult() { } + + public sealed record Success(Account Account) : AccountCreationResult; + + public sealed record Failure(string ErrorMessage) : AccountCreationResult; +} diff --git a/src/Atm.Infrastructure/Repositories/AccountRepository.cs b/src/Atm.Infrastructure/Repositories/AccountRepository.cs index 44b8616..8a7f0e0 100644 --- a/src/Atm.Infrastructure/Repositories/AccountRepository.cs +++ b/src/Atm.Infrastructure/Repositories/AccountRepository.cs @@ -1,4 +1,4 @@ -using Atm.Application.Abstractions.Repositories; +using Atm.Application.Abstractions.Repositories; using Atm.Domain.Accounts; using Atm.Domain.ValueObjects; @@ -6,20 +6,26 @@ namespace Atm.Infrastructure.Repositories; public sealed class AccountRepository : IAccountRepository { - private readonly Dictionary _accounts = new(); + private readonly Dictionary _accounts = new(); + + public Account? GetById(Guid id) + { + return _accounts.GetValueOrDefault(id); + } public Account? GetByNumber(AccountNumber accountNumber) { - return _accounts.GetValueOrDefault(accountNumber.Value); + return _accounts.Values.FirstOrDefault( + account => account.Number.NormalizedValue == accountNumber.NormalizedValue); } public void Add(Account account) { - _accounts[account.Number.Value] = account; + _accounts.Add(account.Id, account); } public void Update(Account account) { - _accounts[account.Number.Value] = account; + _accounts[account.Id] = account; } -} \ No newline at end of file +} diff --git a/src/Atm.Presentation/Contracts/ApiRequests.cs b/src/Atm.Presentation/Contracts/ApiRequests.cs index ce79d66..cb4f71e 100644 --- a/src/Atm.Presentation/Contracts/ApiRequests.cs +++ b/src/Atm.Presentation/Contracts/ApiRequests.cs @@ -6,7 +6,8 @@ public sealed record CreateAccountApiRequest( [property: JsonPropertyName("adminSessionId")] Guid AdminSessionId, [property: JsonPropertyName("accountNumber")] string AccountNumber, [property: JsonPropertyName("pin")] string Pin, - [property: JsonPropertyName("initialBalance")] decimal InitialBalance); + [property: JsonPropertyName("initialBalance")] decimal InitialBalance, + [property: JsonPropertyName("ownerUserId")] Guid OwnerUserId); public sealed record AmountApiRequest( [property: JsonPropertyName("userSessionId")] Guid UserSessionId, diff --git a/src/Atm.Presentation/Controllers/AccountsController.cs b/src/Atm.Presentation/Controllers/AccountsController.cs index debec63..2b550f9 100644 --- a/src/Atm.Presentation/Controllers/AccountsController.cs +++ b/src/Atm.Presentation/Controllers/AccountsController.cs @@ -1,4 +1,4 @@ -using Atm.Application.Contracts.Accounts.CreateAccount; +using Atm.Application.Contracts.Accounts.CreateAccount; using Atm.Application.Contracts.Accounts.GetBalance; using Atm.Application.Contracts.Operations.Deposit; using Atm.Application.Contracts.Operations.GetTransactionHistory; @@ -43,7 +43,8 @@ public IActionResult Create([FromBody] CreateAccountApiRequest request) request.AdminSessionId, request.AccountNumber, request.Pin, - request.InitialBalance)); + request.InitialBalance, + request.OwnerUserId)); return result switch { @@ -62,7 +63,7 @@ public IActionResult Balance([FromQuery(Name = "sessionId")] Guid userSessionId) { GetBalanceResult result = _getBalance.Execute( new GetBalanceRequest(userSessionId)); - + return result switch { GetBalanceResult.Success s => Ok(new BalanceResponse(s.Balance)), @@ -116,7 +117,7 @@ public IActionResult History([FromQuery(Name = "sessionId")] Guid userSessionId) { GetTransactionHistoryResult result = _history.Execute( new GetTransactionHistoryRequest(userSessionId)); - + return result switch { GetTransactionHistoryResult.Success s => Ok(new TransactionsResponse(s.Transactions)), diff --git a/tests/Atm.Application.Tests/AccountTests.cs b/tests/Atm.Application.Tests/AccountTests.cs new file mode 100644 index 0000000..c9e2a0c --- /dev/null +++ b/tests/Atm.Application.Tests/AccountTests.cs @@ -0,0 +1,63 @@ +using Atm.Domain.Accounts; +using Atm.Domain.Accounts.Results; +using Atm.Domain.ValueObjects; + +namespace Atm.Application.Tests; + +public class AccountTests +{ + [Fact] + public void CreateRequiresAnOwnerAndUtcTime() + { + var number = new AccountNumber("test"); + var hash = new PinHash("test-only-hash"); + var clock = new FixedClock(); + + Assert.IsType(Account.Create( + Guid.NewGuid(), number, Guid.Empty, hash, new Money(0m), clock.UtcNow)); + Assert.IsType(Account.Create( + Guid.NewGuid(), number, Guid.NewGuid(), hash, new Money(0m), clock.UtcNow.ToOffset(TimeSpan.FromHours(3)))); + } + + [Fact] + public void SuccessfulChangesAdvanceTheVersionAndTimestamp() + { + Account account = BankingTestData.CreateAccount(new AccountNumber("test"), 100m); + DateTimeOffset now = account.CreatedAtUtc.AddMinutes(1); + + Assert.IsType(account.Deposit(new Amount(10m), now)); + Assert.Equal(1, account.Version); + Assert.Equal(now, account.UpdatedAtUtc); + Assert.IsType(account.Withdraw(new Amount(20m), now.AddMinutes(1))); + Assert.Equal(2, account.Version); + Assert.Equal(90m, account.Balance.Value); + } + + [Fact] + public void FailedChangesDoNotMutateTheAccount() + { + Account account = BankingTestData.CreateAccount(new AccountNumber("test"), 100m); + DateTimeOffset now = account.CreatedAtUtc; + + Assert.IsType(account.Withdraw(new Amount(101m), now)); + Assert.IsType(account.Deposit(new Amount(1m), now.AddTicks(-1))); + Assert.Equal(100m, account.Balance.Value); + Assert.Equal(0, account.Version); + Assert.Equal(now, account.UpdatedAtUtc); + } + + [Fact] + public void LargeBalancesHaveNoBusinessCeilingButCannotLosePrecision() + { + Account account = BankingTestData.CreateAccount(new AccountNumber("large"), 1000000000000000m); + Assert.IsType(account.Deposit(new Amount(1m), account.CreatedAtUtc)); + Assert.Equal(1000000000000001m, account.Balance.Value); + + Account largest = BankingTestData.CreateAccount(new AccountNumber("largest"), decimal.MaxValue); + Assert.IsType(largest.Deposit(new Amount(1m), largest.CreatedAtUtc)); + Assert.IsType(largest.Deposit(new Amount(0.0001m), largest.CreatedAtUtc)); + Assert.IsType(largest.Withdraw(new Amount(0.0001m), largest.CreatedAtUtc)); + Assert.Equal(decimal.MaxValue, largest.Balance.Value); + Assert.Equal(0, largest.Version); + } +} diff --git a/tests/Atm.Application.Tests/BankingTestData.cs b/tests/Atm.Application.Tests/BankingTestData.cs new file mode 100644 index 0000000..8bda6a6 --- /dev/null +++ b/tests/Atm.Application.Tests/BankingTestData.cs @@ -0,0 +1,15 @@ +using Atm.Domain.Accounts; +using Atm.Domain.Accounts.Results; +using Atm.Domain.ValueObjects; + +namespace Atm.Application.Tests; + +internal static class BankingTestData +{ + public static Account CreateAccount(AccountNumber number, decimal balance) + { + return Assert.IsType(Account.Create( + Guid.NewGuid(), number, Guid.NewGuid(), + new PinHash("test-only-hash"), new Money(balance), new FixedClock().UtcNow)).Account; + } +} diff --git a/tests/Atm.Application.Tests/DepositUseCaseTests.cs b/tests/Atm.Application.Tests/DepositUseCaseTests.cs index 565bb54..7d43cac 100644 --- a/tests/Atm.Application.Tests/DepositUseCaseTests.cs +++ b/tests/Atm.Application.Tests/DepositUseCaseTests.cs @@ -1,4 +1,4 @@ -using Atm.Application.Abstractions.Repositories; +using Atm.Application.Abstractions.Repositories; using Atm.Application.Common; using Atm.Application.Contracts.Operations.Deposit; using Atm.Application.Operations; @@ -24,11 +24,11 @@ public void DepositWhenValidIncreaseBalanceAndSavesOperation() sessions.GetById(sessionId).Returns(new Session(sessionId, SessionType.User, accountNumber)); - var account = new Account(accountNumber, new PinCode("1234"), new Money(100m)); + Account account = BankingTestData.CreateAccount(accountNumber, 100m); accounts.GetByNumber(accountNumber).Returns(account); var auth = new SessionAuthorizer(sessions); - var useCase = new DepositUseCase(accounts, operations, auth); + var useCase = new DepositUseCase(accounts, operations, auth, new FixedClock()); DepositResult result = useCase.Execute(new DepositRequest(sessionId, 50m)); @@ -43,4 +43,4 @@ public void DepositWhenValidIncreaseBalanceAndSavesOperation() op.Amount.Value == 50m && op.BalanceAfter.Value == 150m)); } -} \ No newline at end of file +} diff --git a/tests/Atm.Application.Tests/FixedClock.cs b/tests/Atm.Application.Tests/FixedClock.cs new file mode 100644 index 0000000..af39324 --- /dev/null +++ b/tests/Atm.Application.Tests/FixedClock.cs @@ -0,0 +1,8 @@ +using Atm.Application.Abstractions.Time; + +namespace Atm.Application.Tests; + +internal sealed class FixedClock : IClock +{ + public DateTimeOffset UtcNow { get; set; } = new(2026, 9, 2, 12, 0, 0, TimeSpan.Zero); +} diff --git a/tests/Atm.Application.Tests/WithdrawCaseTests.cs b/tests/Atm.Application.Tests/WithdrawCaseTests.cs index 0b53806..09a5015 100644 --- a/tests/Atm.Application.Tests/WithdrawCaseTests.cs +++ b/tests/Atm.Application.Tests/WithdrawCaseTests.cs @@ -1,4 +1,4 @@ -using Atm.Application.Abstractions.Repositories; +using Atm.Application.Abstractions.Repositories; using Atm.Application.Common; using Atm.Application.Contracts.Operations.Withdraw; using Atm.Application.Operations; @@ -24,11 +24,11 @@ public void WithdrawWhenEnoughBalanceDecreasesBalanceAndSavesOperation() sessions.GetById(sessionId).Returns(new Session(sessionId, SessionType.User, accountNumber)); - var account = new Account(accountNumber, new PinCode("6666"), new Money(100m)); + Account account = BankingTestData.CreateAccount(accountNumber, 100m); accounts.GetByNumber(accountNumber).Returns(account); var auth = new SessionAuthorizer(sessions); - var useCase = new WithdrawUseCase(accounts, operations, auth); + var useCase = new WithdrawUseCase(accounts, operations, auth, new FixedClock()); WithdrawResult result = useCase.Execute(new WithdrawRequest(sessionId, 40m)); @@ -56,11 +56,11 @@ public void WithdrawWhenNotEnoughBalanceReturnsFailureAndDoesNotSave() sessions.GetById(sessionId).Returns(new Session(sessionId, SessionType.User, accountNumber)); - var account = new Account(accountNumber, new PinCode("9999"), new Money(10m)); + Account account = BankingTestData.CreateAccount(accountNumber, 10m); accounts.GetByNumber(accountNumber).Returns(account); var auth = new SessionAuthorizer(sessions); - var useCase = new WithdrawUseCase(accounts, operations, auth); + var useCase = new WithdrawUseCase(accounts, operations, auth, new FixedClock()); WithdrawResult result = useCase.Execute(new WithdrawRequest(sessionId, 20m)); @@ -68,4 +68,4 @@ public void WithdrawWhenNotEnoughBalanceReturnsFailureAndDoesNotSave() accounts.DidNotReceive().Update(Arg.Any()); operations.DidNotReceive().Add(Arg.Any()); } -} \ No newline at end of file +} From b5c6bf3005d68c3d19145f44aa90ebaf00fe3916 Mon Sep 17 00:00:00 2001 From: 666mxvbee Date: Fri, 4 Sep 2026 20:21:11 +0300 Subject: [PATCH 06/11] feat(sessions): add expiry revocation and account identity --- .../Accounts/GetBalanceUseCase.cs | 20 ++--- src/Atm.Application/Atm.Application.csproj | 1 + .../Common/AuthorizationResult.cs | 6 +- .../Common/SessionAuthorizer.cs | 46 ++++------- .../Operations/DepositUseCase.cs | 4 +- .../GetTransactionHistoryUseCase.cs | 37 +++++---- .../Operations/WithdrawUseCase.cs | 4 +- .../Sessions/CreateAdminSessionUseCase.cs | 54 ++++++++----- .../Sessions/CreateUserSessionUseCase.cs | 54 +++++++++---- .../Sessions/SessionOptions.cs | 10 +++ src/Atm.Application/packages.lock.json | 15 ++++ .../Sessions/Results/SessionCreationResult.cs | 10 +++ .../Results/SessionRevocationResult.cs | 10 +++ src/Atm.Domain/Sessions/Session.cs | 78 +++++++++++++----- .../Contracts/ApiResponses.cs | 5 +- src/Atm.Presentation/Program.cs | 11 ++- src/Atm.Presentation/appsettings.json | 4 + src/Atm.Presentation/packages.lock.json | 3 +- .../Atm.Application.Tests/BankingTestData.cs | 14 ++++ .../DepositUseCaseTests.cs | 28 +++---- tests/Atm.Application.Tests/SessionTests.cs | 81 +++++++++++++++++++ .../WithdrawCaseTests.cs | 41 +++++----- .../Atm.Application.Tests/packages.lock.json | 3 +- 23 files changed, 372 insertions(+), 167 deletions(-) create mode 100644 src/Atm.Application/Sessions/SessionOptions.cs create mode 100644 src/Atm.Domain/Sessions/Results/SessionCreationResult.cs create mode 100644 src/Atm.Domain/Sessions/Results/SessionRevocationResult.cs create mode 100644 tests/Atm.Application.Tests/SessionTests.cs diff --git a/src/Atm.Application/Accounts/GetBalanceUseCase.cs b/src/Atm.Application/Accounts/GetBalanceUseCase.cs index 7d6ed8a..d229c0f 100644 --- a/src/Atm.Application/Accounts/GetBalanceUseCase.cs +++ b/src/Atm.Application/Accounts/GetBalanceUseCase.cs @@ -1,4 +1,4 @@ -using Atm.Application.Abstractions.Repositories; +using Atm.Application.Abstractions.Repositories; using Atm.Application.Common; using Atm.Application.Contracts.Accounts.GetBalance; @@ -18,20 +18,14 @@ public GetBalanceUseCase(IAccountRepository accounts, SessionAuthorizer auth) public GetBalanceResult Execute(GetBalanceRequest request) { AuthorizationResult auth = _auth.AuthorizeUser(request.UserSessionId); - - if (auth is AuthorizationResult.Unauthorized u) - { - return new GetBalanceResult.Unauthorized(u.ErrorMessage); - } + if (auth is AuthorizationResult.Unauthorized unauthorized) + return new GetBalanceResult.Unauthorized(unauthorized.ErrorMessage); var user = (AuthorizationResult.UserSuccess)auth; - Domain.Accounts.Account? acc = _accounts.GetByNumber(user.AccountNumber); - - if (acc is null) - { + Domain.Accounts.Account? account = _accounts.GetById(user.AccountId); + if (account is null) return new GetBalanceResult.Failure("Account not found"); - } - return new GetBalanceResult.Success(acc.Balance.Value); + return new GetBalanceResult.Success(account.Balance.Value); } -} \ No newline at end of file +} diff --git a/src/Atm.Application/Atm.Application.csproj b/src/Atm.Application/Atm.Application.csproj index 2424e34..88aff46 100644 --- a/src/Atm.Application/Atm.Application.csproj +++ b/src/Atm.Application/Atm.Application.csproj @@ -8,6 +8,7 @@ + diff --git a/src/Atm.Application/Common/AuthorizationResult.cs b/src/Atm.Application/Common/AuthorizationResult.cs index 6587986..a7501ec 100644 --- a/src/Atm.Application/Common/AuthorizationResult.cs +++ b/src/Atm.Application/Common/AuthorizationResult.cs @@ -1,14 +1,12 @@ -using Atm.Domain.ValueObjects; - namespace Atm.Application.Common; public abstract record AuthorizationResult { private AuthorizationResult() { } - public sealed record UserSuccess(AccountNumber AccountNumber) : AuthorizationResult; + public sealed record UserSuccess(Guid AccountId) : AuthorizationResult; public sealed record AdminSuccess : AuthorizationResult; public sealed record Unauthorized(string ErrorMessage) : AuthorizationResult; -} \ No newline at end of file +} diff --git a/src/Atm.Application/Common/SessionAuthorizer.cs b/src/Atm.Application/Common/SessionAuthorizer.cs index 2e8d145..9a06463 100644 --- a/src/Atm.Application/Common/SessionAuthorizer.cs +++ b/src/Atm.Application/Common/SessionAuthorizer.cs @@ -1,4 +1,5 @@ -using Atm.Application.Abstractions.Repositories; +using Atm.Application.Abstractions.Repositories; +using Atm.Application.Abstractions.Time; using Atm.Domain.Sessions; namespace Atm.Application.Common; @@ -6,48 +7,35 @@ namespace Atm.Application.Common; public sealed class SessionAuthorizer { private readonly ISessionRepository _sessions; + private readonly IClock _clock; - public SessionAuthorizer(ISessionRepository sessions) + public SessionAuthorizer(ISessionRepository sessions, IClock clock) { _sessions = sessions; + _clock = clock; } public AuthorizationResult AuthorizeUser(Guid sessionId) { - Session? s = _sessions.GetById(sessionId); - - if (s is null) - { - return new AuthorizationResult.Unauthorized("Session not found"); - } - - if (s.Type != SessionType.User) - { + Session? session = _sessions.GetById(sessionId); + if (session is null || !session.IsActive(_clock.UtcNow)) + return new AuthorizationResult.Unauthorized("Session is missing, expired or revoked."); + if (session.Type != SessionType.User) return new AuthorizationResult.Unauthorized("Session type not supported [user session required]"); - } - - if (s.AccountNumber is null) - { - return new AuthorizationResult.Unauthorized("Account number not set"); - } + if (session.AccountId is not Guid accountId) + return new AuthorizationResult.Unauthorized("Account is not set."); - return new AuthorizationResult.UserSuccess(s.AccountNumber); + return new AuthorizationResult.UserSuccess(accountId); } public AuthorizationResult AuthorizeAdmin(Guid sessionId) { - Session? s = _sessions.GetById(sessionId); - - if (s is null) - { - return new AuthorizationResult.Unauthorized("Session not found"); - } - - if (s.Type != SessionType.Admin) - { + Session? session = _sessions.GetById(sessionId); + if (session is null || !session.IsActive(_clock.UtcNow)) + return new AuthorizationResult.Unauthorized("Session is missing, expired or revoked."); + if (session.Type != SessionType.Admin) return new AuthorizationResult.Unauthorized("Session type not supported [admin session required]"); - } return new AuthorizationResult.AdminSuccess(); } -} \ No newline at end of file +} diff --git a/src/Atm.Application/Operations/DepositUseCase.cs b/src/Atm.Application/Operations/DepositUseCase.cs index 50bf22b..c290f07 100644 --- a/src/Atm.Application/Operations/DepositUseCase.cs +++ b/src/Atm.Application/Operations/DepositUseCase.cs @@ -32,7 +32,7 @@ public DepositResult Execute(DepositRequest request) } var user = (AuthorizationResult.UserSuccess)auth; - Domain.Accounts.Account? acc = _accounts.GetByNumber(user.AccountNumber); + Domain.Accounts.Account? acc = _accounts.GetById(user.AccountId); if (acc is null) { @@ -57,7 +57,7 @@ public DepositResult Execute(DepositRequest request) } _accounts.Update(acc); - _operations.Add(new OperationRecord(Guid.NewGuid(), user.AccountNumber, OperationType.Deposit, amount, acc.Balance)); + _operations.Add(new OperationRecord(Guid.NewGuid(), acc.Number, OperationType.Deposit, amount, acc.Balance)); return new DepositResult.Success(acc.Balance.Value); } diff --git a/src/Atm.Application/Operations/GetTransactionHistoryUseCase.cs b/src/Atm.Application/Operations/GetTransactionHistoryUseCase.cs index 78dc825..34ebf12 100644 --- a/src/Atm.Application/Operations/GetTransactionHistoryUseCase.cs +++ b/src/Atm.Application/Operations/GetTransactionHistoryUseCase.cs @@ -1,4 +1,4 @@ -using Atm.Application.Abstractions.Repositories; +using Atm.Application.Abstractions.Repositories; using Atm.Application.Common; using Atm.Application.Contracts.Operations.GetTransactionHistory; @@ -7,32 +7,41 @@ namespace Atm.Application.Operations; internal sealed class GetTransactionHistoryUseCase : IGetTransactionHistoryPort { private readonly IOperationRepository _operations; + private readonly IAccountRepository _accounts; private readonly SessionAuthorizer _auth; - public GetTransactionHistoryUseCase(IOperationRepository operations, SessionAuthorizer auth) + public GetTransactionHistoryUseCase( + IOperationRepository operations, + IAccountRepository accounts, + SessionAuthorizer auth) { _operations = operations; + _accounts = accounts; _auth = auth; } public GetTransactionHistoryResult Execute(GetTransactionHistoryRequest request) { AuthorizationResult auth = _auth.AuthorizeUser(request.UserSessionId); - - if (auth is AuthorizationResult.Unauthorized u) - { - return new GetTransactionHistoryResult.Unauthorized(u.ErrorMessage); - } + if (auth is AuthorizationResult.Unauthorized unauthorized) + return new GetTransactionHistoryResult.Unauthorized(unauthorized.ErrorMessage); var user = (AuthorizationResult.UserSuccess)auth; - IReadOnlyList ops = _operations.GetByAccountNumber(user.AccountNumber); - var list = new List(ops.Count); - - foreach (Domain.Operations.OperationRecord op in ops) + Domain.Accounts.Account? account = _accounts.GetById(user.AccountId); + if (account is null) + return new GetTransactionHistoryResult.Failure("Account not found"); + + IReadOnlyList operations = + _operations.GetByAccountNumber(account.Number); + var transactions = new List(operations.Count); + foreach (Domain.Operations.OperationRecord operation in operations) { - list.Add(new TransactionDto(op.Type.ToString(), op.Amount.Value, op.BalanceAfter.Value)); + transactions.Add(new TransactionDto( + operation.Type.ToString(), + operation.Amount.Value, + operation.BalanceAfter.Value)); } - return new GetTransactionHistoryResult.Success(list); + return new GetTransactionHistoryResult.Success(transactions); } -} \ No newline at end of file +} diff --git a/src/Atm.Application/Operations/WithdrawUseCase.cs b/src/Atm.Application/Operations/WithdrawUseCase.cs index a2158d5..607c44c 100644 --- a/src/Atm.Application/Operations/WithdrawUseCase.cs +++ b/src/Atm.Application/Operations/WithdrawUseCase.cs @@ -32,7 +32,7 @@ public WithdrawResult Execute(WithdrawRequest request) } var user = (AuthorizationResult.UserSuccess)auth; - Domain.Accounts.Account? acc = _accounts.GetByNumber(user.AccountNumber); + Domain.Accounts.Account? acc = _accounts.GetById(user.AccountId); if (acc is null) { @@ -56,7 +56,7 @@ public WithdrawResult Execute(WithdrawRequest request) } _accounts.Update(acc); - _operations.Add(new OperationRecord(Guid.NewGuid(), user.AccountNumber, OperationType.Withdraw, amount, acc.Balance)); + _operations.Add(new OperationRecord(Guid.NewGuid(), acc.Number, OperationType.Withdraw, amount, acc.Balance)); return new WithdrawResult.Success(acc.Balance.Value); } diff --git a/src/Atm.Application/Sessions/CreateAdminSessionUseCase.cs b/src/Atm.Application/Sessions/CreateAdminSessionUseCase.cs index ce90160..84d6dea 100644 --- a/src/Atm.Application/Sessions/CreateAdminSessionUseCase.cs +++ b/src/Atm.Application/Sessions/CreateAdminSessionUseCase.cs @@ -1,7 +1,10 @@ -using Atm.Application.Abstractions.Repositories; +using Atm.Application.Abstractions.Repositories; +using Atm.Application.Abstractions.Time; using Atm.Application.Contracts.Sessions.CreateAdminSession; using Atm.Domain.Sessions; +using Atm.Domain.Sessions.Results; using Atm.Domain.ValueObjects; +using Microsoft.Extensions.Options; namespace Atm.Application.Sessions; @@ -9,39 +12,52 @@ internal sealed class CreateAdminSessionUseCase : ICreateAdminSessionPort { private readonly ISessionRepository _sessions; private readonly ISystemPasswordProvider _passwords; - - public CreateAdminSessionUseCase(ISessionRepository sessions, ISystemPasswordProvider passwords) + private readonly IClock _clock; + private readonly SessionOptions _options; + + public CreateAdminSessionUseCase( + ISessionRepository sessions, + ISystemPasswordProvider passwords, + IClock clock, + IOptions options) { _sessions = sessions; _passwords = passwords; + _clock = clock; + _options = options.Value; } public CreateAdminSessionResult Execute(CreateAdminSessionRequest request) { string expected = _passwords.GetSystemPassword(); - if (string.IsNullOrWhiteSpace(expected)) - { return new CreateAdminSessionResult.Failure("System password is not configured"); - } - - if (request.SystemPassword != expected) - { + if (!string.Equals(request.SystemPassword, expected, StringComparison.Ordinal)) return new CreateAdminSessionResult.Unauthorized("Invalid system password"); - } try { - var accountNumber = new AccountNumber(request.AccountNumber); - var id = Guid.NewGuid(); - - _sessions.Add(new Session(id, SessionType.Admin, accountNumber)); - - return new CreateAdminSessionResult.Success(id); + // Kept only for backward compatibility with the current HTTP contract. + _ = new AccountNumber(request.AccountNumber); } - catch(Exception ex) + catch (ArgumentException exception) { - return new CreateAdminSessionResult.Failure(ex.Message); + return new CreateAdminSessionResult.Failure(exception.Message); } + + DateTimeOffset createdAtUtc = _clock.UtcNow; + SessionCreationResult result = Session.Create( + Guid.NewGuid(), + SessionType.Admin, + null, + createdAtUtc, + createdAtUtc.AddMinutes(_options.AdminLifetimeMinutes)); + + if (result is SessionCreationResult.Failure failure) + return new CreateAdminSessionResult.Failure(failure.ErrorMessage); + + Session session = ((SessionCreationResult.Success)result).Session; + _sessions.Add(session); + return new CreateAdminSessionResult.Success(session.Id); } -} \ No newline at end of file +} diff --git a/src/Atm.Application/Sessions/CreateUserSessionUseCase.cs b/src/Atm.Application/Sessions/CreateUserSessionUseCase.cs index 049dc05..68937a3 100644 --- a/src/Atm.Application/Sessions/CreateUserSessionUseCase.cs +++ b/src/Atm.Application/Sessions/CreateUserSessionUseCase.cs @@ -1,8 +1,11 @@ using Atm.Application.Abstractions.Repositories; using Atm.Application.Abstractions.Security; +using Atm.Application.Abstractions.Time; using Atm.Application.Contracts.Sessions.CreateUserSession; using Atm.Domain.Sessions; +using Atm.Domain.Sessions.Results; using Atm.Domain.ValueObjects; +using Microsoft.Extensions.Options; namespace Atm.Application.Sessions; @@ -11,37 +14,54 @@ internal sealed class CreateUserSessionUseCase : ICreateUserSessionPort private readonly ISessionRepository _sessions; private readonly IAccountRepository _accounts; private readonly IPinHasher _pinHasher; + private readonly IClock _clock; + private readonly SessionOptions _options; - public CreateUserSessionUseCase(ISessionRepository sessions, IAccountRepository accounts, IPinHasher pinHasher) + public CreateUserSessionUseCase( + ISessionRepository sessions, + IAccountRepository accounts, + IPinHasher pinHasher, + IClock clock, + IOptions options) { _sessions = sessions; _accounts = accounts; _pinHasher = pinHasher; + _clock = clock; + _options = options.Value; } public CreateUserSessionResult Execute(CreateUserSessionRequest request) { + AccountNumber number; + PinCode pin; try { - var number = new AccountNumber(request.AccountNumber); - var pin = new PinCode(request.Pin); - Domain.Accounts.Account? acc = _accounts.GetByNumber(number); + number = new AccountNumber(request.AccountNumber); + pin = new PinCode(request.Pin); + } + catch (ArgumentException exception) + { + return new CreateUserSessionResult.Failure(exception.Message); + } - if (acc is null || !_pinHasher.Verify(acc.PinHash, pin)) - { - return new CreateUserSessionResult.Unauthorized("Invalid credentials"); - } + Domain.Accounts.Account? account = _accounts.GetByNumber(number); + if (account is null || !_pinHasher.Verify(account.PinHash, pin)) + return new CreateUserSessionResult.Unauthorized("Invalid credentials"); - var id = Guid.NewGuid(); - var session = new Session(id, SessionType.User, number); + DateTimeOffset createdAtUtc = _clock.UtcNow; + SessionCreationResult result = Session.Create( + Guid.NewGuid(), + SessionType.User, + account.Id, + createdAtUtc, + createdAtUtc.AddMinutes(_options.UserLifetimeMinutes)); - _sessions.Add(session); + if (result is SessionCreationResult.Failure failure) + return new CreateUserSessionResult.Failure(failure.ErrorMessage); - return new CreateUserSessionResult.Success(id); - } - catch (ArgumentException ex) - { - return new CreateUserSessionResult.Failure(ex.Message); - } + Session session = ((SessionCreationResult.Success)result).Session; + _sessions.Add(session); + return new CreateUserSessionResult.Success(session.Id); } } diff --git a/src/Atm.Application/Sessions/SessionOptions.cs b/src/Atm.Application/Sessions/SessionOptions.cs new file mode 100644 index 0000000..1ed0139 --- /dev/null +++ b/src/Atm.Application/Sessions/SessionOptions.cs @@ -0,0 +1,10 @@ +namespace Atm.Application.Sessions; + +public sealed class SessionOptions +{ + public const string SectionName = "Sessions"; + + public int UserLifetimeMinutes { get; set; } + + public int AdminLifetimeMinutes { get; set; } +} diff --git a/src/Atm.Application/packages.lock.json b/src/Atm.Application/packages.lock.json index e4e5f9e..b7879a5 100644 --- a/src/Atm.Application/packages.lock.json +++ b/src/Atm.Application/packages.lock.json @@ -8,6 +8,21 @@ "resolved": "9.0.19", "contentHash": "XgQ0aVWClYjYSBqbYrKC/xyZ1KHf+VVwNF3+d54jcaqnst30Q//ugs/FJwUztLZiph0gp85g7i/eB0556twO0Q==" }, + "Microsoft.Extensions.Options": { + "type": "Direct", + "requested": "[9.0.19, )", + "resolved": "9.0.19", + "contentHash": "FdPZy4kUSwFlIVZ3EkK9fdHw2RfblUwzflSb4kjVeGf9v2lX9IH+q2RGUovYMR7CVfKMc2y2Db/bZ2b4yiMfOg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.19", + "Microsoft.Extensions.Primitives": "9.0.19" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "9hIg8PQiMnpVFIsEHm25Wi1gBrPa2pS1G75uveyDUVLXrNKqBap9WGwQIgO0s6LUgRAOdsSYnbKbNC5b1G5Pcg==" + }, "atm.application.abstractions": { "type": "Project", "dependencies": { diff --git a/src/Atm.Domain/Sessions/Results/SessionCreationResult.cs b/src/Atm.Domain/Sessions/Results/SessionCreationResult.cs new file mode 100644 index 0000000..ac4269d --- /dev/null +++ b/src/Atm.Domain/Sessions/Results/SessionCreationResult.cs @@ -0,0 +1,10 @@ +namespace Atm.Domain.Sessions.Results; + +public abstract record SessionCreationResult +{ + private SessionCreationResult() { } + + public sealed record Success(Session Session) : SessionCreationResult; + + public sealed record Failure(string ErrorMessage) : SessionCreationResult; +} diff --git a/src/Atm.Domain/Sessions/Results/SessionRevocationResult.cs b/src/Atm.Domain/Sessions/Results/SessionRevocationResult.cs new file mode 100644 index 0000000..c71e1b0 --- /dev/null +++ b/src/Atm.Domain/Sessions/Results/SessionRevocationResult.cs @@ -0,0 +1,10 @@ +namespace Atm.Domain.Sessions.Results; + +public abstract record SessionRevocationResult +{ + private SessionRevocationResult() { } + + public sealed record Success(DateTimeOffset RevokedAtUtc) : SessionRevocationResult; + + public sealed record Failure(string ErrorMessage) : SessionRevocationResult; +} diff --git a/src/Atm.Domain/Sessions/Session.cs b/src/Atm.Domain/Sessions/Session.cs index 8284340..6582ace 100644 --- a/src/Atm.Domain/Sessions/Session.cs +++ b/src/Atm.Domain/Sessions/Session.cs @@ -1,34 +1,70 @@ -using Atm.Domain.ValueObjects; +using Atm.Domain.Common; +using Atm.Domain.Sessions.Results; namespace Atm.Domain.Sessions; public sealed class Session { - public Session(Guid id, SessionType type, AccountNumber? accountNumber) + private Session() { } + + public Guid Id { get; private set; } + + public SessionType Type { get; private set; } + + public Guid? AccountId { get; private set; } + + public DateTimeOffset CreatedAtUtc { get; private set; } + + public DateTimeOffset ExpiresAtUtc { get; private set; } + + public DateTimeOffset? RevokedAtUtc { get; private set; } + + public static SessionCreationResult Create( + Guid id, + SessionType type, + Guid? accountId, + DateTimeOffset createdAtUtc, + DateTimeOffset expiresAtUtc) { if (id == Guid.Empty) - { - throw new ArgumentException("Session ID cannot be empty"); - } + return new SessionCreationResult.Failure("Session ID must not be empty."); + if (!Enum.IsDefined(type)) + return new SessionCreationResult.Failure("Session type is not supported."); + if (type == SessionType.User && (accountId is null || accountId == Guid.Empty)) + return new SessionCreationResult.Failure("User session must reference an account."); + if (type == SessionType.Admin && accountId is not null) + return new SessionCreationResult.Failure("System admin session must not reference an account."); + if (!DomainRules.IsUtc(createdAtUtc) || !DomainRules.IsUtc(expiresAtUtc)) + return new SessionCreationResult.Failure("Session timestamps must be UTC."); + if (expiresAtUtc <= createdAtUtc) + return new SessionCreationResult.Failure("Session expiration must follow its creation time."); - if (type == SessionType.User && accountNumber is null) + return new SessionCreationResult.Success(new Session { - throw new ArgumentException("User session must contain account number"); - } - - if (type == SessionType.Admin && accountNumber is null) - { - throw new ArgumentException("Admin session must contain account number"); - } - - Id = id; - Type = type; - AccountNumber = accountNumber; + Id = id, + Type = type, + AccountId = accountId, + CreatedAtUtc = createdAtUtc, + ExpiresAtUtc = expiresAtUtc, + }); } - public Guid Id { get; } + public bool IsActive(DateTimeOffset utcNow) + { + return DomainRules.IsUtc(utcNow) + && utcNow >= CreatedAtUtc + && utcNow < ExpiresAtUtc + && RevokedAtUtc is null; + } - public SessionType Type { get; } + public SessionRevocationResult Revoke(DateTimeOffset revokedAtUtc) + { + if (RevokedAtUtc is DateTimeOffset existing) + return new SessionRevocationResult.Success(existing); + if (!DomainRules.IsUtc(revokedAtUtc) || revokedAtUtc < CreatedAtUtc) + return new SessionRevocationResult.Failure("Revocation time must be UTC and must not precede session creation."); - public AccountNumber? AccountNumber { get; } -} \ No newline at end of file + RevokedAtUtc = revokedAtUtc; + return new SessionRevocationResult.Success(revokedAtUtc); + } +} diff --git a/src/Atm.Presentation/Contracts/ApiResponses.cs b/src/Atm.Presentation/Contracts/ApiResponses.cs index 7cbdf07..c2aaf64 100644 --- a/src/Atm.Presentation/Contracts/ApiResponses.cs +++ b/src/Atm.Presentation/Contracts/ApiResponses.cs @@ -5,10 +5,10 @@ namespace Atm.Presentation.Contracts; public sealed record ErrorResponse( [property: JsonPropertyName("error")] string Error); - + public sealed record SessionResponse( [property: JsonPropertyName("sessionId")] Guid SessionId); - + public sealed record BalanceResponse( [property: JsonPropertyName("balance")] decimal Balance); @@ -17,4 +17,3 @@ public sealed record MessageResponse( public sealed record TransactionsResponse( [property: JsonPropertyName("transactions")] IReadOnlyList Transactions); - \ No newline at end of file diff --git a/src/Atm.Presentation/Program.cs b/src/Atm.Presentation/Program.cs index 8cf0616..74a7068 100644 --- a/src/Atm.Presentation/Program.cs +++ b/src/Atm.Presentation/Program.cs @@ -1,7 +1,8 @@ -using Atm.Application; +using Atm.Application; using Atm.Application.Abstractions.Repositories; using Atm.Infrastructure; using Atm.Presentation.Common; +using ApplicationSessionOptions = Atm.Application.Sessions.SessionOptions; WebApplicationBuilder builder = WebApplication.CreateBuilder(args); @@ -18,6 +19,12 @@ builder.Services.AddApplication(); builder.Services.AddInfrastructure(); +builder.Services.AddOptions() + .Bind(builder.Configuration.GetSection(ApplicationSessionOptions.SectionName)) + .Validate(options => options.UserLifetimeMinutes > 0, "User session lifetime must be positive.") + .Validate(options => options.AdminLifetimeMinutes > 0, "Admin session lifetime must be positive.") + .ValidateOnStart(); + builder.Services.AddSingleton(); WebApplication app = builder.Build(); @@ -31,4 +38,4 @@ app.UseHttpsRedirection(); app.MapControllers(); -app.Run(); \ No newline at end of file +app.Run(); diff --git a/src/Atm.Presentation/appsettings.json b/src/Atm.Presentation/appsettings.json index 52a8f53..27151bb 100644 --- a/src/Atm.Presentation/appsettings.json +++ b/src/Atm.Presentation/appsettings.json @@ -2,6 +2,10 @@ "SystemAuth": { "SystemPassword": "change_me" }, + "Sessions": { + "UserLifetimeMinutes": 30, + "AdminLifetimeMinutes": 15 + }, "Logging": { "LogLevel": { "Default": "Information", diff --git a/src/Atm.Presentation/packages.lock.json b/src/Atm.Presentation/packages.lock.json index 013d951..00449bf 100644 --- a/src/Atm.Presentation/packages.lock.json +++ b/src/Atm.Presentation/packages.lock.json @@ -197,7 +197,8 @@ "Atm.Application.Abstractions": "[1.0.0, )", "Atm.Application.Contracts": "[1.0.0, )", "Atm.Domain": "[1.0.0, )", - "Microsoft.Extensions.DependencyInjection.Abstractions": "[9.0.19, )" + "Microsoft.Extensions.DependencyInjection.Abstractions": "[9.0.19, )", + "Microsoft.Extensions.Options": "[9.0.19, )" } }, "atm.application.abstractions": { diff --git a/tests/Atm.Application.Tests/BankingTestData.cs b/tests/Atm.Application.Tests/BankingTestData.cs index 8bda6a6..3c3f56e 100644 --- a/tests/Atm.Application.Tests/BankingTestData.cs +++ b/tests/Atm.Application.Tests/BankingTestData.cs @@ -1,5 +1,7 @@ using Atm.Domain.Accounts; using Atm.Domain.Accounts.Results; +using Atm.Domain.Sessions; +using Atm.Domain.Sessions.Results; using Atm.Domain.ValueObjects; namespace Atm.Application.Tests; @@ -12,4 +14,16 @@ public static Account CreateAccount(AccountNumber number, decimal balance) Guid.NewGuid(), number, Guid.NewGuid(), new PinHash("test-only-hash"), new Money(balance), new FixedClock().UtcNow)).Account; } + + public static Session CreateUserSession(Guid id, Account account, FixedClock clock) + { + return Assert.IsType(Session.Create( + id, SessionType.User, account.Id, clock.UtcNow, clock.UtcNow.AddMinutes(30))).Session; + } + + public static Session CreateAdminSession(Guid id, FixedClock clock) + { + return Assert.IsType(Session.Create( + id, SessionType.Admin, null, clock.UtcNow, clock.UtcNow.AddMinutes(15))).Session; + } } diff --git a/tests/Atm.Application.Tests/DepositUseCaseTests.cs b/tests/Atm.Application.Tests/DepositUseCaseTests.cs index 7d43cac..6edd8e5 100644 --- a/tests/Atm.Application.Tests/DepositUseCaseTests.cs +++ b/tests/Atm.Application.Tests/DepositUseCaseTests.cs @@ -4,7 +4,6 @@ using Atm.Application.Operations; using Atm.Domain.Accounts; using Atm.Domain.Operations; -using Atm.Domain.Sessions; using Atm.Domain.ValueObjects; using NSubstitute; @@ -18,29 +17,26 @@ public void DepositWhenValidIncreaseBalanceAndSavesOperation() IAccountRepository accounts = Substitute.For(); ISessionRepository sessions = Substitute.For(); IOperationRepository operations = Substitute.For(); - + var clock = new FixedClock(); var accountNumber = new AccountNumber("aboba"); var sessionId = Guid.NewGuid(); - - sessions.GetById(sessionId).Returns(new Session(sessionId, SessionType.User, accountNumber)); - Account account = BankingTestData.CreateAccount(accountNumber, 100m); - accounts.GetByNumber(accountNumber).Returns(account); - var auth = new SessionAuthorizer(sessions); - var useCase = new DepositUseCase(accounts, operations, auth, new FixedClock()); + sessions.GetById(sessionId).Returns(BankingTestData.CreateUserSession(sessionId, account, clock)); + accounts.GetById(account.Id).Returns(account); + + var auth = new SessionAuthorizer(sessions, clock); + var useCase = new DepositUseCase(accounts, operations, auth, clock); DepositResult result = useCase.Execute(new DepositRequest(sessionId, 50m)); Assert.IsType(result); Assert.Equal(150m, ((DepositResult.Success)result).Balance); - - accounts.Received(1).Update(Arg.Is(a => a.Balance.Value == 150m)); - - operations.Received(1).Add(Arg.Is(op => - op.AccountNumber == accountNumber && - op.Type == OperationType.Deposit && - op.Amount.Value == 50m && - op.BalanceAfter.Value == 150m)); + accounts.Received(1).Update(Arg.Is(item => item.Balance.Value == 150m)); + operations.Received(1).Add(Arg.Is(operation => + operation.AccountNumber == accountNumber && + operation.Type == OperationType.Deposit && + operation.Amount.Value == 50m && + operation.BalanceAfter.Value == 150m)); } } diff --git a/tests/Atm.Application.Tests/SessionTests.cs b/tests/Atm.Application.Tests/SessionTests.cs new file mode 100644 index 0000000..576f04e --- /dev/null +++ b/tests/Atm.Application.Tests/SessionTests.cs @@ -0,0 +1,81 @@ +using Atm.Application.Abstractions.Repositories; +using Atm.Application.Common; +using Atm.Domain.Accounts; +using Atm.Domain.Sessions; +using Atm.Domain.Sessions.Results; +using Atm.Domain.ValueObjects; +using NSubstitute; + +namespace Atm.Application.Tests; + +public sealed class SessionTests +{ + [Fact] + public void CreateEnforcesIdentityShapeAndUtcLifetime() + { + var clock = new FixedClock(); + + Assert.IsType(Session.Create( + Guid.Empty, SessionType.Admin, null, clock.UtcNow, clock.UtcNow.AddMinutes(1))); + Assert.IsType(Session.Create( + Guid.NewGuid(), SessionType.User, null, clock.UtcNow, clock.UtcNow.AddMinutes(1))); + Assert.IsType(Session.Create( + Guid.NewGuid(), SessionType.Admin, Guid.NewGuid(), clock.UtcNow, clock.UtcNow.AddMinutes(1))); + Assert.IsType(Session.Create( + Guid.NewGuid(), SessionType.Admin, null, clock.UtcNow, clock.UtcNow)); + Assert.IsType(Session.Create( + Guid.NewGuid(), SessionType.Admin, null, + clock.UtcNow.ToOffset(TimeSpan.FromHours(3)), clock.UtcNow.AddMinutes(1))); + } + + [Fact] + public void IsActiveUsesInclusiveStartAndExclusiveExpiration() + { + var clock = new FixedClock(); + Session session = BankingTestData.CreateAdminSession(Guid.NewGuid(), clock); + + Assert.True(session.IsActive(clock.UtcNow)); + Assert.True(session.IsActive(session.ExpiresAtUtc.AddTicks(-1))); + Assert.False(session.IsActive(session.ExpiresAtUtc)); + Assert.False(session.IsActive(clock.UtcNow.AddTicks(-1))); + Assert.False(session.IsActive(clock.UtcNow.ToOffset(TimeSpan.FromHours(3)))); + } + + [Fact] + public void RevokeIsIdempotentAndKeepsTheFirstTimestamp() + { + var clock = new FixedClock(); + Session session = BankingTestData.CreateAdminSession(Guid.NewGuid(), clock); + DateTimeOffset first = clock.UtcNow.AddMinutes(1); + + Assert.IsType(session.Revoke(clock.UtcNow.AddTicks(-1))); + Assert.IsType(session.Revoke(first)); + SessionRevocationResult.Success repeated = Assert.IsType( + session.Revoke(first.AddMinutes(1))); + + Assert.Equal(first, repeated.RevokedAtUtc); + Assert.Equal(first, session.RevokedAtUtc); + Assert.False(session.IsActive(first)); + } + + [Fact] + public void AuthorizerRejectsExpiredAndRevokedSessions() + { + ISessionRepository sessions = Substitute.For(); + var clock = new FixedClock(); + Account account = BankingTestData.CreateAccount(new AccountNumber("session-test"), 0m); + var sessionId = Guid.NewGuid(); + Session session = BankingTestData.CreateUserSession(sessionId, account, clock); + sessions.GetById(sessionId).Returns(session); + var authorizer = new SessionAuthorizer(sessions, clock); + + Assert.IsType(authorizer.AuthorizeUser(sessionId)); + + clock.UtcNow = session.ExpiresAtUtc; + Assert.IsType(authorizer.AuthorizeUser(sessionId)); + + clock.UtcNow = session.CreatedAtUtc; + Assert.IsType(session.Revoke(clock.UtcNow)); + Assert.IsType(authorizer.AuthorizeUser(sessionId)); + } +} diff --git a/tests/Atm.Application.Tests/WithdrawCaseTests.cs b/tests/Atm.Application.Tests/WithdrawCaseTests.cs index 09a5015..f9711d7 100644 --- a/tests/Atm.Application.Tests/WithdrawCaseTests.cs +++ b/tests/Atm.Application.Tests/WithdrawCaseTests.cs @@ -4,7 +4,6 @@ using Atm.Application.Operations; using Atm.Domain.Accounts; using Atm.Domain.Operations; -using Atm.Domain.Sessions; using Atm.Domain.ValueObjects; using NSubstitute; @@ -18,30 +17,27 @@ public void WithdrawWhenEnoughBalanceDecreasesBalanceAndSavesOperation() IAccountRepository accounts = Substitute.For(); ISessionRepository sessions = Substitute.For(); IOperationRepository operations = Substitute.For(); - + var clock = new FixedClock(); var accountNumber = new AccountNumber("aboba-2"); var sessionId = Guid.NewGuid(); - - sessions.GetById(sessionId).Returns(new Session(sessionId, SessionType.User, accountNumber)); - Account account = BankingTestData.CreateAccount(accountNumber, 100m); - accounts.GetByNumber(accountNumber).Returns(account); - var auth = new SessionAuthorizer(sessions); - var useCase = new WithdrawUseCase(accounts, operations, auth, new FixedClock()); + sessions.GetById(sessionId).Returns(BankingTestData.CreateUserSession(sessionId, account, clock)); + accounts.GetById(account.Id).Returns(account); + + var auth = new SessionAuthorizer(sessions, clock); + var useCase = new WithdrawUseCase(accounts, operations, auth, clock); WithdrawResult result = useCase.Execute(new WithdrawRequest(sessionId, 40m)); Assert.IsType(result); Assert.Equal(60m, ((WithdrawResult.Success)result).Balance); - - accounts.Received(1).Update(Arg.Is(a => a.Balance.Value == 60m)); - - operations.Received(1).Add(Arg.Is(op => - op.AccountNumber == accountNumber && - op.Type == OperationType.Withdraw && - op.Amount.Value == 40m && - op.BalanceAfter.Value == 60m)); + accounts.Received(1).Update(Arg.Is(item => item.Balance.Value == 60m)); + operations.Received(1).Add(Arg.Is(operation => + operation.AccountNumber == accountNumber && + operation.Type == OperationType.Withdraw && + operation.Amount.Value == 40m && + operation.BalanceAfter.Value == 60m)); } [Fact] @@ -50,17 +46,16 @@ public void WithdrawWhenNotEnoughBalanceReturnsFailureAndDoesNotSave() IAccountRepository accounts = Substitute.For(); ISessionRepository sessions = Substitute.For(); IOperationRepository operations = Substitute.For(); - + var clock = new FixedClock(); var accountNumber = new AccountNumber("aboba-666"); var sessionId = Guid.NewGuid(); - - sessions.GetById(sessionId).Returns(new Session(sessionId, SessionType.User, accountNumber)); - Account account = BankingTestData.CreateAccount(accountNumber, 10m); - accounts.GetByNumber(accountNumber).Returns(account); - var auth = new SessionAuthorizer(sessions); - var useCase = new WithdrawUseCase(accounts, operations, auth, new FixedClock()); + sessions.GetById(sessionId).Returns(BankingTestData.CreateUserSession(sessionId, account, clock)); + accounts.GetById(account.Id).Returns(account); + + var auth = new SessionAuthorizer(sessions, clock); + var useCase = new WithdrawUseCase(accounts, operations, auth, clock); WithdrawResult result = useCase.Execute(new WithdrawRequest(sessionId, 20m)); diff --git a/tests/Atm.Application.Tests/packages.lock.json b/tests/Atm.Application.Tests/packages.lock.json index 3080a22..3376167 100644 --- a/tests/Atm.Application.Tests/packages.lock.json +++ b/tests/Atm.Application.Tests/packages.lock.json @@ -281,7 +281,8 @@ "Atm.Application.Abstractions": "[1.0.0, )", "Atm.Application.Contracts": "[1.0.0, )", "Atm.Domain": "[1.0.0, )", - "Microsoft.Extensions.DependencyInjection.Abstractions": "[9.0.19, )" + "Microsoft.Extensions.DependencyInjection.Abstractions": "[9.0.19, )", + "Microsoft.Extensions.Options": "[9.0.19, )" } }, "atm.application.abstractions": { From 56927ed4415de791ef3c04553877d960c825402a Mon Sep 17 00:00:00 2001 From: 666mxvbee Date: Fri, 4 Sep 2026 20:24:21 +0300 Subject: [PATCH 07/11] feat(domain): add banking transactions and immutable history --- .../Operations/AccountHistoryEntry.cs | 84 ++++++++++ .../Operations/AccountHistoryEntryType.cs | 9 ++ src/Atm.Domain/Operations/BankTransaction.cs | 123 ++++++++++++++ .../Operations/BankTransactionType.cs | 8 + .../Operations/CommissionSnapshot.cs | 45 ++++++ .../AccountHistoryEntryCreationResult.cs | 10 ++ .../Results/BankTransactionCreationResult.cs | 10 ++ .../BankTransactionTests.cs | 153 ++++++++++++++++++ 8 files changed, 442 insertions(+) create mode 100644 src/Atm.Domain/Operations/AccountHistoryEntry.cs create mode 100644 src/Atm.Domain/Operations/AccountHistoryEntryType.cs create mode 100644 src/Atm.Domain/Operations/BankTransaction.cs create mode 100644 src/Atm.Domain/Operations/BankTransactionType.cs create mode 100644 src/Atm.Domain/Operations/CommissionSnapshot.cs create mode 100644 src/Atm.Domain/Operations/Results/AccountHistoryEntryCreationResult.cs create mode 100644 src/Atm.Domain/Operations/Results/BankTransactionCreationResult.cs create mode 100644 tests/Atm.Application.Tests/BankTransactionTests.cs diff --git a/src/Atm.Domain/Operations/AccountHistoryEntry.cs b/src/Atm.Domain/Operations/AccountHistoryEntry.cs new file mode 100644 index 0000000..d033777 --- /dev/null +++ b/src/Atm.Domain/Operations/AccountHistoryEntry.cs @@ -0,0 +1,84 @@ +using Atm.Domain.Operations.Results; +using Atm.Domain.ValueObjects; + +namespace Atm.Domain.Operations; + +public sealed class AccountHistoryEntry +{ + private AccountHistoryEntry() { } + + public Guid Id { get; private set; } + + public Guid TransactionId { get; private set; } + + public Guid AccountId { get; private set; } + + public AccountHistoryEntryType EntryType { get; private set; } + + public Money BalanceBefore { get; private set; } = null!; + + public Money BalanceAfter { get; private set; } = null!; + + public DateTimeOffset CreatedAtUtc { get; private set; } + + public static AccountHistoryEntryCreationResult Create( + Guid id, + BankTransaction? transaction, + Guid accountId, + Money? balanceBefore) + { + if (id == Guid.Empty || accountId == Guid.Empty) + return new AccountHistoryEntryCreationResult.Failure("History entry and account IDs must not be empty."); + if (transaction is null || balanceBefore is null) + return new AccountHistoryEntryCreationResult.Failure("Transaction and previous balance are required."); + + AccountHistoryEntryType entryType; + Money balanceAfter; + try + { + if (transaction.Type == BankTransactionType.Deposit && transaction.TargetAccountId == accountId) + { + entryType = AccountHistoryEntryType.Deposit; + balanceAfter = balanceBefore.Add(transaction.GetDepositCreditAmount()); + } + else if (transaction.Type == BankTransactionType.Withdrawal && transaction.SourceAccountId == accountId) + { + entryType = AccountHistoryEntryType.Withdrawal; + balanceAfter = balanceBefore.Subtract(transaction.GetSourceDebitAmount()); + } + else if (transaction.Type == BankTransactionType.Transfer && transaction.SourceAccountId == accountId) + { + entryType = AccountHistoryEntryType.TransferDebit; + balanceAfter = balanceBefore.Subtract(transaction.GetSourceDebitAmount()); + } + else if (transaction.Type == BankTransactionType.Transfer && transaction.TargetAccountId == accountId) + { + entryType = AccountHistoryEntryType.TransferCredit; + balanceAfter = balanceBefore.Add(transaction.Amount); + } + else + { + return new AccountHistoryEntryCreationResult.Failure("Account does not participate in the transaction."); + } + } + catch (InvalidOperationException exception) + { + return new AccountHistoryEntryCreationResult.Failure(exception.Message); + } + catch (OverflowException) + { + return new AccountHistoryEntryCreationResult.Failure("Resulting balance cannot be represented exactly as decimal."); + } + + return new AccountHistoryEntryCreationResult.Success(new AccountHistoryEntry + { + Id = id, + TransactionId = transaction.Id, + AccountId = accountId, + EntryType = entryType, + BalanceBefore = balanceBefore, + BalanceAfter = balanceAfter, + CreatedAtUtc = transaction.CreatedAtUtc, + }); + } +} diff --git a/src/Atm.Domain/Operations/AccountHistoryEntryType.cs b/src/Atm.Domain/Operations/AccountHistoryEntryType.cs new file mode 100644 index 0000000..aa94227 --- /dev/null +++ b/src/Atm.Domain/Operations/AccountHistoryEntryType.cs @@ -0,0 +1,9 @@ +namespace Atm.Domain.Operations; + +public enum AccountHistoryEntryType +{ + Deposit = 1, + Withdrawal = 2, + TransferDebit = 3, + TransferCredit = 4, +} diff --git a/src/Atm.Domain/Operations/BankTransaction.cs b/src/Atm.Domain/Operations/BankTransaction.cs new file mode 100644 index 0000000..b897fda --- /dev/null +++ b/src/Atm.Domain/Operations/BankTransaction.cs @@ -0,0 +1,123 @@ +using Atm.Domain.Common; +using Atm.Domain.Operations.Results; +using Atm.Domain.ValueObjects; + +namespace Atm.Domain.Operations; + +public sealed class BankTransaction +{ + private BankTransaction() { } + + public Guid Id { get; private set; } + + public Guid RequestId { get; private set; } + + public BankTransactionType Type { get; private set; } + + public Guid? SourceAccountId { get; private set; } + + public Guid? TargetAccountId { get; private set; } + + public Amount Amount { get; private set; } = null!; + + public CommissionSnapshot Commission { get; private set; } = null!; + + public DateTimeOffset CreatedAtUtc { get; private set; } + + public static BankTransactionCreationResult Create( + Guid id, + Guid requestId, + BankTransactionType type, + Guid? sourceAccountId, + Guid? targetAccountId, + Amount? amount, + CommissionSnapshot? commission, + DateTimeOffset createdAtUtc) + { + if (id == Guid.Empty || requestId == Guid.Empty) + return new BankTransactionCreationResult.Failure("Transaction and request IDs must not be empty."); + if (!Enum.IsDefined(type)) + return new BankTransactionCreationResult.Failure("Transaction type is not supported."); + if (amount is null || commission is null) + return new BankTransactionCreationResult.Failure("Amount and commission snapshot are required."); + if (!commission.IsCalculatedFor(amount)) + return new BankTransactionCreationResult.Failure("Commission snapshot does not match the transaction amount."); + if (!DomainRules.IsUtc(createdAtUtc)) + return new BankTransactionCreationResult.Failure("Transaction timestamp must be UTC."); + + string? shapeError = ValidateAccountShape(type, sourceAccountId, targetAccountId); + if (shapeError is not null) + return new BankTransactionCreationResult.Failure(shapeError); + + try + { + if (type == BankTransactionType.Deposit) + _ = CalculateDepositCredit(amount, commission); + else + _ = CalculateSourceDebit(amount, commission); + } + catch (InvalidOperationException exception) + { + return new BankTransactionCreationResult.Failure(exception.Message); + } + catch (OverflowException) + { + return new BankTransactionCreationResult.Failure("The transaction amount cannot be represented exactly as decimal."); + } + + return new BankTransactionCreationResult.Success(new BankTransaction + { + Id = id, + RequestId = requestId, + Type = type, + SourceAccountId = sourceAccountId, + TargetAccountId = targetAccountId, + Amount = amount, + Commission = commission, + CreatedAtUtc = createdAtUtc, + }); + } + + public Amount GetDepositCreditAmount() + { + if (Type != BankTransactionType.Deposit) + throw new InvalidOperationException("Only a deposit has a deposit credit amount."); + return CalculateDepositCredit(Amount, Commission); + } + + public Amount GetSourceDebitAmount() + { + if (Type == BankTransactionType.Deposit) + throw new InvalidOperationException("A deposit has no source debit amount."); + return CalculateSourceDebit(Amount, Commission); + } + + private static string? ValidateAccountShape( + BankTransactionType type, + Guid? sourceAccountId, + Guid? targetAccountId) + { + bool hasSource = sourceAccountId is Guid source && source != Guid.Empty; + bool hasTarget = targetAccountId is Guid target && target != Guid.Empty; + + return type switch + { + BankTransactionType.Deposit when !hasSource && hasTarget => null, + BankTransactionType.Withdrawal when hasSource && !hasTarget => null, + BankTransactionType.Transfer when hasSource && hasTarget && sourceAccountId != targetAccountId => null, + _ => "Source and target accounts do not match the transaction type.", + }; + } + + private static Amount CalculateDepositCredit(Amount amount, CommissionSnapshot commission) + { + if (amount.Value <= commission.CommissionAmount.Value) + throw new InvalidOperationException("Deposit amount must exceed its commission."); + return new Amount(DomainRules.SubtractExact(amount.Value, commission.CommissionAmount.Value)); + } + + private static Amount CalculateSourceDebit(Amount amount, CommissionSnapshot commission) + { + return new Amount(DomainRules.AddExact(amount.Value, commission.CommissionAmount.Value)); + } +} diff --git a/src/Atm.Domain/Operations/BankTransactionType.cs b/src/Atm.Domain/Operations/BankTransactionType.cs new file mode 100644 index 0000000..dcd858b --- /dev/null +++ b/src/Atm.Domain/Operations/BankTransactionType.cs @@ -0,0 +1,8 @@ +namespace Atm.Domain.Operations; + +public enum BankTransactionType +{ + Deposit = 1, + Withdrawal = 2, + Transfer = 3, +} diff --git a/src/Atm.Domain/Operations/CommissionSnapshot.cs b/src/Atm.Domain/Operations/CommissionSnapshot.cs new file mode 100644 index 0000000..d0740ab --- /dev/null +++ b/src/Atm.Domain/Operations/CommissionSnapshot.cs @@ -0,0 +1,45 @@ +using Atm.Domain.Common; +using Atm.Domain.ValueObjects; + +namespace Atm.Domain.Operations; + +public sealed class CommissionSnapshot +{ + private CommissionSnapshot() { } + + public CommissionSnapshot(Amount operationAmount, decimal rate, decimal fixedAmount) + { + ArgumentNullException.ThrowIfNull(operationAmount); + if (rate < 0m || rate > 1m || !DomainRules.HasScale(rate, 6)) + throw new ArgumentException("Commission rate must be between zero and one with at most six decimal places.", nameof(rate)); + + var fixedMoney = new Money(fixedAmount); + decimal proportional = checked(operationAmount.Value * rate); + decimal unrounded = DomainRules.AddExact(proportional, fixedMoney.Value); + decimal rounded = decimal.Round(unrounded, 4, MidpointRounding.ToEven); + + Rate = rate; + FixedAmount = fixedMoney; + CommissionAmount = new Money(rounded); + } + + public decimal Rate { get; private set; } + + public Money FixedAmount { get; private set; } = null!; + + public Money CommissionAmount { get; private set; } = null!; + + public bool IsCalculatedFor(Amount operationAmount) + { + ArgumentNullException.ThrowIfNull(operationAmount); + try + { + var expected = new CommissionSnapshot(operationAmount, Rate, FixedAmount.Value); + return expected.CommissionAmount.Value == CommissionAmount.Value; + } + catch (OverflowException) + { + return false; + } + } +} diff --git a/src/Atm.Domain/Operations/Results/AccountHistoryEntryCreationResult.cs b/src/Atm.Domain/Operations/Results/AccountHistoryEntryCreationResult.cs new file mode 100644 index 0000000..3b87e6f --- /dev/null +++ b/src/Atm.Domain/Operations/Results/AccountHistoryEntryCreationResult.cs @@ -0,0 +1,10 @@ +namespace Atm.Domain.Operations.Results; + +public abstract record AccountHistoryEntryCreationResult +{ + private AccountHistoryEntryCreationResult() { } + + public sealed record Success(AccountHistoryEntry Entry) : AccountHistoryEntryCreationResult; + + public sealed record Failure(string ErrorMessage) : AccountHistoryEntryCreationResult; +} diff --git a/src/Atm.Domain/Operations/Results/BankTransactionCreationResult.cs b/src/Atm.Domain/Operations/Results/BankTransactionCreationResult.cs new file mode 100644 index 0000000..aa5023f --- /dev/null +++ b/src/Atm.Domain/Operations/Results/BankTransactionCreationResult.cs @@ -0,0 +1,10 @@ +namespace Atm.Domain.Operations.Results; + +public abstract record BankTransactionCreationResult +{ + private BankTransactionCreationResult() { } + + public sealed record Success(BankTransaction Transaction) : BankTransactionCreationResult; + + public sealed record Failure(string ErrorMessage) : BankTransactionCreationResult; +} diff --git a/tests/Atm.Application.Tests/BankTransactionTests.cs b/tests/Atm.Application.Tests/BankTransactionTests.cs new file mode 100644 index 0000000..5f91d95 --- /dev/null +++ b/tests/Atm.Application.Tests/BankTransactionTests.cs @@ -0,0 +1,153 @@ +using Atm.Domain.Operations; +using Atm.Domain.Operations.Results; +using Atm.Domain.ValueObjects; + +namespace Atm.Application.Tests; + +public sealed class BankTransactionTests +{ + private static readonly DateTimeOffset Now = new(2026, 9, 4, 12, 0, 0, TimeSpan.Zero); + + [Fact] + public void CommissionSnapshotUsesBankersRoundingAndStoresTheTariff() + { + var roundDown = new CommissionSnapshot(new Amount(1.0001m), 0.5m, 0m); + var roundUp = new CommissionSnapshot(new Amount(1.0003m), 0.5m, 0m); + + Assert.Equal(0.5m, roundDown.Rate); + Assert.Equal(0m, roundDown.FixedAmount.Value); + Assert.Equal(0.5000m, roundDown.CommissionAmount.Value); + Assert.Equal(0.5002m, roundUp.CommissionAmount.Value); + } + + [Theory] + [InlineData(-0.000001)] + [InlineData(1.000001)] + [InlineData(0.1234567)] + public void CommissionSnapshotRejectsInvalidRate(decimal rate) + { + Assert.Throws(() => new CommissionSnapshot(new Amount(10m), rate, 0m)); + } + + [Fact] + public void CommissionSnapshotRejectsInvalidFixedAmountAndPrecisionLoss() + { + Assert.Throws(() => new CommissionSnapshot(new Amount(10m), 0m, -1m)); + Assert.Throws(() => new CommissionSnapshot(new Amount(10m), 0m, 0.00001m)); + Assert.Throws(() => + new CommissionSnapshot(new Amount(decimal.MaxValue), 1m, 0.0001m)); + } + + [Fact] + public void CreateEnforcesAccountShapeRequestIdAndUtc() + { + var source = Guid.NewGuid(); + var target = Guid.NewGuid(); + var amount = new Amount(10m); + var commission = new CommissionSnapshot(amount, 0m, 0m); + + Assert.IsType(BankTransaction.Create( + Guid.NewGuid(), Guid.Empty, BankTransactionType.Deposit, null, target, amount, commission, Now)); + Assert.IsType(BankTransaction.Create( + Guid.NewGuid(), Guid.NewGuid(), BankTransactionType.Deposit, source, target, amount, commission, Now)); + Assert.IsType(BankTransaction.Create( + Guid.NewGuid(), Guid.NewGuid(), BankTransactionType.Withdrawal, source, target, amount, commission, Now)); + Assert.IsType(BankTransaction.Create( + Guid.NewGuid(), Guid.NewGuid(), BankTransactionType.Transfer, source, source, amount, commission, Now)); + Assert.IsType(BankTransaction.Create( + Guid.NewGuid(), Guid.NewGuid(), BankTransactionType.Deposit, null, target, amount, commission, + Now.ToOffset(TimeSpan.FromHours(3)))); + } + + [Fact] + public void CreateRejectsMismatchedOrEconomicallyInvalidCommission() + { + var target = Guid.NewGuid(); + var originalAmount = new Amount(100m); + var mismatchedAmount = new Amount(101m); + var commission = new CommissionSnapshot(originalAmount, 0.1m, 0m); + + Assert.IsType(BankTransaction.Create( + Guid.NewGuid(), Guid.NewGuid(), BankTransactionType.Deposit, + null, target, mismatchedAmount, commission, Now)); + + var smallAmount = new Amount(1m); + var fullCommission = new CommissionSnapshot(smallAmount, 0m, 1m); + Assert.IsType(BankTransaction.Create( + Guid.NewGuid(), Guid.NewGuid(), BankTransactionType.Deposit, + null, target, smallAmount, fullCommission, Now)); + } + + [Fact] + public void HistoryEntryCalculatesDepositAndWithdrawalBalances() + { + var accountId = Guid.NewGuid(); + BankTransaction deposit = CreateTransaction( + BankTransactionType.Deposit, null, accountId, 100m, 0m, 10m); + AccountHistoryEntry depositEntry = Assert.IsType( + AccountHistoryEntry.Create(Guid.NewGuid(), deposit, accountId, new Money(10m))).Entry; + + Assert.Equal(AccountHistoryEntryType.Deposit, depositEntry.EntryType); + Assert.Equal(10m, depositEntry.BalanceBefore.Value); + Assert.Equal(100m, depositEntry.BalanceAfter.Value); + Assert.Equal(deposit.Id, depositEntry.TransactionId); + Assert.Equal(deposit.CreatedAtUtc, depositEntry.CreatedAtUtc); + + BankTransaction withdrawal = CreateTransaction( + BankTransactionType.Withdrawal, accountId, null, 20m, 0m, 2m); + AccountHistoryEntry withdrawalEntry = Assert.IsType( + AccountHistoryEntry.Create(Guid.NewGuid(), withdrawal, accountId, new Money(100m))).Entry; + + Assert.Equal(AccountHistoryEntryType.Withdrawal, withdrawalEntry.EntryType); + Assert.Equal(78m, withdrawalEntry.BalanceAfter.Value); + } + + [Fact] + public void HistoryEntryProducesTwoSidesOfATransfer() + { + var source = Guid.NewGuid(); + var target = Guid.NewGuid(); + BankTransaction transaction = CreateTransaction( + BankTransactionType.Transfer, source, target, 10m, 0m, 1m); + + AccountHistoryEntry debit = Assert.IsType( + AccountHistoryEntry.Create(Guid.NewGuid(), transaction, source, new Money(100m))).Entry; + AccountHistoryEntry credit = Assert.IsType( + AccountHistoryEntry.Create(Guid.NewGuid(), transaction, target, new Money(50m))).Entry; + + Assert.Equal(AccountHistoryEntryType.TransferDebit, debit.EntryType); + Assert.Equal(89m, debit.BalanceAfter.Value); + Assert.Equal(AccountHistoryEntryType.TransferCredit, credit.EntryType); + Assert.Equal(60m, credit.BalanceAfter.Value); + Assert.Equal(debit.TransactionId, credit.TransactionId); + } + + [Fact] + public void HistoryEntryFailureDoesNotChangeTheProvidedBalance() + { + var source = Guid.NewGuid(); + BankTransaction transaction = CreateTransaction( + BankTransactionType.Withdrawal, source, null, 10m, 0m, 1m); + var balance = new Money(10m); + + Assert.IsType( + AccountHistoryEntry.Create(Guid.NewGuid(), transaction, source, balance)); + Assert.Equal(10m, balance.Value); + Assert.IsType( + AccountHistoryEntry.Create(Guid.NewGuid(), transaction, Guid.NewGuid(), new Money(100m))); + } + + private static BankTransaction CreateTransaction( + BankTransactionType type, + Guid? source, + Guid? target, + decimal amountValue, + decimal rate, + decimal fixedAmount) + { + var amount = new Amount(amountValue); + var commission = new CommissionSnapshot(amount, rate, fixedAmount); + return Assert.IsType(BankTransaction.Create( + Guid.NewGuid(), Guid.NewGuid(), type, source, target, amount, commission, Now)).Transaction; + } +} From efb0eb1a011a4aa2f7920825ea596cd7daf5cae9 Mon Sep 17 00:00:00 2001 From: 666mxvbee Date: Fri, 4 Sep 2026 20:28:40 +0300 Subject: [PATCH 08/11] test(domain): verify banking flows and document stage changes --- README.md | 232 +++++++----------- .../Properties/AssemblyInfo.cs | 3 + .../AccountAuthenticationFlowTests.cs | 97 ++++++++ 3 files changed, 185 insertions(+), 147 deletions(-) create mode 100644 src/Atm.Application/Properties/AssemblyInfo.cs create mode 100644 tests/Atm.Application.Tests/AccountAuthenticationFlowTests.cs diff --git a/README.md b/README.md index 00a6463..7da6005 100644 --- a/README.md +++ b/README.md @@ -1,223 +1,161 @@ -# ATM [Hexagonal Architecture] +# ATM Web API -Implementation of an ATM system with a Web API interface using ASP.NET Controllers and in-memory storage. +An ATM training project built with .NET 9, ASP.NET Core Controllers, and hexagonal architecture. -## Functionality +Database stage 1 is currently in progress. Microstages 13.1 and 13.2 are implemented; runtime repositories are still temporary in-memory adapters until the EF Core repositories replace them in microstage 13.9. -- creating a user session using AccountNumber + PIN -- creating an administrator session using SystemPassword + AccountNumber -- creating an account, available only through an admin session -- viewing the account balance -- depositing money into an account -- withdrawing money -- viewing transaction history +## Current functionality -Any account-related operation creates a record in the transaction history. +- create expiring user and administrator sessions; +- create an account through an administrator session; +- view an account balance; +- deposit and withdraw money; +- view the legacy in-memory operation history. -## Architecture - -The project follows the hexagonal architecture approach: +The domain model now also contains persistent shapes for users, canonical friendships, accounts, sessions, bank transactions, commission snapshots, and immutable account history entries. Transactional persistence, idempotency enforcement, commissions in runtime use cases, and transfers are deliberately implemented in later microstages. -- **Domain** — domain model and value objects -- **Application** — business logic / use cases through ports -- **Application.Abstractions** — abstractions such as repositories, providers, and similar components -- **Application.Contracts** — request/response contracts and operation results -- **Infrastructure** — implementations of abstractions, including in-memory repositories, and DI registration -- **Presentation** — ASP.NET Web API Controllers and HTTP-to-port mapping +## Architecture -The layers are connected using `Microsoft.Extensions.DependencyInjection`. +- **Atm.Domain** — entities, value objects, invariants, and domain operation results; +- **Atm.Application** — use-case orchestration; +- **Atm.Application.Abstractions** — repository, clock, security, and provider ports; +- **Atm.Application.Contracts** — input/output ports and application results; +- **Atm.Infrastructure** — temporary repositories plus infrastructure adapters; +- **Atm.Presentation** — HTTP controllers and composition root. -Each module provides extension methods for dependency registration: +Domain and Application have no EF Core or PostgreSQL dependency. -- `builder.Services.AddApplication();` -- `builder.Services.AddInfrastructure();` +## Validation and result style -## System Password Configuration +Small value objects such as `AccountNumber`, `UserLogin`, `Money`, and `PinCode` validate themselves in constructors and reject invalid values with `ArgumentException`. -The system password is read through `ISystemPasswordProvider`, implemented in `Atm.Presentation.Common.SystemPasswordProvider`, and is taken from the configuration. +Business operations that can normally be accepted or rejected use a dedicated result type with nested `Success` and `Failure` records. The project intentionally does not introduce a universal `Result` or one result hierarchy per primitive field. -`appsettings.json`: +## Money rules -```json -{ - "SystemAuth": { - "SystemPassword": "your_password_here" - } -} -``` +Amounts use .NET `decimal` and allow at most four fractional digits. There is no artificial `numeric(19,4)` business ceiling: for example, values above `1_000_000_000_000_000` are valid. -## Running the API +This does not mean infinite precision. Values must still fit in `decimal`, and arithmetic is rejected when it would overflow or silently lose a small amount near the representable boundary. Future PostgreSQL mappings use unconstrained `numeric` with scale checks instead of `numeric(19,4)`. -The HTTP launch profile uses: +Commission snapshots retain the applied rate, fixed component, and calculated commission. The formula is: ```text -http://localhost:5015 +round(amount * rate + fixed, 4, MidpointRounding.ToEven) ``` -Swagger UI is available at: +Runtime deposit and withdrawal use cases still have their legacy no-commission behavior until microstage 13.7 wires the new transaction model into one database transaction. -```text -http://localhost:5015/swagger -``` +## PIN security -## API Use Cases +The account entity stores `PinHash`, never `PinCode`. `IPinHasher` is implemented using the versioned Microsoft ASP.NET Core `PasswordHasher`, which generates a unique salt and provides the matching verification routine. -### 1. Create an admin session +Do not log request bodies containing PINs. JWT, cookies, and ASP.NET Core Identity user management are outside this stage. -Use this when an administrator needs to perform admin-only actions, such as creating a new account. +## Clock and sessions -`POST /api/sessions/admin` +`IClock` supplies the current UTC time. Production uses `SystemClock`; tests use a controllable `FixedClock`. This keeps time-dependent domain behavior deterministic. -```json -{ - "systemPassword": "your_password_here", - "accountNumber": "acc-1" -} -``` +A user session references the stable account UUID. A system administrator session is not associated with an account. Sessions have UTC creation, expiration, and optional revocation timestamps. They are active from creation inclusively until expiration exclusively, unless revoked first. -Successful response: +Session lifetimes are validated at startup: ```json { - "sessionId": "admin-session-guid" + "Sessions": { + "UserLifetimeMinutes": 30, + "AdminLifetimeMinutes": 15 + } } ``` -The returned `sessionId` is used as `adminSessionId` in admin-only requests. +Both values must be positive. -### 2. Create an account +## System password configuration -Requires an admin session. - -`POST /api/accounts/create` - -```json -{ - "adminSessionId": "admin-session-guid", - "accountNumber": "acc-1", - "pin": "1234", - "initialBalance": 100 -} -``` - -Successful response: +The administrator password comes from configuration through `ISystemPasswordProvider`: ```json { - "message": "Account created" + "SystemAuth": { + "SystemPassword": "use-a-secret-provider" + } } ``` -### 3. Create a user session +Do not commit a real password. Override the development placeholder with user secrets or an environment variable. -Use this after an account has been created. The user signs in with the account number and PIN. +## API examples -`POST /api/sessions/user` +The local HTTP profile uses `http://localhost:5015`; Swagger is available at `http://localhost:5015/swagger` in Development. -```json -{ - "accountNumber": "acc-1", - "pin": "1234" -} -``` +### Create an administrator session -Successful response: +`POST /api/sessions/admin` ```json { - "sessionId": "user-session-guid" + "systemPassword": "use-a-secret-provider", + "accountNumber": "legacy-admin-context" } ``` -The returned `sessionId` is used as the user session identifier for balance, deposit, withdrawal, and history operations. - -### 4. View balance +`accountNumber` remains required only for backward compatibility with the existing HTTP contract. A system administrator session does not store or authorize against this number. -Requires a user session. +### Create an account -`GET /api/accounts/balance?sessionId=user-session-guid` - -Successful response: +`POST /api/accounts/create` ```json { - "balance": 100 + "adminSessionId": "00000000-0000-0000-0000-000000000001", + "accountNumber": "acc-1", + "pin": "1234", + "initialBalance": 100, + "ownerUserId": "00000000-0000-0000-0000-000000000002" } ``` -### 5. Deposit money +`ownerUserId` is a breaking addition to the old contract. In the current transitional in-memory implementation only a non-empty UUID is checked. Existence is enforced by the user repository and PostgreSQL foreign key in later persistence microstages. There is intentionally no user/profile HTTP CRUD endpoint in stage 1. -Requires a user session. +### Create a user session -`POST /api/accounts/deposit` - -```json -{ - "userSessionId": "user-session-guid", - "amount": 50 -} -``` - -Successful response: +`POST /api/sessions/user` ```json { - "balance": 150 + "accountNumber": "acc-1", + "pin": "1234" } ``` -### 6. Withdraw money - -Requires a user session. +The returned `sessionId` authorizes balance, deposit, withdrawal, and history operations. -`POST /api/accounts/withdraw` +### Account operations -```json -{ - "userSessionId": "user-session-guid", - "amount": 30 -} -``` +- `GET /api/accounts/balance?sessionId={user-session-id}` +- `POST /api/accounts/deposit` with `userSessionId` and `amount` +- `POST /api/accounts/withdraw` with `userSessionId` and `amount` +- `GET /api/accounts/history?sessionId={user-session-id}` -Successful response: +## Build and test -```json -{ - "balance": 120 -} +```powershell +dotnet restore Atm.sln --locked-mode +dotnet build Atm.sln -c Release --no-restore +dotnet test Atm.sln -c Release --no-build ``` -### 7. View transaction history - -Requires a user session. - -`GET /api/accounts/history?sessionId=user-session-guid` - -Successful response: +Unit tests create mocks or focused test objects directly and do not build the production DI container or start a database. -```json -{ - "transactions": [ - { - "type": "Deposit", - "amount": 50, - "balanceAfter": 150 - }, - { - "type": "Withdraw", - "amount": 30, - "balanceAfter": 120 - } - ] -} -``` +## Transitional limitations -## Authentication and Authorization Model +The following stage-1 work is not yet complete: -The project currently uses application-level sessions instead of ASP.NET Core JWT or cookie authentication. +- `AtmDbContext`, mappings, migration, and PostgreSQL repositories; +- asynchronous persistence ports and cancellation tokens; +- atomic balance/transaction/history commits; +- database-backed idempotency and concurrency handling; +- runtime commission policies and transfers; +- PostgreSQL integration tests, production DI replacement, and CI environment. -- Admin authentication is performed with `SystemPassword`. -- User authentication is performed with `AccountNumber` and `PIN`. -- Authorization is performed by checking the session type: `Admin` or `User`. -- Admin-only operations require an admin session. -- Account operations require a user session. \ No newline at end of file +Do not treat the current in-memory repositories as production persistence. diff --git a/src/Atm.Application/Properties/AssemblyInfo.cs b/src/Atm.Application/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..a1a4d7e --- /dev/null +++ b/src/Atm.Application/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Atm.Application.Tests")] diff --git a/tests/Atm.Application.Tests/AccountAuthenticationFlowTests.cs b/tests/Atm.Application.Tests/AccountAuthenticationFlowTests.cs new file mode 100644 index 0000000..a928a45 --- /dev/null +++ b/tests/Atm.Application.Tests/AccountAuthenticationFlowTests.cs @@ -0,0 +1,97 @@ +using Atm.Application.Abstractions.Repositories; +using Atm.Application.Accounts; +using Atm.Application.Common; +using Atm.Application.Contracts.Accounts.CreateAccount; +using Atm.Application.Contracts.Sessions.CreateUserSession; +using Atm.Application.Sessions; +using Atm.Domain.Accounts; +using Atm.Domain.Sessions; +using Atm.Domain.ValueObjects; +using Atm.Infrastructure.Security; +using Microsoft.Extensions.Options; + +namespace Atm.Application.Tests; + +public sealed class AccountAuthenticationFlowTests +{ + [Fact] + public void CreatedAccountStoresOnlyHashAndAcceptsOnlyTheCorrectPin() + { + var accounts = new TestAccountRepository(); + var sessions = new TestSessionRepository(); + var clock = new FixedClock(); + var hasher = new PinHasher(); + var adminSessionId = Guid.NewGuid(); + var ownerUserId = Guid.NewGuid(); + sessions.Add(BankingTestData.CreateAdminSession(adminSessionId, clock)); + + var authorizer = new SessionAuthorizer(sessions, clock); + var createAccount = new CreateAccountUseCase(accounts, authorizer, hasher, clock); + CreateAccountResult created = createAccount.Execute(new CreateAccountRequest( + adminSessionId, " account-01 ", "1234", 1_000_000_000_000_000m, ownerUserId)); + + Assert.IsType(created); + Account storedAccount = Assert.IsType(accounts.Account); + Assert.Equal("ACCOUNT-01", storedAccount.Number.NormalizedValue); + Assert.Equal(ownerUserId, storedAccount.OwnerUserId); + Assert.NotEqual("1234", storedAccount.PinHash.Value); + Assert.True(hasher.Verify(storedAccount.PinHash, new PinCode("1234"))); + + IOptions options = Options.Create(new SessionOptions + { + UserLifetimeMinutes = 30, + AdminLifetimeMinutes = 15, + }); + var createSession = new CreateUserSessionUseCase(sessions, accounts, hasher, clock, options); + + Assert.IsType(createSession.Execute( + new CreateUserSessionRequest("account-01", "4321"))); + CreateUserSessionResult.Success signedIn = Assert.IsType( + createSession.Execute(new CreateUserSessionRequest("account-01", "1234"))); + + Session userSession = Assert.IsType(sessions.GetById(signedIn.SessionId)); + Assert.Equal(storedAccount.Id, userSession.AccountId); + Assert.True(userSession.IsActive(clock.UtcNow)); + Assert.Equal(clock.UtcNow.AddMinutes(30), userSession.ExpiresAtUtc); + } + + private sealed class TestAccountRepository : IAccountRepository + { + public Account? Account { get; private set; } + + public Account? GetById(Guid id) + { + return Account?.Id == id ? Account : null; + } + + public Account? GetByNumber(AccountNumber accountNumber) + { + return Account?.Number.NormalizedValue == accountNumber.NormalizedValue ? Account : null; + } + + public void Add(Account account) + { + Account = account; + } + + public void Update(Account account) + { + Account = account; + } + } + + private sealed class TestSessionRepository : ISessionRepository + { + private readonly Dictionary _sessions = new(); + + public Session? GetById(Guid id) + { + return _sessions.GetValueOrDefault(id); + } + + public void Add(Session session) + { + _sessions.Add(session.Id, session); + } + } +} From c84d2eb09d4073c20328a4a28373545cf985f608 Mon Sep 17 00:00:00 2001 From: 666mxvbee Date: Fri, 4 Sep 2026 20:37:30 +0300 Subject: [PATCH 09/11] style: remove inline code comments --- src/Atm.Application/Sessions/CreateAdminSessionUseCase.cs | 1 - src/Atm.Domain/Users/Friendship.cs | 1 - src/Atm.Domain/Users/User.cs | 1 - src/Atm.Infrastructure/Security/PinHasher.cs | 1 - 4 files changed, 4 deletions(-) diff --git a/src/Atm.Application/Sessions/CreateAdminSessionUseCase.cs b/src/Atm.Application/Sessions/CreateAdminSessionUseCase.cs index 84d6dea..a8768df 100644 --- a/src/Atm.Application/Sessions/CreateAdminSessionUseCase.cs +++ b/src/Atm.Application/Sessions/CreateAdminSessionUseCase.cs @@ -37,7 +37,6 @@ public CreateAdminSessionResult Execute(CreateAdminSessionRequest request) try { - // Kept only for backward compatibility with the current HTTP contract. _ = new AccountNumber(request.AccountNumber); } catch (ArgumentException exception) diff --git a/src/Atm.Domain/Users/Friendship.cs b/src/Atm.Domain/Users/Friendship.cs index 13b6c38..092f1d9 100644 --- a/src/Atm.Domain/Users/Friendship.cs +++ b/src/Atm.Domain/Users/Friendship.cs @@ -21,7 +21,6 @@ public static FriendshipCreationResult Create( if (!DomainRules.IsUtc(createdAtUtc)) return new FriendshipCreationResult.Failure("Created timestamp must be UTC."); - // Canonical hexadecimal order matches UUID byte order, not Guid.ToByteArray's little-endian layout. bool firstIsSmaller = string.CompareOrdinal(firstUserId.ToString("N"), secondUserId.ToString("N")) < 0; return new FriendshipCreationResult.Success(new Friendship { diff --git a/src/Atm.Domain/Users/User.cs b/src/Atm.Domain/Users/User.cs index 28146dd..bd172e1 100644 --- a/src/Atm.Domain/Users/User.cs +++ b/src/Atm.Domain/Users/User.cs @@ -6,7 +6,6 @@ namespace Atm.Domain.Users; public sealed class User { - // Public construction is validated by Create; private setters also allow ORM materialization. private User() { } public Guid Id { get; private set; } diff --git a/src/Atm.Infrastructure/Security/PinHasher.cs b/src/Atm.Infrastructure/Security/PinHasher.cs index 8a85026..3e39ce9 100644 --- a/src/Atm.Infrastructure/Security/PinHasher.cs +++ b/src/Atm.Infrastructure/Security/PinHasher.cs @@ -17,7 +17,6 @@ public bool Verify(PinHash hash, PinCode pin) { try { - // Both Success and SuccessRehashNeeded mean the PIN is valid. return _hasher.VerifyHashedPassword(this, hash.Value, pin.Value) != PasswordVerificationResult.Failed; } From 218a00c6de9a1ba1aa94d238f805c2acfbab523b Mon Sep 17 00:00:00 2001 From: 666mxvbee Date: Fri, 4 Sep 2026 20:41:53 +0300 Subject: [PATCH 10/11] test: separate projects by architectural layer --- Atm.sln | 30 ++++ README.md | 8 +- .../AccountAuthenticationFlowTests.cs | 21 ++- .../Atm.Application.Tests.csproj | 1 - .../SessionAuthorizerTests.cs | 33 +++++ tests/Atm.Application.Tests/SessionTests.cs | 81 ---------- .../Atm.Application.Tests/packages.lock.json | 139 ------------------ .../AccountTests.cs | 16 +- .../Atm.Domain.Tests/Atm.Domain.Tests.csproj | 25 ++++ .../BankTransactionTests.cs | 2 +- tests/Atm.Domain.Tests/DomainTestData.cs | 34 +++++ tests/Atm.Domain.Tests/SessionTests.cs | 58 ++++++++ .../UserTests.cs | 2 +- .../ValueObjectTests.cs | 2 +- .../Atm.Infrastructure.Tests.csproj | 25 ++++ .../PinHasherTests.cs | 2 +- 16 files changed, 243 insertions(+), 236 deletions(-) create mode 100644 tests/Atm.Application.Tests/SessionAuthorizerTests.cs delete mode 100644 tests/Atm.Application.Tests/SessionTests.cs rename tests/{Atm.Application.Tests => Atm.Domain.Tests}/AccountTests.cs (80%) create mode 100644 tests/Atm.Domain.Tests/Atm.Domain.Tests.csproj rename tests/{Atm.Application.Tests => Atm.Domain.Tests}/BankTransactionTests.cs (99%) create mode 100644 tests/Atm.Domain.Tests/DomainTestData.cs create mode 100644 tests/Atm.Domain.Tests/SessionTests.cs rename tests/{Atm.Application.Tests => Atm.Domain.Tests}/UserTests.cs (98%) rename tests/{Atm.Application.Tests => Atm.Domain.Tests}/ValueObjectTests.cs (98%) create mode 100644 tests/Atm.Infrastructure.Tests/Atm.Infrastructure.Tests.csproj rename tests/{Atm.Application.Tests => Atm.Infrastructure.Tests}/PinHasherTests.cs (96%) diff --git a/Atm.sln b/Atm.sln index fee2692..d015d3a 100644 --- a/Atm.sln +++ b/Atm.sln @@ -21,6 +21,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Atm.Application.Tests", "tests\Atm.Application.Tests\Atm.Application.Tests.csproj", "{2DEDE88D-E43A-4FBC-A2E2-0AE5AD2945B5}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Atm.Domain.Tests", "tests\Atm.Domain.Tests\Atm.Domain.Tests.csproj", "{D9ACF0B4-7830-4D65-96E2-49EC72B8DD3A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Atm.Infrastructure.Tests", "tests\Atm.Infrastructure.Tests\Atm.Infrastructure.Tests.csproj", "{F2E5698E-1037-4F49-A653-1A7D60B909E4}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -115,6 +119,30 @@ Global {2DEDE88D-E43A-4FBC-A2E2-0AE5AD2945B5}.Release|x64.Build.0 = Release|Any CPU {2DEDE88D-E43A-4FBC-A2E2-0AE5AD2945B5}.Release|x86.ActiveCfg = Release|Any CPU {2DEDE88D-E43A-4FBC-A2E2-0AE5AD2945B5}.Release|x86.Build.0 = Release|Any CPU + {D9ACF0B4-7830-4D65-96E2-49EC72B8DD3A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D9ACF0B4-7830-4D65-96E2-49EC72B8DD3A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D9ACF0B4-7830-4D65-96E2-49EC72B8DD3A}.Debug|x64.ActiveCfg = Debug|Any CPU + {D9ACF0B4-7830-4D65-96E2-49EC72B8DD3A}.Debug|x64.Build.0 = Debug|Any CPU + {D9ACF0B4-7830-4D65-96E2-49EC72B8DD3A}.Debug|x86.ActiveCfg = Debug|Any CPU + {D9ACF0B4-7830-4D65-96E2-49EC72B8DD3A}.Debug|x86.Build.0 = Debug|Any CPU + {D9ACF0B4-7830-4D65-96E2-49EC72B8DD3A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D9ACF0B4-7830-4D65-96E2-49EC72B8DD3A}.Release|Any CPU.Build.0 = Release|Any CPU + {D9ACF0B4-7830-4D65-96E2-49EC72B8DD3A}.Release|x64.ActiveCfg = Release|Any CPU + {D9ACF0B4-7830-4D65-96E2-49EC72B8DD3A}.Release|x64.Build.0 = Release|Any CPU + {D9ACF0B4-7830-4D65-96E2-49EC72B8DD3A}.Release|x86.ActiveCfg = Release|Any CPU + {D9ACF0B4-7830-4D65-96E2-49EC72B8DD3A}.Release|x86.Build.0 = Release|Any CPU + {F2E5698E-1037-4F49-A653-1A7D60B909E4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F2E5698E-1037-4F49-A653-1A7D60B909E4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F2E5698E-1037-4F49-A653-1A7D60B909E4}.Debug|x64.ActiveCfg = Debug|Any CPU + {F2E5698E-1037-4F49-A653-1A7D60B909E4}.Debug|x64.Build.0 = Debug|Any CPU + {F2E5698E-1037-4F49-A653-1A7D60B909E4}.Debug|x86.ActiveCfg = Debug|Any CPU + {F2E5698E-1037-4F49-A653-1A7D60B909E4}.Debug|x86.Build.0 = Debug|Any CPU + {F2E5698E-1037-4F49-A653-1A7D60B909E4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F2E5698E-1037-4F49-A653-1A7D60B909E4}.Release|Any CPU.Build.0 = Release|Any CPU + {F2E5698E-1037-4F49-A653-1A7D60B909E4}.Release|x64.ActiveCfg = Release|Any CPU + {F2E5698E-1037-4F49-A653-1A7D60B909E4}.Release|x64.Build.0 = Release|Any CPU + {F2E5698E-1037-4F49-A653-1A7D60B909E4}.Release|x86.ActiveCfg = Release|Any CPU + {F2E5698E-1037-4F49-A653-1A7D60B909E4}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -127,5 +155,7 @@ Global {685D6187-D16F-4231-A59D-111A4C832F55} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {72D902C9-8BE5-4408-B0EA-F77BCDD7C534} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {2DEDE88D-E43A-4FBC-A2E2-0AE5AD2945B5} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {D9ACF0B4-7830-4D65-96E2-49EC72B8DD3A} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {F2E5698E-1037-4F49-A653-1A7D60B909E4} = {0AB3BF05-4346-4AA6-1389-037BE0695223} EndGlobalSection EndGlobal diff --git a/README.md b/README.md index 7da6005..263cc31 100644 --- a/README.md +++ b/README.md @@ -145,7 +145,13 @@ dotnet build Atm.sln -c Release --no-restore dotnet test Atm.sln -c Release --no-build ``` -Unit tests create mocks or focused test objects directly and do not build the production DI container or start a database. +Tests are separated by architectural layer: + +- `Atm.Domain.Tests` validates value objects, entities, invariants, commissions, and history calculations; +- `Atm.Application.Tests` validates use cases, authorization, and application orchestration through ports; +- `Atm.Infrastructure.Tests` validates concrete infrastructure adapters such as `PinHasher`. + +Each test project references only the production layer it is intended to test. Unit tests create mocks or focused test objects directly and do not build the production DI container or start a database. ## Transitional limitations diff --git a/tests/Atm.Application.Tests/AccountAuthenticationFlowTests.cs b/tests/Atm.Application.Tests/AccountAuthenticationFlowTests.cs index a928a45..2790a13 100644 --- a/tests/Atm.Application.Tests/AccountAuthenticationFlowTests.cs +++ b/tests/Atm.Application.Tests/AccountAuthenticationFlowTests.cs @@ -1,4 +1,5 @@ using Atm.Application.Abstractions.Repositories; +using Atm.Application.Abstractions.Security; using Atm.Application.Accounts; using Atm.Application.Common; using Atm.Application.Contracts.Accounts.CreateAccount; @@ -7,7 +8,6 @@ using Atm.Domain.Accounts; using Atm.Domain.Sessions; using Atm.Domain.ValueObjects; -using Atm.Infrastructure.Security; using Microsoft.Extensions.Options; namespace Atm.Application.Tests; @@ -20,7 +20,7 @@ public void CreatedAccountStoresOnlyHashAndAcceptsOnlyTheCorrectPin() var accounts = new TestAccountRepository(); var sessions = new TestSessionRepository(); var clock = new FixedClock(); - var hasher = new PinHasher(); + var hasher = new TestPinHasher(); var adminSessionId = Guid.NewGuid(); var ownerUserId = Guid.NewGuid(); sessions.Add(BankingTestData.CreateAdminSession(adminSessionId, clock)); @@ -94,4 +94,21 @@ public void Add(Session session) _sessions.Add(session.Id, session); } } + + private sealed class TestPinHasher : IPinHasher + { + private readonly Dictionary _pinsByHash = new(); + + public PinHash Hash(PinCode pin) + { + var hash = new PinHash($"test-hash-{Guid.NewGuid():N}"); + _pinsByHash.Add(hash.Value, pin.Value); + return hash; + } + + public bool Verify(PinHash hash, PinCode pin) + { + return _pinsByHash.GetValueOrDefault(hash.Value) == pin.Value; + } + } } diff --git a/tests/Atm.Application.Tests/Atm.Application.Tests.csproj b/tests/Atm.Application.Tests/Atm.Application.Tests.csproj index 76d3475..6ba4c8d 100644 --- a/tests/Atm.Application.Tests/Atm.Application.Tests.csproj +++ b/tests/Atm.Application.Tests/Atm.Application.Tests.csproj @@ -21,7 +21,6 @@ - diff --git a/tests/Atm.Application.Tests/SessionAuthorizerTests.cs b/tests/Atm.Application.Tests/SessionAuthorizerTests.cs new file mode 100644 index 0000000..8d3466d --- /dev/null +++ b/tests/Atm.Application.Tests/SessionAuthorizerTests.cs @@ -0,0 +1,33 @@ +using Atm.Application.Abstractions.Repositories; +using Atm.Application.Common; +using Atm.Domain.Accounts; +using Atm.Domain.Sessions; +using Atm.Domain.Sessions.Results; +using Atm.Domain.ValueObjects; +using NSubstitute; + +namespace Atm.Application.Tests; + +public sealed class SessionAuthorizerTests +{ + [Fact] + public void AuthorizerRejectsExpiredAndRevokedSessions() + { + ISessionRepository sessions = Substitute.For(); + var clock = new FixedClock(); + Account account = BankingTestData.CreateAccount(new AccountNumber("session-test"), 0m); + var sessionId = Guid.NewGuid(); + Session session = BankingTestData.CreateUserSession(sessionId, account, clock); + sessions.GetById(sessionId).Returns(session); + var authorizer = new SessionAuthorizer(sessions, clock); + + Assert.IsType(authorizer.AuthorizeUser(sessionId)); + + clock.UtcNow = session.ExpiresAtUtc; + Assert.IsType(authorizer.AuthorizeUser(sessionId)); + + clock.UtcNow = session.CreatedAtUtc; + Assert.IsType(session.Revoke(clock.UtcNow)); + Assert.IsType(authorizer.AuthorizeUser(sessionId)); + } +} diff --git a/tests/Atm.Application.Tests/SessionTests.cs b/tests/Atm.Application.Tests/SessionTests.cs deleted file mode 100644 index 576f04e..0000000 --- a/tests/Atm.Application.Tests/SessionTests.cs +++ /dev/null @@ -1,81 +0,0 @@ -using Atm.Application.Abstractions.Repositories; -using Atm.Application.Common; -using Atm.Domain.Accounts; -using Atm.Domain.Sessions; -using Atm.Domain.Sessions.Results; -using Atm.Domain.ValueObjects; -using NSubstitute; - -namespace Atm.Application.Tests; - -public sealed class SessionTests -{ - [Fact] - public void CreateEnforcesIdentityShapeAndUtcLifetime() - { - var clock = new FixedClock(); - - Assert.IsType(Session.Create( - Guid.Empty, SessionType.Admin, null, clock.UtcNow, clock.UtcNow.AddMinutes(1))); - Assert.IsType(Session.Create( - Guid.NewGuid(), SessionType.User, null, clock.UtcNow, clock.UtcNow.AddMinutes(1))); - Assert.IsType(Session.Create( - Guid.NewGuid(), SessionType.Admin, Guid.NewGuid(), clock.UtcNow, clock.UtcNow.AddMinutes(1))); - Assert.IsType(Session.Create( - Guid.NewGuid(), SessionType.Admin, null, clock.UtcNow, clock.UtcNow)); - Assert.IsType(Session.Create( - Guid.NewGuid(), SessionType.Admin, null, - clock.UtcNow.ToOffset(TimeSpan.FromHours(3)), clock.UtcNow.AddMinutes(1))); - } - - [Fact] - public void IsActiveUsesInclusiveStartAndExclusiveExpiration() - { - var clock = new FixedClock(); - Session session = BankingTestData.CreateAdminSession(Guid.NewGuid(), clock); - - Assert.True(session.IsActive(clock.UtcNow)); - Assert.True(session.IsActive(session.ExpiresAtUtc.AddTicks(-1))); - Assert.False(session.IsActive(session.ExpiresAtUtc)); - Assert.False(session.IsActive(clock.UtcNow.AddTicks(-1))); - Assert.False(session.IsActive(clock.UtcNow.ToOffset(TimeSpan.FromHours(3)))); - } - - [Fact] - public void RevokeIsIdempotentAndKeepsTheFirstTimestamp() - { - var clock = new FixedClock(); - Session session = BankingTestData.CreateAdminSession(Guid.NewGuid(), clock); - DateTimeOffset first = clock.UtcNow.AddMinutes(1); - - Assert.IsType(session.Revoke(clock.UtcNow.AddTicks(-1))); - Assert.IsType(session.Revoke(first)); - SessionRevocationResult.Success repeated = Assert.IsType( - session.Revoke(first.AddMinutes(1))); - - Assert.Equal(first, repeated.RevokedAtUtc); - Assert.Equal(first, session.RevokedAtUtc); - Assert.False(session.IsActive(first)); - } - - [Fact] - public void AuthorizerRejectsExpiredAndRevokedSessions() - { - ISessionRepository sessions = Substitute.For(); - var clock = new FixedClock(); - Account account = BankingTestData.CreateAccount(new AccountNumber("session-test"), 0m); - var sessionId = Guid.NewGuid(); - Session session = BankingTestData.CreateUserSession(sessionId, account, clock); - sessions.GetById(sessionId).Returns(session); - var authorizer = new SessionAuthorizer(sessions, clock); - - Assert.IsType(authorizer.AuthorizeUser(sessionId)); - - clock.UtcNow = session.ExpiresAtUtc; - Assert.IsType(authorizer.AuthorizeUser(sessionId)); - - clock.UtcNow = session.CreatedAtUtc; - Assert.IsType(session.Revoke(clock.UtcNow)); - Assert.IsType(authorizer.AuthorizeUser(sessionId)); - } -} diff --git a/tests/Atm.Application.Tests/packages.lock.json b/tests/Atm.Application.Tests/packages.lock.json index 3376167..efaa490 100644 --- a/tests/Atm.Application.Tests/packages.lock.json +++ b/tests/Atm.Application.Tests/packages.lock.json @@ -52,125 +52,16 @@ "System.Diagnostics.EventLog": "6.0.0" } }, - "Microsoft.AspNetCore.Cryptography.Internal": { - "type": "Transitive", - "resolved": "9.0.19", - "contentHash": "zqiWdZ8Jw4/sfTBSFHchU3Iajm8EqLPUq/cUIuUjARwNGJ9hrKR7Yf0bs4WlF+GdPX6Bj7kmaf99aY0OTbLCOg==" - }, - "Microsoft.AspNetCore.Cryptography.KeyDerivation": { - "type": "Transitive", - "resolved": "9.0.19", - "contentHash": "w0ZZWnsz7U2dh5l9eytJYYHG3/HwafewFyZT+NsnBuQAt90cw2hmiX9J9OU1mrtn7EiIJqhRxjzY5kAbmhD2DA==", - "dependencies": { - "Microsoft.AspNetCore.Cryptography.Internal": "9.0.19" - } - }, "Microsoft.CodeCoverage": { "type": "Transitive", "resolved": "17.12.0", "contentHash": "4svMznBd5JM21JIG2xZKGNanAHNXplxf/kQDFfLHXQ3OnpJkayRK/TjacFjA+EYmoyuNXHo/sOETEfcYtAzIrA==" }, - "Microsoft.EntityFrameworkCore": { - "type": "Transitive", - "resolved": "9.0.19", - "contentHash": "qWqqxrfEDfi3C/z0S/ACIA2gUnreRPHX74WE/dVnesbztNesYO4NLy76lE6FV/zDP4vH/pkvDr8h3Mq9+cFv/w==", - "dependencies": { - "Microsoft.EntityFrameworkCore.Abstractions": "9.0.19", - "Microsoft.EntityFrameworkCore.Analyzers": "9.0.19", - "Microsoft.Extensions.Caching.Memory": "9.0.19", - "Microsoft.Extensions.Logging": "9.0.19" - } - }, - "Microsoft.EntityFrameworkCore.Abstractions": { - "type": "Transitive", - "resolved": "9.0.19", - "contentHash": "igPnmVL3DU2ZH/4DNeQqWttNrQpwwg0q6MWj5peZxtl0QVMDSeFtnt2yhhdSffklZKYsAFko1sh/va3z4y7S6A==" - }, - "Microsoft.EntityFrameworkCore.Analyzers": { - "type": "Transitive", - "resolved": "9.0.19", - "contentHash": "yI5dQuvigJ6ICQECOlJDtrsUYcuDryv3qsZK1i6ZA/IzMzZ/VPkDzjE4r2LivuyVSGnkDRkH2cp4LmrhXARRKw==" - }, - "Microsoft.EntityFrameworkCore.Relational": { - "type": "Transitive", - "resolved": "9.0.19", - "contentHash": "KAfYGG4LWRNn1G/vMRydxgOjuZoWUvdd5raUcIia/HPg1jUjlQzS386nUx5gt3YfKxpFJaCGxdsQ1nCZWuYR8A==", - "dependencies": { - "Microsoft.EntityFrameworkCore": "9.0.19", - "Microsoft.Extensions.Caching.Memory": "9.0.19", - "Microsoft.Extensions.Configuration.Abstractions": "9.0.19", - "Microsoft.Extensions.Logging": "9.0.19" - } - }, - "Microsoft.Extensions.Caching.Abstractions": { - "type": "Transitive", - "resolved": "9.0.19", - "contentHash": "qe/6m6wxRG+N5xTf4Z3lDBAiVu6oBCUyADJ5OzlFgukD9Vg/6zB3MNnNEFA7amZg8hjcS6F4OZBRmB07A+gmHQ==", - "dependencies": { - "Microsoft.Extensions.Primitives": "9.0.19" - } - }, - "Microsoft.Extensions.Caching.Memory": { - "type": "Transitive", - "resolved": "9.0.19", - "contentHash": "Y0w5LkBqWkDh5vYTKM+QKlzxpqegEtP86oDhFMXgx4tDkWL2t+rl4h2vxBHkvBtM/gM6PoLK9QCrnoclXtHtVQ==", - "dependencies": { - "Microsoft.Extensions.Caching.Abstractions": "9.0.19", - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.19", - "Microsoft.Extensions.Logging.Abstractions": "9.0.19", - "Microsoft.Extensions.Options": "9.0.19", - "Microsoft.Extensions.Primitives": "9.0.19" - } - }, - "Microsoft.Extensions.Configuration.Abstractions": { - "type": "Transitive", - "resolved": "9.0.19", - "contentHash": "M6h5lX6QWSSn7FDeRUHePZen/xvxPsuhcX1VpBfefafqavn2BnfxheR7BH5D5AIZGM7Nah0xJKnq2DublD4doQ==", - "dependencies": { - "Microsoft.Extensions.Primitives": "9.0.19" - } - }, - "Microsoft.Extensions.DependencyInjection": { - "type": "Transitive", - "resolved": "9.0.19", - "contentHash": "o/XFYSiV4a7eiCkf7W1GFAviVhHQAPY5bHrQRsNWAPT3GhJ3Af63vcFQjHivIrTNZITlPPaGWhBy7jykKYYyaQ==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.19" - } - }, "Microsoft.Extensions.DependencyInjection.Abstractions": { "type": "Transitive", "resolved": "9.0.19", "contentHash": "XgQ0aVWClYjYSBqbYrKC/xyZ1KHf+VVwNF3+d54jcaqnst30Q//ugs/FJwUztLZiph0gp85g7i/eB0556twO0Q==" }, - "Microsoft.Extensions.Identity.Core": { - "type": "Transitive", - "resolved": "9.0.19", - "contentHash": "6P2/iMA35j8RrjsJXhqaOy4Sturcu+NXzXSlKguGTzjBEVRf6atWPrKkUuZew9K5S5NbNh8gdYip2dXm8t5fLg==", - "dependencies": { - "Microsoft.AspNetCore.Cryptography.KeyDerivation": "9.0.19", - "Microsoft.Extensions.Logging": "9.0.19", - "Microsoft.Extensions.Options": "9.0.19" - } - }, - "Microsoft.Extensions.Logging": { - "type": "Transitive", - "resolved": "9.0.19", - "contentHash": "3cnWzJ/FLEhLiW+TJTWtTjVj6fwlsP8OrbJqgOP4J9feAKjmT77vmClakHlej+jnzBgpnVU0+SZwtEIOr+KS6A==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection": "9.0.19", - "Microsoft.Extensions.Logging.Abstractions": "9.0.19", - "Microsoft.Extensions.Options": "9.0.19" - } - }, - "Microsoft.Extensions.Logging.Abstractions": { - "type": "Transitive", - "resolved": "9.0.19", - "contentHash": "RpqMVoZ+NECJxLOZRjWOfjST0mULs4wb848mxG/CS9SMkVL6yi1sSIda4dGcsB01J2jUFd8hNwsaYl1gplU+Cw==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.19" - } - }, "Microsoft.Extensions.Options": { "type": "Transitive", "resolved": "9.0.19", @@ -207,24 +98,6 @@ "resolved": "13.0.1", "contentHash": "ppPFpBcvxdsfUonNcvITKqLl3bqxWbDCZIzDWHzjpdAHRFfZe0Dw9HmA0+za13IdyrgJwpkDTDA9fHaxOrt20A==" }, - "Npgsql": { - "type": "Transitive", - "resolved": "9.0.3", - "contentHash": "tPvY61CxOAWxNsKLEBg+oR646X4Bc8UmyQ/tJszL/7mEmIXQnnBhVJZrZEEUv0Bstu0mEsHZD5At3EO8zQRAYw==", - "dependencies": { - "Microsoft.Extensions.Logging.Abstractions": "8.0.2" - } - }, - "Npgsql.EntityFrameworkCore.PostgreSQL": { - "type": "Transitive", - "resolved": "9.0.4", - "contentHash": "mw5vcY2IEc7L+IeGrxpp/J5OSnCcjkjAgJYCm/eD52wpZze8zsSifdqV7zXslSMmfJG2iIUGZyo3KuDtEFKwMQ==", - "dependencies": { - "Microsoft.EntityFrameworkCore": "[9.0.1, 10.0.0)", - "Microsoft.EntityFrameworkCore.Relational": "[9.0.1, 10.0.0)", - "Npgsql": "9.0.3" - } - }, "System.Diagnostics.EventLog": { "type": "Transitive", "resolved": "6.0.0", @@ -296,18 +169,6 @@ }, "atm.domain": { "type": "Project" - }, - "atm.infrastructure": { - "type": "Project", - "dependencies": { - "Atm.Application.Abstractions": "[1.0.0, )", - "Atm.Domain": "[1.0.0, )", - "Microsoft.EntityFrameworkCore": "[9.0.19, )", - "Microsoft.EntityFrameworkCore.Relational": "[9.0.19, )", - "Microsoft.Extensions.DependencyInjection.Abstractions": "[9.0.19, )", - "Microsoft.Extensions.Identity.Core": "[9.0.19, )", - "Npgsql.EntityFrameworkCore.PostgreSQL": "[9.0.4, )" - } } } } diff --git a/tests/Atm.Application.Tests/AccountTests.cs b/tests/Atm.Domain.Tests/AccountTests.cs similarity index 80% rename from tests/Atm.Application.Tests/AccountTests.cs rename to tests/Atm.Domain.Tests/AccountTests.cs index c9e2a0c..58a5e74 100644 --- a/tests/Atm.Application.Tests/AccountTests.cs +++ b/tests/Atm.Domain.Tests/AccountTests.cs @@ -2,7 +2,7 @@ using Atm.Domain.Accounts.Results; using Atm.Domain.ValueObjects; -namespace Atm.Application.Tests; +namespace Atm.Domain.Tests; public class AccountTests { @@ -11,18 +11,18 @@ public void CreateRequiresAnOwnerAndUtcTime() { var number = new AccountNumber("test"); var hash = new PinHash("test-only-hash"); - var clock = new FixedClock(); + DateTimeOffset now = DomainTestData.UtcNow; Assert.IsType(Account.Create( - Guid.NewGuid(), number, Guid.Empty, hash, new Money(0m), clock.UtcNow)); + Guid.NewGuid(), number, Guid.Empty, hash, new Money(0m), now)); Assert.IsType(Account.Create( - Guid.NewGuid(), number, Guid.NewGuid(), hash, new Money(0m), clock.UtcNow.ToOffset(TimeSpan.FromHours(3)))); + Guid.NewGuid(), number, Guid.NewGuid(), hash, new Money(0m), now.ToOffset(TimeSpan.FromHours(3)))); } [Fact] public void SuccessfulChangesAdvanceTheVersionAndTimestamp() { - Account account = BankingTestData.CreateAccount(new AccountNumber("test"), 100m); + Account account = DomainTestData.CreateAccount(new AccountNumber("test"), 100m); DateTimeOffset now = account.CreatedAtUtc.AddMinutes(1); Assert.IsType(account.Deposit(new Amount(10m), now)); @@ -36,7 +36,7 @@ public void SuccessfulChangesAdvanceTheVersionAndTimestamp() [Fact] public void FailedChangesDoNotMutateTheAccount() { - Account account = BankingTestData.CreateAccount(new AccountNumber("test"), 100m); + Account account = DomainTestData.CreateAccount(new AccountNumber("test"), 100m); DateTimeOffset now = account.CreatedAtUtc; Assert.IsType(account.Withdraw(new Amount(101m), now)); @@ -49,11 +49,11 @@ public void FailedChangesDoNotMutateTheAccount() [Fact] public void LargeBalancesHaveNoBusinessCeilingButCannotLosePrecision() { - Account account = BankingTestData.CreateAccount(new AccountNumber("large"), 1000000000000000m); + Account account = DomainTestData.CreateAccount(new AccountNumber("large"), 1000000000000000m); Assert.IsType(account.Deposit(new Amount(1m), account.CreatedAtUtc)); Assert.Equal(1000000000000001m, account.Balance.Value); - Account largest = BankingTestData.CreateAccount(new AccountNumber("largest"), decimal.MaxValue); + Account largest = DomainTestData.CreateAccount(new AccountNumber("largest"), decimal.MaxValue); Assert.IsType(largest.Deposit(new Amount(1m), largest.CreatedAtUtc)); Assert.IsType(largest.Deposit(new Amount(0.0001m), largest.CreatedAtUtc)); Assert.IsType(largest.Withdraw(new Amount(0.0001m), largest.CreatedAtUtc)); diff --git a/tests/Atm.Domain.Tests/Atm.Domain.Tests.csproj b/tests/Atm.Domain.Tests/Atm.Domain.Tests.csproj new file mode 100644 index 0000000..69d6dd0 --- /dev/null +++ b/tests/Atm.Domain.Tests/Atm.Domain.Tests.csproj @@ -0,0 +1,25 @@ + + + + net9.0 + enable + enable + false + + + + + + + + + + + + + + + + + + diff --git a/tests/Atm.Application.Tests/BankTransactionTests.cs b/tests/Atm.Domain.Tests/BankTransactionTests.cs similarity index 99% rename from tests/Atm.Application.Tests/BankTransactionTests.cs rename to tests/Atm.Domain.Tests/BankTransactionTests.cs index 5f91d95..c6fa6a3 100644 --- a/tests/Atm.Application.Tests/BankTransactionTests.cs +++ b/tests/Atm.Domain.Tests/BankTransactionTests.cs @@ -2,7 +2,7 @@ using Atm.Domain.Operations.Results; using Atm.Domain.ValueObjects; -namespace Atm.Application.Tests; +namespace Atm.Domain.Tests; public sealed class BankTransactionTests { diff --git a/tests/Atm.Domain.Tests/DomainTestData.cs b/tests/Atm.Domain.Tests/DomainTestData.cs new file mode 100644 index 0000000..e5e84a4 --- /dev/null +++ b/tests/Atm.Domain.Tests/DomainTestData.cs @@ -0,0 +1,34 @@ +using Atm.Domain.Accounts; +using Atm.Domain.Accounts.Results; +using Atm.Domain.Sessions; +using Atm.Domain.Sessions.Results; +using Atm.Domain.ValueObjects; + +namespace Atm.Domain.Tests; + +internal static class DomainTestData +{ + public static DateTimeOffset UtcNow { get; } = + new(2026, 9, 2, 12, 0, 0, TimeSpan.Zero); + + public static Account CreateAccount(AccountNumber number, decimal balance) + { + return Assert.IsType(Account.Create( + Guid.NewGuid(), + number, + Guid.NewGuid(), + new PinHash("test-only-hash"), + new Money(balance), + UtcNow)).Account; + } + + public static Session CreateAdminSession(Guid id) + { + return Assert.IsType(Session.Create( + id, + SessionType.Admin, + null, + UtcNow, + UtcNow.AddMinutes(15))).Session; + } +} diff --git a/tests/Atm.Domain.Tests/SessionTests.cs b/tests/Atm.Domain.Tests/SessionTests.cs new file mode 100644 index 0000000..595e57e --- /dev/null +++ b/tests/Atm.Domain.Tests/SessionTests.cs @@ -0,0 +1,58 @@ +using Atm.Domain.Sessions; +using Atm.Domain.Sessions.Results; + +namespace Atm.Domain.Tests; + +public sealed class SessionTests +{ + [Fact] + public void CreateEnforcesIdentityShapeAndUtcLifetime() + { + DateTimeOffset now = DomainTestData.UtcNow; + + Assert.IsType(Session.Create( + Guid.Empty, SessionType.Admin, null, now, now.AddMinutes(1))); + Assert.IsType(Session.Create( + Guid.NewGuid(), SessionType.User, null, now, now.AddMinutes(1))); + Assert.IsType(Session.Create( + Guid.NewGuid(), SessionType.Admin, Guid.NewGuid(), now, now.AddMinutes(1))); + Assert.IsType(Session.Create( + Guid.NewGuid(), SessionType.Admin, null, now, now)); + Assert.IsType(Session.Create( + Guid.NewGuid(), + SessionType.Admin, + null, + now.ToOffset(TimeSpan.FromHours(3)), + now.AddMinutes(1))); + } + + [Fact] + public void IsActiveUsesInclusiveStartAndExclusiveExpiration() + { + DateTimeOffset now = DomainTestData.UtcNow; + Session session = DomainTestData.CreateAdminSession(Guid.NewGuid()); + + Assert.True(session.IsActive(now)); + Assert.True(session.IsActive(session.ExpiresAtUtc.AddTicks(-1))); + Assert.False(session.IsActive(session.ExpiresAtUtc)); + Assert.False(session.IsActive(now.AddTicks(-1))); + Assert.False(session.IsActive(now.ToOffset(TimeSpan.FromHours(3)))); + } + + [Fact] + public void RevokeIsIdempotentAndKeepsTheFirstTimestamp() + { + DateTimeOffset now = DomainTestData.UtcNow; + Session session = DomainTestData.CreateAdminSession(Guid.NewGuid()); + DateTimeOffset first = now.AddMinutes(1); + + Assert.IsType(session.Revoke(now.AddTicks(-1))); + Assert.IsType(session.Revoke(first)); + SessionRevocationResult.Success repeated = Assert.IsType( + session.Revoke(first.AddMinutes(1))); + + Assert.Equal(first, repeated.RevokedAtUtc); + Assert.Equal(first, session.RevokedAtUtc); + Assert.False(session.IsActive(first)); + } +} diff --git a/tests/Atm.Application.Tests/UserTests.cs b/tests/Atm.Domain.Tests/UserTests.cs similarity index 98% rename from tests/Atm.Application.Tests/UserTests.cs rename to tests/Atm.Domain.Tests/UserTests.cs index 2ee6957..9b6bff3 100644 --- a/tests/Atm.Application.Tests/UserTests.cs +++ b/tests/Atm.Domain.Tests/UserTests.cs @@ -2,7 +2,7 @@ using Atm.Domain.Users.Results; using Atm.Domain.ValueObjects; -namespace Atm.Application.Tests; +namespace Atm.Domain.Tests; public sealed class UserTests { diff --git a/tests/Atm.Application.Tests/ValueObjectTests.cs b/tests/Atm.Domain.Tests/ValueObjectTests.cs similarity index 98% rename from tests/Atm.Application.Tests/ValueObjectTests.cs rename to tests/Atm.Domain.Tests/ValueObjectTests.cs index 8fc1dcb..2baa8af 100644 --- a/tests/Atm.Application.Tests/ValueObjectTests.cs +++ b/tests/Atm.Domain.Tests/ValueObjectTests.cs @@ -1,7 +1,7 @@ using Atm.Domain.ValueObjects; using System.Globalization; -namespace Atm.Application.Tests; +namespace Atm.Domain.Tests; public sealed class ValueObjectTests { diff --git a/tests/Atm.Infrastructure.Tests/Atm.Infrastructure.Tests.csproj b/tests/Atm.Infrastructure.Tests/Atm.Infrastructure.Tests.csproj new file mode 100644 index 0000000..f207203 --- /dev/null +++ b/tests/Atm.Infrastructure.Tests/Atm.Infrastructure.Tests.csproj @@ -0,0 +1,25 @@ + + + + net9.0 + enable + enable + false + + + + + + + + + + + + + + + + + + diff --git a/tests/Atm.Application.Tests/PinHasherTests.cs b/tests/Atm.Infrastructure.Tests/PinHasherTests.cs similarity index 96% rename from tests/Atm.Application.Tests/PinHasherTests.cs rename to tests/Atm.Infrastructure.Tests/PinHasherTests.cs index da10d6c..528b558 100644 --- a/tests/Atm.Application.Tests/PinHasherTests.cs +++ b/tests/Atm.Infrastructure.Tests/PinHasherTests.cs @@ -1,7 +1,7 @@ using Atm.Domain.ValueObjects; using Atm.Infrastructure.Security; -namespace Atm.Application.Tests; +namespace Atm.Infrastructure.Tests; public class PinHasherTests { From a63f57034e7bdff8d62668c026f0ca117cece14e Mon Sep 17 00:00:00 2001 From: 666mxvbee Date: Fri, 4 Sep 2026 20:42:51 +0300 Subject: [PATCH 11/11] chore(test): lock new test project dependencies --- tests/Atm.Domain.Tests/packages.lock.json | 115 ++++++++ .../packages.lock.json | 279 ++++++++++++++++++ 2 files changed, 394 insertions(+) create mode 100644 tests/Atm.Domain.Tests/packages.lock.json create mode 100644 tests/Atm.Infrastructure.Tests/packages.lock.json diff --git a/tests/Atm.Domain.Tests/packages.lock.json b/tests/Atm.Domain.Tests/packages.lock.json new file mode 100644 index 0000000..04bcc42 --- /dev/null +++ b/tests/Atm.Domain.Tests/packages.lock.json @@ -0,0 +1,115 @@ +{ + "version": 1, + "dependencies": { + "net9.0": { + "coverlet.collector": { + "type": "Direct", + "requested": "[6.0.2, )", + "resolved": "6.0.2", + "contentHash": "bJShQ6uWRTQ100ZeyiMqcFlhP7WJ+bCuabUs885dJiBEzMsJMSFr7BOyeCw4rgvQokteGi5rKQTlkhfQPUXg2A==" + }, + "Microsoft.NET.Test.Sdk": { + "type": "Direct", + "requested": "[17.12.0, )", + "resolved": "17.12.0", + "contentHash": "kt/PKBZ91rFCWxVIJZSgVLk+YR+4KxTuHf799ho8WNiK5ZQpJNAEZCAWX86vcKrs+DiYjiibpYKdGZP6+/N17w==", + "dependencies": { + "Microsoft.CodeCoverage": "17.12.0", + "Microsoft.TestPlatform.TestHost": "17.12.0" + } + }, + "xunit": { + "type": "Direct", + "requested": "[2.9.2, )", + "resolved": "2.9.2", + "contentHash": "7LhFS2N9Z6Xgg8aE5lY95cneYivRMfRI8v+4PATa4S64D5Z/Plkg0qa8dTRHSiGRgVZ/CL2gEfJDE5AUhOX+2Q==", + "dependencies": { + "xunit.analyzers": "1.16.0", + "xunit.assert": "2.9.2", + "xunit.core": "[2.9.2]" + } + }, + "xunit.runner.visualstudio": { + "type": "Direct", + "requested": "[2.8.2, )", + "resolved": "2.8.2", + "contentHash": "vm1tbfXhFmjFMUmS4M0J0ASXz3/U5XvXBa6DOQUL3fEz4Vt6YPhv+ESCarx6M6D+9kJkJYZKCNvJMas1+nVfmQ==" + }, + "Microsoft.CodeCoverage": { + "type": "Transitive", + "resolved": "17.12.0", + "contentHash": "4svMznBd5JM21JIG2xZKGNanAHNXplxf/kQDFfLHXQ3OnpJkayRK/TjacFjA+EYmoyuNXHo/sOETEfcYtAzIrA==" + }, + "Microsoft.TestPlatform.ObjectModel": { + "type": "Transitive", + "resolved": "17.12.0", + "contentHash": "TDqkTKLfQuAaPcEb3pDDWnh7b3SyZF+/W9OZvWFp6eJCIiiYFdSB6taE2I6tWrFw5ywhzOb6sreoGJTI6m3rSQ==", + "dependencies": { + "System.Reflection.Metadata": "1.6.0" + } + }, + "Microsoft.TestPlatform.TestHost": { + "type": "Transitive", + "resolved": "17.12.0", + "contentHash": "MiPEJQNyADfwZ4pJNpQex+t9/jOClBGMiCiVVFuELCMSX2nmNfvUor3uFVxNNCg30uxDP8JDYfPnMXQzsfzYyg==", + "dependencies": { + "Microsoft.TestPlatform.ObjectModel": "17.12.0", + "Newtonsoft.Json": "13.0.1" + } + }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.1", + "contentHash": "ppPFpBcvxdsfUonNcvITKqLl3bqxWbDCZIzDWHzjpdAHRFfZe0Dw9HmA0+za13IdyrgJwpkDTDA9fHaxOrt20A==" + }, + "System.Reflection.Metadata": { + "type": "Transitive", + "resolved": "1.6.0", + "contentHash": "COC1aiAJjCoA5GBF+QKL2uLqEBew4JsCkQmoHKbN3TlOZKa2fKLz5CpiRQKDz0RsAOEGsVKqOD5bomsXq/4STQ==" + }, + "xunit.abstractions": { + "type": "Transitive", + "resolved": "2.0.3", + "contentHash": "pot1I4YOxlWjIb5jmwvvQNbTrZ3lJQ+jUGkGjWE3hEFM0l5gOnBWS+H3qsex68s5cO52g+44vpGzhAt+42vwKg==" + }, + "xunit.analyzers": { + "type": "Transitive", + "resolved": "1.16.0", + "contentHash": "hptYM7vGr46GUIgZt21YHO4rfuBAQS2eINbFo16CV/Dqq+24Tp+P5gDCACu1AbFfW4Sp/WRfDPSK8fmUUb8s0Q==" + }, + "xunit.assert": { + "type": "Transitive", + "resolved": "2.9.2", + "contentHash": "QkNBAQG4pa66cholm28AxijBjrmki98/vsEh4Sx5iplzotvPgpiotcxqJQMRC8d7RV7nIT8ozh97957hDnZwsQ==" + }, + "xunit.core": { + "type": "Transitive", + "resolved": "2.9.2", + "contentHash": "O6RrNSdmZ0xgEn5kT927PNwog5vxTtKrWMihhhrT0Sg9jQ7iBDciYOwzBgP2krBEk5/GBXI18R1lKvmnxGcb4w==", + "dependencies": { + "xunit.extensibility.core": "[2.9.2]", + "xunit.extensibility.execution": "[2.9.2]" + } + }, + "xunit.extensibility.core": { + "type": "Transitive", + "resolved": "2.9.2", + "contentHash": "Ol+KlBJz1x8BrdnhN2DeOuLrr1I/cTwtHCggL9BvYqFuVd/TUSzxNT5O0NxCIXth30bsKxgMfdqLTcORtM52yQ==", + "dependencies": { + "xunit.abstractions": "2.0.3" + } + }, + "xunit.extensibility.execution": { + "type": "Transitive", + "resolved": "2.9.2", + "contentHash": "rKMpq4GsIUIJibXuZoZ8lYp5EpROlnYaRpwu9Zr0sRZXE7JqJfEEbCsUriZqB+ByXCLFBJyjkTRULMdC+U566g==", + "dependencies": { + "xunit.extensibility.core": "[2.9.2]" + } + }, + "atm.domain": { + "type": "Project" + } + } + } +} \ No newline at end of file diff --git a/tests/Atm.Infrastructure.Tests/packages.lock.json b/tests/Atm.Infrastructure.Tests/packages.lock.json new file mode 100644 index 0000000..43b0953 --- /dev/null +++ b/tests/Atm.Infrastructure.Tests/packages.lock.json @@ -0,0 +1,279 @@ +{ + "version": 1, + "dependencies": { + "net9.0": { + "coverlet.collector": { + "type": "Direct", + "requested": "[6.0.2, )", + "resolved": "6.0.2", + "contentHash": "bJShQ6uWRTQ100ZeyiMqcFlhP7WJ+bCuabUs885dJiBEzMsJMSFr7BOyeCw4rgvQokteGi5rKQTlkhfQPUXg2A==" + }, + "Microsoft.NET.Test.Sdk": { + "type": "Direct", + "requested": "[17.12.0, )", + "resolved": "17.12.0", + "contentHash": "kt/PKBZ91rFCWxVIJZSgVLk+YR+4KxTuHf799ho8WNiK5ZQpJNAEZCAWX86vcKrs+DiYjiibpYKdGZP6+/N17w==", + "dependencies": { + "Microsoft.CodeCoverage": "17.12.0", + "Microsoft.TestPlatform.TestHost": "17.12.0" + } + }, + "xunit": { + "type": "Direct", + "requested": "[2.9.2, )", + "resolved": "2.9.2", + "contentHash": "7LhFS2N9Z6Xgg8aE5lY95cneYivRMfRI8v+4PATa4S64D5Z/Plkg0qa8dTRHSiGRgVZ/CL2gEfJDE5AUhOX+2Q==", + "dependencies": { + "xunit.analyzers": "1.16.0", + "xunit.assert": "2.9.2", + "xunit.core": "[2.9.2]" + } + }, + "xunit.runner.visualstudio": { + "type": "Direct", + "requested": "[2.8.2, )", + "resolved": "2.8.2", + "contentHash": "vm1tbfXhFmjFMUmS4M0J0ASXz3/U5XvXBa6DOQUL3fEz4Vt6YPhv+ESCarx6M6D+9kJkJYZKCNvJMas1+nVfmQ==" + }, + "Microsoft.AspNetCore.Cryptography.Internal": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "zqiWdZ8Jw4/sfTBSFHchU3Iajm8EqLPUq/cUIuUjARwNGJ9hrKR7Yf0bs4WlF+GdPX6Bj7kmaf99aY0OTbLCOg==" + }, + "Microsoft.AspNetCore.Cryptography.KeyDerivation": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "w0ZZWnsz7U2dh5l9eytJYYHG3/HwafewFyZT+NsnBuQAt90cw2hmiX9J9OU1mrtn7EiIJqhRxjzY5kAbmhD2DA==", + "dependencies": { + "Microsoft.AspNetCore.Cryptography.Internal": "9.0.19" + } + }, + "Microsoft.CodeCoverage": { + "type": "Transitive", + "resolved": "17.12.0", + "contentHash": "4svMznBd5JM21JIG2xZKGNanAHNXplxf/kQDFfLHXQ3OnpJkayRK/TjacFjA+EYmoyuNXHo/sOETEfcYtAzIrA==" + }, + "Microsoft.EntityFrameworkCore": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "qWqqxrfEDfi3C/z0S/ACIA2gUnreRPHX74WE/dVnesbztNesYO4NLy76lE6FV/zDP4vH/pkvDr8h3Mq9+cFv/w==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Abstractions": "9.0.19", + "Microsoft.EntityFrameworkCore.Analyzers": "9.0.19", + "Microsoft.Extensions.Caching.Memory": "9.0.19", + "Microsoft.Extensions.Logging": "9.0.19" + } + }, + "Microsoft.EntityFrameworkCore.Abstractions": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "igPnmVL3DU2ZH/4DNeQqWttNrQpwwg0q6MWj5peZxtl0QVMDSeFtnt2yhhdSffklZKYsAFko1sh/va3z4y7S6A==" + }, + "Microsoft.EntityFrameworkCore.Analyzers": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "yI5dQuvigJ6ICQECOlJDtrsUYcuDryv3qsZK1i6ZA/IzMzZ/VPkDzjE4r2LivuyVSGnkDRkH2cp4LmrhXARRKw==" + }, + "Microsoft.EntityFrameworkCore.Relational": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "KAfYGG4LWRNn1G/vMRydxgOjuZoWUvdd5raUcIia/HPg1jUjlQzS386nUx5gt3YfKxpFJaCGxdsQ1nCZWuYR8A==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "9.0.19", + "Microsoft.Extensions.Caching.Memory": "9.0.19", + "Microsoft.Extensions.Configuration.Abstractions": "9.0.19", + "Microsoft.Extensions.Logging": "9.0.19" + } + }, + "Microsoft.Extensions.Caching.Abstractions": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "qe/6m6wxRG+N5xTf4Z3lDBAiVu6oBCUyADJ5OzlFgukD9Vg/6zB3MNnNEFA7amZg8hjcS6F4OZBRmB07A+gmHQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "9.0.19" + } + }, + "Microsoft.Extensions.Caching.Memory": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "Y0w5LkBqWkDh5vYTKM+QKlzxpqegEtP86oDhFMXgx4tDkWL2t+rl4h2vxBHkvBtM/gM6PoLK9QCrnoclXtHtVQ==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "9.0.19", + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.19", + "Microsoft.Extensions.Logging.Abstractions": "9.0.19", + "Microsoft.Extensions.Options": "9.0.19", + "Microsoft.Extensions.Primitives": "9.0.19" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "M6h5lX6QWSSn7FDeRUHePZen/xvxPsuhcX1VpBfefafqavn2BnfxheR7BH5D5AIZGM7Nah0xJKnq2DublD4doQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "9.0.19" + } + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "o/XFYSiV4a7eiCkf7W1GFAviVhHQAPY5bHrQRsNWAPT3GhJ3Af63vcFQjHivIrTNZITlPPaGWhBy7jykKYYyaQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.19" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "XgQ0aVWClYjYSBqbYrKC/xyZ1KHf+VVwNF3+d54jcaqnst30Q//ugs/FJwUztLZiph0gp85g7i/eB0556twO0Q==" + }, + "Microsoft.Extensions.Identity.Core": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "6P2/iMA35j8RrjsJXhqaOy4Sturcu+NXzXSlKguGTzjBEVRf6atWPrKkUuZew9K5S5NbNh8gdYip2dXm8t5fLg==", + "dependencies": { + "Microsoft.AspNetCore.Cryptography.KeyDerivation": "9.0.19", + "Microsoft.Extensions.Logging": "9.0.19", + "Microsoft.Extensions.Options": "9.0.19" + } + }, + "Microsoft.Extensions.Logging": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "3cnWzJ/FLEhLiW+TJTWtTjVj6fwlsP8OrbJqgOP4J9feAKjmT77vmClakHlej+jnzBgpnVU0+SZwtEIOr+KS6A==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "9.0.19", + "Microsoft.Extensions.Logging.Abstractions": "9.0.19", + "Microsoft.Extensions.Options": "9.0.19" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "RpqMVoZ+NECJxLOZRjWOfjST0mULs4wb848mxG/CS9SMkVL6yi1sSIda4dGcsB01J2jUFd8hNwsaYl1gplU+Cw==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.19" + } + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "FdPZy4kUSwFlIVZ3EkK9fdHw2RfblUwzflSb4kjVeGf9v2lX9IH+q2RGUovYMR7CVfKMc2y2Db/bZ2b4yiMfOg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.19", + "Microsoft.Extensions.Primitives": "9.0.19" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "9.0.19", + "contentHash": "9hIg8PQiMnpVFIsEHm25Wi1gBrPa2pS1G75uveyDUVLXrNKqBap9WGwQIgO0s6LUgRAOdsSYnbKbNC5b1G5Pcg==" + }, + "Microsoft.TestPlatform.ObjectModel": { + "type": "Transitive", + "resolved": "17.12.0", + "contentHash": "TDqkTKLfQuAaPcEb3pDDWnh7b3SyZF+/W9OZvWFp6eJCIiiYFdSB6taE2I6tWrFw5ywhzOb6sreoGJTI6m3rSQ==", + "dependencies": { + "System.Reflection.Metadata": "1.6.0" + } + }, + "Microsoft.TestPlatform.TestHost": { + "type": "Transitive", + "resolved": "17.12.0", + "contentHash": "MiPEJQNyADfwZ4pJNpQex+t9/jOClBGMiCiVVFuELCMSX2nmNfvUor3uFVxNNCg30uxDP8JDYfPnMXQzsfzYyg==", + "dependencies": { + "Microsoft.TestPlatform.ObjectModel": "17.12.0", + "Newtonsoft.Json": "13.0.1" + } + }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.1", + "contentHash": "ppPFpBcvxdsfUonNcvITKqLl3bqxWbDCZIzDWHzjpdAHRFfZe0Dw9HmA0+za13IdyrgJwpkDTDA9fHaxOrt20A==" + }, + "Npgsql": { + "type": "Transitive", + "resolved": "9.0.3", + "contentHash": "tPvY61CxOAWxNsKLEBg+oR646X4Bc8UmyQ/tJszL/7mEmIXQnnBhVJZrZEEUv0Bstu0mEsHZD5At3EO8zQRAYw==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "8.0.2" + } + }, + "Npgsql.EntityFrameworkCore.PostgreSQL": { + "type": "Transitive", + "resolved": "9.0.4", + "contentHash": "mw5vcY2IEc7L+IeGrxpp/J5OSnCcjkjAgJYCm/eD52wpZze8zsSifdqV7zXslSMmfJG2iIUGZyo3KuDtEFKwMQ==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "[9.0.1, 10.0.0)", + "Microsoft.EntityFrameworkCore.Relational": "[9.0.1, 10.0.0)", + "Npgsql": "9.0.3" + } + }, + "System.Reflection.Metadata": { + "type": "Transitive", + "resolved": "1.6.0", + "contentHash": "COC1aiAJjCoA5GBF+QKL2uLqEBew4JsCkQmoHKbN3TlOZKa2fKLz5CpiRQKDz0RsAOEGsVKqOD5bomsXq/4STQ==" + }, + "xunit.abstractions": { + "type": "Transitive", + "resolved": "2.0.3", + "contentHash": "pot1I4YOxlWjIb5jmwvvQNbTrZ3lJQ+jUGkGjWE3hEFM0l5gOnBWS+H3qsex68s5cO52g+44vpGzhAt+42vwKg==" + }, + "xunit.analyzers": { + "type": "Transitive", + "resolved": "1.16.0", + "contentHash": "hptYM7vGr46GUIgZt21YHO4rfuBAQS2eINbFo16CV/Dqq+24Tp+P5gDCACu1AbFfW4Sp/WRfDPSK8fmUUb8s0Q==" + }, + "xunit.assert": { + "type": "Transitive", + "resolved": "2.9.2", + "contentHash": "QkNBAQG4pa66cholm28AxijBjrmki98/vsEh4Sx5iplzotvPgpiotcxqJQMRC8d7RV7nIT8ozh97957hDnZwsQ==" + }, + "xunit.core": { + "type": "Transitive", + "resolved": "2.9.2", + "contentHash": "O6RrNSdmZ0xgEn5kT927PNwog5vxTtKrWMihhhrT0Sg9jQ7iBDciYOwzBgP2krBEk5/GBXI18R1lKvmnxGcb4w==", + "dependencies": { + "xunit.extensibility.core": "[2.9.2]", + "xunit.extensibility.execution": "[2.9.2]" + } + }, + "xunit.extensibility.core": { + "type": "Transitive", + "resolved": "2.9.2", + "contentHash": "Ol+KlBJz1x8BrdnhN2DeOuLrr1I/cTwtHCggL9BvYqFuVd/TUSzxNT5O0NxCIXth30bsKxgMfdqLTcORtM52yQ==", + "dependencies": { + "xunit.abstractions": "2.0.3" + } + }, + "xunit.extensibility.execution": { + "type": "Transitive", + "resolved": "2.9.2", + "contentHash": "rKMpq4GsIUIJibXuZoZ8lYp5EpROlnYaRpwu9Zr0sRZXE7JqJfEEbCsUriZqB+ByXCLFBJyjkTRULMdC+U566g==", + "dependencies": { + "xunit.extensibility.core": "[2.9.2]" + } + }, + "atm.application.abstractions": { + "type": "Project", + "dependencies": { + "Atm.Domain": "[1.0.0, )" + } + }, + "atm.domain": { + "type": "Project" + }, + "atm.infrastructure": { + "type": "Project", + "dependencies": { + "Atm.Application.Abstractions": "[1.0.0, )", + "Atm.Domain": "[1.0.0, )", + "Microsoft.EntityFrameworkCore": "[9.0.19, )", + "Microsoft.EntityFrameworkCore.Relational": "[9.0.19, )", + "Microsoft.Extensions.DependencyInjection.Abstractions": "[9.0.19, )", + "Microsoft.Extensions.Identity.Core": "[9.0.19, )", + "Npgsql.EntityFrameworkCore.PostgreSQL": "[9.0.4, )" + } + } + } + } +} \ No newline at end of file