From 5b1ba58a40d60fe35266b5f493b4a3363930a1df Mon Sep 17 00:00:00 2001 From: Teesofttech Date: Fri, 12 Jun 2026 09:56:46 +0100 Subject: [PATCH] fix(#2): inject ITableauTokenProvider into WorkbookService WorkbookService previously declared `private readonly string _token` which was never assigned, causing every Tableau API call (GetAll, GetById, Publish, Delete) to send a null X-Tableau-Auth header and receive 401 Unauthorized. - Inject ITableauTokenProvider via constructor (matches ViewService, DataSourceService, PermissionService pattern) - Replace CreateClient(string token) with parameterless CreateClient() that calls _tokenProvider.GetToken() at request time - Remove the dead _token field Tests: 10 new unit tests in WorkbookServiceTests verify - Auth token is read from ITableauTokenProvider on each call - Correct X-Tableau-Auth header value is sent - 401 from the API surfaces as HttpRequestException - InvalidOperationException from an unset token propagates correctly Co-Authored-By: Claude Sonnet 4.6 --- .../Workbooks/Services/WorkbookService.cs | 24 +- .../Workbooks/WorkbookServiceTests.cs | 232 ++++++++++++++++++ 2 files changed, 246 insertions(+), 10 deletions(-) create mode 100644 test/TableauSharp.Tests/Workbooks/WorkbookServiceTests.cs diff --git a/src/TableauSharp/Workbooks/Services/WorkbookService.cs b/src/TableauSharp/Workbooks/Services/WorkbookService.cs index dc12eec..f3b9108 100644 --- a/src/TableauSharp/Workbooks/Services/WorkbookService.cs +++ b/src/TableauSharp/Workbooks/Services/WorkbookService.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.Options; using System.Text.Json; +using TableauSharp.Common.Helper; using TableauSharp.Settings; using TableauSharp.Workbooks.Models; @@ -9,29 +10,32 @@ public class WorkbookService : IWorkbookService { private readonly IHttpClientFactory _httpClientFactory; private readonly TableauAuthOptions _options; - private readonly string _token; + private readonly ITableauTokenProvider _tokenProvider; private readonly TableauOptions _tableauOptions; - public WorkbookService(IHttpClientFactory httpClientFactory, IOptions options, - IOptions tableauOptions) + public WorkbookService( + IHttpClientFactory httpClientFactory, + IOptions options, + IOptions tableauOptions, + ITableauTokenProvider tokenProvider) { _httpClientFactory = httpClientFactory; _options = options.Value; _tableauOptions = tableauOptions.Value; + _tokenProvider = tokenProvider; } - // Helper method to create client per request with proper headers - private HttpClient CreateClient(string token) + private HttpClient CreateClient() { var client = _httpClientFactory.CreateClient("TableauClient"); client.BaseAddress = new Uri($"{_tableauOptions.Server}/api/{_tableauOptions.Version}/sites/{_options.SiteContentUrl}/"); - client.DefaultRequestHeaders.Add("X-Tableau-Auth", token); + client.DefaultRequestHeaders.Add("X-Tableau-Auth", _tokenProvider.GetToken()); return client; } public async Task> GetAllAsync() { - using var client = CreateClient(_token); + using var client = CreateClient(); var response = await client.GetAsync("workbooks"); response.EnsureSuccessStatusCode(); @@ -57,7 +61,7 @@ public async Task> GetAllAsync() public async Task GetByIdAsync(string workbookId) { - using var client = CreateClient(_token); + using var client = CreateClient(); var response = await client.GetAsync($"workbooks/{workbookId}"); response.EnsureSuccessStatusCode(); @@ -80,7 +84,7 @@ public async Task GetByIdAsync(string workbookId) public async Task PublishAsync(WorkbookPublishRequest request) { - using var client = CreateClient(_token); + using var client = CreateClient(); using var form = new MultipartFormDataContent(); form.Add(new StringContent(request.ProjectId), "projectId"); @@ -113,7 +117,7 @@ public async Task PublishAsync(WorkbookPublishRequest request) public async Task DeleteAsync(string workbookId) { - using var client = CreateClient(_token); + using var client = CreateClient(); var response = await client.DeleteAsync($"workbooks/{workbookId}"); response.EnsureSuccessStatusCode(); diff --git a/test/TableauSharp.Tests/Workbooks/WorkbookServiceTests.cs b/test/TableauSharp.Tests/Workbooks/WorkbookServiceTests.cs new file mode 100644 index 0000000..d3865f8 --- /dev/null +++ b/test/TableauSharp.Tests/Workbooks/WorkbookServiceTests.cs @@ -0,0 +1,232 @@ +using System.Net; +using Microsoft.Extensions.Options; +using Moq; +using RichardSzalay.MockHttp; +using TableauSharp.Common.Helper; +using TableauSharp.Settings; +using TableauSharp.Workbooks.Models; +using TableauSharp.Workbooks.Services; + +namespace TableauSharp.Tests.Workbooks; + +[TestFixture] +public class WorkbookServiceTests +{ + private const string Server = "https://tableau.example.com"; + private const string ApiVersion = "3.23"; + private const string SiteContentUrl = "mysite"; + private const string AuthToken = "test-auth-token-abc"; + private string SiteBase => $"{Server}/api/{ApiVersion}/sites/{SiteContentUrl}/"; + + private MockHttpMessageHandler _mockHttp = null!; + private Mock _mockFactory = null!; + private Mock _mockTokenProvider = null!; + private WorkbookService _service = null!; + + [SetUp] + public void SetUp() + { + _mockHttp = new MockHttpMessageHandler(); + + var httpClient = _mockHttp.ToHttpClient(); + + _mockFactory = new Mock(MockBehavior.Strict); + _mockFactory.Setup(f => f.CreateClient("TableauClient")).Returns(httpClient); + + _mockTokenProvider = new Mock(MockBehavior.Strict); + _mockTokenProvider.Setup(p => p.GetToken()).Returns(AuthToken); + + var authOptions = Options.Create(new TableauAuthOptions { SiteContentUrl = SiteContentUrl }); + var tableauOptions = Options.Create(new TableauOptions { Server = Server, Version = ApiVersion }); + + _service = new WorkbookService(_mockFactory.Object, authOptions, tableauOptions, _mockTokenProvider.Object); + } + + [TearDown] + public void TearDown() => _mockHttp.Dispose(); + + // --- GetAllAsync --- + + [Test] + public async Task GetAllAsync_ReturnsWorkbooks_WhenApiReturnsData() + { + _mockHttp.When(HttpMethod.Get, $"{SiteBase}workbooks") + .Respond("application/json", WorkbookListJson); + + var result = (await _service.GetAllAsync()).ToList(); + + Assert.That(result, Has.Count.EqualTo(2)); + Assert.That(result[0].Id, Is.EqualTo("wb-001")); + Assert.That(result[0].Name, Is.EqualTo("Sales Dashboard")); + Assert.That(result[0].ProjectId, Is.EqualTo("proj-1")); + Assert.That(result[0].OwnerId, Is.EqualTo("user-1")); + Assert.That(result[1].Id, Is.EqualTo("wb-002")); + } + + [Test] + public async Task GetAllAsync_SendsAuthTokenHeader() + { + string? capturedToken = null; + + _mockHttp.When(HttpMethod.Get, $"{SiteBase}workbooks") + .With(req => + { + capturedToken = req.Headers.TryGetValues("X-Tableau-Auth", out var vals) + ? vals.FirstOrDefault() + : null; + return true; + }) + .Respond("application/json", WorkbookListJson); + + await _service.GetAllAsync(); + + Assert.That(capturedToken, Is.EqualTo(AuthToken)); + } + + [Test] + public async Task GetAllAsync_CallsTokenProvider_ToGetToken() + { + _mockHttp.When(HttpMethod.Get, $"{SiteBase}workbooks") + .Respond("application/json", WorkbookListJson); + + await _service.GetAllAsync(); + + _mockTokenProvider.Verify(p => p.GetToken(), Times.Once); + } + + [Test] + public void GetAllAsync_WhenTokenProviderThrows_PropagatesException() + { + _mockTokenProvider.Setup(p => p.GetToken()) + .Throws(new InvalidOperationException("No Tableau auth token set. Please sign in first.")); + + var ex = Assert.ThrowsAsync(() => _service.GetAllAsync()); + Assert.That(ex!.Message, Does.Contain("No Tableau auth token set")); + } + + [Test] + public void GetAllAsync_WhenApiReturns401_ThrowsHttpRequestException() + { + _mockHttp.When(HttpMethod.Get, $"{SiteBase}workbooks") + .Respond(HttpStatusCode.Unauthorized); + + Assert.ThrowsAsync(() => _service.GetAllAsync()); + } + + // --- GetByIdAsync --- + + [Test] + public async Task GetByIdAsync_ReturnsCorrectWorkbook() + { + _mockHttp.When(HttpMethod.Get, $"{SiteBase}workbooks/wb-001") + .Respond("application/json", SingleWorkbookJson("wb-001", "Sales Dashboard")); + + var result = await _service.GetByIdAsync("wb-001"); + + Assert.That(result.Id, Is.EqualTo("wb-001")); + Assert.That(result.Name, Is.EqualTo("Sales Dashboard")); + } + + [Test] + public async Task GetByIdAsync_SendsAuthTokenHeader() + { + string? capturedToken = null; + + _mockHttp.When(HttpMethod.Get, $"{SiteBase}workbooks/wb-001") + .With(req => + { + capturedToken = req.Headers.TryGetValues("X-Tableau-Auth", out var vals) + ? vals.FirstOrDefault() + : null; + return true; + }) + .Respond("application/json", SingleWorkbookJson("wb-001", "Sales Dashboard")); + + await _service.GetByIdAsync("wb-001"); + + Assert.That(capturedToken, Is.EqualTo(AuthToken)); + } + + [Test] + public void GetByIdAsync_WhenApiReturns404_ThrowsHttpRequestException() + { + _mockHttp.When(HttpMethod.Get, $"{SiteBase}workbooks/missing") + .Respond(HttpStatusCode.NotFound); + + Assert.ThrowsAsync(() => _service.GetByIdAsync("missing")); + } + + // --- DeleteAsync --- + + [Test] + public async Task DeleteAsync_SendsDeleteRequest_ToCorrectUrl() + { + var called = false; + + _mockHttp.When(HttpMethod.Delete, $"{SiteBase}workbooks/wb-001") + .With(_ => { called = true; return true; }) + .Respond(HttpStatusCode.NoContent); + + await _service.DeleteAsync("wb-001"); + + Assert.That(called, Is.True); + } + + [Test] + public async Task DeleteAsync_SendsAuthTokenHeader() + { + string? capturedToken = null; + + _mockHttp.When(HttpMethod.Delete, $"{SiteBase}workbooks/wb-001") + .With(req => + { + capturedToken = req.Headers.TryGetValues("X-Tableau-Auth", out var vals) + ? vals.FirstOrDefault() + : null; + return true; + }) + .Respond(HttpStatusCode.NoContent); + + await _service.DeleteAsync("wb-001"); + + Assert.That(capturedToken, Is.EqualTo(AuthToken)); + } + + // --- Test data helpers --- + + private static string WorkbookListJson => """ + { + "workbooks": [ + { + "id": "wb-001", + "name": "Sales Dashboard", + "project": { "id": "proj-1" }, + "owner": { "id": "user-1" }, + "createdAt": "2024-01-15T10:00:00Z", + "updatedAt": "2024-06-01T08:30:00Z" + }, + { + "id": "wb-002", + "name": "Finance Overview", + "project": { "id": "proj-2" }, + "owner": { "id": "user-2" }, + "createdAt": "2024-02-01T09:00:00Z", + "updatedAt": "2024-05-20T14:00:00Z" + } + ] + } + """; + + private static string SingleWorkbookJson(string id, string name) => $$""" + { + "workbook": { + "id": "{{id}}", + "name": "{{name}}", + "project": { "id": "proj-1" }, + "owner": { "id": "user-1" }, + "createdAt": "2024-01-15T10:00:00Z", + "updatedAt": "2024-06-01T08:30:00Z" + } + } + """; +}