From 95c5626e442bcc6f632c6b9e2c20a530d28e2469 Mon Sep 17 00:00:00 2001 From: OMpawar-21 Date: Wed, 8 Jul 2026 17:20:55 +0530 Subject: [PATCH 1/3] test: expand unit test coverage for ModelGenerator and previously-untested CMA/Model classes --- .gitignore | 5 +- Scripts/generate_test_report.py | 614 ++++++++++++++++++ .../ConfigTests.cs | 103 +++ .../ContentstackConstantsTests.cs | 90 +++ .../ContentstackExceptionTests.cs | 91 +++ .../ContentstackHttpRequestTests.cs | 196 ++++++ .../ContentstackHttpResponseTests.cs | 167 +++++ .../ContentstackResponseModelTests.cs | 42 ++ .../MessagesTests.cs | 146 +++++ .../MetaDataModelTests.cs | 32 + .../ModelGeneratorAddParamsTests.cs | 234 +++++++ .../ModelGeneratorCreateFileTests.cs | 295 +++++++++ .../ModelGeneratorFieldTypeResolutionTests.cs | 280 ++++++++ .../ModelGeneratorNullableVariantTests.cs | 117 ++++ ...GeneratorSupportingClassGenerationTests.cs | 285 ++++++++ .../ModelGeneratorTestHelpers.cs | 158 +++++ .../ModelGeneratorTests.cs | 16 +- .../OAuthModelTests.cs | 355 ++++++++++ .../ProgramTests.cs | 38 ++ .../StackResponseModelTests.cs | 38 ++ .../CMA/ContentStackError.cs | 9 - .../CMA/HTTPRequestHandler.cs | 4 +- .../ModelGenerator.cs | 30 +- contentstack.model.generator/Program.cs | 2 +- skills/testing/SKILL.md | 22 +- 25 files changed, 3339 insertions(+), 30 deletions(-) create mode 100644 Scripts/generate_test_report.py create mode 100644 contentstack.model.generator.tests/ConfigTests.cs create mode 100644 contentstack.model.generator.tests/ContentstackConstantsTests.cs create mode 100644 contentstack.model.generator.tests/ContentstackExceptionTests.cs create mode 100644 contentstack.model.generator.tests/ContentstackHttpRequestTests.cs create mode 100644 contentstack.model.generator.tests/ContentstackHttpResponseTests.cs create mode 100644 contentstack.model.generator.tests/ContentstackResponseModelTests.cs create mode 100644 contentstack.model.generator.tests/MessagesTests.cs create mode 100644 contentstack.model.generator.tests/MetaDataModelTests.cs create mode 100644 contentstack.model.generator.tests/ModelGeneratorAddParamsTests.cs create mode 100644 contentstack.model.generator.tests/ModelGeneratorCreateFileTests.cs create mode 100644 contentstack.model.generator.tests/ModelGeneratorFieldTypeResolutionTests.cs create mode 100644 contentstack.model.generator.tests/ModelGeneratorNullableVariantTests.cs create mode 100644 contentstack.model.generator.tests/ModelGeneratorSupportingClassGenerationTests.cs create mode 100644 contentstack.model.generator.tests/ModelGeneratorTestHelpers.cs create mode 100644 contentstack.model.generator.tests/OAuthModelTests.cs create mode 100644 contentstack.model.generator.tests/ProgramTests.cs create mode 100644 contentstack.model.generator.tests/StackResponseModelTests.cs diff --git a/.gitignore b/.gitignore index f59e822..4224e8f 100644 --- a/.gitignore +++ b/.gitignore @@ -24,4 +24,7 @@ packages/ */mono_crash* .vscode .dccache -.sonarqube/ \ No newline at end of file +.sonarqube/ +*/TestResults/ +TestResults/ +test-report_*.html \ No newline at end of file diff --git a/Scripts/generate_test_report.py b/Scripts/generate_test_report.py new file mode 100644 index 0000000..819cfd7 --- /dev/null +++ b/Scripts/generate_test_report.py @@ -0,0 +1,614 @@ +#!/usr/bin/env python3 +""" +HTML Test Report Generator for contentstack-model-generator. + +Converts a .trx test-results file (and an optional Cobertura coverage.xml) +into a single self-contained HTML report: pass/fail summary, per-class test +breakdown with failure details, and a code coverage section. + +Usage: + python3 Scripts/generate_test_report.py [path-to-coverage.cobertura.xml] + +Or run tests first to generate both inputs: + dotnet test contentstack.model.generator.tests/contentstack.model.generator.tests.csproj \\ + --logger "trx;LogFileName=test-results.trx" \\ + --collect:"XPlat Code Coverage" \\ + --results-directory contentstack.model.generator.tests/TestResults + +Uses only the Python standard library (xml.etree.ElementTree) - no extra +dependencies to install. The .trx/coverage files parsed here are always +locally generated by `dotnet test`, not untrusted input. +""" + +import glob +import os +import sys +import xml.etree.ElementTree as ET +from datetime import datetime + +TRX_NS = {'': 'http://microsoft.com/schemas/VisualStudio/TeamTest/2010'} + + +def escape_html(text): + if text is None: + return "" + text = str(text) + return (text + .replace('&', '&') + .replace('<', '<') + .replace('>', '>') + .replace('"', '"') + .replace("'", ''')) + + +class TestReportGenerator: + def __init__(self, trx_file, coverage_file=None): + self.trx_file = trx_file + self.coverage_file = coverage_file + self.results = { + 'total': 0, + 'passed': 0, + 'failed': 0, + 'skipped': 0, + 'tests': [], + } + self.coverage = None + + def parse_trx(self): + tree = ET.parse(self.trx_file) + root = tree.getroot() + + result_summary = root.find('.//ResultSummary', TRX_NS) + counters = result_summary.find('Counters', TRX_NS) if result_summary is not None else None + if counters is not None: + self.results['total'] = int(counters.get('total', 0)) + self.results['passed'] = int(counters.get('passed', 0)) + self.results['failed'] = int(counters.get('failed', 0)) + self.results['skipped'] = int(counters.get('notExecuted', 0)) + + unit_tests_by_id = {el.get('id'): el for el in root.findall('.//UnitTest', TRX_NS)} + + for test_result in root.findall('.//UnitTestResult', TRX_NS): + test_name = test_result.get('testName', 'Unknown') + outcome = test_result.get('outcome', 'Unknown') + duration = test_result.get('duration', '0') + + try: + hours, minutes, seconds = duration.split(':') + total_seconds = int(hours) * 3600 + int(minutes) * 60 + float(seconds) + duration_str = f"{total_seconds:.3f}s" + except (ValueError, AttributeError): + duration_str = duration + + error_message = None + error_stacktrace = None + output_elem = test_result.find('Output', TRX_NS) + if output_elem is not None: + error_info = output_elem.find('ErrorInfo', TRX_NS) + if error_info is not None: + message_elem = error_info.find('Message', TRX_NS) + stacktrace_elem = error_info.find('StackTrace', TRX_NS) + if message_elem is not None: + error_message = message_elem.text + if stacktrace_elem is not None: + error_stacktrace = stacktrace_elem.text + + test_def = unit_tests_by_id.get(test_result.get('testId', '')) + category = 'General' + if test_def is not None: + test_method = test_def.find('.//TestMethod', TRX_NS) + if test_method is not None: + class_name = test_method.get('className', '') + if class_name: + category = class_name.split('.')[-1] + + self.results['tests'].append({ + 'name': test_name, + 'outcome': outcome, + 'duration': duration_str, + 'category': category, + 'error_message': error_message, + 'error_stacktrace': error_stacktrace, + }) + + return True + + def parse_coverage(self): + if not self.coverage_file or not os.path.exists(self.coverage_file): + return + + tree = ET.parse(self.coverage_file) + root = tree.getroot() + + # ModelGenerator.cs (and any other file) is split across multiple + # entries by coverlet (the class itself plus one per + # compiler-generated async state machine) - aggregate by filename + # so each source file shows as a single row. + files = {} + for cls in root.iter('class'): + filename = cls.get('filename', cls.get('name', 'unknown')) + lines_el = cls.find('lines') + if lines_el is None: + continue + entry = files.setdefault(filename, {'valid': 0, 'covered': 0}) + for line in lines_el.findall('line'): + entry['valid'] += 1 + if int(line.get('hits', 0)) > 0: + entry['covered'] += 1 + + classes = [] + total_valid = 0 + total_covered = 0 + for filename, stats in files.items(): + valid = stats['valid'] + covered = stats['covered'] + total_valid += valid + total_covered += covered + classes.append({ + 'filename': filename, + 'lines_valid': valid, + 'lines_covered': covered, + 'line_rate': (covered / valid * 100) if valid else 0.0, + }) + + classes.sort(key=lambda c: c['line_rate']) + + self.coverage = { + 'line_rate': float(root.get('line-rate', 0)) * 100, + 'branch_rate': float(root.get('branch-rate', 0)) * 100, + 'lines_valid': total_valid, + 'lines_covered': total_covered, + 'classes': classes, + } + + def generate_coverage_section_html(self): + if not self.coverage: + return "" + + cov = self.coverage + rows_html = "" + for c in cov['classes']: + rate = c['line_rate'] + bar_class = 'good' if rate >= 80 else 'warn' if rate >= 50 else 'bad' + rows_html += f""" + + {escape_html(c['filename'])} + +
+
+
+ + {rate:.1f}% + {c['lines_covered']} / {c['lines_valid']} + +""" + + return f""" +
+

Code Coverage

+
+
+
{cov['line_rate']:.1f}%
+
Line Coverage
+
+
+
{cov['branch_rate']:.1f}%
+
Branch Coverage
+
+
+
{cov['lines_covered']}
+
Lines Covered
+
+
+
{cov['lines_valid']}
+
Total Lines
+
+
+ + + + + + + + + + +{rows_html} + +
FileLine Coverage%Lines
+
+""" + + def generate_html(self, output_file): + pass_rate = (self.results['passed'] / self.results['total'] * 100) if self.results['total'] > 0 else 0 + + tests_by_category = {} + for test in self.results['tests']: + tests_by_category.setdefault(test['category'], []).append(test) + sorted_categories = sorted(tests_by_category.keys()) + + html = f""" + + + + + contentstack-model-generator - Test Report + + + +
+
+

contentstack-model-generator Test Report

+

{datetime.now().strftime('%B %d, %Y at %I:%M %p')}

+
+ +
+
+
{self.results['total']}
+
Total Tests
+
+
+
{self.results['passed']}
+
Passed
+
+
+
{self.results['failed']}
+
Failed
+
+
+
{self.results['skipped']}
+
Skipped
+
+
+ +
+

Pass Rate

+
+
{pass_rate:.1f}%
+
+
+{self.generate_coverage_section_html()} +
+

Test Results by Class

+""" + + for category in sorted_categories: + tests = tests_by_category[category] + passed = sum(1 for t in tests if t['outcome'] == 'Passed') + failed = sum(1 for t in tests if t['outcome'] == 'Failed') + skipped = sum(1 for t in tests if t['outcome'] == 'NotExecuted') + + html += f""" +
+
+
+ + {escape_html(category)} +
+
+ {passed} passed · + {failed} failed · + {skipped} skipped · + {len(tests)} total +
+
+
+ + + + + + + + + +""" + + for test_idx, test in enumerate(tests): + status_class = ('status-passed' if test['outcome'] == 'Passed' + else 'status-failed' if test['outcome'] == 'Failed' + else 'status-skipped') + test_id = f"test-{category}-{test_idx}" + + html += f""" + + + + + +""" + + html += """ + +
Test NameStatusDuration
+
{escape_html(test['name'])}
+""" + if test['outcome'] == 'Failed' and (test['error_message'] or test['error_stacktrace']): + html += f""" +
+
Error:
{escape_html(test['error_message'])}
+""" + if test['error_stacktrace']: + html += f""" +
+ Stack Trace +
{escape_html(test['error_stacktrace'])}
+
+""" + html += """ +
+""" + + html += f""" +
{test['outcome']}{test['duration']}
+
+
+""" + + html += f""" +
+ + +
+ + + + +""" + + with open(output_file, 'w', encoding='utf-8') as f: + f.write(html) + + print(f"Test report generated: {output_file}") + return output_file + + +def find_first(pattern): + matches = glob.glob(pattern, recursive=True) + return matches[0] if matches else None + + +def main(): + print("=" * 80) + print("contentstack-model-generator Test Report Generator") + print("=" * 80) + + trx_file = sys.argv[1] if len(sys.argv) > 1 else find_first('contentstack.model.generator.tests/TestResults/**/*.trx') + coverage_file = sys.argv[2] if len(sys.argv) > 2 else find_first('contentstack.model.generator.tests/TestResults/**/coverage.cobertura.xml') + + if not trx_file or not os.path.exists(trx_file): + print("\nError: No .trx file found!") + print(__doc__) + sys.exit(1) + + print(f"\nTRX file: {trx_file}") + print(f"Coverage file: {coverage_file or '(none found - report will omit coverage section)'}") + + generator = TestReportGenerator(trx_file, coverage_file) + + print("\nParsing test results...") + generator.parse_trx() + print(f"Found {generator.results['total']} tests " + f"({generator.results['passed']} passed, {generator.results['failed']} failed, " + f"{generator.results['skipped']} skipped)") + + if coverage_file: + print("\nParsing coverage data...") + generator.parse_coverage() + if generator.coverage: + print(f"Line coverage: {generator.coverage['line_rate']:.1f}% " + f"({generator.coverage['lines_covered']}/{generator.coverage['lines_valid']} lines)") + + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + output_file = generator.generate_html(f'test-report_{timestamp}.html') + + print("\n" + "=" * 80) + print(f"Done. Open: file://{os.path.abspath(output_file)}") + print("=" * 80) + + +if __name__ == "__main__": + main() diff --git a/contentstack.model.generator.tests/ConfigTests.cs b/contentstack.model.generator.tests/ConfigTests.cs new file mode 100644 index 0000000..18ad7cd --- /dev/null +++ b/contentstack.model.generator.tests/ConfigTests.cs @@ -0,0 +1,103 @@ +using contentstack.CMA; +using Xunit; + +namespace contentstack.model.generator.tests +{ + public class ConfigTests + { + [Fact] + public void Port_DefaultValue_Is443() + { + var config = new Config(); + + Assert.Equal("443", config.Port); + } + + [Fact] + public void Protocol_DefaultValue_IsHttps() + { + var config = new Config(); + + Assert.Equal("https", config.Protocol); + } + + [Fact] + public void Host_DefaultValue_IsHostURL() + { + var config = new Config(); + + Assert.Equal("cdn.contentstack.io", config.Host); + } + + [Fact] + public void Version_DefaultValue_IsV3() + { + var config = new Config(); + + Assert.Equal("v3", config.Version); + } + + [Fact] + public void Port_Setter_OverridesDefaultValue() + { + var config = new Config { Port = "8080" }; + + Assert.Equal("8080", config.Port); + } + + [Fact] + public void Host_Setter_OverridesDefaultValue() + { + var config = new Config { Host = "api.contentstack.io" }; + + Assert.Equal("api.contentstack.io", config.Host); + } + + [Fact] + public void Version_Setter_OverridesDefaultValue() + { + var config = new Config { Version = "v4" }; + + Assert.Equal("v4", config.Version); + } + + [Fact] + public void BaseUrl_WithDefaults_CombinesProtocolHostAndVersion() + { + var config = new Config(); + + Assert.Equal("https://cdn.contentstack.io/v3", config.BaseUrl); + } + + [Fact] + public void BaseUrl_WithOverriddenHostAndVersion_ReflectsOverrides() + { + var config = new Config { Host = "api.contentstack.io", Version = "v4" }; + + Assert.Equal("https://api.contentstack.io/v4", config.BaseUrl); + } + + [Fact] + public void BaseUrl_TrimsSlashesFromComponents() + { + var config = new Config { Host = "/api.contentstack.io/", Version = "/v3/" }; + + Assert.Equal("https://api.contentstack.io/v3", config.BaseUrl); + } + + [Fact] + public void ApiKey_AppUid_AuthToken_CanBeSetAndRetrieved() + { + var config = new Config + { + ApiKey = "test_api_key", + AppUid = "test_app_uid", + AuthToken = "test_auth_token" + }; + + Assert.Equal("test_api_key", config.ApiKey); + Assert.Equal("test_app_uid", config.AppUid); + Assert.Equal("test_auth_token", config.AuthToken); + } + } +} diff --git a/contentstack.model.generator.tests/ContentstackConstantsTests.cs b/contentstack.model.generator.tests/ContentstackConstantsTests.cs new file mode 100644 index 0000000..599ac2f --- /dev/null +++ b/contentstack.model.generator.tests/ContentstackConstantsTests.cs @@ -0,0 +1,90 @@ +using contentstack.CMA; +using Xunit; + +namespace contentstack.model.generator.tests +{ + /// + /// ContentstackConstants has a private constructor - the only way to obtain an + /// instance (even from this InternalsVisibleTo test assembly) is via the internal + /// static Instance property. + /// + public class ContentstackConstantsTests + { + [Fact] + public void Instance_ReturnsNonNullInstance() + { + var instance = ContentstackConstants.Instance; + + Assert.NotNull(instance); + } + + [Fact] + public void Instance_EachAccess_ReturnsNewInstance() + { + var first = ContentstackConstants.Instance; + var second = ContentstackConstants.Instance; + + Assert.NotSame(first, second); + } + + [Fact] + public void ContentTypeUid_CanBeSetAndRetrieved() + { + var instance = ContentstackConstants.Instance; + + instance.ContentTypeUid = "blog_post"; + + Assert.Equal("blog_post", instance.ContentTypeUid); + } + + [Fact] + public void EntryUid_CanBeSetAndRetrieved() + { + var instance = ContentstackConstants.Instance; + + instance.EntryUid = "entry_123"; + + Assert.Equal("entry_123", instance.EntryUid); + } + + [Fact] + public void Content_Types_DefaultValue_ReturnsContentTypes() + { + var instance = ContentstackConstants.Instance; + + Assert.Equal("content_types", instance.Content_Types); + } + + [Fact] + public void Content_Types_Setter_OverridesValue() + { + var instance = ContentstackConstants.Instance; + + instance.Content_Types = "custom_content_types"; + + Assert.Equal("custom_content_types", instance.Content_Types); + } + + [Fact] + public void Entries_DefaultValue_ReturnsContentTypesDueToSharedBackingField() + { + // Content_Types and Entries read/write the SAME private backing field + // (initialized to "content_types"), so Entries' "?? \"entries\"" fallback + // is unreachable on a fresh instance - this pins current behavior. + var instance = ContentstackConstants.Instance; + + Assert.Equal("content_types", instance.Entries); + } + + [Fact] + public void Entries_Setter_AlsoChangesContentTypesValue() + { + var instance = ContentstackConstants.Instance; + + instance.Entries = "entries"; + + Assert.Equal("entries", instance.Entries); + Assert.Equal("entries", instance.Content_Types); + } + } +} diff --git a/contentstack.model.generator.tests/ContentstackExceptionTests.cs b/contentstack.model.generator.tests/ContentstackExceptionTests.cs new file mode 100644 index 0000000..e43b4a3 --- /dev/null +++ b/contentstack.model.generator.tests/ContentstackExceptionTests.cs @@ -0,0 +1,91 @@ +using System; +using System.Collections.Generic; +using System.Net; +using contentstack.CMA; +using Xunit; + +namespace contentstack.model.generator.tests +{ + public class ContentstackExceptionTests + { + [Fact] + public void DefaultConstructor_CreatesInstanceWithEmptyErrorMessage() + { + var exception = new ContentstackException(); + + Assert.Equal(string.Empty, exception.ErrorMessage); + } + + [Fact] + public void Constructor_WithMessage_SetsBaseMessageAndErrorMessage() + { + var exception = new ContentstackException("something failed"); + + Assert.Equal("something failed", exception.Message); + Assert.Equal("something failed", exception.ErrorMessage); + } + + [Fact] + public void Constructor_WithInnerException_SetsErrorMessageFromInnerExceptionMessage() + { + var inner = new InvalidOperationException("inner failure"); + + var exception = new ContentstackException(inner); + + Assert.Equal("inner failure", exception.ErrorMessage); + Assert.Equal("inner failure", exception.Message); + } + + [Fact] + public void Constructor_WithMessageAndInnerException_SetsBaseMessageButNotShadowedMessageProperty() + { + // ContentstackException declares `public new string Message`, which shadows + // Exception.Message. This constructor forwards to base(message, innerException) + // (so the base Exception's Message is set) but - unlike the other constructors - + // never sets ErrorMessage, so the shadowing `Message` property itself stays null. + var inner = new InvalidOperationException("root cause"); + + var exception = new ContentstackException("wrapped failure", inner); + + Assert.Null(exception.Message); + Assert.Equal("wrapped failure", ((Exception)exception).Message); + Assert.Same(inner, exception.InnerException); + } + + [Fact] + public void ErrorMessage_Setter_AlsoUpdatesMessageProperty() + { + var exception = new ContentstackException(); + + exception.ErrorMessage = "updated message"; + + Assert.Equal("updated message", exception.Message); + } + + [Fact] + public void StatusCode_CanBeSetAndRetrieved() + { + var exception = new ContentstackException { StatusCode = HttpStatusCode.NotFound }; + + Assert.Equal(HttpStatusCode.NotFound, exception.StatusCode); + } + + [Fact] + public void ErrorCode_CanBeSetAndRetrieved() + { + var exception = new ContentstackException { ErrorCode = 141 }; + + Assert.Equal(141, exception.ErrorCode); + } + + [Fact] + public void Errors_CanBeSetAndRetrieved() + { + var errors = new Dictionary { { "title", "is required" } }; + + var exception = new ContentstackException { Errors = errors }; + + Assert.Same(errors, exception.Errors); + } + } +} diff --git a/contentstack.model.generator.tests/ContentstackHttpRequestTests.cs b/contentstack.model.generator.tests/ContentstackHttpRequestTests.cs new file mode 100644 index 0000000..83b7196 --- /dev/null +++ b/contentstack.model.generator.tests/ContentstackHttpRequestTests.cs @@ -0,0 +1,196 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using contentstack.CMA.Http; +using Xunit; + +namespace contentstack.model.generator.tests +{ + /// + /// Tests for contentstack.CMA.Http.ContentstackHttpRequest - part of an HTTP + /// abstraction layer (CMA/Http/*) that is not currently wired into + /// ContentstackClient. Its constructor takes an HttpClient directly (unlike + /// CMA/HTTPRequestHandler.cs, which hardcodes `new HttpClient()`), so it can be + /// fully tested by backing the HttpClient with a fake HttpMessageHandler - no + /// real network call, no mocking framework needed. + /// + public class ContentstackHttpRequestTests + { + private class FakeHttpMessageHandler : HttpMessageHandler + { + private readonly Func _responder; + + public HttpRequestMessage LastRequest { get; private set; } + + public FakeHttpMessageHandler(Func responder) + { + _responder = responder; + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + LastRequest = request; + return Task.FromResult(_responder(request)); + } + } + + private static ContentstackHttpRequest CreateRequest( + out FakeHttpMessageHandler handler, + HttpStatusCode statusCode = HttpStatusCode.OK, + string body = "{\"ok\":true}") + { + handler = new FakeHttpMessageHandler(_ => new HttpResponseMessage(statusCode) + { + Content = new StringContent(body, Encoding.UTF8, "application/json") + }); + var httpClient = new HttpClient(handler); + return new ContentstackHttpRequest(httpClient, new JsonSerializerOptions()) + { + RequestUri = new Uri("http://localhost/test") + }; + } + + [Fact] + public void Constructor_ExposesUnderlyingHttpClient() + { + var httpClient = new HttpClient(new FakeHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.OK))); + + var request = new ContentstackHttpRequest(httpClient, new JsonSerializerOptions()); + + Assert.Same(httpClient, request.HttpClient); + } + + [Fact] + public void Method_Getter_ReflectsUnderlyingRequestMessage() + { + var httpClient = new HttpClient(new FakeHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.OK))); + var request = new ContentstackHttpRequest(httpClient, new JsonSerializerOptions()) + { + Method = HttpMethod.Post + }; + + Assert.Equal(HttpMethod.Post, request.Method); + Assert.Equal(HttpMethod.Post, request.Request.Method); + } + + [Fact] + public async Task GetResponseAsync_SuccessStatusCode_ReturnsContentstackResponse() + { + var request = CreateRequest(out _, HttpStatusCode.OK, "{\"name\":\"test\"}"); + + var response = await request.GetResponseAsync(); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("{\"name\":\"test\"}", response.OpenResponse()); + } + + [Fact] + public async Task GetResponseAsync_SendsConfiguredRequestUri() + { + var request = CreateRequest(out var handler, HttpStatusCode.OK, "{}"); + + await request.GetResponseAsync(); + + Assert.Equal(new Uri("http://localhost/test"), handler.LastRequest.RequestUri); + } + + [Fact] + public async Task GetResponseAsync_AmbiguousRangeStatusCode_ReturnsResponseWithoutThrowing() + { + var request = CreateRequest(out _, HttpStatusCode.MultipleChoices, "{}"); + + var response = await request.GetResponseAsync(); + + Assert.Equal(HttpStatusCode.MultipleChoices, response.StatusCode); + } + + [Fact] + public async Task GetResponseAsync_ErrorStatusCode_ThrowsHttpRequestException() + { + var request = CreateRequest(out _, HttpStatusCode.NotFound, "{}"); + + await Assert.ThrowsAsync(() => request.GetResponseAsync()); + } + + [Fact] + public void GetResponse_SuccessStatusCode_ReturnsContentstackResponse() + { + var request = CreateRequest(out _, HttpStatusCode.OK, "{}"); + + var response = request.GetResponse(); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public void GetResponse_ErrorStatusCode_UnwrapsAggregateExceptionAndThrowsHttpRequestException() + { + var request = CreateRequest(out _, HttpStatusCode.NotFound, "{}"); + + Assert.Throws(() => request.GetResponse()); + } + + [Fact] + public void SetRequestHeaders_AddsHeadersToRequest() + { + var httpClient = new HttpClient(new FakeHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.OK))); + var request = new ContentstackHttpRequest(httpClient, new JsonSerializerOptions()); + + request.SetRequestHeaders(new Dictionary { { "api_key", "test_key" } }); + + Assert.True(request.Request.Headers.TryGetValues("api_key", out var values)); + Assert.Contains("test_key", values); + } + + [Fact] + public void WriteToRequestBody_SetsRequestContent() + { + var httpClient = new HttpClient(new FakeHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.OK))); + var request = new ContentstackHttpRequest(httpClient, new JsonSerializerOptions()); + var content = new StringContent("{}"); + + request.WriteToRequestBody(content); + + Assert.Same(content, request.GetRequestContent()); + } + + [Fact] + public void WriteToRequestBody_WithContentHeaders_SetsContentTypeHeader() + { + var httpClient = new HttpClient(new FakeHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.OK))); + var request = new ContentstackHttpRequest(httpClient, new JsonSerializerOptions()); + var content = new StringContent("{}"); + + request.WriteToRequestBody(content, new Dictionary { { "Content-Type", "application/json" } }); + + Assert.Equal("application/json", request.Request.Content.Headers.ContentType.MediaType); + } + + [Fact] + public void Dispose_IsIdempotent() + { + var httpClient = new HttpClient(new FakeHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.OK))); + var request = new ContentstackHttpRequest(httpClient, new JsonSerializerOptions()); + + request.Dispose(); + var exception = Record.Exception(() => request.Dispose()); + + Assert.Null(exception); + } + + [Fact] + public void SetRequestHeaders_AfterDispose_ThrowsObjectDisposedException() + { + var httpClient = new HttpClient(new FakeHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.OK))); + var request = new ContentstackHttpRequest(httpClient, new JsonSerializerOptions()); + request.Dispose(); + + Assert.Throws(() => request.SetRequestHeaders(new Dictionary())); + } + } +} diff --git a/contentstack.model.generator.tests/ContentstackHttpResponseTests.cs b/contentstack.model.generator.tests/ContentstackHttpResponseTests.cs new file mode 100644 index 0000000..91eeae1 --- /dev/null +++ b/contentstack.model.generator.tests/ContentstackHttpResponseTests.cs @@ -0,0 +1,167 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using contentstack.CMA.Http; +using Xunit; + +namespace contentstack.model.generator.tests +{ + /// + /// Tests for contentstack.CMA.Http.ContentstackResponse - part of an HTTP + /// abstraction layer (CMA/Http/*) that is not currently wired into + /// ContentstackClient (which still uses CMA/HTTPRequestHandler.cs). Its + /// constructor takes a plain HttpResponseMessage, so it's fully testable in + /// isolation without any real network call. + /// + public class ContentstackHttpResponseTests + { + private class SamplePayload + { + public string Name { get; set; } + } + + private static HttpResponseMessage CreateResponse( + HttpStatusCode statusCode = HttpStatusCode.OK, + string body = "{\"name\":\"test\"}", + string mediaType = "application/json") + { + var response = new HttpResponseMessage(statusCode) + { + Content = new StringContent(body, Encoding.UTF8, mediaType) + }; + response.Headers.Add("X-Custom-Header", "custom-value"); + return response; + } + + [Fact] + public void Constructor_SetsStatusCodeAndSuccessFlag() + { + var response = new ContentstackResponse(CreateResponse(HttpStatusCode.OK), new JsonSerializerOptions()); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.True(response.IsSuccessStatusCode); + } + + [Fact] + public void Constructor_WithErrorStatusCode_SetsSuccessFlagFalse() + { + var response = new ContentstackResponse(CreateResponse(HttpStatusCode.NotFound), new JsonSerializerOptions()); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + Assert.False(response.IsSuccessStatusCode); + } + + [Fact] + public void Constructor_SetsContentTypeAndContentLength() + { + var response = new ContentstackResponse(CreateResponse(body: "{\"a\":1}"), new JsonSerializerOptions()); + + Assert.Equal("application/json", response.ContentType); + Assert.Equal(Encoding.UTF8.GetByteCount("{\"a\":1}"), response.ContentLength); + } + + [Fact] + public void ResponseBody_ReturnsUnderlyingHttpResponseMessage() + { + var httpResponse = CreateResponse(); + + var response = new ContentstackResponse(httpResponse, new JsonSerializerOptions()); + + Assert.Same(httpResponse, response.ResponseBody); + } + + [Fact] + public void GetHeaderNames_IncludesResponseAndContentHeaders() + { + var response = new ContentstackResponse(CreateResponse(), new JsonSerializerOptions()); + + var headerNames = response.GetHeaderNames(); + + Assert.Contains("X-Custom-Header", headerNames); + Assert.Contains("Content-Type", headerNames); + } + + [Fact] + public void GetHeaderValue_ReturnsMatchingHeaderValue() + { + var response = new ContentstackResponse(CreateResponse(), new JsonSerializerOptions()); + + Assert.Equal("custom-value", response.GetHeaderValue("X-Custom-Header")); + } + + [Fact] + public void GetHeaderValue_MissingHeader_ReturnsEmptyString() + { + var response = new ContentstackResponse(CreateResponse(), new JsonSerializerOptions()); + + Assert.Equal(string.Empty, response.GetHeaderValue("Does-Not-Exist")); + } + + [Fact] + public void IsHeaderPresent_ForExistingHeader_ReturnsTrue() + { + var response = new ContentstackResponse(CreateResponse(), new JsonSerializerOptions()); + + Assert.True(response.IsHeaderPresent("X-Custom-Header")); + } + + [Fact] + public void IsHeaderPresent_ForMissingHeader_ReturnsFalse() + { + var response = new ContentstackResponse(CreateResponse(), new JsonSerializerOptions()); + + Assert.False(response.IsHeaderPresent("Does-Not-Exist")); + } + + [Fact] + public void OpenResponse_ReturnsRawBody() + { + var response = new ContentstackResponse(CreateResponse(body: "{\"name\":\"test\"}"), new JsonSerializerOptions()); + + Assert.Equal("{\"name\":\"test\"}", response.OpenResponse()); + } + + [Fact] + public void OpenJsonObjectResponse_ParsesBodyIntoJsonObject() + { + var response = new ContentstackResponse(CreateResponse(body: "{\"name\":\"test\"}"), new JsonSerializerOptions()); + + var jObject = response.OpenJsonObjectResponse(); + + Assert.Equal("test", jObject["name"].GetValue()); + } + + [Fact] + public void OpenTResponse_DeserializesBodyIntoRequestedType() + { + var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + var response = new ContentstackResponse(CreateResponse(body: "{\"name\":\"test\"}"), options); + + var result = response.OpenTResponse(); + + Assert.Equal("test", result.Name); + } + + [Fact] + public void Dispose_IsIdempotent() + { + var response = new ContentstackResponse(CreateResponse(), new JsonSerializerOptions()); + + response.Dispose(); + var exception = Record.Exception(() => response.Dispose()); + + Assert.Null(exception); + } + + [Fact] + public void OpenResponse_AfterDispose_ThrowsObjectDisposedException() + { + var response = new ContentstackResponse(CreateResponse(), new JsonSerializerOptions()); + response.Dispose(); + + Assert.Throws(() => response.OpenResponse()); + } + } +} diff --git a/contentstack.model.generator.tests/ContentstackResponseModelTests.cs b/contentstack.model.generator.tests/ContentstackResponseModelTests.cs new file mode 100644 index 0000000..b60e08d --- /dev/null +++ b/contentstack.model.generator.tests/ContentstackResponseModelTests.cs @@ -0,0 +1,42 @@ +using System.Collections.Generic; +using Contentstack.Model.Generator.Model; +using contentstack.model.generator.Model; +using Xunit; + +namespace contentstack.model.generator.tests +{ + /// + /// Tests for Contentstack.Model.Generator.Model.ContentstackResponse - the DTO + /// ContentstackClient.GetContentTypes()/GetGlobalFields() deserialize into. Tested + /// directly against the type rather than through ContentstackClient, since the + /// latter requires a real network call. + /// + public class ContentstackResponseModelTests + { + [Fact] + public void DefaultConstructor_CreatesInstance() + { + var response = new ContentstackResponse(); + + Assert.NotNull(response); + } + + [Fact] + public void ListContentTypes_CanBeSetAndRetrieved() + { + var list = new List { new Contenttype { Uid = "blog" } }; + + var response = new ContentstackResponse { listContentTypes = list }; + + Assert.Same(list, response.listContentTypes); + } + + [Fact] + public void Count_CanBeSetAndRetrieved() + { + var response = new ContentstackResponse { Count = 42 }; + + Assert.Equal(42, response.Count); + } + } +} diff --git a/contentstack.model.generator.tests/MessagesTests.cs b/contentstack.model.generator.tests/MessagesTests.cs new file mode 100644 index 0000000..c0a4f79 --- /dev/null +++ b/contentstack.model.generator.tests/MessagesTests.cs @@ -0,0 +1,146 @@ +using contentstack.model.generator; +using Xunit; + +namespace contentstack.model.generator.tests +{ + public class MessagesTests + { + [Fact] + public void NoPathSpecified_FormatsPath() + { + Assert.Equal( + "No path specified. Generating files in the current working directory: /tmp.", + Messages.NoPathSpecified("/tmp")); + } + + [Fact] + public void OutputPathSpecified_FormatsPath() + { + Assert.Equal( + "Output path specified. Generating files at: /tmp.", + Messages.OutputPathSpecified("/tmp")); + } + + [Fact] + public void OutputPathNotFound_FormatsPath() + { + Assert.Equal("Output path not found. Creating: /tmp.", Messages.OutputPathNotFound("/tmp")); + } + + [Fact] + public void OpeningOutputDirectory_FormatsDirectory() + { + Assert.Equal("Opening output directory: /tmp.", Messages.OpeningOutputDirectory("/tmp")); + } + + [Fact] + public void FetchingContentTypes_FormatsStackName() + { + Assert.Equal("Fetching content types from MyStack stack.", Messages.FetchingContentTypes("MyStack")); + } + + [Fact] + public void FoundContentTypes_FormatsCount() + { + Assert.Equal("Found 5 content types.", Messages.FoundContentTypes(5)); + } + + [Fact] + public void FetchedContentTypes_FormatsCount() + { + Assert.Equal("Fetched 5 content types.", Messages.FetchedContentTypes(5)); + } + + [Fact] + public void TotalContentTypesFetched_FormatsCount() + { + Assert.Equal("Total content types fetched: 5.", Messages.TotalContentTypesFetched(5)); + } + + [Fact] + public void FetchingGlobalFields_FormatsStackName() + { + Assert.Equal("Fetching global fields from stack: MyStack.", Messages.FetchingGlobalFields("MyStack")); + } + + [Fact] + public void FoundGlobalFields_FormatsCount() + { + Assert.Equal("Found 3 global fields.", Messages.FoundGlobalFields(3)); + } + + [Fact] + public void FetchedGlobalFields_FormatsCount() + { + Assert.Equal("Fetched 3 global fields.", Messages.FetchedGlobalFields(3)); + } + + [Fact] + public void TotalGlobalFieldsFetched_FormatsCount() + { + Assert.Equal("Total global fields fetched: 3.", Messages.TotalGlobalFieldsFetched(3)); + } + + [Fact] + public void SkippingFile_FormatsFileName() + { + Assert.Equal("Skipping Blog.cs file.", Messages.SkippingFile("Blog.cs")); + } + + [Fact] + public void AddingFile_FormatsFileNameAndDirectory() + { + Assert.Equal("Adding Blog.cs file to /tmp", Messages.AddingFile("Blog.cs", "/tmp")); + } + + [Fact] + public void FieldDataType_FormatsDataType() + { + Assert.Equal("Field data type: text.", Messages.FieldDataType("text")); + } + + [Fact] + public void ExtractingModularBlocksInContentType_FormatsContentTypeName() + { + Assert.Equal( + "Extracting modular blocks in Blog Content Type", + Messages.ExtractingModularBlocksInContentType("Blog")); + } + + [Fact] + public void ExtractingGroupsInContentType_FormatsContentTypeName() + { + Assert.Equal( + "Extracting groups in Blog Content Type", + Messages.ExtractingGroupsInContentType("Blog")); + } + + [Fact] + public void ExtractingModularBlocksInGroup_FormatsGroupName() + { + Assert.Equal("Extracting modular blocks in Meta group.", Messages.ExtractingModularBlocksInGroup("Meta")); + } + + [Fact] + public void ExtractingGroupsInGroup_FormatsGroupName() + { + Assert.Equal("Extracting groups in Meta group.", Messages.ExtractingGroupsInGroup("Meta")); + } + + [Fact] + public void ConstantMessages_HaveExpectedValues() + { + Assert.Equal("Unexpected error occurred. See logs for details.", Messages.UnexpectedError); + Assert.Equal("Operation failed. See error details in logs.", Messages.OperationFailed); + Assert.Equal( + "API communication error. Check your network or configuration and try again.", + Messages.ApiCommunicationError); + Assert.Equal( + "Authentication failed. Verify your API key and auth token and try again.", + Messages.AuthenticationFailed); + Assert.Equal("Fetching stack details for the provided API key.", Messages.FetchingStackDetails); + Assert.Equal("Generating files from content types.", Messages.GeneratingFiles); + Assert.Equal("Files created successfully.", Messages.FilesCreatedSuccessfully); + } + } +} diff --git a/contentstack.model.generator.tests/MetaDataModelTests.cs b/contentstack.model.generator.tests/MetaDataModelTests.cs new file mode 100644 index 0000000..91856f8 --- /dev/null +++ b/contentstack.model.generator.tests/MetaDataModelTests.cs @@ -0,0 +1,32 @@ +using contentstack.model.generator.Model; +using Xunit; + +namespace contentstack.model.generator.tests +{ + public class MetaDataModelTests + { + [Fact] + public void DefaultValue_CanBeSetAndRetrieved() + { + var metaData = new MetaData { DefaultValue = "fallback" }; + + Assert.Equal("fallback", metaData.DefaultValue); + } + + [Fact] + public void IsRichText_CanBeSetAndRetrieved() + { + var metaData = new MetaData { IsRichText = true }; + + Assert.True(metaData.IsRichText); + } + + [Fact] + public void IsExtension_CanBeSetAndRetrieved() + { + var metaData = new MetaData { IsExtension = true }; + + Assert.True(metaData.IsExtension); + } + } +} diff --git a/contentstack.model.generator.tests/ModelGeneratorAddParamsTests.cs b/contentstack.model.generator.tests/ModelGeneratorAddParamsTests.cs new file mode 100644 index 0000000..ca6c94f --- /dev/null +++ b/contentstack.model.generator.tests/ModelGeneratorAddParamsTests.cs @@ -0,0 +1,234 @@ +using System.Collections.Generic; +using System.Text; +using contentstack.model.generator.Model; +using Xunit; +using static contentstack.model.generator.tests.ModelGeneratorTestHelpers; + +namespace contentstack.model.generator.tests +{ + public class ModelGeneratorAddParamsTests + { + #region Attribute + type emission per field type + + [Fact] + public void AddParams_TextField_EmitsJsonPropertyNameAndStringProperty() + { + var generator = CreateGenerator(); + var schema = new List { TextField("title") }; + var sb = new StringBuilder(); + + generator.AddParams("Blog", schema, sb); + var content = sb.ToString(); + + Assert.Contains("[JsonPropertyName(\"title\")]", content); + Assert.Contains("public string Title { get; set; }", content); + } + + [Fact] + public void AddParams_NumberField_EmitsDoubleProperty() + { + var generator = CreateGenerator(); + var schema = new List { NumberField("views") }; + var sb = new StringBuilder(); + + generator.AddParams("Blog", schema, sb); + var content = sb.ToString(); + + Assert.Contains("[JsonPropertyName(\"views\")]", content); + Assert.Contains("public double Views { get; set; }", content); + } + + [Fact] + public void AddParams_BooleanField_EmitsBoolProperty() + { + var generator = CreateGenerator(); + var schema = new List { BooleanField("published") }; + var sb = new StringBuilder(); + + generator.AddParams("Blog", schema, sb); + var content = sb.ToString(); + + Assert.Contains("public bool Published { get; set; }", content); + } + + [Fact] + public void AddParams_DateField_EmitsDateTimeProperty() + { + var generator = CreateGenerator(); + var schema = new List { DateField("published_on") }; + var sb = new StringBuilder(); + + generator.AddParams("Blog", schema, sb); + var content = sb.ToString(); + + Assert.Contains("public DateTime PublishedOn { get; set; }", content); + } + + [Fact] + public void AddParams_FileField_EmitsAssetProperty() + { + var generator = CreateGenerator(); + var schema = new List { FileField("cover_image") }; + var sb = new StringBuilder(); + + generator.AddParams("Blog", schema, sb); + var content = sb.ToString(); + + Assert.Contains("public Asset CoverImage { get; set; }", content); + } + + [Fact] + public void AddParams_LinkField_EmitsContentstackLinkProperty() + { + var generator = CreateGenerator(); + var schema = new List { LinkTypeField("cta") }; + var sb = new StringBuilder(); + + generator.AddParams("Blog", schema, sb); + var content = sb.ToString(); + + Assert.Contains("public ContentstackLink Cta { get; set; }", content); + } + + #endregion + + #region Reference / group / blocks + + [Fact] + public void AddParams_ReferenceFieldSingle_EmitsResolvedClassNameProperty() + { + var generator = CreateGenerator(seedContentTypes: new[] { MakeContentType("blog_post", "Blog Post") }); + var schema = new List { ReferenceField("related", SingleRef("blog_post")) }; + var sb = new StringBuilder(); + + generator.AddParams("Blog", schema, sb); + var content = sb.ToString(); + + Assert.Contains("public BlogPost Related { get; set; }", content); + } + + [Fact] + public void AddParams_ReferenceFieldMultipleContentTypes_EmitsObjectProperty() + { + var generator = CreateGenerator(seedContentTypes: new[] { MakeContentType("blog_post", "Blog Post") }); + var schema = new List { ReferenceField("related", SingleRef("blog_post"), refMultipleContentType: true) }; + var sb = new StringBuilder(); + + generator.AddParams("Blog", schema, sb); + var content = sb.ToString(); + + Assert.Contains("public object Related { get; set; }", content); + } + + [Fact] + public void AddParams_GroupField_EmitsGroupPrefixedTypeProperty() + { + var generator = CreateGenerator(); + var schema = new List { GroupField("meta", "Meta", new List()) }; + var sb = new StringBuilder(); + + generator.AddParams("Blog", schema, sb); + var content = sb.ToString(); + + Assert.Contains("public GroupBlogMeta Meta { get; set; }", content); + } + + [Fact] + public void AddParams_BlocksField_EmitsModularBlockPrefixedTypeProperty() + { + var generator = CreateGenerator(); + var schema = new List { BlocksField("sections", "Sections", new List()) }; + var sb = new StringBuilder(); + + generator.AddParams("Blog", schema, sb); + var content = sb.ToString(); + + Assert.Contains("public MBBlogSections Sections { get; set; }", content); + } + + #endregion + + #region Markdown special case + + [Fact] + public void AddParams_MarkdownTextField_EmitsGetSetHtmlConversionAndBackingStoreField() + { + var generator = CreateGenerator(); + var schema = new List { TextField("body", isMarkdown: true) }; + var sb = new StringBuilder(); + + generator.AddParams("Blog", schema, sb); + var content = sb.ToString(); + + Assert.Contains("public string Body {", content); + Assert.Contains("this.BodyStore = value;", content); + Assert.Contains("return this.BodyStore.ToHtml();", content); + Assert.Contains("private string BodyStore = \"\";", content); + } + + [Fact] + public void AddParams_MarkdownTextField_WithIsMultiple_UsesToListHtml() + { + var generator = CreateGenerator(); + var schema = new List { TextField("body", isMarkdown: true, isMultiple: true) }; + var sb = new StringBuilder(); + + generator.AddParams("Blog", schema, sb); + var content = sb.ToString(); + + Assert.Contains("return this.BodyStore.ToListHtml();", content); + } + + [Fact] + public void AddParams_MarkdownTextField_PropertyDeclaration_NeverHasNullableSuffix() + { + var generator = CreateGenerator(isNullable: true); + var schema = new List { TextField("body", isMarkdown: true) }; + var sb = new StringBuilder(); + + generator.AddParams("Blog", schema, sb); + var content = sb.ToString(); + + Assert.Contains("public string Body {", content); + Assert.DoesNotContain("public string? Body {", content); + } + + #endregion + + #region Multiplicity + ordering + + [Fact] + public void AddParams_MultipleValueField_WrapsPropertyTypeInList() + { + var generator = CreateGenerator(); + var schema = new List { NumberField("scores", isMultiple: true) }; + var sb = new StringBuilder(); + + generator.AddParams("Blog", schema, sb); + var content = sb.ToString(); + + Assert.Contains("public List Scores { get; set; }", content); + } + + [Fact] + public void AddParams_MultipleFields_PreservesSchemaOrderInOutput() + { + var generator = CreateGenerator(); + var schema = new List { TextField("a"), NumberField("b"), BooleanField("c") }; + var sb = new StringBuilder(); + + generator.AddParams("Blog", schema, sb); + var content = sb.ToString(); + + var indexA = content.IndexOf("[JsonPropertyName(\"a\")]"); + var indexB = content.IndexOf("[JsonPropertyName(\"b\")]"); + var indexC = content.IndexOf("[JsonPropertyName(\"c\")]"); + + Assert.True(indexA >= 0 && indexB >= 0 && indexC >= 0); + Assert.True(indexA < indexB); + Assert.True(indexB < indexC); + } + + #endregion + } +} diff --git a/contentstack.model.generator.tests/ModelGeneratorCreateFileTests.cs b/contentstack.model.generator.tests/ModelGeneratorCreateFileTests.cs new file mode 100644 index 0000000..d31043b --- /dev/null +++ b/contentstack.model.generator.tests/ModelGeneratorCreateFileTests.cs @@ -0,0 +1,295 @@ +using System; +using System.Collections.Generic; +using System.IO; +using contentstack.model.generator.Model; +using Xunit; +using static contentstack.model.generator.tests.ModelGeneratorTestHelpers; + +namespace contentstack.model.generator.tests +{ + /// + /// CreateFile() end-to-end: class shape, RTE-reference extends-clause toggle, nested + /// blocks/group generation, using-directive emission, and the embedded-items + /// interpolation-bug regression. + /// + /// Not covered (intentionally): the "file already exists" overwrite-prompt branch in + /// shouldCreateFile() - with Force=false (the default) that calls Prompt.GetYesNo(), + /// which blocks on console input and would hang a headless test run. Every test here + /// uses a fresh unique temp directory so that branch is never reached. + /// + public class ModelGeneratorCreateFileTests : IDisposable + { + private readonly DirectoryInfo _tempDir; + + public ModelGeneratorCreateFileTests() + { + _tempDir = Directory.CreateTempSubdirectory("cmg_createfile_tests_"); + } + + private string ReadGenerated(string fileName) => + File.ReadAllText(Path.Combine(_tempDir.FullName, fileName)); + + #region Class shape + + [Fact] + public void CreateFile_SimpleContentType_WritesFileWithConstUidAndContentTypeUid() + { + var generator = CreateGenerator(); + var contentType = MakeContentType("blog", "Blog"); + + generator.CreateFile("Blog", "ContentstackModels", contentType, _tempDir); + var content = ReadGenerated("Blog.cs"); + + Assert.Contains("public const string ContentType = \"blog\";", content); + Assert.Contains("[JsonPropertyName(\"uid\")]", content); + Assert.Contains("public string Uid { get; set; }", content); + Assert.Contains("[JsonPropertyName(\"_content_type_uid\")]", content); + Assert.Contains("public string ContentTypeUid { get; set; }", content); + } + + [Fact] + public void CreateFile_SchemaWithoutRteReference_ExtendsIEmbeddedObjectOnly() + { + var generator = CreateGenerator(); + var contentType = MakeContentType("blog", "Blog", new List { TextField("title") }); + + generator.CreateFile("Blog", "ContentstackModels", contentType, _tempDir); + var content = ReadGenerated("Blog.cs"); + + Assert.Contains("public partial class Blog : IEmbeddedObject", content); + Assert.DoesNotContain("IEntryEmbedable", content); + } + + [Fact] + public void CreateFile_SchemaWithRteReference_ExtendsIEntryEmbedableAndIEmbeddedObject() + { + var generator = CreateGenerator(); + var contentType = MakeContentType("blog", "Blog", new List { TextField("body", referenceTo: "some-rte-reference") }); + + generator.CreateFile("Blog", "ContentstackModels", contentType, _tempDir); + var content = ReadGenerated("Blog.cs"); + + Assert.Contains("public partial class Blog : IEntryEmbedable, IEmbeddedObject", content); + } + + [Fact] + public void CreateFile_SchemaWithoutRteReference_OmitsEmbeddedItemsPropertyBlock() + { + var generator = CreateGenerator(); + var contentType = MakeContentType("blog", "Blog", new List { TextField("title") }); + + generator.CreateFile("Blog", "ContentstackModels", contentType, _tempDir); + var content = ReadGenerated("Blog.cs"); + + Assert.DoesNotContain("embeddedItems", content); + } + + #endregion + + #region Nested modular blocks + + [Fact] + public void CreateFile_SchemaWithBlocksField_CreatesNestedModularBlockDirectoryAndFiles() + { + var generator = CreateGenerator(); + var blocks = new List { MakeContentType("heading", "Heading") }; + var contentType = MakeContentType("blog", "Blog", new List { BlocksField("sections", "Sections", blocks) }); + + generator.CreateFile("Blog", "ContentstackModels", contentType, _tempDir); + var blocksDir = Path.Combine(_tempDir.FullName, "BlogBlocks"); + + Assert.True(Directory.Exists(blocksDir)); + Assert.True(File.Exists(Path.Combine(blocksDir, "MBBlogSectionsHeading.cs"))); + Assert.True(File.Exists(Path.Combine(blocksDir, "MBBlogSectionsEnum.cs"))); + Assert.True(File.Exists(Path.Combine(blocksDir, "MBBlogSections.cs"))); + Assert.True(File.Exists(Path.Combine(blocksDir, "MBBlogSectionsConverter.cs"))); + } + + [Fact] + public void CreateFile_SchemaWithBlocksField_AddsCorrectUsingDirectiveToTopLevelFile() + { + var generator = CreateGenerator(); + var blocks = new List { MakeContentType("heading", "Heading") }; + var contentType = MakeContentType("blog", "Blog", new List { BlocksField("sections", "Sections", blocks) }); + + generator.CreateFile("Blog", "ContentstackModels", contentType, _tempDir); + var content = ReadGenerated("Blog.cs"); + + Assert.Contains($"using ContentstackModels.{_tempDir.Name}.BlogBlocks;", content); + } + + [Fact] + public void CreateFile_SchemaWithEmptyBlocksList_StillCreatesConverterFile() + { + var generator = CreateGenerator(); + var contentType = MakeContentType("blog", "Blog", new List { BlocksField("sections", "Sections", new List()) }); + + generator.CreateFile("Blog", "ContentstackModels", contentType, _tempDir); + var blocksDir = Path.Combine(_tempDir.FullName, "BlogBlocks"); + var converterContent = File.ReadAllText(Path.Combine(blocksDir, "MBBlogSectionsConverter.cs")); + + Assert.Contains("return new MBBlogSections();", converterContent); + Assert.DoesNotContain("ContentstackHelper.FieldExists", converterContent); + Assert.False(File.Exists(Path.Combine(blocksDir, "MBBlogSectionsEnum.cs"))); + Assert.False(File.Exists(Path.Combine(blocksDir, "MBBlogSections.cs"))); + } + + [Fact] + public void CreateFile_SchemaWithBlocksFieldReferencingGlobalField_CreatesGlobalFieldModularFile() + { + var globalFieldSchema = new List + { + TextField("note"), + BlocksField("sub_blocks", "SubBlocks", new List()), + GroupField("sub_group", "SubGroup", new List()) + }; + var generator = CreateGenerator(seedContentTypes: new[] { MakeContentType("global_heading", "Global Heading", globalFieldSchema) }); + var blocks = new List { MakeContentType("heading", "Heading", referenceTo: "global_heading") }; + var contentType = MakeContentType("blog", "Blog", new List { BlocksField("sections", "Sections", blocks) }); + + generator.CreateFile("Blog", "ContentstackModels", contentType, _tempDir); + var blockFile = Path.Combine(_tempDir.FullName, "BlogBlocks", "MBBlogSectionsHeading.cs"); + + Assert.True(File.Exists(blockFile)); + var content = File.ReadAllText(blockFile); + Assert.Contains("[JsonPropertyName(\"note\")]", content); + Assert.Contains("public string Note { get; set; }", content); + Assert.Contains("using ContentstackModels.Models.GlobalHeadingBlocks;", content); + Assert.Contains("using ContentstackModels.Models.GlobalHeadingGroup;", content); + } + + [Fact] + public void CreateFile_NestedBlocksWithinBlock_AddsUsingDirectiveForNestedModularBlocks() + { + var nestedBlocks = new List { MakeContentType("nested_heading", "Nested Heading") }; + var innerSchema = new List { BlocksField("nested_sections", "NestedSections", nestedBlocks) }; + var blocks = new List { MakeContentType("heading", "Heading", innerSchema) }; + var contentType = MakeContentType("blog", "Blog", new List { BlocksField("sections", "Sections", blocks) }); + var generator = CreateGenerator(); + + generator.CreateFile("Blog", "ContentstackModels", contentType, _tempDir); + var blocksDir = Path.Combine(_tempDir.FullName, "BlogBlocks"); + var blockFile = Path.Combine(blocksDir, "MBBlogSectionsHeading.cs"); + + Assert.True(Directory.Exists(Path.Combine(blocksDir, "MBBlogSectionsHeadingBlocks"))); + Assert.True(File.Exists(blockFile)); + var content = File.ReadAllText(blockFile); + Assert.Contains($"using ContentstackModels.{_tempDir.Name}.BlogBlocks.MBBlogSectionsHeadingBlocks;", content); + } + + #endregion + + #region findRTEReference (direct) + + [Fact] + public void FindRTEReference_NullSchema_ReturnsFalse() + { + var generator = CreateGenerator(); + + var result = generator.findRTEReference(null); + + Assert.False(result); + } + + [Fact] + public void FindRTEReference_BlocksFieldWithNestedRteReference_ReturnsTrue() + { + var generator = CreateGenerator(); + var nestedSchema = new List { TextField("body", referenceTo: "some-rte-reference") }; + var blocks = new List { MakeContentType("heading", "Heading", nestedSchema) }; + var schema = new List { BlocksField("sections", "Sections", blocks) }; + + var result = generator.findRTEReference(schema); + + Assert.True(result); + } + + [Fact] + public void FindRTEReference_GroupFieldWithNullSchema_ReturnsFalse() + { + var generator = CreateGenerator(); + var schema = new List { GroupField("meta", "Meta", null) }; + + var result = generator.findRTEReference(schema); + + Assert.False(result); + } + + #endregion + + #region Nested groups + + [Fact] + public void CreateFile_SchemaWithGroupField_CreatesNestedGroupDirectoryAndFile() + { + var generator = CreateGenerator(); + var contentType = MakeContentType("blog", "Blog", new List { GroupField("meta", "Meta", new List { TextField("note") }) }); + + generator.CreateFile("Blog", "ContentstackModels", contentType, _tempDir); + var groupDir = Path.Combine(_tempDir.FullName, "BlogGroup"); + var groupFile = Path.Combine(groupDir, "GroupBlogMeta.cs"); + + Assert.True(Directory.Exists(groupDir)); + Assert.True(File.Exists(groupFile)); + + var content = File.ReadAllText(groupFile); + Assert.Contains("[JsonPropertyName(\"note\")]", content); + Assert.Contains("public string Note { get; set; }", content); + } + + [Fact] + public void CreateFile_SchemaWithGroupField_AddsCorrectUsingDirectiveToTopLevelFile() + { + var generator = CreateGenerator(); + var contentType = MakeContentType("blog", "Blog", new List { GroupField("meta", "Meta", new List { TextField("note") }) }); + + generator.CreateFile("Blog", "ContentstackModels", contentType, _tempDir); + var content = ReadGenerated("Blog.cs"); + + Assert.Contains($"using ContentstackModels.{_tempDir.Name}.BlogGroup;", content); + } + + [Fact] + public void CreateFile_GroupFieldContainingBlocksField_AddsModularUsingDirectiveInsideGroup() + { + var nestedBlocks = new List { MakeContentType("sub_heading", "Sub Heading") }; + var groupSchema = new List { BlocksField("sub_sections", "SubSections", nestedBlocks) }; + var contentType = MakeContentType("blog", "Blog", new List { GroupField("meta", "Meta", groupSchema) }); + var generator = CreateGenerator(); + + generator.CreateFile("Blog", "ContentstackModels", contentType, _tempDir); + var groupFile = Path.Combine(_tempDir.FullName, "BlogGroup", "GroupBlogMeta.cs"); + + Assert.True(File.Exists(groupFile)); + var content = File.ReadAllText(groupFile); + Assert.Contains($"using ContentstackModels.{_tempDir.Name}.BlogGroup.GroupBlogMetaBlocks;", content); + } + + #endregion + + #region Embedded-items interpolation bug regression + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void CreateFile_RteReferenceField_EmbeddedItemsLine_IsCorrectlyInterpolated_NotLiteralBraces(bool isNullable) + { + var generator = CreateGenerator(isNullable: isNullable); + var contentType = MakeContentType("blog", "Blog", new List { TextField("body", referenceTo: "some-rte-reference") }); + + generator.CreateFile("Blog", "ContentstackModels", contentType, _tempDir); + var content = ReadGenerated("Blog.cs"); + + Assert.DoesNotContain("{nullableString()}", content); + var suffix = isNullable ? "?" : ""; + Assert.Contains($"public Dictionary>{suffix} embeddedItems {{ get; set; }}", content); + } + + #endregion + + public void Dispose() + { + try { Directory.Delete(_tempDir.FullName, recursive: true); } catch { /* best-effort cleanup */ } + } + } +} diff --git a/contentstack.model.generator.tests/ModelGeneratorFieldTypeResolutionTests.cs b/contentstack.model.generator.tests/ModelGeneratorFieldTypeResolutionTests.cs new file mode 100644 index 0000000..436bf13 --- /dev/null +++ b/contentstack.model.generator.tests/ModelGeneratorFieldTypeResolutionTests.cs @@ -0,0 +1,280 @@ +using System; +using contentstack.model.generator.Model; +using Xunit; +using static contentstack.model.generator.tests.ModelGeneratorTestHelpers; + +namespace contentstack.model.generator.tests +{ + public class ModelGeneratorFieldTypeResolutionTests + { + #region Scalar field types + + [Fact] + public void GetDatatypeForField_TextField_ReturnsString() + { + var generator = CreateGenerator(); + var field = TextField("title"); + + var result = generator.GetDatatypeForField(field, "Blog"); + + Assert.Equal("string", result); + } + + [Fact] + public void GetDatatypeForField_NumberField_ReturnsDouble() + { + var generator = CreateGenerator(); + var field = NumberField("views"); + + var result = generator.GetDatatypeForField(field, "Blog"); + + Assert.Equal("double", result); + } + + [Fact] + public void GetDatatypeForField_BooleanField_ReturnsBool() + { + var generator = CreateGenerator(); + var field = BooleanField("published"); + + var result = generator.GetDatatypeForField(field, "Blog"); + + Assert.Equal("bool", result); + } + + [Fact] + public void GetDatatypeForField_DateField_ReturnsDateTime() + { + var generator = CreateGenerator(); + var field = DateField("published_on"); + + var result = generator.GetDatatypeForField(field, "Blog"); + + Assert.Equal("DateTime", result); + } + + [Fact] + public void GetDatatypeForField_FileField_ReturnsAsset() + { + var generator = CreateGenerator(); + var field = FileField("cover_image"); + + var result = generator.GetDatatypeForField(field, "Blog"); + + Assert.Equal("Asset", result); + } + + [Fact] + public void GetDatatypeForField_LinkField_ReturnsContentstackLink() + { + var generator = CreateGenerator(); + var field = LinkTypeField("cta"); + + var result = generator.GetDatatypeForField(field, "Blog"); + + Assert.Equal("ContentstackLink", result); + } + + [Fact] + public void GetDatatypeForField_JsonFieldWithJsonRte_ReturnsNode() + { + var generator = CreateGenerator(); + var field = JsonField("rich_text", isJsonRTE: true); + + var result = generator.GetDatatypeForField(field, "Blog"); + + Assert.Equal("Node", result); + } + + [Fact] + public void GetDatatypeForField_JsonFieldWithoutJsonRte_ReturnsDynamic() + { + var generator = CreateGenerator(); + var field = JsonField("raw_json", isJsonRTE: false); + + var result = generator.GetDatatypeForField(field, "Blog"); + + Assert.Equal("dynamic", result); + } + + [Fact] + public void GetDatatypeForField_UnknownDataType_ReturnsObject() + { + var generator = CreateGenerator(); + var field = UnknownTypeField("mystery"); + + var result = generator.GetDatatypeForField(field, "Blog"); + + Assert.Equal("object", result); + } + + #endregion + + #region Blocks / group naming + + [Fact] + public void GetDatatypeForField_BlocksField_ReturnsModularBlockPrefixedName() + { + var generator = CreateGenerator(); + var field = BlocksField("sections", "Sections", new System.Collections.Generic.List()); + + var result = generator.GetDatatypeForField(field, "Blog"); + + Assert.Equal("MBBlogSections", result); + } + + [Fact] + public void GetDatatypeForField_GroupField_ReturnsGroupPrefixedName() + { + var generator = CreateGenerator(); + var field = GroupField("meta", "Meta", new System.Collections.Generic.List()); + + var result = generator.GetDatatypeForField(field, "Blog"); + + Assert.Equal("GroupBlogMeta", result); + } + + #endregion + + #region Reference / global_field resolution + + [Fact] + public void GetDatatypeForField_ReferenceSingle_Resolved_ReturnsClassName() + { + var generator = CreateGenerator(seedContentTypes: new[] { MakeContentType("blog_post", "Blog Post") }); + var field = ReferenceField("related", SingleRef("blog_post")); + + var result = generator.GetDatatypeForField(field, "Blog"); + + Assert.Equal("BlogPost", result); + } + + [Fact] + public void GetDatatypeForField_ReferenceSingle_Unresolved_ReturnsObject() + { + var generator = CreateGenerator(); + var field = ReferenceField("related", SingleRef("missing_uid")); + + var result = generator.GetDatatypeForField(field, "Blog"); + + Assert.Equal("object", result); + } + + [Fact] + public void GetDatatypeForField_ReferenceSingle_WithRefMultipleContentTypesTrue_ReturnsObject() + { + var generator = CreateGenerator(seedContentTypes: new[] { MakeContentType("blog_post", "Blog Post") }); + var field = ReferenceField("related", SingleRef("blog_post"), refMultipleContentType: true); + + var result = generator.GetDatatypeForField(field, "Blog"); + + Assert.Equal("object", result); + } + + [Fact] + public void GetDatatypeForField_ReferenceArrayOfOne_Resolved_ReturnsClassName() + { + var generator = CreateGenerator(seedContentTypes: new[] { MakeContentType("blog_post", "Blog Post") }); + var field = ReferenceField("related", MultiRef("blog_post")); + + var result = generator.GetDatatypeForField(field, "Blog"); + + Assert.Equal("BlogPost", result); + } + + [Fact] + public void GetDatatypeForField_ReferenceArrayOfTwo_ReturnsObject() + { + var generator = CreateGenerator(seedContentTypes: new[] { MakeContentType("blog_post", "Blog Post") }); + var field = ReferenceField("related", MultiRef("blog_post", "author")); + + var result = generator.GetDatatypeForField(field, "Blog"); + + Assert.Equal("object", result); + } + + [Fact] + public void GetDatatypeForField_GlobalField_UsesSameResolutionAsReference() + { + var generator = CreateGenerator(seedContentTypes: new[] { MakeContentType("blog_post", "Blog Post") }); + var field = GlobalFieldField("related", SingleRef("blog_post")); + + var result = generator.GetDatatypeForField(field, "Blog"); + + Assert.Equal("BlogPost", result); + } + + #endregion + + #region Multiplicity wrapping + + [Fact] + public void GetDatatypeForField_Reference_StackVersionAtThreshold_AlwaysWrapsInList() + { + var generator = CreateGenerator(stackVersion: DateTime.Parse("2019-04-04")); + var field = ReferenceField("related", SingleRef("missing_uid")); + + var result = generator.GetDatatypeForField(field, "Blog"); + + Assert.Equal("List", result); + } + + [Fact] + public void GetDatatypeForField_Reference_StackVersionBeforeThreshold_SingleValue_ReturnsUnwrapped() + { + var generator = CreateGenerator(); + var field = ReferenceField("related", SingleRef("missing_uid")); + + var result = generator.GetDatatypeForField(field, "Blog"); + + Assert.Equal("object", result); + } + + [Fact] + public void GetDatatypeForField_GlobalField_StackVersionAfterThreshold_IsMultipleFalse_ReturnsUnwrapped() + { + var generator = CreateGenerator(stackVersion: DateTime.Parse("2019-04-04")); + var field = GlobalFieldField("related", SingleRef("missing_uid")); + + var result = generator.GetDatatypeForField(field, "Blog"); + + Assert.Equal("object", result); + } + + [Fact] + public void GetDatatypeForField_AnyFieldType_WithRefMultipleTrue_WrapsInList() + { + var generator = CreateGenerator(); + var field = TextField("tags"); + field.FieldMetadata.RefMultiple = true; + + var result = generator.GetDatatypeForField(field, "Blog"); + + Assert.Equal("List", result); + } + + [Fact] + public void GetDatatypeForField_AnyFieldType_WithIsMultipleTrue_WrapsInList() + { + var generator = CreateGenerator(); + var field = NumberField("scores", isMultiple: true); + + var result = generator.GetDatatypeForField(field, "Blog"); + + Assert.Equal("List", result); + } + + [Fact] + public void GetDatatypeForField_ScalarField_NoMultiplicityFlags_ReturnsUnwrapped() + { + var generator = CreateGenerator(); + var field = NumberField("score"); + + var result = generator.GetDatatypeForField(field, "Blog"); + + Assert.Equal("double", result); + } + + #endregion + } +} diff --git a/contentstack.model.generator.tests/ModelGeneratorNullableVariantTests.cs b/contentstack.model.generator.tests/ModelGeneratorNullableVariantTests.cs new file mode 100644 index 0000000..6b66c47 --- /dev/null +++ b/contentstack.model.generator.tests/ModelGeneratorNullableVariantTests.cs @@ -0,0 +1,117 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using contentstack.model.generator.Model; +using Xunit; +using static contentstack.model.generator.tests.ModelGeneratorTestHelpers; + +namespace contentstack.model.generator.tests +{ + /// + /// Toggles IsNullable (the --is-nullable / -N CLI flag) across the code-generation + /// methods that append a nullable-reference suffix, and pins the one known asymmetry: + /// CreateEmbeddedObjectClass()'s Read/Write signatures never take a nullable suffix. + /// + public class ModelGeneratorNullableVariantTests : IDisposable + { + private readonly DirectoryInfo _tempDir; + + public ModelGeneratorNullableVariantTests() + { + _tempDir = Directory.CreateTempSubdirectory("cmg_nullable_tests_"); + } + + [Theory] + [InlineData(true, "?")] + [InlineData(false, "")] + public void AddParams_ScalarField_NullableSuffix_MatchesIsNullableSetting(bool isNullable, string suffix) + { + var generator = CreateGenerator(isNullable: isNullable); + var schema = new List { TextField("title") }; + var sb = new StringBuilder(); + + generator.AddParams("Blog", schema, sb); + var content = sb.ToString(); + + Assert.Contains($"public string{suffix} Title {{ get; set; }}", content); + } + + [Theory] + [InlineData(true, "?")] + [InlineData(false, "")] + public void CreateLinkClass_NullableSuffix_MatchesIsNullableSetting(bool isNullable, string suffix) + { + var generator = CreateGenerator(isNullable: isNullable); + + generator.CreateLinkClass("ContentstackModels", _tempDir); + var content = File.ReadAllText(Path.Combine(_tempDir.FullName, "ContentstackLink.cs")); + + Assert.Contains($"public string{suffix} Title {{ get; set; }}", content); + Assert.Contains($"public string{suffix} Href {{ get; set; }}", content); + } + + [Theory] + [InlineData(true, "?")] + [InlineData(false, "")] + public void CreateFile_UidAndContentTypeUid_NullableSuffix_MatchesIsNullableSetting(bool isNullable, string suffix) + { + var generator = CreateGenerator(isNullable: isNullable); + var contentType = MakeContentType("blog", "Blog", new List()); + + generator.CreateFile("Blog", "ContentstackModels", contentType, _tempDir); + var content = File.ReadAllText(Path.Combine(_tempDir.FullName, "Blog.cs")); + + Assert.Contains($"public string{suffix} Uid {{ get; set; }}", content); + Assert.Contains($"public string{suffix} ContentTypeUid {{ get; set; }}", content); + } + + [Theory] + [InlineData(true, "?")] + [InlineData(false, "")] + public void CreateFile_EmbeddedItemsProperty_NullableSuffix_MatchesIsNullableSetting(bool isNullable, string suffix) + { + var generator = CreateGenerator(isNullable: isNullable); + var schema = new List { TextField("body", referenceTo: "some-rte-reference") }; + var contentType = MakeContentType("blog", "Blog", schema); + + generator.CreateFile("Blog", "ContentstackModels", contentType, _tempDir); + var content = File.ReadAllText(Path.Combine(_tempDir.FullName, "Blog.cs")); + + Assert.Contains($"public Dictionary>{suffix} embeddedItems {{ get; set; }}", content); + } + + [Theory] + [InlineData(true, "?")] + [InlineData(false, "")] + public void CreateModularBlockConverter_ReadWriteSignatures_NullableSuffix_MatchesIsNullableSetting(bool isNullable, string suffix) + { + var generator = CreateGenerator(isNullable: isNullable); + var blockTypes = new Dictionary { { "heading", "MBTestHeading" } }; + + generator.CreateModularBlockConverter("ContentstackModels", "MBTest", blockTypes, _tempDir); + var content = File.ReadAllText(Path.Combine(_tempDir.FullName, "MBTestConverter.cs")); + + Assert.Contains($"public override MBTest{suffix} Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)", content); + Assert.Contains($"public override void Write(Utf8JsonWriter writer, MBTest{suffix} value, JsonSerializerOptions options)", content); + } + + [Fact] + public void CreateEmbeddedObjectClass_ReadWriteSignatures_NeverHaveNullableSuffix() + { + var generator = CreateGenerator(isNullable: true); + + generator.CreateEmbeddedObjectClass("ContentstackModels", _tempDir); + var content = File.ReadAllText(Path.Combine(_tempDir.FullName, "IEmbeddedObjectConverter.cs")); + + Assert.Contains("public override List Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)", content); + Assert.Contains("public override void Write(Utf8JsonWriter writer, List value, JsonSerializerOptions options)", content); + Assert.DoesNotContain("List?", content); + } + + public void Dispose() + { + try { Directory.Delete(_tempDir.FullName, recursive: true); } catch { /* best-effort cleanup */ } + } + } +} diff --git a/contentstack.model.generator.tests/ModelGeneratorSupportingClassGenerationTests.cs b/contentstack.model.generator.tests/ModelGeneratorSupportingClassGenerationTests.cs new file mode 100644 index 0000000..db00462 --- /dev/null +++ b/contentstack.model.generator.tests/ModelGeneratorSupportingClassGenerationTests.cs @@ -0,0 +1,285 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Xunit; +using static contentstack.model.generator.tests.ModelGeneratorTestHelpers; + +namespace contentstack.model.generator.tests +{ + /// + /// CreateLinkClass(), CreateEmbeddedObjectClass(), CreateModularBlockConverter(), + /// CreateHelperClass(), CreateStringHelperClass(), and CreateDisplayAttributeClass() + /// called directly (bypassing CreateFile()) - these are only invoked from OnExecute() + /// in production, so there is no other way to reach them in a unit test. + /// + public class ModelGeneratorSupportingClassGenerationTests : IDisposable + { + private readonly DirectoryInfo _tempDir; + + public ModelGeneratorSupportingClassGenerationTests() + { + _tempDir = Directory.CreateTempSubdirectory("cmg_supporting_tests_"); + } + + private string ReadGenerated(string fileName) => + File.ReadAllText(Path.Combine(_tempDir.FullName, fileName)); + + #region CreateLinkClass + + [Fact] + public void CreateLinkClass_WritesFileWithTitleAndHrefProperties() + { + var generator = CreateGenerator(); + + generator.CreateLinkClass("ContentstackModels", _tempDir); + var content = ReadGenerated("ContentstackLink.cs"); + + Assert.Contains("[JsonPropertyName(\"title\")]", content); + Assert.Contains("public string Title { get; set; }", content); + Assert.Contains("[JsonPropertyName(\"href\")]", content); + Assert.Contains("public string Href { get; set; }", content); + } + + [Fact] + public void CreateLinkClass_UsingDirectives_IncludeTemplateStartBlock() + { + var generator = CreateGenerator(); + + generator.CreateLinkClass("ContentstackModels", _tempDir); + var content = ReadGenerated("ContentstackLink.cs"); + + Assert.Contains("using Contentstack.Utils.Interfaces;", content); + Assert.Contains("using System.Text.Json.Serialization;", content); + } + + [Fact] + public void CreateLinkClass_ExistingFile_PromptYes_OverwritesFile() + { + var original = Console.In; + try + { + CreateGenerator(isNullable: false).CreateLinkClass("ContentstackModels", _tempDir); + + Console.SetIn(new StringReader("y\n")); + CreateGenerator(isNullable: true).CreateLinkClass("ContentstackModels", _tempDir); + + var content = ReadGenerated("ContentstackLink.cs"); + Assert.Contains("public string? Title { get; set; }", content); + } + finally + { + Console.SetIn(original); + } + } + + [Fact] + public void CreateLinkClass_ExistingFile_PromptNo_SkipsOverwrite() + { + var original = Console.In; + try + { + CreateGenerator(isNullable: false).CreateLinkClass("ContentstackModels", _tempDir); + + Console.SetIn(new StringReader("n\n")); + CreateGenerator(isNullable: true).CreateLinkClass("ContentstackModels", _tempDir); + + var content = ReadGenerated("ContentstackLink.cs"); + Assert.Contains("public string Title { get; set; }", content); + Assert.DoesNotContain("public string? Title { get; set; }", content); + } + finally + { + Console.SetIn(original); + } + } + + #endregion + + #region CreateHelperClass + + [Fact] + public void CreateHelperClass_WritesFileWithGetDescriptionAndFieldExists() + { + var generator = CreateGenerator(); + + generator.CreateHelperClass("ContentstackModels", _tempDir); + var content = ReadGenerated("ContentstackHelper.cs"); + + Assert.Contains("public static string GetDescription(Enum en)", content); + Assert.Contains("public static bool FieldExists(string fieldName, JsonObject jObject)", content); + Assert.Contains("return jObject[fieldName] != null;", content); + } + + #endregion + + #region CreateStringHelperClass + + [Fact] + public void CreateStringHelperClass_WritesFileWithToHtmlAndToListHtmlExtensions() + { + var generator = CreateGenerator(); + + generator.CreateStringHelperClass("ContentstackModels", _tempDir); + var content = ReadGenerated("ContentstackStringExtension.cs"); + + Assert.Contains("using Markdig;", content); + Assert.Contains("public static string ToHtml(this String str)", content); + Assert.Contains("public static List ToListHtml(this List str)", content); + } + + #endregion + + #region CreateDisplayAttributeClass + + [Fact] + public void CreateDisplayAttributeClass_WritesAttributeClassWithDisplayNameConstructor() + { + var generator = CreateGenerator(); + + generator.CreateDisplayAttributeClass("ContentstackModels", _tempDir); + var content = ReadGenerated("DisplayNameAttribute.cs"); + + Assert.Contains("[AttributeUsage(AttributeTargets.Field)]", content); + Assert.Contains("public partial class DisplayNameAttribute: Attribute", content); + Assert.Contains("public DisplayNameAttribute(string displayName)", content); + } + + #endregion + + #region CreateEmbeddedObjectClass + + [Fact] + public void CreateEmbeddedObjectClass_WritesConverterWithJsonConverterAttributeAndClassDeclaration() + { + var generator = CreateGenerator(); + + generator.CreateEmbeddedObjectClass("ContentstackModels", _tempDir); + var content = ReadGenerated("IEmbeddedObjectConverter.cs"); + + Assert.Contains("[CSJsonConverter(\"IEmbeddedObjectConverter\")]", content); + Assert.Contains("public partial class IEmbeddedObjectConverter : JsonConverter>", content); + } + + [Fact] + public void CreateEmbeddedObjectClass_PerContentType_EmitsIfBranchKeyedByContentTypeUid() + { + var generator = CreateGenerator(seedContentTypes: new[] + { + MakeContentType("blog_post", "Blog Post"), + MakeContentType("author", "Author") + }); + + generator.CreateEmbeddedObjectClass("ContentstackModels", _tempDir); + var content = ReadGenerated("IEmbeddedObjectConverter.cs"); + + Assert.Contains("if (ctUid == \"blog_post\")", content); + Assert.Contains("else if (ctUid == \"author\")", content); + Assert.Contains("JsonSerializer.Deserialize(obj.ToJsonString(), deserializeOptions);", content); + Assert.Contains("JsonSerializer.Deserialize(obj.ToJsonString(), deserializeOptions);", content); + } + + [Fact] + public void CreateEmbeddedObjectClass_ContentTypeWithEmptyUid_EmitsBranchForEmptyIdentifier() + { + var generator = CreateGenerator(seedContentTypes: new[] { MakeContentType("", "Untitled") }); + + generator.CreateEmbeddedObjectClass("ContentstackModels", _tempDir); + var content = ReadGenerated("IEmbeddedObjectConverter.cs"); + + Assert.Contains("if (ctUid == \"\")", content); + } + + [Fact] + public void CreateEmbeddedObjectClass_NoContentTypes_OmitsElseKeywordOnFirstBranch() + { + var generator = CreateGenerator(); + + generator.CreateEmbeddedObjectClass("ContentstackModels", _tempDir); + var content = ReadGenerated("IEmbeddedObjectConverter.cs"); + + Assert.Contains("if (ctUid == \"sys_assets\")", content); + Assert.DoesNotContain("else if (ctUid == \"sys_assets\")", content); + } + + [Fact] + public void CreateEmbeddedObjectClass_AlwaysEmitsSysAssetsFallbackBranch() + { + var generator = CreateGenerator(seedContentTypes: new[] { MakeContentType("blog_post", "Blog Post") }); + + generator.CreateEmbeddedObjectClass("ContentstackModels", _tempDir); + var content = ReadGenerated("IEmbeddedObjectConverter.cs"); + + Assert.Contains("else if (ctUid == \"sys_assets\")", content); + Assert.Contains("JsonSerializer.Deserialize(obj.ToJsonString(), deserializeOptions);", content); + } + + #endregion + + #region CreateModularBlockConverter + + [Fact] + public void CreateModularBlockConverter_WritesConverterClassDeclaration() + { + var generator = CreateGenerator(); + var blockTypes = new Dictionary { { "heading", "MBTestHeading" } }; + + generator.CreateModularBlockConverter("ContentstackModels", "MBTest", blockTypes, _tempDir); + var content = ReadGenerated("MBTestConverter.cs"); + + Assert.Contains("[CSJsonConverter(\"MBTestConverter\")]", content); + Assert.Contains("public partial class MBTestConverter : JsonConverter", content); + } + + [Fact] + public void CreateModularBlockConverter_PerBlockType_EmitsFieldExistsCheckAndBlockTypeAssignment() + { + var generator = CreateGenerator(); + var blockTypes = new Dictionary + { + { "heading", "MBTestHeading" }, + { "quote", "MBTestQuote" } + }; + + generator.CreateModularBlockConverter("ContentstackModels", "MBTest", blockTypes, _tempDir); + var content = ReadGenerated("MBTestConverter.cs"); + + Assert.Contains("if (ContentstackHelper.FieldExists(ContentstackHelper.GetDescription(MBTestEnum.Heading), jObject))", content); + Assert.Contains("block.BlockType = MBTestEnum.Heading;", content); + Assert.Contains("if (ContentstackHelper.FieldExists(ContentstackHelper.GetDescription(MBTestEnum.Quote), jObject))", content); + Assert.Contains("block.BlockType = MBTestEnum.Quote;", content); + } + + [Fact] + public void CreateModularBlockConverter_EmptyBlockTypes_EmitsOnlyFallbackReturn() + { + var generator = CreateGenerator(); + var blockTypes = new Dictionary(); + + generator.CreateModularBlockConverter("ContentstackModels", "MBTest", blockTypes, _tempDir); + var content = ReadGenerated("MBTestConverter.cs"); + + Assert.Contains("return new MBTest();", content); + Assert.DoesNotContain("ContentstackHelper.FieldExists", content); + } + + [Fact] + public void CreateModularBlockConverter_Write_WrapsSerializeInNullCheck() + { + var generator = CreateGenerator(); + var blockTypes = new Dictionary { { "heading", "MBTestHeading" } }; + + generator.CreateModularBlockConverter("ContentstackModels", "MBTest", blockTypes, _tempDir); + var content = ReadGenerated("MBTestConverter.cs"); + + Assert.Contains("if (value != null)", content); + Assert.Contains("JsonSerializer.Serialize(writer, value, options);", content); + } + + #endregion + + public void Dispose() + { + try { Directory.Delete(_tempDir.FullName, recursive: true); } catch { /* best-effort cleanup */ } + } + } +} diff --git a/contentstack.model.generator.tests/ModelGeneratorTestHelpers.cs b/contentstack.model.generator.tests/ModelGeneratorTestHelpers.cs new file mode 100644 index 0000000..71027c9 --- /dev/null +++ b/contentstack.model.generator.tests/ModelGeneratorTestHelpers.cs @@ -0,0 +1,158 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; +using contentstack.model.generator; +using contentstack.model.generator.Model; +using Contentstack.Model.Generator.Model; + +namespace contentstack.model.generator.tests +{ + /// + /// Shared fixture builders for ModelGenerator code-generation tests. + /// Not a test class - contains no [Fact]/[Theory] methods. + /// + internal static class ModelGeneratorTestHelpers + { + // Older than the "Apr, 04 2019" threshold GetDatatypeForField checks for "reference" fields. + private static readonly DateTime PreReferenceVersionThreshold = DateTime.Parse("2018-01-01"); + + internal static ModelGenerator CreateGenerator( + bool isNullable = false, + DateTime? stackVersion = null, + IEnumerable? seedContentTypes = null) + { + var generator = new ModelGenerator + { + IsNullable = isNullable, + stack = new StackResponse + { + Settings = new StackSettings { version = stackVersion ?? PreReferenceVersionThreshold } + } + }; + + if (seedContentTypes != null) + { + generator._contentTypes.AddRange(seedContentTypes); + } + + return generator; + } + + internal static MetaData Meta( + bool refMultiple = false, + bool refMultipleContentType = false, + bool isMarkdown = false, + bool isJsonRTE = false) + { + return new MetaData + { + RefMultiple = refMultiple, + RefMultipleContentType = refMultipleContentType, + IsMarkdown = isMarkdown, + IsJsonRTE = isJsonRTE + }; + } + + internal static Field TextField(string uid, string? displayName = null, bool isMultiple = false, bool isMarkdown = false, object? referenceTo = null) + { + return new Field + { + Uid = uid, + DisplayName = displayName ?? uid, + DataType = "text", + IsMultiple = isMultiple, + ReferenceTo = referenceTo, + FieldMetadata = Meta(isMarkdown: isMarkdown) + }; + } + + internal static Field NumberField(string uid, bool isMultiple = false, bool refMultiple = false) + { + return new Field + { + Uid = uid, + DisplayName = uid, + DataType = "number", + IsMultiple = isMultiple, + FieldMetadata = Meta(refMultiple: refMultiple) + }; + } + + internal static Field BooleanField(string uid) + { + return new Field { Uid = uid, DisplayName = uid, DataType = "boolean", FieldMetadata = Meta() }; + } + + internal static Field DateField(string uid) + { + return new Field { Uid = uid, DisplayName = uid, DataType = "isodate", FieldMetadata = Meta() }; + } + + internal static Field FileField(string uid, bool isMultiple = false) + { + return new Field { Uid = uid, DisplayName = uid, DataType = "file", IsMultiple = isMultiple, FieldMetadata = Meta() }; + } + + internal static Field JsonField(string uid, bool isJsonRTE) + { + return new Field { Uid = uid, DisplayName = uid, DataType = "json", FieldMetadata = Meta(isJsonRTE: isJsonRTE) }; + } + + internal static Field LinkTypeField(string uid) + { + return new Field { Uid = uid, DisplayName = uid, DataType = "link", FieldMetadata = Meta() }; + } + + internal static Field UnknownTypeField(string uid) + { + return new Field { Uid = uid, DisplayName = uid, DataType = "some_unmapped_type", FieldMetadata = Meta() }; + } + + internal static Field ReferenceField(string uid, object referenceTo, bool refMultiple = false, bool refMultipleContentType = false, bool isMultiple = false) + { + return new Field + { + Uid = uid, + DisplayName = uid, + DataType = "reference", + IsMultiple = isMultiple, + ReferenceTo = referenceTo, + FieldMetadata = Meta(refMultiple: refMultiple, refMultipleContentType: refMultipleContentType) + }; + } + + internal static Field GlobalFieldField(string uid, object referenceTo, bool refMultiple = false, bool refMultipleContentType = false, bool isMultiple = false) + { + var field = ReferenceField(uid, referenceTo, refMultiple, refMultipleContentType, isMultiple); + field.DataType = "global_field"; + return field; + } + + internal static Field GroupField(string uid, string displayName, List? schema) + { + return new Field { Uid = uid, DisplayName = displayName, DataType = "group", Schema = schema, FieldMetadata = Meta() }; + } + + internal static Field BlocksField(string uid, string displayName, List blocks) + { + return new Field { Uid = uid, DisplayName = displayName, DataType = "blocks", Blocks = blocks, FieldMetadata = Meta() }; + } + + internal static Contenttype MakeContentType(string uid, string title, List? schema = null, string? referenceTo = null) + { + return new Contenttype + { + Uid = uid, + Title = title, + Schema = schema ?? new List(), + ReferenceTo = referenceTo + }; + } + + // Field.ReferenceTo is typed `object` but production code pattern-matches `is JsonElement` - + // a plain string/string[] would silently fall through to "object", so build real JsonElements. + internal static object SingleRef(string uid) => JsonSerializer.SerializeToElement(uid); + + internal static object MultiRef(params string[] uids) => JsonSerializer.SerializeToElement(uids); + } +} diff --git a/contentstack.model.generator.tests/ModelGeneratorTests.cs b/contentstack.model.generator.tests/ModelGeneratorTests.cs index cb60ab6..4c58488 100644 --- a/contentstack.model.generator.tests/ModelGeneratorTests.cs +++ b/contentstack.model.generator.tests/ModelGeneratorTests.cs @@ -78,11 +78,25 @@ public void OAuthScopes_ShouldBeOptional() [Fact] public void OAuthClientSecret_ShouldBeOptional() { - + var modelGenerator = new ModelGenerator(); Assert.Null(modelGenerator.OAuthClientSecret); } + [Fact] + public void Path_DefaultValue_IsNull() + { + var modelGenerator = new ModelGenerator(); + Assert.Null(modelGenerator.Path); + } + + [Fact] + public void Version_DefaultValue_IsFalse() + { + var modelGenerator = new ModelGenerator(); + Assert.False(modelGenerator.Version); + } + #endregion #region OAuth Scopes Parsing Tests diff --git a/contentstack.model.generator.tests/OAuthModelTests.cs b/contentstack.model.generator.tests/OAuthModelTests.cs new file mode 100644 index 0000000..6b854ca --- /dev/null +++ b/contentstack.model.generator.tests/OAuthModelTests.cs @@ -0,0 +1,355 @@ +using System; +using System.Text.Json; +using Contentstack.Model.Generator.Model; +using Xunit; + +namespace contentstack.model.generator.tests +{ + public class OAuthModelTests + { + #region OAuthUser / OAuthAppAuthorizationData / OAuthAppAuthorizationResponse + + [Fact] + public void OAuthUser_Uid_CanBeSetAndRetrieved() + { + var user = new OAuthUser { Uid = "user_123" }; + + Assert.Equal("user_123", user.Uid); + } + + [Fact] + public void OAuthAppAuthorizationData_PropertiesCanBeSetAndRetrieved() + { + var data = new OAuthAppAuthorizationData + { + AuthorizationUid = "auth_uid_123", + User = new OAuthUser { Uid = "user_123" } + }; + + Assert.Equal("auth_uid_123", data.AuthorizationUid); + Assert.Equal("user_123", data.User.Uid); + } + + [Fact] + public void OAuthAppAuthorizationResponse_DeserializesFromJson() + { + var json = "{\"data\":[{\"authorization_uid\":\"auth_1\",\"user\":{\"uid\":\"user_1\"}}]}"; + + var response = JsonSerializer.Deserialize(json); + + Assert.NotNull(response); + Assert.Single(response.Data); + Assert.Equal("auth_1", response.Data[0].AuthorizationUid); + Assert.Equal("user_1", response.Data[0].User.Uid); + } + + #endregion + + #region OAuthOptions + + [Fact] + public void OAuthOptions_Defaults_MatchContentstackAppDefaults() + { + var options = new OAuthOptions(); + + Assert.Equal("6400aa06db64de001a31c8a9", options.AppId); + Assert.Equal("Ie0FEfTzlfAHL4xM", options.ClientId); + Assert.Equal("http://localhost:8184", options.RedirectUri); + Assert.Equal("code", options.ResponseType); + Assert.Null(options.ClientSecret); + Assert.Null(options.Scope); + } + + [Fact] + public void UsePkce_WithNoClientSecret_IsTrue() + { + var options = new OAuthOptions(); + + Assert.True(options.UsePkce); + } + + [Fact] + public void UsePkce_WithClientSecret_IsFalse() + { + var options = new OAuthOptions { ClientSecret = "secret" }; + + Assert.False(options.UsePkce); + } + + [Fact] + public void IsValid_WithDefaultOptions_ReturnsTrue() + { + var options = new OAuthOptions(); + + Assert.True(options.IsValid()); + } + + [Fact] + public void IsValid_WithMissingAppId_ReturnsFalseWithMessage() + { + var options = new OAuthOptions { AppId = "" }; + + var isValid = options.IsValid(out var errorMessage); + + Assert.False(isValid); + Assert.Equal("AppId is required for OAuth configuration.", errorMessage); + } + + [Fact] + public void IsValid_WithMissingClientId_ReturnsFalseWithMessage() + { + var options = new OAuthOptions { ClientId = "" }; + + var isValid = options.IsValid(out var errorMessage); + + Assert.False(isValid); + Assert.Equal("ClientId is required for OAuth configuration.", errorMessage); + } + + [Fact] + public void IsValid_WithMissingRedirectUri_ReturnsFalseWithMessage() + { + var options = new OAuthOptions { RedirectUri = "" }; + + var isValid = options.IsValid(out var errorMessage); + + Assert.False(isValid); + Assert.Equal("RedirectUri is required for OAuth configuration.", errorMessage); + } + + [Fact] + public void IsValid_WithRelativeRedirectUri_ReturnsFalseWithMessage() + { + var options = new OAuthOptions { RedirectUri = "not-a-valid-uri" }; + + var isValid = options.IsValid(out var errorMessage); + + Assert.False(isValid); + Assert.Equal("RedirectUri must be a valid absolute URI.", errorMessage); + } + + [Fact] + public void IsValid_WithNonHttpRedirectUriScheme_ReturnsFalseWithMessage() + { + var options = new OAuthOptions { RedirectUri = "ftp://localhost:8184" }; + + var isValid = options.IsValid(out var errorMessage); + + Assert.False(isValid); + Assert.Equal("RedirectUri must use http or https scheme.", errorMessage); + } + + [Fact] + public void IsValid_WithMissingResponseType_ReturnsFalseWithMessage() + { + var options = new OAuthOptions { ResponseType = "" }; + + var isValid = options.IsValid(out var errorMessage); + + Assert.False(isValid); + Assert.Equal("ResponseType is required for OAuth configuration.", errorMessage); + } + + [Fact] + public void IsValid_WithNonCodeResponseType_ReturnsFalseWithMessage() + { + var options = new OAuthOptions { ResponseType = "token" }; + + var isValid = options.IsValid(out var errorMessage); + + Assert.False(isValid); + Assert.Equal("ResponseType must be 'code' for authorization code flow.", errorMessage); + } + + [Fact] + public void IsValid_NonPkceWithWhitespaceOnlyClientSecret_ReturnsFalseWithMessage() + { + // ClientSecret = " " is not IsNullOrEmpty (so UsePkce is false / non-PKCE flow), + // but IsNullOrWhiteSpace is true, so this reaches the traditional-flow guard. + var options = new OAuthOptions { ClientSecret = " " }; + + var isValid = options.IsValid(out var errorMessage); + + Assert.False(isValid); + Assert.Equal( + "ClientSecret is required for traditional OAuth flow. Use PKCE flow (leave ClientSecret empty) for public clients.", + errorMessage); + } + + [Fact] + public void IsValid_NonPkceWithClientSecret_ReturnsTrue() + { + var options = new OAuthOptions { ClientSecret = "a-real-secret" }; + + Assert.True(options.IsValid()); + } + + [Fact] + public void Validate_WithValidOptions_DoesNotThrow() + { + var options = new OAuthOptions(); + + var exception = Record.Exception(() => options.Validate()); + + Assert.Null(exception); + } + + [Fact] + public void Validate_WithInvalidOptions_ThrowsOAuthConfigurationException() + { + var options = new OAuthOptions { AppId = "" }; + + var exception = Assert.Throws(() => options.Validate()); + Assert.Equal("AppId is required for OAuth configuration.", exception.Message); + } + + [Fact] + public void ToString_IncludesKeyConfigurationValues() + { + var options = new OAuthOptions { Scope = new[] { "read", "write" } }; + + var result = options.ToString(); + + Assert.Contains("AppId=6400aa06db64de001a31c8a9", result); + Assert.Contains("UsePkce=True", result); + Assert.Contains("HasScope=True", result); + } + + #endregion + + #region OAuthResponse + + [Fact] + public void OAuthResponse_DeserializesFromJson() + { + var json = "{\"access_token\":\"tok\",\"refresh_token\":\"ref\",\"expires_in\":3600," + + "\"organization_uid\":\"org_1\",\"user_uid\":\"user_1\"}"; + + var response = JsonSerializer.Deserialize(json); + + Assert.Equal("tok", response.AccessToken); + Assert.Equal("ref", response.RefreshToken); + Assert.Equal(3600, response.ExpiresIn); + Assert.Equal("org_1", response.OrganizationUid); + Assert.Equal("user_1", response.UserUid); + } + + #endregion + + #region OAuthTokens + + [Fact] + public void IsExpired_WithDefaultExpiresAt_IsTrue() + { + var tokens = new OAuthTokens(); + + Assert.True(tokens.IsExpired); + } + + [Fact] + public void IsExpired_WithPastExpiresAt_IsTrue() + { + var tokens = new OAuthTokens { ExpiresAt = DateTime.UtcNow.AddMinutes(-10) }; + + Assert.True(tokens.IsExpired); + } + + [Fact] + public void IsExpired_WithFutureExpiresAt_IsFalse() + { + var tokens = new OAuthTokens { ExpiresAt = DateTime.UtcNow.AddHours(1) }; + + Assert.False(tokens.IsExpired); + } + + [Fact] + public void NeedsRefresh_WithDefaultExpiresAt_IsTrue() + { + var tokens = new OAuthTokens(); + + Assert.True(tokens.NeedsRefresh); + } + + [Fact] + public void NeedsRefresh_WithinFiveMinutesOfExpiry_IsTrue() + { + var tokens = new OAuthTokens { ExpiresAt = DateTime.UtcNow.AddMinutes(2) }; + + Assert.True(tokens.NeedsRefresh); + } + + [Fact] + public void NeedsRefresh_WellBeforeExpiry_IsFalse() + { + var tokens = new OAuthTokens { ExpiresAt = DateTime.UtcNow.AddHours(1) }; + + Assert.False(tokens.NeedsRefresh); + } + + [Fact] + public void NeedsRefresh_WhenSubtractingFiveMinutesUnderflowsDateTimeRange_IsTrue() + { + var tokens = new OAuthTokens { ExpiresAt = DateTime.MinValue.AddSeconds(1) }; + + Assert.True(tokens.NeedsRefresh); + } + + [Fact] + public void IsValid_WithAccessTokenAndNotExpired_IsTrue() + { + var tokens = new OAuthTokens { AccessToken = "tok", ExpiresAt = DateTime.UtcNow.AddHours(1) }; + + Assert.True(tokens.IsValid); + } + + [Fact] + public void IsValid_WithNoAccessToken_IsFalse() + { + var tokens = new OAuthTokens { AccessToken = null, ExpiresAt = DateTime.UtcNow.AddHours(1) }; + + Assert.False(tokens.IsValid); + } + + [Fact] + public void IsValid_WhenExpired_IsFalse() + { + var tokens = new OAuthTokens { AccessToken = "tok", ExpiresAt = DateTime.UtcNow.AddMinutes(-1) }; + + Assert.False(tokens.IsValid); + } + + [Fact] + public void OtherProperties_CanBeSetAndRetrieved() + { + var tokens = new OAuthTokens + { + RefreshToken = "refresh", + OrganizationUid = "org_1", + UserUid = "user_1", + ClientId = "client_1", + AppId = "app_1" + }; + + Assert.Equal("refresh", tokens.RefreshToken); + Assert.Equal("org_1", tokens.OrganizationUid); + Assert.Equal("user_1", tokens.UserUid); + Assert.Equal("client_1", tokens.ClientId); + Assert.Equal("app_1", tokens.AppId); + } + + #endregion + + #region OAuthConfigurationException + + [Fact] + public void OAuthConfigurationException_SetsMessage() + { + var exception = new OAuthConfigurationException("bad config"); + + Assert.Equal("bad config", exception.Message); + Assert.IsAssignableFrom(exception); + } + + #endregion + } +} diff --git a/contentstack.model.generator.tests/ProgramTests.cs b/contentstack.model.generator.tests/ProgramTests.cs new file mode 100644 index 0000000..6b78555 --- /dev/null +++ b/contentstack.model.generator.tests/ProgramTests.cs @@ -0,0 +1,38 @@ +using System.Threading.Tasks; +using contentstack.model.generator; +using Xunit; + +namespace contentstack.model.generator.tests +{ + /// + /// Program.Main() is the real CLI entry point. These tests only exercise + /// argument-validation paths that return before any network call is made + /// (missing --api-key / missing --authtoken), so they run safely offline. + /// + public class ProgramTests + { + [Fact] + public async Task Main_WithNoArguments_ReturnsNonZeroExitCode() + { + var result = await Program.Main(new string[0]); + + Assert.NotEqual(Program.OK, result); + } + + [Fact] + public async Task Main_WithApiKeyButNoAuthTokenOrOAuth_ReturnsErrorCode() + { + var result = await Program.Main(new[] { "--api-key", "test_key" }); + + Assert.Equal(Program.ERROR, result); + } + + [Fact] + public void ExitCodeConstants_HaveExpectedValues() + { + Assert.Equal(0, Program.OK); + Assert.Equal(1, Program.ERROR); + Assert.Equal(2, Program.EXCEPTION); + } + } +} diff --git a/contentstack.model.generator.tests/StackResponseModelTests.cs b/contentstack.model.generator.tests/StackResponseModelTests.cs new file mode 100644 index 0000000..288042e --- /dev/null +++ b/contentstack.model.generator.tests/StackResponseModelTests.cs @@ -0,0 +1,38 @@ +using System; +using Contentstack.Model.Generator.Model; +using Xunit; + +namespace contentstack.model.generator.tests +{ + public class StackResponseModelTests + { + [Fact] + public void Properties_CanBeSetAndRetrieved() + { + var stack = new StackResponse + { + APIKey = "test_api_key", + Name = "My Stack", + MasterLocale = "en-us", + Uid = "stack_uid_123", + Description = "A test stack" + }; + + Assert.Equal("test_api_key", stack.APIKey); + Assert.Equal("My Stack", stack.Name); + Assert.Equal("en-us", stack.MasterLocale); + Assert.Equal("stack_uid_123", stack.Uid); + Assert.Equal("A test stack", stack.Description); + } + + [Fact] + public void Settings_Version_CanBeSetAndRetrieved() + { + var version = new DateTime(2019, 4, 4); + + var settings = new StackSettings { version = version }; + + Assert.Equal(version, settings.version); + } + } +} diff --git a/contentstack.model.generator/CMA/ContentStackError.cs b/contentstack.model.generator/CMA/ContentStackError.cs index d6d8ab6..bdcc9cd 100644 --- a/contentstack.model.generator/CMA/ContentStackError.cs +++ b/contentstack.model.generator/CMA/ContentStackError.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.Net; -using System.Runtime.Serialization; using System.Text.Json.Serialization; namespace contentstack.CMA @@ -87,17 +86,9 @@ public ContentstackException(Exception exception) this.ErrorMessage = exception.Message; } - protected ContentstackException(SerializationInfo info, StreamingContext context) : base(info, context) - { - } - public ContentstackException(string message, Exception innerException) : base(message, innerException) { } - public override void GetObjectData(SerializationInfo info, StreamingContext context) - { - base.GetObjectData(info, context); - } #endregion diff --git a/contentstack.model.generator/CMA/HTTPRequestHandler.cs b/contentstack.model.generator/CMA/HTTPRequestHandler.cs index a805352..a70cfd5 100644 --- a/contentstack.model.generator/CMA/HTTPRequestHandler.cs +++ b/contentstack.model.generator/CMA/HTTPRequestHandler.cs @@ -97,8 +97,8 @@ public async Task ProcessRequest(string Url, Dictionary var errorContent = await response.Content.ReadAsStringAsync(); throw new HttpRequestException($"HTTP request failed with status code: {response.StatusCode} - {errorContent}"); } - } - catch (Exception ex) + } + catch (Exception) { throw; } diff --git a/contentstack.model.generator/ModelGenerator.cs b/contentstack.model.generator/ModelGenerator.cs index c98d87a..34a7035 100644 --- a/contentstack.model.generator/ModelGenerator.cs +++ b/contentstack.model.generator/ModelGenerator.cs @@ -65,7 +65,7 @@ public class ModelGenerator public string Path { get; } [Option(CommandOptionType.NoValue, ShortName = "N", LongName = "is-nullable", Description = "The features that protect against throwing a System.NullReferenceException can be disruptive when turned on.")] - public bool IsNullable { get; } + public bool IsNullable { get; internal set; } [VersionOption("0.5.1")] public bool Version { get; } @@ -82,8 +82,8 @@ public class ModelGenerator using System.Text.Json.Nodes; using System.Text.Json.Serialization;"; - private readonly List _contentTypes = new List(); - StackResponse stack; + internal readonly List _contentTypes = new List(); + internal StackResponse stack; public async Task OnExecute(CommandLineApplication app, IConsole console) { @@ -205,7 +205,7 @@ public async Task OnExecute(CommandLineApplication app, IConsole console) } Console.WriteLine(Messages.TotalGlobalFieldsFetched(totalCount)); } - catch (Exception e) + catch (Exception) { Console.ForegroundColor = ConsoleColor.Red; Console.Error.WriteLine(Messages.ApiCommunicationError); @@ -311,7 +311,7 @@ private string GetDatatype(Field field, string contentTypeName) return "object"; } - private string GetDatatypeForField(Field field, string contentTypeName) + internal string GetDatatypeForField(Field field, string contentTypeName) { string dataType = GetDatatype(field, contentTypeName); if (field.DataType == "reference" && DateTime.Compare(stack.Settings.version, DateTime.Parse("Apr, 04 2019")) >= 0) @@ -391,7 +391,7 @@ private string GetDataTypeForGroup(Field field, string contentTypeName) return $"{GroupPrefix}{contentTypeName}{FormatClassName(field.DisplayName)}".Replace(" ", ""); } - private void CreateEmbeddedObjectClass(string NameSpace, DirectoryInfo directoryInfo) + internal void CreateEmbeddedObjectClass(string NameSpace, DirectoryInfo directoryInfo) { string ConverterName = "IEmbeddedObjectConverter"; var file = shouldCreateFile(ConverterName, directoryInfo); @@ -464,7 +464,7 @@ private void CreateEmbeddedObjectClass(string NameSpace, DirectoryInfo directory } } - private void CreateDisplayAttributeClass(string NameSpace, DirectoryInfo directoryInfo) + internal void CreateDisplayAttributeClass(string NameSpace, DirectoryInfo directoryInfo) { // Create File for DisplayAttribute string contentstackLinkClass = "DisplayNameAttribute"; @@ -506,7 +506,7 @@ private void CreateDisplayAttributeClass(string NameSpace, DirectoryInfo directo } } } - private void CreateLinkClass(string NameSpace, DirectoryInfo directoryInfo) + internal void CreateLinkClass(string NameSpace, DirectoryInfo directoryInfo) { // Create File for LinkClass string contentstackLinkClass = "ContentstackLink"; @@ -642,7 +642,7 @@ private void CreateModularFile(string contentTypeName, string nameSpace, Content } } - private Boolean findRTEReference(List Schema) + internal Boolean findRTEReference(List Schema) { if (Schema == null) { @@ -670,7 +670,7 @@ private Boolean findRTEReference(List Schema) return fields.Count > 0; } - private void CreateFile(string contentTypeName, string nameSpace, Contenttype contentType, DirectoryInfo directoryInfo) + internal void CreateFile(string contentTypeName, string nameSpace, Contenttype contentType, DirectoryInfo directoryInfo) { Console.WriteLine(Messages.ExtractingModularBlocksInContentType(contentTypeName)); @@ -726,7 +726,7 @@ private void CreateFile(string contentTypeName, string nameSpace, Contenttype co if (fields) { sb.AppendLine($" [JsonPropertyName(\"_embedded_items\")]"); - sb.AppendLine(" public Dictionary>{nullableString()} embeddedItems { get; set; }"); + sb.AppendLine($" public Dictionary>{nullableString()} embeddedItems {{ get; set; }}"); } // End of namespace and class @@ -845,7 +845,7 @@ private Tuple> ExtractFieldType(strin return new Tuple>(usingDirective, NameSpace, directory, fields); } - private void AddParams(string contentType, List schema, in StringBuilder sb) + internal void AddParams(string contentType, List schema, in StringBuilder sb) { foreach (var field in schema) { @@ -1025,7 +1025,7 @@ private void CreateModularBlockEnum(string enumName, string nameSpace, Dictionar } } - private void CreateHelperClass(string nameSpace, DirectoryInfo directoryInfo) + internal void CreateHelperClass(string nameSpace, DirectoryInfo directoryInfo) { string className = "ContentstackHelper"; FileInfo file = shouldCreateFile(className, directoryInfo); @@ -1071,7 +1071,7 @@ private void CreateHelperClass(string nameSpace, DirectoryInfo directoryInfo) } } - private void CreateStringHelperClass(string nameSpace, DirectoryInfo directoryInfo) + internal void CreateStringHelperClass(string nameSpace, DirectoryInfo directoryInfo) { string className = "ContentstackStringExtension"; FileInfo file = shouldCreateFile(className, directoryInfo); @@ -1121,7 +1121,7 @@ private void CreateStringHelperClass(string nameSpace, DirectoryInfo directoryIn } } - private void CreateModularBlockConverter(string nameSpace, string className, Dictionary blockTypes, DirectoryInfo directoryInfo) + internal void CreateModularBlockConverter(string nameSpace, string className, Dictionary blockTypes, DirectoryInfo directoryInfo) { FileInfo file = shouldCreateFile($"{className}Converter", directoryInfo); diff --git a/contentstack.model.generator/Program.cs b/contentstack.model.generator/Program.cs index 0070bb5..f54132c 100644 --- a/contentstack.model.generator/Program.cs +++ b/contentstack.model.generator/Program.cs @@ -14,7 +14,7 @@ protected Program() { } - static async Task Main(string[] args) + internal static async Task Main(string[] args) { try { diff --git a/skills/testing/SKILL.md b/skills/testing/SKILL.md index b9f75f1..d5f16de 100644 --- a/skills/testing/SKILL.md +++ b/skills/testing/SKILL.md @@ -35,6 +35,20 @@ With coverage (example using coverlet collector — adjust flags to match your l dotnet test contentstack.model.generator/contentstack.model.generator.sln --collect:"XPlat Code Coverage" ``` +### HTML test + coverage report + +`Scripts/generate_test_report.py` (stdlib-only, no extra Python packages) turns a `.trx` run plus its Cobertura coverage file into a single self-contained HTML report (pass/fail summary, per-class breakdown with failure details, and a code coverage table). Mirrors the pattern used in the `contentstack-dotnet` sibling repo's `Scripts/generate_enhanced_html_report.py`. + +```bash +dotnet test contentstack.model.generator.tests/contentstack.model.generator.tests.csproj \ + --logger "trx;LogFileName=test-results.trx" \ + --collect:"XPlat Code Coverage" \ + --results-directory contentstack.model.generator.tests/TestResults + +python3 Scripts/generate_test_report.py +``` +Run with no arguments, it auto-discovers the newest `.trx` and `coverage.cobertura.xml` under `contentstack.model.generator.tests/TestResults/`. Pass explicit paths (`python3 Scripts/generate_test_report.py `) to target a specific run. Output is `test-report_.html` at the repo root — gitignored, local artifact only. + Run a **single test** or class (examples): ```bash @@ -117,7 +131,13 @@ Leave a blank line between phases if it helps readability (many tests in this re | [OAuthErrorHandlingTests.cs](../../contentstack.model.generator.tests/OAuthErrorHandlingTests.cs) | OAuth error paths. | | [OAuthIntegrationTests.cs](../../contentstack.model.generator.tests/OAuthIntegrationTests.cs) | Broader OAuth integration scenarios. | | [ModelGeneratorOAuthTests.cs](../../contentstack.model.generator.tests/ModelGeneratorOAuthTests.cs) | CLI / `ModelGenerator` + OAuth interactions. | -| [ModelGeneratorTests.cs](../../contentstack.model.generator.tests/ModelGeneratorTests.cs) | Codegen / `ModelGenerator` behavior. | +| [ModelGeneratorTests.cs](../../contentstack.model.generator.tests/ModelGeneratorTests.cs) | CLI option metadata / `ModelGenerator` property behavior. | +| [ModelGeneratorTestHelpers.cs](../../contentstack.model.generator.tests/ModelGeneratorTestHelpers.cs) | Not a test class - shared `Field`/`MetaData`/`Contenttype` fixture builders and `CreateGenerator(...)` factory for the codegen test files below. | +| [ModelGeneratorFieldTypeResolutionTests.cs](../../contentstack.model.generator.tests/ModelGeneratorFieldTypeResolutionTests.cs) | `GetDatatypeForField` - field-type → C# type mapping, reference/global_field resolution, multiplicity wrapping. | +| [ModelGeneratorAddParamsTests.cs](../../contentstack.model.generator.tests/ModelGeneratorAddParamsTests.cs) | `AddParams` - attribute + property emission per field type, markdown special case. | +| [ModelGeneratorNullableVariantTests.cs](../../contentstack.model.generator.tests/ModelGeneratorNullableVariantTests.cs) | `IsNullable` (`--is-nullable`/`-N`) toggle across `AddParams`/`CreateLinkClass`/`CreateFile`/`CreateModularBlockConverter`, plus the `CreateEmbeddedObjectClass` no-suffix asymmetry. | +| [ModelGeneratorCreateFileTests.cs](../../contentstack.model.generator.tests/ModelGeneratorCreateFileTests.cs) | `CreateFile` end-to-end - class shape, nested blocks/group generation, using directives, embedded-items interpolation regression. | +| [ModelGeneratorSupportingClassGenerationTests.cs](../../contentstack.model.generator.tests/ModelGeneratorSupportingClassGenerationTests.cs) | `CreateLinkClass`, `CreateEmbeddedObjectClass`, `CreateModularBlockConverter` called directly. | ### Credentials and secrets From 13615b20a939f57e54de8db86957a6264d6c95a8 Mon Sep 17 00:00:00 2001 From: OMpawar-21 Date: Wed, 8 Jul 2026 17:46:41 +0530 Subject: [PATCH 2/3] Update CHANGELOG.md --- CHANGELOG.md | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d23cbd1..ad31b6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,27 @@ -### Version: 1.0.0-beta.1 +### Version: 1.0.0 #### Date: -- Upgraded target framework from net7.0 to net10.0 -- Updated CI/CD pipeline to use .NET 10 SDK (actions/setup-dotnet@v4) -- Updated Microsoft.AspNetCore.Mvc.Testing from 9.0.9 to 10.0.0 -- Fixed GetHeader method visibility (private → internal) to allow test access +##### Breaking Changes: +- Removed the `Newtonsoft.Json` dependency entirely. Both the tool's internals and the models/converters it generates now use `System.Text.Json`. +- Generated model files now use `[JsonPropertyName]` instead of `[JsonProperty]`, and reference `System.Text.Json` / `System.Text.Json.Nodes` / `System.Text.Json.Serialization` instead of `Newtonsoft.Json`. +- Generated embedded-object and modular-block converters now inherit `System.Text.Json.Serialization.JsonConverter` (`Read`/`Write`) instead of Newtonsoft's `JsonConverter` (`ReadJson`/`WriteJson`). Regenerating models against an existing project will change these attributes and converters - update consuming code accordingly. +- Requires **.NET 10.0** or later (previously .NET 7.0). +- `ContentstackException` no longer supports legacy `BinaryFormatter`-based serialization (removed the obsolete `SerializationInfo`/`StreamingContext` constructor and `GetObjectData` override). + +##### Enhancements: +- Upgraded target framework from net7.0 to net10.0. +- Updated CI/CD pipeline to use .NET 10 SDK (actions/setup-dotnet@v4). +- Updated Microsoft.AspNetCore.Mvc.Testing from 9.0.9 to 10.0.0. +- Migrated internal model DTOs (Field, MetaData, Contenttype, StackResponse, ContentStackError, OAuth token response) from `[JsonProperty]` to `[JsonPropertyName]`. +- Migrated OAuthService token exchange/refresh deserialization to `System.Text.Json`. +- Migrated the HTTP layer (IResponse, ContentstackResponse, HTTPRequestHandler, ContentstackHttpRequest) from Newtonsoft `JObject` to `System.Text.Json.Nodes.JsonObject`. +- Migrated ContentstackClient's serializer settings, response deserialization, and error parsing to `System.Text.Json` (`JsonSerializerOptions`, `JsonNode`). +- Migrated the code-generation templates (using statements, field attributes, link class, embedded-object converter, modular-block converter, and helper class) to emit `System.Text.Json`-based generated code. +- Fixed reference-field type resolution (`GetDatatypeForContentType`) to use `System.Text.Json.JsonElement` instead of the removed Newtonsoft `JArray`/`JObject` types. + +##### Bug fix: +- Fixed GetHeader method visibility (private → internal) to allow test access. +- Fixed a template bug where the generated `embeddedItems` property's nullable annotation was emitted as literal text (`{nullableString()}`) instead of being applied, for content types with RTE-embedded references. ### Version: 0.5.1 #### Date: Jan-12-2026 From 421e64ccb9f3b0f5d2acba08f00138e3a0f9ab39 Mon Sep 17 00:00:00 2001 From: OMpawar-21 Date: Wed, 8 Jul 2026 18:03:28 +0530 Subject: [PATCH 3/3] Update README.md --- README.md | 73 ++++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 54 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 19aaaf5..70b6a2a 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,9 @@ # Contentstack model generator This utility is use to generate models based on ContentTypes in Stack. +## Requirements +**As of v1.0.0**, this tool targets **.NET 10.0** and requires the **.NET 10.0 SDK or later**. Versions prior to v1.0.0 targeted .NET 7.0. + ## Installation To install Contenstack model generator run following command: ``` @@ -27,11 +30,11 @@ The Contentstack Model Generator supports two authentication methods: | `-A` | `--authtoken` | The Authtoken for the Content Management API (required for traditional auth) | | `-b` | `--branch` | The branch header in the API request to fetch or manage modules located within specific branches. | | `-e` | `--endpoint` | The Contentstack Host for the Content Management API | -| `-n` | `--namespace` | The namespace the classes should be created in | +| `-n` | `--namespace` | The namespace the classes should be created in (Default Value: ContentstackModels) | | `-N` | `--is-nullable` | The features that protect against throwing a System.NullReferenceException can be disruptive when turned on. | | `-f` | `--force` | Automatically overwrite files that already exist | -| `-m` | `--modular-block-prefix` | The Modular block Class Prefix. | -| `-g` | `--group-prefix` | The Group Class Prefix. | +| `-m` | `--modular-block-prefix` | The Modular block Class Prefix. (Default Value: MB) | +| `-g` | `--group-prefix` | The Group Class Prefix. (Default Value: Group) | | `-p` | `--path` | Path to the file or directory to create files in. | ### OAuth 2.0 Options @@ -106,40 +109,72 @@ Here's what you'll see when running an OAuth command: $ contentstack.model.generator --oauth -a --client-id myclient123 --redirect-uri http://localhost:8184 -Contentstack Model Generator v0.5.0 -===================================== - OAuth Authentication Required ============================= Please open the following URL in your browser to authorize the application: -https://app.contentstack.com/#!/apps/6400aa06db64de001a31c8a9/authorize?response_type=code&client_id=myclient123&redirect_uri=http%3A%2F%2Flocalhost%3A8184&code_challenge=... +https://app.contentstack.com/#!/apps/6400aa06db64de001a31c8a9/authorize?response_type=code&client_id=myclient123&redirect_uri=http%3A%2F%2Flocalhost%3A8184&code_challenge=...&code_challenge_method=S256 After authorization, you will be redirected to a local URL. Please copy the 'code' parameter from the redirect URL and paste it here: -Authorization code: [User pastes the code here] +Authorization code: [paste the code here] Exchanging authorization code for access token... OAuth authentication successful! -Access token expires at: 2024-01-15 14:30:00 UTC +Access token expires at: 2026-01-15 14:30:00 UTC +Fetching stack details for the provided API key. +No path specified. Generating files in the current working directory: /Users/you/project. +Fetching content types from My Contentstack Stack stack. +Found 5 content types. +Total content types fetched: 5. +Fetching global fields from stack: My Contentstack Stack. +Found 2 global fields. +Total global fields fetched: 2. +Generating files from content types. +Files created successfully. +Opening output directory: /Users/you/project. -Fetching stack information... -Stack: My Contentstack Stack -API Key: api_key +Logging out from OAuth... +OAuth logout successful! +``` -Fetching content types... -Found 5 content types: -Generating files from content type +## Generated Code -Files successfully created! -Opening /Models +**As of v1.0.0**, generated model files use **System.Text.Json** instead of Newtonsoft.Json: -Logging out from OAuth... -OAuth logout successful! +- Properties are decorated with `[JsonPropertyName("...")]` instead of `[JsonProperty(propertyName: "...")]`. +- Generated files reference `System.Text.Json`, `System.Text.Json.Nodes`, and `System.Text.Json.Serialization` instead of `Newtonsoft.Json`. +- Generated converters (for embedded objects and modular blocks) inherit `System.Text.Json.Serialization.JsonConverter` and implement `Read`/`Write` instead of Newtonsoft's `JsonConverter` with `ReadJson`/`WriteJson`. + +A generated model file looks like this: + +```csharp +using System; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using Contentstack.Core.Models; +using Contentstack.Utils.Interfaces; + +namespace ContentstackModels.Models +{ + public partial class BlogPost : IEmbeddedObject + { + public const string ContentType = "blog_post"; + [JsonPropertyName("uid")] + public string Uid { get; set; } + [JsonPropertyName("_content_type_uid")] + public string ContentTypeUid { get; set; } + [JsonPropertyName("title")] + public string Title { get; set; } + } +} ``` +**Breaking change:** if you regenerate models for an existing project using v1.0.0 or later, the attributes and converter base classes in the output will change from the Newtonsoft-based shape to the System.Text.Json-based shape shown above. Update your consuming code and Contentstack .NET SDK versions accordingly before regenerating. + ### MIT License Copyright (c) 2012-2026 Contentstack