diff --git a/src/BirdsiteLive.Common/Settings/InstanceSettings.cs b/src/BirdsiteLive.Common/Settings/InstanceSettings.cs index aabe822..c0fff2e 100644 --- a/src/BirdsiteLive.Common/Settings/InstanceSettings.cs +++ b/src/BirdsiteLive.Common/Settings/InstanceSettings.cs @@ -3,5 +3,7 @@ public class InstanceSettings { public string Domain { get; set; } + + public string PostgresConnString { get; set; } } } \ No newline at end of file diff --git a/src/BirdsiteLive.Domain/BirdsiteLive.Domain.csproj b/src/BirdsiteLive.Domain/BirdsiteLive.Domain.csproj index 50eb4d2..cb89578 100644 --- a/src/BirdsiteLive.Domain/BirdsiteLive.Domain.csproj +++ b/src/BirdsiteLive.Domain/BirdsiteLive.Domain.csproj @@ -8,6 +8,7 @@ + diff --git a/src/BirdsiteLive.Domain/BusinessUseCases/ProcessFollowUser.cs b/src/BirdsiteLive.Domain/BusinessUseCases/ProcessFollowUser.cs new file mode 100644 index 0000000..c09c696 --- /dev/null +++ b/src/BirdsiteLive.Domain/BusinessUseCases/ProcessFollowUser.cs @@ -0,0 +1,55 @@ +using System.Threading.Tasks; +using BirdsiteLive.DAL.Contracts; + +namespace BirdsiteLive.Domain.BusinessUseCases +{ + public interface IProcessFollowUser + { + Task ExecuteAsync(string followerUsername, string followerDomain, string followerInbox, string twitterUser); + } + + public class ProcessFollowUser : IProcessFollowUser + { + private readonly IFollowersDal _followerDal; + private readonly ITwitterUserDal _twitterUserDal; + + #region Ctor + public ProcessFollowUser(IFollowersDal followerDal, ITwitterUserDal twitterUserDal) + { + _followerDal = followerDal; + _twitterUserDal = twitterUserDal; + } + #endregion + + public async Task ExecuteAsync(string followerUsername, string followerDomain, string followerInbox, string twitterUsername) + { + // Get Follower and Twitter Users + var follower = await _followerDal.GetFollowerAsync(followerUsername, followerDomain); + if (follower == null) + { + await _followerDal.CreateFollowerAsync(followerUsername, followerDomain, followerInbox); + follower = await _followerDal.GetFollowerAsync(followerUsername, followerDomain); + } + + var twitterUser = await _twitterUserDal.GetTwitterUserAsync(twitterUsername); + if (twitterUser == null) + { + await _twitterUserDal.CreateTwitterUserAsync(twitterUsername, -1); + twitterUser = await _twitterUserDal.GetTwitterUserAsync(twitterUsername); + } + + // Update Follower + var twitterUserId = twitterUser.Id; + if(!follower.Followings.Contains(twitterUserId)) + follower.Followings.Add(twitterUserId); + + if(!follower.FollowingsSyncStatus.ContainsKey(twitterUserId)) + follower.FollowingsSyncStatus.Add(twitterUserId, -1); + + follower.FollowingsSyncStatus[twitterUserId] = -1; + + // Save Follower + await _followerDal.UpdateFollowerAsync(follower); + } + } +} \ No newline at end of file diff --git a/src/BirdsiteLive.Domain/BusinessUseCases/ProcessUnfollowUser.cs b/src/BirdsiteLive.Domain/BusinessUseCases/ProcessUnfollowUser.cs new file mode 100644 index 0000000..fe4035f --- /dev/null +++ b/src/BirdsiteLive.Domain/BusinessUseCases/ProcessUnfollowUser.cs @@ -0,0 +1,7 @@ +namespace BirdsiteLive.Domain.BusinessUseCases +{ + public class ProcessUnfollowUser + { + + } +} \ No newline at end of file diff --git a/src/BirdsiteLive.Domain/UserService.cs b/src/BirdsiteLive.Domain/UserService.cs index d084e97..c6ead30 100644 --- a/src/BirdsiteLive.Domain/UserService.cs +++ b/src/BirdsiteLive.Domain/UserService.cs @@ -8,6 +8,7 @@ using System.Threading.Tasks; using BirdsiteLive.ActivityPub; using BirdsiteLive.Common.Settings; using BirdsiteLive.Cryptography; +using BirdsiteLive.Domain.BusinessUseCases; using BirdsiteLive.Twitter.Models; using Tweetinvi.Core.Exceptions; using Tweetinvi.Models; @@ -23,15 +24,18 @@ namespace BirdsiteLive.Domain public class UserService : IUserService { + private readonly IProcessFollowUser _processFollowUser; + private readonly ICryptoService _cryptoService; private readonly IActivityPubService _activityPubService; private readonly string _host; #region Ctor - public UserService(InstanceSettings instanceSettings, ICryptoService cryptoService, IActivityPubService activityPubService) + public UserService(InstanceSettings instanceSettings, ICryptoService cryptoService, IActivityPubService activityPubService, IProcessFollowUser processFollowUser) { _cryptoService = cryptoService; _activityPubService = activityPubService; + _processFollowUser = processFollowUser; _host = $"https://{instanceSettings.Domain.Replace("https://",string.Empty).Replace("http://", string.Empty).TrimEnd('/')}"; } #endregion @@ -100,15 +104,21 @@ namespace BirdsiteLive.Domain return note; } - public async Task FollowRequestedAsync(string signature, string method, string path, string queryString, Dictionary requestHeaders, ActivityFollow activity) + public async Task FollowRequestedAsync(string signature, string method, string path, string queryString, Dictionary requestHeaders, ActivityFollow activity) { // Validate - if (!await ValidateSignature(activity.actor, signature, method, path, queryString, requestHeaders)) return false; + var sigValidation = await ValidateSignature(activity.actor, signature, method, path, queryString, requestHeaders); + if (!sigValidation.SignatureIsValidated) return false; // Save Follow in DB - - // Send Accept Activity - var targetHost = activity.actor.Replace("https://", string.Empty).Split('/').First(); + var followerUserName = sigValidation.User.name.ToLowerInvariant(); + var followerHost = sigValidation.User.url.Replace("https://", string.Empty).Split('/').First(); + var followerInbox = sigValidation.User.inbox; + var twitterUser = activity.apObject.Split('/').Last().Replace("@", string.Empty); + await _processFollowUser.ExecuteAsync(followerUserName, followerHost, followerInbox, twitterUser); + + // Send Accept Activity + //var followerHost = activity.actor.Replace("https://", string.Empty).Split('/').First(); var acceptFollow = new ActivityAcceptFollow() { context = "https://www.w3.org/ns/activitystreams", @@ -123,11 +133,11 @@ namespace BirdsiteLive.Domain apObject = activity.apObject } }; - var result = await _activityPubService.PostDataAsync(acceptFollow, targetHost, activity.apObject); + var result = await _activityPubService.PostDataAsync(acceptFollow, followerHost, activity.apObject); return result == HttpStatusCode.Accepted; } - private async Task ValidateSignature(string actor, string rawSig, string method, string path, string queryString, Dictionary requestHeaders) + private async Task ValidateSignature(string actor, string rawSig, string method, string path, string queryString, Dictionary requestHeaders) { var signatures = rawSig.Split(','); var signature_header = new Dictionary(); @@ -184,7 +194,17 @@ namespace BirdsiteLive.Domain var result = signKey.VerifyData(Encoding.UTF8.GetBytes(toSign.ToString()), sig, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); - return result; + return new SignatureValidationResult() + { + SignatureIsValidated = result, + User = remoteUser + }; } } + + public class SignatureValidationResult + { + public bool SignatureIsValidated { get; set; } + public Actor User { get; set; } + } } diff --git a/src/BirdsiteLive/BirdsiteLive.csproj b/src/BirdsiteLive/BirdsiteLive.csproj index 332831e..5757c99 100644 --- a/src/BirdsiteLive/BirdsiteLive.csproj +++ b/src/BirdsiteLive/BirdsiteLive.csproj @@ -17,6 +17,7 @@ + diff --git a/src/BirdsiteLive/Controllers/WellKnownController.cs b/src/BirdsiteLive/Controllers/WellKnownController.cs index 3fce212..870801b 100644 --- a/src/BirdsiteLive/Controllers/WellKnownController.cs +++ b/src/BirdsiteLive/Controllers/WellKnownController.cs @@ -18,10 +18,10 @@ namespace BirdsiteLive.Controllers private readonly InstanceSettings _settings; #region Ctor - public WellKnownController(IOptions settings, ITwitterService twitterService) + public WellKnownController(InstanceSettings settings, ITwitterService twitterService) { _twitterService = twitterService; - _settings = settings.Value; + _settings = settings; } #endregion diff --git a/src/BirdsiteLive/Services/FederationService.cs b/src/BirdsiteLive/Services/FederationService.cs index ee07161..62f862b 100644 --- a/src/BirdsiteLive/Services/FederationService.cs +++ b/src/BirdsiteLive/Services/FederationService.cs @@ -1,6 +1,7 @@ using System; using System.Threading; using System.Threading.Tasks; +using BirdsiteLive.DAL.Contracts; using BirdsiteLive.Domain; using Microsoft.Extensions.Hosting; @@ -8,22 +9,41 @@ namespace BirdsiteLive.Services { public class FederationService : BackgroundService { + private readonly IDbInitializerDal _dbInitializerDal; private readonly IUserService _userService; #region Ctor - public FederationService(IUserService userService) + public FederationService(IDbInitializerDal dbInitializerDal, IUserService userService) { + _dbInitializerDal = dbInitializerDal; _userService = userService; } #endregion protected override async Task ExecuteAsync(CancellationToken stoppingToken) { + await DbInitAsync(); + for (;;) { Console.WriteLine("RUNNING SERVICE"); await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken); } } + + private async Task DbInitAsync() + { + var currentVersion = await _dbInitializerDal.GetCurrentDbVersionAsync(); + var mandatoryVersion = _dbInitializerDal.GetMandatoryDbVersion(); + + if (currentVersion == null) + { + await _dbInitializerDal.InitDbAsync(); + } + else if (currentVersion != mandatoryVersion) + { + throw new NotImplementedException(); + } + } } } \ No newline at end of file diff --git a/src/BirdsiteLive/Startup.cs b/src/BirdsiteLive/Startup.cs index 6d07aaa..3eaecd8 100644 --- a/src/BirdsiteLive/Startup.cs +++ b/src/BirdsiteLive/Startup.cs @@ -3,6 +3,9 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using BirdsiteLive.Common.Settings; +using BirdsiteLive.DAL.Contracts; +using BirdsiteLive.DAL.Postgres.DataAccessLayers; +using BirdsiteLive.DAL.Postgres.Settings; using BirdsiteLive.Models; using Lamar; using Microsoft.AspNetCore.Builder; @@ -34,7 +37,7 @@ namespace BirdsiteLive // This method gets called by the runtime. Use this method to add services to the container. public void ConfigureServices(IServiceCollection services) { - services.Configure(Configuration.GetSection("Instance")); + //services.Configure(Configuration.GetSection("Instance")); //services.Configure(Configuration.GetSection("Twitter")); services.AddControllersWithViews(); @@ -48,15 +51,27 @@ namespace BirdsiteLive var instanceSettings = Configuration.GetSection("Instance").Get(); services.For().Use(x => instanceSettings); + var postgresSettings = new PostgresSettings + { + ConnString = instanceSettings.PostgresConnString + }; + services.For().Use(x => postgresSettings); + + services.For().Use().Singleton(); + services.For().Use().Singleton(); + services.For().Use().Singleton(); + services.Scan(_ => { _.Assembly("BirdsiteLive.Twitter"); _.Assembly("BirdsiteLive.Domain"); + _.Assembly("BirdsiteLive.DAL"); + _.Assembly("BirdsiteLive.DAL.Postgres"); _.TheCallingAssembly(); //_.AssemblyContainingType(); //_.Exclude(type => type.Name.Contains("Settings")); - + _.WithDefaultConventions(); _.LookForRegistries(); diff --git a/src/BirdsiteLive/appsettings.json b/src/BirdsiteLive/appsettings.json index 6de0ca3..5054e67 100644 --- a/src/BirdsiteLive/appsettings.json +++ b/src/BirdsiteLive/appsettings.json @@ -8,7 +8,8 @@ }, "AllowedHosts": "*", "Instance": { - "Domain": "domain.name" + "Domain": "domain.name", + "PostgresConnString": "Host=127.0.0.1;Username=username;Password=password;Database=mydb" }, "Twitter": { "ConsumerKey": "twitter.api.key", diff --git a/src/DataAccessLayers/BirdsiteLive.DAL.Postgres/DataAccessLayers/FollowersPostgresDal.cs b/src/DataAccessLayers/BirdsiteLive.DAL.Postgres/DataAccessLayers/FollowersPostgresDal.cs index fdfd7e7..0ec78dc 100644 --- a/src/DataAccessLayers/BirdsiteLive.DAL.Postgres/DataAccessLayers/FollowersPostgresDal.cs +++ b/src/DataAccessLayers/BirdsiteLive.DAL.Postgres/DataAccessLayers/FollowersPostgresDal.cs @@ -20,8 +20,11 @@ namespace BirdsiteLive.DAL.Postgres.DataAccessLayers } #endregion - public async Task CreateFollowerAsync(string acct, string host, int[] followings, Dictionary followingSyncStatus, string inboxUrl) + public async Task CreateFollowerAsync(string acct, string host, string inboxUrl, int[] followings = null, Dictionary followingSyncStatus = null) { + if(followings == null) followings = new int[0]; + if(followingSyncStatus == null) followingSyncStatus = new Dictionary(); + var serializedDic = JsonConvert.SerializeObject(followingSyncStatus); acct = acct.ToLowerInvariant(); @@ -68,18 +71,19 @@ namespace BirdsiteLive.DAL.Postgres.DataAccessLayers } } - public async Task UpdateFollowerAsync(int id, int[] followings, Dictionary followingsSyncStatus) + public async Task UpdateFollowerAsync(Follower follower) { - if (id == default) throw new ArgumentException("id"); + if (follower == default) throw new ArgumentException("follower"); + if (follower.Id == default) throw new ArgumentException("id"); - var serializedDic = JsonConvert.SerializeObject(followingsSyncStatus); + var serializedDic = JsonConvert.SerializeObject(follower.FollowingsSyncStatus); var query = $"UPDATE {_settings.FollowersTableName} SET followings = @followings, followingsSyncStatus = CAST(@followingsSyncStatus as json) WHERE id = @id"; using (var dbConnection = Connection) { dbConnection.Open(); - await dbConnection.QueryAsync(query, new { id, followings, followingsSyncStatus = serializedDic }); + await dbConnection.QueryAsync(query, new { follower.Id, follower.Followings, followingsSyncStatus = serializedDic }); } } @@ -125,7 +129,7 @@ namespace BirdsiteLive.DAL.Postgres.DataAccessLayers Acct = follower.Acct, Host = follower.Host, InboxUrl = follower.InboxUrl, - Followings = follower.Followings, + Followings = follower.Followings.ToList(), FollowingsSyncStatus = JsonConvert.DeserializeObject>(follower.FollowingsSyncStatus) }; } diff --git a/src/DataAccessLayers/BirdsiteLive.DAL.Postgres/Settings/PostgresSettings.cs b/src/DataAccessLayers/BirdsiteLive.DAL.Postgres/Settings/PostgresSettings.cs index 8037a42..c7504ef 100644 --- a/src/DataAccessLayers/BirdsiteLive.DAL.Postgres/Settings/PostgresSettings.cs +++ b/src/DataAccessLayers/BirdsiteLive.DAL.Postgres/Settings/PostgresSettings.cs @@ -4,9 +4,9 @@ { public string ConnString { get; set; } - public string DbVersionTableName { get; set; } = "db-version"; - public string TwitterUserTableName { get; set; } = "twitter-users"; + public string DbVersionTableName { get; set; } = "db_version"; + public string TwitterUserTableName { get; set; } = "twitter_users"; public string FollowersTableName { get; set; } = "followers"; - public string CachedTweetsTableName { get; set; } = "cached-tweets"; + public string CachedTweetsTableName { get; set; } = "cached_tweets"; } } \ No newline at end of file diff --git a/src/DataAccessLayers/BirdsiteLive.DAL/Contracts/IFollowersDal.cs b/src/DataAccessLayers/BirdsiteLive.DAL/Contracts/IFollowersDal.cs index 19b3dbc..f7108a0 100644 --- a/src/DataAccessLayers/BirdsiteLive.DAL/Contracts/IFollowersDal.cs +++ b/src/DataAccessLayers/BirdsiteLive.DAL/Contracts/IFollowersDal.cs @@ -7,9 +7,10 @@ namespace BirdsiteLive.DAL.Contracts public interface IFollowersDal { Task GetFollowerAsync(string acct, string host); - Task CreateFollowerAsync(string acct, string host, int[] followings, Dictionary followingSyncStatus, string inboxUrl); + Task CreateFollowerAsync(string acct, string host, string inboxUrl, int[] followings = null, + Dictionary followingSyncStatus = null); Task GetFollowersAsync(int followedUserId); - Task UpdateFollowerAsync(int id, int[] followings, Dictionary followingSyncStatus); + Task UpdateFollowerAsync(Follower follower); Task DeleteFollowerAsync(int id); Task DeleteFollowerAsync(string acct, string host); } diff --git a/src/DataAccessLayers/BirdsiteLive.DAL/Models/Follower.cs b/src/DataAccessLayers/BirdsiteLive.DAL/Models/Follower.cs index 32ee22b..2499263 100644 --- a/src/DataAccessLayers/BirdsiteLive.DAL/Models/Follower.cs +++ b/src/DataAccessLayers/BirdsiteLive.DAL/Models/Follower.cs @@ -6,7 +6,7 @@ namespace BirdsiteLive.DAL.Models { public int Id { get; set; } - public int[] Followings { get; set; } + public List Followings { get; set; } public Dictionary FollowingsSyncStatus { get; set; } public string Acct { get; set; } diff --git a/src/Tests/BirdsiteLive.DAL.Postgres.Tests/DataAccessLayers/FollowersPostgresDalTests.cs b/src/Tests/BirdsiteLive.DAL.Postgres.Tests/DataAccessLayers/FollowersPostgresDalTests.cs index fed68ed..cdb0dc0 100644 --- a/src/Tests/BirdsiteLive.DAL.Postgres.Tests/DataAccessLayers/FollowersPostgresDalTests.cs +++ b/src/Tests/BirdsiteLive.DAL.Postgres.Tests/DataAccessLayers/FollowersPostgresDalTests.cs @@ -48,7 +48,7 @@ namespace BirdsiteLive.DAL.Postgres.Tests.DataAccessLayers var inboxUrl = "https://domain.ext/myhandle/inbox"; var dal = new FollowersPostgresDal(_settings); - await dal.CreateFollowerAsync(acct, host, following, followingSync, inboxUrl); + await dal.CreateFollowerAsync(acct, host, inboxUrl, following, followingSync); var result = await dal.GetFollowerAsync(acct, host); @@ -56,7 +56,7 @@ namespace BirdsiteLive.DAL.Postgres.Tests.DataAccessLayers Assert.AreEqual(acct, result.Acct); Assert.AreEqual(host, result.Host); Assert.AreEqual(inboxUrl, result.InboxUrl); - Assert.AreEqual(following.Length, result.Followings.Length); + Assert.AreEqual(following.Length, result.Followings.Count); Assert.AreEqual(following[0], result.Followings[0]); Assert.AreEqual(followingSync.Count, result.FollowingsSyncStatus.Count); Assert.AreEqual(followingSync.First().Key, result.FollowingsSyncStatus.First().Key); @@ -74,21 +74,21 @@ namespace BirdsiteLive.DAL.Postgres.Tests.DataAccessLayers var following = new[] { 1,2,3 }; var followingSync = new Dictionary(); var inboxUrl = "https://domain.ext/myhandle1/inbox"; - await dal.CreateFollowerAsync(acct, host, following, followingSync, inboxUrl); + await dal.CreateFollowerAsync(acct, host, inboxUrl, following, followingSync); //User 2 acct = "myhandle2"; host = "domain.ext"; following = new[] { 2, 4, 5 }; inboxUrl = "https://domain.ext/myhandle2/inbox"; - await dal.CreateFollowerAsync(acct, host, following, followingSync, inboxUrl); + await dal.CreateFollowerAsync(acct, host, inboxUrl, following, followingSync); //User 2 acct = "myhandle3"; host = "domain.ext"; following = new[] { 1 }; inboxUrl = "https://domain.ext/myhandle3/inbox"; - await dal.CreateFollowerAsync(acct, host, following, followingSync, inboxUrl); + await dal.CreateFollowerAsync(acct, host, inboxUrl, following, followingSync); var result = await dal.GetFollowersAsync(2); Assert.AreEqual(2, result.Length); @@ -115,22 +115,24 @@ namespace BirdsiteLive.DAL.Postgres.Tests.DataAccessLayers var inboxUrl = "https://domain.ext/myhandle/inbox"; var dal = new FollowersPostgresDal(_settings); - await dal.CreateFollowerAsync(acct, host, following, followingSync, inboxUrl); + await dal.CreateFollowerAsync(acct, host, inboxUrl, following, followingSync); var result = await dal.GetFollowerAsync(acct, host); - var updatedFollowing = new[] { 12, 19, 23, 24 }; - var updatedFollowingSync = new Dictionary() - { + var updatedFollowing = new List { 12, 19, 23, 24 }; + var updatedFollowingSync = new Dictionary(){ {12, 170L}, {19, 171L}, {23, 172L}, {24, 173L} }; + result.Followings = updatedFollowing.ToList(); + result.FollowingsSyncStatus = updatedFollowingSync; + - await dal.UpdateFollowerAsync(result.Id, updatedFollowing, updatedFollowingSync); + await dal.UpdateFollowerAsync(result); result = await dal.GetFollowerAsync(acct, host); - Assert.AreEqual(updatedFollowing.Length, result.Followings.Length); + Assert.AreEqual(updatedFollowing.Count, result.Followings.Count); Assert.AreEqual(updatedFollowing[0], result.Followings[0]); Assert.AreEqual(updatedFollowingSync.Count, result.FollowingsSyncStatus.Count); Assert.AreEqual(updatedFollowingSync.First().Key, result.FollowingsSyncStatus.First().Key); @@ -152,7 +154,7 @@ namespace BirdsiteLive.DAL.Postgres.Tests.DataAccessLayers var inboxUrl = "https://domain.ext/myhandle/inbox"; var dal = new FollowersPostgresDal(_settings); - await dal.CreateFollowerAsync(acct, host, following, followingSync, inboxUrl); + await dal.CreateFollowerAsync(acct, host, inboxUrl, following, followingSync); var result = await dal.GetFollowerAsync(acct, host); var updatedFollowing = new[] { 12, 19 }; @@ -161,11 +163,13 @@ namespace BirdsiteLive.DAL.Postgres.Tests.DataAccessLayers {12, 170L}, {19, 171L} }; + result.Followings = updatedFollowing.ToList(); + result.FollowingsSyncStatus = updatedFollowingSync; - await dal.UpdateFollowerAsync(result.Id, updatedFollowing, updatedFollowingSync); + await dal.UpdateFollowerAsync(result); result = await dal.GetFollowerAsync(acct, host); - Assert.AreEqual(updatedFollowing.Length, result.Followings.Length); + Assert.AreEqual(updatedFollowing.Length, result.Followings.Count); Assert.AreEqual(updatedFollowing[0], result.Followings[0]); Assert.AreEqual(updatedFollowingSync.Count, result.FollowingsSyncStatus.Count); Assert.AreEqual(updatedFollowingSync.First().Key, result.FollowingsSyncStatus.First().Key); @@ -187,7 +191,7 @@ namespace BirdsiteLive.DAL.Postgres.Tests.DataAccessLayers var inboxUrl = "https://domain.ext/myhandle/inbox"; var dal = new FollowersPostgresDal(_settings); - await dal.CreateFollowerAsync(acct, host, following, followingSync, inboxUrl); + await dal.CreateFollowerAsync(acct, host, inboxUrl, following, followingSync); var result = await dal.GetFollowerAsync(acct, host); Assert.IsNotNull(result); @@ -212,7 +216,7 @@ namespace BirdsiteLive.DAL.Postgres.Tests.DataAccessLayers var inboxUrl = "https://domain.ext/myhandle/inbox"; var dal = new FollowersPostgresDal(_settings); - await dal.CreateFollowerAsync(acct, host, following, followingSync, inboxUrl); + await dal.CreateFollowerAsync(acct, host, inboxUrl, following, followingSync); var result = await dal.GetFollowerAsync(acct, host); Assert.IsNotNull(result);