diff --git a/lib/folksonomy.dart b/lib/folksonomy.dart new file mode 100644 index 0000000000..9690c46b9e --- /dev/null +++ b/lib/folksonomy.dart @@ -0,0 +1,380 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:http/http.dart'; + +import 'model/KeyStats.dart'; +import 'model/ProductList.dart'; +import 'model/ProductStats.dart'; +import 'model/ProductTag.dart'; +import 'utils/HttpHelper.dart'; +import 'utils/QueryType.dart'; +import 'utils/UriHelper.dart'; + +/// Client calls of the Folksonomy API (Open Food Facts) +/// +/// cf. https://api.folksonomy.openfoodfacts.org/docs +class FolksonomyAPIClient { + FolksonomyAPIClient._(); + + /// "hello world" + static Future hello({ + final QueryType? queryType, + }) async { + final Response response = await HttpHelper().doGetRequest( + UriHelper.getFolksonomyUri( + path: '/', + queryType: queryType, + ), + queryType: queryType, + ); + _checkResponse(response); + } + + /// Returns all the [ProductStats], with an optional filter. + /// + /// The result can be filtered with that [key], or with [key] = [value]. + static Future> getProductStats({ + final String? key, + final String? value, + final QueryType? queryType, + }) async { + final Map parameters = {}; + /* TODO + if (owner != null) { + parameters['owner'] = owner; + } + */ + if (key == null && value != null) { + throw Exception( + 'Does a value have a meaning without its key? I don\'t think so.'); + } + if (key != null) { + parameters['k'] = key; + } + if (value != null) { + parameters['v'] = value; + } + final Response response = await HttpHelper().doGetRequest( + UriHelper.getFolksonomyUri( + path: 'products/stats', + queryParameters: parameters, + queryType: queryType, + ), + queryType: queryType, + ); + _checkResponse(response); + final List result = []; + if (response.body == 'null') { + // not found + return result; + } + final List json = jsonDecode(response.body) as List; + for (var element in json) { + result.add(ProductStats.fromJson(element)); + } + return result; + } + + /// Returns all the products with that [key]. + /// + /// The key of the returned map is the barcode, the value is the tag value. + static Future> getProducts({ + required final String key, + final String? value, + final QueryType? queryType, + }) async { + final Map parameters = {}; + /* TODO + if (owner != null) { + parameters['owner'] = owner; + } + */ + parameters['k'] = key; + if (value != null) { + parameters['v'] = value; + } + final Response response = await HttpHelper().doGetRequest( + UriHelper.getFolksonomyUri( + path: 'products', + queryParameters: parameters, + queryType: queryType, + ), + queryType: queryType, + ); + _checkResponse(response); + final Map result = {}; + final List json = jsonDecode(response.body) as List; + for (var element in json) { + final ProductList productList = ProductList.fromJson(element); + if (productList.key != key) { + throw Exception('Unexpected key: ${productList.key}'); + } + result[productList.barcode] = productList.value; + } + return result; + } + + /// Returns all the [ProductTag]s for this product + /// + /// The key of the returned map is the tag key. + static Future> getProductTags({ + required final String barcode, + final QueryType? queryType, + }) async { + final Map parameters = {}; + /* TODO + if (owner != null) { + parameters['owner'] = owner; + } + */ + final Response response = await HttpHelper().doGetRequest( + UriHelper.getFolksonomyUri( + path: 'product/$barcode', + queryParameters: parameters, + queryType: queryType, + ), + queryType: queryType, + ); + _checkResponse(response); + final Map result = {}; + if (response.body == 'null') { + // not found + return result; + } + final List json = jsonDecode(response.body) as List; + for (var element in json) { + final ProductTag productTag = ProductTag.fromJson(element); + result[productTag.key] = productTag; + } + return result; + } + + /// Returns the [ProductTag] for this product and this tag key + /// + /// Returns null if not found. + static Future getProductTag({ + required final String barcode, + required final String key, + final QueryType? queryType, + }) async { + final Map parameters = {}; + /* TODO + if (owner != null) { + parameters['owner'] = owner; + } + */ + final Response response = await HttpHelper().doGetRequest( + UriHelper.getFolksonomyUri( + path: 'product/$barcode/$key', + queryParameters: parameters, + queryType: queryType, + ), + queryType: queryType, + ); + _checkResponse(response); + if (response.body == 'null') { + // not found + return null; + } + final Map json = + jsonDecode(response.body) as Map; + return ProductTag.fromJson(json); + } + + /// Returns all the [ProductTag]s for this product, with their subkeys. + /// + /// The key of the returned map is the key. + static Future> getProductTagWithSubKeys({ + required final String barcode, + required final String key, + final QueryType? queryType, + }) async { + final Map parameters = {}; + /* TODO + if (owner != null) { + parameters['owner'] = owner; + } + */ + final Response response = await HttpHelper().doGetRequest( + UriHelper.getFolksonomyUri( + path: 'product/$barcode/$key*', // look at the star! + queryParameters: parameters, + queryType: queryType, + ), + queryType: queryType, + ); + _checkResponse(response); + final Map result = {}; + if (response.body == 'null') { + // not found + return result; + } + final List json = jsonDecode(response.body) as List; + for (var element in json) { + final ProductTag productTag = ProductTag.fromJson(element); + result[productTag.key] = productTag; + } + return result; + } + +/* TODO +Future deleteProductTag({ + required final String barcode, + required final String key, + required final int version, + final QueryType? queryType, +}) async { + final Map parameters = {}; + /* TODO + if (owner != null) { + parameters['owner'] = owner; + } + */ + final Response response = await HttpHelper().doDeleteRequest( + UriHelper.getFolksonomyUri( + path: 'product/$barcode/$key', + queryParameters: parameters, + queryType: queryType, + ), + queryType: queryType, + ); + _checkResponse(response); +} + */ + + /// Returns the versions of [ProductTag] for this [barcode] and [key]. + static Future> getProductTagVersions({ + required final String barcode, + required final String key, + final QueryType? queryType, + }) async { + final Map parameters = {}; + /* TODO + if (owner != null) { + parameters['owner'] = owner; + } + */ + final Response response = await HttpHelper().doGetRequest( + UriHelper.getFolksonomyUri( + path: 'product/$barcode/$key/versions', + queryParameters: parameters, + queryType: queryType, + ), + queryType: queryType, + ); + _checkResponse(response); + final List result = []; + if (response.body == 'null') { + // not found + return result; + } + final List json = jsonDecode(response.body) as List; + for (var element in json) { + result.add(ProductTag.fromJson(element)); + } + return result; + } + + /* TODO + /// productTag.version must be equal to previous version + 1 + static Future updateProductTag({ + required final ProductTag productTag, + final QueryType? queryType, + }) async { + final Map parameters = {}; + /* TODO + if (owner != null) { + parameters['owner'] = owner; + } + */ + final Response response = await HttpHelper().doPutRequest( + UriHelper.getFolksonomyUri( + path: 'product', + queryParameters: parameters, + queryType: queryType, + ), + productTag.toJson().toString(), + userAgent: OpenFoodAPIConfiguration.userAgent, + queryType: queryType, + ); + _checkResponse(response); + } + */ + + /* TODO + /// productTag.version must be equal to 1 + static Future addProductTag({ + required final ProductTag productTag, + final User? user, + final QueryType? queryType, + }) async { + final Map parameters = {}; + /* TODO + if (owner != null) { + parameters['owner'] = owner; + } + */ + final Response response = await HttpHelper().doPostRequest( + UriHelper.getFolksonomyUri( + path: 'product', + queryParameters: parameters, + queryType: queryType, + ), + {}, // TODO later productTag.toJson(), + user, + queryType: queryType, + ); + _checkResponse(response); + } + */ + + /// Returns the list of tag keys with statistics. + static Future> getKeys({ + final QueryType? queryType, + }) async { + final Map parameters = {}; + /* TODO "The keys list can be restricted to private tags from some owner" + if (owner != null) { + parameters['owner'] = owner; + } + */ + final Response response = await HttpHelper().doGetRequest( + UriHelper.getFolksonomyUri( + path: 'keys', + queryParameters: parameters, + queryType: queryType, + ), + queryType: queryType, + ); + _checkResponse(response); + final Map result = {}; + final List json = jsonDecode(response.body) as List; + for (var element in json) { + final KeyStats item = KeyStats.fromJson(element); + result[item.key] = item; + } + return result; + } + + static Future ping({ + final QueryType? queryType, + }) async { + final Response response = await HttpHelper().doGetRequest( + UriHelper.getFolksonomyUri( + path: 'ping', + queryType: queryType, + ), + queryType: queryType, + ); + _checkResponse(response); + } + + /// Throws a detailed exception if relevant. Does nothing if [response] is OK. + static void _checkResponse(final Response response) { + if (response.statusCode != 200) { + // TODO have a look at ValidationError in https://api.folksonomy.openfoodfacts.org/docs + throw Exception('Wrong status code: ${response.statusCode}'); + } + } +} diff --git a/lib/model/KeyStats.dart b/lib/model/KeyStats.dart new file mode 100644 index 0000000000..b1c645f1b0 --- /dev/null +++ b/lib/model/KeyStats.dart @@ -0,0 +1,30 @@ +import 'package:json_annotation/json_annotation.dart'; +import '../interface/JsonObject.dart'; + +part 'KeyStats.g.dart'; + +/// Folksonomy: statistics around a tag key. +@JsonSerializable() +class KeyStats extends JsonObject { + @JsonKey(name: 'k') + final String key; + @JsonKey(name: 'count') + final int count; + @JsonKey(name: 'values') + final int values; + + KeyStats({ + required this.key, + required this.count, + required this.values, + }); + + factory KeyStats.fromJson(Map json) => + _$KeyStatsFromJson(json); + + @override + Map toJson() => _$KeyStatsToJson(this); + + @override + String toString() => toJson().toString(); +} diff --git a/lib/model/KeyStats.g.dart b/lib/model/KeyStats.g.dart new file mode 100644 index 0000000000..4da0612902 --- /dev/null +++ b/lib/model/KeyStats.g.dart @@ -0,0 +1,19 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'KeyStats.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +KeyStats _$KeyStatsFromJson(Map json) => KeyStats( + key: json['k'] as String, + count: json['count'] as int, + values: json['values'] as int, + ); + +Map _$KeyStatsToJson(KeyStats instance) => { + 'k': instance.key, + 'count': instance.count, + 'values': instance.values, + }; diff --git a/lib/model/ProductList.dart b/lib/model/ProductList.dart new file mode 100644 index 0000000000..ab65a31b9f --- /dev/null +++ b/lib/model/ProductList.dart @@ -0,0 +1,30 @@ +import 'package:json_annotation/json_annotation.dart'; +import '../interface/JsonObject.dart'; + +part 'ProductList.g.dart'; + +/// Folksonomy: current value for a product and a tag key. +@JsonSerializable() +class ProductList extends JsonObject { + @JsonKey(name: 'product') + final String barcode; + @JsonKey(name: 'k') + final String key; + @JsonKey(name: 'v') + final String value; + + ProductList({ + required this.barcode, + required this.key, + required this.value, + }); + + factory ProductList.fromJson(Map json) => + _$ProductListFromJson(json); + + @override + Map toJson() => _$ProductListToJson(this); + + @override + String toString() => toJson().toString(); +} diff --git a/lib/model/ProductList.g.dart b/lib/model/ProductList.g.dart new file mode 100644 index 0000000000..0dd375decb --- /dev/null +++ b/lib/model/ProductList.g.dart @@ -0,0 +1,20 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'ProductList.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +ProductList _$ProductListFromJson(Map json) => ProductList( + barcode: json['product'] as String, + key: json['k'] as String, + value: json['v'] as String, + ); + +Map _$ProductListToJson(ProductList instance) => + { + 'product': instance.barcode, + 'k': instance.key, + 'v': instance.value, + }; diff --git a/lib/model/ProductStats.dart b/lib/model/ProductStats.dart new file mode 100644 index 0000000000..7d8f93a041 --- /dev/null +++ b/lib/model/ProductStats.dart @@ -0,0 +1,37 @@ +import 'package:json_annotation/json_annotation.dart'; +import 'package:openfoodfacts/utils/JsonHelper.dart'; +import '../interface/JsonObject.dart'; + +part 'ProductStats.g.dart'; + +/// Folksonomy: statistics about the tag keys on a product. +@JsonSerializable() +class ProductStats extends JsonObject { + @JsonKey(name: 'product') + final String barcode; + @JsonKey(name: 'keys') + final int numberOfKeys; + @JsonKey(name: 'editors') + final int numberOfEditors; + @JsonKey( + name: 'last_edit', + fromJson: JsonHelper.stringTimestampToDate, + ) + final DateTime lastEdit; + + ProductStats({ + required this.barcode, + required this.numberOfKeys, + required this.numberOfEditors, + required this.lastEdit, + }); + + factory ProductStats.fromJson(Map json) => + _$ProductStatsFromJson(json); + + @override + Map toJson() => _$ProductStatsToJson(this); + + @override + String toString() => toJson().toString(); +} diff --git a/lib/model/ProductStats.g.dart b/lib/model/ProductStats.g.dart new file mode 100644 index 0000000000..0b3a539bdf --- /dev/null +++ b/lib/model/ProductStats.g.dart @@ -0,0 +1,22 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'ProductStats.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +ProductStats _$ProductStatsFromJson(Map json) => ProductStats( + barcode: json['product'] as String, + numberOfKeys: json['keys'] as int, + numberOfEditors: json['editors'] as int, + lastEdit: JsonHelper.stringTimestampToDate(json['last_edit']), + ); + +Map _$ProductStatsToJson(ProductStats instance) => + { + 'product': instance.barcode, + 'keys': instance.numberOfKeys, + 'editors': instance.numberOfEditors, + 'last_edit': instance.lastEdit.toIso8601String(), + }; diff --git a/lib/model/ProductTag.dart b/lib/model/ProductTag.dart new file mode 100644 index 0000000000..d8beb5540f --- /dev/null +++ b/lib/model/ProductTag.dart @@ -0,0 +1,45 @@ +import 'package:json_annotation/json_annotation.dart'; +import 'package:openfoodfacts/utils/JsonHelper.dart'; +import '../interface/JsonObject.dart'; + +part 'ProductTag.g.dart'; + +/// Folksonomy product tag: for this barcode, that value is set for that key. +@JsonSerializable() +class ProductTag extends JsonObject { + @JsonKey(name: 'product') + final String barcode; + @JsonKey(name: 'k') + final String key; + @JsonKey(name: 'v') + final String value; + final String owner; + final int version; + final String editor; + @JsonKey( + name: 'last_edit', + fromJson: JsonHelper.stringTimestampToDate, + ) + final DateTime lastEdit; + final String comment; + + ProductTag({ + required this.barcode, + required this.key, + required this.value, + required this.owner, + required this.version, + required this.editor, + required this.lastEdit, + required this.comment, + }); + + factory ProductTag.fromJson(Map json) => + _$ProductTagFromJson(json); + + @override + Map toJson() => _$ProductTagToJson(this); + + @override + String toString() => toJson().toString(); +} diff --git a/lib/model/ProductTag.g.dart b/lib/model/ProductTag.g.dart new file mode 100644 index 0000000000..bf47c70cbd --- /dev/null +++ b/lib/model/ProductTag.g.dart @@ -0,0 +1,30 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'ProductTag.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +ProductTag _$ProductTagFromJson(Map json) => ProductTag( + barcode: json['product'] as String, + key: json['k'] as String, + value: json['v'] as String, + owner: json['owner'] as String, + version: json['version'] as int, + editor: json['editor'] as String, + lastEdit: JsonHelper.stringTimestampToDate(json['last_edit']), + comment: json['comment'] as String, + ); + +Map _$ProductTagToJson(ProductTag instance) => + { + 'product': instance.barcode, + 'k': instance.key, + 'v': instance.value, + 'owner': instance.owner, + 'version': instance.version, + 'editor': instance.editor, + 'last_edit': instance.lastEdit.toIso8601String(), + 'comment': instance.comment, + }; diff --git a/lib/openfoodfacts.dart b/lib/openfoodfacts.dart index 7445b52e81..0019e35d21 100644 --- a/lib/openfoodfacts.dart +++ b/lib/openfoodfacts.dart @@ -44,13 +44,18 @@ import 'utils/ProductHelper.dart'; import 'utils/ProductQueryConfigurations.dart'; import 'utils/ProductSearchQueryConfiguration.dart'; +export 'folksonomy.dart'; export 'interface/Parameter.dart'; export 'model/Additives.dart'; export 'model/Ingredient.dart'; export 'model/Insight.dart'; +export 'model/KeyStats.dart'; export 'model/Product.dart'; +// export 'model/ProductList.dart'; // not needed export 'model/ProductImage.dart'; export 'model/ProductResult.dart'; +export 'model/ProductStats.dart'; +export 'model/ProductTag.dart'; export 'model/RobotoffQuestion.dart'; export 'model/SearchResult.dart'; export 'model/SendImage.dart'; diff --git a/lib/utils/JsonHelper.dart b/lib/utils/JsonHelper.dart index 9769843cd1..1bc0f3e759 100644 --- a/lib/utils/JsonHelper.dart +++ b/lib/utils/JsonHelper.dart @@ -193,4 +193,8 @@ class JsonHelper { Duration.millisecondsPerSecond) .round(); } + + /// Returns a [DateTime] from a JSON-encoded String (e.g. '2021-10-29T11:00:56.177379') + static DateTime stringTimestampToDate(dynamic json) => + DateTime.parse(json as String); } diff --git a/lib/utils/OpenFoodAPIConfiguration.dart b/lib/utils/OpenFoodAPIConfiguration.dart index cbea8f6011..9062cb09ef 100644 --- a/lib/utils/OpenFoodAPIConfiguration.dart +++ b/lib/utils/OpenFoodAPIConfiguration.dart @@ -32,6 +32,13 @@ class OpenFoodAPIConfiguration { ///Uri host of the test requests to Robotoff static String uriTestHostRobotoff = 'robotoff.openfoodfacts.net'; + ///Uri host of the Folksonomy requests to the backend, modify this to direct the request to a self-hosted instance. + static String uriProdHostFolksonomy = 'api.folksonomy.openfoodfacts.org'; + + ///Uri host of the test requests to Folksonomy + static String uriTestHostFolksonomy = + 'api.folksonomy.openfoodfacts.net'; // TODO does not work + ///Changes whether the requests sent by this package to the test or main server. static QueryType globalQueryType = QueryType.PROD; diff --git a/lib/utils/UriHelper.dart b/lib/utils/UriHelper.dart index cdea0ec39a..f562023dd5 100644 --- a/lib/utils/UriHelper.dart +++ b/lib/utils/UriHelper.dart @@ -38,6 +38,21 @@ class UriHelper { queryParameters: queryParameters, ); + ///Returns a OFF-Folksonomy uri with the in the [OpenFoodAPIConfiguration] specified settings + static Uri getFolksonomyUri({ + required final String path, + final Map? queryParameters, + final QueryType? queryType, + }) => + Uri( + scheme: OpenFoodAPIConfiguration.uriScheme, + host: OpenFoodAPIConfiguration.getQueryType(queryType) == QueryType.PROD + ? OpenFoodAPIConfiguration.uriProdHostFolksonomy + : OpenFoodAPIConfiguration.uriTestHostFolksonomy, + path: path, + queryParameters: queryParameters, + ); + /// Replaces the subdomain of an URI with specific country and language /// /// For instance diff --git a/test/api_folksonomy_test.dart b/test/api_folksonomy_test.dart new file mode 100644 index 0000000000..b6e0fffd13 --- /dev/null +++ b/test/api_folksonomy_test.dart @@ -0,0 +1,238 @@ +import 'package:openfoodfacts/openfoodfacts.dart'; +import 'package:openfoodfacts/utils/OpenFoodAPIConfiguration.dart'; +import 'package:openfoodfacts/utils/QueryType.dart'; +import 'package:test/test.dart'; + +/// Tests around folksonomy +void main() { + // TODO have it working on TEST too + OpenFoodAPIConfiguration.globalQueryType = QueryType.PROD; + + // of course we need to check that those 3 "known" guys combine well + const String knownBarcode = '9310036071174'; + const String knownKey = 'packaging:character:wikidata'; + const String knownValue = 'Q51785'; + + const String unknownBarcode = 'blablabla$knownBarcode'; + const String unknownKey = 'blablabla$knownKey'; + const String unknownValue = 'blablabla$knownValue'; + + const List> unknownParameters = >[ + {'barcode': unknownBarcode, 'key': knownKey}, + {'barcode': knownBarcode, 'key': unknownKey}, + ]; + + /// Checks that all [ProductTag]s concern that [barcode], and [key] in option. + void _checkProductTagList(final Iterable list) { + bool found = false; + for (var element in list) { + expect(element.barcode, knownBarcode); + if (element.key == knownKey) { + found = true; + expect(element.value, knownValue); + } + } + expect(found, true); + } + + /// Checks that all [ProductStats]s concern that [barcode], and [key] in option. + void _checkProductStatsList(final Iterable list) { + bool foundBarcode = false; + for (final ProductStats productStats in list) { + if (productStats.barcode == knownBarcode) { + foundBarcode = true; + expect(productStats.numberOfKeys, greaterThanOrEqualTo(1)); + } + } + expect(foundBarcode, true); + } + + group('$OpenFoodAPIClient Folksonomy', () { + test('hello', () async => await FolksonomyAPIClient.hello()); + + test('getProductStats - all', () async { + final List result = + await FolksonomyAPIClient.getProductStats(); + expect(result, isNotEmpty); + _checkProductStatsList(result); + }); + + test('getProductStats - found', () async { + final List result = + await FolksonomyAPIClient.getProductStats(key: knownKey); + expect(result, isNotEmpty); + _checkProductStatsList(result); + }); + + test('getProductStats - not found', () async { + final List result = + await FolksonomyAPIClient.getProductStats(key: unknownKey); + expect(result, isEmpty); + }); + + test('getProducts - found key', () async { + final Map result = await FolksonomyAPIClient.getProducts( + key: knownKey, + ); + expect(result, isNotEmpty); + expect(result[knownBarcode], knownValue); + }); + + test('getProducts - found key and value', () async { + final Map result = await FolksonomyAPIClient.getProducts( + key: knownKey, + value: knownValue, + ); + expect(result, isNotEmpty); + expect(result[knownBarcode], knownValue); + }); + + test('getProducts - not found key', () async { + final Map result = await FolksonomyAPIClient.getProducts( + key: unknownKey, + ); + expect(result, isEmpty); + }); + + test('getProducts - not found key and value', () async { + final Map result = await FolksonomyAPIClient.getProducts( + key: knownKey, + value: unknownValue, + ); + expect(result, isEmpty); + }); + + test('getProductTags - found', () async { + final Map result = + await FolksonomyAPIClient.getProductTags( + barcode: knownBarcode, + ); + expect(result, isNotEmpty); + _checkProductTagList(result.values); + }); + + test('getProductTags - not found', () async { + final Map result = + await FolksonomyAPIClient.getProductTags( + barcode: unknownBarcode, + ); + expect(result, isEmpty); + }); + + test('getProductTag - found', () async { + final ProductTag? result = await FolksonomyAPIClient.getProductTag( + barcode: knownBarcode, + key: knownKey, + ); + expect(result, isNotNull); + expect(result!.barcode, knownBarcode); + expect(result.key, knownKey); + }); + + test('getProductTag - not found', () async { + for (final unknownParameter in unknownParameters) { + final ProductTag? result = await FolksonomyAPIClient.getProductTag( + barcode: unknownParameter['barcode']!, + key: unknownParameter['key']!, + ); + expect(result, isNull); + } + }); + + test('getProductTagWithSubKeys - found', () async { + final Map result = + await FolksonomyAPIClient.getProductTagWithSubKeys( + barcode: knownBarcode, + key: knownKey, + ); + expect(result, isNotEmpty); + _checkProductTagList(result.values); + }); + + test('getProductTagWithSubKeys - not found', () async { + for (final unknownParameter in unknownParameters) { + final Map result = + await FolksonomyAPIClient.getProductTagWithSubKeys( + barcode: unknownParameter['barcode']!, + key: unknownParameter['key']!, + ); + expect(result, isEmpty); + } + }); + + /* TODO + test('deleteProductTag', () async { + await FolksonomyAPIClient.deleteProductTag( + barcode: '9310036071GDFFDD174', + key: 'packaging:character:dswikidata', + version: 21434534534, + ); + }, skip: 'To be fixed and run on TEST env'); + */ + + test('getProductTagVersions - found', () async { + final List result = + await FolksonomyAPIClient.getProductTagVersions( + barcode: knownBarcode, + key: knownKey, + ); + expect(result, isNotEmpty); + _checkProductTagList(result); + }); + + test('getProductTagVersions - not found', () async { + for (final unknownParameter in unknownParameters) { + final List result = + await FolksonomyAPIClient.getProductTagVersions( + barcode: unknownParameter['barcode']!, + key: unknownParameter['key']!, + ); + expect(result, isEmpty); + } + }); + + /* TODO + test('updateProductTag', () async { + await FolksonomyAPIClient.updateProductTag( + productTag: ProductTag( + barcode: 'barcode', + key: 'key', + value: 'value', + owner: 'owner', + version: 0, + editor: 'editor', + lastEdit: DateTime.now(), + comment: 'comment', + ), + ); + }, skip: 'To be fixed and run on TEST env'); + */ + + /* TODO + test('addProductTag', () async { + await FolksonomyAPIClient.addProductTag( + productTag: ProductTag( + barcode: 'barcode', + key: 'key', + value: 'value', + owner: 'owner', + version: 0, + editor: 'editor', + lastEdit: DateTime.now(), + comment: 'comment', + ), + ); + }, skip: 'To be fixed and run on TEST env'); + */ + + test('getKeys', () async { + final Map result = await FolksonomyAPIClient.getKeys(); + final KeyStats keyStats = result[knownKey]!; + expect(keyStats.key, knownKey); + expect(keyStats.count, greaterThanOrEqualTo(1)); + expect(keyStats.values, greaterThanOrEqualTo(1)); + }); + + test('ping', () async => await FolksonomyAPIClient.ping()); + }); +} diff --git a/test/api_saveProduct_test.dart b/test/api_saveProduct_test.dart index c670fdd405..6996251a6f 100644 --- a/test/api_saveProduct_test.dart +++ b/test/api_saveProduct_test.dart @@ -91,19 +91,20 @@ void main() { Duration(seconds: 90), )); - String _getRandomTimestamp({int random = 100000}) => - DateTime.now().toString() + - ' (' + - Random().nextInt(random).toString() + - ')'; + /// Returns a timestamp up to the minute level. + String _getMinuteTimestamp() => + DateTime.now().toIso8601String().substring(0, 16); test('dont overwrite language', () async { const String barcode = '4008391212596'; - // Assign random product names, to make sure we won't fail to update the - // product and then read a previously written value + // Assign time-related product names, to make sure we won't fail to update + // the product and then read a previously written value. + // In github tests it looks like the same test is being run twice + // almost in parallel. + // If we stay at the minute level we're relatively safe. final String frenchProductName = - "Flocons d'epeautre au blé complet " + _getRandomTimestamp(); - final String germanProductName = 'Dinkelflakes' + _getRandomTimestamp(); + "Flocons d'epeautre au blé complet " + _getMinuteTimestamp(); + final String germanProductName = 'Dinkelflakes' + _getMinuteTimestamp(); // save french product name final Product frenchProduct = Product( @@ -228,7 +229,11 @@ void main() { expect(status.status, 1); expect(status.statusVerbose, 'fields saved'); - }); + }, + timeout: Timeout( + // this guy is rather slow + Duration(seconds: 90), + )); test('add new product test 4', () async { Product product = Product(