Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 14 additions & 10 deletions src/TableauSharp/Workbooks/Services/WorkbookService.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using Microsoft.Extensions.Options;
using System.Text.Json;
using TableauSharp.Common.Helper;
using TableauSharp.Settings;
using TableauSharp.Workbooks.Models;

Expand All @@ -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<TableauAuthOptions> options,
IOptions<TableauOptions> tableauOptions)
public WorkbookService(
IHttpClientFactory httpClientFactory,
IOptions<TableauAuthOptions> options,
IOptions<TableauOptions> 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<IEnumerable<TableauWorkbook>> GetAllAsync()
{
using var client = CreateClient(_token);
using var client = CreateClient();
var response = await client.GetAsync("workbooks");
response.EnsureSuccessStatusCode();

Expand All @@ -57,7 +61,7 @@ public async Task<IEnumerable<TableauWorkbook>> GetAllAsync()

public async Task<TableauWorkbook> GetByIdAsync(string workbookId)
{
using var client = CreateClient(_token);
using var client = CreateClient();

var response = await client.GetAsync($"workbooks/{workbookId}");
response.EnsureSuccessStatusCode();
Expand All @@ -80,7 +84,7 @@ public async Task<TableauWorkbook> GetByIdAsync(string workbookId)

public async Task<TableauWorkbook> PublishAsync(WorkbookPublishRequest request)
{
using var client = CreateClient(_token);
using var client = CreateClient();

using var form = new MultipartFormDataContent();
form.Add(new StringContent(request.ProjectId), "projectId");
Expand Down Expand Up @@ -113,7 +117,7 @@ public async Task<TableauWorkbook> 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();
Expand Down
232 changes: 232 additions & 0 deletions test/TableauSharp.Tests/Workbooks/WorkbookServiceTests.cs
Original file line number Diff line number Diff line change
@@ -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<IHttpClientFactory> _mockFactory = null!;
private Mock<ITableauTokenProvider> _mockTokenProvider = null!;
private WorkbookService _service = null!;

[SetUp]
public void SetUp()
{
_mockHttp = new MockHttpMessageHandler();

var httpClient = _mockHttp.ToHttpClient();

_mockFactory = new Mock<IHttpClientFactory>(MockBehavior.Strict);
_mockFactory.Setup(f => f.CreateClient("TableauClient")).Returns(httpClient);

_mockTokenProvider = new Mock<ITableauTokenProvider>(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<InvalidOperationException>(() => _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<HttpRequestException>(() => _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<HttpRequestException>(() => _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"
}
}
""";
}
Loading