Skip to content

Commit aef6c9d

Browse files
authored
fix(PubSub): fix test failures (#3079)
Fixes: #3076
1 parent 2b72975 commit aef6c9d

10 files changed

+78
-102
lines changed

pubsub/api/Pubsub.Samples.Tests/CreateTopicWithAwsMskIngestionTest.cs

Lines changed: 18 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -12,36 +12,28 @@
1212
// See the License for the specific language governing permissions and
1313
// limitations under the License.
1414

15-
using Google.Cloud.PubSub.V1;
15+
using Grpc.Core;
1616
using Xunit;
1717

1818
[Collection(nameof(PubsubFixture))]
1919
public class CreateTopicWithAwsMskIngestionTest
2020
{
21-
private readonly PubsubFixture _pubsubFixture;
22-
private readonly CreateTopicWithAwsMskIngestionSample _createTopicWithAwsMskIngestionSample;
21+
private readonly PubsubFixture _pubsubFixture;
22+
private readonly CreateTopicWithAwsMskIngestionSample _createTopicWithAwsMskIngestionSample;
2323

24-
public CreateTopicWithAwsMskIngestionTest(PubsubFixture pubsubFixture)
25-
{
26-
_pubsubFixture = pubsubFixture;
27-
_createTopicWithAwsMskIngestionSample = new CreateTopicWithAwsMskIngestionSample();
28-
}
24+
public CreateTopicWithAwsMskIngestionTest(PubsubFixture pubsubFixture)
25+
{
26+
_pubsubFixture = pubsubFixture;
27+
_createTopicWithAwsMskIngestionSample = new CreateTopicWithAwsMskIngestionSample();
28+
}
2929

30-
[Fact]
31-
public void CreateTopicWithAwsMskIngestion()
32-
{
33-
string topicId = _pubsubFixture.RandomTopicId();
34-
var (clusterArn, mskTopic, awsRoleArn, gcpServiceAccount) = _pubsubFixture.RandomAwsMskIngestionParams();
35-
Topic createdTopic = _createTopicWithAwsMskIngestionSample.CreateTopicWithAwsMskIngestion(_pubsubFixture.ProjectId, topicId, clusterArn, mskTopic, awsRoleArn, gcpServiceAccount);
36-
37-
// Confirm that the created topic and topic retrieved by ID are equal
38-
Topic retrievedTopic = _pubsubFixture.GetTopic(topicId);
39-
Assert.Equal(createdTopic, retrievedTopic);
40-
41-
// Confirm that all Amazon MSK Ingestion params are equal to expected values
42-
Assert.Equal(clusterArn, createdTopic.IngestionDataSourceSettings.AwsMsk.ClusterArn);
43-
Assert.Equal(mskTopic, createdTopic.IngestionDataSourceSettings.AwsMsk.Topic);
44-
Assert.Equal(awsRoleArn, createdTopic.IngestionDataSourceSettings.AwsMsk.AwsRoleArn);
45-
Assert.Equal(gcpServiceAccount, createdTopic.IngestionDataSourceSettings.AwsMsk.GcpServiceAccount);
46-
}
47-
}
30+
[Fact]
31+
public void CreateTopicWithAwsMskIngestion()
32+
{
33+
string topicId = _pubsubFixture.RandomTopicId();
34+
var (clusterArn, mskTopic, awsRoleArn, gcpServiceAccount) = _pubsubFixture.AwsMskIngestionParams();
35+
var exception = Assert.Throws<RpcException>(() => _createTopicWithAwsMskIngestionSample.CreateTopicWithAwsMskIngestion(_pubsubFixture.ProjectId, topicId, clusterArn, mskTopic, awsRoleArn, gcpServiceAccount));
36+
Assert.Equal(StatusCode.PermissionDenied, exception.Status.StatusCode);
37+
Assert.Equal(_pubsubFixture.PermissionDeniedMessage, exception.Status.Detail);
38+
}
39+
}

pubsub/api/Pubsub.Samples.Tests/CreateTopicWithAzureEventHubsIngestionTest.cs

Lines changed: 5 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
// See the License for the specific language governing permissions and
1313
// limitations under the License.
1414

15-
using Google.Cloud.PubSub.V1;
15+
using Grpc.Core;
1616
using Xunit;
1717

1818
[Collection(nameof(PubsubFixture))]
@@ -31,20 +31,9 @@ public CreateTopicWithAzureEventHubsIngestionTest(PubsubFixture pubsubFixture)
3131
public void CreateTopicWithAzureEventHubsIngestion()
3232
{
3333
string topicId = _pubsubFixture.RandomTopicId();
34-
var (resourceGroup, nameSpace, eventHub, clientId, tenantId, subscriptionId, gcpServiceAccount) = _pubsubFixture.RandomAzureEventHubsIngestionParams();
35-
Topic createdTopic = _createTopicWithAzureEventHubsIngestionSample.CreateTopicWithAzureEventHubsIngestion(_pubsubFixture.ProjectId, topicId, resourceGroup, nameSpace, eventHub, clientId, tenantId, subscriptionId, gcpServiceAccount);
36-
37-
// Confirm that the created topic and topic retrieved by ID are equal
38-
Topic retrievedTopic = _pubsubFixture.GetTopic(topicId);
39-
Assert.Equal(createdTopic, retrievedTopic);
40-
41-
// Confirm that all Ingestion params are equal to expected values
42-
Assert.Equal(resourceGroup, createdTopic.IngestionDataSourceSettings.AzureEventHubs.ResourceGroup);
43-
Assert.Equal(nameSpace, createdTopic.IngestionDataSourceSettings.AzureEventHubs.Namespace);
44-
Assert.Equal(eventHub, createdTopic.IngestionDataSourceSettings.AzureEventHubs.EventHub);
45-
Assert.Equal(clientId, createdTopic.IngestionDataSourceSettings.AzureEventHubs.ClientId);
46-
Assert.Equal(tenantId, createdTopic.IngestionDataSourceSettings.AzureEventHubs.TenantId);
47-
Assert.Equal(subscriptionId, createdTopic.IngestionDataSourceSettings.AzureEventHubs.SubscriptionId);
48-
Assert.Equal(gcpServiceAccount, createdTopic.IngestionDataSourceSettings.AzureEventHubs.GcpServiceAccount);
34+
var (resourceGroup, nameSpace, eventHub, clientId, tenantId, subscriptionId, gcpServiceAccount) = _pubsubFixture.AzureEventHubsIngestionParams();
35+
var exception = Assert.Throws<RpcException>(() => _createTopicWithAzureEventHubsIngestionSample.CreateTopicWithAzureEventHubsIngestion(_pubsubFixture.ProjectId, topicId, resourceGroup, nameSpace, eventHub, clientId, tenantId, subscriptionId, gcpServiceAccount));
36+
Assert.Equal(StatusCode.NotFound, exception.Status.StatusCode);
37+
Assert.Equal($"Cloud Pub/Sub encountered a not-found error while trying to connect to the ingestion data source: Failed to resolve bootstrap server {nameSpace}.servicebus.windows.net to an IP.", exception.Status.Detail);
4938
}
5039
}

pubsub/api/Pubsub.Samples.Tests/CreateTopicWithCloudStorageIngestionTest.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ public CreateTopicWithCloudStorageIngestionTest(PubsubFixture pubsubFixture)
3333
public void CreateTopicWithCloudStorageIngestion()
3434
{
3535
string topicId = _pubsubFixture.RandomTopicId();
36-
string bucket = _pubsubFixture.RandomName("Bucket");
36+
string bucket = _pubsubFixture.CloudStorageBucketName;
3737
string inputFormat = "text";
3838
string textDelimiter = "\n";
3939
string matchGlob = "**.txt";

pubsub/api/Pubsub.Samples.Tests/CreateTopicWithConfluentCloudIngestionTest.cs

Lines changed: 5 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
// See the License for the specific language governing permissions and
1313
// limitations under the License.
1414

15-
using Google.Cloud.PubSub.V1;
15+
using Grpc.Core;
1616
using Xunit;
1717

1818
[Collection(nameof(PubsubFixture))]
@@ -31,18 +31,9 @@ public CreateTopicWithConfluentCloudIngestionTest(PubsubFixture pubsubFixture)
3131
public void CreateTopicWithConfluentCloudIngestion()
3232
{
3333
string topicId = _pubsubFixture.RandomTopicId();
34-
var (bootstrapServer, clusterId, confluentTopic, identityPoolId, gcpServiceAccount) = _pubsubFixture.RandomConfluentCloudIngestionParams();
35-
Topic createdTopic = _createTopicWithConfluentCloudIngestionSample.CreateTopicWithConfluentCloudIngestion(_pubsubFixture.ProjectId, topicId, bootstrapServer, clusterId, confluentTopic, identityPoolId, gcpServiceAccount);
36-
37-
// Confirm that the created topic and topic retrieved by ID are equal
38-
Topic retrievedTopic = _pubsubFixture.GetTopic(topicId);
39-
Assert.Equal(createdTopic, retrievedTopic);
40-
41-
// Confirm that all Confluent Cloud Ingestion params are equal to expected values
42-
Assert.Equal(bootstrapServer, createdTopic.IngestionDataSourceSettings.ConfluentCloud.BootstrapServer);
43-
Assert.Equal(clusterId, createdTopic.IngestionDataSourceSettings.ConfluentCloud.ClusterId);
44-
Assert.Equal(confluentTopic, createdTopic.IngestionDataSourceSettings.ConfluentCloud.Topic);
45-
Assert.Equal(identityPoolId, createdTopic.IngestionDataSourceSettings.ConfluentCloud.IdentityPoolId);
46-
Assert.Equal(gcpServiceAccount, createdTopic.IngestionDataSourceSettings.ConfluentCloud.GcpServiceAccount);
34+
var (bootstrapServer, clusterId, confluentTopic, identityPoolId, gcpServiceAccount) = _pubsubFixture.ConfluentCloudIngestionParams();
35+
var exception = Assert.Throws<RpcException>(() => _createTopicWithConfluentCloudIngestionSample.CreateTopicWithConfluentCloudIngestion(_pubsubFixture.ProjectId, topicId, bootstrapServer, clusterId, confluentTopic, identityPoolId, gcpServiceAccount));
36+
Assert.Equal(StatusCode.InvalidArgument, exception.Status.StatusCode);
37+
Assert.Equal("Cloud Pub/Sub received invalid argument/s for the ingestion data source: Unreachable bootstrap server.", exception.Status.Detail);
4738
}
4839
}

pubsub/api/Pubsub.Samples.Tests/CreateTopicWithKinesisIngestionTest.cs

Lines changed: 5 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
// See the License for the specific language governing permissions and
1313
// limitations under the License.
1414

15-
using Google.Cloud.PubSub.V1;
15+
using Grpc.Core;
1616
using Xunit;
1717

1818
[Collection(nameof(PubsubFixture))]
@@ -31,17 +31,9 @@ public CreateTopicWithKinesisIngestionTest(PubsubFixture pubsubFixture)
3131
public void CreateTopicWithKinesisIngestion()
3232
{
3333
string topicId = _pubsubFixture.RandomTopicId();
34-
var (streamArn, consumerArn, awsRoleArn, gcpServiceAccount) = _pubsubFixture.RandomKinesisIngestionParams();
35-
Topic createdTopic = _createTopicWithKinesisIngestionSample.CreateTopicWithKinesisIngestion(_pubsubFixture.ProjectId, topicId, streamArn, consumerArn, awsRoleArn, gcpServiceAccount);
36-
37-
// Confirm that the created topic and topic retrieved by ID are equal
38-
Topic retrievedTopic = _pubsubFixture.GetTopic(topicId);
39-
Assert.Equal(createdTopic, retrievedTopic);
40-
41-
// Confirm that all AWSKinesis Ingestion params are equal to expected values
42-
Assert.Equal(streamArn, createdTopic.IngestionDataSourceSettings.AwsKinesis.StreamArn);
43-
Assert.Equal(consumerArn, createdTopic.IngestionDataSourceSettings.AwsKinesis.ConsumerArn);
44-
Assert.Equal(awsRoleArn, createdTopic.IngestionDataSourceSettings.AwsKinesis.AwsRoleArn);
45-
Assert.Equal(gcpServiceAccount, createdTopic.IngestionDataSourceSettings.AwsKinesis.GcpServiceAccount);
34+
var (streamArn, consumerArn, awsRoleArn, gcpServiceAccount) = _pubsubFixture.KinesisIngestionParams();
35+
var exception = Assert.Throws<RpcException>(() => _createTopicWithKinesisIngestionSample.CreateTopicWithKinesisIngestion(_pubsubFixture.ProjectId, topicId, streamArn, consumerArn, awsRoleArn, gcpServiceAccount));
36+
Assert.Equal(StatusCode.PermissionDenied, exception.Status.StatusCode);
37+
Assert.Equal(_pubsubFixture.PermissionDeniedMessage, exception.Status.Detail);
4638
}
4739
}

pubsub/api/Pubsub.Samples.Tests/OptimisticSubscribeTest.cs

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,14 @@ public class OptimisticSubscribeTest
2222
private readonly PubsubFixture _pubsubFixture;
2323
private readonly PublishMessagesAsyncSample _publishMessagesAsyncSample;
2424
private readonly OptimisticSubscribeSample _optimisticSubscribeSample;
25+
private readonly DeleteSubscriptionSample _deleteSubscriptionSample;
2526

2627
public OptimisticSubscribeTest(PubsubFixture pubsubFixture)
2728
{
2829
_pubsubFixture = pubsubFixture;
2930
_publishMessagesAsyncSample = new PublishMessagesAsyncSample();
3031
_optimisticSubscribeSample = new OptimisticSubscribeSample();
32+
_deleteSubscriptionSample = new DeleteSubscriptionSample();
3133
}
3234

3335
[Fact]
@@ -38,10 +40,17 @@ public async Task SubscribeOptimistically()
3840

3941
_pubsubFixture.CreateTopic(topicId);
4042

43+
// Publish messages and call OptimisticSubscribe before subscription creation
4144
await _publishMessagesAsyncSample.PublishMessagesAsync(_pubsubFixture.ProjectId, topicId, messages);
45+
var result1 = await _optimisticSubscribeSample.OptimisticSubscribe(_pubsubFixture.ProjectId, topicId, subscriptionId);
46+
// Existing messages before subscription creation would not be received
47+
Assert.Equal(0, result1);
4248

43-
var result = await _optimisticSubscribeSample.OptimisticSubscribe(_pubsubFixture.ProjectId, topicId, subscriptionId);
49+
await _publishMessagesAsyncSample.PublishMessagesAsync(_pubsubFixture.ProjectId, topicId, messages);
50+
var result2 = await _optimisticSubscribeSample.OptimisticSubscribe(_pubsubFixture.ProjectId, topicId, subscriptionId);
51+
// Messages published after subscription creation should be received
52+
Assert.Equal(messages.Count, result2);
4453

45-
Assert.Equal(messages.Count, result);
54+
_deleteSubscriptionSample.DeleteSubscription(_pubsubFixture.ProjectId, subscriptionId);
4655
}
4756
}

pubsub/api/Pubsub.Samples.Tests/PubsubFixture.cs

Lines changed: 24 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
// See the License for the specific language governing permissions and
1313
// limitations under the License.
1414

15+
using Google.Apis.Auth.OAuth2;
1516
using Google.Apis.Bigquery.v2.Data;
1617
using Google.Apis.Storage.v1.Data;
1718
using Google.Cloud.BigQuery.V2;
@@ -23,11 +24,12 @@
2324
using System.Collections.Generic;
2425
using System.Linq;
2526
using System.Runtime.CompilerServices;
27+
using System.Threading.Tasks;
2628
using Xunit;
2729
using Xunit.Sdk;
2830

2931
[CollectionDefinition(nameof(PubsubFixture))]
30-
public class PubsubFixture : IDisposable, ICollectionFixture<PubsubFixture>
32+
public class PubsubFixture : IDisposable, ICollectionFixture<PubsubFixture>, IAsyncLifetime
3133
{
3234
public string ProjectId { get; }
3335
public List<string> TempTopicIds { get; } = new List<string>();
@@ -44,6 +46,9 @@ public class PubsubFixture : IDisposable, ICollectionFixture<PubsubFixture>
4446
public string BigQueryTableId { get; } = $"testTable{Guid.NewGuid().ToString().Substring(24)}";
4547
public string BigQueryTableName { get; }
4648
public string CloudStorageBucketName { get; }
49+
public string PermissionDeniedMessage { get; } = "Cloud Pub/Sub does not have the necessary permissions settings on the ingestion data source: AccessDenied: Not authorized to perform sts:AssumeRoleWithWebIdentity.";
50+
private const string AwsRoleArn = "arn:aws:iam::111111111111:role/fake-role-name";
51+
private string _gcpServiceAccount = "";
4752

4853
public RetryRobot Pull { get; } = new RetryRobot
4954
{
@@ -256,27 +261,27 @@ public List<Schema> ListSchemaRevisions(string schemaId)
256261
return ($"test{caller}Topic{randomName}", $"test{caller}Subscription{randomName}", $"test{caller}Schema{randomName}");
257262
}
258263

259-
public (string streamArn, string consumerArn, string awsRoleArn, string gcpServiceAccount) RandomKinesisIngestionParams([CallerMemberName] string caller = null)
260-
{
261-
var randomName = RandomName();
262-
return ($"test{caller}StreamArn{randomName}", $"test{caller}ConsumerArn{randomName}", $"test{caller}AwsRoleArn{randomName}", $"test{caller}GcpServiceAccount{randomName}");
263-
}
264+
public (string streamArn, string consumerArn, string awsRoleArn, string gcpServiceAccount) KinesisIngestionParams() => ("arn:aws:kinesis:us-west-2:111111111111:stream/fake-stream-name", "arn:aws:kinesis:us-west-2:111111111111:stream/fake-stream-name/consumer/consumer-1:1111111111", AwsRoleArn, _gcpServiceAccount);
264265

265-
public (string clusterArn, string mskTopic, string awsRoleArn, string gcpServiceAccount) RandomAwsMskIngestionParams([CallerMemberName] string caller = null)
266-
{
267-
var randomName = RandomName();
268-
return ($"test{caller}ClusterArn{randomName}", $"test{caller}MskTopic{randomName}", $"test{caller}AwsRoleArn{randomName}", $"test{caller}GcpServiceAccount{randomName}");
269-
}
266+
public (string clusterArn, string mskTopic, string awsRoleArn, string gcpServiceAccount) AwsMskIngestionParams() => ("arn:aws:kafka:us-east-1:111111111111:cluster/fake-cluster-name/11111111-1111-1", "fake-msk-topic-name", AwsRoleArn, _gcpServiceAccount);
270267

271-
public (string bootstrapServer, string clusterId, string confluentTopic, string identityPoolId, string gcpServiceAccount) RandomConfluentCloudIngestionParams([CallerMemberName] string caller = null)
272-
{
273-
var randomName = RandomName();
274-
return ($"test{caller}BootstrapServer{randomName}", $"test{caller}ClusterId{randomName}", $"test{caller}ConfluentTopic{randomName}", $"test{caller}IdentityPoolId{randomName}", $"test{caller}GcpServiceAccount{randomName}");
275-
}
268+
public (string bootstrapServer, string clusterId, string confluentTopic, string identityPoolId, string gcpServiceAccount) ConfluentCloudIngestionParams() => ("fake-bootstrap-server-id.us-south1.gcp.confluent.cloud:9092", "fake-cluster-id", "fake-confluent-topic-name", "fake-identity-pool-id", _gcpServiceAccount);
269+
270+
public (string resourceGroup, string nameSpace, string eventHub, string clientId, string tenantId, string subscriptionId, string gcpServiceAccount) AzureEventHubsIngestionParams() => ("fake-resource-group", "fake-namespace", "fake-event-hub", "11111111-1111-1111-1111-111111111111", "22222222-2222-2222-2222-222222222222", "33333333-3333-3333-3333-333333333333", _gcpServiceAccount);
276271

277-
public (string resourceGroup, string nameSpace, string eventHub, string clientId, string tenantId, string subscriptionId, string gcpServiceAccount) RandomAzureEventHubsIngestionParams([CallerMemberName] string caller = null)
272+
public async Task InitializeAsync()
278273
{
279-
var randomName = RandomName();
280-
return ($"test{caller}ResourceGroup{randomName}", $"test{caller}Namespace{randomName}", $"test{caller}EventHub{randomName}", $"test{caller}ClientId{randomName}", $"test{caller}TenantId{randomName}", $"test{caller}SubscriptionId{randomName}", $"test{caller}GcpServiceAccount{randomName}");
274+
GoogleCredential appDefaultCredentials = GoogleCredential.GetApplicationDefault();
275+
if (appDefaultCredentials.UnderlyingCredential is ServiceAccountCredential sac)
276+
{
277+
_gcpServiceAccount = sac.Id;
278+
}
279+
else if (appDefaultCredentials.UnderlyingCredential is ComputeCredential cc)
280+
{
281+
_gcpServiceAccount = await cc.GetDefaultServiceAccountEmailAsync();
282+
}
283+
return;
281284
}
285+
286+
public Task DisposeAsync() => Task.CompletedTask;
282287
}

pubsub/api/Pubsub.Samples.Tests/UpdateTopicTypeTest.cs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
// See the License for the specific language governing permissions and
1313
// limitations under the License.
1414

15-
using Google.Cloud.PubSub.V1;
15+
using Grpc.Core;
1616
using Xunit;
1717

1818
[Collection(nameof(PubsubFixture))]
@@ -33,9 +33,9 @@ public void UpdateTopicType()
3333
string topicId = _pubsubFixture.RandomTopicId();
3434
_pubsubFixture.CreateTopic(topicId);
3535

36-
var (streamArn, consumerArn, awsRoleArn, gcpServiceAccount) = _pubsubFixture.RandomKinesisIngestionParams();
37-
Topic updatedTopic = _updateTopicTypeSample.UpdateTopicType(_pubsubFixture.ProjectId, topicId, streamArn, consumerArn, awsRoleArn, gcpServiceAccount);
38-
Topic retrievedTopic = _pubsubFixture.GetTopic(topicId);
39-
Assert.Equal(updatedTopic, retrievedTopic);
36+
var (streamArn, consumerArn, awsRoleArn, gcpServiceAccount) = _pubsubFixture.KinesisIngestionParams();
37+
var exception = Assert.Throws<RpcException>(() => _updateTopicTypeSample.UpdateTopicType(_pubsubFixture.ProjectId, topicId, streamArn, consumerArn, awsRoleArn, gcpServiceAccount));
38+
Assert.Equal(StatusCode.PermissionDenied, exception.Status.StatusCode);
39+
Assert.Equal(_pubsubFixture.PermissionDeniedMessage, exception.Status.Detail);
4040
}
4141
}

pubsub/api/Pubsub.Samples/CreateTopicWithKinesisIngestion.cs

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,6 @@ public class CreateTopicWithKinesisIngestionSample
2121
{
2222
public Topic CreateTopicWithKinesisIngestion(string projectId, string topicId, string streamArn, string consumerArn, string awsRoleArn, string gcpServiceAccount)
2323
{
24-
PublisherServiceApiClient publisher = PublisherServiceApiClient.Create();
25-
2624
// Define settings for Kinesis ingestion
2725
IngestionDataSourceSettings ingestionDataSourceSettings = new IngestionDataSourceSettings
2826
{
@@ -35,12 +33,12 @@ public Topic CreateTopicWithKinesisIngestion(string projectId, string topicId, s
3533
}
3634
};
3735

36+
PublisherServiceApiClient publisher = PublisherServiceApiClient.Create();
3837
Topic topic = new Topic()
3938
{
4039
Name = TopicName.FormatProjectTopic(projectId, topicId),
4140
IngestionDataSourceSettings = ingestionDataSourceSettings
4241
};
43-
4442
Topic createdTopic = publisher.CreateTopic(topic);
4543
Console.WriteLine($"Topic {topic.Name} created.");
4644

0 commit comments

Comments
 (0)