From 863c69dfe21a56c14cbb96aaab47566d09d2c039 Mon Sep 17 00:00:00 2001 From: ches <1688276+vorobeyme@users.noreply.github.com> Date: Thu, 18 Apr 2024 17:54:06 +0300 Subject: [PATCH] test: add unit tests for Projects API methods (#23) * refactor(projects model): enhance add request struct - extract embedded structs into a separate one for better usage and reuse. - add new fields to struct * test: add unit tests for Projects API methods --- crowdin/crowdin_test.go | 29 +- crowdin/model/projects.go | 316 ++++-- crowdin/projects_test.go | 2038 +++++++++++++++++++++++++++++++++++++ go.mod | 8 + go.sum | 10 + 5 files changed, 2304 insertions(+), 97 deletions(-) create mode 100644 crowdin/projects_test.go create mode 100644 go.sum diff --git a/crowdin/crowdin_test.go b/crowdin/crowdin_test.go index 67c6d4f..b67ecf6 100644 --- a/crowdin/crowdin_test.go +++ b/crowdin/crowdin_test.go @@ -3,6 +3,7 @@ package crowdin import ( "bytes" "context" + "encoding/json" "fmt" "net/http" "net/http/httptest" @@ -48,12 +49,36 @@ func testBody(t *testing.T, r *http.Request, want string) { } } +// testJSONBody checks if the request body is equal to the expected JSON (beautified) +// by comparing the unmarshalled maps. +func testJSONBody(t *testing.T, r *http.Request, want string) { + t.Helper() + buf := new(bytes.Buffer) + _, err := buf.ReadFrom(r.Body) + if err != nil { + t.Fatalf("Failed to read request body: %v", err) + } + + var wantMap, gotMap map[string]any + if err := json.Unmarshal([]byte(want), &wantMap); err != nil { + t.Fatalf("Failed to unmarshal expected JSON: %v", err) + } + + got := buf.String() + if err := json.Unmarshal([]byte(got), &gotMap); err != nil { + t.Fatalf("Failed to unmarshal result JSON: %v", err) + } + + if !reflect.DeepEqual(wantMap, gotMap) { + t.Errorf("JSON does not match:\nExpected: %v\nGot: %v", wantMap, gotMap) + } +} + func testHeader(t *testing.T, r *http.Request, header, want string) { t.Helper() if got := r.Header.Get(header); got != want { t.Errorf("Request header %s: %v, want %v", header, got, want) } - } func testClientServices(t *testing.T, c *Client) { @@ -243,7 +268,7 @@ func TestDelete(t *testing.T) { client, mux, teardown := setupClient() defer teardown() - mux.HandleFunc("/delete", func(w http.ResponseWriter, r *http.Request) { + mux.HandleFunc("/delete", func(_ http.ResponseWriter, r *http.Request) { if r.Method != http.MethodDelete { t.Errorf("Request method is %v, want %v", r.Method, http.MethodGet) } diff --git a/crowdin/model/projects.go b/crowdin/model/projects.go index 1f5313b..a5f93b5 100644 --- a/crowdin/model/projects.go +++ b/crowdin/model/projects.go @@ -9,59 +9,72 @@ import ( type ( // Project represents a Crowdin project. Project struct { - ID int `json:"id"` - Type int `json:"type"` - UserID int `json:"userId"` - SourceLanguageID string `json:"sourceLanguageId"` - TargetLanguageIDs []string `json:"targetLanguageIds"` - LanguageAccessPolicy string `json:"languageAccessPolicy"` - Name string `json:"name"` - Cname *string `json:"cname"` - Identifier string `json:"identifier"` - Description string `json:"description"` - Visibility string `json:"visibility"` - Logo string `json:"logo"` - PublicDownloads *bool `json:"publicDownloads"` - CreatedAt *string `json:"createdAt"` - UpdatedAt *string `json:"updatedAt"` - LastActivity *string `json:"lastActivity"` - SourceLanguage *Language `json:"sourceLanguage"` - TargetLanguages []*Language `json:"targetLanguages"` - - TranslateDuplicates int `json:"translateDuplicates,omitempty"` - TagsDetection int `json:"tagsDetection,omitempty"` - GlossaryAccess bool `json:"glossaryAccess,omitempty"` - IsMTAllowed bool `json:"isMtAllowed,omitempty"` - TaskBasedAccessControl bool `json:"taskBasedAccessControl,omitempty"` - HiddenStringsProofreadersAccess bool `json:"hiddenStringsProofreadersAccess,omitempty"` - AutoSubstitution bool `json:"autoSubstitution,omitempty"` - ExportTranslatedOnly bool `json:"exportTranslatedOnly,omitempty"` - SkipUntranslatedStrings bool `json:"skipUntranslatedStrings,omitempty"` - ExportApprovedOnly bool `json:"exportApprovedOnly,omitempty"` - AutoTranslateDialects bool `json:"autoTranslateDialects,omitempty"` - UseGlobalTM bool `json:"useGlobalTm,omitempty"` - TMContextType string `json:"tmContextType,omitempty"` - ShowTMSuggestionsDialects bool `json:"showTmSuggestionsDialects,omitempty"` - IsSuspended bool `json:"isSuspended,omitempty"` - QACheckIsActive bool `json:"qaCheckIsActive,omitempty"` - QACheckCategories map[string]bool `json:"qaCheckCategories,omitempty"` - QAChecksIgnorableCategories map[string]bool `json:"qaChecksIgnorableCategories,omitempty"` - LanguageMapping map[string]map[string]string `json:"languageMapping,omitempty"` - NotificationSettings map[string]bool `json:"notificationSettings,omitempty"` - DefaultTMID int `json:"defaultTmId,omitempty"` - DefaultGlossaryID int `json:"defaultGlossaryId,omitempty"` - AssignedTMs map[int]map[string]int `json:"assignedTms,omitempty"` - AssignedGlossaries []int `json:"assignedGlossaries,omitempty"` - TMPenalties any `json:"tmPenalties,omitempty"` - NormalizePlaceholder bool `json:"normalizePlaceholder,omitempty"` - TMPreTranslate *ProjectTMPreTranslate `json:"tmPreTranslate,omitempty"` - MTPreTranslate *ProjectMTPreTranslate `json:"mtPreTranslate,omitempty"` - SaveMetaInfoInSource bool `json:"saveMetaInfoInSource,omitempty"` - SkipUntranslatedFiles bool `json:"skipUntranslatedFiles,omitempty"` - InContext bool `json:"inContext,omitempty"` - InContextProcessHiddenStrings bool `json:"inContextProcessHiddenStrings,omitempty"` - InContextPseudoLanguageID *string `json:"inContextPseudoLanguageId,omitempty"` - InContextPseudoLanguage *Language `json:"inContextPseudoLanguage,omitempty"` + ID int `json:"id"` + GroupID int `json:"groupId,omitempty"` + Type int `json:"type"` + UserID int `json:"userId"` + SourceLanguageID string `json:"sourceLanguageId"` + TargetLanguageIDs []string `json:"targetLanguageIds"` + LanguageAccessPolicy string `json:"languageAccessPolicy"` + Name string `json:"name"` + Cname string `json:"cname,omitempty"` + Identifier string `json:"identifier"` + Description string `json:"description"` + Visibility string `json:"visibility"` + Logo string `json:"logo"` + IsExternal bool `json:"isExternal,omitempty"` + ExternalType string `json:"externalType,omitempty"` + WorkflowID int `json:"workflowId,omitempty"` + HasCrowdsourcing bool `json:"hasCrowdsourcing,omitempty"` + PublicDownloads bool `json:"publicDownloads"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` + LastActivity string `json:"lastActivity"` + SourceLanguage *Language `json:"sourceLanguage"` + TargetLanguages []*Language `json:"targetLanguages"` + WebURL string `json:"webUrl"` + Fields map[string]any `json:"fields,omitempty"` + + ClientOrganizationID int `json:"clientOrganizationId,omitempty"` + TranslateDuplicates int `json:"translateDuplicates,omitempty"` + TagsDetection int `json:"tagsDetection,omitempty"` + GlossaryAccess bool `json:"glossaryAccess,omitempty"` + IsMTAllowed bool `json:"isMtAllowed,omitempty"` + TaskBasedAccessControl bool `json:"taskBasedAccessControl,omitempty"` + HiddenStringsProofreadersAccess bool `json:"hiddenStringsProofreadersAccess,omitempty"` + AutoSubstitution bool `json:"autoSubstitution,omitempty"` + ExportTranslatedOnly bool `json:"exportTranslatedOnly,omitempty"` + SkipUntranslatedStrings bool `json:"skipUntranslatedStrings,omitempty"` + ExportApprovedOnly bool `json:"exportApprovedOnly,omitempty"` + ExportWithMinApprovalsCount int `json:"exportWithMinApprovalsCount,omitempty"` + ExportStringsThatPassedWorkflow bool `json:"exportStringsThatPassedWorkflow,omitempty"` + AutoTranslateDialects bool `json:"autoTranslateDialects,omitempty"` + UseGlobalTM bool `json:"useGlobalTm,omitempty"` + TMContextType string `json:"tmContextType,omitempty"` + ShowTMSuggestionsDialects bool `json:"showTmSuggestionsDialects,omitempty"` + IsSuspended bool `json:"isSuspended,omitempty"` + QACheckIsActive bool `json:"qaCheckIsActive,omitempty"` + QAApprovalsCount int `json:"qaApprovalsCount,omitempty"` + QACheckCategories map[string]bool `json:"qaCheckCategories,omitempty"` + QAChecksIgnorableCategories map[string]bool `json:"qaChecksIgnorableCategories,omitempty"` + CustomQACheckIDs []int `json:"customQACheckIds,omitempty"` + LanguageMapping map[string]LanguageMapping `json:"languageMapping,omitempty"` + DelayedWorkflowStart bool `json:"delayedTranslations,omitempty"` + NotificationSettings *NotificationSettings `json:"notificationSettings,omitempty"` + DefaultTMID int `json:"defaultTmId,omitempty"` + DefaultGlossaryID int `json:"defaultGlossaryId,omitempty"` + AssignedTMs map[int]map[string]int `json:"assignedTms,omitempty"` + AssignedGlossaries []int `json:"assignedGlossaries,omitempty"` + TMPenalties any `json:"tmPenalties,omitempty"` + NormalizePlaceholder bool `json:"normalizePlaceholder,omitempty"` + TMPreTranslate *ProjectTMPreTranslate `json:"tmPreTranslate,omitempty"` + MTPreTranslate *ProjectMTPreTranslate `json:"mtPreTranslate,omitempty"` + SaveMetaInfoInSource bool `json:"saveMetaInfoInSource,omitempty"` + SkipUntranslatedFiles bool `json:"skipUntranslatedFiles,omitempty"` + InContext bool `json:"inContext,omitempty"` + InContextProcessHiddenStrings bool `json:"inContextProcessHiddenStrings,omitempty"` + InContextPseudoLanguageID string `json:"inContextPseudoLanguageId,omitempty"` + InContextPseudoLanguage *Language `json:"inContextPseudoLanguage,omitempty"` } ProjectTMPenalties struct { @@ -107,6 +120,10 @@ type ( type ProjectsListOptions struct { ListOptions + // Order projects by. + // Enum: id, name, identifier, description, createdAt, updatedAt, lastActivity. Default: id. + // Example: orderBy=createdAt desc,name,id. + OrderBy string `json:"orderBy,omitempty"` // User Identifier. UserID int `json:"userId,omitempty"` // Projects with Manager Access. Enum: 0, 1. Default: 0. @@ -123,6 +140,9 @@ func (o *ProjectsListOptions) Values() (url.Values, bool) { } v, _ := o.ListOptions.Values() + if o.OrderBy != "" { + v.Add("orderBy", o.OrderBy) + } if o.UserID > 0 { v.Add("userId", fmt.Sprintf("%d", o.UserID)) } @@ -200,32 +220,29 @@ type ProjectsAddRequest struct { // Defines whether to export only approved strings. ExportApprovedOnly *bool `json:"exportApprovedOnly,omitempty"` // If true - QA checks are active. Default: true. - QACheckIsActive *bool `json:"qaCheckIsActive,omitempty"` - QACheckCategories map[string]bool `json:"qaCheckCategories,omitempty"` + QACheckIsActive *bool `json:"qaCheckIsActive,omitempty"` + // Acceptable categories are: empty, size, tags, spaces, variables, punctuation, symbolRegister, + // specialSymbols, wrongTranslation, spellcheck, icu, terms, duplicate, ftl, android + QACheckCategories map[string]bool `json:"qaCheckCategories,omitempty"` + // Acceptable categories are: empty, size, tags, spaces, variables, punctuation, symbolRegister, + // specialSymbols, wrongTranslation, spellcheck, icu, terms, duplicate, ftl, android QAChecksIgnorableCategories map[string]bool `json:"qaChecksIgnorableCategories,omitempty"` // Language Mapping. - LanguageMapping map[string]map[string]string `json:"languageMapping,omitempty"` + LanguageMapping map[string]LanguageMapping `json:"languageMapping,omitempty"` // Allow project members to manage glossary terms. // The project owner and managers always can add and edit terms. Default: false. GlossaryAccess *bool `json:"glossaryAccess,omitempty"` // Enable the transformation of the placeholders to the unified format to improve the work with TM suggestions. NormalizePlaceholder *bool `json:"normalizePlaceholder,omitempty"` // Notification Settings. - NotificationSettings struct { - // Notify translators about new strings. Default: false. - TranslatorNewStrings *bool `json:"translatorNewStrings,omitempty"` - // Notify project managers about new strings. Default: false. - ManagerNewStrings *bool `json:"managerNewStrings,omitempty"` - // Notify project managers about language translation/validation completion. Default: false. - ManagerLanguageCompleted *bool `json:"managerLanguageCompleted,omitempty"` - } `json:"notificationSettings,omitempty"` + NotificationSettings *NotificationSettings `json:"notificationSettings,omitempty"` // TM perfect match searching mode. Enum: "segmentContext" "auto" "prevAndNextSegment". Default: "segmentContext". // segmentContext - searching by context. // auto - context search for key-value formats and segment search for others. // prevAndNextSegment - search by previous and next segment. - TMContextType string `json:"tmContextType,omitempty"` - TMPreTranslate ProjectTMPreTranslate `json:"tmPreTranslate,omitempty"` - MTPreTranslate ProjectMTPreTranslate `json:"mtPreTranslate,omitempty"` + TMContextType string `json:"tmContextType,omitempty"` + TMPreTranslate *ProjectTMPreTranslate `json:"tmPreTranslate,omitempty"` + MTPreTranslate *ProjectMTPreTranslate `json:"mtPreTranslate,omitempty"` // Context and max.length added in Crowdin will be visible in the downloaded files. SaveMetaInfoInSource *bool `json:"saveMetaInfoInSource,omitempty"` // Defines the project type. Use 0 for a file-based project and 1 for a string-based project. @@ -242,6 +259,100 @@ type ProjectsAddRequest struct { // In-Context pseudo-language id. // Note: Must be different from project source and target languages InContextPseudoLanguageID string `json:"inContextPseudoLanguageId,omitempty"` + + // Workflow Template Step Identifier. + ID int `json:"id,omitempty"` + // Workflow Template Identifier. + TemplateID int `json:"templateId,omitempty"` + // Workflow Template Steps Configuration. + // Note. Must be used together with `templateId`. Can't be used with + // `vendorId`, `mtEngineId` in same request. + Steps []WorkflowTemplateStepConfig `json:"steps,omitempty"` + // Group Identifier. + GroupID int `json:"groupId,omitempty"` + // Specify Vendor Identifier, if no Vendor is assigned to Workflow step yet. + VendorID int `json:"vendorId,omitempty"` + // Specify Machine Translation engine Identifier, if no MT engine is + // assigned to Workflow step yet. + MTEngineID int `json:"mtEngineId,omitempty"` + // Enum 0, 1, 2, 3, 4, 5. Default: 0. + // 0 - Show – translators will translate each instance separately, + // 1 - Hide (regular detection) – all duplicates will share the same translation + // 2 - Show, but auto-translate them, + // 3 - Show within a version branch (regular detection) - duplicates will be hidden only + // between versions branches + // 4 - Hide (strict detection) – all duplicates will share the same translation + // 5 - Show within a version branch (strict detection) - duplicates will be hidden only + // between versions branches + TranslateDuplicates *int `json:"translateDuplicates,omitempty"` + // Delay workflow start after project creation. Default: false. + DelayedWorkflowStart *bool `json:"delayedTranslations,omitempty"` + // Defines whether to export only approved strings. + // Note: value greater than 0 can't be used with `exportStringsThatPassedWorkflow=true` + // in same request. + ExportWithMinApprovalsCount *int `json:"exportWithMinApprovalsCount,omitempty"` + // Defines whether to export only strings that passed workflow. + // Note: `true` value can't be used with `exportWithMinApprovalsCount>0` in same request + // or in projects without an assigned workflow. + ExportStringsThatPassedWorkflow *bool `json:"exportStringsThatPassedWorkflow,omitempty"` + // Clear QA checks for translations with specific number of approvals. Default: 1. + QAApprovalsCount *int `json:"qaApprovalsCount,omitempty"` + // Custom QA checks identifiers. + CustomQACheckIDs []int `json:"customQACheckIds,omitempty"` + // MT Engine Identifier. + MTID int `json:"mtId,omitempty"` + // Fields. + Fields map[string]any `json:"fields,omitempty"` + // Target Languages Identifiers. + Languages []string `json:"languages,omitempty"` +} + +// LanguageMapping represents a project language mapping. +type LanguageMapping struct { + Name string `json:"name"` + TwoLettersCode string `json:"two_letters_code"` + ThreeLettersCode string `json:"three_letters_code"` + Locale string `json:"locale"` + LocaleWithUnderscore string `json:"locale_with_underscore"` + AndroidCode string `json:"android_code"` + OSXCode string `json:"osx_code"` + OSXLocale string `json:"osx_locale"` +} + +// NotificationSettings represents a project notification settings. +type NotificationSettings struct { + // Notify translators about new strings. Default: false. + TranslatorNewStrings *bool `json:"translatorNewStrings,omitempty"` + // Notify project managers about new strings. Default: false. + ManagerNewStrings *bool `json:"managerNewStrings,omitempty"` + // Notify project managers about language translation/validation completion. + // Default: false. + ManagerLanguageCompleted *bool `json:"managerLanguageCompleted,omitempty"` +} + +// TODO: Move to the Workflow service later. +// +// WorkflowTemplateStepConfig represents a workflow template +// step configuration and is used in the ProjectsAddRequest. +type WorkflowTemplateStepConfig struct { + // Workflow Template Step Identifier. + ID int `json:"id"` + // Target Languages Identifiers. + Languages []string `json:"languages"` + // User Identifiers. + // Note: Use only with `Translation` and `Proofreading` step types. + Assignees []int `json:"assignees"` + + // Vendor Identifier. + VendorID int `json:"vendorId,omitempty"` + + // Use only if TM Pre-translation is part of your Workflow Template. + Config struct { + // Minimum match for TM suggestions. + MinRelevant int `json:"minRelevant"` + // Improves TM suggestions. + AutoSubstitution bool `json:"autoSubstitution"` + } `json:"config,omitempty"` } // Validate checks if the add request is valid. @@ -261,16 +372,13 @@ func (r *ProjectsAddRequest) Validate() error { // ProjectsFileFormatSettings represents a Crowdin project file format settings. type ProjectsFileFormatSettings struct { - ID int `json:"id"` - Name string `json:"name"` - Format string `json:"format"` - Extensions []string `json:"extensions"` - Settings struct { - ContentSegmentation bool `json:"contentSegmentation"` - CustomSegmentation bool `json:"customSegmentation"` - } `json:"settings"` - CreatedAt string `json:"createdAt"` - UpdatedAt string `json:"updatedAt"` + ID int `json:"id"` + Name string `json:"name"` + Format string `json:"format"` + Extensions []string `json:"extensions"` + Settings map[string]any `json:"settings"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` } // ProjectsFileFormatSettingsResponse defines the structure of a response when @@ -438,7 +546,7 @@ type ( ExportQuotes string `json:"exportQuotes,omitempty"` } - StringCanalogFileFormatSettings struct { + StringCatalogFileFormatSettings struct { // Determines whether to import the key as source string if it does not exist. // Default: false. ImportKeyAsSource *bool `json:"importKeyAsSource,omitempty"` @@ -471,26 +579,38 @@ type ( ReactIntlFileFormatSettings struct{ CommonFileFormatSettings } ) +// Validate checks if the add project file format settings request is valid. +// It implements the crowdin.RequestValidator interface. +func (r *ProjectsAddFileFormatSettingsRequest) Validate() error { + if r == nil { + return ErrNilRequest + } + if r.Format == "" { + return errors.New("format is required") + } + if r.Settings == nil { + return errors.New("settings is required") + } + return r.Settings.ValidateSettings() +} + func (p *CommonFileFormatSettings) ValidateSettings() error { return nil } func (p *PropertyFileFormatSettings) ValidateSettings() error { return nil } func (p *XMLFileFormatSettings) ValidateSettings() error { return nil } func (p *MediaWikiFileFormatSettings) ValidateSettings() error { return nil } func (p *TXTFileFormatSettings) ValidateSettings() error { return nil } func (p *JavaScriptFileFormatSettings) ValidateSettings() error { return nil } -func (p *StringCanalogFileFormatSettings) ValidateSettings() error { return nil } +func (p *StringCatalogFileFormatSettings) ValidateSettings() error { return nil } func (p *OtherFileFormatSettings) ValidateSettings() error { return nil } // ProjectsStringsExporterSettings represents a Crowdin project strings // exporter settings. type ProjectsStringsExporterSettings struct { - ID int `json:"id"` - Format string `json:"format"` - Settings struct { - ConvertPlaceholders bool `json:"convertPlaceholders,omitempty"` - LanguagePairMapping map[string]string `json:"languagePairMapping,omitempty"` - } `json:"settings"` - CreatedAt string `json:"createdAt"` - UpdatedAt string `json:"updatedAt"` + ID int `json:"id"` + Format string `json:"format"` + Settings StringsExporterSettings `json:"settings"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` } // ProjectsStringsExporterSettingsResponse defines the structure of a response when @@ -511,14 +631,17 @@ type ProjectsStringsExporterSettingsRequest struct { // Defines strings exporter format. Enum: "android", "macosx", "xliff". Format string `json:"format"` // Defines strings exporter settings. - Settings struct { - // Convert placeholders to MacOSX format. Default: false. - // Note: Only for Android and MacOSX formats. - ConvertPlaceholders *bool `json:"convertPlaceholders,omitempty"` - // Defines language pair mapping the target language for the specified source language. - // Note: Only for XLIFF format. - LanguagePairMapping map[string]string `json:"languagePairMapping,omitempty"` - } `json:"settings"` + Settings StringsExporterSettings `json:"settings"` +} + +// StringsExporterSettings defines the structure of a strings exporter settings. +type StringsExporterSettings struct { + // Convert placeholders to MacOSX format. Default: false. + // Note: Only for Android and MacOSX formats. + ConvertPlaceholders *bool `json:"convertPlaceholders,omitempty"` + // Defines language pair mapping the target language for the specified source language. + // Note: Only for XLIFF format. + LanguagePairMapping map[string]string `json:"languagePairMapping,omitempty"` } // Validate checks if the update request is valid. @@ -530,5 +653,8 @@ func (r *ProjectsStringsExporterSettingsRequest) Validate() error { if r.Format == "" { return errors.New("format is required") } + if r.Settings.ConvertPlaceholders == nil && len(r.Settings.LanguagePairMapping) == 0 { + return errors.New("settings is required") + } return nil } diff --git a/crowdin/projects_test.go b/crowdin/projects_test.go new file mode 100644 index 0000000..8cbafb8 --- /dev/null +++ b/crowdin/projects_test.go @@ -0,0 +1,2038 @@ +package crowdin + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "testing" + + "github.com/crowdin/crowdin-api-client-go/crowdin/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestProjectsService_Get(t *testing.T) { + client, mux, teardown := setupClient() + defer teardown() + + var jsonResp = `{ + "data": { + "id": 8, + "type": 0, + "userId": 6, + "sourceLanguageId": "en", + "targetLanguageIds": ["es"], + "languageAccessPolicy": "moderate", + "name": "Knowledge Base", + "cname": "my-custom-domain.crowdin.com", + "identifier": "1f198a4e907688bc65834a6d5a6000c3", + "description": "Vault of all terms and their explanation", + "visibility": "private", + "logo": "data:image/png;base64,iVBORw0KGg", + "publicDownloads": true, + "createdAt": "2023-09-20T11:34:40+00:00", + "updatedAt": "2023-09-20T11:34:40+00:00", + "lastActivity": "2023-09-20T11:34:40+00:00", + "sourceLanguage": { + "id": "es", + "name": "Spanish", + "editorCode": "es", + "twoLettersCode": "es", + "threeLettersCode": "spa", + "locale": "es-ES", + "androidCode": "es-rES", + "osxCode": "es.lproj", + "osxLocale": "es", + "pluralCategoryNames": ["one"], + "pluralRules": "(n != 1)", + "pluralExamples": ["0, 2-999; 1.2, 2.07..."], + "textDirection": "ltr", + "dialectOf": "es" + }, + "targetLanguages": [ + { + "id": "es", + "name": "Spanish", + "editorCode": "es", + "twoLettersCode": "es", + "threeLettersCode": "spa", + "locale": "es-ES", + "androidCode": "es-rES", + "osxCode": "es.lproj", + "osxLocale": "es", + "pluralCategoryNames": ["one"], + "pluralRules": "(n != 1)", + "pluralExamples": ["0, 2-999; 1.2, 2.07..."], + "textDirection": "ltr", + "dialectOf": "es" + } + ], + "webUrl": "https://crowdin.com/project/some-project", + "translateDuplicates": 2, + "tagsDetection": 0, + "glossaryAccess": false, + "isMtAllowed": false, + "taskBasedAccessControl": false, + "hiddenStringsProofreadersAccess": true, + "autoSubstitution": true, + "exportTranslatedOnly": false, + "skipUntranslatedStrings": false, + "exportApprovedOnly": false, + "autoTranslateDialects": true, + "useGlobalTm": false, + "showTmSuggestionsDialects": true, + "isSuspended": false, + "qaCheckIsActive": true, + "qaCheckCategories": { + "empty": true, + "size": true, + "tags": true, + "spaces": true, + "variables": true, + "punctuation": true, + "symbolRegister": true, + "specialSymbols": true, + "wrongTranslation": true, + "spellcheck": true, + "icu": true, + "terms": true, + "duplicate": true, + "ftl": true, + "android": true + }, + "qaChecksIgnorableCategories": { + "empty": false, + "size": true, + "tags": true, + "spaces": true, + "variables": true, + "punctuation": true, + "symbolRegister": true, + "specialSymbols": true, + "wrongTranslation": true, + "spellcheck": true, + "icu": false, + "terms": true, + "duplicate": false, + "ftl": false, + "android": true + }, + "languageMapping": { + "uk": { + "name": "Ukrainian", + "two_letters_code": "ua", + "three_letters_code": "ukr", + "locale": "uk-UA", + "locale_with_underscore": "uk_UA", + "android_code": "uk-rUA", + "osx_code": "ua.lproj", + "osx_locale": "ua" + } + }, + "notificationSettings": { + "translatorNewStrings": true, + "managerNewStrings": false, + "managerLanguageCompleted": false + }, + "defaultTmId": 1, + "defaultGlossaryId": 1, + "assignedTms": { + "1": { + "priority": 1 + } + }, + "assignedGlossaries": [ + 2 + ], + "tmPenalties": { + "autoSubstitution": 1, + "tmPriority": { + "priority": 2, + "penalty": 1 + }, + "multipleTranslations": 1, + "timeSinceLastUsage": { + "months": 2, + "penalty": 1 + }, + "timeSinceLastModified": { + "months": 2, + "penalty": 1 + } + }, + "normalizePlaceholder": false, + "tmPreTranslate": { + "enabled": true, + "autoApproveOption": "all", + "minimumMatchRatio": "perfect" + }, + "mtPreTranslate": { + "enabled": true, + "mts": [ + { + "mtId": 1, + "languageIds": ["uk"] + } + ] + }, + "saveMetaInfoInSource": true, + "skipUntranslatedFiles": false, + "inContext": true, + "inContextProcessHiddenStrings": true, + "inContextPseudoLanguageId": "uk", + "inContextPseudoLanguage": { + "id": "uk", + "name": "Ukrainian", + "editorCode": "uk", + "twoLettersCode": "uk", + "threeLettersCode": "ukr", + "locale": "uk-UA", + "androidCode": "uk-rUA", + "osxCode": "uk.lproj", + "osxLocale": "uk", + "pluralCategoryNames": [ + "one", + "few", + "many", + "other" + ], + "pluralRules": "((n%10==1 && n%100!=11) ? 0 : ((n%10 >= 2 && n%10 <=4 && (n%100 < 12 || n%100 > 14)) ? 1 : ((n%10 == 0 || (n%10 >= 5 && n%10 <=9)) || (n%100 >= 11 && n%100 <= 14)) ? 2 : 3))", + "pluralExamples": [ + "1, 21, 31, 41, 51, 61, 71, 81...", + "2-4, 22-24, 32-34, 42-44, 52-54, 62...", + "0, 5-19, 100, 1000, 10000...", + "0.0-0.9, 1.1-1.6, 10.0, 100.0..." + ], + "textDirection": "ltr", + "dialectOf": null + }, + "tmContextType": "segmentContext" + } + }` + + mux.HandleFunc("/api/v2/projects/8", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "/api/v2/projects/8", r.RequestURI) + + fmt.Fprint(w, jsonResp) + }) + + project, _, err := client.Projects.Get(context.Background(), 8) + require.NoError(t, err) + + expectedProjects := &model.Project{ + ID: 8, + Type: 0, + UserID: 6, + SourceLanguageID: "en", + TargetLanguageIDs: []string{"es"}, + LanguageAccessPolicy: "moderate", + Name: "Knowledge Base", + Cname: "my-custom-domain.crowdin.com", + Identifier: "1f198a4e907688bc65834a6d5a6000c3", + Description: "Vault of all terms and their explanation", + Visibility: "private", + Logo: "data:image/png;base64,iVBORw0KGg", + PublicDownloads: true, + CreatedAt: "2023-09-20T11:34:40+00:00", + UpdatedAt: "2023-09-20T11:34:40+00:00", + LastActivity: "2023-09-20T11:34:40+00:00", + SourceLanguage: &model.Language{ + ID: "es", + Name: "Spanish", + EditorCode: "es", + TwoLettersCode: "es", + ThreeLettersCode: "spa", + Locale: "es-ES", + AndroidCode: "es-rES", + OSXCode: "es.lproj", + OSXLocale: "es", + PluralCategoryNames: []string{"one"}, + PluralRules: "(n != 1)", + PluralExamples: []string{"0, 2-999; 1.2, 2.07..."}, + TextDirection: "ltr", + DialectOf: "es", + }, + TargetLanguages: []*model.Language{ + { + ID: "es", + Name: "Spanish", + EditorCode: "es", + TwoLettersCode: "es", + ThreeLettersCode: "spa", + Locale: "es-ES", + AndroidCode: "es-rES", + OSXCode: "es.lproj", + OSXLocale: "es", + PluralCategoryNames: []string{"one"}, + PluralRules: "(n != 1)", + PluralExamples: []string{"0, 2-999; 1.2, 2.07..."}, + TextDirection: "ltr", + DialectOf: "es", + }, + }, + WebURL: "https://crowdin.com/project/some-project", + TranslateDuplicates: 2, + TagsDetection: 0, + GlossaryAccess: false, + IsMTAllowed: false, + TaskBasedAccessControl: false, + HiddenStringsProofreadersAccess: true, + AutoSubstitution: true, + ExportTranslatedOnly: false, + SkipUntranslatedStrings: false, + ExportApprovedOnly: false, + AutoTranslateDialects: true, + UseGlobalTM: false, + ShowTMSuggestionsDialects: true, + IsSuspended: false, + QACheckIsActive: true, + QACheckCategories: map[string]bool{ + "empty": true, + "size": true, + "tags": true, + "spaces": true, + "variables": true, + "punctuation": true, + "symbolRegister": true, + "specialSymbols": true, + "wrongTranslation": true, + "spellcheck": true, + "icu": true, + "terms": true, + "duplicate": true, + "ftl": true, + "android": true, + }, + QAChecksIgnorableCategories: map[string]bool{ + "empty": false, + "size": true, + "tags": true, + "spaces": true, + "variables": true, + "punctuation": true, + "symbolRegister": true, + "specialSymbols": true, + "wrongTranslation": true, + "spellcheck": true, + "icu": false, + "terms": true, + "duplicate": false, + "ftl": false, + "android": true, + }, + LanguageMapping: map[string]model.LanguageMapping{ + "uk": { + Name: "Ukrainian", + TwoLettersCode: "ua", + ThreeLettersCode: "ukr", + Locale: "uk-UA", + LocaleWithUnderscore: "uk_UA", + AndroidCode: "uk-rUA", + OSXCode: "ua.lproj", + OSXLocale: "ua", + }, + }, + NotificationSettings: &model.NotificationSettings{ + TranslatorNewStrings: ToPtr(true), + ManagerNewStrings: ToPtr(false), + ManagerLanguageCompleted: ToPtr(false), + }, + DefaultTMID: 1, + DefaultGlossaryID: 1, + AssignedTMs: map[int]map[string]int{ + 1: { + "priority": 1, + }, + }, + AssignedGlossaries: []int{2}, + TMPenalties: map[string]interface{}{ + "autoSubstitution": float64(1), + "tmPriority": map[string]interface{}{ + "priority": float64(2), + "penalty": float64(1), + }, + "multipleTranslations": float64(1), + "timeSinceLastUsage": map[string]interface{}{ + "months": float64(2), + "penalty": float64(1), + }, + "timeSinceLastModified": map[string]interface{}{ + "months": float64(2), + "penalty": float64(1), + }, + }, + NormalizePlaceholder: false, + TMPreTranslate: &model.ProjectTMPreTranslate{ + Enabled: ToPtr(true), + AutoApproveOption: "all", + MinimumMatchRatio: "perfect", + }, + MTPreTranslate: &model.ProjectMTPreTranslate{ + Enabled: ToPtr(true), + MTs: []model.ProjectMTs{ + { + MTID: 1, + LanguageIDs: []string{"uk"}, + }, + }, + }, + SaveMetaInfoInSource: true, + SkipUntranslatedFiles: false, + InContext: true, + InContextProcessHiddenStrings: true, + InContextPseudoLanguageID: "uk", + InContextPseudoLanguage: &model.Language{ + ID: "uk", + Name: "Ukrainian", + EditorCode: "uk", + TwoLettersCode: "uk", + ThreeLettersCode: "ukr", + Locale: "uk-UA", + AndroidCode: "uk-rUA", + OSXCode: "uk.lproj", + OSXLocale: "uk", + PluralCategoryNames: []string{"one", "few", "many", "other"}, + PluralRules: "((n%10==1 && n%100!=11) ? 0 : ((n%10 >= 2 && n%10 <=4 && (n%100 < 12 || n%100 > 14)) ? 1 : ((n%10 == 0 || (n%10 >= 5 && n%10 <=9)) || (n%100 >= 11 && n%100 <= 14)) ? 2 : 3))", + PluralExamples: []string{"1, 21, 31, 41, 51, 61, 71, 81...", "2-4, 22-24, 32-34, 42-44, 52-54, 62...", "0, 5-19, 100, 1000, 10000...", "0.0-0.9, 1.1-1.6, 10.0, 100.0..."}, + TextDirection: "ltr", + DialectOf: "", + }, + TMContextType: "segmentContext", + } + assert.Equal(t, expectedProjects, project) +} + +func TestProjectsService_Get_Enterprise(t *testing.T) { + client, mux, teardown := setupClient() + defer teardown() + + var jsonResp = `{ + "data": { + "id": 9, + "groupId": 4, + "type": 0, + "userId": 6, + "sourceLanguageId": "en", + "targetLanguageIds": ["es"], + "name": "Knowledge Base", + "identifier": "1f198a4e907688bc65834a6d5a6000c3", + "description": "Vault of all terms and their explanation", + "logo": "data:image/png;base64,iVBORw0KGgo", + "background": "data:image/png;base64,iVBORw0KGgo", + "isExternal": false, + "externalType": "proofread", + "workflowId": 3, + "hasCrowdsourcing": false, + "publicDownloads": true, + "createdAt": "2023-09-20T11:34:40+00:00", + "updatedAt": "2023-09-20T11:34:40+00:00", + "lastActivity": "2023-09-20T11:34:40+00:00", + "sourceLanguage": { + "id": "es", + "name": "Spanish", + "editorCode": "es", + "twoLettersCode": "es", + "threeLettersCode": "spa", + "locale": "es-ES", + "androidCode": "es-rES", + "osxCode": "es.lproj", + "osxLocale": "es", + "pluralCategoryNames": ["one"], + "pluralRules": "(n != 1)", + "pluralExamples": ["0, 2-999; 1.2, 2.07..."], + "textDirection": "ltr", + "dialectOf": "es" + }, + "targetLanguages": [ + { + "id": "es", + "name": "Spanish", + "editorCode": "es", + "twoLettersCode": "es", + "threeLettersCode": "spa", + "locale": "es-ES", + "androidCode": "es-rES", + "osxCode": "es.lproj", + "osxLocale": "es", + "pluralCategoryNames": ["one"], + "pluralRules": "(n != 1)", + "pluralExamples": ["0, 2-999; 1.2, 2.07..."], + "textDirection": "ltr", + "dialectOf": "es" + } + ], + "webUrl": "https://example.crowdin.com/u/projects/123", + "fields": { + "fieldSlug": "fieldValue" + }, + "clientOrganizationId": 52760, + "translateDuplicates": 1, + "tagsDetection": 0, + "glossaryAccess": false, + "isMtAllowed": false, + "taskBasedAccessControl": false, + "hiddenStringsProofreadersAccess": true, + "autoSubstitution": true, + "showTmSuggestionsDialects": true, + "exportTranslatedOnly": false, + "skipUntranslatedStrings": false, + "exportWithMinApprovalsCount": 0, + "exportStringsThatPassedWorkflow": true, + "autoTranslateDialects": true, + "normalizePlaceholder": false, + "isSuspended": false, + "qaCheckIsActive": true, + "qaApprovalsCount": 1, + "qaCheckCategories": { + "empty": true, + "size": true, + "tags": true, + "spaces": true, + "variables": true, + "punctuation": true, + "symbolRegister": true, + "specialSymbols": true, + "wrongTranslation": true, + "spellcheck": true, + "icu": true, + "terms": true, + "duplicate": true, + "ftl": true, + "android": true + }, + "qaChecksIgnorableCategories": { + "empty": false, + "size": true, + "tags": true, + "spaces": true, + "variables": true, + "punctuation": true, + "symbolRegister": true, + "specialSymbols": true, + "wrongTranslation": true, + "spellcheck": true, + "icu": false, + "terms": true, + "duplicate": false, + "ftl": false, + "android": true + }, + "customQaCheckIds": [ + 1 + ], + "languageMapping": { + "uk": { + "name": "Ukrainian", + "two_letters_code": "ua", + "three_letters_code": "ukr", + "locale": "uk-UA", + "locale_with_underscore": "uk_UA", + "android_code": "uk-rUA", + "osx_code": "ua.lproj", + "osx_locale": "ua" + } + }, + "delayedWorkflowStart": false, + "notificationSettings": { + "translatorNewStrings": true, + "managerNewStrings": false, + "managerLanguageCompleted": false + }, + "defaultTmId": 1, + "defaultGlossaryId": 1, + "assignedTms": { + "1": { + "priority": 1 + } + }, + "assignedGlossaries": [ + 2 + ], + "tmPenalties": { + "autoSubstitution": 1, + "tmPriority": { + "priority": 2, + "penalty": 1 + }, + "multipleTranslations": 1, + "timeSinceLastUsage": { + "months": 2, + "penalty": 1 + }, + "timeSinceLastModified": { + "months": 2, + "penalty": 1 + } + }, + "saveMetaInfoInSource": true, + "skipUntranslatedFiles": false, + "inContext": true, + "inContextProcessHiddenStrings": true, + "inContextPseudoLanguageId": "uk", + "inContextPseudoLanguage": { + "id": "uk", + "name": "Ukrainian", + "editorCode": "uk", + "twoLettersCode": "uk", + "threeLettersCode": "ukr", + "locale": "uk-UA", + "androidCode": "uk-rUA", + "osxCode": "uk.lproj", + "osxLocale": "uk", + "pluralCategoryNames": [ + "one", + "few", + "many", + "other" + ], + "pluralRules": "((n%10==1 && n%100!=11) ? 0 : ((n%10 >= 2 && n%10 <=4 && (n%100 < 12 || n%100 > 14)) ? 1 : ((n%10 == 0 || (n%10 >= 5 && n%10 <=9)) || (n%100 >= 11 && n%100 <= 14)) ? 2 : 3))", + "pluralExamples": [ + "1, 21, 31, 41, 51, 61, 71, 81...", + "2-4, 22-24, 32-34, 42-44, 52-54, 62...", + "0, 5-19, 100, 1000, 10000...", + "0.0-0.9, 1.1-1.6, 10.0, 100.0..." + ], + "textDirection": "ltr", + "dialectOf": null + }, + "tmContextType": "segmentContext" + } + }` + + mux.HandleFunc("/api/v2/projects/8", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "/api/v2/projects/8", r.RequestURI) + + fmt.Fprint(w, jsonResp) + }) + + project, _, err := client.Projects.Get(context.Background(), 8) + require.NoError(t, err) + + expectedProjects := &model.Project{ + ID: 9, + GroupID: 4, + Type: 0, + UserID: 6, + SourceLanguageID: "en", + TargetLanguageIDs: []string{"es"}, + Name: "Knowledge Base", + Identifier: "1f198a4e907688bc65834a6d5a6000c3", + Description: "Vault of all terms and their explanation", + Logo: "data:image/png;base64,iVBORw0KGgo", + IsExternal: false, + ExternalType: "proofread", + WorkflowID: 3, + HasCrowdsourcing: false, + PublicDownloads: true, + CreatedAt: "2023-09-20T11:34:40+00:00", + UpdatedAt: "2023-09-20T11:34:40+00:00", + LastActivity: "2023-09-20T11:34:40+00:00", + SourceLanguage: &model.Language{ + ID: "es", + Name: "Spanish", + EditorCode: "es", + TwoLettersCode: "es", + ThreeLettersCode: "spa", + Locale: "es-ES", + AndroidCode: "es-rES", + OSXCode: "es.lproj", + OSXLocale: "es", + PluralCategoryNames: []string{"one"}, + PluralRules: "(n != 1)", + PluralExamples: []string{"0, 2-999; 1.2, 2.07..."}, + TextDirection: "ltr", + DialectOf: "es", + }, + TargetLanguages: []*model.Language{ + { + ID: "es", + Name: "Spanish", + EditorCode: "es", + TwoLettersCode: "es", + ThreeLettersCode: "spa", + Locale: "es-ES", + AndroidCode: "es-rES", + OSXCode: "es.lproj", + OSXLocale: "es", + PluralCategoryNames: []string{"one"}, + PluralRules: "(n != 1)", + PluralExamples: []string{"0, 2-999; 1.2, 2.07..."}, + TextDirection: "ltr", + DialectOf: "es", + }, + }, + WebURL: "https://example.crowdin.com/u/projects/123", + Fields: map[string]any{"fieldSlug": "fieldValue"}, + ClientOrganizationID: 52760, + TranslateDuplicates: 1, + TagsDetection: 0, + GlossaryAccess: false, + IsMTAllowed: false, + TaskBasedAccessControl: false, + HiddenStringsProofreadersAccess: true, + AutoSubstitution: true, + ShowTMSuggestionsDialects: true, + ExportTranslatedOnly: false, + SkipUntranslatedStrings: false, + ExportWithMinApprovalsCount: 0, + ExportStringsThatPassedWorkflow: true, + AutoTranslateDialects: true, + NormalizePlaceholder: false, + IsSuspended: false, + QACheckIsActive: true, + QAApprovalsCount: 1, + QACheckCategories: map[string]bool{ + "empty": true, + "size": true, + "tags": true, + "spaces": true, + "variables": true, + "punctuation": true, + "symbolRegister": true, + "specialSymbols": true, + "wrongTranslation": true, + "spellcheck": true, + "icu": true, + "terms": true, + "duplicate": true, + "ftl": true, + "android": true, + }, + QAChecksIgnorableCategories: map[string]bool{ + "empty": false, + "size": true, + "tags": true, + "spaces": true, + "variables": true, + "punctuation": true, + "symbolRegister": true, + "specialSymbols": true, + "wrongTranslation": true, + "spellcheck": true, + "icu": false, + "terms": true, + "duplicate": false, + "ftl": false, + "android": true, + }, + CustomQACheckIDs: []int{1}, + LanguageMapping: map[string]model.LanguageMapping{ + "uk": { + Name: "Ukrainian", + TwoLettersCode: "ua", + ThreeLettersCode: "ukr", + Locale: "uk-UA", + LocaleWithUnderscore: "uk_UA", + AndroidCode: "uk-rUA", + OSXCode: "ua.lproj", + OSXLocale: "ua", + }, + }, + DelayedWorkflowStart: false, + NotificationSettings: &model.NotificationSettings{ + TranslatorNewStrings: ToPtr(true), + ManagerNewStrings: ToPtr(false), + ManagerLanguageCompleted: ToPtr(false), + }, + DefaultTMID: 1, + DefaultGlossaryID: 1, + AssignedTMs: map[int]map[string]int{ + 1: { + "priority": 1, + }, + }, + AssignedGlossaries: []int{2}, + TMPenalties: map[string]interface{}{ + "autoSubstitution": float64(1), + "tmPriority": map[string]interface{}{ + "priority": float64(2), + "penalty": float64(1), + }, + "multipleTranslations": float64(1), + "timeSinceLastUsage": map[string]interface{}{ + "months": float64(2), + "penalty": float64(1), + }, + "timeSinceLastModified": map[string]interface{}{ + "months": float64(2), + "penalty": float64(1), + }, + }, + SaveMetaInfoInSource: true, + SkipUntranslatedFiles: false, + InContext: true, + InContextProcessHiddenStrings: true, + InContextPseudoLanguageID: "uk", + InContextPseudoLanguage: &model.Language{ + ID: "uk", + Name: "Ukrainian", + EditorCode: "uk", + TwoLettersCode: "uk", + ThreeLettersCode: "ukr", + Locale: "uk-UA", + AndroidCode: "uk-rUA", + OSXCode: "uk.lproj", + OSXLocale: "uk", + PluralCategoryNames: []string{"one", "few", "many", "other"}, + PluralRules: "((n%10==1 && n%100!=11) ? 0 : ((n%10 >= 2 && n%10 <=4 && (n%100 < 12 || n%100 > 14)) ? 1 : ((n%10 == 0 || (n%10 >= 5 && n%10 <=9)) || (n%100 >= 11 && n%100 <= 14)) ? 2 : 3))", + PluralExamples: []string{"1, 21, 31, 41, 51, 61, 71, 81...", "2-4, 22-24, 32-34, 42-44, 52-54, 62...", "0, 5-19, 100, 1000, 10000...", "0.0-0.9, 1.1-1.6, 10.0, 100.0..."}, + TextDirection: "ltr", + DialectOf: "", + }, + TMContextType: "segmentContext", + } + assert.Equal(t, expectedProjects, project) +} + +func TestProjectsService_List(t *testing.T) { + client, mux, teardown := setupClient() + defer teardown() + + mux.HandleFunc("/api/v2/projects", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "/api/v2/projects", r.RequestURI) + + fmt.Fprint(w, `{ + "data": [ + { + "data": { + "id": 1 + } + }, + { + "data": { + "id": 2 + } + } + ], + "pagination": { + "offset": 10, + "limit": 25 + } + }`) + }) + + projects, resp, err := client.Projects.List(context.Background(), nil) + require.NoError(t, err) + + expectedProjects := []*model.Project{ + {ID: 1}, + {ID: 2}, + } + assert.Len(t, projects, 2) + assert.Equal(t, expectedProjects, projects) + + expectedPagination := model.Pagination{Offset: 10, Limit: 25} + assert.NotNil(t, resp) + assert.Equal(t, expectedPagination, resp.Pagination) +} + +func TestProjectsService_List_CheckQueryParams(t *testing.T) { + const url = "/api/v2/projects" + cases := []struct { + name string + opt *model.ProjectsListOptions + expect string + }{ + { + name: "Without options", + opt: nil, + expect: url, + }, + { + name: "Order by", + opt: &model.ProjectsListOptions{ + OrderBy: "createdAt desc,name,id", + }, + expect: url + "?orderBy=createdAt+desc%2Cname%2Cid", + }, + { + name: "User ID", + opt: &model.ProjectsListOptions{ + UserID: 1, + }, + expect: url + "?userId=1", + }, + { + name: "Has manager access", + opt: &model.ProjectsListOptions{ + HasManagerAccess: ToPtr(1), + }, + expect: url + "?hasManagerAccess=1", + }, + { + name: "Not accepted value", + opt: &model.ProjectsListOptions{ + HasManagerAccess: ToPtr(100), + }, + expect: url, + }, + { + name: "Type", + opt: &model.ProjectsListOptions{ + Type: ToPtr(1), + }, + expect: url + "?type=1", + }, + { + name: "Not accepted type", + opt: &model.ProjectsListOptions{ + Type: ToPtr(100), + }, + expect: url, + }, + { + name: "List with limit and offset", + opt: &model.ProjectsListOptions{ + ListOptions: model.ListOptions{Limit: 10, Offset: 20}, + }, + expect: url + "?limit=10&offset=20", + }, + { + name: "All query params", + opt: &model.ProjectsListOptions{ + OrderBy: "createdAt desc,name,id", + UserID: 1, + HasManagerAccess: ToPtr(0), + ListOptions: model.ListOptions{Limit: 10, Offset: 20}, + }, + expect: url + "?hasManagerAccess=0&limit=10&offset=20&orderBy=createdAt+desc%2Cname%2Cid&userId=1", + }, + } + + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + client, mux, teardown := setupClient() + defer teardown() + + mux.HandleFunc("/api/v2/projects", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, tt.expect, r.RequestURI) + + fmt.Fprint(w, `{ + "data": [] + }`) + }) + + _, _, err := client.Projects.List(context.Background(), tt.opt) + require.NoError(t, err) + }) + } +} + +func TestProjectsService_Add(t *testing.T) { + client, mux, teardown := setupClient() + defer teardown() + + projectReq := &model.ProjectsAddRequest{ + Name: "Knowledge Base", + SourceLanguageID: "en", + Identifier: "1f198a4e907688bc65834a6d5a6000c3", + TargetLanguageIDs: []string{"es"}, + Visibility: "private", + LangAccessPolicy: "moderate", + Cname: "my-custom-domain.crowdin.com", + Description: "Articles and tutorials", + TagsDetection: ToPtr(2), + IsMTAllowed: ToPtr(false), + TaskBasedAccessControl: ToPtr(false), + AutoSubstitution: ToPtr(false), + AutoTranslateDialects: ToPtr(true), + PublicDownloads: ToPtr(false), + HiddenStringsProofreadersAccess: ToPtr(false), + UseGlobalTM: ToPtr(false), + ShowTMSuggestionsDialects: ToPtr(true), + SkipUntranslatedStrings: ToPtr(false), + ExportApprovedOnly: ToPtr(false), + QACheckIsActive: ToPtr(false), + QACheckCategories: map[string]bool{ + "empty": true, + "size": true, + "tags": true, + "spaces": true, + "variables": true, + "punctuation": true, + "symbolRegister": true, + "specialSymbols": true, + "wrongTranslation": true, + "spellcheck": true, + "icu": true, + "terms": true, + "duplicate": true, + "ftl": true, + "android": true, + }, + QAChecksIgnorableCategories: map[string]bool{ + "empty": true, + "size": true, + "tags": true, + "spaces": true, + "variables": true, + "punctuation": true, + "symbolRegister": true, + "specialSymbols": true, + "wrongTranslation": true, + "spellcheck": true, + "icu": true, + "terms": true, + "duplicate": true, + "ftl": true, + "android": true, + }, + LanguageMapping: map[string]model.LanguageMapping{ + "uk": { + Name: "Ukrainian", + TwoLettersCode: "ua", + ThreeLettersCode: "ukr", + Locale: "uk-UA", + LocaleWithUnderscore: "uk_UA", + AndroidCode: "uk-rUA", + OSXCode: "ua.lproj", + OSXLocale: "ua", + }, + }, + GlossaryAccess: ToPtr(false), + NormalizePlaceholder: ToPtr(false), + NotificationSettings: &model.NotificationSettings{ + TranslatorNewStrings: ToPtr(false), + ManagerNewStrings: ToPtr(true), + ManagerLanguageCompleted: ToPtr(true), + }, + TMPreTranslate: &model.ProjectTMPreTranslate{ + Enabled: ToPtr(true), + AutoApproveOption: "all", + MinimumMatchRatio: "perfect", + }, + MTPreTranslate: &model.ProjectMTPreTranslate{ + Enabled: ToPtr(true), + MTs: []model.ProjectMTs{ + { + MTID: 1, + LanguageIDs: []string{"uk"}, + }, + }, + }, + SaveMetaInfoInSource: ToPtr(true), + Type: ToPtr(0), + SkipUntranslatedFiles: ToPtr(false), + InContext: ToPtr(true), + InContextProcessHiddenStrings: ToPtr(true), + InContextPseudoLanguageID: "de", + TMContextType: "segmentContext", + } + + mux.HandleFunc("/api/v2/projects", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/api/v2/projects", r.RequestURI) + + expectedReqBody := `{ + "name": "Knowledge Base", + "identifier": "1f198a4e907688bc65834a6d5a6000c3", + "sourceLanguageId": "en", + "targetLanguageIds": [ + "es" + ], + "visibility": "private", + "languageAccessPolicy": "moderate", + "cname": "my-custom-domain.crowdin.com", + "description": "Articles and tutorials", + "tagsDetection": 2, + "isMtAllowed": false, + "taskBasedAccessControl": false, + "autoSubstitution": false, + "autoTranslateDialects": true, + "publicDownloads": false, + "hiddenStringsProofreadersAccess": false, + "useGlobalTm": false, + "showTmSuggestionsDialects": true, + "skipUntranslatedStrings": false, + "exportApprovedOnly": false, + "qaCheckIsActive": false, + "qaCheckCategories": { + "android": true, + "duplicate": true, + "empty": true, + "ftl": true, + "icu": true, + "punctuation": true, + "size": true, + "spaces": true, + "specialSymbols": true, + "spellcheck": true, + "symbolRegister": true, + "tags": true, + "terms": true, + "variables": true, + "wrongTranslation": true + }, + "qaChecksIgnorableCategories": { + "android": true, + "duplicate": true, + "empty": true, + "ftl": true, + "icu": true, + "punctuation": true, + "size": true, + "spaces": true, + "specialSymbols": true, + "spellcheck": true, + "symbolRegister": true, + "tags": true, + "terms": true, + "variables": true, + "wrongTranslation": true + }, + "languageMapping": { + "uk": { + "android_code": "uk-rUA", + "locale": "uk-UA", + "locale_with_underscore": "uk_UA", + "name": "Ukrainian", + "osx_code": "ua.lproj", + "osx_locale": "ua", + "three_letters_code": "ukr", + "two_letters_code": "ua" + } + }, + "glossaryAccess": false, + "normalizePlaceholder": false, + "notificationSettings": { + "translatorNewStrings": false, + "managerNewStrings": true, + "managerLanguageCompleted": true + }, + "tmContextType": "segmentContext", + "tmPreTranslate": { + "enabled": true, + "autoApproveOption": "all", + "minimumMatchRatio": "perfect" + }, + "mtPreTranslate": { + "enabled": true, + "mts": [ + { + "mtId": 1, + "languageIds": [ + "uk" + ] + } + ] + }, + "saveMetaInfoInSource": true, + "type": 0, + "skipUntranslatedFiles": false, + "inContext": true, + "inContextProcessHiddenStrings": true, + "inContextPseudoLanguageId": "de" + }` + testJSONBody(t, r, expectedReqBody) + + err := json.NewEncoder(w).Encode(&model.ProjectsGetResponse{ + Data: &model.Project{ + ID: 9, + Name: projectReq.Name, + Identifier: projectReq.Identifier, + SourceLanguageID: projectReq.SourceLanguageID, + TargetLanguageIDs: projectReq.TargetLanguageIDs, + Description: projectReq.Description, + TagsDetection: *projectReq.TagsDetection, + QACheckIsActive: *projectReq.QACheckIsActive, + QACheckCategories: projectReq.QACheckCategories, + QAChecksIgnorableCategories: projectReq.QAChecksIgnorableCategories, + LanguageMapping: projectReq.LanguageMapping, + NotificationSettings: projectReq.NotificationSettings, + }, + }) + require.NoError(t, err) + }) + + project, resp, err := client.Projects.Add(context.Background(), projectReq) + require.NoError(t, err) + + require.NotNil(t, project) + assert.Equal(t, 9, project.ID) + assert.Equal(t, projectReq.Name, project.Name) + assert.Equal(t, projectReq.Identifier, project.Identifier) + assert.Equal(t, projectReq.SourceLanguageID, project.SourceLanguageID) + assert.Equal(t, projectReq.TargetLanguageIDs, project.TargetLanguageIDs) + assert.Equal(t, projectReq.Description, project.Description) + assert.Equal(t, *projectReq.TagsDetection, project.TagsDetection) + assert.Equal(t, *projectReq.QACheckIsActive, project.QACheckIsActive) + assert.Equal(t, projectReq.QACheckCategories, project.QACheckCategories) + assert.Equal(t, projectReq.QAChecksIgnorableCategories, project.QAChecksIgnorableCategories) + assert.Equal(t, projectReq.LanguageMapping, project.LanguageMapping) + assert.Equal(t, projectReq.NotificationSettings, project.NotificationSettings) + + assert.NotNil(t, resp) +} + +func TestProjectsService_Add_WithRequiredFields(t *testing.T) { + client, mux, teardown := setupClient() + defer teardown() + + mux.HandleFunc("/api/v2/projects", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/api/v2/projects", r.RequestURI) + + expectedReqBody := `{ + "name": "Knowledge Base", + "sourceLanguageId": "en" + }` + testJSONBody(t, r, expectedReqBody) + + fmt.Fprint(w, `{}`) + }) + + _, _, err := client.Projects.Add(context.Background(), &model.ProjectsAddRequest{ + Name: "Knowledge Base", + SourceLanguageID: "en", + }) + require.NoError(t, err) +} + +func TestProjectsService_Add_WithValidationError(t *testing.T) { + cases := []struct { + req *model.ProjectsAddRequest + expectErr string + }{ + {nil, "request cannot be nil"}, + {&model.ProjectsAddRequest{SourceLanguageID: "en"}, "name is required"}, + {&model.ProjectsAddRequest{Name: "Knowledge Base"}, "sourceLanguageId is required"}, + } + + for _, tt := range cases { + t.Run(tt.expectErr, func(t *testing.T) { + err := tt.req.Validate() + require.Error(t, err) + assert.Equal(t, tt.expectErr, err.Error()) + }) + } +} + +func TestProjectsService_Edit(t *testing.T) { + client, mux, teardown := setupClient() + defer teardown() + + projectID := 8 + + mux.HandleFunc(fmt.Sprintf("/api/v2/projects/%d", projectID), func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPatch, r.Method) + assert.Equal(t, fmt.Sprintf("/api/v2/projects/%d", projectID), r.RequestURI) + + req := `[{"op":"replace","path":"/name","value":"New Name"}]` + "\n" + testBody(t, r, req) + + fmt.Fprint(w, `{ + "data": { + "id": 8, + "name": "New Name" + } + }`) + }) + + updateReq := []*model.UpdateRequest{ + { + Op: "replace", + Path: "/name", + Value: "New Name", + }, + } + project, resp, err := client.Projects.Edit(context.Background(), projectID, updateReq) + require.NoError(t, err) + + expectedProject := &model.Project{ + ID: 8, + Name: "New Name", + } + assert.Equal(t, expectedProject, project) + assert.NotNil(t, resp) +} + +func TestProjectsService_Delete(t *testing.T) { + client, mux, teardown := setupClient() + defer teardown() + + mux.HandleFunc("/api/v2/projects/8", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodDelete, r.Method) + assert.Equal(t, "/api/v2/projects/8", r.RequestURI) + + w.WriteHeader(http.StatusNoContent) + }) + + resp, err := client.Projects.Delete(context.Background(), 8) + require.NoError(t, err) + require.NotNil(t, resp) + assert.Equal(t, http.StatusNoContent, resp.StatusCode) +} + +func TestProjectsService_DownloadFileFormatSettingsCustomSegmentation(t *testing.T) { + client, mux, teardown := setupClient() + defer teardown() + + mux.HandleFunc("/api/v2/projects/8/file-format-settings/10/custom-segmentations", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "/api/v2/projects/8/file-format-settings/10/custom-segmentations", r.RequestURI) + + fmt.Fprint(w, `{ + "data": { + "url": "https://production-enterprise-importer.downloads.crowdin.com/992000002/2/14.xliff?response-content-disposition=attachment%3B20filename%3D%22APP.xliff", + "expireIn": "2023-09-20T10:31:21+00:00" + } + }`) + }) + + downloadLink, resp, err := client.Projects.DownloadFileFormatSettingsCustomSegmentation(context.Background(), 8, 10) + require.NoError(t, err) + + expectedDownloadLink := &model.DownloadLink{ + URL: "https://production-enterprise-importer.downloads.crowdin.com/992000002/2/14.xliff?response-content-disposition=attachment%3B20filename%3D%22APP.xliff", + ExpireIn: "2023-09-20T10:31:21+00:00", + } + assert.Equal(t, expectedDownloadLink, downloadLink) + assert.NotNil(t, resp) +} + +func TestProjectsService_ResetFileFormatSettingsCustomSegmentation(t *testing.T) { + client, mux, teardown := setupClient() + defer teardown() + + mux.HandleFunc("/api/v2/projects/8/file-format-settings/10/custom-segmentations", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodDelete, r.Method) + assert.Equal(t, "/api/v2/projects/8/file-format-settings/10/custom-segmentations", r.RequestURI) + + w.WriteHeader(http.StatusNoContent) + }) + + resp, err := client.Projects.ResetFileFormatSettingsCustomSegmentation(context.Background(), 8, 10) + require.NoError(t, err) + require.NotNil(t, resp) + assert.Equal(t, http.StatusNoContent, resp.StatusCode) +} + +func TestProjectsService_ListFileFormatSettings(t *testing.T) { + client, mux, teardown := setupClient() + defer teardown() + + path := "/api/v2/projects/8/file-format-settings" + mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, path, r.RequestURI) + + fmt.Fprint(w, `{ + "data": [ + { + "data": { + "id": 44, + "name": "Android XML", + "format": "android", + "extensions": [ "xml" ], + "settings": { + "exportPattern": null, + "escapeQuotes": 1, + "escapeSpecialCharacters": 1 + }, + "createdAt": "2023-09-19T15:10:43+00:00", + "updatedAt": "2023-09-19T15:10:46+00:00" + } + } + ], + "pagination": { + "offset": 0, + "limit": 25 + } + }`) + }) + + settings, resp, err := client.Projects.ListFileFormatSettings(context.Background(), 8) + require.NoError(t, err) + + expectedSettings := []*model.ProjectsFileFormatSettings{ + { + ID: 44, + Name: "Android XML", + Format: "android", + Extensions: []string{"xml"}, + Settings: map[string]any{ + "exportPattern": nil, + "escapeQuotes": float64(1), + "escapeSpecialCharacters": float64(1), + }, + CreatedAt: "2023-09-19T15:10:43+00:00", + UpdatedAt: "2023-09-19T15:10:46+00:00", + }, + } + assert.Equal(t, expectedSettings, settings) + + expectedPagination := model.Pagination{Offset: 0, Limit: 25} + require.NotNil(t, resp) + assert.Equal(t, expectedPagination, resp.Pagination) +} + +func TestProjectsService_GetFileFormatSettings(t *testing.T) { + client, mux, teardown := setupClient() + defer teardown() + + path := "/api/v2/projects/8/file-format-settings/1" + mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, path, r.RequestURI) + + fmt.Fprint(w, `{ + "data": { + "id": 44, + "name": "Android XML", + "format": "android", + "extensions": [ + "xml" + ], + "settings": { + "exportPattern": null, + "escapeQuotes": 1, + "escapeSpecialCharacters": 1 + }, + "createdAt": "2023-09-19T15:10:43+00:00", + "updatedAt": "2023-09-19T15:10:46+00:00" + } + }`) + }) + + fileFormatSettings, resp, err := client.Projects.GetFileFormatSettings(context.Background(), 8, 1) + require.NoError(t, err) + require.NotNil(t, resp) + + expectedFileFormatSettings := &model.ProjectsFileFormatSettings{ + ID: 44, + Name: "Android XML", + Format: "android", + Extensions: []string{"xml"}, + Settings: map[string]any{ + "exportPattern": nil, + "escapeQuotes": float64(1), + "escapeSpecialCharacters": float64(1), + }, + CreatedAt: "2023-09-19T15:10:43+00:00", + UpdatedAt: "2023-09-19T15:10:46+00:00", + } + assert.Equal(t, expectedFileFormatSettings, fileFormatSettings) +} + +func TestProjectsService_AddFileFormatSettings(t *testing.T) { + client, mux, teardown := setupClient() + defer teardown() + + path := "/api/v2/projects/8/file-format-settings" + mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, path, r.RequestURI) + + testBody(t, r, `{"format":"android","settings":{"exportPattern":"pattern","escapeQuotes":1,"escapeSpecialCharacters":1}}`+"\n") + + fmt.Fprint(w, `{ + "data": { + "id": 44, + "name": "Android XML", + "format": "android", + "extensions": [ + "xml" + ], + "settings": { + "exportPattern": "pattern", + "escapeQuotes": 1, + "escapeSpecialCharacters": 1 + }, + "createdAt": "2023-09-19T15:10:43+00:00", + "updatedAt": "2023-09-19T15:10:46+00:00" + } + }`) + }) + + req := &model.ProjectsAddFileFormatSettingsRequest{ + Format: "android", + Settings: &model.PropertyFileFormatSettings{ + ExportPattern: ToPtr("pattern"), + EscapeQuotes: ToPtr(1), + EscapeSpecialCharacters: ToPtr(1), + }, + } + fileFormatSettings, resp, err := client.Projects.AddFileFormatSettings(context.Background(), 8, req) + require.NoError(t, err) + require.NotNil(t, resp) + + expectedFileFormatSettings := &model.ProjectsFileFormatSettings{ + ID: 44, + Name: "Android XML", + Format: "android", + Extensions: []string{"xml"}, + Settings: map[string]any{ + "exportPattern": "pattern", + "escapeQuotes": float64(1), + "escapeSpecialCharacters": float64(1), + }, + CreatedAt: "2023-09-19T15:10:43+00:00", + UpdatedAt: "2023-09-19T15:10:46+00:00", + } + assert.Equal(t, expectedFileFormatSettings, fileFormatSettings) +} + +func TestProjectsService_AddFileFormatSettings_WithBodyParams(t *testing.T) { + client, mux, teardown := setupClient() + defer teardown() + + cases := []struct { + name string + req *model.ProjectsAddFileFormatSettingsRequest + expectedReqBody string + }{ + { + name: "Property file format settings", + req: &model.ProjectsAddFileFormatSettingsRequest{ + Format: "android", + Settings: &model.PropertyFileFormatSettings{ + ExportPattern: nil, + EscapeQuotes: ToPtr(0), + EscapeSpecialCharacters: ToPtr(1), + }, + }, + expectedReqBody: `{"format":"android","settings":{"escapeQuotes":0,"escapeSpecialCharacters":1}}` + "\n", + }, + { + name: "XML file format settings", + req: &model.ProjectsAddFileFormatSettingsRequest{ + Format: "android", + Settings: &model.XMLFileFormatSettings{ + TranslateContent: ToPtr(false), + TranslateAttributes: ToPtr(true), + TranslatableElements: []string{"/path/to/node", "//node"}, + ExportPattern: nil, + SRXStorageID: ToPtr(1), + ContentSegmentation: ToPtr(false), + }, + }, + expectedReqBody: `{"format":"android","settings":{"translateContent":false,"translateAttributes":true,"translatableElements":["/path/to/node","//node"],"contentSegmentation":false,"srxStorageId":1}}` + "\n", + }, + { + name: "Common file format settings", + req: &model.ProjectsAddFileFormatSettingsRequest{ + Format: "android", + Settings: &model.WebXMLFileFormatSettings{ + CommonFileFormatSettings: model.CommonFileFormatSettings{ + ContentSegmentation: ToPtr(true), + SRXStorageID: ToPtr(1), + ExportPattern: ToPtr("pattern"), + }, + }, + }, + expectedReqBody: `{"format":"android","settings":{"contentSegmentation":true,"srxStorageId":1,"exportPattern":"pattern"}}` + "\n", + }, + { + name: "String Catalog file format settings", + req: &model.ProjectsAddFileFormatSettingsRequest{ + Format: "android", + Settings: &model.StringCatalogFileFormatSettings{ + ImportKeyAsSource: ToPtr(false), + ExportPattern: ToPtr(""), + }, + }, + expectedReqBody: `{"format":"android","settings":{"importKeyAsSource":false,"exportPattern":""}}` + "\n", + }, + { + name: "MediaWiki file format settings", + req: &model.ProjectsAddFileFormatSettingsRequest{ + Format: "android", + Settings: &model.MediaWikiFileFormatSettings{ + SRXStorageID: ToPtr(1), + ExportPattern: ToPtr("pattern"), + }, + }, + expectedReqBody: `{"format":"android","settings":{"srxStorageId":1,"exportPattern":"pattern"}}` + "\n", + }, + { + name: "TXT file format settings", + req: &model.ProjectsAddFileFormatSettingsRequest{ + Format: "android", + Settings: &model.TXTFileFormatSettings{ + SRXStorageID: nil, + ExportPattern: nil, + }, + }, + expectedReqBody: `{"format":"android","settings":{}}` + "\n", + }, + { + name: "JavaScript file format settings", + req: &model.ProjectsAddFileFormatSettingsRequest{ + Format: "android", + Settings: &model.JavaScriptFileFormatSettings{ + ExportPattern: "pattern", + ExportQuotes: "double", + }, + }, + expectedReqBody: `{"format":"android","settings":{"exportPattern":"pattern","exportQuotes":"double"}}` + "\n", + }, + { + name: "Other file format settings", + req: &model.ProjectsAddFileFormatSettingsRequest{ + Format: "android", + Settings: &model.OtherFileFormatSettings{}, + }, + expectedReqBody: `{"format":"android","settings":{}}` + "\n", + }, + } + + for idx, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + path := fmt.Sprintf("/api/v2/projects/%d/file-format-settings", idx) + mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { + testBody(t, r, tt.expectedReqBody) + fmt.Fprint(w, `{}`) + }) + + _, _, err := client.Projects.AddFileFormatSettings(context.Background(), idx, tt.req) + require.NoError(t, err) + }) + } +} + +func TestProjectsService_AddFileFormatSettings_WithValidationError(t *testing.T) { + client, _, teardown := setupClient() + defer teardown() + + cases := []struct { + name string + req *model.ProjectsAddFileFormatSettingsRequest + expectedErr string + }{ + { + name: "Empty request", + req: nil, + expectedErr: "request cannot be nil", + }, + { + name: "Empty format", + req: &model.ProjectsAddFileFormatSettingsRequest{Format: ""}, + expectedErr: "format is required", + }, + { + name: "Empty settings", + req: &model.ProjectsAddFileFormatSettingsRequest{Format: "android"}, + expectedErr: "settings is required", + }, + } + + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + _, _, err := client.Projects.AddFileFormatSettings(context.Background(), 8, tt.req) + require.Error(t, err) + assert.Equal(t, tt.expectedErr, err.Error()) + }) + } +} + +func TestProjectsService_EditFileFormatSettings(t *testing.T) { + client, mux, teardown := setupClient() + defer teardown() + + req := []*model.UpdateRequest{ + { + Op: "replace", + Path: "/format", + Value: "android", + }, + } + + path := "/api/v2/projects/8/file-format-settings/1" + mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPatch, r.Method) + assert.Equal(t, path, r.RequestURI) + + expectedReqBody := `[{"op":"replace","path":"/format","value":"android"}]` + "\n" + testBody(t, r, expectedReqBody) + + fmt.Fprint(w, `{ + "data": { + "id": 44, + "name": "Android XML", + "format": "android", + "extensions": [ + "xml" + ], + "settings": { + "exportPattern": null, + "escapeQuotes": 1, + "escapeSpecialCharacters": 1 + }, + "createdAt": "2023-09-19T15:10:43+00:00", + "updatedAt": "2023-09-19T15:10:46+00:00" + } + }`) + }) + + fileFormatSettings, resp, err := client.Projects.EditFileFormatSettings(context.Background(), 8, 1, req) + require.NoError(t, err) + require.NotNil(t, resp) + + expectedFileFormatSettings := &model.ProjectsFileFormatSettings{ + ID: 44, + Name: "Android XML", + Format: "android", + Extensions: []string{"xml"}, + Settings: map[string]any{ + "exportPattern": nil, + "escapeQuotes": float64(1), + "escapeSpecialCharacters": float64(1), + }, + CreatedAt: "2023-09-19T15:10:43+00:00", + UpdatedAt: "2023-09-19T15:10:46+00:00", + } + assert.Equal(t, expectedFileFormatSettings, fileFormatSettings) +} + +func TestProjectsService_DeleteFileFormatSettings(t *testing.T) { + client, mux, teardown := setupClient() + defer teardown() + + path := "/api/v2/projects/8/file-format-settings/10" + mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodDelete, r.Method) + assert.Equal(t, path, r.RequestURI) + + w.WriteHeader(http.StatusNoContent) + }) + + resp, err := client.Projects.DeleteFileFormatSettings(context.Background(), 8, 10) + require.NoError(t, err) + require.NotNil(t, resp) + assert.Equal(t, http.StatusNoContent, resp.StatusCode) +} + +func TestProjectsService_ListStringsExporterSettings(t *testing.T) { + client, mux, teardown := setupClient() + defer teardown() + + path := "/api/v2/projects/8/strings-exporter-settings" + mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, path, r.RequestURI) + + resp := `{ + "data": [ + { + "data": { + "id": 2, + "format": "android", + "settings": { + "convertPlaceholders": false + }, + "createdAt": "2023-09-19T15:10:43+00:00", + "updatedAt": "2023-09-19T15:10:46+00:00" + } + } + ], + "pagination": { + "offset": 10, + "limit": 25 + } + }` + + fmt.Fprint(w, resp) + }) + + settings, resp, err := client.Projects.ListStringsExporterSettings(context.Background(), 8) + require.NoError(t, err) + + expectedSettings := []*model.ProjectsStringsExporterSettings{ + { + ID: 2, + Format: "android", + Settings: model.StringsExporterSettings{ + ConvertPlaceholders: ToPtr(false), + }, + CreatedAt: "2023-09-19T15:10:43+00:00", + UpdatedAt: "2023-09-19T15:10:46+00:00", + }, + } + assert.Equal(t, expectedSettings, settings) + + expectedPagination := model.Pagination{Offset: 10, Limit: 25} + require.NotNil(t, resp) + assert.Equal(t, expectedPagination, resp.Pagination) +} + +func TestProjectsService_GetStringsExporterSettings(t *testing.T) { + client, mux, teardown := setupClient() + defer teardown() + + path := "/api/v2/projects/8/strings-exporter-settings/1" + mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, path, r.RequestURI) + + fmt.Fprint(w, `{ + "data": { + "id": 2, + "format": "android", + "settings": { + "convertPlaceholders": false + }, + "createdAt": "2023-09-19T15:10:43+00:00", + "updatedAt": "2023-09-19T15:10:46+00:00" + } + }`) + }) + + settings, resp, err := client.Projects.GetStringsExporterSettings(context.Background(), 8, 1) + require.NoError(t, err) + require.NotNil(t, resp) + + expectedSettings := &model.ProjectsStringsExporterSettings{ + ID: 2, + Format: "android", + Settings: model.StringsExporterSettings{ + ConvertPlaceholders: ToPtr(false), + }, + CreatedAt: "2023-09-19T15:10:43+00:00", + UpdatedAt: "2023-09-19T15:10:46+00:00", + } + assert.Equal(t, expectedSettings, settings) + assert.Nil(t, expectedSettings.Settings.LanguagePairMapping) +} + +func TestProjectsService_AddStringsExporterSettings(t *testing.T) { + client, mux, teardown := setupClient() + defer teardown() + + path := "/api/v2/projects/8/strings-exporter-settings" + mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, path, r.RequestURI) + + expectedReqBody := `{"format":"macosx","settings":{"convertPlaceholders":false}}` + "\n" + testBody(t, r, expectedReqBody) + + fmt.Fprint(w, `{ + "data": { + "id": 2, + "format": "macosx", + "settings": { + "convertPlaceholders": false + }, + "createdAt": "2023-09-19T15:10:43+00:00", + "updatedAt": "2023-09-19T15:10:46+00:00" + } + }`) + }) + + req := &model.ProjectsStringsExporterSettingsRequest{ + Format: "macosx", + Settings: model.StringsExporterSettings{ + ConvertPlaceholders: ToPtr(false), + }, + } + settings, resp, err := client.Projects.AddStringsExporterSettings(context.Background(), 8, req) + require.NoError(t, err) + require.NotNil(t, resp) + + expectedSettings := &model.ProjectsStringsExporterSettings{ + ID: 2, + Format: "macosx", + Settings: model.StringsExporterSettings{ + ConvertPlaceholders: ToPtr(false), + }, + CreatedAt: "2023-09-19T15:10:43+00:00", + UpdatedAt: "2023-09-19T15:10:46+00:00", + } + assert.Equal(t, expectedSettings, settings) + assert.Nil(t, expectedSettings.Settings.LanguagePairMapping) +} + +func TestProjectsService_AddStringsExporterSettings_WithRequiredFields(t *testing.T) { + client, mux, teardown := setupClient() + defer teardown() + + cases := []struct { + name string + req *model.ProjectsStringsExporterSettingsRequest + expectedReqBody string + }{ + { + name: "With convert placeholders settings (macosx)", + req: &model.ProjectsStringsExporterSettingsRequest{ + Format: "macosx", + Settings: model.StringsExporterSettings{ + ConvertPlaceholders: ToPtr(false), + }, + }, + expectedReqBody: `{"format":"macosx","settings":{"convertPlaceholders":false}}` + "\n", + }, + { + name: "With convert placeholders settings (android)", + req: &model.ProjectsStringsExporterSettingsRequest{ + Format: "android", + Settings: model.StringsExporterSettings{ + ConvertPlaceholders: ToPtr(false), + }, + }, + expectedReqBody: `{"format":"android","settings":{"convertPlaceholders":false}}` + "\n", + }, + { + name: "With language pair mapping settings", + req: &model.ProjectsStringsExporterSettingsRequest{ + Format: "xliff", + Settings: model.StringsExporterSettings{ + LanguagePairMapping: map[string]string{ + "uk": "es", + "de": "en", + }, + }, + }, + expectedReqBody: `{"format":"xliff","settings":{"languagePairMapping":{"de":"en","uk":"es"}}}` + "\n", + }, + } + + for idx, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + path := fmt.Sprintf("/api/v2/projects/%d/strings-exporter-settings", idx) + mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, path, r.RequestURI) + + testBody(t, r, tt.expectedReqBody) + + fmt.Fprint(w, `{"data": {"id": 2}}`) + }) + + settings, _, err := client.Projects.AddStringsExporterSettings(context.Background(), idx, tt.req) + require.NoError(t, err, "Test case %d", idx) + assert.NotNil(t, settings, "Test case %d", idx) + }) + } +} + +func TestProjectsService_AddStringsExporterSettings_WithValidationError(t *testing.T) { + cases := []struct { + req *model.ProjectsStringsExporterSettingsRequest + expectErr string + }{ + { + nil, + "request cannot be nil", + }, + { + &model.ProjectsStringsExporterSettingsRequest{}, + "format is required", + }, + { + &model.ProjectsStringsExporterSettingsRequest{ + Settings: model.StringsExporterSettings{}, + }, + "format is required", + }, + { + &model.ProjectsStringsExporterSettingsRequest{ + Format: "macosx", + }, + "settings is required", + }, + { + &model.ProjectsStringsExporterSettingsRequest{ + Format: "xliff", + Settings: model.StringsExporterSettings{ + LanguagePairMapping: map[string]string{}, + }, + }, + "settings is required", + }, + } + + for _, tt := range cases { + t.Run(tt.expectErr, func(t *testing.T) { + err := tt.req.Validate() + require.Error(t, err) + assert.Equal(t, tt.expectErr, err.Error()) + }) + } +} + +func TestProjectsService_EditStringsExporterSettings(t *testing.T) { + client, mux, teardown := setupClient() + defer teardown() + + path := "/api/v2/projects/8/strings-exporter-settings/1" + mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPatch, r.Method) + assert.Equal(t, path, r.RequestURI) + + expectedReqBody := `{ + "format": "xliff", + "settings": { + "languagePairMapping": { + "uk": "es", + "de": "en" + } + } + }` + testJSONBody(t, r, expectedReqBody) + + fmt.Fprint(w, `{ + "data": { + "id": 2, + "format": "xliff", + "settings": { + "languagePairMapping": { + "uk": "es", + "de": "en" + } + }, + "createdAt": "2023-09-19T15:10:43+00:00", + "updatedAt": "2023-09-19T15:10:46+00:00" + } + }`) + }) + + req := &model.ProjectsStringsExporterSettingsRequest{ + Format: "xliff", + Settings: model.StringsExporterSettings{ + LanguagePairMapping: map[string]string{ + "uk": "es", + "de": "en", + }, + }, + } + settings, resp, err := client.Projects.EditStringsExporterSettings(context.Background(), 8, 1, req) + require.NoError(t, err) + require.NotNil(t, resp) + + expectedSettings := &model.ProjectsStringsExporterSettings{ + ID: 2, + Format: "xliff", + Settings: model.StringsExporterSettings{ + LanguagePairMapping: map[string]string{ + "uk": "es", + "de": "en", + }, + }, + CreatedAt: "2023-09-19T15:10:43+00:00", + UpdatedAt: "2023-09-19T15:10:46+00:00", + } + assert.Equal(t, expectedSettings, settings) + assert.Nil(t, expectedSettings.Settings.ConvertPlaceholders) +} + +func TestProjectsService_DeleteStringsExporterSettings(t *testing.T) { + client, mux, teardown := setupClient() + defer teardown() + + mux.HandleFunc("/api/v2/projects/8/strings-exporter-settings/1", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodDelete, r.Method) + assert.Equal(t, "/api/v2/projects/8/strings-exporter-settings/1", r.RequestURI) + + w.WriteHeader(http.StatusNoContent) + }) + + resp, err := client.Projects.DeleteStringsExporterSettings(context.Background(), 8, 1) + require.NoError(t, err) + assert.NotNil(t, resp) + assert.Equal(t, http.StatusNoContent, resp.StatusCode) +} diff --git a/go.mod b/go.mod index 30bd3f8..1f7afba 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,11 @@ module github.com/crowdin/crowdin-api-client-go go 1.22.0 + +require github.com/stretchr/testify v1.9.0 + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..60ce688 --- /dev/null +++ b/go.sum @@ -0,0 +1,10 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=